fix: 편집과 미리보기를 한 화면에서 보고, 저장·게시를 아래에 고정한다
편집기는 탭이었다. 고친 것이 어떻게 보이는지 확인하려면 편집하던 자리를 화면에서 치워야 했고, 돌아오면 스크롤 위치도 잃었다. 두 패널을 나란히 두면 그 왕복이 통째로 없어진다. 작업 상태와 저장·게시는 오른쪽 세로 rail 이었다. 그 자리는 편집기와 미리보기가 함께 쓸 가로 폭을 가져갔고, 편집 칸이 길어질수록 rail 은 위에 붙은 채 본문만 멀어졌다. 화면 아래에 고정하면 폭을 돌려주면서 스크롤 위치와 무관하게 손이 닿는다. 탭을 없앴으므로 각 패널이 스스로 이름을 가져야 한다. `aria-labelledby` 로 제목을 가리켜 landmark 로 만든다 — 탭 목록이 하던 "여기는 편집, 저기는 미리보기" 안내를 대신한다. `aside` 와 「작업 상태」라는 이름은 그대로 둔다. 자리가 바뀐 것이지 이 묶음이 무엇인지가 바뀐 것은 아니다. 미리보기가 늘 떠 있게 되면서 Picker 테스트의 단언도 함께 고쳤다. 「Picker 가 좁아졌는가」를 화면 전체에 묻고 있었는데, 이제는 미리보기 쪽의 같은 Asset 까지 세게 된다. 그 김에 `.studio-asset-panel` 의 `aria-labelledby` 가 role 없는 div 에서 아무 이름도 만들지 못하던 것도 `role="group"` 으로 실효화했다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0189NzCryfeqDzS81EWidnBx
This commit is contained in:
co-authored by
Claude Opus 5
parent
b3119952d5
commit
60c8c82097
@@ -102,7 +102,7 @@ export function CaseFields({
|
|||||||
<label className="studio-field"><span>마지막 검증일</span><input type="date" value={draft.lastVerifiedOn ?? ""} onChange={(event) => update({ lastVerifiedOn: event.currentTarget.value || null })} /><FieldNotice issues={issues} path="/lastVerifiedOn" /></label>
|
<label className="studio-field"><span>마지막 검증일</span><input type="date" value={draft.lastVerifiedOn ?? ""} onChange={(event) => update({ lastVerifiedOn: event.currentTarget.value || null })} /><FieldNotice issues={issues} path="/lastVerifiedOn" /></label>
|
||||||
<label className="studio-field studio-field--wide"><span>본문 Markdown</span><textarea ref={bodyRef} className="studio-markdown-field" value={draft.bodyMarkdown} onChange={(event) => update({ bodyMarkdown: event.currentTarget.value })} /><FieldNotice issues={issues} path="/bodyMarkdown" /></label>
|
<label className="studio-field studio-field--wide"><span>본문 Markdown</span><textarea ref={bodyRef} className="studio-markdown-field" value={draft.bodyMarkdown} onChange={(event) => update({ bodyMarkdown: event.currentTarget.value })} /><FieldNotice issues={issues} path="/bodyMarkdown" /></label>
|
||||||
</div>
|
</div>
|
||||||
<div className="studio-asset-panel" aria-labelledby="studio-asset-panel-title">
|
<div className="studio-asset-panel" role="group" aria-labelledby="studio-asset-panel-title">
|
||||||
<p className="studio-eyebrow">EVIDENCE</p>
|
<p className="studio-eyebrow">EVIDENCE</p>
|
||||||
<h3 id="studio-asset-panel-title">본문에 Asset 삽입</h3>
|
<h3 id="studio-asset-panel-title">본문에 Asset 삽입</h3>
|
||||||
<p>목록에서 선택하면 본문 커서 위치에 evidence 구문을 삽입합니다. READY 상태의 Asset만 선택할 수 있습니다.</p>
|
<p>목록에서 선택하면 본문 커서 위치에 evidence 구문을 삽입합니다. READY 상태의 Asset만 선택할 수 있습니다.</p>
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import { useRef, useState, type KeyboardEvent } from "react";
|
|
||||||
|
|
||||||
import type { components } from "../../../contracts/studio/generated.ts";
|
import type { components } from "../../../contracts/studio/generated.ts";
|
||||||
import type { Asset } from "../../../contracts/studio/contract.ts";
|
import type { Asset } from "../../../contracts/studio/contract.ts";
|
||||||
import type { DocumentEditorController } from "./document-editor-controller.ts";
|
import type { DocumentEditorController } from "./document-editor-controller.ts";
|
||||||
import { CASE_FIELD_PATHS, CaseFields } from "./case-fields.tsx";
|
import { CASE_FIELD_PATHS, CaseFields } from "./case-fields.tsx";
|
||||||
import { COMMON_FIELD_PATHS, CommonDocumentFields } from "./common-document-fields.tsx";
|
import { COMMON_FIELD_PATHS, CommonDocumentFields } from "./common-document-fields.tsx";
|
||||||
import { DocumentStatusRail } from "./document-status-rail.tsx";
|
import { DocumentStatusBar } from "./document-status-bar.tsx";
|
||||||
import { InstantPreview } from "./instant-preview.tsx";
|
import { InstantPreview } from "./instant-preview.tsx";
|
||||||
import { DECISION_FIELD_PATHS, ProjectDecisionFields } from "./project-decision-fields.tsx";
|
import { DECISION_FIELD_PATHS, ProjectDecisionFields } from "./project-decision-fields.tsx";
|
||||||
import { issuesOutside } from "./field-issues.tsx";
|
import { issuesOutside } from "./field-issues.tsx";
|
||||||
@@ -28,22 +26,6 @@ export function DocumentEditor({
|
|||||||
onAssetsObserved: (assets: readonly Asset[]) => void;
|
onAssetsObserved: (assets: readonly Asset[]) => void;
|
||||||
onAssetUploaded: (asset: Asset) => void;
|
onAssetUploaded: (asset: Asset) => void;
|
||||||
}) {
|
}) {
|
||||||
const [tab, setTab] = useState<"EDIT" | "PREVIEW">("EDIT");
|
|
||||||
const editTab = useRef<HTMLButtonElement>(null);
|
|
||||||
const previewTab = useRef<HTMLButtonElement>(null);
|
|
||||||
const selectTab = (next: "EDIT" | "PREVIEW") => {
|
|
||||||
setTab(next);
|
|
||||||
(next === "EDIT" ? editTab : previewTab).current?.focus();
|
|
||||||
};
|
|
||||||
const keyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
|
|
||||||
let next: "EDIT" | "PREVIEW" | null = null;
|
|
||||||
if (event.key === "ArrowLeft" || event.key === "ArrowRight") next = tab === "EDIT" ? "PREVIEW" : "EDIT";
|
|
||||||
if (event.key === "Home") next = "EDIT";
|
|
||||||
if (event.key === "End") next = "PREVIEW";
|
|
||||||
if (!next) return;
|
|
||||||
event.preventDefault();
|
|
||||||
selectTab(next);
|
|
||||||
};
|
|
||||||
const topics = catalog.filter(({ type }) => type === "TOPIC");
|
const topics = catalog.filter(({ type }) => type === "TOPIC");
|
||||||
const projects = catalog.filter(({ type }) => type === "PROJECT");
|
const projects = catalog.filter(({ type }) => type === "PROJECT");
|
||||||
const relations = catalog.filter(({ type }) => type === "RELATION");
|
const relations = catalog.filter(({ type }) => type === "RELATION");
|
||||||
@@ -66,35 +48,47 @@ export function DocumentEditor({
|
|||||||
: DECISION_FIELD_PATHS;
|
: DECISION_FIELD_PATHS;
|
||||||
const unplaced = issuesOutside(issues, [...COMMON_FIELD_PATHS, ...kindPaths]);
|
const unplaced = issuesOutside(issues, [...COMMON_FIELD_PATHS, ...kindPaths]);
|
||||||
|
|
||||||
|
/*
|
||||||
|
편집과 미리보기를 한 화면에 나란히 둔다. 예전에는 탭이었고, 고친 것이 어떻게 보이는지
|
||||||
|
확인하려면 탭을 옮겨야 했다 — 옮기는 동안 편집 중이던 칸은 화면에서 사라졌고, 돌아오면
|
||||||
|
스크롤 위치도 잃었다. 두 패널을 동시에 두면 그 왕복이 통째로 없어진다.
|
||||||
|
|
||||||
|
탭을 없앴으므로 각 패널은 스스로 이름을 가져야 한다. `aria-labelledby` 로 제목을 가리켜
|
||||||
|
landmark 로 만든다 — 탭 목록이 하던 "여기는 편집, 저기는 미리보기" 안내를 대신한다.
|
||||||
|
*/
|
||||||
return (
|
return (
|
||||||
<div className="studio-editor-page">
|
<div className="studio-editor-page">
|
||||||
<div className="studio-editor-tabs" role="tablist" aria-label="문서 편집 화면">
|
<div className="studio-editor-split">
|
||||||
<button id="studio-edit-tab" ref={editTab} type="button" role="tab" aria-selected={tab === "EDIT"} aria-controls="studio-edit-panel" tabIndex={tab === "EDIT" ? 0 : -1} onClick={() => selectTab("EDIT")} onKeyDown={keyDown}>편집</button>
|
<section className="studio-editor-workspace" aria-labelledby="studio-edit-title">
|
||||||
<button id="studio-preview-tab" ref={previewTab} type="button" role="tab" aria-selected={tab === "PREVIEW"} aria-controls="studio-preview-panel" tabIndex={tab === "PREVIEW" ? 0 : -1} onClick={() => selectTab("PREVIEW")} onKeyDown={keyDown}>즉시 미리보기</button>
|
<header className="studio-editor-heading">
|
||||||
</div>
|
<p className="studio-eyebrow">{controller.draft.kind} · VERSION {controller.saved.version}</p>
|
||||||
<div className="studio-editor-layout">
|
<h1 id="studio-edit-title">문서 편집</h1>
|
||||||
<div className="studio-editor-workspace">
|
<p>{controller.draft.title || "제목 없는 작업본"}</p>
|
||||||
<div id="studio-edit-panel" role="tabpanel" aria-labelledby="studio-edit-tab" hidden={tab !== "EDIT"}>
|
</header>
|
||||||
<header className="studio-editor-heading">
|
<CommonDocumentFields draft={controller.draft} topics={topics} projects={projects} relations={relations} issues={issues} onUpdate={controller.update} />
|
||||||
<p className="studio-eyebrow">{controller.draft.kind} · VERSION {controller.saved.version}</p>
|
{controller.draft.kind === "CASE"
|
||||||
<h1>문서 편집</h1>
|
? <CaseFields draft={controller.draft} issues={issues} onChange={controller.replace} onAssetsObserved={onAssetsObserved} onAssetUploaded={onAssetUploaded} />
|
||||||
<p>{controller.draft.title || "제목 없는 작업본"}</p>
|
: controller.draft.kind === "REFERENCE"
|
||||||
</header>
|
? <ReferenceFields draft={controller.draft} issues={issues} onChange={controller.replace} />
|
||||||
<CommonDocumentFields draft={controller.draft} topics={topics} projects={projects} relations={relations} issues={issues} onUpdate={controller.update} />
|
: controller.draft.kind === "QUESTION"
|
||||||
{controller.draft.kind === "CASE"
|
? <QuestionFields draft={controller.draft} evidence={evidence} issues={issues} onChange={controller.replace} />
|
||||||
? <CaseFields draft={controller.draft} issues={issues} onChange={controller.replace} onAssetsObserved={onAssetsObserved} onAssetUploaded={onAssetUploaded} />
|
: <ProjectDecisionFields draft={controller.draft} issues={issues} onChange={controller.replace} />}
|
||||||
: controller.draft.kind === "REFERENCE"
|
</section>
|
||||||
? <ReferenceFields draft={controller.draft} issues={issues} onChange={controller.replace} />
|
{/*
|
||||||
: controller.draft.kind === "QUESTION"
|
미리보기는 편집 칸보다 훨씬 짧을 수도, 길 수도 있다. 그대로 두면 본문을 스크롤하는
|
||||||
? <QuestionFields draft={controller.draft} evidence={evidence} issues={issues} onChange={controller.replace} />
|
동안 화면 밖으로 나가 버리므로 붙잡아 두고 자기 높이 안에서 따로 스크롤한다.
|
||||||
: <ProjectDecisionFields draft={controller.draft} issues={issues} onChange={controller.replace} />}
|
*/}
|
||||||
</div>
|
<section className="studio-editor-preview" aria-labelledby="studio-preview-title">
|
||||||
<div id="studio-preview-panel" role="tabpanel" aria-labelledby="studio-preview-tab" hidden={tab !== "PREVIEW"}>
|
<header className="studio-editor-preview__heading">
|
||||||
|
<p className="studio-eyebrow">LIVE</p>
|
||||||
|
<h2 id="studio-preview-title">즉시 미리보기</h2>
|
||||||
|
</header>
|
||||||
|
<div className="studio-editor-preview__body">
|
||||||
<InstantPreview draft={controller.draft} catalog={catalog} assets={assets} />
|
<InstantPreview draft={controller.draft} catalog={catalog} assets={assets} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
<DocumentStatusRail controller={controller} unplacedIssues={unplaced} />
|
|
||||||
</div>
|
</div>
|
||||||
|
<DocumentStatusBar controller={controller} unplacedIssues={unplaced} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import type { DocumentEditorController } from "./document-editor-controller.ts";
|
||||||
|
import type { FieldIssue } from "./field-issues.tsx";
|
||||||
|
|
||||||
|
const labels = {
|
||||||
|
CLEAN: "저장됨",
|
||||||
|
DIRTY: "저장되지 않음",
|
||||||
|
SAVING: "저장 중…",
|
||||||
|
CONFLICT: "저장 충돌",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const kindLabels = {
|
||||||
|
CASE: "CASE",
|
||||||
|
REFERENCE: "REFERENCE",
|
||||||
|
QUESTION: "QUESTION",
|
||||||
|
PROJECT_DECISION: "Decision",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작업 상태와 저장·게시를 화면 아래에 고정한다.
|
||||||
|
*
|
||||||
|
* 예전에는 오른쪽 세로 rail 이었다. 그 자리는 편집기와 미리보기가 함께 쓸 가로 폭을 가져갔고,
|
||||||
|
* 편집 칸이 길어질수록 rail 은 위에 붙은 채 본문만 멀어졌다. 아래에 고정하면 폭을 돌려주면서
|
||||||
|
* 스크롤 위치와 무관하게 저장·게시에 손이 닿는다 — 릴리즈 편집에서 버튼이 아래로 밀려나던
|
||||||
|
* 것과 같은 문제를 여기서는 만들지 않는다.
|
||||||
|
*
|
||||||
|
* `aside` 와 「작업 상태」라는 이름은 그대로 둔다. 자리가 바뀐 것이지 이 묶음이 무엇인지가
|
||||||
|
* 바뀐 것은 아니고, 보조 기술이 이 영역을 부르던 이름도 그대로여야 한다.
|
||||||
|
*/
|
||||||
|
export function DocumentStatusBar({
|
||||||
|
controller,
|
||||||
|
unplacedIssues,
|
||||||
|
}: {
|
||||||
|
controller: DocumentEditorController;
|
||||||
|
/**
|
||||||
|
* 어느 칸에도 붙지 못한 지적. 화면에 없는 칸을 가리키는 것들이며, 여기 남기지 않으면 이유를
|
||||||
|
* 말해 주지 않는 실패만 남는다.
|
||||||
|
*/
|
||||||
|
unplacedIssues: readonly FieldIssue[];
|
||||||
|
}) {
|
||||||
|
const busy = controller.status === "SAVING" || controller.publishing;
|
||||||
|
return (
|
||||||
|
<aside className="studio-document-status-bar" aria-labelledby="studio-document-status-title">
|
||||||
|
<h2 id="studio-document-status-title" className="studio-visually-hidden">작업 상태</h2>
|
||||||
|
<p className={`studio-editor-status studio-editor-status--${controller.status.toLowerCase()}`} role="status" aria-label="편집 상태">{labels[controller.status]}</p>
|
||||||
|
<dl><div><dt>저장 버전</dt><dd>{controller.saved.version}</dd></div><div><dt>종류</dt><dd>{kindLabels[controller.draft.kind]}</dd></div></dl>
|
||||||
|
{/*
|
||||||
|
경고는 하나만 띄운다. 충돌은 그 자체로 무엇을 해야 하는지 말해 주므로 서버가 준 문구보다
|
||||||
|
앞서고, 그 밖의 실패는 서버가 준 이유를 그대로 보여 준다. 둘을 함께 띄우면 같은 실패를
|
||||||
|
두 번 말하게 된다.
|
||||||
|
*/}
|
||||||
|
{controller.status === "CONFLICT" ? (
|
||||||
|
<p className="studio-editor-conflict" role="alert">서버 최신본과 충돌했습니다. 이 세션에서는 다시 열어 비교해 주세요.</p>
|
||||||
|
) : controller.saveError ? (
|
||||||
|
<p className="studio-editor-conflict" role="alert">{controller.saveError}</p>
|
||||||
|
) : controller.publishError ? (
|
||||||
|
<p className="studio-editor-conflict" role="alert">{controller.publishError}</p>
|
||||||
|
) : (
|
||||||
|
<p className="studio-document-status-bar__note">불완전한 초안도 저장할 수 있습니다. Ctrl+S 로도 저장합니다. 게시를 누르면 채워야 할 칸을 그 자리에 표시합니다.</p>
|
||||||
|
)}
|
||||||
|
<div className="studio-document-status-bar__actions">
|
||||||
|
<button type="button" title="Ctrl+S" onClick={() => { void controller.save(); }} disabled={busy || controller.status === "CLEAN" || controller.status === "CONFLICT"}>{controller.status === "SAVING" ? "저장 중…" : "저장"}</button>
|
||||||
|
{/*
|
||||||
|
버튼은 저장과 게시 둘뿐이다. 예전에는 게시까지 검증 → 미리보기 → 게시 세 화면을 차례로
|
||||||
|
밟아야 했다 — 백엔드가 게시 요청에 신선한 검증 id 와 미리보기 id, 그리고 현재 경고를 모두
|
||||||
|
확인했다는 목록을 요구하기 때문이다(`PublishStudioDocumentUseCase`).
|
||||||
|
|
||||||
|
그 셋은 작성자에게 물어볼 것이 없다. 검증은 서버가 판정하고, 미리보기는 그 판정으로부터
|
||||||
|
만들어지며, 경고는 게시를 막지 않는다. 그래서 세 요청을 이 버튼 뒤로 옮겼다. 계약도
|
||||||
|
백엔드도 그대로다 — 사라진 것은 작성자가 밟던 화면이지 서버가 지키던 불변식이 아니다.
|
||||||
|
*/}
|
||||||
|
<button
|
||||||
|
className="studio-primary-button"
|
||||||
|
type="button"
|
||||||
|
onClick={() => { void controller.publish(); }}
|
||||||
|
disabled={busy || controller.status === "CONFLICT"}
|
||||||
|
>
|
||||||
|
{controller.publishing ? "게시 중…" : "게시"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{unplacedIssues.length ? (
|
||||||
|
<ul className="studio-editor-unplaced-issues" aria-label="칸에 붙지 못한 지적">
|
||||||
|
{unplacedIssues.map((issue) => (
|
||||||
|
<li key={`${issue.code}:${issue.path}`} data-severity={issue.severity}>
|
||||||
|
{issue.message} <code>{issue.path}</code>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
import type { DocumentEditorController } from "./document-editor-controller.ts";
|
|
||||||
import type { FieldIssue } from "./field-issues.tsx";
|
|
||||||
|
|
||||||
const labels = {
|
|
||||||
CLEAN: "저장됨",
|
|
||||||
DIRTY: "저장되지 않음",
|
|
||||||
SAVING: "저장 중…",
|
|
||||||
CONFLICT: "저장 충돌",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const kindLabels = {
|
|
||||||
CASE: "CASE",
|
|
||||||
REFERENCE: "REFERENCE",
|
|
||||||
QUESTION: "QUESTION",
|
|
||||||
PROJECT_DECISION: "Decision",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export function DocumentStatusRail({
|
|
||||||
controller,
|
|
||||||
unplacedIssues,
|
|
||||||
}: {
|
|
||||||
controller: DocumentEditorController;
|
|
||||||
/**
|
|
||||||
* 어느 칸에도 붙지 못한 지적. 화면에 없는 칸을 가리키는 것들이며, 여기 남기지 않으면 이유를
|
|
||||||
* 말해 주지 않는 실패만 남는다.
|
|
||||||
*/
|
|
||||||
unplacedIssues: readonly FieldIssue[];
|
|
||||||
}) {
|
|
||||||
const busy = controller.status === "SAVING" || controller.publishing;
|
|
||||||
return (
|
|
||||||
<aside className="studio-document-status-rail" aria-labelledby="studio-document-status-title">
|
|
||||||
<p className="studio-eyebrow">WORKING COPY</p>
|
|
||||||
<h2 id="studio-document-status-title">작업 상태</h2>
|
|
||||||
<p className={`studio-editor-status studio-editor-status--${controller.status.toLowerCase()}`} role="status" aria-label="편집 상태">{labels[controller.status]}</p>
|
|
||||||
<dl><div><dt>저장 버전</dt><dd>{controller.saved.version}</dd></div><div><dt>종류</dt><dd>{kindLabels[controller.draft.kind]}</dd></div></dl>
|
|
||||||
<button type="button" title="Ctrl+S" onClick={() => { void controller.save(); }} disabled={busy || controller.status === "CLEAN" || controller.status === "CONFLICT"}>{controller.status === "SAVING" ? "저장 중…" : "저장"}</button>
|
|
||||||
{/*
|
|
||||||
버튼은 저장과 게시 둘뿐이다. 예전에는 게시까지 검증 → 미리보기 → 게시 세 화면을 차례로
|
|
||||||
밟아야 했다 — 백엔드가 게시 요청에 신선한 검증 id 와 미리보기 id, 그리고 현재 경고를 모두
|
|
||||||
확인했다는 목록을 요구하기 때문이다(`PublishStudioDocumentUseCase`).
|
|
||||||
|
|
||||||
그 셋은 작성자에게 물어볼 것이 없다. 검증은 서버가 판정하고, 미리보기는 그 판정으로부터
|
|
||||||
만들어지며, 경고는 게시를 막지 않는다. 그래서 세 요청을 이 버튼 뒤로 옮겼다. 계약도
|
|
||||||
백엔드도 그대로다 — 사라진 것은 작성자가 밟던 화면이지 서버가 지키던 불변식이 아니다.
|
|
||||||
*/}
|
|
||||||
<button
|
|
||||||
className="studio-primary-button"
|
|
||||||
type="button"
|
|
||||||
onClick={() => { void controller.publish(); }}
|
|
||||||
disabled={busy || controller.status === "CONFLICT"}
|
|
||||||
>
|
|
||||||
{controller.publishing ? "게시 중…" : "게시"}
|
|
||||||
</button>
|
|
||||||
{/*
|
|
||||||
경고는 하나만 띄운다. 충돌은 그 자체로 무엇을 해야 하는지 말해 주므로 서버가 준 문구보다
|
|
||||||
앞서고, 그 밖의 실패는 서버가 준 이유를 그대로 보여 준다. 둘을 함께 띄우면 같은 실패를
|
|
||||||
두 번 말하게 된다.
|
|
||||||
*/}
|
|
||||||
{controller.status === "CONFLICT" ? (
|
|
||||||
<p className="studio-editor-conflict" role="alert">서버 최신본과 충돌했습니다. 이 세션에서는 다시 열어 비교해 주세요.</p>
|
|
||||||
) : controller.saveError ? (
|
|
||||||
<p className="studio-editor-conflict" role="alert">{controller.saveError}</p>
|
|
||||||
) : controller.publishError ? (
|
|
||||||
<p className="studio-editor-conflict" role="alert">{controller.publishError}</p>
|
|
||||||
) : (
|
|
||||||
<p>불완전한 초안도 저장할 수 있습니다. Ctrl+S 로도 저장합니다. 게시를 누르면 채워야 할 칸을 그 자리에 표시합니다.</p>
|
|
||||||
)}
|
|
||||||
{unplacedIssues.length ? (
|
|
||||||
<ul className="studio-editor-unplaced-issues" aria-label="칸에 붙지 못한 지적">
|
|
||||||
{unplacedIssues.map((issue) => (
|
|
||||||
<li key={`${issue.code}:${issue.path}`} data-severity={issue.severity}>
|
|
||||||
{issue.message} <code>{issue.path}</code>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
) : null}
|
|
||||||
</aside>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,15 +1,26 @@
|
|||||||
.studio-app .studio-editor-page,
|
.studio-app .studio-editor-page,
|
||||||
.studio-app .studio-editor-layout,
|
.studio-app .studio-editor-split,
|
||||||
.studio-app .studio-editor-workspace,
|
.studio-app .studio-editor-workspace,
|
||||||
.studio-app .studio-editor-workspace > [role="tabpanel"] { min-width: 0; }
|
.studio-app .studio-editor-preview { min-width: 0; }
|
||||||
|
|
||||||
.studio-app .studio-editor-loading { min-height: 240px; padding-block: 56px; color: var(--muted); }
|
.studio-app .studio-editor-loading { min-height: 240px; padding-block: 56px; color: var(--muted); }
|
||||||
.studio-app .studio-editor-tabs { display: flex; gap: 28px; border-bottom: 1px solid var(--line-strong); }
|
/*
|
||||||
.studio-app .studio-editor-tabs button { position: relative; min-width: 112px; min-height: 44px; padding: 0; border: 0; background: transparent; color: var(--muted); font: inherit; font-weight: 650; }
|
편집과 미리보기를 한 화면에 나란히 둔다. 탭이었을 때는 한 번에 하나만 보였고, 고친 결과를
|
||||||
.studio-app .studio-editor-tabs button[aria-selected="true"] { color: var(--ink); }
|
보려면 편집하던 자리를 화면에서 치워야 했다.
|
||||||
.studio-app .studio-editor-tabs button[aria-selected="true"]::after { position: absolute; right: 0; bottom: -1px; left: 0; height: 2px; background: var(--signal); content: ""; }
|
*/
|
||||||
|
.studio-app .studio-editor-split { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 48px; align-items: start; padding-top: 40px; }
|
||||||
|
/* 반쪽 폭에는 62px 짜리 제목이 앉을 자리가 없다. */
|
||||||
|
.studio-app .studio-editor-split .studio-editor-heading h1 { font-size: clamp(28px, 3vw, 40px); }
|
||||||
|
|
||||||
.studio-app .studio-editor-layout { display: grid; grid-template-columns: minmax(0, 1fr) minmax(240px, 280px); gap: 64px; align-items: start; padding-top: 40px; }
|
/*
|
||||||
|
미리보기는 편집 칸과 길이가 다르다. 붙잡아 두지 않으면 본문을 스크롤하는 동안 화면 밖으로
|
||||||
|
나가 버리므로 자기 높이 안에서 따로 스크롤한다. 아래로 비워 두는 만큼이 고정된 상태 바가
|
||||||
|
덮는 자리다.
|
||||||
|
*/
|
||||||
|
.studio-app .studio-editor-preview { position: sticky; top: 28px; display: flex; flex-direction: column; max-height: calc(100vh - 190px); padding-left: 40px; border-left: 1px solid var(--line); }
|
||||||
|
.studio-app .studio-editor-preview__heading { padding-bottom: 20px; border-bottom: 1px solid var(--line); }
|
||||||
|
.studio-app .studio-editor-preview__heading .studio-eyebrow { margin-bottom: 8px; }
|
||||||
|
.studio-app .studio-editor-preview__body { flex: 1; min-height: 0; padding-top: 20px; overflow-y: auto; }
|
||||||
.studio-app .studio-editor-heading { padding-bottom: 36px; border-bottom: 1px solid var(--line-strong); }
|
.studio-app .studio-editor-heading { padding-bottom: 36px; border-bottom: 1px solid var(--line-strong); }
|
||||||
.studio-app .studio-editor-heading h1 { margin: 0; font-size: clamp(38px, 5vw, 62px); line-height: 1.05; letter-spacing: -0.045em; }
|
.studio-app .studio-editor-heading h1 { margin: 0; font-size: clamp(38px, 5vw, 62px); line-height: 1.05; letter-spacing: -0.045em; }
|
||||||
.studio-app .studio-editor-heading > p:last-child { max-width: 780px; margin: 18px 0 0; color: var(--muted); font-size: 17px; line-height: 1.65; overflow-wrap: anywhere; }
|
.studio-app .studio-editor-heading > p:last-child { max-width: 780px; margin: 18px 0 0; color: var(--muted); font-size: 17px; line-height: 1.65; overflow-wrap: anywhere; }
|
||||||
@@ -17,7 +28,7 @@
|
|||||||
.studio-app .studio-editor-section-heading { margin-bottom: 24px; }
|
.studio-app .studio-editor-section-heading { margin-bottom: 24px; }
|
||||||
.studio-app .studio-editor-section-heading .studio-eyebrow { margin-bottom: 8px; }
|
.studio-app .studio-editor-section-heading .studio-eyebrow { margin-bottom: 8px; }
|
||||||
.studio-app .studio-editor-section-heading h2,
|
.studio-app .studio-editor-section-heading h2,
|
||||||
.studio-app .studio-document-status-rail h2,
|
.studio-app .studio-editor-preview__heading h2,
|
||||||
.studio-app .studio-preview-error h2 { margin: 0; font-size: 25px; letter-spacing: -0.03em; }
|
.studio-app .studio-preview-error h2 { margin: 0; font-size: 25px; letter-spacing: -0.03em; }
|
||||||
|
|
||||||
.studio-app .studio-field-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 22px; }
|
.studio-app .studio-field-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 22px; }
|
||||||
@@ -71,27 +82,38 @@
|
|||||||
.studio-app .studio-item-actions { display: flex; flex-wrap: wrap; gap: 8px; }
|
.studio-app .studio-item-actions { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||||
.studio-app .studio-item-actions button,
|
.studio-app .studio-item-actions button,
|
||||||
.studio-app .studio-add-item,
|
.studio-app .studio-add-item,
|
||||||
.studio-app .studio-document-status-rail button { min-height: 44px; padding-inline: 13px; border: 1px solid var(--line-strong); border-radius: 5px; background: var(--paper); color: var(--ink); font-weight: 650; }
|
.studio-app .studio-document-status-bar button { min-height: 44px; padding-inline: 13px; border: 1px solid var(--line-strong); border-radius: 5px; background: var(--paper); color: var(--ink); font-weight: 650; }
|
||||||
.studio-app .studio-item-actions button:disabled,
|
.studio-app .studio-item-actions button:disabled,
|
||||||
.studio-app .studio-add-item:disabled { opacity: 0.45; }
|
.studio-app .studio-add-item:disabled { opacity: 0.45; }
|
||||||
.studio-app .studio-add-item { margin-top: 12px; }
|
.studio-app .studio-add-item { margin-top: 12px; }
|
||||||
.studio-app .studio-resolution-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 22px; }
|
.studio-app .studio-resolution-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 22px; }
|
||||||
.studio-app .studio-resolution-fields legend { grid-column: 1 / -1; }
|
.studio-app .studio-resolution-fields legend { grid-column: 1 / -1; }
|
||||||
|
|
||||||
.studio-app .studio-document-status-rail { position: sticky; top: 28px; min-width: 0; padding: 24px 0; border-top: 1px solid var(--line-strong); border-bottom: 1px solid var(--line-strong); }
|
/*
|
||||||
.studio-app .studio-document-status-rail .studio-eyebrow { margin-bottom: 8px; }
|
작업 상태와 저장·게시를 화면 아래에 붙여 둔다. 문서가 길어져도 스크롤 위치와 무관하게 손이
|
||||||
.studio-app .studio-editor-status { margin: 20px 0; font-weight: 700; }
|
닿는다. 배경을 불투명하게 두는 것은 장식이 아니라 필요다 — 밑으로 지나가는 본문이 비치면
|
||||||
|
상태 글자를 읽을 수 없다.
|
||||||
|
*/
|
||||||
|
.studio-app .studio-document-status-bar { position: sticky; bottom: 0; z-index: 2; display: flex; flex-wrap: wrap; align-items: center; gap: 12px 28px; min-width: 0; margin-top: 48px; padding: 16px 0; border-top: 1px solid var(--line-strong); background: var(--paper); }
|
||||||
|
.studio-app .studio-editor-status { margin: 0; font-weight: 700; }
|
||||||
.studio-app .studio-editor-status--dirty,
|
.studio-app .studio-editor-status--dirty,
|
||||||
.studio-app .studio-editor-status--conflict,
|
.studio-app .studio-editor-status--conflict,
|
||||||
.studio-app .studio-editor-conflict { color: #8f2f27; }
|
.studio-app .studio-editor-conflict { color: #8f2f27; }
|
||||||
.studio-app .studio-editor-status--clean { color: #166748; }
|
.studio-app .studio-editor-status--clean { color: #166748; }
|
||||||
.studio-app .studio-document-status-rail dl { display: grid; gap: 12px; margin: 0 0 20px; }
|
.studio-app .studio-document-status-bar dl { display: flex; gap: 20px; margin: 0; }
|
||||||
.studio-app .studio-document-status-rail dl div { display: flex; justify-content: space-between; gap: 16px; }
|
.studio-app .studio-document-status-bar dl div { display: flex; align-items: baseline; gap: 8px; }
|
||||||
.studio-app .studio-document-status-rail dt { color: var(--muted); font-size: 12px; }
|
.studio-app .studio-document-status-bar dt { color: var(--muted); font-size: 12px; }
|
||||||
.studio-app .studio-document-status-rail dd { margin: 0; font-size: 13px; }
|
.studio-app .studio-document-status-bar dd { margin: 0; font-size: 13px; }
|
||||||
.studio-app .studio-document-status-rail button { width: 100%; border-color: var(--signal); background: var(--signal); color: #fff; }
|
.studio-app .studio-document-status-bar__note { flex: 1 1 240px; margin: 0; color: var(--muted); font-size: 12px; line-height: 1.6; }
|
||||||
.studio-app .studio-document-status-rail button:disabled { border-color: var(--line-strong); background: var(--paper); color: var(--muted); }
|
/* 색은 위 묶음이 정한다 — 여기서 다시 칠하면 실패가 안내문처럼 보인다. */
|
||||||
.studio-app .studio-document-status-rail > p:last-child { margin: 16px 0 0; color: var(--muted); font-size: 12px; line-height: 1.65; }
|
.studio-app .studio-document-status-bar .studio-editor-conflict { flex: 1 1 240px; margin: 0; font-size: 12px; line-height: 1.6; }
|
||||||
|
.studio-app .studio-document-status-bar__actions { display: flex; align-items: center; gap: 12px; margin-left: auto; }
|
||||||
|
/* `.studio-primary-button` 은 세로로 쌓이는 자리에 맞춘 바깥 여백을 갖고 있다. 한 줄에
|
||||||
|
나란히 두면 그 여백이 줄 높이를 늘리고, 옆 버튼만 늘어나 짝이 어긋난다. */
|
||||||
|
.studio-app .studio-document-status-bar__actions .studio-primary-button { margin: 0; }
|
||||||
|
.studio-app .studio-document-status-bar button { min-width: 104px; border-color: var(--signal); background: var(--signal); color: #fff; }
|
||||||
|
.studio-app .studio-document-status-bar button:disabled { border-color: var(--line-strong); background: var(--paper); color: var(--muted); }
|
||||||
|
.studio-app .studio-document-status-bar .studio-editor-unplaced-issues { flex: 1 1 100%; }
|
||||||
|
|
||||||
.studio-app .studio-asset-panel { margin-top: 28px; padding-top: 28px; border-top: 1px solid var(--line); }
|
.studio-app .studio-asset-panel { margin-top: 28px; padding-top: 28px; border-top: 1px solid var(--line); }
|
||||||
.studio-app .studio-asset-panel .studio-eyebrow { margin-bottom: 8px; }
|
.studio-app .studio-asset-panel .studio-eyebrow { margin-bottom: 8px; }
|
||||||
@@ -107,7 +129,7 @@
|
|||||||
matches this form's submit button, and both selectors carry the same
|
matches this form's submit button, and both selectors carry the same
|
||||||
specificity, so only source order tells them apart. */
|
specificity, so only source order tells them apart. */
|
||||||
/* `minmax(0, 1fr)` and the row's `min-width: 0` are both load bearing, for the
|
/* `minmax(0, 1fr)` and the row's `min-width: 0` are both load bearing, for the
|
||||||
reason `.studio-editor-layout` already needs the same pair: a grid track and
|
reason `.studio-editor-split` already needs the same pair: a grid track and
|
||||||
a flex container both default to an automatic minimum of their min-content
|
a flex container both default to an automatic minimum of their min-content
|
||||||
size, so at 360px the search row sized itself to 366px inside a 328px column
|
size, so at 360px the search row sized itself to 366px inside a 328px column
|
||||||
and pushed the submit button off-screen (document scrollWidth 382). */
|
and pushed the submit button off-screen (document scrollWidth 382). */
|
||||||
@@ -128,15 +150,20 @@
|
|||||||
.studio-app .studio-preview-error ul { margin: 20px 0 0; padding-left: 20px; color: #8f2f27; line-height: 1.7; overflow-wrap: anywhere; }
|
.studio-app .studio-preview-error ul { margin: 20px 0 0; padding-left: 20px; color: #8f2f27; line-height: 1.7; overflow-wrap: anywhere; }
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
@media (max-width: 1024px) {
|
||||||
.studio-app .studio-editor-layout { grid-template-columns: minmax(0, 1fr); gap: 40px; }
|
.studio-app .studio-editor-split { grid-template-columns: minmax(0, 1fr); gap: 40px; }
|
||||||
.studio-app .studio-document-status-rail { position: static; }
|
.studio-app .studio-editor-split .studio-editor-heading h1 { font-size: clamp(38px, 5vw, 62px); }
|
||||||
|
.studio-app .studio-editor-preview { position: static; max-height: none; padding-top: 8px; padding-left: 0; border-top: 1px solid var(--line-strong); border-left: 0; }
|
||||||
|
.studio-app .studio-editor-preview__body { overflow-y: visible; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 767px) {
|
@media (max-width: 767px) {
|
||||||
.studio-app .studio-editor-heading { padding-bottom: 28px; }
|
.studio-app .studio-editor-heading { padding-bottom: 28px; }
|
||||||
.studio-app .studio-editor-tabs { gap: 18px; }
|
.studio-app .studio-editor-split { padding-top: 28px; }
|
||||||
.studio-app .studio-editor-tabs button { flex: 1; min-width: 0; }
|
/* 안내문은 세 줄로 접혀 뷰포트의 20%를 고정으로 가져간다. 실패 문구는 남기고 안내만 접는다 —
|
||||||
.studio-app .studio-editor-layout { padding-top: 28px; }
|
Ctrl+S 는 여기서 누를 수 있는 것도 아니다. */
|
||||||
|
.studio-app .studio-document-status-bar__note { display: none; }
|
||||||
|
.studio-app .studio-document-status-bar__actions { flex: 1 1 100%; margin-left: 0; }
|
||||||
|
.studio-app .studio-document-status-bar__actions button { flex: 1; min-width: 0; }
|
||||||
.studio-app .studio-field-grid,
|
.studio-app .studio-field-grid,
|
||||||
.studio-app .studio-resolution-fields { grid-template-columns: minmax(0, 1fr); }
|
.studio-app .studio-resolution-fields { grid-template-columns: minmax(0, 1fr); }
|
||||||
.studio-app .studio-field--wide,
|
.studio-app .studio-field--wide,
|
||||||
@@ -181,6 +208,6 @@
|
|||||||
저장과 게시는 되돌릴 수 있는 정도가 다르다 — 하나는 초안을 남기고, 하나는 공개한다. 둘이
|
저장과 게시는 되돌릴 수 있는 정도가 다르다 — 하나는 초안을 남기고, 하나는 공개한다. 둘이
|
||||||
맞붙어 있으면 누르려던 것을 지나쳐 누르기 쉬우므로 사이를 벌린다.
|
맞붙어 있으면 누르려던 것을 지나쳐 누르기 쉬우므로 사이를 벌린다.
|
||||||
*/
|
*/
|
||||||
.studio-app .studio-document-status-rail button + button { margin-top: 10px; }
|
.studio-app .studio-document-status-bar__actions { gap: 12px; }
|
||||||
/* 게시만 강조한다. 저장은 되돌릴 수 있으므로 같은 무게로 부를 이유가 없다. */
|
/* 게시만 강조한다. 저장은 되돌릴 수 있으므로 같은 무게로 부를 이유가 없다. */
|
||||||
.studio-app .studio-document-status-rail button:not(.studio-primary-button) { border-color: var(--line-strong); background: var(--paper); color: var(--ink); }
|
.studio-app .studio-document-status-bar button:not(.studio-primary-button) { border-color: var(--line-strong); background: var(--paper); color: var(--ink); }
|
||||||
|
|||||||
@@ -461,8 +461,7 @@ test("inserts the directive at the saved cursor position and the live preview re
|
|||||||
`${body.slice(0, cursor)}\n\n${expectedDirective}${body.slice(cursor)}`,
|
`${body.slice(0, cursor)}\n\n${expectedDirective}${body.slice(cursor)}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
await user.click(screen.getByRole("tab", { name: "즉시 미리보기" }));
|
const panel = screen.getByRole("region", { name: "즉시 미리보기" });
|
||||||
const panel = screen.getByRole("tabpanel", { name: "즉시 미리보기" });
|
|
||||||
|
|
||||||
expect(within(panel).queryByRole("alert")).not.toBeInTheDocument();
|
expect(within(panel).queryByRole("alert")).not.toBeInTheDocument();
|
||||||
// `zoom: true` (DIAGRAM kind) renders the figure's image twice -- once as
|
// `zoom: true` (DIAGRAM kind) renders the figure's image twice -- once as
|
||||||
@@ -1819,12 +1818,12 @@ test("a Picker search never drops an already-inserted asset out of Instant Previ
|
|||||||
// must narrow; the preview must not.
|
// must narrow; the preview must not.
|
||||||
await user.type(screen.getByLabelText("Asset 검색"), "other");
|
await user.type(screen.getByLabelText("Asset 검색"), "other");
|
||||||
await user.click(screen.getByRole("button", { name: "검색" }));
|
await user.click(screen.getByRole("button", { name: "검색" }));
|
||||||
|
const picker = screen.getByRole("group", { name: "본문에 Asset 삽입" });
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(screen.queryByRole("button", { name: /inserted-diagram/ })).not.toBeInTheDocument(),
|
expect(within(picker).queryByRole("button", { name: /inserted-diagram/ })).not.toBeInTheDocument(),
|
||||||
);
|
);
|
||||||
|
|
||||||
await user.click(screen.getByRole("tab", { name: "즉시 미리보기" }));
|
const panel = screen.getByRole("region", { name: "즉시 미리보기" });
|
||||||
const panel = screen.getByRole("tabpanel", { name: "즉시 미리보기" });
|
|
||||||
|
|
||||||
assert.equal(
|
assert.equal(
|
||||||
within(panel).queryByRole("alert")?.textContent ?? null,
|
within(panel).queryByRole("alert")?.textContent ?? null,
|
||||||
|
|||||||
@@ -166,8 +166,7 @@ describe("TechLog Studio project decision authoring", () => {
|
|||||||
expect(screen.getByRole("complementary", { name: "작업 상태" }))
|
expect(screen.getByRole("complementary", { name: "작업 상태" }))
|
||||||
.toHaveTextContent("종류Decision");
|
.toHaveTextContent("종류Decision");
|
||||||
|
|
||||||
await user.click(screen.getByRole("tab", { name: "즉시 미리보기" }));
|
const preview = screen.getByRole("region", { name: "즉시 미리보기" });
|
||||||
const preview = screen.getByRole("tabpanel", { name: "즉시 미리보기" });
|
|
||||||
expect(
|
expect(
|
||||||
within(preview).getByRole("heading", {
|
within(preview).getByRole("heading", {
|
||||||
name: "목록 페이징과 컬렉션 로딩을 분리합니다",
|
name: "목록 페이징과 컬렉션 로딩을 분리합니다",
|
||||||
|
|||||||
@@ -114,20 +114,17 @@ describe("TechLog Studio document editor", () => {
|
|||||||
]);
|
]);
|
||||||
expect(within(rail).queryAllByRole("link")).toHaveLength(0);
|
expect(within(rail).queryAllByRole("link")).toHaveLength(0);
|
||||||
|
|
||||||
const editTab = screen.getByRole("tab", { name: "편집" });
|
// 편집과 미리보기는 한 화면에 함께 있다. 탭이었을 때는 고친 결과를 보려면 편집하던 자리를
|
||||||
const previewTab = screen.getByRole("tab", { name: "즉시 미리보기" });
|
// 화면에서 치워야 했고, 돌아오면 스크롤 위치도 잃었다 — 그 왕복이 없어야 한다는 것이
|
||||||
editTab.focus();
|
// 이 화면의 요구다. 아무것도 누르지 않은 채로 둘 다 보이는지 묻는다.
|
||||||
await user.keyboard("{ArrowRight}");
|
expect(screen.getByRole("region", { name: "문서 편집" })).toBeVisible();
|
||||||
expect(previewTab).toHaveFocus();
|
|
||||||
expect(previewTab).toHaveAttribute("aria-selected", "true");
|
|
||||||
expect(
|
expect(
|
||||||
within(screen.getByRole("tabpanel", { name: "즉시 미리보기" })).getByRole(
|
within(screen.getByRole("region", { name: "즉시 미리보기" })).getByRole(
|
||||||
"heading",
|
"heading",
|
||||||
{ level: 1, name: "편집한 Redis 경계" },
|
{ level: 1, name: "편집한 Redis 경계" },
|
||||||
),
|
),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
await user.keyboard("{Home}");
|
expect(screen.queryByRole("tab", { name: "즉시 미리보기" })).toBeNull();
|
||||||
expect(editTab).toHaveFocus();
|
|
||||||
expect(screen.getByLabelText("제목")).toHaveValue("편집한 Redis 경계");
|
expect(screen.getByLabelText("제목")).toHaveValue("편집한 Redis 경계");
|
||||||
expect(calls).toEqual({ save: 0, validate: 0, preview: 0, publish: 0 });
|
expect(calls).toEqual({ save: 0, validate: 0, preview: 0, publish: 0 });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -105,7 +105,8 @@ async function interact(page, action) {
|
|||||||
} else if (action === "studio-menu") {
|
} else if (action === "studio-menu") {
|
||||||
await page.getByRole("button", { name: "Studio 메뉴 열기" }).click();
|
await page.getByRole("button", { name: "Studio 메뉴 열기" }).click();
|
||||||
} else if (action === "immediate-preview") {
|
} else if (action === "immediate-preview") {
|
||||||
await page.getByRole("tab", { name: "즉시 미리보기" }).click();
|
// 미리보기는 이제 편집 옆에 늘 떠 있다 — 열 탭이 없으므로 자리 잡기를 기다리기만 한다.
|
||||||
|
await page.getByRole("region", { name: "즉시 미리보기" }).waitFor();
|
||||||
} else if (action === "dirty-dialog") {
|
} else if (action === "dirty-dialog") {
|
||||||
await page.getByLabel("요약").fill("저장하지 않은 시각 검증 변경");
|
await page.getByLabel("요약").fill("저장하지 않은 시각 검증 변경");
|
||||||
await page.getByRole("link", { name: "게시 기록", exact: true }).first().click();
|
await page.getByRole("link", { name: "게시 기록", exact: true }).first().click();
|
||||||
|
|||||||
@@ -95,8 +95,7 @@ test("Studio immediate preview matches the source without a mask", async ({ page
|
|||||||
page,
|
page,
|
||||||
"/studio/documents/11111111-1111-4111-8111-111111111111/edit",
|
"/studio/documents/11111111-1111-4111-8111-111111111111/edit",
|
||||||
);
|
);
|
||||||
await page.getByRole("tab", { name: "즉시 미리보기" }).click();
|
await expect(page.getByRole("region", { name: "즉시 미리보기" })).toBeVisible();
|
||||||
await expect(page.getByRole("tabpanel", { name: "즉시 미리보기" })).toBeVisible();
|
|
||||||
await expect(page).toHaveScreenshot("tech-log-studio-immediate-preview-1440.png", {
|
await expect(page).toHaveScreenshot("tech-log-studio-immediate-preview-1440.png", {
|
||||||
fullPage: true,
|
fullPage: true,
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user