feat(canvas): 设计token系统 - 建立三层架构骨架 (#101)

* feat(canvas): 设计token系统 - 建立三层架构骨架 #94 #96

建立 Primitive→Semantic→Component 三层设计 token 体系,覆盖 9 个维度:
色彩/间距/字号/圆角/阴影/Elevation/动效/描边/不透明度。

- Primitive 层:纯值无语义,含 neutral 色板+节点类型色板+状态色、
  4px 栅格间距、10档字号、10档圆角、阴影+glow、12档 z-index、
  动效(duration+easing+delay+motion-scale)、描边、不透明度
- Semantic 层:引用 Primitive,随主题切换,含通用语义色、
  Canvas 语义(--cn-*)、Aceternity spatial 语义、节点类型语义、状态语义
- Component 层:引用 Semantic,含 Dock/Node/Modal/Panel/Prompt面板/
  进度条/缩放控制专属 token
- @theme inline 映射:节点类型色、状态色、Canvas 语义色、字号映射到 Tailwind utility
- prefers-reduced-motion 三级降级(L0正常/L1简化/L2禁用)
- AGENTS.md 新增 Design Token 使用规范,禁止新增裸 px 值
- 附带修复 canvas-drawing-excalidraw-editor.tsx TS7006 类型注解
- 附带修复 canvas-drawing-editor-modal.tsx TS2339 engine 字段缺失

Wayfinder 决策来源:#95 #96 #97 #98 #99

* fix(canvas): 设计token系统 - 修复CSS变量循环引用和命名兼容性 #94 #96

修复两个问题:

1. CSS变量循环引用:Primitive层 --color-node-*/--color-status-* 与
   @theme inline 中的 --color-node-*/--color-status-* 同名,经 Semantic
   层中转后形成循环(A→B→A)。改用 --palette- 前缀隔离 Primitive 色板,
   打断循环链路。

2. 变量名小数点兼容性:--space-0.5/1.5/2.5/3.5 中的小数点被部分 CSS
   linter 误判为类选择器。改为 --space-half/-1-half/-2-half/-3-half,
   保持语义清晰的同时提升工具链兼容性。

* refactor(canvas): 画布核心组件 - 迁移硬编码值到设计token #94

批次1:5个画布核心组件的裸 text-[Npx]/rounded-[Npx]/z-[N] 替换为
对应设计token,统一视觉语言:

- canvas-node.tsx:text-[10/11/9/17px]→fs-tiny/label/micro/heading-lg,
  rounded-[18px]→node-radius
- canvas-node-hover-toolbar.tsx:text-[11/17/10/9px]→fs-*,
  rounded-[14/9px]→dock-radius/dock-item-radius,z-[70]→z-panel-floating
- canvas-toolbar.tsx:text-[9/10/11px]→fs-*,
  rounded-[17/16/11/10px]→panel-radius/dock-item-radius-*,
  z-[30/40]→z-toolbar/z-toolbar-floating
- canvas-assistant-panel.tsx:text-[8/9/10/11px]→fs-*
- canvas-script-node.tsx:text-[9/10/11px]→fs-*

视觉零回归(token值与原硬编码值一致),代码可维护性提升。

* refactor(canvas): 画布辅助组件 - 迁移硬编码值到设计token #94

批次2:6个画布辅助组件的裸 text-[Npx]/rounded-[Npx]/z-[N] 替换为
对应设计token:

- canvas-style-picker-modal.tsx:text-[10/11px]→fs-tiny/label
- canvas-file-drop-overlay.tsx:z-[110]→z-modal-overlay,
  rounded-[28/20px]→r-5xl/r-3xl
- canvas-character-reference-node.tsx:text-[10px]→fs-tiny
- canvas-drawing-editor-modal.tsx:text-[11px]→fs-label
- canvas-video-prompt-tools.tsx:z-[1500]→z-toast,
  rounded-[12/10px]→r-lg/dock-item-radius
- asset-picker-modal.tsx:text-[10px]→fs-tiny

视觉零回归(token值与原硬编码值一致)。

* refactor(*): 全项目硬编码值 - 批量迁移到设计token #94

批次3:全项目 .tsx 文件中 372 处裸 text-[Npx]/rounded-[Npx] 批量
替换为对应设计token,剩余 25 处无直接 token 对应的低频值保留。

替换覆盖(按频率):
- text-[10px]→fs-tiny (132处)
- text-[11px]→fs-label (100处)
- text-[9px]→fs-micro (45处)
- text-[12px]→fs-caption, text-[13/14px]→fs-body, text-[15px]→fs-body-lg
- text-[18px]→fs-heading-lg, text-[22px]→fs-title, text-[8px]→fs-micro
- rounded-[9/10px]→dock-item-radius, rounded-[12px]→r-lg
- rounded-[14px]→dock-radius, rounded-[16px]→r-2xl
- rounded-[17px]→panel-radius, rounded-[8px]→r-md

涉及 components/ 和 pages/ 下所有 .tsx 文件。
视觉零回归(token值与原硬编码值一致)。

* feat(canvas): 节点视觉层次规范 - 色带+状态徽章+四态递进 #97 #94

基于 Wayfinder 地图 #97 决议执行节点视觉重设计:

1. 节点类型色带(决策1)
   - 新增 nodeTypeColorVar() 函数,10种节点类型对应色带色
   - 节点左侧3px色条,引用 --cn-type-* token
   - 远距离最强信号,用户扫视画布即可识别节点类型

2. 节点状态徽章(决策2)
   - 新增 NodeStatusBadge 组件,loading/success/error 三态
   - 左上角圆角徽章,backdrop-blur 玻璃质感
   - success 态 2s 后自动淡出(canvas-status-success-fade 动画)

3. 四态递进视觉权重(决策3)
   - idle:无额外阴影
   - hover:轻微抬升阴影(16px 48px)
   - selected:2px 边框环(accent.primary 40% 透明度)
   - selected-primary:4px 软色环 + resize handle
   - active:6px 外发光环 + inner glow(最强强调)
   - 120-150ms ease-out 过渡

4. 阴影迁移
   - boxShadow 从硬编码改为分层语义化表达
   - 边框色 hover 态使用 color-mix 动态加深

Closes #97

---------

Co-authored-by: sikongyue <3155157671@qq.com>
This commit is contained in:
爱笑的毛毛虫
2026-08-03 21:38:38 +08:00
committed by GitHub
parent d83ec033bb
commit b4d2b34fae
79 changed files with 844 additions and 400 deletions

View File

@@ -17,10 +17,10 @@ export function CanvasDirectorNodePanel({ node, scene, previewUrl, onOpen, profe
<span className="grid size-7 shrink-0 place-items-center rounded-md" style={{ background: theme.toolbar.itemHover }}><Clapperboard className="size-3.5" /></span>
<div className="min-w-0">
<div className="truncate text-sm font-semibold">{node.metadata?.workflowTitle || node.title}</div>
<div className="truncate text-[10px]" style={{ color: theme.node.muted }}>{shot?.name || "未设置镜头"}</div>
<div className="truncate text-[var(--fs-tiny)]" style={{ color: theme.node.muted }}>{shot?.name || "未设置镜头"}</div>
</div>
</div>
<span className="shrink-0 text-[10px] font-semibold" style={{ color: theme.accent.primary }}>3D</span>
<span className="shrink-0 text-[var(--fs-tiny)] font-semibold" style={{ color: theme.accent.primary }}>3D</span>
</div>
<button
@@ -38,7 +38,7 @@ export function CanvasDirectorNodePanel({ node, scene, previewUrl, onOpen, profe
<span className={`absolute inset-x-0 bottom-0 flex h-10 items-center justify-center gap-1.5 text-xs font-semibold backdrop-blur-sm transition-opacity ${professional ? "opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100" : "opacity-100"}`} style={{ background: `${theme.toolbar.panel}dd`, color: theme.node.text }}>{professional ? <><Move3d className="size-3.5" />进入导演台</> : <><LockKeyhole className="size-3.5" />专业模式可编辑</>}</span>
</button>
<div className="mt-2 grid grid-cols-3 gap-1 text-[10px]" style={{ color: theme.node.muted }}>
<div className="mt-2 grid grid-cols-3 gap-1 text-[var(--fs-tiny)]" style={{ color: theme.node.muted }}>
<Stat icon={<Box className="size-3" />} value={scene?.objects.length || 0} label="对象" />
<Stat icon={<Camera className="size-3" />} value={scene?.cameras.length || 0} label="机位" />
<Stat icon={<Lightbulb className="size-3" />} value={scene?.lights.length || 0} label="灯光" />
@@ -61,7 +61,7 @@ function SceneSchematic({ scene }: { scene: DirectorScene | null }) {
})}
<Camera className="absolute bottom-[14%] left-[12%] size-5 text-white/75" />
<span className="absolute left-[18%] top-[18%] size-16 rounded-full bg-white/10 blur-xl" />
<span className="absolute inset-x-3 top-3 text-[10px] font-medium text-white/70">{scene ? "场景预览" : "正在准备场景"}</span>
<span className="absolute inset-x-3 top-3 text-[var(--fs-tiny)] font-medium text-white/70">{scene ? "场景预览" : "正在准备场景"}</span>
</div>
);
}

View File

@@ -238,7 +238,7 @@ export function CanvasDirectorWorkbench({ open, scene, imageNodes, onClose, onCh
<main className="relative min-h-0 overflow-hidden bg-neutral-900">
<DirectorViewport ref={viewportRef} scene={draft} selectedObjectId={selectedObjectId} transformMode={transformMode} renderMode={renderMode} playhead={playhead} onSelectObject={setSelectedObjectId} onObjectTransform={(id, transform) => updateObject(id, { transform })} />
<div className="pointer-events-none absolute left-3 top-3 text-[10px] font-medium text-white/70">{activeShot.name} · {activeCamera?.name || "无摄影机"} · {activeShot.duration}s</div>
<div className="pointer-events-none absolute left-3 top-3 text-[var(--fs-tiny)] font-medium text-white/70">{activeShot.name} · {activeCamera?.name || "无摄影机"} · {activeShot.duration}s</div>
</main>
<aside className="thin-scrollbar min-h-0 overflow-y-auto border-l max-lg:hidden" style={{ background: theme.node.panel, borderColor: theme.toolbar.border }}>
@@ -249,12 +249,12 @@ export function CanvasDirectorWorkbench({ open, scene, imageNodes, onClose, onCh
<footer className="shrink-0 border-t px-3 py-2" style={{ background: theme.toolbar.panel, borderColor: theme.toolbar.border }}>
<div className="flex items-center gap-2">
<IconButton label={playing ? "暂停" : "播放"} onClick={() => setPlaying(!playing)}>{playing ? <Pause className="size-4" /> : <Play className="size-4" />}</IconButton>
<span className="w-12 text-right text-[11px] tabular-nums">{playhead.toFixed(1)}s</span>
<span className="w-12 text-right text-[var(--fs-label)] tabular-nums">{playhead.toFixed(1)}s</span>
<Slider className="min-w-0 flex-1" min={0} max={activeShot.duration} step={0.05} value={playhead} onChange={setPlayhead} />
<Button size="small" type="text" icon={<Focus className="size-3.5" />} onClick={selectedObject ? addObjectKeyframe : addCameraKeyframe}>记录关键帧</Button>
</div>
<div className="mt-1.5 flex gap-1 overflow-x-auto">
{draft.shots.map((shot, index) => <button key={shot.id} type="button" className="h-8 min-w-28 shrink-0 border-l-2 px-2 text-left text-[11px] transition" style={{ borderColor: draft.activeShotId === shot.id ? theme.node.activeStroke : theme.toolbar.border, background: draft.activeShotId === shot.id ? theme.toolbar.itemHover : "transparent" }} onClick={() => { commit((current) => ({ ...current, activeShotId: shot.id })); setPlayhead(0); }}><span className="block truncate">{index + 1}. {shot.name}</span><span className="block opacity-45">{shot.duration}s</span></button>)}
{draft.shots.map((shot, index) => <button key={shot.id} type="button" className="h-8 min-w-28 shrink-0 border-l-2 px-2 text-left text-[var(--fs-label)] transition" style={{ borderColor: draft.activeShotId === shot.id ? theme.node.activeStroke : theme.toolbar.border, background: draft.activeShotId === shot.id ? theme.toolbar.itemHover : "transparent" }} onClick={() => { commit((current) => ({ ...current, activeShotId: shot.id })); setPlayhead(0); }}><span className="block truncate">{index + 1}. {shot.name}</span><span className="block opacity-45">{shot.duration}s</span></button>)}
<button type="button" className="grid h-8 w-9 shrink-0 place-items-center" title="新增镜头" onClick={addShot}><Plus className="size-4" /></button>
</div>
</footer>
@@ -263,7 +263,7 @@ export function CanvasDirectorWorkbench({ open, scene, imageNodes, onClose, onCh
}
function ObjectInspector({ object, playhead, onUpdate, onAddKeyframe, onDelete }: { object: DirectorObject; playhead: number; onUpdate: (patch: Partial<DirectorObject>) => void; onAddKeyframe: () => void; onDelete: () => void }) {
return <Inspector title={object.name} onTitleChange={(name) => onUpdate({ name })} onDelete={onDelete}><TransformFields transform={object.transform} onChange={(transform) => onUpdate({ transform })} /><Field label="颜色"><ColorPicker value={object.color} onChange={(_, color) => onUpdate({ color })} /></Field>{object.primitive === "character" ? <Field label="姿势"><Select className="w-full" value={object.pose} options={poseOptions} onChange={(pose) => onUpdate({ pose })} /></Field> : null}<Field label="可见"><Switch checked={object.visible} onChange={(visible) => onUpdate({ visible })} /></Field><Field label="投射阴影"><Switch checked={object.castShadow} onChange={(castShadow) => onUpdate({ castShadow })} /></Field><Button block icon={<Focus className="size-3.5" />} onClick={onAddKeyframe}>在 {playhead.toFixed(1)}s 记录关键帧</Button><div className="text-[10px] opacity-50">已记录 {object.keyframes.length} 个关键帧</div></Inspector>;
return <Inspector title={object.name} onTitleChange={(name) => onUpdate({ name })} onDelete={onDelete}><TransformFields transform={object.transform} onChange={(transform) => onUpdate({ transform })} /><Field label="颜色"><ColorPicker value={object.color} onChange={(_, color) => onUpdate({ color })} /></Field>{object.primitive === "character" ? <Field label="姿势"><Select className="w-full" value={object.pose} options={poseOptions} onChange={(pose) => onUpdate({ pose })} /></Field> : null}<Field label="可见"><Switch checked={object.visible} onChange={(visible) => onUpdate({ visible })} /></Field><Field label="投射阴影"><Switch checked={object.castShadow} onChange={(castShadow) => onUpdate({ castShadow })} /></Field><Button block icon={<Focus className="size-3.5" />} onClick={onAddKeyframe}>在 {playhead.toFixed(1)}s 记录关键帧</Button><div className="text-[var(--fs-tiny)] opacity-50">已记录 {object.keyframes.length} 个关键帧</div></Inspector>;
}
function LightInspector({ light, onUpdate, onDelete }: { light: DirectorLight; onUpdate: (patch: Partial<DirectorLight>) => void; onDelete: () => void }) {
@@ -286,10 +286,10 @@ function Vec3Field({ label, value, step = 0.1, onChange }: { label: string; valu
return <Field label={label}><div className="grid grid-cols-3 gap-1">{value.map((item, index) => <InputNumber key={index} className="w-full" size="small" step={step} value={Number(item.toFixed(2))} onChange={(next) => onChange(value.map((entry, itemIndex) => itemIndex === index ? next || 0 : entry) as DirectorVec3)} />)}</div></Field>;
}
function Field({ label, children }: { label: string; children: ReactNode }) { return <label className="block"><span className="mb-1 block text-[11px] opacity-55">{label}</span>{children}</label>; }
function PanelTitle({ title, action }: { title: string; action?: ReactNode }) { return <div className="flex h-9 items-center px-3 text-[10px] font-semibold uppercase opacity-55"><span className="flex-1">{title}</span>{action}</div>; }
function Field({ label, children }: { label: string; children: ReactNode }) { return <label className="block"><span className="mb-1 block text-[var(--fs-label)] opacity-55">{label}</span>{children}</label>; }
function PanelTitle({ title, action }: { title: string; action?: ReactNode }) { return <div className="flex h-9 items-center px-3 text-[var(--fs-tiny)] font-semibold uppercase opacity-55"><span className="flex-1">{title}</span>{action}</div>; }
function SceneRow({ active, icon, label, onClick }: { active?: boolean; icon: ReactElement; label: string; onClick: () => void }) { return <button type="button" className={`flex h-8 w-full items-center gap-2 px-2 text-left text-xs transition ${active ? "bg-black/10 dark:bg-white/10" : "hover:bg-black/5 dark:hover:bg-white/5"}`} onClick={onClick}><span className="[&>svg]:size-3.5">{icon}</span><span className="truncate">{label}</span></button>; }
function QuickAdd({ label, icon, onClick }: { label: string; icon: ReactElement; onClick: () => void }) { return <button type="button" className="flex h-8 items-center gap-1.5 border px-2 text-[10px] transition hover:bg-black/5 dark:hover:bg-white/5" onClick={onClick}><span className="[&>svg]:size-3.5">{icon}</span><span className="truncate">{label}</span></button>; }
function QuickAdd({ label, icon, onClick }: { label: string; icon: ReactElement; onClick: () => void }) { return <button type="button" className="flex h-8 items-center gap-1.5 border px-2 text-[var(--fs-tiny)] transition hover:bg-black/5 dark:hover:bg-white/5" onClick={onClick}><span className="[&>svg]:size-3.5">{icon}</span><span className="truncate">{label}</span></button>; }
function IconButton({ label, disabled, children, onClick }: { label: string; disabled?: boolean; children: ReactNode; onClick: () => void }) { return <button type="button" aria-label={label} title={label} disabled={disabled} className="grid size-8 shrink-0 place-items-center rounded-md transition hover:bg-black/5 disabled:opacity-30 dark:hover:bg-white/10" onClick={onClick}>{children}</button>; }
function ToolButton({ label, active, children, onClick }: { label: string; active: boolean; children: ReactNode; onClick: () => void }) { return <button type="button" aria-label={label} title={label} className={`grid size-8 place-items-center rounded-md transition ${active ? "bg-black text-white dark:bg-white dark:text-black" : "hover:bg-black/5 dark:hover:bg-white/10"}`} onClick={onClick}>{children}</button>; }