Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions apps/desktop/src/main/__tests__/icon-system-contract.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/renderer/maka-tokens.css
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 5 additions & 5 deletions apps/desktop/src/renderer/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
}
Expand All @@ -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;
Expand Down
18 changes: 18 additions & 0 deletions docs/design-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -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="..."]`:
Expand Down
2 changes: 1 addition & 1 deletion packages/ui/src/ui.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down