diff --git a/apps/desktop/src/main/__tests__/icon-system-contract.test.ts b/apps/desktop/src/main/__tests__/icon-system-contract.test.ts new file mode 100644 index 00000000000..6a6d9ea5037 --- /dev/null +++ b/apps/desktop/src/main/__tests__/icon-system-contract.test.ts @@ -0,0 +1,70 @@ +import { strict as assert } from 'node:assert'; +import { readFile } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; +import { describe, it } from 'node:test'; + +// Chrome icon-size contract (PR-ICON-SCALE). +// +// The hand-rolled desktop chrome (left-nav glyphs + the `buttonVariants` button +// icon) shares ONE size, expressed as the `--icon-size` token, so nav and +// buttons can't drift apart again (the original defect: nav at 18px while +// buttons sat at size-4/1rem). Deliberately small dense-meta icons (12-14px) +// and large hero/emphasis icons (20px+) stay set at their call sites — they are +// intentional, not drift, so this contract does NOT flatten them, and it +// explicitly forbids reintroducing a blanket `svg.lucide { width }` rule that +// would silently resize every icon in the app. + +const rendererDir = join(process.cwd(), 'src', 'renderer'); +const repoRoot = resolve(process.cwd(), '..', '..'); + +function ruleBody(css: string, selector: string): string | undefined { + const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return css.match(new RegExp(`(?:^|\\n)${escaped}\\s*\\{([^}]*)\\}`))?.[1]; +} + +describe('icon system contract (single chrome size token)', () => { + it('defines --icon-size as the chrome glyph token', async () => { + const tokens = await readFile(join(rendererDir, 'maka-tokens.css'), 'utf8'); + assert.match(tokens, /--icon-size:\s*16px/, 'chrome icon size token = 16px'); + }); + + it('routes nav glyphs through the token, not hardcoded px', async () => { + const styles = await readFile(join(rendererDir, 'styles.css'), 'utf8'); + const navIcon = ruleBody(styles, '.maka-nav-icon'); + const navPrimary = ruleBody(styles, '.maka-nav-primary-icon'); + const navRow = ruleBody(styles, '.maka-nav-row'); + assert.ok(navIcon && navPrimary && navRow, 'nav rules must exist'); + assert.match(navIcon, /width:\s*var\(--icon-size\)/); + assert.match(navIcon, /height:\s*var\(--icon-size\)/); + assert.doesNotMatch(navIcon, /\b(width|height):\s*\d+px/, '.maka-nav-icon must not re-hardcode px'); + assert.match(navPrimary, /width:\s*var\(--icon-size\)/); + assert.match(navPrimary, /height:\s*var\(--icon-size\)/); + assert.doesNotMatch(navPrimary, /\b(width|height):\s*\d+px/, '.maka-nav-primary-icon must not re-hardcode px'); + assert.match(navRow, /grid-template-columns:\s*var\(--icon-size\)/, 'nav-row glyph column tracks the token'); + }); + + it('routes the shared button icon through the same token', async () => { + const ui = await readFile(join(repoRoot, 'packages', 'ui', 'src', 'ui.tsx'), 'utf8'); + assert.match( + ui, + /\[&_svg\]:size-\[var\(--icon-size,1rem\)\]/, + 'buttonVariants svg size must consume --icon-size (1rem fallback keeps @maka/ui standalone)', + ); + }); + + it('forbids a blanket svg.lucide size rule (no app-wide resize hammer)', async () => { + const styles = await readFile(join(rendererDir, 'styles.css'), 'utf8'); + const blanket = ruleBody(styles, 'svg.lucide'); + assert.ok( + !blanket || !/\b(width|height)\s*:/.test(blanket), + 'a global `svg.lucide { width/height }` rule would silently resize every ' + + 'icon, including intentional 11-14px dense glyphs — it must not exist', + ); + }); + + it('registers the iconography contract in design-system.md', async () => { + const doc = await readFile(join(repoRoot, 'docs', 'design-system.md'), 'utf8'); + assert.match(doc, /###\s*1\.9\s*图标/, 'design-system.md must carry §1.9 图标'); + assert.match(doc, /--icon-size\b/, 'doc must register the icon-size token'); + }); +}); diff --git a/apps/desktop/src/main/__tests__/localized-main-shell-contract.test.ts b/apps/desktop/src/main/__tests__/localized-main-shell-contract.test.ts index 9d3c330aafc..14842214726 100644 --- a/apps/desktop/src/main/__tests__/localized-main-shell-contract.test.ts +++ b/apps/desktop/src/main/__tests__/localized-main-shell-contract.test.ts @@ -556,7 +556,7 @@ describe('localized main shell contract', () => { // top-actions — references the one var(--maka-titlebar-control-safe-top), // with its own per-container correction constant (not asserted). assert.match(sidebarTopBar, /padding-top:\s*calc\(var\(--maka-titlebar-control-safe-top\) - \d+px\)/); - assert.match(styles, /(?:^|\n)\.maka-nav-icon\s*\{[\s\S]*?width:\s*18px[\s\S]*?height:\s*18px/); + // nav-icon sizing is owned by icon-system-contract.test.ts (the --icon-size token). assert.match(styles, /\.maka-sidebar-modules\b/); assert.doesNotMatch(styles, /\.maka-sidebar-module-hint\b/); assert.match(styles, /\.maka-nav-row\b/); diff --git a/apps/desktop/src/renderer/maka-tokens.css b/apps/desktop/src/renderer/maka-tokens.css index 4f07e926e67..539bb59546c 100644 --- a/apps/desktop/src/renderer/maka-tokens.css +++ b/apps/desktop/src/renderer/maka-tokens.css @@ -210,6 +210,8 @@ --radius-button: 6px; /* buttons get a small radius for tactility */ --radius-modal: 8px; --spacing: 0.25rem; /* 4px base step */ + --icon-size: 16px; /* chrome glyph size (nav + button icons); dense + meta (12-14) and hero (20+) stay call-site */ /* === motion === Custom easing curves per emil-design-eng — the built-in CSS easings diff --git a/apps/desktop/src/renderer/styles.css b/apps/desktop/src/renderer/styles.css index 4032c9f484b..1c44f3bd1c6 100644 --- a/apps/desktop/src/renderer/styles.css +++ b/apps/desktop/src/renderer/styles.css @@ -892,8 +892,8 @@ button:active { } .maka-nav-primary-icon { - width: 15px; - height: 15px; + width: var(--icon-size); + height: var(--icon-size); color: currentColor; flex: 0 0 auto; } @@ -921,8 +921,8 @@ button:active { } .maka-nav-icon { - width: 18px; - height: 18px; + width: var(--icon-size); + height: var(--icon-size); color: var(--foreground-50); justify-self: center; } @@ -947,7 +947,7 @@ button:active { margin-inline: 6px; min-height: 0; display: grid; - grid-template-columns: 18px minmax(0, 1fr) auto; + grid-template-columns: var(--icon-size) minmax(0, 1fr) auto; align-items: center; gap: 7px; border: 0; diff --git a/docs/design-system.md b/docs/design-system.md index 1aed09be968..75d0d13f046 100644 --- a/docs/design-system.md +++ b/docs/design-system.md @@ -216,6 +216,24 @@ background / foreground / accent (purple) / info (amber) / success (green) / des --- +### 1.9 图标(Iconography) + +图标用 `lucide-react`,按**语境分档**,不是全局统一一个尺寸。 + +| 档 | 尺寸 | 设定方式 | 用途 | +|---|---|---|---| +| chrome | `--icon-size`(16px) | token | 左侧 nav 图标 + `buttonVariants` 按钮内图标 | +| 密集 meta | 12–14px | call-site `size={…}` | 状态点、footer 操作、chip、与小字号同行的内联图标 | +| 强调 / hero | 20px+ | call-site `size={…}` | onboarding、错误页、settings about、permission 等单个语义图标 | + +规则: + +- **chrome 是唯一被 token 化的档。** nav 图标(`.maka-nav-icon` / `.maka-nav-primary-icon`)和 `packages/ui/src/ui.tsx` 的 `buttonVariants`(`[&_svg]:size-[var(--icon-size,1rem)]`)都消费 `--icon-size`,所以两者不会再各走各的(历史缺陷:nav 18px 而按钮 1rem)。改这一个 token 同时移动两者。 +- **密集 / hero 是 call-site 刻意值,不收敛。** 它们是有意的视觉层级,不是漂移;不要用全局 `svg.lucide { width }` 之类的规则一锅端,那会把 11–14px 的小图标静默放大、把 hero 压小。 +- `packages/ui/src/primitives/*` 是另一套组件库,有自己的响应式图标尺寸(`size-4.5`/`size-4`),不归本节管。 + +--- + ## 2. 密度契约(Density contract) 三档密度对应 `:root[data-ui-density="..."]`: diff --git a/packages/ui/src/ui.tsx b/packages/ui/src/ui.tsx index ac718b57a0f..d9753e8c56d 100644 --- a/packages/ui/src/ui.tsx +++ b/packages/ui/src/ui.tsx @@ -31,7 +31,7 @@ export const buttonVariants = cva( 'transition-[background,border-color,box-shadow,transform,opacity] duration-150 ease-[var(--ease-maka)]', 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background', 'active:scale-[0.98] disabled:pointer-events-none disabled:opacity-45', - '[&_svg]:size-4 [&_svg]:shrink-0', + '[&_svg]:size-[var(--icon-size,1rem)] [&_svg]:shrink-0', ], { variants: {