diff --git a/AGENTS.md b/AGENTS.md index a760a3f5..a8952190 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,6 +12,14 @@ before live use. Files marked `FOUNDATION` require explicit human guidance before editing and stricter review. Escalate needed changes rather than editing without authorization. +## Design-system instruction discovery + +Before any UI styling, component, token, appearance or viewer work anywhere in +this repository, read [the canonical design-system rules](src/shared/design-system/AGENTS.md). +This includes feature consumers, `tests/fixtures/design-system/`, its HTML entry, +and `scripts/design-system/`; directory inheritance alone does not cover them. +Follow its decision-specific references before changing a design decision. + ## Engineering standard Before editing, state the intended outcome and non-goals. Read the owning code, diff --git a/package.json b/package.json index 2532f3c1..ac2dfbd5 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "design:build": "pnpm design:typecheck && vite build --config vite.design.config.ts", "design:preview": "vite preview --config vite.design.config.ts", "design:typecheck": "tsc -p tsconfig.design.json", - "design:check": "node scripts/design-system/check-type.mjs && node scripts/design-system/check-color.mjs && node scripts/design-system/check-contrast.mjs", + "design:check": "node scripts/design-system/check-type.mjs && node scripts/design-system/check-color.mjs && node scripts/design-system/check-contrast.mjs && node scripts/design-system/check-authoring.mjs", "design:census": "node scripts/design-system/token-consumers.mjs", "design:test": "vitest run --config vitest.design.config.ts", "design:test:browser": "pnpm build && pnpm design:build && playwright test --config tests/fixtures/design-system/playwright.config.ts" diff --git a/scripts/design-system/AGENTS.md b/scripts/design-system/AGENTS.md new file mode 100644 index 00000000..8a6c98d4 --- /dev/null +++ b/scripts/design-system/AGENTS.md @@ -0,0 +1,5 @@ +# Design-system tooling instructions + +Read [the canonical design-system rules](../../src/shared/design-system/AGENTS.md) +before changing enforcement. Checks verify mechanics and recorded decisions, +not whether human design judgment is correct. diff --git a/scripts/design-system/authoring-baseline.json b/scripts/design-system/authoring-baseline.json new file mode 100644 index 00000000..678a95cb --- /dev/null +++ b/scripts/design-system/authoring-baseline.json @@ -0,0 +1,2018 @@ +[ + { + "file": "src/app/AppearanceSettings.tsx", + "rule": "local-type", + "evidence": "h2 | text-lg", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/app/AppearanceSettings.tsx", + "rule": "local-type", + "evidence": "h2 | font-medium", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/app/AppearanceSettings.tsx", + "rule": "local-type", + "evidence": "legend | text-base", + "count": 2, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/app/AppearanceSettings.tsx", + "rule": "local-type", + "evidence": "legend | font-medium", + "count": 2, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/app/AppearanceSettings.tsx", + "rule": "local-type", + "evidence": "p | text-sm", + "count": 2, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/app/AppearanceSettings.tsx", + "rule": "local-type", + "evidence": "span | font-medium", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/app/NotificationSettings.tsx", + "rule": "local-type", + "evidence": "h2 | text-lg", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/app/NotificationSettings.tsx", + "rule": "local-type", + "evidence": "h2 | font-medium", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/app/NotificationSettings.tsx", + "rule": "local-type", + "evidence": "p | text-sm", + "count": 5, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/app/NotificationSettings.tsx", + "rule": "local-type", + "evidence": "legend | font-medium", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/app/PluginImport.tsx", + "rule": "local-type", + "evidence": "p | text-sm", + "count": 2, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/app/PluginImport.tsx", + "rule": "local-type", + "evidence": "label | text-sm", + "count": 2, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/app/PluginImport.tsx", + "rule": "local-type", + "evidence": "p | text-xs", + "count": 3, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/app/PluginImport.tsx", + "rule": "local-type", + "evidence": "div | text-sm", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/app/PluginImport.tsx", + "rule": "local-type", + "evidence": "p | font-medium", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/app/PluginImport.tsx", + "rule": "local-type", + "evidence": "legend | text-sm", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/app/PluginImport.tsx", + "rule": "local-type", + "evidence": "legend | font-medium", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/app/PluginImport.tsx", + "rule": "local-type", + "evidence": "span | text-sm", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/app/PluginImport.tsx", + "rule": "local-type", + "evidence": "span | font-medium", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/app/PluginImport.tsx", + "rule": "local-type", + "evidence": "details | text-sm", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/app/ProfileSettings.tsx", + "rule": "local-type", + "evidence": "h2 | text-lg", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/app/ProfileSettings.tsx", + "rule": "local-type", + "evidence": "h2 | font-medium", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/app/ProfileSettings.tsx", + "rule": "color-opacity", + "evidence": "div | border-shell-edge/80", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/app/ProfileSettings.tsx", + "rule": "local-type", + "evidence": "p | text-sm", + "count": 2, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/app/Settings.tsx", + "rule": "color-opacity", + "evidence": "span | bg-surface/70", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/app/Settings.tsx", + "rule": "local-type", + "evidence": "h1 | text-3xl", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/app/Settings.tsx", + "rule": "local-type", + "evidence": "h1 | font-medium", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/app/Settings.tsx", + "rule": "local-type", + "evidence": "h1 | tracking-tight", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/app/Settings.tsx", + "rule": "color-opacity", + "evidence": "nav | border-shell-edge/80", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/app/Settings.tsx", + "rule": "color-opacity", + "evidence": "nav | bg-surface/60", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/app/Settings.tsx", + "rule": "color-opacity", + "evidence": "button | hover:bg-surface/70", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/app/Settings.tsx", + "rule": "local-type", + "evidence": "h2 | text-lg", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/app/Settings.tsx", + "rule": "local-type", + "evidence": "h2 | font-medium", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/app/Settings.tsx", + "rule": "color-opacity", + "evidence": "div | border-shell-edge/80", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/app/Settings.tsx", + "rule": "local-type", + "evidence": "h3 | font-medium", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/app/shell/Home.tsx", + "rule": "local-type", + "evidence": "p | text-sm", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/app/shell/Home.tsx", + "rule": "local-type", + "evidence": "h1 | text-4xl", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/app/shell/Home.tsx", + "rule": "local-type", + "evidence": "h1 | font-medium", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/app/shell/Home.tsx", + "rule": "local-type", + "evidence": "h1 | tracking-tight", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/app/shell/Home.tsx", + "rule": "local-type", + "evidence": "h1 | sm:text-5xl", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/app/shell/Home.tsx", + "rule": "color-opacity", + "evidence": "div | border-shell-edge/70", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/app/shell/Home.tsx", + "rule": "color-opacity", + "evidence": "div | bg-surface/95", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/app/shell/Home.tsx", + "rule": "local-type", + "evidence": "span | text-base", + "count": 2, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/app/shell/Home.tsx", + "rule": "local-type", + "evidence": "span | font-medium", + "count": 2, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/app/shell/Home.tsx", + "rule": "local-type", + "evidence": "span | text-sm", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/app/shell/PageSearch.tsx", + "rule": "local-type", + "evidence": "input | text-sm", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/app/shell/PageSearch.tsx", + "rule": "local-type", + "evidence": "p | text-sm", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/app/shell/ProfileButton.tsx", + "rule": "color-opacity", + "evidence": "button | bg-surface/65", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/app/shell/ProfileButton.tsx", + "rule": "local-type", + "evidence": "p | text-sm", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/app/shell/ProfileButton.tsx", + "rule": "local-type", + "evidence": "p | font-medium", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/bundled/bestie/index.tsx", + "rule": "local-type", + "evidence": "h2 | text-lg", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/bundled/bestie/index.tsx", + "rule": "local-type", + "evidence": "p | text-sm", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/bundled/channels/Channels.module.css", + "rule": "local-type", + "evidence": ".root | font-size: calc(14px * var(--buzz-text-scale, 1))", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/bundled/channels/Channels.module.css", + "rule": "local-type", + "evidence": ".sidebar .unreadEdge | font-size: calc(12px * var(--buzz-text-scale, 1))", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/bundled/channels/Channels.module.css", + "rule": "local-type", + "evidence": ".channelSection > summary | font-size: calc(12px * var(--buzz-text-scale, 1))", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/bundled/channels/Channels.module.css", + "rule": "local-type", + "evidence": ".channelList button[aria-current=\"page\"] | font-weight: 550", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/bundled/channels/Channels.module.css", + "rule": "local-type", + "evidence": ".preferenceNotice | font-size: calc(12px * var(--buzz-text-scale, 1))", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/bundled/channels/Channels.module.css", + "rule": "local-type", + "evidence": ".heading button:not(:where([data-buzz-ui], [data-buzz-ui] *)) | font-size: calc(12px * var(--buzz-text-scale, 1))", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/bundled/channels/Channels.module.css", + "rule": "local-type", + "evidence": ".empty | font-size: calc(13px * var(--buzz-text-scale, 1))", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/bundled/channels/Channels.module.css", + "rule": "local-color", + "evidence": ".connect | background: color-mix(in srgb, var(--surface) 79%, transparent)", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/bundled/channels/Channels.module.css", + "rule": "local-type", + "evidence": ".connect h1 | font-size: calc(28px * var(--buzz-text-scale, 1))", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/bundled/channels/Channels.module.css", + "rule": "local-type", + "evidence": ".note | font-size: calc(12px * var(--buzz-text-scale, 1))", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/bundled/channels/Channels.module.css", + "rule": "local-type", + "evidence": "@media (max-width: 650px) > .channelList button | font-size: calc(12px * var(--buzz-text-scale, 1))", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/bundled/channels/Channels.module.css", + "rule": "local-type", + "evidence": "@media (max-width: 650px) > .roster | font-size: calc(10px * var(--buzz-text-scale, 1))", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/bundled/channels/Channels.module.css", + "rule": "local-type", + "evidence": ".diagnosticsMenu | font-size: calc(12px * var(--buzz-text-scale, 1))", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/bundled/channels/Channels.module.css", + "rule": "local-type", + "evidence": ".liveStatus | font-size: calc(12px * var(--buzz-text-scale, 1))", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/bundled/channels/Channels.module.css", + "rule": "local-type", + "evidence": ".unreadBadge | font-size: 0.75rem", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/bundled/channels/Channels.module.css", + "rule": "local-type", + "evidence": ".unreadBadge | font-weight: 650", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/bundled/emoji/Emoji.module.css", + "rule": "local-color", + "evidence": ".emojiPopover | --picker-search-background: #f5f5f6", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/bundled/emoji/Emoji.module.css", + "rule": "local-color", + "evidence": ".emojiPopover | --picker-search-foreground: #0a0a0a", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/bundled/emoji/Emoji.module.css", + "rule": "local-color", + "evidence": ".emojiPopover | --picker-search-muted: #8d8d8d", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/bundled/emoji/Emoji.module.css", + "rule": "local-color", + "evidence": ".emojiPopover | --picker-search-ring: #cecece", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/bundled/emoji/Emoji.module.css", + "rule": "local-color", + "evidence": ".emojiPopover | --picker-tab-foreground: #0a0a0a", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/bundled/emoji/Emoji.module.css", + "rule": "local-color", + "evidence": ".emojiPopover | --picker-tab-muted: #8d8d8d", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/bundled/emoji/Emoji.module.css", + "rule": "local-color", + "evidence": ".emojiPopover | --picker-tab-selected: #f0f0f0", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/bundled/emoji/Emoji.module.css", + "rule": "local-color", + "evidence": ".emojiPopover | --picker-scrollbar-thumb: #e8e8e8", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/bundled/emoji/Emoji.module.css", + "rule": "local-color", + "evidence": "[data-color-mode=\"dark\"] .emojiPopover | --picker-search-background: #161616", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/bundled/emoji/Emoji.module.css", + "rule": "mode-color", + "evidence": "[data-color-mode=\"dark\"] .emojiPopover | --picker-search-background: #161616", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/bundled/emoji/Emoji.module.css", + "rule": "local-color", + "evidence": "[data-color-mode=\"dark\"] .emojiPopover | --picker-search-foreground: #f5f5f5", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/bundled/emoji/Emoji.module.css", + "rule": "mode-color", + "evidence": "[data-color-mode=\"dark\"] .emojiPopover | --picker-search-foreground: #f5f5f5", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/bundled/emoji/Emoji.module.css", + "rule": "local-color", + "evidence": "[data-color-mode=\"dark\"] .emojiPopover | --picker-search-muted: #a6a6a6", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/bundled/emoji/Emoji.module.css", + "rule": "mode-color", + "evidence": "[data-color-mode=\"dark\"] .emojiPopover | --picker-search-muted: #a6a6a6", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/bundled/emoji/Emoji.module.css", + "rule": "local-color", + "evidence": "[data-color-mode=\"dark\"] .emojiPopover | --picker-search-ring: #424242", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/bundled/emoji/Emoji.module.css", + "rule": "mode-color", + "evidence": "[data-color-mode=\"dark\"] .emojiPopover | --picker-search-ring: #424242", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/bundled/emoji/Emoji.module.css", + "rule": "local-color", + "evidence": "[data-color-mode=\"dark\"] .emojiPopover | --picker-tab-foreground: #f5f5f5", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/bundled/emoji/Emoji.module.css", + "rule": "mode-color", + "evidence": "[data-color-mode=\"dark\"] .emojiPopover | --picker-tab-foreground: #f5f5f5", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/bundled/emoji/Emoji.module.css", + "rule": "local-color", + "evidence": "[data-color-mode=\"dark\"] .emojiPopover | --picker-tab-muted: #a6a6a6", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/bundled/emoji/Emoji.module.css", + "rule": "mode-color", + "evidence": "[data-color-mode=\"dark\"] .emojiPopover | --picker-tab-muted: #a6a6a6", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/bundled/emoji/Emoji.module.css", + "rule": "local-color", + "evidence": "[data-color-mode=\"dark\"] .emojiPopover | --picker-tab-selected: #1c1c1c", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/bundled/emoji/Emoji.module.css", + "rule": "mode-color", + "evidence": "[data-color-mode=\"dark\"] .emojiPopover | --picker-tab-selected: #1c1c1c", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/bundled/emoji/Emoji.module.css", + "rule": "local-color", + "evidence": "[data-color-mode=\"dark\"] .emojiPopover | --picker-scrollbar-thumb: #424242", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/bundled/emoji/Emoji.module.css", + "rule": "mode-color", + "evidence": "[data-color-mode=\"dark\"] .emojiPopover | --picker-scrollbar-thumb: #424242", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/bundled/emoji/Emoji.module.css", + "rule": "local-type", + "evidence": ".emojiStatus | font-size: calc(12px * var(--buzz-text-scale, 1))", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/bundled/emoji/Emoji.module.css", + "rule": "local-type", + "evidence": ".gifSearch input | font-size: calc(14px * var(--buzz-text-scale, 1))", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/bundled/emoji/Emoji.module.css", + "rule": "local-type", + "evidence": ".gifAttribution | font-size: calc(11px * var(--buzz-text-scale, 1))", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/bundled/github/GitHub.module.css", + "rule": "local-type", + "evidence": ".root | font-size: calc(13px * var(--buzz-text-scale, 1))", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/bundled/github/GitHub.module.css", + "rule": "local-type", + "evidence": ".identity small,\n.byline,\n.note | font-size: calc(11px * var(--buzz-text-scale, 1))", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/bundled/github/GitHub.module.css", + "rule": "local-type", + "evidence": ".root h2 | font-size: calc(18px * var(--buzz-text-scale, 1))", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/bundled/mentions/MentionCompletion.tsx", + "rule": "local-type", + "evidence": "Avatar | text-xs", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/bundled/mentions/MentionPicker.tsx", + "rule": "local-type", + "evidence": "Avatar | text-xs", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/bundled/mentions/Mentions.module.css", + "rule": "local-type", + "evidence": ".mentionPopover p | font-size: calc(12px * var(--buzz-text-scale, 1))", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/bundled/mentions/Mentions.module.css", + "rule": "local-type", + "evidence": ".mentionChoices code | font-size: calc(10px * var(--buzz-text-scale, 1))", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/bundled/mentions/Mentions.module.css", + "rule": "local-type", + "evidence": ".mentionLabel > span | font-size: calc(13px * var(--buzz-text-scale, 1))", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/bundled/mentions/Mentions.module.css", + "rule": "local-type", + "evidence": ".mentionLabel > span | font-weight: 500", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/bundled/projects/index.tsx", + "rule": "local-type", + "evidence": "h1 | text-3xl", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/bundled/projects/index.tsx", + "rule": "local-type", + "evidence": "h1 | font-medium", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/bundled/projects/index.tsx", + "rule": "local-type", + "evidence": "h1 | tracking-tight", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/bundled/terminal/Terminal.module.css", + "rule": "local-color", + "evidence": ".splashArt [data-layer=\"head\"] | color: oklch(var(--splash-lightness) var(--splash-chroma) var(--splash-hue))", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/bundled/terminal/Terminal.module.css", + "rule": "local-color", + "evidence": ".splashArt [data-layer=\"bevel_hi\"] | color: oklch(var(--splash-lightness) 0.12 var(--splash-hue))", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/bundled/terminal/Terminal.module.css", + "rule": "local-color", + "evidence": ".splashArt [data-layer=\"field\"] | color: color-mix(\n in srgb,\n oklch(var(--splash-lightness) var(--splash-chroma) var(--splash-hue)) 55%,\n var(--bg-panel)\n )", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/features/communities/Communities.module.css", + "rule": "local-color", + "evidence": ".switcher | border: 1px solid color-mix(in srgb, var(--shell-edge) 56%, transparent)", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/features/communities/Communities.module.css", + "rule": "local-color", + "evidence": ".switcher | background: color-mix(in srgb, var(--surface) 31%, transparent)", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/features/communities/Communities.module.css", + "rule": "local-type", + "evidence": ".switcher | font-size: calc(13px * var(--buzz-text-scale, 1))", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/features/communities/Communities.module.css", + "rule": "local-type", + "evidence": ".communityIcon | font-size: calc(11px * var(--buzz-text-scale, 1))", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/features/communities/Communities.module.css", + "rule": "local-type", + "evidence": ".dialog h2 | font-size: calc(1.25rem * var(--buzz-text-scale, 1))", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/features/communities/Communities.module.css", + "rule": "local-type", + "evidence": ".dialog h2 | font-weight: 650", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/features/communities/Communities.module.css", + "rule": "local-type", + "evidence": ".dialog p | font-size: calc(0.875rem * var(--buzz-text-scale, 1))", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/features/communities/Communities.module.css", + "rule": "local-type", + "evidence": ".dialog label:not(.check) | font-size: calc(0.875rem * var(--buzz-text-scale, 1))", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/features/communities/Communities.module.css", + "rule": "local-type", + "evidence": ".dialog label:not(.check) | font-weight: 550", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/features/communities/Communities.module.css", + "rule": "local-type", + "evidence": ".note | font-size: calc(0.8rem * var(--buzz-text-scale, 1))", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/features/communities/Communities.module.css", + "rule": "local-type", + "evidence": ".policy | font-size: calc(0.875rem * var(--buzz-text-scale, 1))", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/features/communities/ProfileFields.tsx", + "rule": "local-type", + "evidence": "label | text-sm", + "count": 2, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/features/communities/ProfileFields.tsx", + "rule": "local-type", + "evidence": "label | font-medium", + "count": 2, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/features/conversation/Completions.module.css", + "rule": "local-color", + "evidence": ".popup | box-shadow: 0 8px 30px #0002", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/features/conversation/Completions.module.css", + "rule": "local-color", + "evidence": ".popup[data-compact] | --completion-highlight: #f5f5f6", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/features/conversation/Completions.module.css", + "rule": "local-color", + "evidence": "[data-color-mode=\"dark\"] .popup[data-compact] | --completion-highlight: #161616", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/features/conversation/Completions.module.css", + "rule": "mode-color", + "evidence": "[data-color-mode=\"dark\"] .popup[data-compact] | --completion-highlight: #161616", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/features/conversation/Completions.module.css", + "rule": "local-type", + "evidence": ".preview | font-size: 24px", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/features/conversation/Completions.module.css", + "rule": "local-type", + "evidence": ".label small | font-size: 11px", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/features/conversation/Completions.module.css", + "rule": "local-type", + "evidence": ".popup p | font-size: 11px", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/features/messages/Messages.module.css", + "rule": "local-type", + "evidence": ".heading button | font-size: calc(12px * var(--buzz-text-scale, 1))", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/features/messages/Messages.module.css", + "rule": "local-type", + "evidence": ".edge | font-size: calc(11px * var(--buzz-text-scale, 1))", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/features/messages/Messages.module.css", + "rule": "local-type", + "evidence": ".edge button | font-size: calc(11px * var(--buzz-text-scale, 1))", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/features/messages/Messages.module.css", + "rule": "local-type", + "evidence": ".day | font-size: calc(12px * var(--buzz-text-scale, 1))", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/features/messages/Messages.module.css", + "rule": "local-type", + "evidence": ".avatar | font-size: calc(12px * var(--buzz-text-scale, 1))", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/features/messages/Messages.module.css", + "rule": "local-type", + "evidence": ".byline | font-size: calc(14px * var(--buzz-text-scale, 1))", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/features/messages/Messages.module.css", + "rule": "local-type", + "evidence": ".byline time | font-size: calc(12px * var(--buzz-text-scale, 1))", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/features/messages/Messages.module.css", + "rule": "local-type", + "evidence": ".text | font-size: calc(15px * var(--buzz-text-scale, 1))", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/features/messages/Messages.module.css", + "rule": "local-type", + "evidence": ".text[data-single-emoji] | font-size: calc(42px * var(--buzz-text-scale, 1))", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/features/messages/Messages.module.css", + "rule": "local-type", + "evidence": ".replies | font-size: calc(13px * var(--buzz-text-scale, 1))", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/features/messages/Messages.module.css", + "rule": "local-type", + "evidence": ".attachment | font-size: calc(12px * var(--buzz-text-scale, 1))", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/features/messages/Messages.module.css", + "rule": "local-type", + "evidence": ".composer | font-size: calc(14px * var(--buzz-text-scale, 1))", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/features/messages/Messages.module.css", + "rule": "local-type", + "evidence": ".empty | font-size: calc(13px * var(--buzz-text-scale, 1))", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/features/messages/Messages.module.css", + "rule": "local-type", + "evidence": ".composer textarea[data-single-emoji] | font-size: calc(42px * var(--buzz-text-scale, 1))", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/features/messages/Messages.module.css", + "rule": "local-type", + "evidence": ".composer textarea[data-custom-emoji-only] | font-size: 0", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/features/messages/Messages.module.css", + "rule": "local-color", + "evidence": ".composerCustomEmoji[data-selected] | outline: 2px solid color-mix(in srgb, var(--primary) 35%, transparent)", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/features/messages/Messages.module.css", + "rule": "local-type", + "evidence": ".composerHint | font-size: calc(11px * var(--buzz-text-scale, 1))", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/features/messages/Messages.module.css", + "rule": "local-type", + "evidence": ".delivery | font-size: calc(12px * var(--buzz-text-scale, 1))", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/features/messages/Messages.module.css", + "rule": "local-type", + "evidence": ".threadDivider | font-size: calc(12px * var(--buzz-text-scale, 1))", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/features/messages/Messages.module.css", + "rule": "local-type", + "evidence": ".threadHistoryControls button | font-size: calc(12px * var(--buzz-text-scale, 1))", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/features/messages/Messages.module.css", + "rule": "local-type", + "evidence": ".threadNote | font-size: calc(12px * var(--buzz-text-scale, 1))", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/features/messages/Messages.module.css", + "rule": "local-type", + "evidence": ".threadAvatar | font-size: calc(9px * var(--buzz-text-scale, 1))", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/features/messages/Messages.module.css", + "rule": "local-type", + "evidence": ".mentionRecipients | font-size: calc(12px * var(--buzz-text-scale, 1))", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/features/messages/Messages.module.css", + "rule": "local-type", + "evidence": ".mentionRecipients button | font-size: calc(12px * var(--buzz-text-scale, 1))", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/features/messages/Messages.module.css", + "rule": "local-type", + "evidence": ".mentionRecipients code | font-size: calc(10px * var(--buzz-text-scale, 1))", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/features/messages/Messages.module.css", + "rule": "local-type", + "evidence": ".text,\n.plainText | font-size: calc(15px * var(--buzz-text-scale, 1))", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/features/messages/Messages.module.css", + "rule": "local-type", + "evidence": ".text :where(h1, h2, h3, h4, h5, h6) | font-size: 1em", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/features/messages/Messages.module.css", + "rule": "local-type", + "evidence": ".text h1 | font-size: 1.35em", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/features/messages/Messages.module.css", + "rule": "local-type", + "evidence": ".text h2 | font-size: 1.2em", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/features/messages/Messages.module.css", + "rule": "local-type", + "evidence": ".text h3 | font-size: 1.08em", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/features/messages/Messages.module.css", + "rule": "local-type", + "evidence": ".text :where(h1, h2, h3) | font-weight: 700", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/features/messages/Messages.module.css", + "rule": "local-type", + "evidence": ".text :where(h4, h5, h6) | font-weight: 650", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/features/messages/Messages.module.css", + "rule": "local-type", + "evidence": ".text code | font-size: 0.88em", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/features/messages/Messages.module.css", + "rule": "local-type", + "evidence": ".text table | font-size: 0.92em", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/features/messages/Messages.module.css", + "rule": "local-type", + "evidence": ".text th | font-weight: 650", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/features/messages/Messages.module.css", + "rule": "local-type", + "evidence": ".membership | font-size: calc(13px * var(--buzz-text-scale, 1))", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/features/messages/Messages.module.css", + "rule": "local-type", + "evidence": ".membershipAvatar | font-size: calc(10px * var(--buzz-text-scale, 1))", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/features/panels/Panels.module.css", + "rule": "local-type", + "evidence": ".card | font-size: calc(14px * var(--buzz-text-scale, 1))", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/Avatar.tsx", + "rule": "local-color", + "evidence": "span | bg-[#ece9f4]", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/Avatar.tsx", + "rule": "local-color", + "evidence": "span | text-[#675780]", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/globals.css", + "rule": "local-type", + "evidence": "@layer base > body | font-size: calc(1rem * var(--buzz-text-scale, 1))", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/globals.css", + "rule": "local-type", + "evidence": "@layer base > button:not(:where([data-buzz-ui], [data-buzz-ui] *)) | font-size: calc(14px * var(--buzz-text-scale, 1))", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/globals.css", + "rule": "local-type", + "evidence": "@layer base > h1:not(:where([data-buzz-ui], [data-buzz-ui] *)) | font-size: clamp(\n calc(28px * var(--buzz-text-scale, 1)),\n calc(4vw * var(--buzz-text-scale, 1)),\n calc(40px * var(--buzz-text-scale, 1))\n )", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/globals.css", + "rule": "local-type", + "evidence": "@layer base > h1:not(:where([data-buzz-ui], [data-buzz-ui] *)) | letter-spacing: -1px", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/globals.css", + "rule": "local-type", + "evidence": "@layer base > h2:not(:where([data-buzz-ui], [data-buzz-ui] *)) | font-size: calc(22px * var(--buzz-text-scale, 1))", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/globals.css", + "rule": "local-type", + "evidence": "@layer base > h3:not(:where([data-buzz-ui], [data-buzz-ui] *)) | font-size: calc(17px * var(--buzz-text-scale, 1))", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/globals.css", + "rule": "local-type", + "evidence": "@layer base > body | letter-spacing: normal", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/globals.css", + "rule": "local-color", + "evidence": "@utility bg-primary-hover | background-color: color-mix(in oklab, var(--primary) 90%, transparent)", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "local-color", + "evidence": "@layer base > :root | --text: #242e31", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "local-color", + "evidence": "@layer base > :root | --text-muted: #626b70", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "local-color", + "evidence": "@layer base > :root | --surface: #ffffff", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "local-color", + "evidence": "@layer base > :root | --surface-accent: #f5f5f4", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "local-color", + "evidence": "@layer base > :root | --surface-hover: #eef1f2", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "local-color", + "evidence": "@layer base > :root | --surface-control: #f1f4f5", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "local-color", + "evidence": "@layer base > :root | --surface-input: #fafafa", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "local-color", + "evidence": "@layer base > :root | --surface-elevated: #fafbf8", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "local-color", + "evidence": "@layer base > :root | --border: #e8e9e7", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "local-color", + "evidence": "@layer base > :root | --border-input: #8a9498", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "local-color", + "evidence": "@layer base > :root | --accent: #313f43", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "local-color", + "evidence": "@layer base > :root | --primary: #313f43", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "local-color", + "evidence": "@layer base > :root | --on-primary: #ffffff", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "local-color", + "evidence": "@layer base > :root | --selected: #dfebf1", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "local-color", + "evidence": "@layer base > :root | --on-selected: #305c72", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "local-color", + "evidence": "@layer base > :root | --focus: #397c98", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "local-color", + "evidence": "@layer base > :root | --link: #36657d", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "local-color", + "evidence": "@layer base > :root | --action: #f3e8ff", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "local-color", + "evidence": "@layer base > :root | --on-action: #242e31", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "local-color", + "evidence": "@layer base > :root | --danger: #a12f27", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "local-color", + "evidence": "@layer base > :root | --warning: #80501e", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "local-color", + "evidence": "@layer base > :root | --warning-surface: #fff7da", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "local-color", + "evidence": "@layer base > :root | --warning-border: #d7b265", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "local-color", + "evidence": "@layer base > :root | --success: #276440", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "local-color", + "evidence": "@layer base > :root | --success-surface: #e5f3e9", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "local-color", + "evidence": "@layer base > :root | --workspace: #e7f0ef", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "local-color", + "evidence": "@layer base > :root | --shell-dot: #536d7929", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "local-color", + "evidence": "@layer base > :root | --shell-edge: #ffffff", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "local-color", + "evidence": "@layer base > :root | --overlay: #17261e60", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "local-color", + "evidence": "@layer base > :root | --toggle-track: #a0a7ab", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "local-color", + "evidence": "@layer base > :root | --toggle-thumb: #ffffff", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "local-color", + "evidence": "@layer base > :root | --elevation-card: 0 1px 4px #202c3214", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "local-color", + "evidence": "@layer base > :root | --elevation-surface: 0 1px 3px #202c3210, 0 8px 28px #202c3208", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "local-color", + "evidence": "@layer base > :root | --elevation-popover: 0 8px 32px #202c3220", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "local-color", + "evidence": "@layer base > :root | --elevation-dialog: 0 2rem 6rem #0003", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "local-color", + "evidence": "@layer base > :root | --elevation-dock: -12px 0 32px #253d4c14", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "local-color", + "evidence": "@layer base > :root[data-color-mode=\"dark\"] | --text: #e6edf0", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "mode-color", + "evidence": "@layer base > :root[data-color-mode=\"dark\"] | --text: #e6edf0", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "local-color", + "evidence": "@layer base > :root[data-color-mode=\"dark\"] | --text-muted: #aab8c0", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "mode-color", + "evidence": "@layer base > :root[data-color-mode=\"dark\"] | --text-muted: #aab8c0", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "local-color", + "evidence": "@layer base > :root[data-color-mode=\"dark\"] | --surface: #1b252b", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "mode-color", + "evidence": "@layer base > :root[data-color-mode=\"dark\"] | --surface: #1b252b", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "local-color", + "evidence": "@layer base > :root[data-color-mode=\"dark\"] | --surface-accent: #26333b", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "mode-color", + "evidence": "@layer base > :root[data-color-mode=\"dark\"] | --surface-accent: #26333b", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "local-color", + "evidence": "@layer base > :root[data-color-mode=\"dark\"] | --surface-hover: #30414b", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "mode-color", + "evidence": "@layer base > :root[data-color-mode=\"dark\"] | --surface-hover: #30414b", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "local-color", + "evidence": "@layer base > :root[data-color-mode=\"dark\"] | --surface-control: #293740", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "mode-color", + "evidence": "@layer base > :root[data-color-mode=\"dark\"] | --surface-control: #293740", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "local-color", + "evidence": "@layer base > :root[data-color-mode=\"dark\"] | --surface-input: #202c33", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "mode-color", + "evidence": "@layer base > :root[data-color-mode=\"dark\"] | --surface-input: #202c33", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "local-color", + "evidence": "@layer base > :root[data-color-mode=\"dark\"] | --surface-elevated: #233039", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "mode-color", + "evidence": "@layer base > :root[data-color-mode=\"dark\"] | --surface-elevated: #233039", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "local-color", + "evidence": "@layer base > :root[data-color-mode=\"dark\"] | --border: #384b56", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "mode-color", + "evidence": "@layer base > :root[data-color-mode=\"dark\"] | --border: #384b56", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "local-color", + "evidence": "@layer base > :root[data-color-mode=\"dark\"] | --border-input: #708791", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "mode-color", + "evidence": "@layer base > :root[data-color-mode=\"dark\"] | --border-input: #708791", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "local-color", + "evidence": "@layer base > :root[data-color-mode=\"dark\"] | --accent: #d6e3eb", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "mode-color", + "evidence": "@layer base > :root[data-color-mode=\"dark\"] | --accent: #d6e3eb", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "local-color", + "evidence": "@layer base > :root[data-color-mode=\"dark\"] | --primary: #d6e3eb", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "mode-color", + "evidence": "@layer base > :root[data-color-mode=\"dark\"] | --primary: #d6e3eb", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "local-color", + "evidence": "@layer base > :root[data-color-mode=\"dark\"] | --on-primary: #172229", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "mode-color", + "evidence": "@layer base > :root[data-color-mode=\"dark\"] | --on-primary: #172229", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "local-color", + "evidence": "@layer base > :root[data-color-mode=\"dark\"] | --selected: #304c5e", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "mode-color", + "evidence": "@layer base > :root[data-color-mode=\"dark\"] | --selected: #304c5e", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "local-color", + "evidence": "@layer base > :root[data-color-mode=\"dark\"] | --on-selected: #c5e6fc", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "mode-color", + "evidence": "@layer base > :root[data-color-mode=\"dark\"] | --on-selected: #c5e6fc", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "local-color", + "evidence": "@layer base > :root[data-color-mode=\"dark\"] | --focus: #98c7ed", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "mode-color", + "evidence": "@layer base > :root[data-color-mode=\"dark\"] | --focus: #98c7ed", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "local-color", + "evidence": "@layer base > :root[data-color-mode=\"dark\"] | --link: #a3d3f0", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "mode-color", + "evidence": "@layer base > :root[data-color-mode=\"dark\"] | --link: #a3d3f0", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "local-color", + "evidence": "@layer base > :root[data-color-mode=\"dark\"] | --action: #534467", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "mode-color", + "evidence": "@layer base > :root[data-color-mode=\"dark\"] | --action: #534467", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "local-color", + "evidence": "@layer base > :root[data-color-mode=\"dark\"] | --on-action: #f5eaff", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "mode-color", + "evidence": "@layer base > :root[data-color-mode=\"dark\"] | --on-action: #f5eaff", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "local-color", + "evidence": "@layer base > :root[data-color-mode=\"dark\"] | --danger: #ffaaa0", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "mode-color", + "evidence": "@layer base > :root[data-color-mode=\"dark\"] | --danger: #ffaaa0", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "local-color", + "evidence": "@layer base > :root[data-color-mode=\"dark\"] | --warning: #f5cf89", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "mode-color", + "evidence": "@layer base > :root[data-color-mode=\"dark\"] | --warning: #f5cf89", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "local-color", + "evidence": "@layer base > :root[data-color-mode=\"dark\"] | --warning-surface: #3d3222", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "mode-color", + "evidence": "@layer base > :root[data-color-mode=\"dark\"] | --warning-surface: #3d3222", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "local-color", + "evidence": "@layer base > :root[data-color-mode=\"dark\"] | --warning-border: #927444", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "mode-color", + "evidence": "@layer base > :root[data-color-mode=\"dark\"] | --warning-border: #927444", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "local-color", + "evidence": "@layer base > :root[data-color-mode=\"dark\"] | --success: #9cdbb0", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "mode-color", + "evidence": "@layer base > :root[data-color-mode=\"dark\"] | --success: #9cdbb0", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "local-color", + "evidence": "@layer base > :root[data-color-mode=\"dark\"] | --success-surface: #20392d", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "mode-color", + "evidence": "@layer base > :root[data-color-mode=\"dark\"] | --success-surface: #20392d", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "local-color", + "evidence": "@layer base > :root[data-color-mode=\"dark\"] | --workspace: #11181d", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "mode-color", + "evidence": "@layer base > :root[data-color-mode=\"dark\"] | --workspace: #11181d", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "local-color", + "evidence": "@layer base > :root[data-color-mode=\"dark\"] | --shell-image: radial-gradient(ellipse at 15% 0%, #203d53, transparent 65%),\n radial-gradient(ellipse at 90% 100%, #39304b, transparent 70%)", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "mode-color", + "evidence": "@layer base > :root[data-color-mode=\"dark\"] | --shell-image: radial-gradient(ellipse at 15% 0%, #203d53, transparent 65%),\n radial-gradient(ellipse at 90% 100%, #39304b, transparent 70%)", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "local-color", + "evidence": "@layer base > :root[data-color-mode=\"dark\"] | --shell-dot: #a7c8db24", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "mode-color", + "evidence": "@layer base > :root[data-color-mode=\"dark\"] | --shell-dot: #a7c8db24", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "local-color", + "evidence": "@layer base > :root[data-color-mode=\"dark\"] | --shell-edge: #43535f", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "mode-color", + "evidence": "@layer base > :root[data-color-mode=\"dark\"] | --shell-edge: #43535f", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "local-color", + "evidence": "@layer base > :root[data-color-mode=\"dark\"] | --overlay: #060c13a6", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "mode-color", + "evidence": "@layer base > :root[data-color-mode=\"dark\"] | --overlay: #060c13a6", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "local-color", + "evidence": "@layer base > :root[data-color-mode=\"dark\"] | --toggle-track: #708791", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "mode-color", + "evidence": "@layer base > :root[data-color-mode=\"dark\"] | --toggle-track: #708791", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "local-color", + "evidence": "@layer base > :root[data-color-mode=\"dark\"] | --toggle-thumb: #172229", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "mode-color", + "evidence": "@layer base > :root[data-color-mode=\"dark\"] | --toggle-thumb: #172229", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "local-color", + "evidence": "@layer base > :root[data-color-mode=\"dark\"] | --elevation-card: 0 1px 4px #0003", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "mode-color", + "evidence": "@layer base > :root[data-color-mode=\"dark\"] | --elevation-card: 0 1px 4px #0003", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "local-color", + "evidence": "@layer base > :root[data-color-mode=\"dark\"] | --elevation-surface: 0 1px 3px #0004, 0 8px 28px #0002", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "mode-color", + "evidence": "@layer base > :root[data-color-mode=\"dark\"] | --elevation-surface: 0 1px 3px #0004, 0 8px 28px #0002", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "local-color", + "evidence": "@layer base > :root[data-color-mode=\"dark\"] | --elevation-popover: 0 8px 32px #0006", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "mode-color", + "evidence": "@layer base > :root[data-color-mode=\"dark\"] | --elevation-popover: 0 8px 32px #0006", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "local-color", + "evidence": "@layer base > :root[data-color-mode=\"dark\"] | --elevation-dialog: 0 2rem 6rem #0008", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "mode-color", + "evidence": "@layer base > :root[data-color-mode=\"dark\"] | --elevation-dialog: 0 2rem 6rem #0008", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "local-color", + "evidence": "@layer base > :root[data-color-mode=\"dark\"] | --elevation-dock: -12px 0 32px #0005", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + }, + { + "file": "src/shared/styles/tokens.css", + "rule": "mode-color", + "evidence": "@layer base > :root[data-color-mode=\"dark\"] | --elevation-dock: -12px 0 32px #0005", + "count": 1, + "reason": "Existing styling at 57796f0; retained until this owning surface is deliberately migrated." + } +] diff --git a/scripts/design-system/authoring-rules.mjs b/scripts/design-system/authoring-rules.mjs new file mode 100644 index 00000000..24213c09 --- /dev/null +++ b/scripts/design-system/authoring-rules.mjs @@ -0,0 +1,190 @@ +import postcss from "postcss"; +import { createRequire } from "node:module"; +// Reuse the parser shipped by our pinned Vite build tool; no second compiler. +const { parseSync } = createRequire(import.meta.resolve("vite"))( + "rolldown/utils", +); + +const PAINT = + /^(?:color|background(?:-color|-image)?|border(?:-(?:top|right|bottom|left|block|inline)(?:-start|-end)?)?(?:-color)?|outline(?:-color)?|box-shadow|text-shadow|fill|stroke|caret-color|accent-color|text-decoration-color)$/; +const RAW_COLOR = + /#[\da-f]{3,8}\b|\b(?:rgba?|hsla?|oklch|oklab|lab|lch|color|color-mix)\s*\(/i; +const NAMED_COLOR = + /^(?:black|white|red|green|blue|yellow|purple|orange|gray|grey|pink|cyan|magenta|lime|navy|teal|aqua|maroon|olive|silver|fuchsia|rebeccapurple)$/i; +const MODE = + /\.dark\b|data-color-mode\s*[=~|^$*]?=\s*["']?(?:dark|light)|prefers-color-scheme/; +const STOCK = + /(?:^|:)(?:bg|text|border|ring|fill|stroke|shadow|outline|from|via|to)-(?:slate|gray|zinc|stone|neutral|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|950|[1-9]00)(?:$|\/)/; +const COLOR_CLASS = + /(?:^|:)(?:bg|text|border|ring|fill|stroke|shadow|outline|divide|decoration|accent|caret|from|via|to)-/; +const COLOR_OPACITY = + /(?:^|:)(?:bg|text|border|ring|fill|stroke|shadow|outline|divide|decoration|accent|caret|from|via|to)-[^/]+\/(?:\d+|\[.+\])$/; +const TEXT_SIZE = + /(?:^|:)text-(?:xs|sm|base|lg|xl|[2-9]xl|\[[\d.]+(?:px|rem|em)[^\]]*\])$/; +const TYPE_CLASS = + /(?:^|:)(?:font-(?:medium|bold|light|thin|black|extrabold|extralight)|tracking-.+|uppercase)$/; +const RESTYLE = + /(?:^|:)(?:bg-|text-|border-|ring-|shadow-|opacity-|fill-|stroke-|outline-|font-|leading-|tracking-)|(?:^|:)(?:hover|active|focus|focus-visible|disabled):/; +const LAYOUT_TEXT = + /^(?:text-(?:left|right|center|justify|start|end|ellipsis|clip|wrap|nowrap|balance|pretty))$/; +const ROLES = + /^(?:text-(?:display|title|heading|body(?:-lg|-sm)?|mono(?:-lg|-sm)?))$/; + +/** Findings are stable structural evidence, not line numbers or whole-file exemptions. */ +export function authoringFindings(file, source) { + const findings = []; + const add = (rule, evidence) => findings.push({ rule, evidence }); + const central = file === "src/shared/design-system/styles/tokens.css"; + const typeOwner = file === "src/shared/design-system/styles/typography.css"; + function declaration(prop, value, context, mode) { + const paint = PAINT.test(prop); + const customColor = + prop.startsWith("--") && + (RAW_COLOR.test(value) || + /var\(--(?:bg|text|border|neutral|purple|red|green|amber|blue|cyan|orange)-/.test( + value, + )); + if (!central && (paint || customColor)) { + if (RAW_COLOR.test(value) || NAMED_COLOR.test(value)) + add("local-color", `${context} | ${prop}: ${value}`); + if (mode) add("mode-color", `${context} | ${prop}: ${value}`); + } + if (!typeOwner && /^(font-size|font-weight|letter-spacing)$/.test(prop)) { + const allowed = + prop === "font-weight" + ? /^(?:400|600|normal|inherit|var\(--)/ + : /^(?:inherit|var\(--)/; + if (!allowed.test(value)) + add("local-type", `${context} | ${prop}: ${value}`); + } + } + if (file.endsWith(".css")) { + const css = postcss.parse(source); + css.walkDecls((decl) => { + const parents = []; + for (let p = decl.parent; p && p.type !== "root"; p = p.parent) { + parents.unshift( + p.type === "rule" ? p.selector : `@${p.name} ${p.params}`, + ); + } + const context = parents.join(" > "); + declaration(decl.prop, decl.value, context, MODE.test(context)); + }); + return findings; + } + if (!/\.tsx$/.test(file)) return findings; + // Static JSX authoring only. Dynamic identifiers and runtime values remain + // review responsibilities. Parse syntax so callbacks/comments are not markup. + const parsed = parseSync(file, source); + if (parsed.errors.length) + throw new Error( + `Cannot audit ${file}: ${parsed.errors.map((e) => e.message).join("; ")}`, + ); + const shared = new Set(); + for (const node of parsed.program.body) { + if ( + node.type !== "ImportDeclaration" || + !/design-system\/ui(?:\/|$)/.test(node.source.value) + ) + continue; + for (const specifier of node.specifiers) shared.add(specifier.local.name); + } + function strings(node) { + if (!node) return []; + if (node.type === "Literal" && typeof node.value === "string") + return [node.value]; + if (node.type === "TemplateLiteral") + return node.quasis.map((q) => q.value.cooked ?? q.value.raw); + if (node.type === "ConditionalExpression") + return [...strings(node.consequent), ...strings(node.alternate)]; + if (node.type === "LogicalExpression") + return [...strings(node.left), ...strings(node.right)]; + if (node.type === "JSXExpressionContainer") return strings(node.expression); + return []; + } + function visit(node) { + if (!node || typeof node !== "object") return; + if (node.type === "JSXOpeningElement") { + const tag = source.slice(node.name.start, node.name.end); + const isShared = shared.has(tag.split(".")[0]); + for (const attribute of node.attributes) { + if (attribute.type !== "JSXAttribute") continue; + if (attribute.name.name === "className") { + const classes = strings(attribute.value) + .flatMap((v) => v.split(/\s+/)) + .filter(Boolean); + for (const value of classes) { + if (STOCK.test(value)) add("stock-color", `${tag} | ${value}`); + if (COLOR_CLASS.test(value) && RAW_COLOR.test(value)) + add("local-color", `${tag} | ${value}`); + if (COLOR_OPACITY.test(value)) + add("color-opacity", `${tag} | ${value}`); + if ( + /(?:^|:)dark:|data-color-mode|prefers-color-scheme/.test(value) && + COLOR_CLASS.test(value) + ) + add("mode-color", `${tag} | ${value}`); + if ( + TEXT_SIZE.test(value) || + TYPE_CLASS.test(value) || + (/(?:^|:)leading-/.test(value) && + classes.some((c) => ROLES.test(c))) + ) + add("local-type", `${tag} | ${value}`); + if (isShared && RESTYLE.test(value) && !LAYOUT_TEXT.test(value)) + add("component-restyle", `${tag} | ${value}`); + } + } + if ( + attribute.name.name === "style" && + attribute.value?.expression?.type === "ObjectExpression" + ) { + for (const property of attribute.value.expression.properties) { + if (property.type !== "Property" || property.computed) continue; + const prop = (property.key.name ?? property.key.value).replace( + /[A-Z]/g, + (c) => `-${c.toLowerCase()}`, + ); + for (const value of strings(property.value)) + declaration(prop, value, tag, false); + if (typeof property.value.value === "number") + declaration(prop, String(property.value.value), tag, false); + if (isShared && PAINT.test(prop)) + add("component-restyle", `${tag} | style ${prop}`); + } + } + } + } + for (const value of Object.values(node)) { + if (Array.isArray(value)) for (const child of value) visit(child); + else if (value && typeof value === "object") visit(value); + } + } + visit(parsed.program); + return findings; +} + +/** Exact multiplicities prevent another occurrence inheriting a legacy exemption. */ +export function compareBaseline(current, baseline) { + const counts = new Map(); + for (const finding of current) { + const key = JSON.stringify([finding.file, finding.rule, finding.evidence]); + counts.set(key, (counts.get(key) ?? 0) + 1); + } + const errors = []; + for (const entry of baseline) { + const key = JSON.stringify([entry.file, entry.rule, entry.evidence]); + if ( + !entry.reason?.trim() || + !Number.isInteger(entry.count) || + entry.count < 1 + ) + errors.push(`Invalid baseline entry: ${key}`); + const count = counts.get(key) ?? 0; + if (count < entry.count) errors.push(`Remove stale legacy finding: ${key}`); + counts.set(key, count - entry.count); + } + for (const [key, count] of counts) + if (count > 0) errors.push(`New authoring violation (${count}): ${key}`); + return errors; +} diff --git a/scripts/design-system/authoring-rules.test.mjs b/scripts/design-system/authoring-rules.test.mjs new file mode 100644 index 00000000..cf55e1d5 --- /dev/null +++ b/scripts/design-system/authoring-rules.test.mjs @@ -0,0 +1,153 @@ +import { describe, expect, it } from "vitest"; +import { authoringFindings, compareBaseline } from "./authoring-rules.mjs"; +import { earnedColorFailures } from "./earned-colors.mjs"; + +const css = (source) => + authoringFindings("src/features/example/Example.css", source); +const jsx = (source) => + authoringFindings("src/features/example/Example.tsx", source); + +describe("color decision evidence", () => { + it("accepts a same-step approved pattern and an established text rule", () => { + expect( + earnedColorFailures(`:root { + /* @earned pattern: Morgan approved the recurring callout treatment. */ + --bg-callout: var(--purple-3); + /* @earned rule: Primary reading level. */ + --text-primary: var(--neutral-12); + } .dark { --bg-callout: var(--purple-3); }`), + ).toEqual([]); + }); + it("accepts a mismatched pairing, but not a false modes label", () => { + const source = `:root { /* @earned modes: Raised in both contexts. */ --bg-new: var(--neutral-1); }`; + expect( + earnedColorFailures(`${source} .dark { --bg-new: var(--neutral-2); }`), + ).toEqual([]); + expect( + earnedColorFailures(`${source} .dark { --bg-new: var(--neutral-1); }`), + ).toHaveLength(1); + }); + it("asks for the decision instead of recommending silent demotion", () => { + expect( + earnedColorFailures(":root { --bg-callout: var(--purple-3); }")[0], + ).toContain("Preserve an approved name"); + }); + it("rejects empty, misplaced and missing-default annotations", () => { + expect( + earnedColorFailures( + ":root { /* @earned pattern: */ --bg-new: var(--purple-3); }", + ), + ).toHaveLength(1); + expect( + earnedColorFailures(":root { /* @earned rule: orphan */ }"), + ).toHaveLength(1); + expect( + earnedColorFailures(".dark { --bg-new: var(--purple-3); }"), + ).toHaveLength(1); + }); +}); + +describe("authoring guard", () => { + it.each([ + ".dark .card { background: var(--neutral-2); }", + '[data-color-mode="dark"] .card { color: var(--text-primary); }', + "@media (prefers-color-scheme: dark) { .card { background: var(--neutral-2); } }", + ".card { .dark & { background: var(--neutral-2); } }", + ".dark .card { --fill: var(--neutral-2); }", + ])("rejects local mode-specific paint: %s", (source) => { + expect(css(source).some((finding) => finding.rule === "mode-color")).toBe( + true, + ); + }); + it("allows mode-neutral tokens, non-color mode rules and named translucency", () => { + expect( + css( + ".card { background: var(--bg-panel); opacity: 0.5; } .dark .card { display: block; }", + ), + ).toEqual([]); + expect( + authoringFindings( + "src/shared/design-system/styles/tokens.css", + ":root { --purple-1: #ffffff; } .dark { --purple-1: #000000; }", + ), + ).toEqual([]); + }); + it("detects literals and constructed colors but ignores CSS comments", () => { + expect( + css( + "/* color: #fff */ .card { color: #fff; background: color-mix(in srgb, red, blue); }", + ).filter((f) => f.rule === "local-color"), + ).toHaveLength(2); + }); + it("checks static JSX classes and inline styles", () => { + expect( + jsx( + '
', + ).map((f) => f.rule), + ).toEqual( + expect.arrayContaining([ + "mode-color", + "local-type", + "color-opacity", + "stock-color", + "local-color", + ]), + ); + expect( + jsx( + '
', + ).some((f) => f.rule === "mode-color"), + ).toBe(true); + }); + it("checks imported system components, including aliases, without confusing native layout", () => { + expect( + jsx( + 'import { Button as Action } from "../../shared/design-system/ui/Button"; const view = ;', + ).filter((f) => f.rule === "component-restyle"), + ).toEqual([ + { rule: "component-restyle", evidence: "Action | bg-purple-3" }, + ]); + expect(jsx('
')).toEqual([]); + }); + it("handles callback props and ignores JSX-looking prose", () => { + expect( + jsx( + '
act()} className="dark:bg-neutral-2 text-sm" />', + ).map((f) => f.rule), + ).toEqual(["mode-color", "local-type"]); + expect( + jsx("const example = '
';"), + ).toEqual([]); + expect( + jsx('
').map( + (f) => f.rule, + ), + ).toEqual(["stock-color", "local-color"]); + expect( + css(".dark .card { border-radius: 8px; background-size: cover; }"), + ).toEqual([]); + }); + + it("does not mistake text alignment for typography or numeric CSS for color", () => { + expect( + css(".card { width: 20px; font-weight: 600; color: inherit; }"), + ).toEqual([]); + expect(jsx('
')).toEqual([]); + }); + it("requires exact legacy occurrences, refuses stale entries and does not exempt files", () => { + const finding = { + file: "src/old.css", + rule: "local-color", + evidence: ".a | color: #fff", + }; + const baseline = [ + { ...finding, count: 1, reason: "Existing unmigrated UI" }, + ]; + expect(compareBaseline([finding], baseline)).toEqual([]); + expect(compareBaseline([finding, finding], baseline)).toHaveLength(1); + expect(compareBaseline([], baseline)[0]).toContain("stale"); + expect( + compareBaseline([{ ...finding, evidence: ".b | color: #fff" }], baseline), + ).toHaveLength(2); + }); +}); diff --git a/scripts/design-system/check-authoring.mjs b/scripts/design-system/check-authoring.mjs new file mode 100644 index 00000000..8958c110 --- /dev/null +++ b/scripts/design-system/check-authoring.mjs @@ -0,0 +1,42 @@ +import { readFileSync, readdirSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { relative, resolve } from "node:path"; +import { authoringFindings, compareBaseline } from "./authoring-rules.mjs"; + +const root = fileURLToPath(new URL("../../", import.meta.url)); +const current = []; +function scan(directory) { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const path = resolve(directory, entry.name); + if (entry.isDirectory()) scan(path); + else if ( + entry.isFile() && + /\.(css|tsx)$/.test(entry.name) && + !/\.(test|spec)\.tsx$/.test(entry.name) + ) { + const file = relative(root, path).replaceAll("\\", "/"); + current.push( + ...authoringFindings(file, readFileSync(path, "utf8")).map( + (finding) => ({ file, ...finding }), + ), + ); + } + } +} +scan(resolve(root, "src")); +scan(resolve(root, "tests/fixtures/design-system")); +const baseline = JSON.parse( + readFileSync(new URL("./authoring-baseline.json", import.meta.url), "utf8"), +); +const errors = compareBaseline(current, baseline); +if (errors.length) { + console.error(errors.join("\n")); + console.error( + "Use shared tokens/type roles/component variants. Different light/dark steps belong in a semantic token, never a local override. Preserve approved patterns and record their @earned reason beside the token. Ask when approval is unknown. Do not refresh the legacy baseline to pass. See src/shared/design-system/AGENTS.md.", + ); + process.exitCode = 1; +} else { + console.log( + `✓ Authoring: no new violations; ${baseline.length} exact legacy findings retained`, + ); +} diff --git a/scripts/design-system/check-color.mjs b/scripts/design-system/check-color.mjs index 97c16a53..74237ca0 100644 --- a/scripts/design-system/check-color.mjs +++ b/scripts/design-system/check-color.mjs @@ -34,6 +34,7 @@ import { readdirSync, readFileSync, statSync } from "node:fs"; import { join, relative } from "node:path"; +import { earnedColorFailures } from "./earned-colors.mjs"; const SRC = new URL("../../src/shared/design-system", import.meta.url).pathname; const VIEWER = new URL("../../tests/fixtures/design-system", import.meta.url) @@ -313,62 +314,8 @@ function auditLayers() { } } - // 2. THE ROLES THAT REMAIN MUST BE MODE-ASYMMETRIC. - // - // This replaces four checks that audited the identity families — that each - // referenced a palette step, that none was missing, that no two shared a step, - // that dark never restated one. All four are gone because their subject is: - // nineteen roles were deleted once palette steps became reachable as classes, - // and the ones left are the four surfaces plus emphasis. - // - // The invariant now worth enforcing is the TEST FOR WHETHER A ROLE IS EARNED. - // A surface role exists precisely because light and dark take *different* ramp - // steps, so no single class can express it. If someone adds a surface role - // whose two modes agree, the name is doing nothing and a class would say it — - // that is the mistake this catches, in the same shape it already happened. - // - // Two reasons earn a name whose modes agree, per DESIGN.md § When a name is - // earned. Each entry states which one, so the list reads as decisions rather - // than as accumulated exceptions. - // - // • the name enforces a rule a ramp cannot state — there are three levels - // of text and one border weight; - // • a pattern repeated across screens has been named for the pattern. - // - // What this still catches is the mistake it was written for: a role invented - // by symmetry, restating one step, that no design asked for. - const NAME_IS_EARNED = new Map([ - ["--text-primary", "Three text levels, enforced by name."], - ["--text-secondary", "Three text levels, enforced by name."], - ["--text-tertiary", "Three text levels, enforced by name."], - ["--text-disabled", "Unavailability is a rule, not a fourth level."], - ["--border-primary", "One shared border weight, enforced by name."], - ["--text-on-accent", "Paired text follows its fill, not the mode."], - ]); - - const roleNames = [ - ...new Set( - [...modes.light.matchAll(/^\s*(--(?:bg|text|border)-[a-z0-9-]+):/gm)].map( - (m) => m[1], - ), - ), - ]; - - for (const name of roleNames) { - if (NAME_IS_EARNED.has(name)) continue; - // Glass materials are a bundled treatment, not a surface step, and `bg-app` - // swaps a whole gradient rather than a step. - if (name.includes("glass") || name === "--bg-app") continue; - - const light = read(modes.light, name); - const dark = read(modes.dark, name); - if (dark && dark !== light) continue; // earns its name - - failures.push({ - at: rel, - found: `${name}: ${light}`, - why: `Same value in both modes. Write ${light?.replace(/var\(--(.+)\)/, "$1") ?? "the step"} where it is used — unless this name is earned, in which case add it to NAME_IS_EARNED with its reason: a rule a ramp cannot state, or a pattern repeated across screens that is now named for the pattern.`, - }); + for (const why of earnedColorFailures(css)) { + failures.push({ at: rel, found: "semantic color decision", why }); } } @@ -392,6 +339,6 @@ for (const { at, found, why } of failures) { console.error(` ${why}\n`); } console.error( - "Add the step to the palette in tokens.css, or add a documented override\nin scripts/design-system/check-color.mjs with a reason. See DESIGN.md § Colour discipline.", + "Add the step to the palette in tokens.css, or add a documented override\nin scripts/design-system/check-color.mjs with a reason. See AGENTS.md § Choose and grow the system.", ); process.exit(1); diff --git a/scripts/design-system/check-type.mjs b/scripts/design-system/check-type.mjs index 111e22af..a5c67b66 100644 --- a/scripts/design-system/check-type.mjs +++ b/scripts/design-system/check-type.mjs @@ -2,7 +2,7 @@ /** * Type-system guard. * - * Four rules from DESIGN.md § Type, enforced rather than trusted. Each one has + * Four rules from AGENTS.md § Visual and interaction contract, enforced rather than trusted. Each one has * already cost the existing client real work: * * 1. No arbitrary text sizes — `text-[15px]`, `text-[0.9rem]`, `font-size:`. @@ -94,7 +94,7 @@ const RULES = [ // rule trips it, and a guard that cries wolf gets ignored. pattern: /class(?:Name)?=(?:"|'|`)[^"'`]*\buppercase\b/g, message: - "all-caps text — DESIGN.md § Type forbids it. A quiet label uses text-meta on text-tertiary instead.", + "all-caps text — AGENTS.md requires sentence case. A quiet label uses text-body-sm on text-tertiary instead.", }, { id: "manual-tracking", diff --git a/scripts/design-system/earned-colors.mjs b/scripts/design-system/earned-colors.mjs new file mode 100644 index 00000000..8e66e59e --- /dev/null +++ b/scripts/design-system/earned-colors.mjs @@ -0,0 +1,59 @@ +import postcss from "postcss"; + +const ROLE = /^--(?:bg|text|border)-[a-z0-9-]+$/; +const ANNOTATION = /^@earned (modes|pattern|rule|material):\s*(\S[\s\S]*)$/; + +/** Validate recorded evidence, not approval or the quality of a design reason. */ +export function earnedColorFailures(css) { + const root = postcss.parse(css); + const defaults = new Map(); + const dark = new Map(); + const failures = []; + root.walkDecls((decl) => { + if (!ROLE.test(decl.prop) || decl.parent.type !== "rule") return; + const selector = decl.parent.selector; + if (selector === ":root") { + if (defaults.has(decl.prop)) + failures.push(`${decl.prop}: duplicate default declaration`); + defaults.set(decl.prop, decl); + } else if (/\.dark\b|data-color-mode/.test(selector)) { + dark.set(decl.prop, decl.value); + } + }); + root.walkComments((comment) => { + if (!comment.text.startsWith("@earned")) return; + if ( + comment.next()?.type !== "decl" || + !ROLE.test(comment.next().prop) || + comment.parent.selector !== ":root" + ) { + failures.push( + "@earned must immediately precede a default semantic color declaration", + ); + } + }); + for (const [name, decl] of defaults) { + const previous = decl.prev(); + const match = + previous?.type === "comment" ? ANNOTATION.exec(previous.text) : null; + if (!match) { + failures.push( + `${name}: record @earned modes, rule, material or pattern with a reason immediately before its default declaration. Preserve an approved name; if pattern approval is unknown, ask Morgan rather than inventing a reason or replacing the token.`, + ); + continue; + } + if ( + match[1] === "modes" && + (!dark.has(name) || dark.get(name) === decl.value) + ) { + failures.push( + `${name}: @earned modes needs different default/dark definitions. An approved same-step pattern is valid: record @earned pattern with its actual reason instead.`, + ); + } + } + for (const name of dark.keys()) { + if (!defaults.has(name)) + failures.push(`${name}: dark definition has no default semantic color`); + } + return failures; +} diff --git a/src/shared/design-system/AGENTS.md b/src/shared/design-system/AGENTS.md index 1d6fec34..ee4cb15a 100644 --- a/src/shared/design-system/AGENTS.md +++ b/src/shared/design-system/AGENTS.md @@ -1,14 +1,101 @@ -# Design system handoff - -This is the app's design system. New UI and surfaces moving off the existing styles should use it. -This initial port does not migrate existing surfaces; that is a boundary of the PR, not a prohibition on adoption. -Read DESIGN.md and MAINTAINING_DESIGN_SYSTEM.md before editing. -Use authored ramps and named type roles; keep Base UI behavior and Tabler icons. -Preserve keyboard-only focus and test light/dark and narrow/intermediate/wide views. -Components live in ui/, values in styles/, documentation metadata in tokens/ and ui/registry.ts. -The standalone viewer lives in tests/fixtures/design-system and imports the real shared components. -Do not import features, plugins, native adapters, or app startup into this system or viewer. -When wiring it into the app, use the shared components and tokens rather than the viewer's documentation furniture. -Integrate global styles deliberately through the host entry point instead of layering two resets, and keep the host appearance owner. -The theme helper is viewer-only; app surfaces read appearance through the host. -Run the root design:typecheck, design:check, design:test, and design:build scripts. +# Design-system rules + +This is the canonical policy for new and migrated Buzz UI, including product +consumers, the standalone viewer and design-system tooling—not only this folder. +The root and viewer instructions route here; they do not copy these rules. +`DESIGN.md` explains decisions and examples; `README.md` owns setup and commands. +The registries and live viewer own the inventory, not a second handwritten list. + +## Choose and grow the system + +- Use existing shared components and their variants before assembling styles. + Product code owns composition, content and behavior; shared visual treatment + belongs in the component. Layout classes are fine; do not cancel its colors, + typography, shadows, opacity or interactive states at the call site. +- Use authored palette ramps for ordinary color choices. Semantic names are + earned by a light/dark step mismatch, a rule a ramp cannot express (such as + three text emphasis levels), or an established UI pattern approved by Morgan. + A repeated color alone is not proof of a repeated pattern. +- **No local light/dark color overrides.** When one UI job needs different steps + in light and dark, reuse the semantic token for that job or create one with + the pairing in `styles/tokens.css`. Both modes use the same name at the call + site. If the shared ramp or role itself is wrong, propose fixing that owner + instead of adding a token to conceal the defect. +- An approved pattern may use the **same palette step in both modes**. That is + valid, not an exception to work around. Record its reason immediately before + the light/default token declaration as `/* @earned pattern: */`. Use `@earned rule:` for an established system rule. For a + mismatched pairing use `@earned modes:` and explain the relationship. Material + treatments use `@earned material:`. The check verifies a recorded reason, not + the truth of the design judgment or human approval. +- If a reason is missing, preserve any approved semantic decision and record it. + Without evidence of approval for a pattern, ask Morgan; do not fabricate a + reason, quietly replace the token with a palette step, or weaken the guard. +- Color literals live in the central palette/material definitions. Do not use + stock Tailwind colors, local literals, or opacity/color mixing to manufacture + a shade. Genuine translucency belongs in a named material; whole-element + fading is different. Use glass as a complete treatment, not its private fill. +- Search by intent before adding. Build from a current need, not a speculative + catalogue. Generic reusable UI belongs here; Buzz-specific behavior stays + with its feature. Propose promotion when a second use establishes repetition. + Use enumerable variants, never visual boolean props or parallel components. +- Keep Base UI behavior and Tabler icons. Inspect Base UI for the matching + interactive primitive before writing one; use native elements when there is + no matching primitive or the component is static. +- When the system cannot express the right design, propose improving the system. + Do not distort the design or hide a local workaround. Exceptions require a + deliberate, documented design decision; recurring exceptions suggest a missing + role or a rule that needs changing. + +## Visual and interaction contract + +- Quiet everyday chrome; color signals meaning, not decoration. Never use status + colors decoratively or as the only carrier of meaning. +- Documentation and dense reading sit on an opaque panel, not a page-wide gradient. +- Choose the surface job first: backdrop, panel, inset, floating or glass. Use + `border-primary` for shared neutral boundaries; do not invent border weights. + Group with spacing or a quiet fill rather than boxing every item. Cards have + no default fill; dense data normally uses rows rather than cards. +- Text uses named type roles as a whole setting: no arbitrary sizes, leading or + tracking. Use 400 for content and 600 for structure/emphasis; body emphasis may + compose `font-semibold`. State is not weight. Sentence case, never tracked-out + labels. Relative sizing preserves zoom and the host's text preference. Readable + sans text has a 12px default floor; the authored mono roles are optically smaller. +- Keep default, hover, selected and applicable disabled states clear. Selected is + persistent, not stronger hover; selected no-op toggles have no hover. No extra + pressed styling. Never hide the only recovery action. +- Preserve assistive semantics, one owner per label, and equivalent keyboard, + pointer and shortcut paths. Authored focus rings require both + `html[data-keyboard-navigation]` and `:focus-visible`; no container focus rings. +- Design in light/dark and narrow/intermediate/wide together. Check actual + text/surface pairings, not isolated swatches. Target APCA Lc 60 for body and 45 + for large/non-essential text; report WCAG contrast too where applicable. + Disabled text is not for needed information. Automated opaque-pair checks do + not certify glass, control boundaries or rendered accessibility. +- Motion explains change. No animated blur; direct manipulation follows the + pointer without easing and must not select text during a drag. +- Copy is short and actionable. Labels say what happens; empty states explain + purpose and next steps; errors explain what happened and how to recover. + +## Ownership and change completion + +- Components live in `ui/`, values in `styles/`, inventory in `tokens/` and + `ui/registry.ts`. Update the relevant registry and live specimen in the same + change as an addition. Preserve proposed/core status; documentation is not + proof of product adoption. Facts derivable from code must be checked against it. +- The viewer imports the real components. Neither the system nor the standalone + viewer imports features, plugins, native adapters or app startup. Product UI + must not import viewer furniture. +- The host owns appearance and global-style integration, including portal roots. + Do not layer a second reset or preference owner into the app. The theme helper + here is viewer-only; layout experiments need a host adapter before adoption. +- Read the relevant `DESIGN.md` explanation before changing a design decision. + Update this file when policy changes; update the explanation only when the + rationale changes. Do not duplicate token values or component inventory there. +- Run the scoped commands in `README.md`: types, guards, unit tests and viewer + build; exercise affected browser journeys in Chromium and WebKit for viewer or + component behavior changes. Follow the root contribution workflow for iteration + versus integration gates and report deferred checks. +- Existing legacy findings are recorded individually, not exempted by folder. + Do not add new violations or refresh that baseline to make checks pass. Remove + entries as their owning UI is migrated. CI is a backstop, not a design reviewer. diff --git a/src/shared/design-system/DESIGN.md b/src/shared/design-system/DESIGN.md index 4a3132a4..098195c3 100644 --- a/src/shared/design-system/DESIGN.md +++ b/src/shared/design-system/DESIGN.md @@ -1,408 +1,200 @@ -# DESIGN.md - -How to design well in this client. The token registry says which value to use; this says what tokens cannot express — the judgement a designer makes without thinking and an agent gets wrong without being told. Read it before building a surface. - -Run `pnpm design:dev` and open `/tests/fixtures/design-system.html` to see the system rendered from the tokens themselves. +# Design rationale + +`AGENTS.md` is the canonical authoring policy. This document explains the decisions +behind it; it is not a second rulebook or a copied token/component inventory. +Current values, roles, components and specimens live in the registries and viewer. +`README.md` explains how to run them. Historical examples below explain decisions, +not a claim that every referenced feature was ported to this client. + +## Stewardship + +The system is shared memory of deliberate decisions, not a gate a designer must +pass through. Its purpose is to make common choices easy while leaving room for +specific, surprising or new design. Product work supplies the evidence: a repeated +need can reveal a useful abstraction, and a one-off can reveal a faulty ramp or a +missing component state. A passing check establishes only the properties it tests, +not that an interface is good. + +For example, a prototype's lighter search field exposed a neutral step that was +too heavy. Correcting the shared ramp improved every quiet compact surface using +that step; a search-only color or fractional stop would have hidden the finding. +A real component variant similarly keeps shared behavior intact instead of +creating another component whose interaction will drift. + +Exceptions are design decisions worth explaining. Several exceptions pointing in +one direction are evidence that the system's rule may need changing. The guard +cannot approve a pattern, infer product intent or judge a live preview. It can +require the reason to be recorded and direct an agent back to the human when that +reason is unknown. Mandatory completion steps live in AGENTS.md. ## Posture -Buzz is a place where people build together and bring their agents into the room. Everyday surfaces stay quiet, crisp, and highly functional; character shows up in identity, guidance, transitions, and ceremony rather than in the chrome of ordinary work. Colour is signal, not decoration. When in doubt, the interface gets out of the way of the conversation. +Buzz is a place where people build together and bring agents into the room. +Everyday surfaces stay quiet, crisp and functional. Character belongs in identity, +guidance, transitions and ceremony rather than ordinary chrome. Color as signal +keeps conversations and work more prominent than their containers. -## Surface and depth +## Color: why the ramps are public + +Each palette step has separately authored light and dark values. A component using +`bg-purple-3` already changes with the mode; this differs from stock Tailwind, +where one numbered color is a single literal. The system's palette reconciles +light and dark once, rather than making every screen do it again. -- **Panels sit on the backdrop; the backdrop is a gradient.** Everything else is a panel in a different place. The navigation column is not a special kind of surface. -- **A region is separated by a soft fill, not by an outline.** Reach for `bg-inset` before reaching for a border. A bordered box announces its own edges; a filled one lets the content sit in a place. Grouping is the common case, so the quiet treatment is the default one. -- **A border is for a genuine boundary, and there is one weight: `border-primary`.** It is `neutral-4` in light and `neutral-6` in dark. A hairline needs more separation on a dark surface than the same step number supplies: `neutral-4` measured 1.08:1 on a dark panel and 1.09:1 on the floating composer — drawn, and effectively invisible. `neutral-5` looked like the obvious one-step move but is `bg-float` in dark, so a border there would be 1.00:1: the same colour as the surface it is meant to bound. `neutral-6` clears the actual surfaces at 1.35–1.43 on panel/inset and 1.14 on float. **That mode difference is the reason this is a role rather than `border-neutral-4`.** A second weight arrives only with the design that proves a different boundary needs it. Text and borders still hold different values — text at the dark end of the neutral ramp, borders at the light end — so if a divider looks like text, it is pointed at the wrong role. -- **No page-wide gradient behind documentation or dense reading.** The gradient is the product's backdrop for chrome and panels. Behind a column of prose it fights the text and makes contrast position-dependent — such surfaces sit on `bg-panel`. -- **Shadows stay at the threshold of perception.** If a shadow is obvious, it is too strong. The two elevation values are the whole vocabulary. -- **Elevation is carried by shadow in light mode and by lightness in dark mode.** On a near-black background there is nothing darker for a shadow to cast, so a floating surface becomes a step lighter instead. Never reach for a stronger shadow to make something float in dark mode. -- **On a translucent surface, elevation reads as less translucency, not as a lighter colour.** A glass container with a fully opaque child looks layered; the same container with a merely brighter child looks unchanged. -- **Light comes from one direction, and every glass surface agrees on it.** A glass rim is bright along the lit edge and dimmer on the opposite one; that is what makes it read as a material rather than an outline. Two surfaces lit from different directions in the same view look like a mistake. -- **A glass rim is not an outline.** If a surface needs a visible boundary rather than a material edge, it wants a border role, not glass. -- **Glass needs something behind it worth seeing.** Translucency over a flat fill is wasted cost; use it where the gradient, an image, or content actually shows through. -- **Only panels and chrome should sit directly on the gradient as a default.** Text and hairlines on a gradient have position-dependent contrast. Good practice rather than a hard rule — a rotated label pill on the backdrop is fine. -- **A translucent surface has no contrast guarantee, and this one is measured.** `check-contrast` pairs each text role with the *opaque* surface roles, so glass is invisible to it — the surface a person actually reads against is the fill composited over whatever gradient happens to be behind it, which varies by position on screen. Sampled from a rendered dark-mode screenshot, primary glass over Night garden runs from `#162e28` in its quiet regions to `#1e4a3c` where the green glow reaches through. On the darker end everything clears; on the brighter end **`text-secondary` measures Lc 58 and `text-tertiary` Lc 43**, against targets of 60 and 45. Marginal, and only in a region the glow reaches — but real, and no guard can see it. Three ways out, none obviously right: make the gradients' bright stops dimmer where panels sit, raise the glass fill a ramp step under a bright backdrop, or keep meta text off glass. **Deliberately unresolved** — it needs the real product content on screen, not a token edit. -- **A redundant fill on glass is not free — it compounds.** Two identical translucent layers are not one layer: `glass-2` over `glass-2` composites to **0.77 alpha**, a value no token holds. Four panels each set the same fill as the container they exactly covered, so panels meant to be the most translucent surface in the system read as nearly solid. Before giving a region a glass fill, check whether its parent already is glass; if the region covers it, it needs no fill of its own. -- **A component that can sit on either the gradient or a panel says so, with a variant.** `Tabs` takes `chrome` (a glass pill for the app backdrop) or `panel` (an underline for a plain surface); `IconButton` has the same axis as its `chrome` variant. The failure that earned it: the chrome container is `glass-2`, which over a white panel composites to pure white, and its selected pill is `neutral-1` — also pure white. Container and selection became one colour with only a shadow between them, and no guard could see it because the component had no way to state which background it expected. **The fix was never to retint `--bg-chrome-selected`** — that moves the collision rather than removing it. **One component with a variant, not two components:** behaviour, keyboard model, accessibility, props, and the Base UI parts underneath are identical, so a sibling component would duplicate all of it to change how selection is drawn, and the two would drift exactly as the four hand-assembled chrome surfaces did. When adding a component that could appear in both places, give it the axis and put both on its specimen page — the chrome-only specimen is why this defect survived until it appeared on a real screen. - -## State - -- **The interface has three states, plus disabled where it matters: default, hover, selected.** There is no pressed state: pressed is too fleeting to read and makes an interface feel jumpy. -- **Hover means one step more contrast, in whichever direction that surface needs.** A light row darkens, a dark chip lightens. Direction lives in the value. -- **Selected is a persistent statement, not a stronger hover.** It should be legible without a cursor present. -- **A selected item in a toggle group is not interactive.** Clicking it does nothing, so it gets no hover. -- **Disabled communicates unavailability, not quietness.** It is not a fourth level of the emphasis ramp. -- **Never hide the only way out of a state.** Before adding a visibility rule, ask what happens when the state it assumes is wrong, and whether the person can still recover. - -## Emphasis - -- **Three levels of text: normal, lesser, really lesser.** If a fourth seems necessary, the thing wants a different size, weight, or position instead of a fourth colour. -- **Two text colours do most of the work.** Treat the third level as genuinely for metadata. -- **Borders use the same three levels, and they mean the same thing.** Learn the ramp once. -- **Weight and size carry hierarchy before colour does.** Reaching for a louder colour to fix hierarchy usually means the size relationship is wrong. - -## Type - -Two layers, and the same rule as colour: only roles are used when building a -screen. Layer 1 is the raw ramps (`--type-size-*`, `--type-leading-*`, -`--type-tracking-*`, `--type-weight-*`); layer 2 is the roles, which register in -Tailwind's `--text-*` namespace and become utilities like `text-body`. The -authoring lives in `src/shared/design-system/styles/typography.css`. - -**Size roles and colour roles never collide**, because they live in different -namespaces: colour registers as `--color-*` and is named for emphasis -(`text-primary`), size registers as `--text-*` and is named for an editorial job -(`text-body`). So `text-primary text-body` is one colour plus one setting, and no -name ever means both. - -Nine roles. Sans: `text-display` 32, `text-title` 24, `text-heading` 16/600, -`text-body-lg` 16, `text-body` 14, `text-body-sm` 12. Mono: `text-mono-lg` 15, -`text-mono` 13, `text-mono-sm` 11. Two faces — `font-sans` (Inter Variable) and -`font-mono` (JetBrains Mono) — both already shipped in every current Buzz client. - -- **A type role carries its whole setting.** Size, line height, letter spacing, and weight are one decision, not four. `text-body` alone produces correctly set text, and its line height is never overridden — that is how two supposedly identical labels drift apart. -- **There are two weights: 400 and 600.** 400 is content — everything read. 600 is structure and emphasis: the thing that names what you are looking at, or the words a sentence leans on. `font-semibold` is what bold means here. -- **Bold body text is two utilities, composed.** `text-body font-semibold`, `text-body-sm font-semibold`. This is the one place a component adds a weight, and it is deliberate: **the size is the paragraph's decision, the weight is the phrase's.** A `text-body-bold` role would fuse them, so an agent emphasising three words would also be re-asserting a size it has no business choosing. Composing also means one rule covers every size instead of doubling the ramp. -- **500 and 700 are not in the system.** 500 was measured against 400 at body size and does not read as intent in a scanned list — subtle enough to miss, heavy enough to muddy a column. 700 is louder than anything here needs. Weight is not a ramp; it is two values with two jobs. -- **State is not weight.** Selected, active, and unread are said with colour, a fill, or a dot — all three already exist in the colour system. Reserve 600 for structure and emphasis, or it stops meaning either. -- **The guard has a named escape hatch, and using it is normal.** `scripts/design-system/check-type.mjs` rejects other weights because an agent has no basis for preferring `font-medium` to `font-semibold` and will otherwise pick either. A designer who finds a genuine optical exception adds it to `OVERRIDES` with a reason and moves on — that is an ordinary edit, not an escalation. The point is that the next person reads a decision instead of guessing at an accident. If overrides start accumulating in one direction, the system is missing a role; fix the system rather than adding a tenth entry. -- **Roles are named for the job the text does, never for its size.** `text-title`, not `text-28`. A size name is a value in disguise and goes stale the moment the ramp moves. -- **The ramp is short because the product is.** Across 73 real Buzz screens, 90% of all text is one size and two cover 93%; 16/18/20/22 together were 1.5%, scattered and inconsistent. So there is nothing between 16 and 24, and that gap is deliberate — an app has panels with names, not a document outline. Four steps above body invited a hierarchy the product does not have. -- **A heading is body-large in a different weight.** `text-heading` and `text-body-lg` are both 16px, separated by weight alone — which is what the shipping client does at 16/600, and the only text on a channel screen larger than a message. In a dense app a section name needs to be identifiable, not loud. -- **12px is the floor.** Nothing readable goes below it: the most compressed text in Berd — a timestamp in a hover slot, a model name in a 24px pill — is 12px. If something must be smaller it is not text, it is a glyph inside a component, and that component owns the size as a documented exception. Do not add a general-purpose smaller step. -- **Mono is one step below its sans partner, always.** 11↔12, 13↔14, 15↔16. At equal size a monospace face reads larger than Inter and pulls the eye off the sentence, so the correction is a rule rather than a judgement: pick the sans size, step down. Mono never exceeds body size in ordinary interface text; the one exception is a code or key a person must transcribe, which is what `text-mono-lg` is for. -- **Mono roles are named for the setting, not the content.** `text-mono`, not `text-code` — most mono in a product is a pubkey, a path, a branch name, or a hex value. Calling the role `code` made it read as a lie everywhere except an actual code block. -- **Never all-caps, and never tracked-out labels.** A capitalised label is harder to read than its sentence-case version and reads as enterprise chrome. A quiet label earns its quietness from size and colour — `text-body-sm` on `text-tertiary` — rather than from being shouted. There is deliberately no uppercase utility in this system. -- **Every size is relative.** Nothing may be expressed in px: fixed pixel text freezes against keyboard zoom and ignores the person's font-size preference. The existing client shipped a regression from exactly this. Everything derives from one virtual rem, so zoom and the font-size preference both work by construction — which is why an arbitrary rem literal is rejected too. It zooms correctly and still re-fragments the scale. -- **Tracking is an optical correction, not a style.** Inter needs progressively tighter spacing as it grows. The ramp already applies it per step; do not add tracking by hand. - -## Both modes - -- **Design in both modes, not in light and then dark.** Dark is not a filter applied afterwards: elevation, glass, and accent text all behave differently there. -- **Accent text moves in opposite directions between modes.** Darker than its fill on a light background, lighter on a dark one. -- **A tint is a pale wash in light mode and a deep one in dark.** The name describes the job, not the lightness. -- **Check the pairing, not the swatch.** A colour is only right in the context of what sits on it and behind it. -- **Every dark value in this system is authored rather than observed.** The design exploration it came from is light-only. Treat anything that looks wrong in dark as a finding. - -## Density and rhythm - -- **Dense data renders as rows with dividers, edge to edge.** Wrapping every list item in its own card is the most common way a functional surface becomes a marketing page. -- **Content that separates itself needs no divider, and no container.** A divider is for uniform rows where the eye needs a line to track along. When each entry already carries a visible difference — a colour swatch, a type specimen, an avatar — the content is the separator, and adding a rule or a card on top is redundant structure. Space alone is enough. -- **Never judge a value against a surface it will not be used on.** A swatch on a grey fill, or a type specimen in a tinted box, is being evaluated in a context the product will never reproduce. Samples sit on the page. The one exception is a value that needs a backdrop to exist at all — translucency needs something behind it, and a white surface swatch needs a hairline or it renders as nothing. -- **Cards are for widgets, galleries, and settings groups.** A card is a bordered, padded region on the page, not a different depth. -- **A card carries no default fill.** It sits on `bg-panel` and is grouped by a hairline or by spacing. Fill on a card is reserved for `bg-hover`, and only where the card is actually clickable — so a filled card always means *you are pointing at this*, never merely *this is a box*. This is why there is no `bg-card` role: a card with no default fill needs no name. It also rules out the cards-in-cards look, where a filled card inside a filled panel reads as a stack of empty text fields. Reaching for `bg-inset` here is the specific mistake — `inset` means pushed in, like an input or a code block, which is the opposite gesture from grouping. -- **Pick the frame before the content.** Decide what the surface is — a list, a reading column, a workspace — before filling it. -- **Whitespace is generous by default.** Crowding reads as a different product. - -## Motion - -- **Direct manipulation follows the pointer exactly, with no easing.** Smoothing during a drag or resize reads as lag. Spring physics belongs to what happens after release. -- **A drag gesture must not select text in whatever it passes over.** -- **Never animate blur.** Re-blurring a large surface every frame is expensive enough to feel. Animate opacity instead. -- **Motion explains a change; it does not decorate one.** If removing an animation loses no information, remove it. - -## Colour structure - -Two public layers, plus components: screens normally write a palette step, and use a role only for a decision a step cannot express. - -| Layer | Example | What it is | +| Layer | Example | Purpose | |---|---|---| -| **0 palette** | `--purple-9`, `--neutral-4` | Every hue, twelve steps, authored per mode. The only place a literal lives. **Public: a screen writes `bg-purple-9`.** | -| **1 roles** | `--bg-panel` | The fifteen cases a step cannot express. Public too. | -| **2 components** | `bg-panel`, `bg-neutral-4` | Tailwind utilities, from either layer. | - -**Screens are built from the ramps.** This reverses the rule this file used to -state, and the reversal turns on one fact: **every palette step is authored per -mode.** `neutral-4` is `#e8e8e8` in light and `#232323` in dark, so a component -naming the step behaves correctly in both. That is what makes a raw step safe -here and unsafe in stock Tailwind, where `neutral-200` is a single literal — -naming it there really does break dark mode, and a semantic layer really is the -only fix. - -Once a step is mode-aware, **a role whose light and dark values are the same step -is a name in front of a number**, and a name in front of a number hides the -decision instead of recording it. `bg-accent` was `purple-9`; `text-error` was -`red-12`. Nineteen roles were exactly that and are gone. - -### When a name is earned - -Three cases, and `pnpm design:check` enforces the first two by rejecting any new -role that fails them: - -1. **Light and dark take different steps.** `bg-panel` is `neutral-1` in light and - `neutral-3` in dark. No single class can say that, so the name is load-bearing. - The four structural surfaces are all of this kind. -2. **The name enforces a rule a ramp cannot state.** There are deliberately three - levels of text and one border weight. `text-neutral-11` looks reasonable and is - how a fourth level appears without anyone deciding, so `text-secondary` stays - even though its step is identical in both modes. -3. **Morgan sees a repeated pattern and asks for one.** A tinted callout that - turns up on four screens earns a name — for the *pattern*, not the colour. This - is the only route by which the role layer grows, and it is deliberately manual. - -`neutral` is a hue like any other — the same twelve steps, the same naming. There -is no separate grey ramp and no `palette-` prefix: a step is `--neutral-4`, the -way Tailwind names a colour. - -**Tailwind's default palette is deleted** with `--color-*: initial`, so -`text-gray-500` does not exist. It is a build error, not a style choice. - -**There used to be a families layer** — `--accent-fill`, `--danger-tint`, thirty -steps in five families, sitting between the palette and the roles. It was -deleted. Every one of its thirty steps had exactly *one* reader, so it renamed a -colour rather than abstracting one, and answering "what colour is this button" -meant reading three lines in two places -(`bg-accent` → `accent-fill` → `palette-purple-9`). The naming survives where it -was always clearest — in the role names, which say *what the colour is for* -(`bg-accent-tint` is a background) rather than restating a job (`accent fill` -does not tell you where to put it). - -**The role layer was then cut from 54 names to 15, by the same test.** A census -counted every reader of every role — both `var(--x)` in a stylesheet and the -Tailwind class each role registers as, with the /design pages counted separately -from product code, since a page displaying a swatch proves only that the token -exists. Eighteen roles had no reader anywhere and six were read only by the docs -that documented them; the rest went once palette steps became reachable as -classes and the "same step in both modes" test above disqualified them. - -**The argument that lost is worth recording, because it is a good one.** A role is -a slot whose hue can change, so `bg-accent-tint` survives a retint where -`purple-3` does not. It lost to a fact: the accent hue *did* change, to Tailwind -purple, and it was five values in the ramp rather than a rename. The ramp is the -slot. A role in front of it only adds a hop. - -The palette stays because it is where **light and dark are reconciled**. A hue's -dark steps are not its light steps dimmed — purple's step 12 is near-black in -light and near-white in dark; step 3 is a lilac wash in light and a deep plum in -dark. Only step 9 is identical. Two hand-authored ramps under one name is what -lets a role be a single line and still behave in both modes, and what keeps -`.dark` to a restatement of values rather than the 50 hand-picked colours it used -to hold — which is where an accent tint and a categorical purple drifted into two -different purples in dark. - -To retint, change the ramp. The accent moved to Tailwind purple in five values, -which is the demonstration that **the ramp is the slot** — no rename, nothing -above it needed to know. - -### Which step, for what - -Twelve steps mean the same twelve jobs in every hue, so this is the map from a job -to a step. It used to generate roles; now it tells you which class to write: +| Palette | `--purple-9`, `--neutral-4` | Authored mode-aware steps; the source of color values | +| Semantic roles | `--bg-panel`, `--text-primary` | Named relationships, system rules or approved UI patterns | +| Components | Button, Panel, Tabs | Shared visual treatment and interaction | + +The old families layer added thirty names with one reader each. Answering a color +question meant following three references across two files. Removing it, and later +unused semantic roles, shortened the path from rendered color to its decision. +This history argues against speculative naming—not against an approved pattern +that intentionally gives a reused relationship one stable name. + +## Why a semantic name can be useful + +**Different steps by mode.** A panel needs a different relationship to its +surroundings in light and dark. One mode-aware step cannot express that additional +relationship. A semantic role contains the pairing; the component uses the same +name in both modes. The same reasoning applies when a new treatment needs, for +example, neutral 1 in light and neutral 2 in dark. A component-local dark override +would distribute ownership of that decision across its callers. + +**A system rule.** The text emphasis names keep three levels recognizable. Even +where `text-primary` uses the same step in both modes, the name represents a +reading hierarchy rather than an incidental color. The single border role serves +a similar purpose. + +**An approved repeated pattern.** A tinted callout repeated across screens can +benefit from one semantic name even if its light and dark definitions reference +the same step. The pattern, not the coincidence of a color, supplies the meaning. +Counting consumers can provide evidence; no count establishes human approval. + +The `@earned` annotation beside a token records that decision. A check can verify +its presence, category and relationship to the declarations. It cannot verify +that a sentence is true or that the human approved it. Approval remains a review +question, never something an agent can manufacture to satisfy CI. + +## Choosing a palette step + +This is a general map, not an inventory or a guarantee of contrast: | step | job | example | |---|---|---| | 3 | a tinted surface | `bg-purple-3` | | 4 | that tint, hovered | `bg-purple-4` | -| 8 | border, focus ring | `border-purple-8` | +| 8 | colored border or focus ring | `border-purple-8` | | 9 | solid fill | `bg-purple-9` | | 10 | that fill, hovered | `bg-purple-10` | | 12 | coloured text on a neutral surface | `text-purple-12` | -Adding a hue is mechanical — generate twelve steps, and the map above already -answers which one is the button. `cyan` and `orange` are authored and unused, so -that half is proven. - -**Being mechanical is exactly why it must not run ahead of the product.** This map -is how four status identities came to exist: twenty roles from one line of a -lookup table, nineteen of which nothing ever read. It tells you which step to take -*once a design needs the colour*. It is not a licence to pre-generate a set. - -**It is a good default, not a guarantee — measure the pair you actually use.** -Step 10 crosses over, darker than step 9 in light and lighter in dark, so a hover -reads as a press in light mode and a lift in dark with no special-casing. But -green's and blue's step 10 lift *too* far in dark mode and drop white text below -the APCA target. Likewise step 12 is the safe text step and step 11 is the -tempting one: red-11 is more obviously red and fails the Lc 60 body target on a -dark panel (59.7) and the dark composer (57.5), which is why error text is -`text-red-12`. A step used for text goes into `TEXT_ROLES` in -`scripts/design-system/check-contrast.mjs` so the guard measures what screens actually write. - -Palette values are Radix Colors (MIT), transcribed rather than depended on — -Radix is not on Block's Tech Radar, so this is a values-only copy with no -package. Its twelve-step contract is the one this system already described in -comments, step for step. Two deliberate divergences, both documented in -`tokens.css`: `text-*` roles take step 12 rather than the 11 Radix names -"low-contrast text" (Radix sizes 11 for WCAG 4.5:1; every hue's step 11 measured -Lc 55–61 against this system's Lc 60 target), and the **neutral ramp** is -hand-authored in both modes because it was sized against the real panel stack -rather than taken from an even ramp. - -### Naming grammar - -``` --[-][-][-] -``` - -Fixed order, so there is one correct spelling: `--bg-glass-primary-hover` is -legal, `--bg-glass-hover-primary` is not. One modifier, one material, one state per name. -This governs the fifteen roles; a ramp class is `--` and has -no grammar to get wrong, which is part of its appeal. - -Every word a token may be built from is listed in `VOCABULARY` in the registry. -No page renders it yet. Combining them freely is routine. Introducing a new -word is allowed but is the thing the audit reports on its own line — use an -existing word if one fits. - -### Text and borders register in their own namespaces - -**`--color-x` is not one utility. It is all of them.** One such line defines -`bg-x`, `text-x`, `border-x`, `ring-x` and the rest, every one pointing at the -same value. So the moment two roles differ only by *which prefix uses them*, that -namespace picks one and silently drops the other. - -This has now shipped twice, and both times the symptom looked like a design -mistake rather than a registration one. - -**Borders, first.** Text and borders shared the emphasis names while holding -different values — text at the dark end of the neutral ramp, borders at the light -end. Registered under `--color-*`, `border-primary` resolved to the *text* colour -and every hairline drew at near-black. It is why the first design system site had -black dividers while the tokens said `#d4d4d4`. Fixed with `--border-color-*`. - -**Text colour, second, and worse.** `--color-danger: var(--bg-danger)` also -defined `text-danger`, so error text rendered in red-9 — the saturated *fill* — -instead of red-12. On a dark panel that measured **APCA Lc 34 against a target of -60**, on real error messages, for months. And `check-contrast` passed the entire -time, because it measured `--text-danger`: a token that was declared, documented, -audited, and which no class could reach. - -So the rule, and it is a rule rather than a caution: - -> A text role registers as `--text-color-*`. A border role registers as -> `--border-color-*`. Only backgrounds use the shared `--color-*`. - -`src/shared/design-system/tokens/registry.test.ts` binds this to the file — a text or border -role registered under `--color-*` fails there now, rather than after shipping. - -**The general lesson is about the guards, not the namespaces.** A guard that -measures a token nothing resolves to is worse than no guard: it reports the -system is fine and is not wrong about the token, only about whether anything uses -it. When adding a check, verify it measures the value the *browser* computes. - -## Colour discipline - -- **Colour is signal.** Status, authorship, presence, and mentions earn colour. Ordinary structure does not. -- **Name colours after colour jobs, never after the thing on screen.** If the name is an interface element — mention, unread, badge, sidebar — it belongs in the component, assembled from roles that already exist. -- **A colour is used one of two ways: solid or tint.** Solid carries an action and takes its paired text; tint carries a meaning and takes coloured text. There is deliberately nothing between them. -- **Accent is signal, never structure.** Reaching for an accent surface where a neutral one belongs is the most common way a functional screen starts to look decorated. -- **Never use a status colour decoratively.** A green that does not mean success teaches people to stop trusting green. -- **There are no status roles, and that is deliberate.** Danger, success, warning, and info existed as four identities of five roles each — the accent's shape copied four times, generated from one line of a lookup table. **Nineteen of the twenty had no reader outside the page that displayed them.** They were invented by symmetry rather than by need, and the symmetry actively hid the decision: the red ramp alone offers red-3, red-8, red-9 and red-12 for "an error", and a set of ready-made names made that look settled when it never was. The proof is that `text-danger` shipped resolving to the wrong red and no design had ever looked closely enough to notice. **Status colour gets designed on the screen that needs it.** Until then the ramps are right there — pick a step, measure it, and once two screens pick the same one it has earned a name. -- **Write the step, not a name for the step.** Error text is `text-red-12` and the running-agent dot is `bg-green-9`, written where they are used. Both briefly had semantic names and both were one step, identical in both modes — a name in front of a number. The name comes back if the pattern repeats across screens, and it will be named for the pattern. -- **Measure the step, do not reason about it.** Red-11 is the more obviously red choice for error text and was the first pick; measured against every surface the text actually lands on, it fails the Lc 60 body target on a dark panel (59.7) and the dark composer (57.5). Two of five surfaces — invisible to judgement, decisive on inspection. Red-12 clears all five at 82–97. -- **Categorical colours are the one place appearance-naming would be allowed.** Telling two projects apart genuinely is a choice about appearance, so a hue name is honest there. No such roles exist yet — the palette carries eight hues, and a categorical role gets named when a feature actually needs to distinguish things, not before. -- **Opacity is not how you reach a subtler colour.** If a tint looks too strong, take a different palette step — do not dim a stronger one. `purple-950/50` composites to a real, correct colour, which is exactly the trap: it is a colour decision with no name, no light/dark pair, and nothing the contrast guard can measure. A missing shade is a missing palette step, and adding one is an ordinary reviewed edit. `scripts/design-system/check-color.mjs` enforces this. -- **Transparency is a different axis from shade, and it has its own tokens.** `glass-*` exists for surfaces something must show through. Alpha baked into a named value at the palette layer is the system working; alpha applied to a token in a component is not. -- **A dark value is authored, never derived.** A hue's dark steps are not its light steps darkened or dimmed — Tailwind's purple gets *more* saturated as it descends, so a dark tint drawn from its bottom end reads as oversaturated. This is why the palette holds two authored ramps per hue rather than one ramp and a transform. -- **Two tokens doing the same job must resolve to the same step, not merely to the same value.** Matching literals drift; a shared reference cannot. An accent tint and a hand-picked categorical purple were the same colour in light mode and two different colours in dark, and nothing caught it because both held their own value. - -## Contrast - -Buzz judges contrast with **APCA** (the perceptual algorithm in the WCAG 3 -draft), not the WCAG 2 ratio. Target **Lc 60** for body text, Lc 45 for large -or non-essential text. This is a deliberate position, taken with evidence, and -it is the rule a generated theme is measured against. - -- **Why.** The WCAG 2 ratio underweights blue and ignores polarity, so it - systematically recommends dark text on saturated mid-tone fills where light - text is plainly more readable. Measured: white on `#3b82f6` scores WCAG 3.68 - (fail) but APCA Lc 69 (pass); black on the same fill scores WCAG 5.71 (pass) - but Lc 40 — badly unreadable. Apple ships white on `#0088ff`–`#3daefc` in - Messages at WCAG 2.4–3.5, and Tailwind, Bootstrap, and Radix all ship white - on their primary blue below or near the WCAG threshold. Three independent - signals agree with the eye; one number disagrees with all of them. -- **APCA is not the looser choice.** It is stricter wherever WCAG 2 is - permissive: red on black (WCAG 5.25 pass, Lc 38 fail) and every dark-mode - mid-grey. Adopting it tightens more pairings than it relaxes. -- **Report both.** WCAG 2 is what an audit measures and what regulators - recognise today. Design to APCA, and know the WCAG number before shipping a - surface that will be scanned. Where they disagree, say so in the change. -- **Constrain the fill, never degrade the text.** If neither black nor white - carries a fill legibly, the fill is wrong — it is not a valid solid. Move the - fill's lightness and keep the hue; do not settle for the less-bad text. -- **A paired text token is derived, not authored.** `text-on-*` is a function of - its fill, so it is generated with the fill and never hand-set. Every hand-set - pairing in this system has been wrong at least once. -- **One implementation of the rule.** Desktop, mobile, and web must not each - compute their own pairing; they diverge and the same defect ships three times. -- **Size a text step against the worst surface it can land on**, not the most - common one. `bg-float` is the lightest dark surface, so it is the binding case - in dark mode; a step that only clears the target on `bg-inset` fails wherever - a popover opens. -- **Dark mode is not light mode inverted.** APCA is polarity-asymmetric: - light-on-dark needs more separation than the same WCAG ratio suggests. The - dark ramp's text steps are therefore lighter than a mirrored ramp would put - them — steps 9 and 10 sit above where linear spacing would. -- **`text-disabled` is deliberately below target.** Low contrast is the signal - that a control is unavailable. Never put information a person needs there. -- **`pnpm design:check` enforces this.** Every text role is measured against - every surface it can sit on, in both modes, parsed from `tokens.css` so the - check cannot drift from the tokens. Exceptions live in that script with a - stated reason, which keeps the list short and arguable. -- **A tint's hover is the hardest surface an identity has**, so a `text-*` role is - sized against that rather than against the neutral panel. Every failure the - audit found on a coloured surface was on a tint-hover, never at rest. -- **Hairline dividers are not held to a contrast target.** WCAG's 3:1 non-text - rule covers boundaries needed to identify a *control* or its state, not - grouping lines. Buzz's borders measure 1.2–1.8:1, which is where Radix and - Apple ship theirs; raising them would draw the box the fill already implies. - When the input layer lands, a control's own outline is a different question and - does need the 3:1 treatment. - -## Writing - -- **Every word earns its place.** Prefer the shortest phrasing that stays accurate. -- **Labels say what happens, not what the thing is called internally.** -- **Empty states say what this place is for and what to do next.** An empty state is a first impression, not an error. -- **Errors say what happened and what to do about it.** A message the person cannot act on is decoration. - -## Accessibility - -- **Every interactive element has explicit assistive semantics, and one owner per label.** Two widgets claiming the same label produces duplicate screen-reader stops. -- **Contrast comes from the paired token, not from judgement.** Where a background is not neutral, its text is named for it. -- **Keyboard, pointer, and shortcut paths must not diverge.** When adding an input handler, enumerate the ways a person can reach it and check the ones that are not the mouse. -- **Focus rings are for keyboard navigation, not pointer navigation.** Gate every authored focus treatment with `html[data-keyboard-navigation]` and `:focus-visible`; the app-root input-modality owner supplies that attribute. Mouse, pen, and touch focus stays quiet, including programmatic focus during a drag. Keyboard focus remains clearly visible on the control itself. -- **Colour is never the only carrier of meaning.** Pair it with text, shape, or position. - -## Responsiveness - -- **Design for narrow, intermediate, and wide, not just wide.** Intermediate widths are where layouts usually break. -- **Text scales with the person's preference and with zoom.** Anything readable uses relative units; fixed pixel text freezes and breaks zoom. - -## Growing the system - -Need something the system doesn't have? **Add it, mark it `proposed`, keep working.** There is no gate and no separate mechanism for one-offs — the moment the legal path is slower than writing a raw value, the system starts being bypassed. - -1. Search the component list, then the role list, by intent — not by colour. -2. If the decision is one ramp step in both modes, write that step directly. `bg-purple-3`, `text-red-12`, `border-purple-8`: the steps are public and mode-aware. -3. A state of an existing role — add the `-hover`, `-selected`, or `-disabled` sibling with both values, only if that state cannot be one step in both modes. -4. A material variant — add a named utility that carries its inseparable parts together. Glass is the example: its fill is deliberately not reachable alone, because fill without blur, rim, and lift is not glass. -5. A new role using existing words — only where one step cannot express both modes, or where the name enforces a rule. Add the name, both values, a one-sentence description, and an owner. -6. A new hue — generate its ramp. Never write a raw literal in a component; the palette is where literals live. -7. A new vocabulary word — allowed, but it is the thing the audit reports on its own line, so use an existing word if one fits. -8. **If none fit, stop and ask.** The answer is a proposed decision, not a raw value or an undocumented local exception. - -Every addition lands in `src/shared/design-system/tokens/registry.ts` in the same change that needed it. Promotion from `proposed` to `core` is a metadata change, not a rename. - -## Components - -- **Compose existing components freely. Never reimplement one.** -- **Focus is keyboard-only visual navigation.** Pointer focus stays quiet; keyboard navigation gives the focused control—not its container—a visible focus ring. Browsers can retain `:focus-visible` after programmatic focus too, so every component focus treatment must explicitly require `html[data-keyboard-navigation]`; do not rely on the base-layer reset to defeat a component-layer outline or shadow. Never add a `:focus-within` focus ring to a container: it duplicates the child control's signal and makes pointer focus noisy. -- **Base UI is the behavior layer.** Before writing an interactive shared component, inspect Base UI for the matching primitive. When one exists, wrap and compose it; Base UI owns focus, keyboard behavior, positioning, portals, and dismissal, while Buzz owns the visual language and product semantics. Reach for native elements only when Base UI has no matching primitive or the component is semantically static. -- **Need a variant that doesn't exist? Add it, mark it proposed.** If a variant almost fits but you would cancel several of its states, the base is wrong for the job and the system is missing a variant. -- **Never add a boolean prop for a visual difference.** Variants are enumerable, so an agent can read the list and pick; booleans multiply, and nobody designed most of the combinations. New props are for data and behaviour, not appearance. -- **Used by one feature? It lives in that feature's folder.** Used by two? Propose it as shared. The folder is the namespace. - -## Using the system - -- **Use an existing component before creating one, and an existing role before adding one.** -- **A new visual treatment that repeats belongs in the system, not in the feature.** -- **If a shared role fails in a real context, repair the role — never work around it locally.** A documentation specimen frame needed a border but `border-primary` was neutral-4 in both modes, which measured 1.08:1 on the dark page. The wrong response was the one we made first: name `neutral-6` directly and call documentation furniture a special case. The right response was to ask whether the one shared boundary role was wrong, measure it on every surface it reaches, and make it `neutral-4` light / `neutral-6` dark. The frame then returned to `border-primary`, and every product divider improved with it. **A local exception is evidence the shared decision is incomplete, not a licence to bypass it.** -- **When choosing a colour, surface, or boundary, use this order.** - 1. **Is there already a component for the thing?** Use it. Its variants are the decisions already made. Do not assemble its fill, border, shadow, or states yourself. - 2. **If the component has no variant for its background, add a variant rather than a sibling component.** `Tabs` is `chrome` (glass pill on the gradient) or `panel` (underline on a plain surface): same behaviour, different appearance. A second component would duplicate its keyboard and accessibility contract just to change styling. - 3. **If building a surface directly, choose its job before its colour.** Backdrop → `bg-app`; opaque region on it → `bg-panel`; region pushed in → `bg-inset`; thing floating above → `bg-float`; something the backdrop should show through → `glass-primary` or `glass-secondary`. Do not use a border to do the work of an inset fill. - 4. **If drawing a boundary, use `border-primary`.** It is the one shared hairline, already authored for both modes. If it does not read in the actual context, measure that context and fix this role — do not name a neutral step at the call site. - 5. **If choosing an accent or status colour, choose a ramp step and measure it.** `bg-purple-9`, `bg-purple-3`, `text-red-12`, `border-purple-8`: every step responds to mode. Do not invent a semantic name for one choice; a repeated *pattern* earns a name when Morgan says it does. - 6. **If the choice cannot be expressed by one step in both modes, make a proposed role with both values and a one-sentence job.** If the name only restates one step, it has not earned a role. If it is a whole treatment — glass is the example — make a utility that carries every inseparable part together. - 7. **If none of this feels clearly right, stop and ask.** Choosing a raw literal or a local exception is never the escape hatch. The system is deliberately allowed to grow; uncertainty is evidence of a missing decision, not a prompt to hide one. -- **If a screen looks right but breaks these rules, the rules are probably wrong — say so.** This document is meant to be argued with, not worked around. +The map proved useful across hues but did not make every pairing readable. Red-11 +looked more obviously red, yet measured below the body APCA target on a dark panel +and floating composer. Red-12 cleared those contexts. Green and blue hover fills +also needed inspection because a lighter dark-mode hover can reduce white-text +contrast. A numerical step is a starting point, not the result of measurement. + +Palette values began with Radix Colors (MIT), copied as values rather than a +package dependency. The neutral ramp is authored against the product's real panel +stack. Current deviations and measurements belong beside the values they explain. + +## Surface and depth + +Soft fills distinguish regions without drawing attention to their edges. Dense +rows often separate themselves through content or spacing; cards make more sense +for widgets, galleries and settings groups. A fill that means inset should not +quietly become the generic treatment for every grouping. + +Light-mode elevation is legible through a subtle shadow. On a dark background, +a lighter floating surface conveys depth more effectively than a stronger shadow. +Glass adds another distinction: less translucency reads as a higher layer, whereas +a brighter translucent child can look like the same material. + +A border measurement made the ownership problem concrete. Neutral-4 in dark mode +measured about 1.08:1 on a panel and 1.09:1 on the floating composer. Neutral-5 +matched the floating surface itself. Moving the dark side of `border-primary` to +neutral-6 improved the shared boundary instead of fixing one documentation frame +with a private color. Text and borders have different jobs despite sharing the +word “primary.” + +Tabs exposed a different mismatch: chrome glass over a white panel disappeared +into its selected pill. The useful distinction was a component variant—chrome +versus panel—not another tint or another Tabs implementation. Both variants share +keyboard behavior, accessibility and Base UI parts. + +Glass has real costs. Repeating a translucent fill on a child that covers its +parent compounds opacity; two identical layers are not one layer. A directional +rim communicates material, not a solid boundary. Glass over a flat fill offers +little benefit for its compositing cost. + +One contrast question remains deliberately unresolved: sampled dark glass over +bright regions of Night garden put secondary and tertiary text slightly below +target. The opaque-pair guard cannot see that composition. Dimmer bright stops, +more opaque glass or avoiding metadata on glass are different design choices; +real product content is needed to choose among them. + +## Typography and state + +Type roles combine size, line height, tracking and weight so identical labels +stay identical. Two weights distinguish content from structure/emphasis. Adding +weight just for selection or unread state would give that same distinction a +second meaning. Bold within prose is different: the paragraph owns size and the +phrase owns emphasis, so body type plus semibold composes naturally. + +The short type scale reflects a dense conversational app rather than a document +outline. In the original screen census, one size covered most text and two sizes +covered almost all of it. More intermediate steps invited distinctions the product +did not need. Mono roles are optically smaller than their sans partners because +equal numeric sizes make mono pull attention from surrounding prose. + +Relative sizing preserves the person's text preference and zoom. Fixed sizes and +arbitrary relative literals solve different halves of the problem: one freezes +scaling; the other still fragments the shared scale. + +Persistent selection needs to work without a cursor present. Disabled describes +unavailability, not another level of quiet emphasis. Hover is a relative change +in contrast whose direction depends on the surface. These distinctions explain +why a palette alone cannot encode interaction semantics. + +## Contrast and registration + +APCA is the design target because it considers polarity and better reflects some +saturated-fill pairings than the WCAG 2 ratio. WCAG remains important to reporting +and compliance; APCA measurements alone are not a conformance claim. Disabled +text and decorative hairlines have different purposes from necessary information +and control-identifying boundaries. + +The guard measures configured opaque text/surface pairs from the token source. +It does not discover every rendered pairing, certify glass, or replace inspection +in both modes. If a fill supports neither light nor dark readable text, choosing +the less-bad text is not a solution to the fill. + +Registration once hid two real defects. Registering text and border “primary” in +Tailwind's shared color namespace made borders resolve to the text color. Later, +a role's intended text value passed contrast while its generated utility resolved +to a different fill value. Dedicated text and border namespaces and source-bound +registry tests make those claims checkable. Measuring a correct value that no +browser consumer receives gives false confidence. + +## Components, ownership and documentation + +A shared component is a promise that an interaction and its visual language mean +the same thing wherever they appear. Base UI supplies interaction primitives; +Buzz supplies visual decisions and product semantics. Reusing a component's +variant preserves both owners. Overriding its states at a feature call site makes +the shared promise harder to maintain. + +The host owns appearance lifecycle, including preference recovery and portals. +The standalone viewer is an independent document. Importing its preference helper +into product UI would create a second owner, even if both initially display the +same mode. The host integration contract is documented separately in +`docs/design-system.md` because it describes product behavior, not design policy. + +The viewer renders real components and imports the canonical guidance directly. +Its registries hold descriptions and proposed/core status; tests bind checkable +facts to source and require specimens for registered components. A generated +second inventory is unnecessary when those existing owners can be checked. +Human-authored specimens still explain meaningful interaction states—an inventory +scanner cannot choose the best demonstration. diff --git a/src/shared/design-system/MAINTAINING_DESIGN_SYSTEM.md b/src/shared/design-system/MAINTAINING_DESIGN_SYSTEM.md deleted file mode 100644 index 22e4e704..00000000 --- a/src/shared/design-system/MAINTAINING_DESIGN_SYSTEM.md +++ /dev/null @@ -1,75 +0,0 @@ -# Maintaining the design system - -## The point - -The system helps Buzz stay clear and coherent as it grows. It should make the common choice easy, while leaving room for a design to be specific, surprising, or new. It is a shared memory of decisions that have been made—not a gate a designer needs to pass through. - -When the system cannot express the right design, change the system. Do not distort the design to satisfy an old rule. - -## Start with the work in front of you - -Build the screen or interaction you are trying to make. Use the existing colors, type, surfaces, and components when they fit. Look at the result in context, not just in a token table. - -A repeated need is evidence. One-off work is evidence too: it may reveal that a ramp step is wrong, a component needs another supported state, or a rule no longer reflects the product. - -## Color: use the ramps first - -Each color ramp has a version for light mode and a version for dark mode. A step has the same job in both: a quiet neutral surface, a selected row, a strong accent, or readable colored text. That lets a screen use a normal ramp step such as `neutral-2` and still work in both modes. - -A semantic color name is earned when a plain ramp step cannot tell the truth: - -- Light and dark need different steps for the same job. A panel, for example, needs a different relationship to its surroundings in each mode. -- A name protects a real rule. The three text levels and the single shared border weight are examples. -- A pattern has appeared enough times that giving it one name makes the product easier to understand and keep consistent. - -Do not create a semantic name just to avoid choosing a ramp step. If a name points to the same step in both modes, it is usually hiding a decision rather than recording one. - -## Refine the ramp when the product teaches us something - -The ramps are not sacred. They began as a useful starting point and should change when real product work gives us better evidence. - -The lighter search-field neutral is a good example. A real prototype showed that the old value was too heavy, so we corrected the neutral ramp instead of adding a search-only color or a fractional stop. That improvement now benefits every quiet compact surface that uses the same step. - -Before adding a new step or a new role, ask: - -1. Is an existing step already the right answer? -2. If not, is the ramp itself missing the right relationship? -3. Does light mode need one step and dark mode another? If so, give that relationship a semantic name. -4. Will people see this pattern again, or is it one moment in one screen? - -## Components grow from real repetition - -A shared component is a promise: the same interaction and visual language will work the same way wherever it appears. - -Build the first version where it is needed. Once another real use appears, decide what is truly shared: - -- a generic building block belongs in the design system; -- Buzz-specific behavior belongs with the product capability that owns it; -- a complete arrangement should only become reusable when another surface needs that exact arrangement. - -Do not build a catalogue in advance. Small, proven components are more flexible than a large component with a long list of switches. - -## Keep the important states visible - -Every interactive piece should have a clear default, hover, selected state where it applies, and disabled state where it matters. Selection is a lasting statement, not just a stronger hover. Keyboard focus should be visible, and the same action should work with a pointer or keyboard. - -Check narrow, medium, and wide layouts. Check light and dark mode together. A decision that works only in a component specimen is not finished. - -## What the guards are for - -The automated checks protect decisions that are easy to accidentally undo: readable contrast, the shared type scale, and the color system’s light/dark structure. - -They are guardrails, not judges. Each one has a named exception list with room to explain why a specific design needs to differ. Adding an exception is normal when it reflects a deliberate design choice. If the exceptions start pointing in one direction, improve or remove the rule instead of accumulating workarounds. - -A passing check means the system is internally consistent. Looking at the actual interface tells us whether the system is good. We need both. - -## A good change leaves a trace - -When you change the system, make the decision easy to find: - -- update the relevant token, component, or guidance; -- show the result on the design-system site where useful; -- use the new thing in real product work, not only in documentation; -- note the reason in plain language close to the decision. - -The goal is not to freeze Buzz into a style. It is to give every future designer and builder a clear starting point—and the confidence to improve it when the work asks for more. diff --git a/src/shared/design-system/README.md b/src/shared/design-system/README.md index b9a1c848..603ffb8e 100644 --- a/src/shared/design-system/README.md +++ b/src/shared/design-system/README.md @@ -9,10 +9,10 @@ port adds the system and its viewer without migrating existing app surfaces. - `src/shared/design-system/`: shared components, tokens, styles, chip presentation helpers, registries and colocated tests. - `tests/fixtures/design-system/`: standalone viewer, documentation pages and interactive specimens. It renders the real shared components, not copies. -- `scripts/design-system/`: scoped type, color, contrast and token-consumer checks. +- `scripts/design-system/`: type, color, contrast, authoring and token-consumer checks. -Read `DESIGN.md` for design decisions and `MAINTAINING_DESIGN_SYSTEM.md` for -stewardship. Proposed work retains its status; historical examples in the design +Read `AGENTS.md` for canonical authoring rules and `DESIGN.md` for explanations +and examples. The viewer renders these files directly. Proposed work retains its status; historical examples in the design guide are rationale, not a claim that those features were ported. ## Run from the repository root @@ -58,3 +58,24 @@ for compatibility names, text scaling, keyboard focus and actual-app regression The viewer stays an independent document and preference owner. Workspace experiments remain excluded from host startup; BentoWorkspace still reads viewer preferences and needs a host adapter before product adoption. + +## What validation establishes + +`design:check` runs the scoped type/color/contrast guards and the app-wide +authoring audit. The audit parses CSS and TSX using PostCSS and the parser shipped with pinned +Vite. It checks color-bearing declarations, static classes/conditional branches +and literal inline styles, including directly imported system components. It is +not a proof about computed styles, CSS classes restyling components indirectly, +re-exported components, arbitrary named CSS colors or dynamic class construction. Exact existing +findings are recorded in `scripts/design-system/authoring-baseline.json` so new +occurrences fail even in legacy files. Removed findings require removing their +baseline entries; there is no automatic baseline refresh command. + +Token and component registry tests verify inventory against source; viewer tests +verify registered components have specimens. `design:test` also exercises the +guards with valid and invalid fixtures. Root `check` and Vitest run these in CI. +The census reports usage, not whether a pattern deserves promotion. + +Earned-name reasons live beside default color-role declarations in `styles/tokens.css`, +not in a script allowlist. The guard reports missing or malformed reasons with +the next action; approval and design quality still require review. diff --git a/src/shared/design-system/styles/components.css b/src/shared/design-system/styles/components.css index 3ff57cd5..6c956ea9 100644 --- a/src/shared/design-system/styles/components.css +++ b/src/shared/design-system/styles/components.css @@ -361,7 +361,7 @@ cursor should read as more solid, per DESIGN.md § Surface and depth — but it is why a hover inside glass cannot simply reuse a whole material. */ /* `:not([data-selected])` because hover and selected can both be true at once — - a cursor can sit on the selected tab — and DESIGN.md § State says a selected + a cursor can sit on the selected tab — and AGENTS.md § Visual and interaction contract says a selected item in a toggle group is not interactive, so it must not react. These two selectors score identical specificity, so without the exclusion only source order would keep it right, and reordering them would silently give the diff --git a/src/shared/design-system/styles/tokens.css b/src/shared/design-system/styles/tokens.css index 173bb443..b90d8577 100644 --- a/src/shared/design-system/styles/tokens.css +++ b/src/shared/design-system/styles/tokens.css @@ -474,7 +474,7 @@ /* ============================================================ LAYER 2 — PUBLIC ROLES - The only layer a screen may use. Every value below points at + Semantic names complement the public mode-aware palette. Every value below points at a ramp step or semantic backdrop choice, except the documented exceptions at the bottom. ============================================================ */ @@ -483,13 +483,15 @@ /* --- Structural surfaces. --------------------------------- THE ROLES THAT EXIST BECAUSE A RAMP STEP CANNOT SAY THEM. Each one takes a DIFFERENT step per mode, so no class like - `bg-neutral-1` is correct in both. That is the whole test for - whether a name is earned; everything that passed it is here. + `bg-neutral-1` is correct in both. Other earned-name decisions are recorded beside their definitions. `bg-app` qualifies for the same reason in a different shape: the gradient itself is swapped per mode. */ + /* @earned material: The backdrop is a complete paired gradient treatment. */ --bg-app: var(--gradient-1); + /* @earned modes: Panels sit above the backdrop in both modes. */ --bg-panel: var(--neutral-1); /* dark: neutral-3 */ + /* @earned modes: Floating surfaces use lightness for dark-mode elevation. */ --bg-float: var(--neutral-1); /* dark: neutral-5 */ /* `bg-hover` was here and is gone. It was neutral-4 in both modes, so @@ -508,19 +510,24 @@ three levels of text and one border weight, and a ramp cannot say "three". Reaching for `text-neutral-11` because it looks right is how a fourth level appears without anyone deciding. */ + /* @earned rule: Primary reading level in the three-level text hierarchy. */ --text-primary: var(--neutral-12); + /* @earned rule: Supporting reading level in the three-level text hierarchy. */ --text-secondary: var(--neutral-10); + /* @earned rule: Metadata level in the three-level text hierarchy. */ --text-tertiary: var(--neutral-9); /* Deliberately below the contrast targets: low contrast is the signal that a control is unavailable, and WCAG exempts inactive controls for the same reason. Raising it to Lc 60 would make disabled read as enabled. Never put information here that a person needs. */ + /* @earned rule: Unavailability is a state, not a fourth text emphasis level. */ --text-disabled: var(--neutral-8); /* One border, not three. `primary` names the sole deliberate weight, and neutral-4 keeps it at the threshold of perception over both the panel and the atmospheric backdrop. A second weight arrives only with a design that proves a different boundary needs one. */ + /* @earned rule: One shared neutral border weight, paired for both modes. */ --border-primary: var(--neutral-4); /* --- Identity: deliberately absent. ---------------------- @@ -582,10 +589,15 @@ globals.css are the only legitimate readers: they pair each fill with its blur, rim, and lift so a material cannot arrive in pieces. */ + /* @earned material: Bundled glass treatment; fill is private to its material utility. */ --bg-glass-primary: var(--glass-2); + /* @earned material: Bundled glass treatment; fill is private to its material utility. */ --bg-glass-primary-hover: var(--glass-3); + /* @earned material: Bundled glass treatment; fill is private to its material utility. */ --bg-glass-secondary: var(--glass-4); + /* @earned material: Bundled glass treatment; fill is private to its material utility. */ --bg-glass-secondary-hover: var(--glass-5); + /* @earned modes: Selection stays opaque above chrome glass. */ --bg-chrome-selected: var(--neutral-1); /* --- Focus: `purple-8`, written directly. ----------------- @@ -595,8 +607,8 @@ `purple-9` and disappears. **A focused primary button needs the ring separated from the fill** — an offset in the surface colour, not a different ring hue, so one focus treatment works everywhere. That - belongs to whichever component layer introduces focus. It is in - DESIGN.md, which is where a rule lives; a token could not state it. */ + belongs to whichever component layer introduces focus. Its rationale is in + DESIGN.md; policy lives in AGENTS.md; a token could not state it. */ /* --- Geometry. Repeated relationships from Messages. ----- */ --space-workspace-inset: var(--space-4); @@ -642,6 +654,7 @@ than fixed, because white is readable on a blue or purple fill and unreadable on yellow or lime. Until the computation lands, these hold literals. */ + /* @earned rule: Paired text follows the fill, not the mode. */ --text-on-accent: #ffffff; /* `text-on-inverse` was `var(--neutral-1)` in both modes and went with diff --git a/src/shared/design-system/styles/typography.css b/src/shared/design-system/styles/typography.css index ca4094fb..f532c65d 100644 --- a/src/shared/design-system/styles/typography.css +++ b/src/shared/design-system/styles/typography.css @@ -178,7 +178,7 @@ --text-body--font-weight: var(--type-weight-normal); /* Body small. Secondary text: descriptions, timestamps, chip and tab labels, - help text under a control. The floor — see DESIGN.md § Type. */ + help text under a control. The floor — see DESIGN.md § Typography and state. */ --text-body-sm: var(--type-size-2); --text-body-sm--line-height: var(--type-leading-normal); --text-body-sm--letter-spacing: var(--type-tracking-2); diff --git a/src/shared/design-system/tokens/registry.test.ts b/src/shared/design-system/tokens/registry.test.ts index 02820c83..05a5b3d2 100644 --- a/src/shared/design-system/tokens/registry.test.ts +++ b/src/shared/design-system/tokens/registry.test.ts @@ -46,6 +46,26 @@ describe("token registry — every documented role exists", () => { expect(TOKENS).toContain("@theme inline {"); }); + it("documents every declared semantic color, not just listed roles", () => { + const declared = [ + ...new Set( + [ + ...DECLARATIONS.matchAll(/^\s*(--(?:bg|text|border)-[a-z0-9-]+):/gm), + ].map((match) => match[1]), + ), + ].sort(); + const documented = ROLE_GROUPS.flatMap((group) => + group.roles.map((role) => role.variable), + ).sort(); + expect(documented).toEqual(declared); + for (const group of ROLE_GROUPS) + for (const role of group.roles) { + expect(role.use.trim(), role.token).not.toBe(""); + if (role.status === "proposed") + expect(role.owner?.trim(), role.token).toBeTruthy(); + } + }); + it("declares every role the registry describes", () => { const missing: string[] = []; for (const group of ROLE_GROUPS) { @@ -57,6 +77,20 @@ describe("token registry — every documented role exists", () => { expect(missing).toEqual([]); }); + it("documents all authored palette hues and steps", () => { + const names = PALETTE.flatMap((hue) => + hue.steps.map((step) => step.variable), + ).sort(); + const declared = [ + ...new Set( + [...DECLARATIONS.matchAll(/^\s*(--[a-z]+-\d+):\s*#[0-9a-f]{6};/gm)].map( + (match) => match[1], + ), + ), + ].sort(); + expect(names).toEqual(declared); + }); + it("declares every palette step and ramp step it lists", () => { const missing: string[] = []; for (const hue of PALETTE) { diff --git a/src/shared/design-system/tokens/registry.ts b/src/shared/design-system/tokens/registry.ts index 7a99406b..c3104fb8 100644 --- a/src/shared/design-system/tokens/registry.ts +++ b/src/shared/design-system/tokens/registry.ts @@ -6,8 +6,7 @@ * The design system pages render from this, so a token added here appears in the * documentation automatically and the docs cannot drift from the system. * - * Adding to this file is a normal, unreviewed action — see the growth procedure - * in DESIGN.md. Every entry needs a `use` sentence and, if proposed, an `owner`. + * Authoring policy lives in AGENTS.md. Every entry needs a `use` sentence and, if proposed, an `owner`. */ /** Whether a token is part of the vetted system or someone's addition. */ @@ -34,7 +33,7 @@ export interface Ramp { translucent?: boolean; } -/** A public role. The only layer a screen may use. */ +/** A public semantic role. Authored palette steps are also public. */ export interface Role { /** The Tailwind class, e.g. `bg-panel`. */ token: string; @@ -297,7 +296,7 @@ export const ROLE_GROUPS: RoleGroup[] = [ id: "surfaces", name: "Structural surfaces", description: - "The roles that exist because a ramp step cannot say them: each takes a different step in light and dark, so no single class like `bg-neutral-1` is correct in both. That is the whole test for whether a colour earns a name. Ask one question: is it behind, on, above, or in? (`bg-hover` was here and is now written as `bg-neutral-4` \u2014 it was the same step in both modes. Hover is a *relationship*, one step more contrast than whatever is underneath, which no single token could express anyway.)", + "The roles that exist because a ramp step cannot say them: each takes a different step in light and dark, so no single class like `bg-neutral-1` is correct in both. Other reasons for naming are defined in AGENTS.md. Ask one question: is it behind, on, above, or in? (`bg-hover` was here and is now written as `bg-neutral-4` \u2014 it was the same step in both modes. Hover is a *relationship*, one step more contrast than whatever is underneath, which no single token could express anyway.)", roles: [ { token: "bg-app", @@ -326,7 +325,7 @@ export const ROLE_GROUPS: RoleGroup[] = [ id: "emphasis", name: "Emphasis", description: - "One three-level ramp shared by text and borders: normal, lesser, really lesser. States that are not levels of emphasis get their own names rather than extending the ramp.", + "Three text emphasis levels and one shared border weight. States that are not levels of emphasis get their own names rather than extending the ramp.", roles: [ { token: "text-primary", @@ -359,12 +358,26 @@ export const ROLE_GROUPS: RoleGroup[] = [ { token: "border-primary", variable: "--border-primary", - pointsAt: "neutral 4", + pointsAt: "neutral 4 light / neutral 6 dark", use: "Every deliberate line: panel boundaries, dividers, separators. One quiet weight; add another only when a design proves a different boundary needs it.", status: "core", }, ], }, + { + id: "paired-text", + name: "Paired text", + description: "Text chosen for its fill rather than the color mode.", + roles: [ + { + token: "text-on-accent", + variable: "--text-on-accent", + pointsAt: "paired accent text", + use: "Readable text on the accent fill.", + status: "core", + }, + ], + }, { id: "material", name: "Material", @@ -392,6 +405,13 @@ export const ROLE_GROUPS: RoleGroup[] = [ use: "The selected item inside chrome. Opaque rather than a glass step, because on glass elevation reads as less translucency, not a lighter colour.", status: "core", }, + { + token: "glass-secondary-interactive", + variable: "--bg-glass-secondary-hover", + pointsAt: "glass 5 on hover", + use: "Hover fill of the secondary glass material.", + status: "core", + }, { token: "glass-primary-interactive", variable: "--bg-glass-primary-hover", @@ -433,17 +453,6 @@ export const GRAMMAR_EXAMPLES = { illegal: ["bg-chrome-hover-glass", "bg-hover-chrome"], }; -/** Runs per change, by whoever needs the value. Nothing here needs permission. */ -export const GROWTH_PROCEDURE = [ - "Search the role list by intent, not by colour.", - "A state of an existing role — add the -hover, -selected, or -disabled sibling with both values.", - "A material variant of an existing role — add the -glass sibling with both values and its blur token.", - "A new role using existing words — add the name, both values, a one-sentence description, and an owner.", - "A new hue — generate its ramp, add roles pointing at steps. Never a literal.", - "A new vocabulary word — allowed, but it is the thing the audit reports on its own line, so use an existing word if one fits.", - "Never write a raw value. If nothing above applies, say so rather than reaching for a literal.", -]; - /* ============================================================ TYPOGRAPHY ============================================================ */ diff --git a/src/shared/design-system/ui/dockTheme.ts b/src/shared/design-system/ui/dockTheme.ts index 348035ea..ec94697a 100644 --- a/src/shared/design-system/ui/dockTheme.ts +++ b/src/shared/design-system/ui/dockTheme.ts @@ -45,7 +45,7 @@ const BASE = { insertion line reads more precisely than a highlighted half-tab. */ dndTabIndicator: "line", /* Tab reorder animation. Dockview's `smooth` transitions tab positions - during the drag, which DESIGN.md § Motion rules out: direct manipulation + during the drag, which AGENTS.md § Visual and interaction contract rules out: direct manipulation follows the pointer with no easing. */ tabAnimation: "default", } as const satisfies Partial; diff --git a/src/shared/design-system/ui/registry.test.ts b/src/shared/design-system/ui/registry.test.ts index 336a5390..8fef047e 100644 --- a/src/shared/design-system/ui/registry.test.ts +++ b/src/shared/design-system/ui/registry.test.ts @@ -11,11 +11,17 @@ import { * The component sources as text, read through Vite rather than `node:fs` so the * paths resolve the same way the app resolves them and no Node types are needed. */ -const SOURCES = import.meta.glob("/src/shared/design-system/ui/*.tsx", { - query: "?raw", - import: "default", - eager: true, -}) as Record; +const SOURCES = import.meta.glob( + [ + "/src/shared/design-system/ui/**/*.tsx", + "!/src/shared/design-system/ui/**/*.test.tsx", + ], + { + query: "?raw", + import: "default", + eager: true, + }, +) as Record; function read(source: string): string { const contents = SOURCES[`/src/${source}`]; @@ -32,6 +38,23 @@ function read(source: string): string { * quietly showing a stale answer on the page. */ describe("component registry — Base UI backing", () => { + it("accounts for every shared component source", () => { + // This experiment is embedded in the registered Swap workspace specimen. + const composedSpecimens = [ + "shared/design-system/ui/MultiPanelSwapExperiment.tsx", + ]; + expect( + Object.keys(SOURCES) + .map((path) => path.replace("/src/", "")) + .sort(), + ).toEqual( + [ + ...COMPONENTS.map((component) => component.source), + ...composedSpecimens, + ].sort(), + ); + }); + it("names a source file that exists for every component", () => { for (const component of COMPONENTS) { expect(() => read(component.source), component.slug).not.toThrow(); diff --git a/tests/fixtures/design-system/AGENTS.md b/tests/fixtures/design-system/AGENTS.md new file mode 100644 index 00000000..4663e77d --- /dev/null +++ b/tests/fixtures/design-system/AGENTS.md @@ -0,0 +1,5 @@ +# Viewer instructions + +Before editing, read and follow the canonical rules in +[the design-system AGENTS.md](../../../src/shared/design-system/AGENTS.md). +This pointer supplies discovery across the directory boundary; policy lives there. diff --git a/tests/fixtures/design-system/main.tsx b/tests/fixtures/design-system/main.tsx index 8810e157..642282ec 100644 --- a/tests/fixtures/design-system/main.tsx +++ b/tests/fixtures/design-system/main.tsx @@ -104,7 +104,8 @@ const pages = [ createRoute({ getParentRoute: () => design, path: "maintaining", - component: () => , + // Preserve old bookmarks without retaining a second document. + component: () => , }), createRoute({ getParentRoute: () => design, diff --git a/tests/fixtures/design-system/styles.css b/tests/fixtures/design-system/styles.css index 6006a0d9..bf9905d6 100644 --- a/tests/fixtures/design-system/styles.css +++ b/tests/fixtures/design-system/styles.css @@ -122,7 +122,7 @@ padding-left: 1.25rem; } - /* Dense reference rows: DESIGN.md § Density and rhythm asks for rows with + /* Dense reference rows: DESIGN.md § Surface and depth asks for rows with hairline dividers rather than a card per entry. Left-aligned because every cell is a name or a phrase, never a quantity to compare. */ .design-doc-table { diff --git a/tests/fixtures/design-system/ui/ColorPage.tsx b/tests/fixtures/design-system/ui/ColorPage.tsx index 2385a4cc..9a09eea8 100644 --- a/tests/fixtures/design-system/ui/ColorPage.tsx +++ b/tests/fixtures/design-system/ui/ColorPage.tsx @@ -221,7 +221,7 @@ export function ColorPage() {
{ROLE_GROUPS.map((group) => ( diff --git a/tests/fixtures/design-system/ui/DesignSystemLayout.tsx b/tests/fixtures/design-system/ui/DesignSystemLayout.tsx index f4203f36..af74e272 100644 --- a/tests/fixtures/design-system/ui/DesignSystemLayout.tsx +++ b/tests/fixtures/design-system/ui/DesignSystemLayout.tsx @@ -41,7 +41,6 @@ const SECTIONS: NavSection[] = [ { heading: "System", items: [ - ["Maintaining the system", "/design/maintaining"], ["DESIGN.md", "/design/design-guide"], ["AGENTS.md", "/design/agents-guide"], ], diff --git a/tests/fixtures/design-system/ui/MarkdownPage.tsx b/tests/fixtures/design-system/ui/MarkdownPage.tsx index 7fc9bc00..ae0e1ebf 100644 --- a/tests/fixtures/design-system/ui/MarkdownPage.tsx +++ b/tests/fixtures/design-system/ui/MarkdownPage.tsx @@ -15,7 +15,7 @@ function inline(text: string): ReactNode[] { }); } -/** A deliberately small reader for the three maintained, human-facing docs. +/** A deliberately small reader for the maintained, human-facing docs. * These documents are prose first; this is not a general Markdown product * renderer. Unsupported constructs remain readable text rather than creating a * second documentation format that can drift from the source file. */ diff --git a/tests/fixtures/design-system/ui/OverviewPage.tsx b/tests/fixtures/design-system/ui/OverviewPage.tsx index 08cedb08..fee8cd3d 100644 --- a/tests/fixtures/design-system/ui/OverviewPage.tsx +++ b/tests/fixtures/design-system/ui/OverviewPage.tsx @@ -52,8 +52,8 @@ export function OverviewPage() {

The order is fixed, so there is one correct spelling. The practical guidance for using and evolving it lives in{" "} - - Maintaining the system + + Authoring rules .

@@ -83,10 +83,10 @@ export function OverviewPage() { Glass - Maintaining the system + Authoring rules
diff --git a/tests/fixtures/design-system/ui/SystemDocumentPage.tsx b/tests/fixtures/design-system/ui/SystemDocumentPage.tsx index 85a2546c..432e1142 100644 --- a/tests/fixtures/design-system/ui/SystemDocumentPage.tsx +++ b/tests/fixtures/design-system/ui/SystemDocumentPage.tsx @@ -1,10 +1,8 @@ import agentsSource from "../../../../src/shared/design-system/AGENTS.md?raw"; import designSource from "../../../../src/shared/design-system/DESIGN.md?raw"; -import maintainingSource from "../../../../src/shared/design-system/MAINTAINING_DESIGN_SYSTEM.md?raw"; import { MarkdownPage } from "./MarkdownPage"; const DOCUMENTS = { - maintaining: maintainingSource, design: designSource, agents: agentsSource, } as const; diff --git a/tests/fixtures/design-system/ui/primitives.tsx b/tests/fixtures/design-system/ui/primitives.tsx index 7ea5bed3..a6869e1b 100644 --- a/tests/fixtures/design-system/ui/primitives.tsx +++ b/tests/fixtures/design-system/ui/primitives.tsx @@ -98,7 +98,7 @@ export function Stub({ what, decide }: { what: string; decide: string[] }) { * A list of uniform rows — the tabular case, where every row looks alike and the * eye needs a line to track along. Dividers, no container: the section heading * already says these belong together, so a fill behind them adds a box without - * adding meaning. See DESIGN.md § Density and rhythm. + * adding meaning. See DESIGN.md § Surface and depth. * * If the rows carry their own visual difference — a swatch, a type specimen — use * `Specimens` instead. Content that separates itself needs no divider. diff --git a/tests/fixtures/design-system/viewer.spec.ts b/tests/fixtures/design-system/viewer.spec.ts index eb0aa4e7..be6d31a6 100644 --- a/tests/fixtures/design-system/viewer.spec.ts +++ b/tests/fixtures/design-system/viewer.spec.ts @@ -34,7 +34,6 @@ test("built viewer loads every specimen and foundation without app connections", "Glass", "Motion", "Base UI backing", - "Maintaining the system", "DESIGN.md", "AGENTS.md", ]) { @@ -46,6 +45,17 @@ test("built viewer loads every specimen and foundation without app connections", await expect( nav.getByRole("link", { name: /Composer|Conversation|Agent work/ }), ).toHaveCount(0); + await page.goto(`${viewer}#/design/maintaining`); + await expect( + page.getByRole("heading", { name: "Design rationale", exact: true }), + ).toBeVisible(); + await nav.getByRole("link", { name: "AGENTS.md", exact: true }).click(); + await expect(page.locator("main")).toContainText( + "No local light/dark color overrides", + ); + await expect(page.locator("main")).toContainText( + "same palette step in both modes", + ); expect(failures).toEqual([]); expect(sockets).toEqual([]); }); diff --git a/vitest.config.ts b/vitest.config.ts index b8a5972e..3545af00 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -7,6 +7,7 @@ export default defineConfig({ }, test: { include: [ + "scripts/design-system/**/*.test.mjs", "src/**/*.test.{ts,tsx,mjs}", "dev/**/*.test.mjs", "tests/fixtures/design-system/**/*.test.{ts,tsx}", diff --git a/vitest.design.config.ts b/vitest.design.config.ts index 91169a36..6765f928 100644 --- a/vitest.design.config.ts +++ b/vitest.design.config.ts @@ -3,6 +3,7 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { include: [ + "scripts/design-system/**/*.test.mjs", "src/shared/design-system/**/*.test.ts", "tests/fixtures/design-system/**/*.test.ts", ],