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:
DongHyeonka
2026-08-26 15:49:18 +09:00
co-authored by Claude Opus 5
parent b3119952d5
commit 60c8c82097
10 changed files with 197 additions and 169 deletions
@@ -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();
+1 -2
View File
@@ -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,
}); });