From 97ec9260cadc04c7087d334ee8e809ca1fe3521b Mon Sep 17 00:00:00 2001 From: ummsehun Date: Fri, 26 Jun 2026 16:29:23 +0900 Subject: [PATCH 01/58] =?UTF-8?q?feat(design):=20Phase=200-1=20=E2=80=94?= =?UTF-8?q?=20token=20vocab=20unification=20(D1:=20Luie=20vocab)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 0: add design-token violation guard (scripts/design/tokens-guard.mjs) with renderer-wide baselines that only ratchet down. Phase 1: codemod shadcn long-form utility classes to Luie short-form (scripts/design/unify-token-vocab.mjs). 226 replacements across 50 files. Every mapping resolves to the same CSS variable via the @theme bridge, so zero visual change. shadcnVocab count: 226 -> 0. text-muted-foreground -> text-muted text-foreground -> text-fg bg-background -> bg-app *-accent-foreground -> *-accent-fg *-destructive -> *-danger (excl. destructive-foreground) Excluded: components/ui/* (shadcn primitives) and surface/card/popover (@theme vs tailwind.config double-definition — handled in Phase 2). Verified: tsc --noEmit passes; guard green. Co-Authored-By: Claude Opus 4.8 --- docs/design-redesign-plan.md | 29 +++++--- scripts/design/tokens-guard.mjs | 66 ++++++++++++++++++ scripts/design/unify-token-vocab.mjs | 67 +++++++++++++++++++ .../components/binder/CanvasNodeInspector.tsx | 16 ++--- .../components/binder/GraphNodeInspector.tsx | 6 +- .../canvas/components/graph/PensiveNode.tsx | 20 +++--- .../graphSurfaceParts/GraphHoverCard.tsx | 12 ++-- .../graphSurfaceParts/GraphLegendModal.tsx | 26 +++---- .../components/shell/CanvasActivityShell.tsx | 18 ++--- .../canvasActivityShellParts/TreeNode.tsx | 10 +-- .../viewport/BaseCanvasViewport.tsx | 2 +- .../components/viewport/CanvasStatusBar.tsx | 4 +- .../src/features/editor/components/Editor.tsx | 4 +- .../editor/components/EditorBubbleMenu.tsx | 18 ++--- .../editor/components/EditorRuler.tsx | 2 +- .../editor/components/FontSelector.tsx | 2 +- .../editor/components/InspectorPanel.tsx | 6 +- .../editor/components/SmartLinkTooltip.tsx | 4 +- .../editor/components/toolbar/menus.tsx | 4 +- .../export/components/ExportPreview.tsx | 2 +- .../components/BinderSidebarTabs.tsx | 4 +- .../manuscript/components/DocsSidebar.tsx | 6 +- .../components/ScrivenerSidebar.tsx | 8 +-- .../sections/SidebarChapterList.tsx | 6 +- .../sections/SidebarCharacterList.tsx | 6 +- .../components/sections/SidebarEventList.tsx | 4 +- .../sections/SidebarFactionList.tsx | 4 +- .../components/sections/SidebarMemoList.tsx | 4 +- .../components/sections/SidebarWorldList.tsx | 16 ++--- .../research/components/ResearchPanel.tsx | 4 +- .../research/components/WorldPanel.tsx | 4 +- .../analysisSection/chat/MessageList.tsx | 6 +- .../runtime/RuntimeStatusPanel.tsx | 2 +- .../research/components/memo/MemoMainView.tsx | 6 +- .../components/wiki/CharacterVisualPanel.tsx | 8 +-- .../research/components/wiki/Infobox.tsx | 2 +- .../research/components/wiki/RadarChart.tsx | 2 +- .../components/wiki/WikiDetailView.tsx | 6 +- .../research/components/wiki/WikiSection.tsx | 2 +- .../components/world/DrawingCanvas.tsx | 6 +- .../components/world/SynopsisEditor.tsx | 6 +- .../research/components/world/TermCard.tsx | 4 +- .../components/layout/EditorLayout.tsx | 2 +- .../components/layout/FocusLayout.tsx | 10 +-- .../layout/GoogleDocsEditorColumn.tsx | 4 +- .../components/layout/GoogleDocsHeader.tsx | 8 +-- .../components/layout/GoogleDocsLayout.tsx | 8 +-- .../components/layout/GoogleDocsPanelRail.tsx | 12 ++-- .../layout/GoogleDocsRightPanel.tsx | 2 +- .../components/layout/MainLayout.tsx | 4 +- .../components/layout/ScrivenerLayout.tsx | 6 +- .../components/panels/ContextPanel.tsx | 6 +- .../RecentProjectsSection.tsx | 2 +- 53 files changed, 322 insertions(+), 176 deletions(-) create mode 100644 scripts/design/tokens-guard.mjs create mode 100644 scripts/design/unify-token-vocab.mjs diff --git a/docs/design-redesign-plan.md b/docs/design-redesign-plan.md index c4222539..c0bfa7cf 100644 --- a/docs/design-redesign-plan.md +++ b/docs/design-redesign-plan.md @@ -143,16 +143,29 @@ --- -## 4. 먼저 정해야 할 결정 (Decisions) +## 4. 결정 사항 (Decisions) — 확정됨 2026-06-26 -| # | 결정 | 권장 | 이유 | +| # | 결정 | 확정값 | 이유 | | --- | --- | --- | --- | -| D1 | 남길 토큰 어휘 | **Luie(`app/fg/panel`)** | 사용량 우세(103 vs 43), 도메인 의미 명확 | -| D2 | 설정 축 처리 | temp/contrast/texture/uiMode **제거** | 정체성 > 유연성. 유지 시 "고급" 격리 | -| D3 | canvasTokens.ts | **코어로 흡수** | 캔버스만의 색 체계가 분리 원인 | -| D4 | shadcn 곡률/그림자 | **다운튠**(radius≤`control`, shadow 2종만) | writer-first 상한선 | - -> D1/D2는 사용자 확인 권장 — 제품 방향성 결정이라 코드만으론 못 정함. +| D1 | 남길 토큰 어휘 | ✅ **Luie(`app/fg/panel`)** | 사용량 우세, 도메인 의미 명확. shadcn long-form → Luie 단축형 변환 | +| D2 | 설정 축 처리 | ✅ **3축으로 제거** | 테마3 + accent1만. temp/contrast/texture/uiMode 제거 | +| D3 | canvasTokens.ts | 코어로 흡수 (Phase 3) | 캔버스만의 색 체계가 분리 원인 | +| D4 | shadcn 곡률/그림자 | 다운튠(radius≤`control`, shadow 2종) | writer-first 상한선 | + +### Phase 1 정밀 스코프 (인벤토리 후 수정) +shadcn 어휘 대부분은 `global.tokens.css @theme`에서 Luie 단축형과 **동일 CSS 변수**로 브리지됨 → 변환 시 시각 변화 0. + +**안전 변환(동일 변수 검증 완료):** +- `text-muted-foreground` → `text-muted` (105회) +- `text-foreground` → `text-fg` (74회) +- `bg-background` → `bg-app` (26회) +- `*-accent-foreground` → `*-accent-fg` (4회) +- `*-destructive` → `*-danger` (17회, `destructive-foreground` 제외) + +**제외(이중 정의 충돌 — 별도 처리):** +- `surface`/`card`/`popover`: `@theme --color-surface=var(--bg-surface)` 이지만 `tailwind.config.surface=var(--bg-element)` → **서로 다른 변수**. 변환 시 색 변경됨. ⚠ tailwind v4 `@theme`와 `tailwind.config.js` 색 정의가 충돌하는 latent 버그 → Phase 2에서 정리. +- `components/ui/*` 원자(button/badge/scroll-area): shadcn primitive 레이어 → 유지(D4 다운튠만). +- `bg-secondary`/`text-secondary`: Luie 단축형 부재 → 보류. --- diff --git a/scripts/design/tokens-guard.mjs b/scripts/design/tokens-guard.mjs new file mode 100644 index 00000000..713ca9b8 --- /dev/null +++ b/scripts/design/tokens-guard.mjs @@ -0,0 +1,66 @@ +#!/usr/bin/env node +// Phase 0 — design-token violation guard. +// Counts token-system violations across renderer code and fails if any +// metric EXCEEDS its baseline. Baselines only ratchet down (edit below as +// phases land). Run: node scripts/design/tokens-guard.mjs +import fs from "node:fs"; +import path from "node:path"; + +const ROOT = path.resolve("src/renderer/src"); +const EXCLUDE = [path.join(ROOT, "components/ui")]; // shadcn primitive layer + +// Baselines captured on feat/design @ c3413545 (pre-Phase-1). +// Lower these as phases reduce the numbers; CI fails if actual > baseline. +const BASELINE = { + rawHex: 352, + rawColor: 201, + arbitraryPx: 403, + roundedBig: 175, + shadowBig: 41, + shadcnVocab: 0, // Phase 1: shadcn long-form vocab fully removed (banned going forward) +}; + +const PATTERNS = { + rawHex: /#[0-9a-fA-F]{3,6}\b/g, + rawColor: + /\b(?:bg|text|border)-(?:blue|red|green|purple|yellow|indigo|pink|slate|gray|zinc|neutral|stone|emerald|sky|violet|amber|rose|orange|teal|cyan|lime)-[0-9]{2,3}\b/g, + arbitraryPx: /\[[0-9]+px\]/g, + roundedBig: /\brounded-(?:xl|2xl|3xl|4xl|full)\b/g, + shadowBig: /\bshadow-(?:lg|xl|2xl)\b/g, + shadcnVocab: + /(?:bg|text|border|ring|fill|stroke|divide|placeholder|caret|outline|decoration|from|to|via)-(?:foreground|background|muted-foreground|accent-foreground|destructive(?!-foreground))\b/g, +}; + +function walk(dir) { + let out = []; + for (const e of fs.readdirSync(dir, { withFileTypes: true })) { + const p = path.join(dir, e.name); + if (EXCLUDE.some((x) => p.startsWith(x))) continue; + if (e.isDirectory()) out = out.concat(walk(p)); + else if (/\.(tsx?|css)$/.test(e.name)) out.push(p); + } + return out; +} + +const files = walk(ROOT); +const totals = Object.fromEntries(Object.keys(PATTERNS).map((k) => [k, 0])); +for (const f of files) { + const s = fs.readFileSync(f, "utf8"); + for (const k in PATTERNS) { + const m = s.match(PATTERNS[k]); + if (m) totals[k] += m.length; + } +} + +let failed = false; +console.log(`design tokens guard — ${files.length} files scanned\n`); +for (const k of Object.keys(PATTERNS)) { + const actual = totals[k]; + const base = BASELINE[k]; + const ok = actual <= base; + if (!ok) failed = true; + console.log( + `${ok ? "✓" : "✗"} ${k.padEnd(12)} ${String(actual).padStart(4)} / baseline ${base}${ok ? "" : " ⬆ REGRESSION"}`, + ); +} +process.exit(failed ? 1 : 0); diff --git a/scripts/design/unify-token-vocab.mjs b/scripts/design/unify-token-vocab.mjs new file mode 100644 index 00000000..056e5c21 --- /dev/null +++ b/scripts/design/unify-token-vocab.mjs @@ -0,0 +1,67 @@ +#!/usr/bin/env node +// Phase 1 — token-vocabulary unification codemod (D1: keep Luie vocab). +// Rewrites shadcn long-form utility classes to their Luie short-form +// equivalents. Every mapping resolves to the SAME CSS variable via the +// @theme bridge in global.tokens.css, so this is a zero-visual-change rename. +// +// Excluded: components/ui/* (shadcn primitive layer) and the surface/card/ +// popover tokens (which have a @theme vs tailwind.config double-definition). +// +// Run: node scripts/design/unify-token-vocab.mjs (apply) +// node scripts/design/unify-token-vocab.mjs --dry (preview only) +import fs from "node:fs"; +import path from "node:path"; + +const DRY = process.argv.includes("--dry"); +const ROOT = path.resolve("src/renderer/src"); +const EXCLUDE = [path.join(ROOT, "components/ui")]; + +// Utility prefixes that carry a color token (variants like hover:/dark:/group-hover: +// sit before these and are preserved automatically; opacity suffixes like /50 too). +const PREFIX = "(?:bg|text|border|ring|fill|stroke|divide|placeholder|caret|outline|decoration|from|to|via)"; + +// [oldToken, newToken] — order matters (longest/compound first). +const MAP = [ + ["muted-foreground", "muted"], + ["accent-foreground", "accent-fg"], + ["foreground", "fg"], + ["background", "app"], + // destructive -> danger, but never destructive-foreground + ["destructive(?!-foreground)", "danger"], +]; + +function walk(dir) { + let out = []; + for (const e of fs.readdirSync(dir, { withFileTypes: true })) { + const p = path.join(dir, e.name); + if (EXCLUDE.some((x) => p.startsWith(x))) continue; + if (e.isDirectory()) out = out.concat(walk(p)); + else if (/\.(tsx?)$/.test(e.name)) out.push(p); + } + return out; +} + +const files = walk(ROOT); +let changedFiles = 0; +let totalReplacements = 0; +for (const f of files) { + let s = fs.readFileSync(f, "utf8"); + const before = s; + let fileCount = 0; + for (const [oldTok, newTok] of MAP) { + const re = new RegExp(`(${PREFIX}-)${oldTok}\\b`, "g"); + s = s.replace(re, (_m, p1) => { + fileCount++; + return p1 + newTok; + }); + } + if (s !== before) { + changedFiles++; + totalReplacements += fileCount; + if (!DRY) fs.writeFileSync(f, s); + console.log(`${fileCount.toString().padStart(3)} ${f.replace(ROOT + "/", "")}`); + } +} +console.log( + `\n${DRY ? "[dry] " : ""}${totalReplacements} replacements in ${changedFiles} files`, +); diff --git a/src/renderer/src/features/canvas/components/binder/CanvasNodeInspector.tsx b/src/renderer/src/features/canvas/components/binder/CanvasNodeInspector.tsx index 99b4e3e6..827b07dd 100644 --- a/src/renderer/src/features/canvas/components/binder/CanvasNodeInspector.tsx +++ b/src/renderer/src/features/canvas/components/binder/CanvasNodeInspector.tsx @@ -358,7 +358,7 @@ function ChapterNodeDetail({ nodeId, projectId, graphData }: ChapterNodeDetailPr {loading ? ( -

+

{t("canvas.status.loading")}

) : summary ? ( @@ -372,14 +372,14 @@ function ChapterNodeDetail({ nodeId, projectId, graphData }: ChapterNodeDetailPr ) : (
-

+

{t("canvas.status.empty")}

-

+

{t("canvas.graph.legend.title", "그래프 범례")}

-

+

Graph Legend Map

- + {t("canvas.graph.legend.nodes", "노드 (개체)")}
-
- +
+ {t("canvas.graph.legend.node.prime", "핵심 주연 캐릭터")}
-
- +
+ {t("canvas.graph.legend.node.major", "조연 / 연관 세력")}
-
- +
+ {t("canvas.graph.legend.node.chapter", "집필 회차 (챕터)")}
- + {t("canvas.graph.legend.edges", "에지 (관계)")}
-
+
{t("canvas.graph.legend.edge.character", "성간 인물 관계선")}
-
+
{t("canvas.graph.legend.edge.event", "인과 관계 수사선 (붉은 실)")}
diff --git a/src/renderer/src/features/canvas/components/shell/CanvasActivityShell.tsx b/src/renderer/src/features/canvas/components/shell/CanvasActivityShell.tsx index cdbc3025..e0ad8e3d 100644 --- a/src/renderer/src/features/canvas/components/shell/CanvasActivityShell.tsx +++ b/src/renderer/src/features/canvas/components/shell/CanvasActivityShell.tsx @@ -100,12 +100,12 @@ export default function CanvasActivityShell({ onClose }: CanvasActivityShellProp } return ( -
+
@@ -157,7 +157,7 @@ export default function CanvasActivityShell({ onClose }: CanvasActivityShellProp size="icon-xs" onClick={() => handleToolbarAction("new-folder")} title={t("canvas.activity.newFolder")} - className="h-6 w-6 text-muted-foreground/75 hover:bg-muted/40 hover:text-foreground [&_svg]:h-3.5 [&_svg]:w-3.5" + className="h-6 w-6 text-muted/75 hover:bg-muted/40 hover:text-fg [&_svg]:h-3.5 [&_svg]:w-3.5" > @@ -167,7 +167,7 @@ export default function CanvasActivityShell({ onClose }: CanvasActivityShellProp size="icon-xs" onClick={() => handleToolbarAction("sort")} title={t("canvas.activity.sort")} - className="h-6 w-6 text-muted-foreground/75 hover:bg-muted/40 hover:text-foreground [&_svg]:h-3.5 [&_svg]:w-3.5" + className="h-6 w-6 text-muted/75 hover:bg-muted/40 hover:text-fg [&_svg]:h-3.5 [&_svg]:w-3.5" > @@ -178,7 +178,7 @@ export default function CanvasActivityShell({ onClose }: CanvasActivityShellProp size="icon-xs" onClick={toggleAllFolders} title="모두 펼치기 / 접기" - className="h-6 w-6 text-muted-foreground/75 hover:bg-muted/40 hover:text-foreground [&_svg]:h-3.5 [&_svg]:w-3.5" + className="h-6 w-6 text-muted/75 hover:bg-muted/40 hover:text-fg [&_svg]:h-3.5 [&_svg]:w-3.5" > diff --git a/src/renderer/src/features/canvas/components/shell/canvasActivityShellParts/TreeNode.tsx b/src/renderer/src/features/canvas/components/shell/canvasActivityShellParts/TreeNode.tsx index 9607cf30..f46e7cb6 100644 --- a/src/renderer/src/features/canvas/components/shell/canvasActivityShellParts/TreeNode.tsx +++ b/src/renderer/src/features/canvas/components/shell/canvasActivityShellParts/TreeNode.tsx @@ -43,12 +43,12 @@ export const TreeNode = memo(({ className={cn( "group flex h-7 items-center gap-1.5 rounded cursor-pointer text-xs transition-all duration-150", isSelected - ? "bg-active text-foreground font-semibold" - : "text-muted-foreground hover:bg-muted/40 hover:text-foreground", + ? "bg-active text-fg font-semibold" + : "text-muted hover:bg-muted/40 hover:text-fg", )} > {isFolder ? ( - + {isExpanded ? ( ) : ( @@ -59,7 +59,7 @@ export const TreeNode = memo(({ )} - + {node.type === "folder" ? ( isExpanded ? ( @@ -78,7 +78,7 @@ export const TreeNode = memo(({ {node.type === "canvas" && ( {t("canvas.activity.canvas")} diff --git a/src/renderer/src/features/canvas/components/viewport/BaseCanvasViewport.tsx b/src/renderer/src/features/canvas/components/viewport/BaseCanvasViewport.tsx index d65c06af..163e26d6 100644 --- a/src/renderer/src/features/canvas/components/viewport/BaseCanvasViewport.tsx +++ b/src/renderer/src/features/canvas/components/viewport/BaseCanvasViewport.tsx @@ -205,7 +205,7 @@ export default function BaseCanvasViewport({ gap={20} size={1.5} color="currentColor" - className="text-muted-foreground/25 dark:text-muted-foreground/35" + className="text-muted/25 dark:text-muted/35" /> {extraChildren} diff --git a/src/renderer/src/features/canvas/components/viewport/CanvasStatusBar.tsx b/src/renderer/src/features/canvas/components/viewport/CanvasStatusBar.tsx index 7d5813ac..ea8c04c8 100644 --- a/src/renderer/src/features/canvas/components/viewport/CanvasStatusBar.tsx +++ b/src/renderer/src/features/canvas/components/viewport/CanvasStatusBar.tsx @@ -38,11 +38,11 @@ export default function CanvasStatusBar({ projection }: CanvasStatusBarProps) { return (
- + {t(CANVAS_MODE_I18N[mode] ?? CANVAS_MODE_I18N["flow-map"])} diff --git a/src/renderer/src/features/editor/components/Editor.tsx b/src/renderer/src/features/editor/components/Editor.tsx index bb4b1be5..d75eafd8 100644 --- a/src/renderer/src/features/editor/components/Editor.tsx +++ b/src/renderer/src/features/editor/components/Editor.tsx @@ -323,7 +323,7 @@ function Editor({ return (