From 01a30912eaa792888685027cb716491615e88927 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sun, 5 Jul 2026 03:33:00 +0800 Subject: [PATCH 01/10] refactor(ui): converge opacity onto --opacity-* tokens (#520 PR2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 4 semantic tokens (--opacity-disabled 0.5 / muted 0.65 / pending 0.8 / overlay 0.04 light→0.06 dark theme override) + 0/1 literal + keyframe exclusion. Replace ~50 bare opacity values (0.45-0.58→disabled, 0.6-0.7→muted, 0.72-0.9→pending, 0.04/0.06→overlay); 0.35 disabled (models browser-navbtn) snap disabled. maka-pulse keyframe 0.55 excluded (animation intent, not element state). opacity-converge-contract: bans bare opacity, whitelists 4 tokens + 0/1 + literals, strips @keyframes blocks (animation intent), lookbehind excludes --shadow-*-opacity token declarations. Updated stale-sessions + artifact-pane-lifecycle test expectations (0.7→var(--opacity-muted), 0.56→disabled, 0.78→pending). No @theme inline bridge: Tailwind opacity utilities are numeric (opacity-50), not semantic, so --opacity-* is CSS-only; TSX uses Tailwind opacity-* directly (out of CSS contract scope). Tracking issue: #520. --- .../artifact-pane-lifecycle-contract.test.ts | 8 +- .../opacity-converge-contract.test.ts | 138 ++++++++++++++++++ .../src/main/__tests__/stale-sessions.test.ts | 4 +- apps/desktop/src/renderer/maka-tokens.css | 27 +++- .../src/renderer/styles/chat-header.css | 2 +- apps/desktop/src/renderer/styles/composer.css | 4 +- .../src/renderer/styles/daily-review.css | 2 +- .../src/renderer/styles/health-center.css | 2 +- .../src/renderer/styles/model-switcher.css | 4 +- .../styles/module-pages/plan-reminders.css | 4 +- .../src/renderer/styles/onboarding.css | 8 +- .../src/renderer/styles/reasoning-panel.css | 4 +- .../src/renderer/styles/settings/bot.css | 4 +- .../renderer/styles/settings/connection.css | 2 +- .../src/renderer/styles/settings/models.css | 14 +- .../renderer/styles/settings/nav-sidebar.css | 2 +- .../styles/settings/provider-editor.css | 2 +- .../styles/settings/theme-preview.css | 4 +- apps/desktop/src/renderer/styles/sidebar.css | 2 +- .../src/renderer/styles/tool-output.css | 6 +- .../src/renderer/styles/tool-stream.css | 6 +- 21 files changed, 201 insertions(+), 48 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/opacity-converge-contract.test.ts diff --git a/apps/desktop/src/main/__tests__/artifact-pane-lifecycle-contract.test.ts b/apps/desktop/src/main/__tests__/artifact-pane-lifecycle-contract.test.ts index 7fc2235c5b..af84fea061 100644 --- a/apps/desktop/src/main/__tests__/artifact-pane-lifecycle-contract.test.ts +++ b/apps/desktop/src/main/__tests__/artifact-pane-lifecycle-contract.test.ts @@ -104,8 +104,8 @@ describe('ArtifactPane async lifecycle contract', () => { assert.match(src, /aria-busy=\{pendingArtifactListRetry \? 'true' : undefined\}/); assert.match(src, /data-pending=\{pendingArtifactListRetry \? 'true' : undefined\}/); assert.match(src, /pendingArtifactListRetry \? '重试中…' : '重试'/); - assert.match(css, /\.maka-artifact-error-retry:disabled \{[\s\S]*cursor: default;[\s\S]*opacity: 0\.56;[\s\S]*\}/); - assert.match(css, /\.maka-artifact-error-retry\[data-pending="true"\] \{[\s\S]*opacity: 0\.78;[\s\S]*\}/); + assert.match(css, /\.maka-artifact-error-retry:disabled \{[\s\S]*cursor: default;[\s\S]*opacity: var\(--opacity-disabled\);[\s\S]*\}/); + assert.match(css, /\.maka-artifact-error-retry\[data-pending="true"\] \{[\s\S]*opacity: var\(--opacity-pending\);[\s\S]*\}/); assert.doesNotMatch(src, /className="maka-artifact-error-retry"[\s\S]*onClick=\{\(\) => void refresh\(\)\}/); assert.match( subscriptionEffect, @@ -211,7 +211,7 @@ describe('ArtifactPane async lifecycle contract', () => { assert.match(toolbarBlock, /另存中…/); assert.match(toolbarBlock, /复制中…/); assert.match(toolbarBlock, /删除中…/); - assert.match(css, /\.maka-artifact-toolbar-button:disabled \{[\s\S]*cursor: default;[\s\S]*opacity: 0\.56;[\s\S]*\}/); - assert.match(css, /\.maka-artifact-toolbar-button\[data-pending="true"\] \{[\s\S]*opacity: 0\.78;[\s\S]*\}/); + assert.match(css, /\.maka-artifact-toolbar-button:disabled \{[\s\S]*cursor: default;[\s\S]*opacity: var\(--opacity-disabled\);[\s\S]*\}/); + assert.match(css, /\.maka-artifact-toolbar-button\[data-pending="true"\] \{[\s\S]*opacity: var\(--opacity-pending\);[\s\S]*\}/); }); }); diff --git a/apps/desktop/src/main/__tests__/opacity-converge-contract.test.ts b/apps/desktop/src/main/__tests__/opacity-converge-contract.test.ts new file mode 100644 index 0000000000..39f92f81bf --- /dev/null +++ b/apps/desktop/src/main/__tests__/opacity-converge-contract.test.ts @@ -0,0 +1,138 @@ +/** + * PR-OPACITY-CONVERGE-0 (issue #520 PR2): + * lock the opacity vocabulary so individual PRs can't silently drift + * back to ad-hoc opacity values. + * + * Three invariants: + * + * 1. CSS `opacity` must reference a whitelisted `--opacity-*` token, or be a + * literal (`0` / `1` / `inherit` / `initial` / `unset` / `revert`). Bare + * numbers (0.5, 0.65, 0.8) drift visually and bypass the semantic scale. + * `0` and `1` are literals, not tokens: 1 is opacity's default (= no + * opacity effect), 0 is the absolute hidden boundary — neither carries + * semantic info a token would add. + * + * 2. `--opacity-{disabled,muted,pending,overlay}` tokens are declared in + * `maka-tokens.css` with pinned values (0.5 / 0.65 / 0.8 / 0.04). + * `--opacity-overlay` is declared twice (light 0.04 + dark 0.06 theme + * override), so it's checked for both occurrences rather than exactly-once. + * + * 3. `opacity` inside `@keyframes` is EXCLUDED — animation in/out tracks are + * animation intent, not element state, and tokenizing them would force + * semantic tiers onto continuous animation values. + * + * No `@theme inline` bridge: Tailwind opacity utilities are numeric + * (opacity-50), not semantic names, so `--opacity-*` is CSS-only; TSX uses + * Tailwind opacity-* directly (out of CSS contract scope). + */ + +import { strict as assert } from 'node:assert'; +import { readFile } from 'node:fs/promises'; +import { describe, it } from 'node:test'; +import { REPO_ROOT, TOKENS_FILE, readAllRendererCss, stripCssComments, assertCustomPropPinnedOnce, parseCssCustomProps } from './css-test-helpers.js'; + +// --- token whitelist -------------------------------------------------------- + +const OPACITY_TOKEN_WHITELIST = new Set([ + '--opacity-disabled', + '--opacity-muted', + '--opacity-pending', + '--opacity-overlay', +]); + +const LITERAL_OK = /^(?:0|1|inherit|initial|unset|revert)$/; + +/** Strip `@keyframes` blocks (one level of nested `{}`) so opacity inside + * animation tracks isn't flagged — it's animation intent, not element state. */ +function stripKeyframes(css: string): string { + return css.replace(/@keyframes\s+[\w-]+\s*\{(?:[^{}]|\{[^{}]*\})*\}/g, ''); +} + +function extractOpacityValue(decl: string): string { + return decl.replace(/^opacity:\s*/i, '').replace(/;$/, '').trim(); +} + +// --- CSS scanning ----------------------------------------------------------- + +function findOpacityOffenders(css: string, label: string): string[] { + const stripped = stripKeyframes(stripCssComments(css)); + const offenders: string[] = []; + + for (const m of stripped.matchAll(/(? { + it('renderer CSS uses only whitelisted --opacity-* tokens or 0/1/literals (no bare numbers, keyframes excluded)', async () => { + const css = await readAllRendererCss(); + const offenders = findOpacityOffenders(css, 'renderer CSS'); + assert.deepEqual(offenders, [], `Offenders:\n ${offenders.join('\n ')}`); + }); + + it('--opacity-{disabled,muted,pending} tokens are declared exactly once with pinned values', async () => { + const tokens = await readFile(TOKENS_FILE, 'utf8'); + assertCustomPropPinnedOnce(tokens, '--opacity-disabled', '0.5'); + assertCustomPropPinnedOnce(tokens, '--opacity-muted', '0.65'); + assertCustomPropPinnedOnce(tokens, '--opacity-pending', '0.8'); + }); + + it('--opacity-overlay is declared twice (light 0.04 + dark 0.06 theme override)', async () => { + const tokens = await readFile(TOKENS_FILE, 'utf8'); + const values = parseCssCustomProps(tokens).get('--opacity-overlay') ?? []; + assert.equal(values.length, 2, `--opacity-overlay must be declared twice (light + dark); got ${values.length}: ${JSON.stringify(values)}`); + assert.ok(values.includes('0.04'), `light --opacity-overlay must include 0.04; got ${JSON.stringify(values)}`); + assert.ok(values.includes('0.06'), `dark --opacity-overlay must include 0.06; got ${JSON.stringify(values)}`); + }); +}); + +describe('opacity whitelist negative cases', () => { + it('rejects bare numbers (non-token, non-literal)', () => { + assert.ok(findOpacityOffenders('opacity: 0.5', 'test').length > 0, 'bare 0.5 must fail'); + assert.ok(findOpacityOffenders('opacity: 0.65', 'test').length > 0, 'bare 0.65 must fail'); + assert.ok(findOpacityOffenders('opacity: 0.8', 'test').length > 0, 'bare 0.8 must fail'); + assert.ok(findOpacityOffenders('opacity: 0.04', 'test').length > 0, 'bare 0.04 must fail'); + }); + + it('accepts whitelisted tokens and 0/1 + literals', () => { + assert.deepEqual(findOpacityOffenders('opacity: var(--opacity-disabled)', 'test'), []); + assert.deepEqual(findOpacityOffenders('opacity: var(--opacity-muted)', 'test'), []); + assert.deepEqual(findOpacityOffenders('opacity: var(--opacity-pending)', 'test'), []); + assert.deepEqual(findOpacityOffenders('opacity: var(--opacity-overlay)', 'test'), []); + assert.deepEqual(findOpacityOffenders('opacity: 0', 'test'), []); + assert.deepEqual(findOpacityOffenders('opacity: 1', 'test'), []); + assert.deepEqual(findOpacityOffenders('opacity: inherit', 'test'), []); + }); + + it('excludes opacity inside @keyframes (animation intent, not element state)', () => { + assert.deepEqual( + findOpacityOffenders('@keyframes x { 0% { opacity: 0; } 50% { opacity: 0.3; } 100% { opacity: 1; } }', 'test'), + [], + ); + assert.deepEqual( + findOpacityOffenders('@keyframes y { 0%, 100% { opacity: 0.35; transform: scale(0.85); } 50% { opacity: 1; } }', 'test'), + [], + ); + }); + + it('rejects typos and unknown tokens in var()', () => { + assert.ok(findOpacityOffenders('opacity: var(--opacity-mata)', 'test').length > 0, 'typo must fail'); + assert.ok(findOpacityOffenders('opacity: var(--opacity-private)', 'test').length > 0, 'unknown token must fail'); + }); +}); \ No newline at end of file diff --git a/apps/desktop/src/main/__tests__/stale-sessions.test.ts b/apps/desktop/src/main/__tests__/stale-sessions.test.ts index 1c837e7d91..4d596a3fbf 100644 --- a/apps/desktop/src/main/__tests__/stale-sessions.test.ts +++ b/apps/desktop/src/main/__tests__/stale-sessions.test.ts @@ -122,8 +122,8 @@ describe('stale session CSS contract (@kenji review gate)', () => { // Inactive stale dimming rule must exist. assert.match( css, - /\.maka-list-row\[data-stale="true"\]\s*\{[\s\S]*?opacity:\s*0\.7/, - 'expected `.maka-list-row[data-stale="true"]` opacity dim rule', + /\.maka-list-row\[data-stale="true"\]\s*\{[\s\S]*?opacity:\s*var\(--opacity-muted\)/, + 'expected `.maka-list-row[data-stale="true"]` opacity dim rule (var(--opacity-muted) per PR2)', ); // Active stale restoration rule must exist. assert.match( diff --git a/apps/desktop/src/renderer/maka-tokens.css b/apps/desktop/src/renderer/maka-tokens.css index 4bc07e06b5..fd284574d2 100644 --- a/apps/desktop/src/renderer/maka-tokens.css +++ b/apps/desktop/src/renderer/maka-tokens.css @@ -410,6 +410,20 @@ --duration-emphasized: 180ms; --duration-large: 280ms; + /* === opacity (visual state) === + Semantic opacity tiers for element-state visibility, not arbitrary + alpha. Three "dimmed" tiers cover disabled / muted / pending; live (1) + and hidden (0) are literal defaults (1 is opacity's default, 0 is the + absolute boundary — neither needs a token). --opacity-overlay is the + subtle decorative overlay on body::after (light 0.04 / dark 0.06). + Keyframe opacity (animation in/out) is excluded — it's animation + intent, not element state. + PR-OPACITY-CONVERGE-0 (issue #520 PR2). */ + --opacity-disabled: 0.5; /* disabled controls / strongly dimmed text */ + --opacity-muted: 0.65; /* secondary text / icons / muted affordances */ + --opacity-pending: 0.8; /* pending / active / in-progress states */ + --opacity-overlay: 0.04; /* body::after subtle overlay (light) */ + /* === page-card recipe === Single source of truth for the lifted "page card" chrome used by every top-level module page (Skills, Plan/Reminder, future Settings). Atlas @@ -540,6 +554,7 @@ --shadow-border-opacity: 0.15; --shadow-blur-opacity: 0.12; + --opacity-overlay: 0.06; /* P-SHADOW dark collapse (roadmap D2): blur shadows are nearly invisible on a dark shell but still cost compositing and, worse, @@ -914,12 +929,12 @@ inset: 0; pointer-events: none; z-index: var(--z-overlay); - opacity: 0.04; + opacity: var(--opacity-overlay); mix-blend-mode: overlay; background-image: url("data:image/svg+xml;utf8,"); background-size: 200px 200px; } - .dark body::after { opacity: 0.06; } + .dark body::after { opacity: var(--opacity-overlay); } @media (prefers-reduced-motion: reduce) { body::after { display: none; } } @@ -1233,7 +1248,7 @@ margin-top: var(--space-1-5); line-height: var(--leading-normal); font-style: italic; - opacity: 0.9; + opacity: var(--opacity-pending); } .maka-turn-thinking-actions { @@ -1474,7 +1489,7 @@ } .maka-code-block-copy[data-pending="true"] { cursor: progress; - opacity: 0.78; + opacity: var(--opacity-pending); } .maka-code-block-copy[data-copy-feedback="failed"] { color: var(--destructive); @@ -1812,7 +1827,7 @@ transition: opacity 180ms var(--ease-out-strong); } .maka-composer textarea:focus::placeholder { - opacity: 0.55; + opacity: var(--opacity-disabled); } .maka-composer-toolbar { display: flex; @@ -1849,7 +1864,7 @@ box-shadow: 0 0 0 3px oklch(from var(--focus-ring) l c h / 0.16); } .maka-button[disabled], - .maka-button[aria-disabled="true"] { opacity: 0.45; cursor: not-allowed; } + .maka-button[aria-disabled="true"] { opacity: var(--opacity-disabled); cursor: not-allowed; } /* ---- Tabs ------------------------------------------------------- */ diff --git a/apps/desktop/src/renderer/styles/chat-header.css b/apps/desktop/src/renderer/styles/chat-header.css index ee351d161b..990ee08035 100644 --- a/apps/desktop/src/renderer/styles/chat-header.css +++ b/apps/desktop/src/renderer/styles/chat-header.css @@ -874,7 +874,7 @@ .maka-palette-item[data-disabled="true"] { color: var(--muted-foreground); cursor: default; - opacity: 0.72; + opacity: var(--opacity-pending); } .maka-palette-item[data-pending="true"] { diff --git a/apps/desktop/src/renderer/styles/composer.css b/apps/desktop/src/renderer/styles/composer.css index 376c97fcb9..d79da025cc 100644 --- a/apps/desktop/src/renderer/styles/composer.css +++ b/apps/desktop/src/renderer/styles/composer.css @@ -42,7 +42,7 @@ .maka-composer-send-button:disabled { background: oklch(from var(--foreground) 0.72 0 h / 0.18); color: oklch(1 0 0); - opacity: 0.72; + opacity: var(--opacity-pending); cursor: not-allowed; } @@ -151,7 +151,7 @@ @keyframes maka-composer-stream-bounce { 0%, 60%, 100% { transform: translateY(0); - opacity: 0.55; + opacity: var(--opacity-disabled); } 30% { transform: translateY(-3px); diff --git a/apps/desktop/src/renderer/styles/daily-review.css b/apps/desktop/src/renderer/styles/daily-review.css index 839e2a3f85..6849e6d9d0 100644 --- a/apps/desktop/src/renderer/styles/daily-review.css +++ b/apps/desktop/src/renderer/styles/daily-review.css @@ -80,7 +80,7 @@ } .maka-daily-review-quick-run[data-pending="true"] { - opacity: 0.7; + opacity: var(--opacity-muted); } .maka-daily-review-archives { diff --git a/apps/desktop/src/renderer/styles/health-center.css b/apps/desktop/src/renderer/styles/health-center.css index 994c5d48c2..e046af328e 100644 --- a/apps/desktop/src/renderer/styles/health-center.css +++ b/apps/desktop/src/renderer/styles/health-center.css @@ -140,7 +140,7 @@ } .settingsHealthSummaryTile[data-empty="true"] { - opacity: 0.55; + opacity: var(--opacity-disabled); } .settingsHealthSummaryTile[data-tone="success"] { diff --git a/apps/desktop/src/renderer/styles/model-switcher.css b/apps/desktop/src/renderer/styles/model-switcher.css index 7e3f6819d8..d8662bade1 100644 --- a/apps/desktop/src/renderer/styles/model-switcher.css +++ b/apps/desktop/src/renderer/styles/model-switcher.css @@ -8,11 +8,11 @@ } .maka-model-switcher[data-disabled="true"] { - opacity: 0.56; + opacity: var(--opacity-disabled); } .maka-model-switcher[data-pending="true"] { - opacity: 0.78; + opacity: var(--opacity-pending); cursor: progress; } diff --git a/apps/desktop/src/renderer/styles/module-pages/plan-reminders.css b/apps/desktop/src/renderer/styles/module-pages/plan-reminders.css index 600a41710d..61dc1605b0 100644 --- a/apps/desktop/src/renderer/styles/module-pages/plan-reminders.css +++ b/apps/desktop/src/renderer/styles/module-pages/plan-reminders.css @@ -490,7 +490,7 @@ } .maka-plan-card[data-status="completed"] { - opacity: 0.62; + opacity: var(--opacity-muted); } .maka-plan-card-chrome, @@ -549,7 +549,7 @@ .maka-plan-card-menu-trigger:disabled { cursor: default; - opacity: 0.45; + opacity: var(--opacity-disabled); } .maka-plan-card-menu { diff --git a/apps/desktop/src/renderer/styles/onboarding.css b/apps/desktop/src/renderer/styles/onboarding.css index 248a9096bb..7327f89152 100644 --- a/apps/desktop/src/renderer/styles/onboarding.css +++ b/apps/desktop/src/renderer/styles/onboarding.css @@ -542,7 +542,7 @@ .maka-onboarding-quickchat-submit:disabled { background: color-mix(in srgb, var(--action) 58%, white); color: var(--action-foreground); - opacity: 0.6; + opacity: var(--opacity-muted); cursor: not-allowed; } @@ -618,7 +618,7 @@ } .maka-first-run-task-suggestion:disabled { - opacity: 0.55; + opacity: var(--opacity-disabled); cursor: not-allowed; } @@ -665,7 +665,7 @@ top: 66px; width: min(460px, calc(100% - 48px)); height: 12px; - opacity: 0.7; + opacity: var(--opacity-muted); } .maka-list-row-name { @@ -781,7 +781,7 @@ * rebind makes these sessions still functional. */ .maka-list-row[data-stale="true"] { - opacity: 0.7; + opacity: var(--opacity-muted); } .maka-list-row[data-stale="true"][data-active="true"] { diff --git a/apps/desktop/src/renderer/styles/reasoning-panel.css b/apps/desktop/src/renderer/styles/reasoning-panel.css index 6e4e49310c..91482dd632 100644 --- a/apps/desktop/src/renderer/styles/reasoning-panel.css +++ b/apps/desktop/src/renderer/styles/reasoning-panel.css @@ -54,7 +54,7 @@ animation: maka-reasoning-panel-pulse 1.4s var(--ease-in-out-strong) infinite; } @keyframes maka-reasoning-panel-pulse { - 0%, 100% { opacity: 0.6; transform: scale(1); } + 0%, 100% { opacity: var(--opacity-muted); transform: scale(1); } /* PR-FE-BUG-HUNT-6 (kenji aesthetic audit reminder 9, finding #5): standardize live-dot pulse amplitude across the three streaming indicators. The semantic is identical ("live work happening") @@ -67,7 +67,7 @@ @media (prefers-reduced-motion: reduce) { .maka-reasoning-panel-dot { animation: none; - opacity: 0.8; + opacity: var(--opacity-pending); } } .maka-reasoning-panel-label { diff --git a/apps/desktop/src/renderer/styles/settings/bot.css b/apps/desktop/src/renderer/styles/settings/bot.css index f3088ef7cc..6136d434db 100644 --- a/apps/desktop/src/renderer/styles/settings/bot.css +++ b/apps/desktop/src/renderer/styles/settings/bot.css @@ -50,10 +50,10 @@ options. Clicking still navigates so the user can read the unavailable-state notice on the detail pane. */ .settingsBotList button[data-support="planned"] { - opacity: 0.55; + opacity: var(--opacity-disabled); } .settingsBotList button[data-support="planned"]:hover { - opacity: 0.75; + opacity: var(--opacity-pending); } .settingsBotList button[data-support="planned"][data-active="true"] { opacity: 1; diff --git a/apps/desktop/src/renderer/styles/settings/connection.css b/apps/desktop/src/renderer/styles/settings/connection.css index 0a25c737e9..e45a71dcf4 100644 --- a/apps/desktop/src/renderer/styles/settings/connection.css +++ b/apps/desktop/src/renderer/styles/settings/connection.css @@ -83,7 +83,7 @@ color: var(--destructive-text, var(--destructive, red)); } .settingsConnectionRow[data-status="disabled"] { - opacity: 0.72; + opacity: var(--opacity-pending); } .settingsConnectionRow[data-default="true"] { box-shadow: 0 0 0 2px oklch(from var(--control) l c h / 0.12); diff --git a/apps/desktop/src/renderer/styles/settings/models.css b/apps/desktop/src/renderer/styles/settings/models.css index 454e2d0386..935e361f15 100644 --- a/apps/desktop/src/renderer/styles/settings/models.css +++ b/apps/desktop/src/renderer/styles/settings/models.css @@ -113,7 +113,7 @@ } .enabledConnRow[data-disabled="true"] { - opacity: 0.6; + opacity: var(--opacity-muted); } .enabledConnTitle { @@ -350,7 +350,7 @@ } .maka-browser-navbtn:disabled { - opacity: 0.35; + opacity: var(--opacity-disabled); cursor: default; } @@ -508,7 +508,7 @@ .maka-artifact-error-retry:disabled { cursor: default; - opacity: 0.56; + opacity: var(--opacity-disabled); } .maka-artifact-error-retry:disabled:hover { @@ -516,7 +516,7 @@ } .maka-artifact-error-retry[data-pending="true"] { - opacity: 0.78; + opacity: var(--opacity-pending); } .maka-artifact-error-retry:focus-visible { @@ -581,7 +581,7 @@ } .maka-artifact-row[data-deleted="true"] { - opacity: 0.6; + opacity: var(--opacity-muted); } .maka-artifact-row-icon { @@ -873,7 +873,7 @@ .maka-artifact-toolbar-button:disabled { cursor: default; - opacity: 0.56; + opacity: var(--opacity-disabled); } .maka-artifact-toolbar-button:disabled:hover { @@ -881,7 +881,7 @@ } .maka-artifact-toolbar-button[data-pending="true"] { - opacity: 0.78; + opacity: var(--opacity-pending); } .maka-artifact-toolbar-destructive { diff --git a/apps/desktop/src/renderer/styles/settings/nav-sidebar.css b/apps/desktop/src/renderer/styles/settings/nav-sidebar.css index e91fe5d0a5..b91ca12a55 100644 --- a/apps/desktop/src/renderer/styles/settings/nav-sidebar.css +++ b/apps/desktop/src/renderer/styles/settings/nav-sidebar.css @@ -215,7 +215,7 @@ .settingsNavItem:disabled { cursor: default; - opacity: 0.45; + opacity: var(--opacity-disabled); } .settingsNavGlyph { diff --git a/apps/desktop/src/renderer/styles/settings/provider-editor.css b/apps/desktop/src/renderer/styles/settings/provider-editor.css index 78a6da74dd..6cafd26555 100644 --- a/apps/desktop/src/renderer/styles/settings/provider-editor.css +++ b/apps/desktop/src/renderer/styles/settings/provider-editor.css @@ -62,7 +62,7 @@ /* Gated providers (preview / not-yet-open) stay inert and muted; the * provider-surface gate forbids rendering them as actionable. */ .providerCatalogRow[data-disabled="true"] { - opacity: 0.66; + opacity: var(--opacity-muted); cursor: not-allowed; filter: grayscale(0.28); } diff --git a/apps/desktop/src/renderer/styles/settings/theme-preview.css b/apps/desktop/src/renderer/styles/settings/theme-preview.css index 4e45a956f0..fb30e874a0 100644 --- a/apps/desktop/src/renderer/styles/settings/theme-preview.css +++ b/apps/desktop/src/renderer/styles/settings/theme-preview.css @@ -436,7 +436,7 @@ box-shadow: 0 0 0 3px oklch(from var(--accent) l c h / 0.22); } .settingsBotAction[disabled] { - opacity: 0.45; + opacity: var(--opacity-disabled); cursor: not-allowed; } @@ -711,7 +711,7 @@ } .settingsWechatQrSecondary:disabled { - opacity: 0.58; + opacity: var(--opacity-disabled); cursor: progress; } diff --git a/apps/desktop/src/renderer/styles/sidebar.css b/apps/desktop/src/renderer/styles/sidebar.css index 804fca92ca..b699693c64 100644 --- a/apps/desktop/src/renderer/styles/sidebar.css +++ b/apps/desktop/src/renderer/styles/sidebar.css @@ -469,7 +469,7 @@ } .maka-search-modal-result[disabled] { cursor: default; - opacity: 0.7; + opacity: var(--opacity-muted); } .maka-search-modal-result-title { min-width: 0; diff --git a/apps/desktop/src/renderer/styles/tool-output.css b/apps/desktop/src/renderer/styles/tool-output.css index 0695bd4c78..a230ffca45 100644 --- a/apps/desktop/src/renderer/styles/tool-output.css +++ b/apps/desktop/src/renderer/styles/tool-output.css @@ -240,11 +240,11 @@ color: var(--info-text); } .maka-composer-mode-chip[data-pending="true"] { - opacity: 0.7; + opacity: var(--opacity-muted); } .maka-composer-mode-chip:disabled { cursor: not-allowed; - opacity: 0.55; + opacity: var(--opacity-disabled); } .maka-composer-mode-menu { @@ -375,7 +375,7 @@ } .maka-composer-tool-button:disabled { - opacity: 0.45; + opacity: var(--opacity-disabled); cursor: not-allowed; } diff --git a/apps/desktop/src/renderer/styles/tool-stream.css b/apps/desktop/src/renderer/styles/tool-stream.css index 654c736742..8fdcc2f85c 100644 --- a/apps/desktop/src/renderer/styles/tool-stream.css +++ b/apps/desktop/src/renderer/styles/tool-stream.css @@ -312,7 +312,7 @@ } .settingsMemoryEntryGroup[data-archived="true"] { - opacity: 0.82; + opacity: var(--opacity-pending); } .settingsMemoryEntryGroupHeader { @@ -597,7 +597,7 @@ } .settingsMemoryEditor textarea:disabled { - opacity: 0.65; + opacity: var(--opacity-muted); cursor: not-allowed; } @@ -687,7 +687,7 @@ .maka-first-run-checklist-error-action:disabled { cursor: default; - opacity: 0.58; + opacity: var(--opacity-disabled); } .maka-first-run-checklist-error-action:disabled:hover { From 7490af16b72b6b159c64bcd42b96a99b5366e69a Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sun, 5 Jul 2026 03:36:49 +0800 Subject: [PATCH 02/10] refactor(ui): converge focus-ring recipe onto --focus-ring-width/offset (#520 PR2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2 new geometry tokens (--focus-ring-width 2px / --focus-ring-offset 2px) + 2 existing color tokens (--focus-ring strong / --ring subtle). Recipe: outline: var(--focus-ring-width) solid var(--focus-ring|--ring); outline-offset: var(--focus-ring-offset). Snap 3 outliers to 2px (skills 3px width, nav-sidebar -2px offset, models 6px offset) — no whitelist. box-shadow ring (0 0 0 2px var(--ring)) width → var(--focus-ring-width). alpha variants (oklch from var(--focus-ring) / 0.42) kept (color layer). link focus (models 962, 1px + link color) whitelisted as one-off — it's a link affordance, not the keyboard focus-ring recipe. focus-ring-recipe-contract: bans bare outline width/offset px + box-shadow ring width, whitelists var(--focus-ring-width/offset) + outline:none/0 + link-focus one-off. Updated command-palette-a11y-copy-contract expectation (outline: 2px → var(--focus-ring-width)). Tracking issue: #520. --- ...command-palette-a11y-copy-contract.test.ts | 2 +- .../focus-ring-recipe-contract.test.ts | 126 ++++++++++++++++++ apps/desktop/src/renderer/maka-tokens.css | 11 +- .../src/renderer/styles/chat-header.css | 12 +- .../src/renderer/styles/health-center.css | 4 +- .../src/renderer/styles/markdown-link.css | 4 +- .../renderer/styles/module-pages/skills.css | 4 +- .../src/renderer/styles/onboarding.css | 8 +- .../src/renderer/styles/permission-center.css | 4 +- .../src/renderer/styles/settings/models.css | 6 +- .../renderer/styles/settings/nav-sidebar.css | 12 +- apps/desktop/src/renderer/styles/sidebar.css | 8 +- .../src/renderer/styles/tool-stream.css | 12 +- 13 files changed, 174 insertions(+), 39 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/focus-ring-recipe-contract.test.ts diff --git a/apps/desktop/src/main/__tests__/command-palette-a11y-copy-contract.test.ts b/apps/desktop/src/main/__tests__/command-palette-a11y-copy-contract.test.ts index 62c8a391f7..749f6be0a9 100644 --- a/apps/desktop/src/main/__tests__/command-palette-a11y-copy-contract.test.ts +++ b/apps/desktop/src/main/__tests__/command-palette-a11y-copy-contract.test.ts @@ -92,7 +92,7 @@ describe('Command palette accessibility and visible copy', () => { /\.maka-palette-item:active:not\(\[data-disabled="true"\]\)\s*\{[\s\S]*background:\s*var\(--state-selected-bg\);/, 'Palette rows need pressed feedback via the state-selected background, not a scale transform', ); - assert.match(styles, /\.maka-palette-item:focus-visible\s*\{[\s\S]*outline:\s*2px solid var\(--ring\);/); + assert.match(styles, /\.maka-palette-item:focus-visible\s*\{[\s\S]*outline:\s*var\(--focus-ring-width\) solid var\(--ring\);/); assert.match(styles, /\.maka-palette-item\[data-pending="true"\]\s*\{[\s\S]*cursor:\s*progress;/); assert.match( styles, diff --git a/apps/desktop/src/main/__tests__/focus-ring-recipe-contract.test.ts b/apps/desktop/src/main/__tests__/focus-ring-recipe-contract.test.ts new file mode 100644 index 0000000000..5c262a22c3 --- /dev/null +++ b/apps/desktop/src/main/__tests__/focus-ring-recipe-contract.test.ts @@ -0,0 +1,126 @@ +/** + * PR-FOCUS-RING-RECIPE-0 (issue #520 PR2): + * lock the focus-ring recipe so outline width/offset and box-shadow ring + * width can't drift back to hand-written px values. + * + * Three invariants: + * + * 1. `outline:` width must be `var(--focus-ring-width)` (or `none` / `0` to + * disable focus). Color stays free: `--focus-ring` (strong accent) or + * `--ring` (subtle foreground) for two focus strengths, plus alpha + * variants (`oklch(from var(--focus-ring) l c h / 0.42)`). One geometric + * recipe, two color strengths. + * 2. `outline-offset:` must be `var(--focus-ring-offset)`. + * 3. `box-shadow: 0 0 0 var(--ring)` (global *:focus-visible ring) must + * use `var(--focus-ring-width)` for the ring width. + * + * `--focus-ring-width: 2px` + `--focus-ring-offset: 2px` are declared in + * maka-tokens.css. The link-focus outline (`outline: 1px solid + * oklch(from var(--link) …)`) is whitelisted as a one-off — it's a link + * affordance, not the keyboard focus-ring recipe, and uses a different + * color/width on purpose. + */ + +import { strict as assert } from 'node:assert'; +import { readFile } from 'node:fs/promises'; +import { describe, it } from 'node:test'; +import { REPO_ROOT, TOKENS_FILE, readAllRendererCss, stripCssComments, assertCustomPropPinnedOnce } from './css-test-helpers.js'; + +// --- whitelist ------------------------------------------------------------- + +/** Link-focus one-off: `outline: 1px solid oklch(from var(--link) …)` — + * not the keyboard focus-ring recipe (different color + width on purpose). */ +const LINK_FOCUS_RE = /outline:\s*1px\s+solid\s+oklch\(from\s+var\(--link\)/i; + +// --- scanning -------------------------------------------------------------- + +function findFocusRingOffenders(css: string, label: string): string[] { + const stripped = stripCssComments(css); + const offenders: string[] = []; + + // outline: solid — width must be var(--focus-ring-width), or none/0 + for (const m of stripped.matchAll(/(? var(--ring) — width must be var(--focus-ring-width) + for (const m of stripped.matchAll(/box-shadow:\s*0\s+0\s+0\s+(\d+px)\s+var\(--ring\)/gi)) { + offenders.push(`${label}: ${m[0].trim()} (bare ring width in box-shadow — use var(--focus-ring-width))`); + } + + return offenders; +} + +// === tests ================================================================== + +describe('PR-FOCUS-RING-RECIPE-0 contract', () => { + it('renderer CSS uses var(--focus-ring-width/--offset) for outline width/offset + box-shadow ring (no bare px)', async () => { + const css = await readAllRendererCss(); + const offenders = findFocusRingOffenders(css, 'renderer CSS'); + assert.deepEqual(offenders, [], `Offenders:\n ${offenders.join('\n ')}`); + }); + + it('--focus-ring-width / --focus-ring-offset are declared exactly once with pinned values', async () => { + const tokens = await readFile(TOKENS_FILE, 'utf8'); + assertCustomPropPinnedOnce(tokens, '--focus-ring-width', '2px'); + assertCustomPropPinnedOnce(tokens, '--focus-ring-offset', '2px'); + }); +}); + +describe('focus-ring recipe negative cases', () => { + it('rejects bare outline width px', () => { + assert.ok(findFocusRingOffenders('outline: 2px solid var(--focus-ring)', 'test').length > 0, 'bare 2px must fail'); + assert.ok(findFocusRingOffenders('outline: 3px solid var(--ring)', 'test').length > 0, 'bare 3px must fail'); + }); + + it('accepts var(--focus-ring-width) + any color (focus-ring/ring/alpha)', () => { + assert.deepEqual(findFocusRingOffenders('outline: var(--focus-ring-width) solid var(--focus-ring)', 'test'), []); + assert.deepEqual(findFocusRingOffenders('outline: var(--focus-ring-width) solid var(--ring)', 'test'), []); + assert.deepEqual(findFocusRingOffenders('outline: var(--focus-ring-width) solid oklch(from var(--focus-ring) l c h / 0.42)', 'test'), []); + }); + + it('accepts outline: none / 0 (disable focus) and link-focus one-off', () => { + assert.deepEqual(findFocusRingOffenders('outline: none', 'test'), []); + assert.deepEqual(findFocusRingOffenders('outline: 0', 'test'), []); + assert.deepEqual(findFocusRingOffenders('outline: 1px solid oklch(from var(--link) l c h / 0.34)', 'test'), []); + }); + + it('rejects bare outline-offset px (and negatives)', () => { + assert.ok(findFocusRingOffenders('outline-offset: 2px', 'test').length > 0, 'bare 2px must fail'); + assert.ok(findFocusRingOffenders('outline-offset: -2px', 'test').length > 0, 'bare -2px must fail'); + assert.ok(findFocusRingOffenders('outline-offset: 6px', 'test').length > 0, 'bare 6px must fail'); + }); + + it('accepts var(--focus-ring-offset)', () => { + assert.deepEqual(findFocusRingOffenders('outline-offset: var(--focus-ring-offset)', 'test'), []); + }); + + it('rejects bare ring width in box-shadow: 0 0 0 var(--ring)', () => { + assert.ok(findFocusRingOffenders('box-shadow: 0 0 0 2px var(--ring)', 'test').length > 0, 'bare ring width must fail'); + }); + + it('accepts box-shadow ring with var(--focus-ring-width)', () => { + assert.deepEqual(findFocusRingOffenders('box-shadow: 0 0 0 var(--focus-ring-width) var(--ring)', 'test'), []); + }); +}); \ No newline at end of file diff --git a/apps/desktop/src/renderer/maka-tokens.css b/apps/desktop/src/renderer/maka-tokens.css index fd284574d2..35f6aaf433 100644 --- a/apps/desktop/src/renderer/maka-tokens.css +++ b/apps/desktop/src/renderer/maka-tokens.css @@ -424,6 +424,15 @@ --opacity-pending: 0.8; /* pending / active / in-progress states */ --opacity-overlay: 0.04; /* body::after subtle overlay (light) */ + /* === focus-ring recipe === + --focus-ring is the strong accent color (existing); --ring is the + subtle foreground-derived color (existing, for compact/secondary + focus). These two geometry tokens converge outline width/offset so + every focus ring shares one recipe instead of hand-written 2px. + PR-FOCUS-RING-RECIPE-0 (issue #520 PR2). */ + --focus-ring-width: 2px; + --focus-ring-offset: 2px; + /* === page-card recipe === Single source of truth for the lifted "page card" chrome used by every top-level module page (Skills, Plan/Reminder, future Settings). Atlas @@ -917,7 +926,7 @@ *:focus-visible { outline: none; - box-shadow: 0 0 0 2px var(--ring); + box-shadow: 0 0 0 var(--focus-ring-width) var(--ring); } /* Subtle film-grain overlay. Per the taste-skill and frontend-design diff --git a/apps/desktop/src/renderer/styles/chat-header.css b/apps/desktop/src/renderer/styles/chat-header.css index 990ee08035..6cc7bc1c04 100644 --- a/apps/desktop/src/renderer/styles/chat-header.css +++ b/apps/desktop/src/renderer/styles/chat-header.css @@ -87,8 +87,8 @@ } .maka-chat-header-memory-pill:focus-visible { - outline: 2px solid var(--focus-ring); - outline-offset: 2px; + outline: var(--focus-ring-width) solid var(--focus-ring); + outline-offset: var(--focus-ring-offset); } .maka-chat-header-mode-pill { @@ -135,8 +135,8 @@ } .maka-chat-header-alert:focus-visible { - outline: 2px solid var(--focus-ring); - outline-offset: 2px; + outline: var(--focus-ring-width) solid var(--focus-ring); + outline-offset: var(--focus-ring-offset); } .maka-chat-header-alert[data-tone="info"] { @@ -867,8 +867,8 @@ } .maka-palette-item:focus-visible { - outline: 2px solid var(--ring); - outline-offset: 1px; + outline: var(--focus-ring-width) solid var(--ring); + outline-offset: var(--focus-ring-offset); } .maka-palette-item[data-disabled="true"] { diff --git a/apps/desktop/src/renderer/styles/health-center.css b/apps/desktop/src/renderer/styles/health-center.css index e046af328e..9d650c7c0c 100644 --- a/apps/desktop/src/renderer/styles/health-center.css +++ b/apps/desktop/src/renderer/styles/health-center.css @@ -73,8 +73,8 @@ color: var(--foreground); } .settingsHealthRefresh:focus-visible { - outline: 2px solid var(--focus-ring); - outline-offset: 2px; + outline: var(--focus-ring-width) solid var(--focus-ring); + outline-offset: var(--focus-ring-offset); } .settingsHealthError { diff --git a/apps/desktop/src/renderer/styles/markdown-link.css b/apps/desktop/src/renderer/styles/markdown-link.css index b5303218c8..8c8bba2e99 100644 --- a/apps/desktop/src/renderer/styles/markdown-link.css +++ b/apps/desktop/src/renderer/styles/markdown-link.css @@ -37,8 +37,8 @@ text-decoration-color: var(--link); } .maka-markdown-link-internal:focus-visible { - outline: 2px solid oklch(from var(--focus-ring) l c h / 0.42); - outline-offset: 2px; + outline: var(--focus-ring-width) solid oklch(from var(--focus-ring) l c h / 0.42); + outline-offset: var(--focus-ring-offset); } .maka-markdown-link-internal:active { background: var(--state-selected-bg); diff --git a/apps/desktop/src/renderer/styles/module-pages/skills.css b/apps/desktop/src/renderer/styles/module-pages/skills.css index 118b51be2c..05d997e319 100644 --- a/apps/desktop/src/renderer/styles/module-pages/skills.css +++ b/apps/desktop/src/renderer/styles/module-pages/skills.css @@ -524,8 +524,8 @@ color: oklch(0.55 0.015 220); } .maka-skill-library-row:focus-visible { - outline: 3px solid oklch(from var(--focus-ring) l c h / 0.35); - outline-offset: 2px; + outline: var(--focus-ring-width) solid oklch(from var(--focus-ring) l c h / 0.35); + outline-offset: var(--focus-ring-offset); } /* PR-UI-PIXEL-1: skill rows are anchored by a reference-style icon tile — diff --git a/apps/desktop/src/renderer/styles/onboarding.css b/apps/desktop/src/renderer/styles/onboarding.css index 7327f89152..bcab15a595 100644 --- a/apps/desktop/src/renderer/styles/onboarding.css +++ b/apps/desktop/src/renderer/styles/onboarding.css @@ -613,8 +613,8 @@ } .maka-first-run-task-suggestion:focus-visible { - outline: 2px solid var(--focus-ring); - outline-offset: 2px; + outline: var(--focus-ring-width) solid var(--focus-ring); + outline-offset: var(--focus-ring-offset); } .maka-first-run-task-suggestion:disabled { @@ -850,8 +850,8 @@ text-align: left; } .maka-list-group-toggle:focus-visible { - outline: 2px solid var(--focus-ring); - outline-offset: 2px; + outline: var(--focus-ring-width) solid var(--focus-ring); + outline-offset: var(--focus-ring-offset); border-radius: var(--radius-control); } .maka-list-group-count { diff --git a/apps/desktop/src/renderer/styles/permission-center.css b/apps/desktop/src/renderer/styles/permission-center.css index b709aceab8..df1b9ddd24 100644 --- a/apps/desktop/src/renderer/styles/permission-center.css +++ b/apps/desktop/src/renderer/styles/permission-center.css @@ -68,8 +68,8 @@ color: var(--foreground); } .settingsPermissionRefresh:focus-visible { - outline: 2px solid var(--focus-ring); - outline-offset: 2px; + outline: var(--focus-ring-width) solid var(--focus-ring); + outline-offset: var(--focus-ring-offset); } .settingsPermissionError { diff --git a/apps/desktop/src/renderer/styles/settings/models.css b/apps/desktop/src/renderer/styles/settings/models.css index 935e361f15..7f1adba517 100644 --- a/apps/desktop/src/renderer/styles/settings/models.css +++ b/apps/desktop/src/renderer/styles/settings/models.css @@ -960,7 +960,7 @@ .maka-turn[data-search-highlight="true"] { border-radius: var(--radius-surface); outline: 1px solid oklch(from var(--link) l c h / 0.34); - outline-offset: 6px; + outline-offset: var(--focus-ring-offset); background: oklch(from var(--link) l c h / 0.045); } @@ -1001,8 +1001,8 @@ color: var(--info-text); } .maka-session-branch-banner:focus-visible { - outline: 2px solid var(--focus-ring); - outline-offset: 2px; + outline: var(--focus-ring-width) solid var(--focus-ring); + outline-offset: var(--focus-ring-offset); } .maka-session-branch-banner[data-from-aborted] { /* Muted-warning tone so the user sees the branch starts from before diff --git a/apps/desktop/src/renderer/styles/settings/nav-sidebar.css b/apps/desktop/src/renderer/styles/settings/nav-sidebar.css index b91ca12a55..d52447562d 100644 --- a/apps/desktop/src/renderer/styles/settings/nav-sidebar.css +++ b/apps/desktop/src/renderer/styles/settings/nav-sidebar.css @@ -58,8 +58,8 @@ mouse; without a visible focus ring keyboard-only users couldn't tell it was selected. Use the same 2px accent outline + `:focus-visible` recipe as the settings rows. */ - outline: 2px solid var(--focus-ring); - outline-offset: 2px; + outline: var(--focus-ring-width) solid var(--focus-ring); + outline-offset: var(--focus-ring-offset); color: var(--foreground); } @@ -204,8 +204,8 @@ } .settingsNavItem:focus-visible { - outline: 2px solid var(--focus-ring); - outline-offset: -2px; + outline: var(--focus-ring-width) solid var(--focus-ring); + outline-offset: var(--focus-ring-offset); background: oklch(from var(--focus-ring) l c h / 0.04); } @@ -625,8 +625,8 @@ with the row's hairline divider. */ .settingsFormRow:focus-visible, .settingsRow:focus-visible { - outline: 2px solid var(--focus-ring); - outline-offset: 2px; + outline: var(--focus-ring-width) solid var(--focus-ring); + outline-offset: var(--focus-ring-offset); background: oklch(from var(--focus-ring) l c h / 0.04); } diff --git a/apps/desktop/src/renderer/styles/sidebar.css b/apps/desktop/src/renderer/styles/sidebar.css index b699693c64..fc2ef637b8 100644 --- a/apps/desktop/src/renderer/styles/sidebar.css +++ b/apps/desktop/src/renderer/styles/sidebar.css @@ -333,8 +333,8 @@ color: var(--foreground); } .maka-search-modal-close:focus-visible { - outline: 2px solid var(--ring); - outline-offset: 1px; + outline: var(--focus-ring-width) solid var(--ring); + outline-offset: var(--focus-ring-offset); } /* PR-UX-POLISH-1 commit 5: real search modal layout. * - input-row: text input + leading magnifier icon, sits below the @@ -378,8 +378,8 @@ color: var(--foreground); } .maka-search-modal-clear:focus-visible { - outline: 2px solid var(--ring); - outline-offset: 1px; + outline: var(--focus-ring-width) solid var(--ring); + outline-offset: var(--focus-ring-offset); } .maka-search-modal-body { diff --git a/apps/desktop/src/renderer/styles/tool-stream.css b/apps/desktop/src/renderer/styles/tool-stream.css index 8fdcc2f85c..db21c22e4f 100644 --- a/apps/desktop/src/renderer/styles/tool-stream.css +++ b/apps/desktop/src/renderer/styles/tool-stream.css @@ -463,8 +463,8 @@ } .settingsMemoryFilter input:focus-visible { - outline: 2px solid var(--focus-ring); - outline-offset: 2px; + outline: var(--focus-ring-width) solid var(--focus-ring); + outline-offset: var(--focus-ring-offset); } .settingsMemoryFilterEmpty, @@ -538,8 +538,8 @@ .settingsMemoryManualAddGrid input:focus-visible, .settingsMemoryManualAddGrid textarea:focus-visible { - outline: 2px solid var(--focus-ring); - outline-offset: 2px; + outline: var(--focus-ring-width) solid var(--focus-ring); + outline-offset: var(--focus-ring-offset); } .settingsMemoryDraftWarning { @@ -592,8 +592,8 @@ } .settingsMemoryEditor textarea:focus-visible { - outline: 2px solid var(--focus-ring); - outline-offset: 2px; + outline: var(--focus-ring-width) solid var(--focus-ring); + outline-offset: var(--focus-ring-offset); } .settingsMemoryEditor textarea:disabled { From 376c496575d05e3be33f53aaea33fb1457de8bd6 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sun, 5 Jul 2026 03:45:15 +0800 Subject: [PATCH 03/10] refactor(ui): converge motion duration + transform amplitude onto tokens (#520 PR2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Snap bare transition/animation ms to 4 existing duration tokens (120→quick, 140/150/160→base, 180→emphasized, 200/220/240→emphasized-or-large, 130→quick). Fix undefined --duration-fast reference (chat-header 730-732 → --duration-quick). 0ms/0.01ms whitelisted (a11y/test hack). Add 3 transform amplitude tokens (--scale-press 0.96 / --scale-hover 1.03 / --lift-hover -1px). Replace bare static scale(0.96/1.02/1.03)→var(--scale-press/hover), translateY(-0.5/-1/-2)→var(--lift-hover). Decorative scale(1.1) / strong lift -3px / sidebar slide translateX / rotate kept (diverse, low coverage). Keyframe transform excluded (animation intent). motion-token-converge-contract: upgraded --duration-* pin to exactly-once (assertCustomPropPinnedOnce) + added --scale-press/hover/--lift-hover pin; added bare ms ban (whitelist var(--duration-*) + 0/0.01ms, strip duration token declarations); added --duration-fast undefined check; added transform amplitude ban (whitelist var(--scale-press/hover) + var(--lift-hover) + literal 0/1 + decorative 1.1/strong -3px whitelist + keyframe strip; lookbehind excludes grayscale). Updated chat-tool-card-cascade-contract expectation (160ms → var(--duration-base)). Tracking issue: #520. --- .../chat-tool-card-cascade-contract.test.ts | 2 +- .../motion-token-converge-contract.test.ts | 59 +++++++++++++++++-- apps/desktop/src/renderer/maka-tokens.css | 49 +++++++++------ apps/desktop/src/renderer/reference-shell.css | 2 +- .../src/renderer/styles/chat-header.css | 28 ++++----- apps/desktop/src/renderer/styles/composer.css | 4 +- .../src/renderer/styles/daily-review.css | 2 +- .../src/renderer/styles/model-switcher.css | 2 +- .../styles/module-pages/plan-reminders.css | 6 +- .../renderer/styles/module-pages/skills.css | 2 +- .../src/renderer/styles/onboarding.css | 12 ++-- .../src/renderer/styles/permission-center.css | 2 +- .../src/renderer/styles/settings/models.css | 8 +-- .../styles/settings/provider-editor.css | 4 +- apps/desktop/src/renderer/styles/sidebar.css | 8 +-- .../src/renderer/styles/tool-output.css | 4 +- 16 files changed, 127 insertions(+), 67 deletions(-) diff --git a/apps/desktop/src/main/__tests__/chat-tool-card-cascade-contract.test.ts b/apps/desktop/src/main/__tests__/chat-tool-card-cascade-contract.test.ts index cdb3162f61..31e0380a4a 100644 --- a/apps/desktop/src/main/__tests__/chat-tool-card-cascade-contract.test.ts +++ b/apps/desktop/src/main/__tests__/chat-tool-card-cascade-contract.test.ts @@ -100,7 +100,7 @@ describe('chat tool-card migration contract (#332 PR3b)', () => { for (const residue of [ '[data-slot="tool"] {', 'transform: translateY(0)', - 'transition: border-color 160ms var(--ease-out-strong);', + 'transition: border-color var(--duration-base) var(--ease-out-strong);', '[data-slot="tool"] > summary::-webkit-details-marker { display: none; }', "[data-slot=\"tool\"] > summary::marker { content: ''; }", ]) { diff --git a/apps/desktop/src/main/__tests__/motion-token-converge-contract.test.ts b/apps/desktop/src/main/__tests__/motion-token-converge-contract.test.ts index 8c7534e26f..b444718fb7 100644 --- a/apps/desktop/src/main/__tests__/motion-token-converge-contract.test.ts +++ b/apps/desktop/src/main/__tests__/motion-token-converge-contract.test.ts @@ -24,7 +24,13 @@ import { strict as assert } from 'node:assert'; import { readFile } from 'node:fs/promises'; import { resolve } from 'node:path'; import { describe, it } from 'node:test'; -import { REPO_ROOT, TOKENS_FILE, readAllRendererCss, stripCssComments } from './css-test-helpers.js'; +import { REPO_ROOT, TOKENS_FILE, readAllRendererCss, stripCssComments, assertCustomPropPinnedOnce } from './css-test-helpers.js'; + +/** Strip `@keyframes` blocks (one level of nested `{}`) so opacity/transform + * inside animation tracks isn't flagged — animation intent, not state. */ +function stripKeyframes(css: string): string { + return css.replace(/@keyframes\s+[\w-]+\s*\{(?:[^{}]|\{[^{}]*\})*\}/g, ''); +} describe('PR-MOTION-TOKEN-CONVERGE-0 contract', () => { it('bare cubic-bezier(0.16, 1, 0.3, 1) appears ONLY in the --ease-out-strong token declaration', async () => { @@ -116,12 +122,15 @@ describe('PR-MOTION-TOKEN-CONVERGE-0 contract', () => { ); }); - it('--duration-{quick,base,emphasized,large} tokens are defined in maka-tokens.css', async () => { + it('--duration-{quick,base,emphasized,large} + --scale-{press,hover} + --lift-hover tokens are declared exactly once with pinned values', async () => { const tokens = await readFile(TOKENS_FILE, 'utf8'); - assert.match(tokens, /--duration-quick:\s*120ms/, '--duration-quick must be 120ms'); - assert.match(tokens, /--duration-base:\s*150ms/, '--duration-base must be 150ms'); - assert.match(tokens, /--duration-emphasized:\s*180ms/, '--duration-emphasized must be 180ms'); - assert.match(tokens, /--duration-large:\s*280ms/, '--duration-large must be 280ms'); + assertCustomPropPinnedOnce(tokens, '--duration-quick', '120ms'); + assertCustomPropPinnedOnce(tokens, '--duration-base', '150ms'); + assertCustomPropPinnedOnce(tokens, '--duration-emphasized', '180ms'); + assertCustomPropPinnedOnce(tokens, '--duration-large', '280ms'); + assertCustomPropPinnedOnce(tokens, '--scale-press', '0.96'); + assertCustomPropPinnedOnce(tokens, '--scale-hover', '1.03'); + assertCustomPropPinnedOnce(tokens, '--lift-hover', '-1px'); }); it('--ease-out-strong / --ease-in-out-strong / --ease-drawer / --ease-linear tokens are defined', async () => { @@ -233,4 +242,42 @@ describe('PR-MOTION-TOKEN-CONVERGE-0 contract', () => { ['fixture:1: bare `ease-in-out`'], ); }); + + it('bare ms in transition/animation is banned — use var(--duration-*) (0ms/0.01ms a11y whitelisted)', async () => { + const stripped = stripKeyframes(stripCssComments(await readAllRendererCss())) + .replace(/^\s*--duration-[\w-]+:\s*\d+ms\s*;.*$/gm, ''); // strip duration token declarations + const offenders: string[] = []; + for (const m of stripped.matchAll(/\b(\d+(?:\.\d+)?)ms\b/g)) { + const value = m[1]; + // 0ms (disable transition) + 0.01ms (prefers-reduced-motion / visual-smoke) are a11y/test hacks + if (value === '0' || value === '0.01') continue; + offenders.push(`${value}ms`); + } + assert.deepEqual(offenders, [], `Bare ms in transition/animation must use var(--duration-*). 0ms/0.01ms a11y whitelisted:\n ${offenders.join('\n ')}`); + }); + + it('--duration-fast is not referenced (was an undefined token; fixed to --duration-quick)', async () => { + const css = await readAllRendererCss(); + assert.doesNotMatch(css, /--duration-fast\b/, '--duration-fast was an undefined reference; use --duration-quick'); + }); + + it('transform amplitude uses var(--scale-press/hover) + var(--lift-hover) (bare static scale/translateY banned, keyframes excluded)', async () => { + const stripped = stripKeyframes(stripCssComments(await readAllRendererCss())); + const offenders: string[] = []; + for (const m of stripped.matchAll(/(? summary::before { transform: rotate(90deg); @@ -1420,7 +1433,7 @@ padding: 0 var(--space-0-5); margin: 0 calc(var(--space-0-5) * -1); border-radius: var(--radius-control); - transition: border-bottom-color 120ms var(--ease-out-strong), background 120ms var(--ease-out-strong), box-shadow 120ms var(--ease-out-strong); + transition: border-bottom-color var(--duration-quick) var(--ease-out-strong), background var(--duration-quick) var(--ease-out-strong), box-shadow var(--duration-quick) var(--ease-out-strong); } .maka-bubble-assistant a:hover { border-bottom-color: var(--link); @@ -1481,7 +1494,7 @@ border-radius: var(--radius-control); background: transparent; color: var(--muted-foreground); - transition: background 120ms var(--ease-out-strong), color 120ms var(--ease-out-strong), box-shadow 120ms var(--ease-out-strong); + transition: background var(--duration-quick) var(--ease-out-strong), color var(--duration-quick) var(--ease-out-strong), box-shadow var(--duration-quick) var(--ease-out-strong); } .maka-code-block-copy:hover { color: var(--foreground); @@ -1640,13 +1653,13 @@ on hover reads as "this just slid in from above" rather than a fade-only flash. transform stays GPU-accelerated; transition list keeps both axes coherent. */ - transform: translateY(-3px) scale(0.96); + transform: translateY(-3px) scale(var(--scale-press)); transition: - opacity 160ms var(--ease-out-strong), - transform 180ms var(--ease-out-strong), - color 120ms var(--ease-out-strong), - background 120ms var(--ease-out-strong), - box-shadow 120ms var(--ease-out-strong); + opacity var(--duration-base) var(--ease-out-strong), + transform var(--duration-emphasized) var(--ease-out-strong), + color var(--duration-quick) var(--ease-out-strong), + background var(--duration-quick) var(--ease-out-strong), + box-shadow var(--duration-quick) var(--ease-out-strong); } /* Labelled variant (e.g. "复制思考过程") — flows inline, doesn't absolute- @@ -1729,7 +1742,7 @@ [data-slot="tool"] { opacity: 1; transform: translateY(0); - transition: border-color 160ms var(--ease-out-strong); + transition: border-color var(--duration-base) var(--ease-out-strong); } /* Native
/ marker reset — the summary IS the header row (laid out by `toolVariants({ part: 'header' })`), so the default disclosure @@ -1788,7 +1801,7 @@ flex-direction: column; gap: var(--space-2-5); border: var(--border-width-hairline) solid var(--border); - transition: border-color 160ms var(--ease-out-strong), box-shadow 160ms var(--ease-out-strong), background 160ms var(--ease-out-strong); + transition: border-color var(--duration-base) var(--ease-out-strong), box-shadow var(--duration-base) var(--ease-out-strong), background var(--duration-base) var(--ease-out-strong); } /* Composer focus ring — `:focus-within` lets the wrapping shell glow @@ -1833,7 +1846,7 @@ listening" instead of "the placeholder is just sitting there." The fade is small (1 → 0.55) — enough to register, small enough not to feel like a glitch. */ - transition: opacity 180ms var(--ease-out-strong); + transition: opacity var(--duration-emphasized) var(--ease-out-strong); } .maka-composer textarea:focus::placeholder { opacity: var(--opacity-disabled); @@ -1864,7 +1877,7 @@ font-size: var(--font-size-ui); font-weight: var(--font-weight-medium); line-height: var(--leading-tight); - transition: background 120ms var(--ease-out-strong), border-color 120ms var(--ease-out-strong), color 120ms var(--ease-out-strong), box-shadow 120ms var(--ease-out-strong); + transition: background var(--duration-quick) var(--ease-out-strong), border-color var(--duration-quick) var(--ease-out-strong), color var(--duration-quick) var(--ease-out-strong), box-shadow var(--duration-quick) var(--ease-out-strong); } .maka-button:hover { background: var(--state-hover-bg); } .maka-button:active { background: var(--state-selected-bg); } diff --git a/apps/desktop/src/renderer/reference-shell.css b/apps/desktop/src/renderer/reference-shell.css index 7ce5dab86f..f8f9917f9d 100644 --- a/apps/desktop/src/renderer/reference-shell.css +++ b/apps/desktop/src/renderer/reference-shell.css @@ -89,7 +89,7 @@ margin: var(--agents-content-area-gap); border-radius: var(--radius-modal); background: var(--agents-content-area-bg); - transition: margin-left 150ms var(--ease-out-strong); + transition: margin-left var(--duration-base) var(--ease-out-strong); } /* PR-SETTINGS-RESTORE-SURFACE-0 (WAWQAQ msg `c8dc03e4`): main chat diff --git a/apps/desktop/src/renderer/styles/chat-header.css b/apps/desktop/src/renderer/styles/chat-header.css index 6cc7bc1c04..b0ba4564ec 100644 --- a/apps/desktop/src/renderer/styles/chat-header.css +++ b/apps/desktop/src/renderer/styles/chat-header.css @@ -131,7 +131,7 @@ } .maka-chat-header-alert:hover { - transform: translateY(-1px); + transform: translateY(var(--lift-hover)); } .maka-chat-header-alert:focus-visible { @@ -258,13 +258,13 @@ transition: transform var(--duration-emphasized) var(--ease-out-strong), opacity var(--duration-emphasized) var(--ease-out-strong), - background 120ms var(--ease-out-strong); + background var(--duration-quick) var(--ease-out-strong); } .maka-chat-jump-bottom:hover { color: var(--foreground); background: var(--state-hover-bg); - transform: translateX(-50%) translateY(-2px); + transform: translateX(-50%) translateY(var(--lift-hover)); } .maka-chat-jump-bottom:active { @@ -451,7 +451,7 @@ pointer-events: auto; opacity: 1; transform: translateX(0); - transition: background 120ms var(--ease-out-strong); + transition: background var(--duration-quick) var(--ease-out-strong); } /* D6 waiting-state spectrum #1 (taste v1 §9 Live Status): toasts used @@ -466,7 +466,7 @@ } .maka-toast { - animation: maka-toast-enter 240ms var(--ease-out-strong); + animation: maka-toast-enter var(--duration-large) var(--ease-out-strong); } /* Exit = enter x 75% (roadmap §4.1), ease-in per the enter-out/exit-in @@ -477,7 +477,7 @@ } .maka-toast[data-exiting="true"] { - animation: maka-toast-exit 180ms var(--ease-in-out-strong) forwards; + animation: maka-toast-exit var(--duration-emphasized) var(--ease-in-out-strong) forwards; pointer-events: none; } @@ -727,9 +727,9 @@ here"), not up. foreground-derived so dark mode adapts. */ background: oklch(from var(--foreground) l c h / 0.03); transition: - border-color var(--duration-fast) var(--ease-out-strong), - box-shadow var(--duration-fast) var(--ease-out-strong), - background var(--duration-fast) var(--ease-out-strong); + border-color var(--duration-quick) var(--ease-out-strong), + box-shadow var(--duration-quick) var(--ease-out-strong), + background var(--duration-quick) var(--ease-out-strong); } .maka-palette-input-wrap:has(input:focus) { @@ -848,9 +848,9 @@ font-size: var(--font-size-caption); cursor: default; transition: - background-color 140ms var(--ease-out-strong), - color 140ms var(--ease-out-strong), - opacity 140ms var(--ease-out-strong); + background-color var(--duration-base) var(--ease-out-strong), + color var(--duration-base) var(--ease-out-strong), + opacity var(--duration-base) var(--ease-out-strong); } .maka-palette-item:hover:not([data-disabled="true"]) { @@ -889,8 +889,8 @@ border-radius: var(--radius-control); color: var(--muted-foreground); transition: - background-color 140ms var(--ease-out-strong), - color 140ms var(--ease-out-strong); + background-color var(--duration-base) var(--ease-out-strong), + color var(--duration-base) var(--ease-out-strong); } .maka-palette-item[data-active="true"] .maka-palette-icon { diff --git a/apps/desktop/src/renderer/styles/composer.css b/apps/desktop/src/renderer/styles/composer.css index d79da025cc..662787d185 100644 --- a/apps/desktop/src/renderer/styles/composer.css +++ b/apps/desktop/src/renderer/styles/composer.css @@ -17,7 +17,7 @@ transition: background var(--duration-base) var(--ease-out-strong), transform var(--duration-emphasized) var(--ease-out-strong), - box-shadow 220ms var(--ease-out-strong); + box-shadow var(--duration-emphasized) var(--ease-out-strong); } /* PR-FE-BUG-HUNT-7: send button is a primary CTA so it gets a hair @@ -29,7 +29,7 @@ oklch(from var(--action) calc(l - 0.15) c h) ); color: var(--action-foreground); - transform: scale(1.03); + transform: scale(var(--scale-hover)); box-shadow: inset 0 1px 0 oklch(1 0 0 / 0.18), 0 10px 22px -14px oklch(from var(--action) l c h / 0.55); diff --git a/apps/desktop/src/renderer/styles/daily-review.css b/apps/desktop/src/renderer/styles/daily-review.css index 6849e6d9d0..e15abd3063 100644 --- a/apps/desktop/src/renderer/styles/daily-review.css +++ b/apps/desktop/src/renderer/styles/daily-review.css @@ -446,7 +446,7 @@ padding: var(--space-1-5) var(--space-2-5); border-radius: var(--radius-surface); background: transparent; - transition: background 140ms var(--ease-out-strong); + transition: background var(--duration-base) var(--ease-out-strong); } .maka-daily-review-list-item:hover { background: var(--state-hover-bg); diff --git a/apps/desktop/src/renderer/styles/model-switcher.css b/apps/desktop/src/renderer/styles/model-switcher.css index d8662bade1..df5d126214 100644 --- a/apps/desktop/src/renderer/styles/model-switcher.css +++ b/apps/desktop/src/renderer/styles/model-switcher.css @@ -37,7 +37,7 @@ feels physical. Same convention as the other interactive surfaces in this round (artifact-row, session-row, onboarding-card, prompt-chip). */ - transition: background 140ms var(--ease-out-strong); + transition: background var(--duration-base) var(--ease-out-strong); } .maka-model-switcher-trigger:hover:not(:disabled):not([data-disabled]) { diff --git a/apps/desktop/src/renderer/styles/module-pages/plan-reminders.css b/apps/desktop/src/renderer/styles/module-pages/plan-reminders.css index 61dc1605b0..11076d03b0 100644 --- a/apps/desktop/src/renderer/styles/module-pages/plan-reminders.css +++ b/apps/desktop/src/renderer/styles/module-pages/plan-reminders.css @@ -110,14 +110,14 @@ background-color var(--duration-base) var(--ease-out-strong), border-color var(--duration-base) var(--ease-out-strong), transform var(--duration-emphasized) var(--ease-out-strong), - box-shadow 200ms var(--ease-out-strong); + box-shadow var(--duration-emphasized) var(--ease-out-strong); } .maka-plan-new-task-button:hover { border-color: oklch(0.28 0 0); background: oklch(0.26 0 0); color: oklch(1 0 0); - transform: translateY(-0.5px); + transform: translateY(var(--lift-hover)); box-shadow: inset 0 1px 0 oklch(1 0 0 / 0.20), 0 4px 14px -8px oklch(from var(--foreground) l c h / 0.32); @@ -486,7 +486,7 @@ .maka-plan-card:hover { border-color: oklch(from var(--foreground) l c h / 0.13); box-shadow: 0 4px 12px -8px oklch(from var(--foreground) l c h / 0.08); - transform: translateY(-1px); + transform: translateY(var(--lift-hover)); } .maka-plan-card[data-status="completed"] { diff --git a/apps/desktop/src/renderer/styles/module-pages/skills.css b/apps/desktop/src/renderer/styles/module-pages/skills.css index 05d997e319..8914691b73 100644 --- a/apps/desktop/src/renderer/styles/module-pages/skills.css +++ b/apps/desktop/src/renderer/styles/module-pages/skills.css @@ -303,7 +303,7 @@ color: oklch(0.55 0.015 220); .maka-skill-market-card:hover { border-color: oklch(from var(--foreground) l c h / 0.14); box-shadow: 0 4px 12px -8px oklch(from var(--foreground) l c h / 0.08); - transform: translateY(-1px); + transform: translateY(var(--lift-hover)); } .maka-skill-market-card-head { diff --git a/apps/desktop/src/renderer/styles/onboarding.css b/apps/desktop/src/renderer/styles/onboarding.css index bcab15a595..58bc2bdc2f 100644 --- a/apps/desktop/src/renderer/styles/onboarding.css +++ b/apps/desktop/src/renderer/styles/onboarding.css @@ -101,8 +101,8 @@ } /* PR-FRONTEND-AUDIT-CLEANUP-0 F1 (WAWQAQ msg `dde696a1`): the earlier - `maka-agents-fade-in 150ms` block + nth-child delay ladder was - completely overridden by the `maka-card-enter 280ms` block below, + `maka-agents-fade-in var(--duration-base)` block + nth-child delay ladder was + completely overridden by the `maka-card-enter var(--duration-large)` block below, plus that block's own delay ladder. The dead rules silently caused any "polish" attempt on the upper block to look like a no-op. Deleted. */ @@ -443,9 +443,9 @@ 0 18px 42px oklch(from var(--foreground) l c h / 0.07), 0 1px 0 oklch(from var(--foreground) 1 0 h / 0.72) inset; transition: - border-color 160ms var(--ease-out-strong), - box-shadow 160ms var(--ease-out-strong), - transform 160ms var(--ease-out-strong); + border-color var(--duration-base) var(--ease-out-strong), + box-shadow var(--duration-base) var(--ease-out-strong), + transform var(--duration-base) var(--ease-out-strong); } .maka-onboarding-quickchat-field { @@ -533,7 +533,7 @@ .maka-onboarding-quickchat-submit:hover:not(:disabled) { background: color-mix(in srgb, var(--action) 88%, black); color: var(--action-foreground); - transform: translateY(-1px); + transform: translateY(var(--lift-hover)); box-shadow: inset 0 1px 0 oklch(1 0 0 / 0.18), 0 12px 24px oklch(from var(--action) l c h / 0.26); diff --git a/apps/desktop/src/renderer/styles/permission-center.css b/apps/desktop/src/renderer/styles/permission-center.css index df1b9ddd24..ab8a232358 100644 --- a/apps/desktop/src/renderer/styles/permission-center.css +++ b/apps/desktop/src/renderer/styles/permission-center.css @@ -411,7 +411,7 @@ gap: var(--space-3); padding: var(--space-4) var(--space-4); background: var(--background); - transition: background 160ms var(--ease-out-strong); + transition: background var(--duration-base) var(--ease-out-strong); } .settingsOsPermissionIcon { diff --git a/apps/desktop/src/renderer/styles/settings/models.css b/apps/desktop/src/renderer/styles/settings/models.css index 7f1adba517..3826a68a10 100644 --- a/apps/desktop/src/renderer/styles/settings/models.css +++ b/apps/desktop/src/renderer/styles/settings/models.css @@ -31,7 +31,7 @@ color: var(--foreground); font: inherit; text-align: left; - transition: background-color 130ms var(--ease-out-strong); + transition: background-color var(--duration-quick) var(--ease-out-strong); } .enabledProviderTrigger:hover { background: var(--state-hover-bg); } @@ -56,7 +56,7 @@ .enabledChevron { color: var(--muted-foreground); - transition: transform 160ms var(--ease-out-strong); + transition: transform var(--duration-base) var(--ease-out-strong); } .enabledProviderTrigger[data-panel-open] .enabledChevron, @@ -557,8 +557,8 @@ text-align: left; font-size: var(--font-size-caption); transition: - background 140ms var(--ease-out-strong), - border-color 140ms var(--ease-out-strong); + background var(--duration-base) var(--ease-out-strong), + border-color var(--duration-base) var(--ease-out-strong); } .maka-artifact-row:hover { diff --git a/apps/desktop/src/renderer/styles/settings/provider-editor.css b/apps/desktop/src/renderer/styles/settings/provider-editor.css index 6cafd26555..b035d943a6 100644 --- a/apps/desktop/src/renderer/styles/settings/provider-editor.css +++ b/apps/desktop/src/renderer/styles/settings/provider-editor.css @@ -164,7 +164,7 @@ padding: 0 var(--space-3); outline: none; font-size: var(--font-size-ui); - transition: border-color 140ms var(--ease-out-strong), box-shadow 140ms var(--ease-out-strong), background 140ms var(--ease-out-strong); + transition: border-color var(--duration-base) var(--ease-out-strong), box-shadow var(--duration-base) var(--ease-out-strong), background var(--duration-base) var(--ease-out-strong); } .providerEditor input:focus, @@ -279,7 +279,7 @@ color: var(--foreground); font-size: var(--font-size-ui); font-family: var(--font-mono); - transition: border-color 140ms var(--ease-out-strong), box-shadow 140ms var(--ease-out-strong); + transition: border-color var(--duration-base) var(--ease-out-strong), box-shadow var(--duration-base) var(--ease-out-strong); } .modelTableSearch:focus { diff --git a/apps/desktop/src/renderer/styles/sidebar.css b/apps/desktop/src/renderer/styles/sidebar.css index fc2ef637b8..c04b78e9ce 100644 --- a/apps/desktop/src/renderer/styles/sidebar.css +++ b/apps/desktop/src/renderer/styles/sidebar.css @@ -807,8 +807,8 @@ opacity: 0; transform: translateX(8px); transition: - opacity 140ms var(--ease-out-strong), - transform 140ms var(--ease-out-strong); + opacity var(--duration-base) var(--ease-out-strong), + transform var(--duration-base) var(--ease-out-strong); pointer-events: none; } @@ -915,8 +915,8 @@ padding: var(--space-2-5) var(--space-3); text-align: left; transition: - border-color 140ms var(--ease-out-strong), - background 140ms var(--ease-out-strong); + border-color var(--duration-base) var(--ease-out-strong), + background var(--duration-base) var(--ease-out-strong); } .maka-prompt-chip:hover { diff --git a/apps/desktop/src/renderer/styles/tool-output.css b/apps/desktop/src/renderer/styles/tool-output.css index a230ffca45..06540e1d1e 100644 --- a/apps/desktop/src/renderer/styles/tool-output.css +++ b/apps/desktop/src/renderer/styles/tool-output.css @@ -93,7 +93,7 @@ 0 1px 0 oklch(1 0 0 / 0.76); transition: border-color var(--duration-emphasized) var(--ease-out-strong), - box-shadow 220ms var(--ease-out-strong); + box-shadow var(--duration-emphasized) var(--ease-out-strong); } .composer .maka-composer-inner::before, @@ -367,7 +367,7 @@ .maka-composer-tool-button:hover:not(:disabled) { color: var(--foreground); background: var(--state-hover-bg); - transform: scale(1.02); + transform: scale(var(--scale-hover)); } .maka-composer-tool-button:active:not(:disabled) { From f3a5e3d0db8684f88f1b8de355a117fffe69a808 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sun, 5 Jul 2026 04:12:14 +0800 Subject: [PATCH 04/10] =?UTF-8?q?refactor(ui):=20fix=20focus-ring=20contra?= =?UTF-8?q?ct=20scope=20=E2=80=94=20search-highlight=20offset=20+=20box-sh?= =?UTF-8?q?adow=20alpha=20ring=20(#520=20PR2=20review)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review fix (P2): focus-ring contract was both too wide and too narrow. - Too wide: search-highlight .maka-turn[data-search-highlight] outline-offset 6px (non-focus visual highlight, link color) was snapped to var(--focus-ring-offset) (2px), weakening the search highlight. Restored 6px + whitelisted as one-off in contract. - Too narrow: contract only scanned box-shadow: 0 0 0 var(--ring), missing oklch(from var(--focus-ring) ... / a) alpha variants. Button focus + 13 other sites kept bare 3px/1px ring width. Extended scanner to cover both ring colors; tokenized all focus-ring box-shadow ring widths (1px/2px/3px -> var(--focus-ring-width)). Status/info/accent/foreground rings are NOT focus rings (PR4 box-shadow scope). Negative cases: search-highlight 6px no longer flagged; button focus bare 3px alpha ring now flagged. Full desktop suite 1946/1946 pass. Dual-theme screenshots (turn-narrative + first-run) unchanged. --- .../focus-ring-recipe-contract.test.ts | 24 +++++++++++++------ apps/desktop/src/renderer/maka-tokens.css | 10 ++++---- apps/desktop/src/renderer/styles/composer.css | 2 +- .../src/renderer/styles/field-focus.css | 2 +- .../styles/module-pages/plan-reminders.css | 4 ++-- .../src/renderer/styles/onboarding.css | 2 +- .../src/renderer/styles/settings/models.css | 6 ++--- .../renderer/styles/settings/nav-sidebar.css | 2 +- .../styles/settings/provider-editor.css | 8 +++---- apps/desktop/src/renderer/styles/sidebar.css | 4 ++-- .../src/renderer/styles/tool-output.css | 2 +- 11 files changed, 38 insertions(+), 28 deletions(-) diff --git a/apps/desktop/src/main/__tests__/focus-ring-recipe-contract.test.ts b/apps/desktop/src/main/__tests__/focus-ring-recipe-contract.test.ts index 5c262a22c3..ece722f0e8 100644 --- a/apps/desktop/src/main/__tests__/focus-ring-recipe-contract.test.ts +++ b/apps/desktop/src/main/__tests__/focus-ring-recipe-contract.test.ts @@ -56,16 +56,23 @@ function findFocusRingOffenders(css: string, label: string): string[] { } } - // outline-offset: must be var(--focus-ring-offset) + // outline-offset: must be var(--focus-ring-offset). The search-highlight + // marker (.maka-turn[data-search-highlight]) uses a 6px non-focus offset on + // purpose — it's a visual highlight ring (link color), not the keyboard + // focus-ring recipe, so it's whitelisted as a one-off. for (const m of stripped.matchAll(/(? var(--ring) — width must be var(--focus-ring-width) - for (const m of stripped.matchAll(/box-shadow:\s*0\s+0\s+0\s+(\d+px)\s+var\(--ring\)/gi)) { + // box-shadow ring width: `0 0 0 ` where is var(--ring) + // (subtle) or oklch(from var(--focus-ring) … / a) (strong + alpha) — both are + // the focus-ring recipe, so the ring width must be var(--focus-ring-width). + // Status/info/accent/foreground rings are NOT focus rings (PR4 box-shadow scope). + for (const m of stripped.matchAll(/box-shadow:\s*0\s+0\s+0\s+(\d+px)\s+(var\(--ring\)|oklch\(from\s+var\(--focus-ring\)[^)]*\))/gi)) { offenders.push(`${label}: ${m[0].trim()} (bare ring width in box-shadow — use var(--focus-ring-width))`); } @@ -109,18 +116,21 @@ describe('focus-ring recipe negative cases', () => { it('rejects bare outline-offset px (and negatives)', () => { assert.ok(findFocusRingOffenders('outline-offset: 2px', 'test').length > 0, 'bare 2px must fail'); assert.ok(findFocusRingOffenders('outline-offset: -2px', 'test').length > 0, 'bare -2px must fail'); - assert.ok(findFocusRingOffenders('outline-offset: 6px', 'test').length > 0, 'bare 6px must fail'); + assert.ok(findFocusRingOffenders('outline-offset: 4px', 'test').length > 0, 'bare 4px must fail'); }); - it('accepts var(--focus-ring-offset)', () => { + it('accepts var(--focus-ring-offset) and search-highlight 6px one-off', () => { assert.deepEqual(findFocusRingOffenders('outline-offset: var(--focus-ring-offset)', 'test'), []); + assert.deepEqual(findFocusRingOffenders('outline-offset: 6px', 'test'), []); }); - it('rejects bare ring width in box-shadow: 0 0 0 var(--ring)', () => { + it('rejects bare ring width in box-shadow: 0 0 0 var(--ring) or oklch(from var(--focus-ring) …)', () => { assert.ok(findFocusRingOffenders('box-shadow: 0 0 0 2px var(--ring)', 'test').length > 0, 'bare ring width must fail'); + assert.ok(findFocusRingOffenders('box-shadow: 0 0 0 3px oklch(from var(--focus-ring) l c h / 0.14)', 'test').length > 0, 'bare 3px alpha ring must fail'); }); - it('accepts box-shadow ring with var(--focus-ring-width)', () => { + it('accepts box-shadow ring with var(--focus-ring-width) for both ring colors', () => { assert.deepEqual(findFocusRingOffenders('box-shadow: 0 0 0 var(--focus-ring-width) var(--ring)', 'test'), []); + assert.deepEqual(findFocusRingOffenders('box-shadow: 0 0 0 var(--focus-ring-width) oklch(from var(--focus-ring) l c h / 0.14)', 'test'), []); }); }); \ No newline at end of file diff --git a/apps/desktop/src/renderer/maka-tokens.css b/apps/desktop/src/renderer/maka-tokens.css index 6f8a879405..5f829d9be7 100644 --- a/apps/desktop/src/renderer/maka-tokens.css +++ b/apps/desktop/src/renderer/maka-tokens.css @@ -1441,7 +1441,7 @@ .maka-bubble-assistant a:focus-visible { outline: none; background: oklch(from var(--link) l c h / 0.06); - box-shadow: 0 0 0 2px oklch(from var(--focus-ring) l c h / 0.20); + box-shadow: 0 0 0 var(--focus-ring-width) oklch(from var(--focus-ring) l c h / 0.20); } .maka-bubble-assistant code { font-family: var(--font-mono); @@ -1504,7 +1504,7 @@ outline: none; color: var(--foreground); background: var(--foreground-10); - box-shadow: 0 0 0 3px oklch(from var(--focus-ring) l c h / 0.14); + box-shadow: 0 0 0 var(--focus-ring-width) oklch(from var(--focus-ring) l c h / 0.14); } .maka-code-block-copy[data-copied="true"] { color: var(--success-text); @@ -1812,7 +1812,7 @@ border-color: oklch(from var(--focus-ring) l c h / 0.40); background: var(--background); box-shadow: - 0 0 0 1px oklch(from var(--focus-ring) l c h / 0.20), + 0 0 0 var(--focus-ring-width) oklch(from var(--focus-ring) l c h / 0.20), 0 0 0 4px oklch(from var(--focus-ring) l c h / 0.08), 0 1px 3px oklch(from var(--foreground) l c h / 0.04); } @@ -1820,7 +1820,7 @@ border-color: oklch(from var(--focus-ring) l c h / 0.46); background: oklch(from var(--focus-ring) 0.98 calc(c * 0.20) h / 0.35); box-shadow: - 0 0 0 1px oklch(from var(--focus-ring) l c h / 0.22), + 0 0 0 var(--focus-ring-width) oklch(from var(--focus-ring) l c h / 0.22), 0 0 0 4px oklch(from var(--focus-ring) l c h / 0.08), 0 1px 3px oklch(from var(--foreground) l c h / 0.04); } @@ -1883,7 +1883,7 @@ .maka-button:active { background: var(--state-selected-bg); } .maka-button:focus-visible { outline: none; - box-shadow: 0 0 0 3px oklch(from var(--focus-ring) l c h / 0.16); + box-shadow: 0 0 0 var(--focus-ring-width) oklch(from var(--focus-ring) l c h / 0.16); } .maka-button[disabled], .maka-button[aria-disabled="true"] { opacity: var(--opacity-disabled); cursor: not-allowed; } diff --git a/apps/desktop/src/renderer/styles/composer.css b/apps/desktop/src/renderer/styles/composer.css index 662787d185..c70f637666 100644 --- a/apps/desktop/src/renderer/styles/composer.css +++ b/apps/desktop/src/renderer/styles/composer.css @@ -85,7 +85,7 @@ .maka-composer-workspace-picker:focus-visible { outline: none; border-radius: var(--radius-control); - box-shadow: 0 0 0 3px oklch(from var(--focus-ring) l c h / 0.14); + box-shadow: 0 0 0 var(--focus-ring-width) oklch(from var(--focus-ring) l c h / 0.14); } .maka-composer-workspace-picker svg { diff --git a/apps/desktop/src/renderer/styles/field-focus.css b/apps/desktop/src/renderer/styles/field-focus.css index 42b6a4ac91..f954eb3ae4 100644 --- a/apps/desktop/src/renderer/styles/field-focus.css +++ b/apps/desktop/src/renderer/styles/field-focus.css @@ -5,5 +5,5 @@ :where(input, select, textarea):focus:not(:where([data-maka-field-chrome="none"])) { outline: none; border-color: oklch(from var(--focus-ring) l c h / 0.48); - box-shadow: inset 0 0 0 1px oklch(from var(--focus-ring) l c h / 0.22) !important; + box-shadow: inset 0 0 0 var(--focus-ring-width) oklch(from var(--focus-ring) l c h / 0.22) !important; } diff --git a/apps/desktop/src/renderer/styles/module-pages/plan-reminders.css b/apps/desktop/src/renderer/styles/module-pages/plan-reminders.css index 11076d03b0..484391087a 100644 --- a/apps/desktop/src/renderer/styles/module-pages/plan-reminders.css +++ b/apps/desktop/src/renderer/styles/module-pages/plan-reminders.css @@ -613,7 +613,7 @@ .maka-plan-field input:focus, .maka-plan-field textarea:focus { border-color: oklch(from var(--focus-ring) l c h / 0.55); - box-shadow: 0 0 0 2px oklch(from var(--focus-ring) l c h / 0.12); + box-shadow: 0 0 0 var(--focus-ring-width) oklch(from var(--focus-ring) l c h / 0.12); } .maka-plan-presets { @@ -715,7 +715,7 @@ .maka-plan-search input:focus { border-color: oklch(from var(--focus-ring) l c h / 0.55); - box-shadow: 0 0 0 2px oklch(from var(--focus-ring) l c h / 0.12); + box-shadow: 0 0 0 var(--focus-ring-width) oklch(from var(--focus-ring) l c h / 0.12); } .maka-plan-search-summary { diff --git a/apps/desktop/src/renderer/styles/onboarding.css b/apps/desktop/src/renderer/styles/onboarding.css index 58bc2bdc2f..3629bae10d 100644 --- a/apps/desktop/src/renderer/styles/onboarding.css +++ b/apps/desktop/src/renderer/styles/onboarding.css @@ -478,7 +478,7 @@ border-color: oklch(from var(--focus-ring) l c h / 0.46); background: oklch(from var(--focus-ring) 0.98 calc(c * 0.20) h / 0.35); box-shadow: - 0 0 0 1px oklch(from var(--focus-ring) l c h / 0.22), + 0 0 0 var(--focus-ring-width) oklch(from var(--focus-ring) l c h / 0.22), 0 0 0 4px oklch(from var(--focus-ring) l c h / 0.08); } /* PR-UI-15 (@yuejing 2026-05-22): small example hint shown below the diff --git a/apps/desktop/src/renderer/styles/settings/models.css b/apps/desktop/src/renderer/styles/settings/models.css index 3826a68a10..b745a72a54 100644 --- a/apps/desktop/src/renderer/styles/settings/models.css +++ b/apps/desktop/src/renderer/styles/settings/models.css @@ -438,7 +438,7 @@ .maka-artifact-pane-collapse:focus-visible { outline: none; border-color: oklch(from var(--focus-ring) l c h / 0.42); - box-shadow: 0 0 0 3px oklch(from var(--focus-ring) l c h / 0.14); + box-shadow: 0 0 0 var(--focus-ring-width) oklch(from var(--focus-ring) l c h / 0.14); } .maka-artifact-pane-title { @@ -522,7 +522,7 @@ .maka-artifact-error-retry:focus-visible { outline: none; border-color: oklch(from var(--focus-ring) l c h / 0.42); - box-shadow: 0 0 0 3px oklch(from var(--focus-ring) l c h / 0.14); + box-shadow: 0 0 0 var(--focus-ring-width) oklch(from var(--focus-ring) l c h / 0.14); } .maka-artifact-list { @@ -960,7 +960,7 @@ .maka-turn[data-search-highlight="true"] { border-radius: var(--radius-surface); outline: 1px solid oklch(from var(--link) l c h / 0.34); - outline-offset: var(--focus-ring-offset); + outline-offset: 6px; background: oklch(from var(--link) l c h / 0.045); } diff --git a/apps/desktop/src/renderer/styles/settings/nav-sidebar.css b/apps/desktop/src/renderer/styles/settings/nav-sidebar.css index d52447562d..5c722c3347 100644 --- a/apps/desktop/src/renderer/styles/settings/nav-sidebar.css +++ b/apps/desktop/src/renderer/styles/settings/nav-sidebar.css @@ -636,7 +636,7 @@ .settingsFormGrid select:focus-visible { outline: none; border-color: var(--focus-ring); - box-shadow: 0 0 0 3px oklch(from var(--focus-ring) l c h / 0.18); + box-shadow: 0 0 0 var(--focus-ring-width) oklch(from var(--focus-ring) l c h / 0.18); } /* PR-SETTINGS-A11Y-POLISH-0 — respect prefers-reduced-motion. Strip diff --git a/apps/desktop/src/renderer/styles/settings/provider-editor.css b/apps/desktop/src/renderer/styles/settings/provider-editor.css index b035d943a6..6dbc35c4b4 100644 --- a/apps/desktop/src/renderer/styles/settings/provider-editor.css +++ b/apps/desktop/src/renderer/styles/settings/provider-editor.css @@ -171,7 +171,7 @@ .providerEditor select:focus { border-color: oklch(from var(--focus-ring) l c h / 0.42); background: var(--background); - box-shadow: 0 0 0 3px oklch(from var(--focus-ring) l c h / 0.10); + box-shadow: 0 0 0 var(--focus-ring-width) oklch(from var(--focus-ring) l c h / 0.10); } .providerActions { @@ -210,7 +210,7 @@ .providerExternalLink:focus-visible { outline: none; background: oklch(from var(--focus-ring) l c h / 0.06); - box-shadow: 0 0 0 3px oklch(from var(--focus-ring) l c h / 0.14); + box-shadow: 0 0 0 var(--focus-ring-width) oklch(from var(--focus-ring) l c h / 0.14); } /* UI-02 model table: per-row default radio, capability chips, search box, @@ -287,7 +287,7 @@ /* PR-UI-LAYOUT-22: brought into the focus-ring family — was * border-color only, now 3px accent halo to match the rest. */ border-color: oklch(from var(--focus-ring) l c h / 0.42); - box-shadow: 0 0 0 3px oklch(from var(--focus-ring) l c h / 0.10); + box-shadow: 0 0 0 var(--focus-ring-width) oklch(from var(--focus-ring) l c h / 0.10); } .modelTableEmpty { @@ -369,7 +369,7 @@ outline: none; background: var(--background); border-color: oklch(from var(--focus-ring) l c h / 0.42); - box-shadow: 0 0 0 3px oklch(from var(--focus-ring) l c h / 0.14); + box-shadow: 0 0 0 var(--focus-ring-width) oklch(from var(--focus-ring) l c h / 0.14); } .modelTableRow[data-default="true"] { diff --git a/apps/desktop/src/renderer/styles/sidebar.css b/apps/desktop/src/renderer/styles/sidebar.css index c04b78e9ce..3e9eace757 100644 --- a/apps/desktop/src/renderer/styles/sidebar.css +++ b/apps/desktop/src/renderer/styles/sidebar.css @@ -465,7 +465,7 @@ .maka-search-modal-result:focus-visible { outline: none; background: var(--state-hover-bg); - box-shadow: 0 0 0 2px oklch(from var(--focus-ring) l c h / 0.18); + box-shadow: 0 0 0 var(--focus-ring-width) oklch(from var(--focus-ring) l c h / 0.18); } .maka-search-modal-result[disabled] { cursor: default; @@ -881,7 +881,7 @@ .maka-list-row:has(.maka-list-row-main:focus-visible) { border-color: oklch(from var(--focus-ring) l c h / 0.4); - box-shadow: 0 0 0 2px oklch(from var(--focus-ring) l c h / 0.18); + box-shadow: 0 0 0 var(--focus-ring-width) oklch(from var(--focus-ring) l c h / 0.18); } .maka-prompt-suggestions { diff --git a/apps/desktop/src/renderer/styles/tool-output.css b/apps/desktop/src/renderer/styles/tool-output.css index 06540e1d1e..75cf9509f6 100644 --- a/apps/desktop/src/renderer/styles/tool-output.css +++ b/apps/desktop/src/renderer/styles/tool-output.css @@ -114,7 +114,7 @@ rather than sitting flat (study §8.1, §8.4). */ .composer .maka-composer-inner:focus-within { box-shadow: - 0 0 0 1px oklch(from var(--focus-ring) l c h / 0.24), + 0 0 0 var(--focus-ring-width) oklch(from var(--focus-ring) l c h / 0.24), 0 14px 34px -28px oklch(from var(--foreground) l c h / 0.24); } From a9bac807a1be6767d3118610ecc440b9859e6fe8 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sun, 5 Jul 2026 04:12:34 +0800 Subject: [PATCH 05/10] refactor(ui): restore keyframe opacity literals + dedupe stripKeyframes helper (#520 PR2 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review fix (P3): - keyframe opacity: maka-reasoning-panel-pulse 0%/100% frame opacity was tokenized to var(--opacity-muted), conflicting with the keyframes-excluded contract intent (keyframe opacity is animation intent, not element state — contract strips keyframes, so tokenizing them is redundant + semantically wrong). Restored literal 0.65. Element-state opacity (e.g. .maka-reasoning-panel-dot reduced-motion) stays tokenized. - stripKeyframes dedupe: opacity-converge-contract + motion-token-converge-contract each defined an identical stripKeyframes helper. Moved to css-test-helpers.ts (shared export), deleted both local copies. Full desktop suite 1946/1946 pass. --- apps/desktop/src/main/__tests__/css-test-helpers.ts | 9 +++++++++ .../__tests__/motion-token-converge-contract.test.ts | 8 +------- .../src/main/__tests__/opacity-converge-contract.test.ts | 8 +------- apps/desktop/src/renderer/styles/reasoning-panel.css | 2 +- 4 files changed, 12 insertions(+), 15 deletions(-) diff --git a/apps/desktop/src/main/__tests__/css-test-helpers.ts b/apps/desktop/src/main/__tests__/css-test-helpers.ts index 045f2fba2b..e4601849d4 100644 --- a/apps/desktop/src/main/__tests__/css-test-helpers.ts +++ b/apps/desktop/src/main/__tests__/css-test-helpers.ts @@ -51,6 +51,15 @@ export function stripCssComments(src: string): string { return src.replace(/\/\*[\s\S]*?\*\//g, ''); } +/** Strip `@keyframes { … }` blocks so converge contracts can scan + * element-state declarations without false-positiving on animation frames + * (keyframe opacity/transform are animation intent, not element state). + * One level of `{}` nesting is enough for all current keyframes (0%/50%/100% + * frames with no nested blocks). */ +export function stripKeyframes(css: string): string { + return css.replace(/@keyframes\s+[\w-]+\s*\{(?:[^{}]|\{[^{}]*\})*\}/g, ''); +} + /** Ban non-literal `font:` shorthand in renderer CSS. * * `font:` shorthand can hide bare font-weight (`font: 600 12px sans-serif`), diff --git a/apps/desktop/src/main/__tests__/motion-token-converge-contract.test.ts b/apps/desktop/src/main/__tests__/motion-token-converge-contract.test.ts index b444718fb7..a589d85e38 100644 --- a/apps/desktop/src/main/__tests__/motion-token-converge-contract.test.ts +++ b/apps/desktop/src/main/__tests__/motion-token-converge-contract.test.ts @@ -24,13 +24,7 @@ import { strict as assert } from 'node:assert'; import { readFile } from 'node:fs/promises'; import { resolve } from 'node:path'; import { describe, it } from 'node:test'; -import { REPO_ROOT, TOKENS_FILE, readAllRendererCss, stripCssComments, assertCustomPropPinnedOnce } from './css-test-helpers.js'; - -/** Strip `@keyframes` blocks (one level of nested `{}`) so opacity/transform - * inside animation tracks isn't flagged — animation intent, not state. */ -function stripKeyframes(css: string): string { - return css.replace(/@keyframes\s+[\w-]+\s*\{(?:[^{}]|\{[^{}]*\})*\}/g, ''); -} +import { REPO_ROOT, TOKENS_FILE, readAllRendererCss, stripCssComments, stripKeyframes, assertCustomPropPinnedOnce } from './css-test-helpers.js'; describe('PR-MOTION-TOKEN-CONVERGE-0 contract', () => { it('bare cubic-bezier(0.16, 1, 0.3, 1) appears ONLY in the --ease-out-strong token declaration', async () => { diff --git a/apps/desktop/src/main/__tests__/opacity-converge-contract.test.ts b/apps/desktop/src/main/__tests__/opacity-converge-contract.test.ts index 39f92f81bf..a040dccd5d 100644 --- a/apps/desktop/src/main/__tests__/opacity-converge-contract.test.ts +++ b/apps/desktop/src/main/__tests__/opacity-converge-contract.test.ts @@ -29,7 +29,7 @@ import { strict as assert } from 'node:assert'; import { readFile } from 'node:fs/promises'; import { describe, it } from 'node:test'; -import { REPO_ROOT, TOKENS_FILE, readAllRendererCss, stripCssComments, assertCustomPropPinnedOnce, parseCssCustomProps } from './css-test-helpers.js'; +import { REPO_ROOT, TOKENS_FILE, readAllRendererCss, stripCssComments, stripKeyframes, assertCustomPropPinnedOnce, parseCssCustomProps } from './css-test-helpers.js'; // --- token whitelist -------------------------------------------------------- @@ -42,12 +42,6 @@ const OPACITY_TOKEN_WHITELIST = new Set([ const LITERAL_OK = /^(?:0|1|inherit|initial|unset|revert)$/; -/** Strip `@keyframes` blocks (one level of nested `{}`) so opacity inside - * animation tracks isn't flagged — it's animation intent, not element state. */ -function stripKeyframes(css: string): string { - return css.replace(/@keyframes\s+[\w-]+\s*\{(?:[^{}]|\{[^{}]*\})*\}/g, ''); -} - function extractOpacityValue(decl: string): string { return decl.replace(/^opacity:\s*/i, '').replace(/;$/, '').trim(); } diff --git a/apps/desktop/src/renderer/styles/reasoning-panel.css b/apps/desktop/src/renderer/styles/reasoning-panel.css index 91482dd632..59e591e747 100644 --- a/apps/desktop/src/renderer/styles/reasoning-panel.css +++ b/apps/desktop/src/renderer/styles/reasoning-panel.css @@ -54,7 +54,7 @@ animation: maka-reasoning-panel-pulse 1.4s var(--ease-in-out-strong) infinite; } @keyframes maka-reasoning-panel-pulse { - 0%, 100% { opacity: var(--opacity-muted); transform: scale(1); } + 0%, 100% { opacity: 0.65; transform: scale(1); } /* PR-FE-BUG-HUNT-6 (kenji aesthetic audit reminder 9, finding #5): standardize live-dot pulse amplitude across the three streaming indicators. The semantic is identical ("live work happening") From b8d0a598a91c46dfbe3c433844dbd13c43d69576 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sun, 5 Jul 2026 11:32:55 +0800 Subject: [PATCH 06/10] =?UTF-8?q?refactor(ui):=20make=20focus-ring=20contr?= =?UTF-8?q?act=20selector-aware=20=E2=80=94=20restore=20non-focus=20drag-a?= =?UTF-8?q?ctive=20ring=20(#520=20PR2=20review=20r2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review fix round 2 (P2 + P3): - P2: contract scanned all box-shadow: 0 0 0 by color, mis-tokenizing non-focus highlights (drag-active .maka-onboarding-quickchat + .maka-composer-inner) from 1px to var(--focus-ring-width) (2px). Restored 1px on the 2 drag-active sites. Contract box-shadow scan now selector-aware: only scans :focus / :focus-visible / :focus-within rules (enclosingSelector walks back from each box-shadow to its rule). Non-focus box-shadow (drag-active, status, info, accent rings) is PR4 box-shadow scope. button:focus-visible bare 3px alpha ring still flagged; drag-active 1px non-focus ring no longer flagged. - P3: outline-offset 6px whitelist was value-level (any 6px bypassed). Now selector-aware: only .maka-turn[data-search-highlight=true] block whitelisted. Bare outline-offset: 6px without that selector fails. Negative cases: button focus 3px alpha ring flagged; drag-active 1px non-focus accepted; bare outline-offset 6px without selector flagged; search-highlight block 6px accepted. Full desktop suite 1948/1948 pass. Dual-theme screenshots unchanged. --- .../focus-ring-recipe-contract.test.ts | 53 +++++++++++++------ apps/desktop/src/renderer/maka-tokens.css | 2 +- .../src/renderer/styles/onboarding.css | 2 +- 3 files changed, 40 insertions(+), 17 deletions(-) diff --git a/apps/desktop/src/main/__tests__/focus-ring-recipe-contract.test.ts b/apps/desktop/src/main/__tests__/focus-ring-recipe-contract.test.ts index ece722f0e8..f061d7f0f2 100644 --- a/apps/desktop/src/main/__tests__/focus-ring-recipe-contract.test.ts +++ b/apps/desktop/src/main/__tests__/focus-ring-recipe-contract.test.ts @@ -34,6 +34,17 @@ const LINK_FOCUS_RE = /outline:\s*1px\s+solid\s+oklch\(from\s+var\(--link\)/i; // --- scanning -------------------------------------------------------------- +/** Walk back from a declaration index to its enclosing selector — the text + * between the previous `}` (or start) and the `{` that opens the rule. */ +function enclosingSelector(css: string, idx: number): string { + const before = css.slice(0, idx); + const openBrace = before.lastIndexOf('{'); + if (openBrace < 0) return ''; + let selStart = before.lastIndexOf('}', openBrace); + if (selStart < 0) selStart = 0; + return css.slice(selStart, openBrace); +} + function findFocusRingOffenders(css: string, label: string): string[] { const stripped = stripCssComments(css); const offenders: string[] = []; @@ -57,22 +68,26 @@ function findFocusRingOffenders(css: string, label: string): string[] { } // outline-offset: must be var(--focus-ring-offset). The search-highlight - // marker (.maka-turn[data-search-highlight]) uses a 6px non-focus offset on - // purpose — it's a visual highlight ring (link color), not the keyboard - // focus-ring recipe, so it's whitelisted as a one-off. + // marker .maka-turn[data-search-highlight="true"] uses a 6px non-focus offset + // on purpose (visual highlight, link color) — whitelisted by selector, not + // by bare value, so a bare 6px in any other focus selector still fails. for (const m of stripped.matchAll(/(? ` where is var(--ring) - // (subtle) or oklch(from var(--focus-ring) … / a) (strong + alpha) — both are - // the focus-ring recipe, so the ring width must be var(--focus-ring-width). - // Status/info/accent/foreground rings are NOT focus rings (PR4 box-shadow scope). + // box-shadow ring width: only inside focus selectors (:focus / :focus-visible + // / :focus-within). Non-focus highlights (drag-active, status, info, accent + // rings) reuse the focus-ring color but are NOT the keyboard focus-ring + // recipe — their box-shadow width convergence is PR4 scope. Walk back from + // each box-shadow to its enclosing selector and skip non-focus rules. for (const m of stripped.matchAll(/box-shadow:\s*0\s+0\s+0\s+(\d+px)\s+(var\(--ring\)|oklch\(from\s+var\(--focus-ring\)[^)]*\))/gi)) { + const selector = enclosingSelector(stripped, m.index!); + if (!/:focus(?:-visible|-within)?\b/i.test(selector)) continue; // non-focus, PR4 scope offenders.push(`${label}: ${m[0].trim()} (bare ring width in box-shadow — use var(--focus-ring-width))`); } @@ -119,18 +134,26 @@ describe('focus-ring recipe negative cases', () => { assert.ok(findFocusRingOffenders('outline-offset: 4px', 'test').length > 0, 'bare 4px must fail'); }); - it('accepts var(--focus-ring-offset) and search-highlight 6px one-off', () => { + it('accepts var(--focus-ring-offset) and search-highlight 6px one-off (by selector)', () => { assert.deepEqual(findFocusRingOffenders('outline-offset: var(--focus-ring-offset)', 'test'), []); - assert.deepEqual(findFocusRingOffenders('outline-offset: 6px', 'test'), []); + assert.deepEqual(findFocusRingOffenders('.maka-turn[data-search-highlight="true"] { outline-offset: 6px; }', 'test'), []); + }); + + it('rejects bare outline-offset 6px without the search-highlight selector', () => { + assert.ok(findFocusRingOffenders('outline-offset: 6px', 'test').length > 0, 'bare 6px without selector must fail'); + }); + + it('rejects bare ring width in focus-selector box-shadow: 0 0 0 var(--ring) or oklch(from var(--focus-ring) …)', () => { + assert.ok(findFocusRingOffenders('.maka-button:focus-visible { box-shadow: 0 0 0 2px var(--ring); }', 'test').length > 0, 'bare ring width must fail'); + assert.ok(findFocusRingOffenders('.maka-button:focus-visible { box-shadow: 0 0 0 3px oklch(from var(--focus-ring) l c h / 0.14); }', 'test').length > 0, 'bare 3px alpha ring must fail'); }); - it('rejects bare ring width in box-shadow: 0 0 0 var(--ring) or oklch(from var(--focus-ring) …)', () => { - assert.ok(findFocusRingOffenders('box-shadow: 0 0 0 2px var(--ring)', 'test').length > 0, 'bare ring width must fail'); - assert.ok(findFocusRingOffenders('box-shadow: 0 0 0 3px oklch(from var(--focus-ring) l c h / 0.14)', 'test').length > 0, 'bare 3px alpha ring must fail'); + it('accepts non-focus box-shadow ring (drag-active) — PR4 scope, not focus-ring recipe', () => { + assert.deepEqual(findFocusRingOffenders('.maka-composer[data-drag-active="true"] { box-shadow: 0 0 0 1px oklch(from var(--focus-ring) l c h / 0.22); }', 'test'), []); }); it('accepts box-shadow ring with var(--focus-ring-width) for both ring colors', () => { - assert.deepEqual(findFocusRingOffenders('box-shadow: 0 0 0 var(--focus-ring-width) var(--ring)', 'test'), []); - assert.deepEqual(findFocusRingOffenders('box-shadow: 0 0 0 var(--focus-ring-width) oklch(from var(--focus-ring) l c h / 0.14)', 'test'), []); + assert.deepEqual(findFocusRingOffenders('.maka-button:focus-visible { box-shadow: 0 0 0 var(--focus-ring-width) var(--ring); }', 'test'), []); + assert.deepEqual(findFocusRingOffenders('.maka-button:focus-visible { box-shadow: 0 0 0 var(--focus-ring-width) oklch(from var(--focus-ring) l c h / 0.14); }', 'test'), []); }); }); \ No newline at end of file diff --git a/apps/desktop/src/renderer/maka-tokens.css b/apps/desktop/src/renderer/maka-tokens.css index 5f829d9be7..fd30f7f22e 100644 --- a/apps/desktop/src/renderer/maka-tokens.css +++ b/apps/desktop/src/renderer/maka-tokens.css @@ -1820,7 +1820,7 @@ border-color: oklch(from var(--focus-ring) l c h / 0.46); background: oklch(from var(--focus-ring) 0.98 calc(c * 0.20) h / 0.35); box-shadow: - 0 0 0 var(--focus-ring-width) oklch(from var(--focus-ring) l c h / 0.22), + 0 0 0 1px oklch(from var(--focus-ring) l c h / 0.22), 0 0 0 4px oklch(from var(--focus-ring) l c h / 0.08), 0 1px 3px oklch(from var(--foreground) l c h / 0.04); } diff --git a/apps/desktop/src/renderer/styles/onboarding.css b/apps/desktop/src/renderer/styles/onboarding.css index 3629bae10d..58bc2bdc2f 100644 --- a/apps/desktop/src/renderer/styles/onboarding.css +++ b/apps/desktop/src/renderer/styles/onboarding.css @@ -478,7 +478,7 @@ border-color: oklch(from var(--focus-ring) l c h / 0.46); background: oklch(from var(--focus-ring) 0.98 calc(c * 0.20) h / 0.35); box-shadow: - 0 0 0 var(--focus-ring-width) oklch(from var(--focus-ring) l c h / 0.22), + 0 0 0 1px oklch(from var(--focus-ring) l c h / 0.22), 0 0 0 4px oklch(from var(--focus-ring) l c h / 0.08); } /* PR-UI-15 (@yuejing 2026-05-22): small example hint shown below the From e465785e18d7dad0b4ff5f6ad01c3f49315c91d3 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sun, 5 Jul 2026 13:03:17 +0800 Subject: [PATCH 07/10] refactor(ui): restore keyframe opacity literal + remap disabled/archived/planned opacity tokens (#520 PR2 review r3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review fix round 3 (P3.1 + P3.3): - P3.1: composer keyframe maka-composer-stream-bounce 0%/60%/100% frame opacity was tokenized to var(--opacity-disabled), conflicting with keyframes-excluded intent (animation value, not element state). Restored literal 0.55. - P3.3: disabled selectors (palette-item[data-disabled], send-button:disabled, settingsConnectionRow[data-status=disabled]) were mapped to --opacity-pending (0.8) — token name means in-progress, not disabled. Remapped to --opacity-disabled (0.5). archived (settingsMemoryEntryGroup[data-archived]) and planned (button[data-support=planned]:hover) are not pending either — remapped to --opacity-muted (0.65). pending selectors (model-switcher/artifact-error-retry/artifact-toolbar-button/code-block-copy [data-pending], reasoning-panel reduced-motion dot, turn-thinking-body) keep --opacity-pending. Full desktop suite 1948/1948 pass. Dual-theme screenshots: disabled controls dimmed 0.8->0.5, archived/planned 0.8->0.65. --- apps/desktop/src/renderer/styles/chat-header.css | 2 +- apps/desktop/src/renderer/styles/composer.css | 4 ++-- apps/desktop/src/renderer/styles/settings/bot.css | 2 +- apps/desktop/src/renderer/styles/settings/connection.css | 2 +- apps/desktop/src/renderer/styles/tool-stream.css | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/renderer/styles/chat-header.css b/apps/desktop/src/renderer/styles/chat-header.css index b0ba4564ec..30151b33aa 100644 --- a/apps/desktop/src/renderer/styles/chat-header.css +++ b/apps/desktop/src/renderer/styles/chat-header.css @@ -874,7 +874,7 @@ .maka-palette-item[data-disabled="true"] { color: var(--muted-foreground); cursor: default; - opacity: var(--opacity-pending); + opacity: var(--opacity-disabled); } .maka-palette-item[data-pending="true"] { diff --git a/apps/desktop/src/renderer/styles/composer.css b/apps/desktop/src/renderer/styles/composer.css index c70f637666..1f352c0134 100644 --- a/apps/desktop/src/renderer/styles/composer.css +++ b/apps/desktop/src/renderer/styles/composer.css @@ -42,7 +42,7 @@ .maka-composer-send-button:disabled { background: oklch(from var(--foreground) 0.72 0 h / 0.18); color: oklch(1 0 0); - opacity: var(--opacity-pending); + opacity: var(--opacity-disabled); cursor: not-allowed; } @@ -151,7 +151,7 @@ @keyframes maka-composer-stream-bounce { 0%, 60%, 100% { transform: translateY(0); - opacity: var(--opacity-disabled); + opacity: 0.55; } 30% { transform: translateY(-3px); diff --git a/apps/desktop/src/renderer/styles/settings/bot.css b/apps/desktop/src/renderer/styles/settings/bot.css index 6136d434db..26e737c1d5 100644 --- a/apps/desktop/src/renderer/styles/settings/bot.css +++ b/apps/desktop/src/renderer/styles/settings/bot.css @@ -53,7 +53,7 @@ opacity: var(--opacity-disabled); } .settingsBotList button[data-support="planned"]:hover { - opacity: var(--opacity-pending); + opacity: var(--opacity-muted); } .settingsBotList button[data-support="planned"][data-active="true"] { opacity: 1; diff --git a/apps/desktop/src/renderer/styles/settings/connection.css b/apps/desktop/src/renderer/styles/settings/connection.css index e45a71dcf4..eab2000f3c 100644 --- a/apps/desktop/src/renderer/styles/settings/connection.css +++ b/apps/desktop/src/renderer/styles/settings/connection.css @@ -83,7 +83,7 @@ color: var(--destructive-text, var(--destructive, red)); } .settingsConnectionRow[data-status="disabled"] { - opacity: var(--opacity-pending); + opacity: var(--opacity-disabled); } .settingsConnectionRow[data-default="true"] { box-shadow: 0 0 0 2px oklch(from var(--control) l c h / 0.12); diff --git a/apps/desktop/src/renderer/styles/tool-stream.css b/apps/desktop/src/renderer/styles/tool-stream.css index db21c22e4f..8b70034968 100644 --- a/apps/desktop/src/renderer/styles/tool-stream.css +++ b/apps/desktop/src/renderer/styles/tool-stream.css @@ -312,7 +312,7 @@ } .settingsMemoryEntryGroup[data-archived="true"] { - opacity: var(--opacity-pending); + opacity: var(--opacity-muted); } .settingsMemoryEntryGroupHeader { From 9e0221e907bead02198bdddca7c42773f37ef999 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sun, 5 Jul 2026 13:03:22 +0800 Subject: [PATCH 08/10] test(ui): focus-ring contract covers inset box-shadow ring (#520 PR2 review r3) Review fix round 3 (P3.2): - contract box-shadow regex now matches optional inset prefix, so inset 0 0 0 inside focus selectors is also scanned. field-focus.css uses inset 0 0 0 var(--focus-ring-width) (already tokenized, not flagged). Future bare inset 0 0 0 1px in a focus selector will fail. Non-focus inset ring (drag-active) still skipped via selector-aware check. - Negative case: .field:focus { box-shadow: inset 0 0 0 1px oklch(from var(--focus-ring)...) } must fail. Full desktop suite 1948/1948 pass. --- .../src/main/__tests__/focus-ring-recipe-contract.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/main/__tests__/focus-ring-recipe-contract.test.ts b/apps/desktop/src/main/__tests__/focus-ring-recipe-contract.test.ts index f061d7f0f2..77c4f93afa 100644 --- a/apps/desktop/src/main/__tests__/focus-ring-recipe-contract.test.ts +++ b/apps/desktop/src/main/__tests__/focus-ring-recipe-contract.test.ts @@ -85,7 +85,7 @@ function findFocusRingOffenders(css: string, label: string): string[] { // rings) reuse the focus-ring color but are NOT the keyboard focus-ring // recipe — their box-shadow width convergence is PR4 scope. Walk back from // each box-shadow to its enclosing selector and skip non-focus rules. - for (const m of stripped.matchAll(/box-shadow:\s*0\s+0\s+0\s+(\d+px)\s+(var\(--ring\)|oklch\(from\s+var\(--focus-ring\)[^)]*\))/gi)) { + for (const m of stripped.matchAll(/box-shadow:\s*(?:inset\s+)?0\s+0\s+0\s+(\d+px)\s+(var\(--ring\)|oklch\(from\s+var\(--focus-ring\)[^)]*\))/gi)) { const selector = enclosingSelector(stripped, m.index!); if (!/:focus(?:-visible|-within)?\b/i.test(selector)) continue; // non-focus, PR4 scope offenders.push(`${label}: ${m[0].trim()} (bare ring width in box-shadow — use var(--focus-ring-width))`); @@ -146,6 +146,7 @@ describe('focus-ring recipe negative cases', () => { it('rejects bare ring width in focus-selector box-shadow: 0 0 0 var(--ring) or oklch(from var(--focus-ring) …)', () => { assert.ok(findFocusRingOffenders('.maka-button:focus-visible { box-shadow: 0 0 0 2px var(--ring); }', 'test').length > 0, 'bare ring width must fail'); assert.ok(findFocusRingOffenders('.maka-button:focus-visible { box-shadow: 0 0 0 3px oklch(from var(--focus-ring) l c h / 0.14); }', 'test').length > 0, 'bare 3px alpha ring must fail'); + assert.ok(findFocusRingOffenders('.field:focus { box-shadow: inset 0 0 0 1px oklch(from var(--focus-ring) l c h / 0.22); }', 'test').length > 0, 'bare inset 1px focus ring must fail'); }); it('accepts non-focus box-shadow ring (drag-active) — PR4 scope, not focus-ring recipe', () => { From 8d0c4d72e0d0b0097b917ffb04e7de90492480ca Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sun, 5 Jul 2026 13:23:58 +0800 Subject: [PATCH 09/10] refactor(ui): converge focus-glow halo width + scan multi-layer box-shadow (#520 PR2 review r4) Review fix round 4 (P3): - contract box-shadow scanner only matched the first layer after box-shadow:, missing the focus-glow halo on the second layer of multi-layer box-shadow (e.g. .maka-onboarding-quickchat:focus-within "0 20px 52px ..., 0 0 0 4px oklch(from var(--focus-ring) / 0.08)"). Scanner now matches every comma-separated layer (?:box-shadow:\s*|,\s*) 0 0 0 . - The 4px low-alpha halo is an intentional focus glow (weak outer ring outside the 2px ring). Rather than rely on the scan gap to keep it, named it explicitly: new --focus-glow-width: 4px token. .maka-onboarding-quickchat:focus-within and .maka-composer-inner:focus-within glow -> var(--focus-glow-width). drag-active 4px glow (non-focus) stays bare (PR4 scope). - contract pins --focus-glow-width exactly-once (4px). Negative cases: .x:focus-within multi-layer bare 4px glow in second layer flagged; non-focus drag-active multi-layer glow not flagged; focus-within ring (var(--focus-ring-width)) + glow (var(--focus-glow-width)) accepted. Full desktop suite: 1950/1951 pass (1 flaky rive-workflow SIGTERM timeout, unrelated to CSS/contract, passes in isolation). Dual-theme screenshots unchanged (4px -> var 4px, no visual delta). --- .../focus-ring-recipe-contract.test.ts | 41 +++++++++++++++---- apps/desktop/src/renderer/maka-tokens.css | 9 ++-- .../src/renderer/styles/onboarding.css | 2 +- 3 files changed, 40 insertions(+), 12 deletions(-) diff --git a/apps/desktop/src/main/__tests__/focus-ring-recipe-contract.test.ts b/apps/desktop/src/main/__tests__/focus-ring-recipe-contract.test.ts index 77c4f93afa..85c28d65bf 100644 --- a/apps/desktop/src/main/__tests__/focus-ring-recipe-contract.test.ts +++ b/apps/desktop/src/main/__tests__/focus-ring-recipe-contract.test.ts @@ -80,15 +80,18 @@ function findFocusRingOffenders(css: string, label: string): string[] { offenders.push(`${label}: ${decl} (bare outline-offset — use var(--focus-ring-offset))`); } - // box-shadow ring width: only inside focus selectors (:focus / :focus-visible - // / :focus-within). Non-focus highlights (drag-active, status, info, accent - // rings) reuse the focus-ring color but are NOT the keyboard focus-ring - // recipe — their box-shadow width convergence is PR4 scope. Walk back from - // each box-shadow to its enclosing selector and skip non-focus rules. - for (const m of stripped.matchAll(/box-shadow:\s*(?:inset\s+)?0\s+0\s+0\s+(\d+px)\s+(var\(--ring\)|oklch\(from\s+var\(--focus-ring\)[^)]*\))/gi)) { + // box-shadow ring/glow width: scan every comma-separated layer inside focus + // selectors (:focus / :focus-visible / :focus-within). Non-focus highlights + // (drag-active, status, info, accent rings) reuse the focus-ring color but + // are NOT the keyboard focus-ring recipe — their box-shadow width convergence + // is PR4 scope. Walk back from each layer to its enclosing selector and skip + // non-focus rules. Bare ring width -> var(--focus-ring-width); bare glow halo + // width (the low-alpha 4px outer ring) -> var(--focus-glow-width). + for (const m of stripped.matchAll(/(?:box-shadow:\s*|,\s*)(?:inset\s+)?0\s+0\s+0\s+(\d+px)\s+(var\(--ring\)|oklch\(from\s+var\(--focus-ring\)[^)]*\))/gi)) { const selector = enclosingSelector(stripped, m.index!); if (!/:focus(?:-visible|-within)?\b/i.test(selector)) continue; // non-focus, PR4 scope - offenders.push(`${label}: ${m[0].trim()} (bare ring width in box-shadow — use var(--focus-ring-width))`); + const layer = m[0].replace(/^(?:box-shadow:\s*|,\s*)/, '').trim(); + offenders.push(`${label}: ${layer} (bare ring/glow width in box-shadow — use var(--focus-ring-width) or var(--focus-glow-width))`); } return offenders; @@ -103,10 +106,11 @@ describe('PR-FOCUS-RING-RECIPE-0 contract', () => { assert.deepEqual(offenders, [], `Offenders:\n ${offenders.join('\n ')}`); }); - it('--focus-ring-width / --focus-ring-offset are declared exactly once with pinned values', async () => { + it('--focus-ring-width / --focus-ring-offset / --focus-glow-width are declared exactly once with pinned values', async () => { const tokens = await readFile(TOKENS_FILE, 'utf8'); assertCustomPropPinnedOnce(tokens, '--focus-ring-width', '2px'); assertCustomPropPinnedOnce(tokens, '--focus-ring-offset', '2px'); + assertCustomPropPinnedOnce(tokens, '--focus-glow-width', '4px'); }); }); @@ -153,6 +157,27 @@ describe('focus-ring recipe negative cases', () => { assert.deepEqual(findFocusRingOffenders('.maka-composer[data-drag-active="true"] { box-shadow: 0 0 0 1px oklch(from var(--focus-ring) l c h / 0.22); }', 'test'), []); }); + it('rejects bare ring/glow width in second layer of multi-layer focus box-shadow', () => { + assert.ok( + findFocusRingOffenders('.x:focus-within { box-shadow: 0 20px 52px var(--shadow), 0 0 0 4px oklch(from var(--focus-ring) l c h / 0.08); }', 'test').length > 0, + 'bare 4px glow in second layer must fail — use var(--focus-glow-width)', + ); + }); + + it('accepts non-focus multi-layer box-shadow (drag-active glow) — PR4 scope', () => { + assert.deepEqual( + findFocusRingOffenders('.maka-composer[data-drag-active="true"] { box-shadow: 0 0 0 1px oklch(from var(--focus-ring) l c h / 0.22), 0 0 0 4px oklch(from var(--focus-ring) l c h / 0.08); }', 'test'), + [], + ); + }); + + it('accepts focus-within box-shadow with var(--focus-ring-width) ring + var(--focus-glow-width) glow', () => { + assert.deepEqual( + findFocusRingOffenders('.maka-composer-inner:focus-within { box-shadow: 0 0 0 var(--focus-ring-width) oklch(from var(--focus-ring) l c h / 0.20), 0 0 0 var(--focus-glow-width) oklch(from var(--focus-ring) l c h / 0.08); }', 'test'), + [], + ); + }); + it('accepts box-shadow ring with var(--focus-ring-width) for both ring colors', () => { assert.deepEqual(findFocusRingOffenders('.maka-button:focus-visible { box-shadow: 0 0 0 var(--focus-ring-width) var(--ring); }', 'test'), []); assert.deepEqual(findFocusRingOffenders('.maka-button:focus-visible { box-shadow: 0 0 0 var(--focus-ring-width) oklch(from var(--focus-ring) l c h / 0.14); }', 'test'), []); diff --git a/apps/desktop/src/renderer/maka-tokens.css b/apps/desktop/src/renderer/maka-tokens.css index fd30f7f22e..adfee14126 100644 --- a/apps/desktop/src/renderer/maka-tokens.css +++ b/apps/desktop/src/renderer/maka-tokens.css @@ -427,11 +427,14 @@ /* === focus-ring recipe === --focus-ring is the strong accent color (existing); --ring is the subtle foreground-derived color (existing, for compact/secondary - focus). These two geometry tokens converge outline width/offset so - every focus ring shares one recipe instead of hand-written 2px. + focus). These three geometry tokens converge outline width/offset + plus the outer focus-glow halo width so every focus ring shares one + recipe instead of hand-written 2px. The glow is the low-alpha + 4px halo that sits outside the 2px ring on focus-within fields. PR-FOCUS-RING-RECIPE-0 (issue #520 PR2). */ --focus-ring-width: 2px; --focus-ring-offset: 2px; + --focus-glow-width: 4px; /* === transform amplitude (motion) === Tokenize the geometric magnitude of press/hover feedback so the @@ -1813,7 +1816,7 @@ background: var(--background); box-shadow: 0 0 0 var(--focus-ring-width) oklch(from var(--focus-ring) l c h / 0.20), - 0 0 0 4px oklch(from var(--focus-ring) l c h / 0.08), + 0 0 0 var(--focus-glow-width) oklch(from var(--focus-ring) l c h / 0.08), 0 1px 3px oklch(from var(--foreground) l c h / 0.04); } .maka-composer[data-drag-active="true"] .maka-composer-inner { diff --git a/apps/desktop/src/renderer/styles/onboarding.css b/apps/desktop/src/renderer/styles/onboarding.css index 58bc2bdc2f..59ea3764de 100644 --- a/apps/desktop/src/renderer/styles/onboarding.css +++ b/apps/desktop/src/renderer/styles/onboarding.css @@ -458,7 +458,7 @@ border-color: oklch(from var(--focus-ring) l c h / 0.34); box-shadow: 0 20px 52px oklch(from var(--foreground) l c h / 0.10), - 0 0 0 4px oklch(from var(--focus-ring) l c h / 0.08), + 0 0 0 var(--focus-glow-width) oklch(from var(--focus-ring) l c h / 0.08), 0 1px 0 oklch(from var(--foreground) 1 0 h / 0.75) inset; } /* The visible quick-chat chrome belongs to `.maka-onboarding-quickchat`; From 6e0d08efcbacc65e534fe09a63f1f8985842621a Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sun, 5 Jul 2026 13:55:33 +0800 Subject: [PATCH 10/10] refactor(ui): make link-color outline whitelist selector-aware (#520 PR2 review r5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review fix round 5 (P3): - LINK_FOCUS_RE was a global value-level whitelist: any outline: 1px solid oklch(from var(--link)...) bypassed the focus-ring contract. The only actual exception is .maka-turn[data-search-highlight=true] (search-highlight marker, 1px link-color outline + 6px non-focus offset — visual highlight, not keyboard focus). No real link-focus selector uses a 1px link outline today. - Removed LINK_FOCUS_RE; outline scan now selector-aware via enclosingSelector — only .maka-turn[data-search-highlight=true] block whitelisted for 1px link-color outline. Bare 1px link outline in any other selector fails. Comment updated link-focus one-off -> search-highlight one-off. - Negative cases: bare 1px link-color outline without selector flagged; .maka-turn[data-search-highlight=true] { outline: 1px solid oklch(from var(--link)...); outline-offset: 6px; } accepted. Full desktop suite 1953/1953 pass. --- .../focus-ring-recipe-contract.test.ts | 32 ++++++++++--------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/apps/desktop/src/main/__tests__/focus-ring-recipe-contract.test.ts b/apps/desktop/src/main/__tests__/focus-ring-recipe-contract.test.ts index 85c28d65bf..a2d07d80c9 100644 --- a/apps/desktop/src/main/__tests__/focus-ring-recipe-contract.test.ts +++ b/apps/desktop/src/main/__tests__/focus-ring-recipe-contract.test.ts @@ -14,11 +14,11 @@ * 3. `box-shadow: 0 0 0 var(--ring)` (global *:focus-visible ring) must * use `var(--focus-ring-width)` for the ring width. * - * `--focus-ring-width: 2px` + `--focus-ring-offset: 2px` are declared in - * maka-tokens.css. The link-focus outline (`outline: 1px solid - * oklch(from var(--link) …)`) is whitelisted as a one-off — it's a link - * affordance, not the keyboard focus-ring recipe, and uses a different - * color/width on purpose. + * `--focus-ring-width: 2px` + `--focus-ring-offset: 2px` + `--focus-glow-width: 4px` + * are declared in maka-tokens.css. The search-highlight marker + * (.maka-turn[data-search-highlight="true"]) uses a 1px link-color outline + + * 6px non-focus offset on purpose — it's a visual highlight, not the keyboard + * focus-ring recipe, and is whitelisted by selector (not by bare value). */ import { strict as assert } from 'node:assert'; @@ -26,12 +26,6 @@ import { readFile } from 'node:fs/promises'; import { describe, it } from 'node:test'; import { REPO_ROOT, TOKENS_FILE, readAllRendererCss, stripCssComments, assertCustomPropPinnedOnce } from './css-test-helpers.js'; -// --- whitelist ------------------------------------------------------------- - -/** Link-focus one-off: `outline: 1px solid oklch(from var(--link) …)` — - * not the keyboard focus-ring recipe (different color + width on purpose). */ -const LINK_FOCUS_RE = /outline:\s*1px\s+solid\s+oklch\(from\s+var\(--link\)/i; - // --- scanning -------------------------------------------------------------- /** Walk back from a declaration index to its enclosing selector — the text @@ -56,10 +50,11 @@ function findFocusRingOffenders(css: string, label: string): string[] { // outline: none / 0 / 0px — literal disable, OK if (/^(?:none|0(?:px)?)\b/i.test(value)) continue; - // link-focus one-off whitelist - if (LINK_FOCUS_RE.test(decl)) continue; // outline: var(--focus-ring-width) solid … — recipe, OK if (/^var\(--focus-ring-width\)\s+solid\b/i.test(value)) continue; + // search-highlight one-off: 1px link-color outline (non-focus visual marker) + const selector = enclosingSelector(stripped, m.index!); + if (/\.maka-turn\[data-search-highlight="true"\]/.test(selector) && /^1px\s+solid\s+oklch\(from\s+var\(--link\)/i.test(value)) continue; // any other outline with a bare px width — offender if (/^\d+px\s+solid\b/i.test(value)) { @@ -126,10 +121,17 @@ describe('focus-ring recipe negative cases', () => { assert.deepEqual(findFocusRingOffenders('outline: var(--focus-ring-width) solid oklch(from var(--focus-ring) l c h / 0.42)', 'test'), []); }); - it('accepts outline: none / 0 (disable focus) and link-focus one-off', () => { + it('accepts outline: none / 0 (disable focus)', () => { assert.deepEqual(findFocusRingOffenders('outline: none', 'test'), []); assert.deepEqual(findFocusRingOffenders('outline: 0', 'test'), []); - assert.deepEqual(findFocusRingOffenders('outline: 1px solid oklch(from var(--link) l c h / 0.34)', 'test'), []); + }); + + it('rejects bare 1px link-color outline without the search-highlight selector', () => { + assert.ok(findFocusRingOffenders('outline: 1px solid oklch(from var(--link) l c h / 0.34)', 'test').length > 0, 'bare 1px link outline without selector must fail'); + }); + + it('accepts search-highlight outline + 6px offset (by selector)', () => { + assert.deepEqual(findFocusRingOffenders('.maka-turn[data-search-highlight="true"] { outline: 1px solid oklch(from var(--link) l c h / 0.34); outline-offset: 6px; }', 'test'), []); }); it('rejects bare outline-offset px (and negatives)', () => {