{controller.draft.kind} · VERSION {controller.saved.version}
+
문서 편집
+
{controller.draft.title || "제목 없는 작업본"}
+
+
+ {controller.draft.kind === "CASE"
+ ?
+ : controller.draft.kind === "REFERENCE"
+ ?
+ : controller.draft.kind === "QUESTION"
+ ?
+ : }
+
+ {/*
+ 미리보기는 편집 칸보다 훨씬 짧을 수도, 길 수도 있다. 그대로 두면 본문을 스크롤하는
+ 동안 화면 밖으로 나가 버리므로 붙잡아 두고 자기 높이 안에서 따로 스크롤한다.
+ */}
+
+
+
LIVE
+
즉시 미리보기
+
+
-
-
+
+
);
}
diff --git a/src/features/tech-log/presentation/studio/components/document-status-bar.tsx b/src/features/tech-log/presentation/studio/components/document-status-bar.tsx
new file mode 100644
index 0000000..42d120c
--- /dev/null
+++ b/src/features/tech-log/presentation/studio/components/document-status-bar.tsx
@@ -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 (
+
+ );
+}
diff --git a/src/features/tech-log/presentation/studio/components/document-status-rail.tsx b/src/features/tech-log/presentation/studio/components/document-status-rail.tsx
deleted file mode 100644
index 69bd81b..0000000
--- a/src/features/tech-log/presentation/studio/components/document-status-rail.tsx
+++ /dev/null
@@ -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 (
-
- );
-}
diff --git a/src/features/tech-log/presentation/styles/studio-editor.css b/src/features/tech-log/presentation/styles/studio-editor.css
index 95f85e8..445c4bb 100644
--- a/src/features/tech-log/presentation/styles/studio-editor.css
+++ b/src/features/tech-log/presentation/styles/studio-editor.css
@@ -1,15 +1,26 @@
.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 > [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-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 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; }
@@ -17,7 +28,7 @@
.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 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-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 button,
.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-add-item:disabled { opacity: 0.45; }
.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 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--conflict,
.studio-app .studio-editor-conflict { color: #8f2f27; }
.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-rail dl div { display: flex; justify-content: space-between; gap: 16px; }
-.studio-app .studio-document-status-rail dt { color: var(--muted); font-size: 12px; }
-.studio-app .studio-document-status-rail 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-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 dl { display: flex; gap: 20px; margin: 0; }
+.studio-app .studio-document-status-bar dl div { display: flex; align-items: baseline; gap: 8px; }
+.studio-app .studio-document-status-bar dt { color: var(--muted); font-size: 12px; }
+.studio-app .studio-document-status-bar dd { margin: 0; font-size: 13px; }
+.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-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 .studio-eyebrow { margin-bottom: 8px; }
@@ -107,7 +129,7 @@
matches this form's submit button, and both selectors carry the same
specificity, so only source order tells them apart. */
/* `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
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). */
@@ -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; }
@media (max-width: 1024px) {
- .studio-app .studio-editor-layout { grid-template-columns: minmax(0, 1fr); gap: 40px; }
- .studio-app .studio-document-status-rail { position: static; }
+ .studio-app .studio-editor-split { grid-template-columns: minmax(0, 1fr); gap: 40px; }
+ .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) {
.studio-app .studio-editor-heading { padding-bottom: 28px; }
- .studio-app .studio-editor-tabs { gap: 18px; }
- .studio-app .studio-editor-tabs button { flex: 1; min-width: 0; }
- .studio-app .studio-editor-layout { padding-top: 28px; }
+ .studio-app .studio-editor-split { padding-top: 28px; }
+ /* 안내문은 세 줄로 접혀 뷰포트의 20%를 고정으로 가져간다. 실패 문구는 남기고 안내만 접는다 —
+ 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-resolution-fields { grid-template-columns: minmax(0, 1fr); }
.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); }
diff --git a/tests/features/tech-log/asset-picker.test.tsx b/tests/features/tech-log/asset-picker.test.tsx
index cf40d18..662626e 100644
--- a/tests/features/tech-log/asset-picker.test.tsx
+++ b/tests/features/tech-log/asset-picker.test.tsx
@@ -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)}`,
);
- await user.click(screen.getByRole("tab", { name: "즉시 미리보기" }));
- const panel = screen.getByRole("tabpanel", { name: "즉시 미리보기" });
+ const panel = screen.getByRole("region", { name: "즉시 미리보기" });
expect(within(panel).queryByRole("alert")).not.toBeInTheDocument();
// `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.
await user.type(screen.getByLabelText("Asset 검색"), "other");
await user.click(screen.getByRole("button", { name: "검색" }));
+ const picker = screen.getByRole("group", { name: "본문에 Asset 삽입" });
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("tabpanel", { name: "즉시 미리보기" });
+ const panel = screen.getByRole("region", { name: "즉시 미리보기" });
assert.equal(
within(panel).queryByRole("alert")?.textContent ?? null,
diff --git a/tests/features/tech-log/studio-decision-authoring.test.tsx b/tests/features/tech-log/studio-decision-authoring.test.tsx
index 9484ad6..7b407b1 100644
--- a/tests/features/tech-log/studio-decision-authoring.test.tsx
+++ b/tests/features/tech-log/studio-decision-authoring.test.tsx
@@ -166,8 +166,7 @@ describe("TechLog Studio project decision authoring", () => {
expect(screen.getByRole("complementary", { name: "작업 상태" }))
.toHaveTextContent("종류Decision");
- await user.click(screen.getByRole("tab", { name: "즉시 미리보기" }));
- const preview = screen.getByRole("tabpanel", { name: "즉시 미리보기" });
+ const preview = screen.getByRole("region", { name: "즉시 미리보기" });
expect(
within(preview).getByRole("heading", {
name: "목록 페이징과 컬렉션 로딩을 분리합니다",
diff --git a/tests/features/tech-log/studio-editor-smoke.test.tsx b/tests/features/tech-log/studio-editor-smoke.test.tsx
index 0d9d8a9..aab6de4 100644
--- a/tests/features/tech-log/studio-editor-smoke.test.tsx
+++ b/tests/features/tech-log/studio-editor-smoke.test.tsx
@@ -114,20 +114,17 @@ describe("TechLog Studio document editor", () => {
]);
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(previewTab).toHaveFocus();
- expect(previewTab).toHaveAttribute("aria-selected", "true");
+ // 편집과 미리보기는 한 화면에 함께 있다. 탭이었을 때는 고친 결과를 보려면 편집하던 자리를
+ // 화면에서 치워야 했고, 돌아오면 스크롤 위치도 잃었다 — 그 왕복이 없어야 한다는 것이
+ // 이 화면의 요구다. 아무것도 누르지 않은 채로 둘 다 보이는지 묻는다.
+ expect(screen.getByRole("region", { name: "문서 편집" })).toBeVisible();
expect(
- within(screen.getByRole("tabpanel", { name: "즉시 미리보기" })).getByRole(
+ within(screen.getByRole("region", { name: "즉시 미리보기" })).getByRole(
"heading",
{ level: 1, name: "편집한 Redis 경계" },
),
).toBeVisible();
- await user.keyboard("{Home}");
- expect(editTab).toHaveFocus();
+ expect(screen.queryByRole("tab", { name: "즉시 미리보기" })).toBeNull();
expect(screen.getByLabelText("제목")).toHaveValue("편집한 Redis 경계");
expect(calls).toEqual({ save: 0, validate: 0, preview: 0, publish: 0 });
});
diff --git a/tests/support/browser/verify-tech-log-source-parity.ts b/tests/support/browser/verify-tech-log-source-parity.ts
index c87ee0c..49a1cc6 100644
--- a/tests/support/browser/verify-tech-log-source-parity.ts
+++ b/tests/support/browser/verify-tech-log-source-parity.ts
@@ -105,7 +105,8 @@ async function interact(page, action) {
} else if (action === "studio-menu") {
await page.getByRole("button", { name: "Studio 메뉴 열기" }).click();
} else if (action === "immediate-preview") {
- await page.getByRole("tab", { name: "즉시 미리보기" }).click();
+ // 미리보기는 이제 편집 옆에 늘 떠 있다 — 열 탭이 없으므로 자리 잡기를 기다리기만 한다.
+ await page.getByRole("region", { name: "즉시 미리보기" }).waitFor();
} else if (action === "dirty-dialog") {
await page.getByLabel("요약").fill("저장하지 않은 시각 검증 변경");
await page.getByRole("link", { name: "게시 기록", exact: true }).first().click();
diff --git a/tests/visual/tech-log.visual.spec.ts b/tests/visual/tech-log.visual.spec.ts
index 6f7e5a1..5c69d23 100644
--- a/tests/visual/tech-log.visual.spec.ts
+++ b/tests/visual/tech-log.visual.spec.ts
@@ -95,8 +95,7 @@ test("Studio immediate preview matches the source without a mask", async ({ page
page,
"/studio/documents/11111111-1111-4111-8111-111111111111/edit",
);
- await page.getByRole("tab", { name: "즉시 미리보기" }).click();
- await expect(page.getByRole("tabpanel", { name: "즉시 미리보기" })).toBeVisible();
+ await expect(page.getByRole("region", { name: "즉시 미리보기" })).toBeVisible();
await expect(page).toHaveScreenshot("tech-log-studio-immediate-preview-1440.png", {
fullPage: true,
});