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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand DownExpand Up@@ -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]*\}/);
});
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -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: ''; }",
]) {
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand Down
9 changes: 9 additions & 0 deletions apps/desktop/src/main/__tests__/css-test-helpers.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -51,6 +51,15 @@ export function stripCssComments(src: string): string {
return src.replace(/\/\*[\s\S]*?\*\//g, '');
}

/** Strip `@keyframes <name> { … }` 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`),
Expand Down
187 changes: 187 additions & 0 deletions apps/desktop/src/main/__tests__/focus-ring-recipe-contract.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
/**
* 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 <px> var(--ring)` (global *:focus-visible ring) must
* use `var(--focus-ring-width)` for the ring width.
*
* `--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';
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';

// --- 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[] = [];

// outline: <width> solid <color> — width must be var(--focus-ring-width), or none/0
for (const m of stripped.matchAll(/(?<![-\w])outline:\s*([^;}\n]+)/gi)) {
const decl = m[0].trim();
const value = m[1].trim();

// outline: none / 0 / 0px — literal disable, OK
if (/^(?:none|0(?:px)?)\b/i.test(value)) 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)) {
offenders.push(`${label}: ${decl} (bare outline width — use var(--focus-ring-width))`);
}
}

// outline-offset: must be var(--focus-ring-offset). The search-highlight
// 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(/(?<![-\w])outline-offset:\s*([^;}\n]+)/gi)) {
const decl = m[0].trim();
const value = m[1].trim();
if (/^var\(--focus-ring-offset\)/i.test(value)) continue;
const selector = enclosingSelector(stripped, m.index!);
if (/\.maka-turn\[data-search-highlight="true"\]/.test(selector)) continue;
offenders.push(`${label}: ${decl} (bare outline-offset — use var(--focus-ring-offset))`);
}

// 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
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;
}

// === 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 / --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');
});
});

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)', () => {
assert.deepEqual(findFocusRingOffenders('outline: none', 'test'), []);
assert.deepEqual(findFocusRingOffenders('outline: 0', '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)', () => {
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: 4px', 'test').length > 0, 'bare 4px must fail');
});

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('.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 <px> 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', () => {
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'), []);
});
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,7 +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 } from './css-test-helpers.js';
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 () => {
Expand DownExpand Up@@ -116,12 +116,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 () => {
Expand DownExpand Up@@ -233,4 +236,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(/(?<![\w-])scale\(\s*(var\(--[\w-]+\)|[-\w.]+)\s*\)/g)) {
const value = m[1].trim();
if (value === '1') continue; // literal reset
if (/^var\(--scale-(press|hover)\)$/.test(value)) continue;
if (value === '1.1') continue; // decorative onboarding scale, whitelisted
offenders.push(`scale(${value})`);
}
for (const m of stripped.matchAll(/(?<![\w-])translateY\(\s*(var\(--[\w-]+\)|[-\w.]+)\s*\)/g)) {
const value = m[1].trim();
if (value === '0') continue; // literal reset
if (/^var\(--lift-hover\)$/.test(value)) continue;
if (value === '-3px') continue; // strong lift, whitelisted
offenders.push(`translateY(${value})`);
}
assert.deepEqual(offenders, [], `Bare transform amplitude must use var(--scale-press/hover) or var(--lift-hover). 1/-3px decorative/strong whitelisted, keyframes excluded:\n ${offenders.join('\n ')}`);
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand DownExpand Up@@ -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]*\}/);
});
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -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: ''; }",
]) {
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand Down
9 changes: 9 additions & 0 deletions apps/desktop/src/main/__tests__/css-test-helpers.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -51,6 +51,15 @@ export function stripCssComments(src: string): string {
return src.replace(/\/\*[\s\S]*?\*\//g, '');
}

/** Strip `@keyframes <name> { … }` 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`),
Expand Down
187 changes: 187 additions & 0 deletions apps/desktop/src/main/__tests__/focus-ring-recipe-contract.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
/**
* 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 <px> var(--ring)` (global *:focus-visible ring) must
* use `var(--focus-ring-width)` for the ring width.
*
* `--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';
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';

// --- 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[] = [];

// outline: <width> solid <color> — width must be var(--focus-ring-width), or none/0
for (const m of stripped.matchAll(/(?<![-\w])outline:\s*([^;}\n]+)/gi)) {
const decl = m[0].trim();
const value = m[1].trim();

// outline: none / 0 / 0px — literal disable, OK
if (/^(?:none|0(?:px)?)\b/i.test(value)) 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)) {
offenders.push(`${label}: ${decl} (bare outline width — use var(--focus-ring-width))`);
}
}

// outline-offset: must be var(--focus-ring-offset). The search-highlight
// 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(/(?<![-\w])outline-offset:\s*([^;}\n]+)/gi)) {
const decl = m[0].trim();
const value = m[1].trim();
if (/^var\(--focus-ring-offset\)/i.test(value)) continue;
const selector = enclosingSelector(stripped, m.index!);
if (/\.maka-turn\[data-search-highlight="true"\]/.test(selector)) continue;
offenders.push(`${label}: ${decl} (bare outline-offset — use var(--focus-ring-offset))`);
}

// 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
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;
}

// === 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 / --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');
});
});

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)', () => {
assert.deepEqual(findFocusRingOffenders('outline: none', 'test'), []);
assert.deepEqual(findFocusRingOffenders('outline: 0', '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)', () => {
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: 4px', 'test').length > 0, 'bare 4px must fail');
});

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('.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 <px> 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', () => {
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'), []);
});
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,7 +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 } from './css-test-helpers.js';
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 () => {
Expand DownExpand Up@@ -116,12 +116,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 () => {
Expand DownExpand Up@@ -233,4 +236,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(/(?<![\w-])scale\(\s*(var\(--[\w-]+\)|[-\w.]+)\s*\)/g)) {
const value = m[1].trim();
if (value === '1') continue; // literal reset
if (/^var\(--scale-(press|hover)\)$/.test(value)) continue;
if (value === '1.1') continue; // decorative onboarding scale, whitelisted
offenders.push(`scale(${value})`);
}
for (const m of stripped.matchAll(/(?<![\w-])translateY\(\s*(var\(--[\w-]+\)|[-\w.]+)\s*\)/g)) {
const value = m[1].trim();
if (value === '0') continue; // literal reset
if (/^var\(--lift-hover\)$/.test(value)) continue;
if (value === '-3px') continue; // strong lift, whitelisted
offenders.push(`translateY(${value})`);
}
assert.deepEqual(offenders, [], `Bare transform amplitude must use var(--scale-press/hover) or var(--lift-hover). 1/-3px decorative/strong whitelisted, keyframes excluded:\n ${offenders.join('\n ')}`);
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand DownExpand Up@@ -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]*\}/);
});
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -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: ''; }",
]) {
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand Down
9 changes: 9 additions & 0 deletions apps/desktop/src/main/__tests__/css-test-helpers.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -51,6 +51,15 @@ export function stripCssComments(src: string): string {
return src.replace(/\/\*[\s\S]*?\*\//g, '');
}

/** Strip `@keyframes <name> { … }` 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`),
Expand Down
187 changes: 187 additions & 0 deletions apps/desktop/src/main/__tests__/focus-ring-recipe-contract.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
/**
* 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 <px> var(--ring)` (global *:focus-visible ring) must
* use `var(--focus-ring-width)` for the ring width.
*
* `--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';
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';

// --- 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[] = [];

// outline: <width> solid <color> — width must be var(--focus-ring-width), or none/0
for (const m of stripped.matchAll(/(?<![-\w])outline:\s*([^;}\n]+)/gi)) {
const decl = m[0].trim();
const value = m[1].trim();

// outline: none / 0 / 0px — literal disable, OK
if (/^(?:none|0(?:px)?)\b/i.test(value)) 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)) {
offenders.push(`${label}: ${decl} (bare outline width — use var(--focus-ring-width))`);
}
}

// outline-offset: must be var(--focus-ring-offset). The search-highlight
// 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(/(?<![-\w])outline-offset:\s*([^;}\n]+)/gi)) {
const decl = m[0].trim();
const value = m[1].trim();
if (/^var\(--focus-ring-offset\)/i.test(value)) continue;
const selector = enclosingSelector(stripped, m.index!);
if (/\.maka-turn\[data-search-highlight="true"\]/.test(selector)) continue;
offenders.push(`${label}: ${decl} (bare outline-offset — use var(--focus-ring-offset))`);
}

// 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
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;
}

// === 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 / --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');
});
});

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)', () => {
assert.deepEqual(findFocusRingOffenders('outline: none', 'test'), []);
assert.deepEqual(findFocusRingOffenders('outline: 0', '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)', () => {
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: 4px', 'test').length > 0, 'bare 4px must fail');
});

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('.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 <px> 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', () => {
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'), []);
});
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,7 +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 } from './css-test-helpers.js';
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 () => {
Expand DownExpand Up@@ -116,12 +116,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 () => {
Expand DownExpand Up@@ -233,4 +236,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(/(?<![\w-])scale\(\s*(var\(--[\w-]+\)|[-\w.]+)\s*\)/g)) {
const value = m[1].trim();
if (value === '1') continue; // literal reset
if (/^var\(--scale-(press|hover)\)$/.test(value)) continue;
if (value === '1.1') continue; // decorative onboarding scale, whitelisted
offenders.push(`scale(${value})`);
}
for (const m of stripped.matchAll(/(?<![\w-])translateY\(\s*(var\(--[\w-]+\)|[-\w.]+)\s*\)/g)) {
const value = m[1].trim();
if (value === '0') continue; // literal reset
if (/^var\(--lift-hover\)$/.test(value)) continue;
if (value === '-3px') continue; // strong lift, whitelisted
offenders.push(`translateY(${value})`);
}
assert.deepEqual(offenders, [], `Bare transform amplitude must use var(--scale-press/hover) or var(--lift-hover). 1/-3px decorative/strong whitelisted, keyframes excluded:\n ${offenders.join('\n ')}`);
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand DownExpand Up@@ -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]*\}/);
});
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -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: ''; }",
]) {
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand Down
9 changes: 9 additions & 0 deletions apps/desktop/src/main/__tests__/css-test-helpers.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -51,6 +51,15 @@ export function stripCssComments(src: string): string {
return src.replace(/\/\*[\s\S]*?\*\//g, '');
}

/** Strip `@keyframes <name> { … }` 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`),
Expand Down
187 changes: 187 additions & 0 deletions apps/desktop/src/main/__tests__/focus-ring-recipe-contract.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
/**
* 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 <px> var(--ring)` (global *:focus-visible ring) must
* use `var(--focus-ring-width)` for the ring width.
*
* `--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';
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';

// --- 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[] = [];

// outline: <width> solid <color> — width must be var(--focus-ring-width), or none/0
for (const m of stripped.matchAll(/(?<![-\w])outline:\s*([^;}\n]+)/gi)) {
const decl = m[0].trim();
const value = m[1].trim();

// outline: none / 0 / 0px — literal disable, OK
if (/^(?:none|0(?:px)?)\b/i.test(value)) 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)) {
offenders.push(`${label}: ${decl} (bare outline width — use var(--focus-ring-width))`);
}
}

// outline-offset: must be var(--focus-ring-offset). The search-highlight
// 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(/(?<![-\w])outline-offset:\s*([^;}\n]+)/gi)) {
const decl = m[0].trim();
const value = m[1].trim();
if (/^var\(--focus-ring-offset\)/i.test(value)) continue;
const selector = enclosingSelector(stripped, m.index!);
if (/\.maka-turn\[data-search-highlight="true"\]/.test(selector)) continue;
offenders.push(`${label}: ${decl} (bare outline-offset — use var(--focus-ring-offset))`);
}

// 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
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;
}

// === 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 / --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');
});
});

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)', () => {
assert.deepEqual(findFocusRingOffenders('outline: none', 'test'), []);
assert.deepEqual(findFocusRingOffenders('outline: 0', '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)', () => {
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: 4px', 'test').length > 0, 'bare 4px must fail');
});

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('.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 <px> 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', () => {
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'), []);
});
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,7 +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 } from './css-test-helpers.js';
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 () => {
Expand DownExpand Up@@ -116,12 +116,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 () => {
Expand DownExpand Up@@ -233,4 +236,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(/(?<![\w-])scale\(\s*(var\(--[\w-]+\)|[-\w.]+)\s*\)/g)) {
const value = m[1].trim();
if (value === '1') continue; // literal reset
if (/^var\(--scale-(press|hover)\)$/.test(value)) continue;
if (value === '1.1') continue; // decorative onboarding scale, whitelisted
offenders.push(`scale(${value})`);
}
for (const m of stripped.matchAll(/(?<![\w-])translateY\(\s*(var\(--[\w-]+\)|[-\w.]+)\s*\)/g)) {
const value = m[1].trim();
if (value === '0') continue; // literal reset
if (/^var\(--lift-hover\)$/.test(value)) continue;
if (value === '-3px') continue; // strong lift, whitelisted
offenders.push(`translateY(${value})`);
}
assert.deepEqual(offenders, [], `Bare transform amplitude must use var(--scale-press/hover) or var(--lift-hover). 1/-3px decorative/strong whitelisted, keyframes excluded:\n ${offenders.join('\n ')}`);
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand DownExpand Up@@ -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]*\}/);
});
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -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: ''; }",
]) {
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand Down
9 changes: 9 additions & 0 deletions apps/desktop/src/main/__tests__/css-test-helpers.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -51,6 +51,15 @@ export function stripCssComments(src: string): string {
return src.replace(/\/\*[\s\S]*?\*\//g, '');
}

/** Strip `@keyframes <name> { … }` 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`),
Expand Down
187 changes: 187 additions & 0 deletions apps/desktop/src/main/__tests__/focus-ring-recipe-contract.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
/**
* 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 <px> var(--ring)` (global *:focus-visible ring) must
* use `var(--focus-ring-width)` for the ring width.
*
* `--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';
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';

// --- 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[] = [];

// outline: <width> solid <color> — width must be var(--focus-ring-width), or none/0
for (const m of stripped.matchAll(/(?<![-\w])outline:\s*([^;}\n]+)/gi)) {
const decl = m[0].trim();
const value = m[1].trim();

// outline: none / 0 / 0px — literal disable, OK
if (/^(?:none|0(?:px)?)\b/i.test(value)) 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)) {
offenders.push(`${label}: ${decl} (bare outline width — use var(--focus-ring-width))`);
}
}

// outline-offset: must be var(--focus-ring-offset). The search-highlight
// 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(/(?<![-\w])outline-offset:\s*([^;}\n]+)/gi)) {
const decl = m[0].trim();
const value = m[1].trim();
if (/^var\(--focus-ring-offset\)/i.test(value)) continue;
const selector = enclosingSelector(stripped, m.index!);
if (/\.maka-turn\[data-search-highlight="true"\]/.test(selector)) continue;
offenders.push(`${label}: ${decl} (bare outline-offset — use var(--focus-ring-offset))`);
}

// 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
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;
}

// === 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 / --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');
});
});

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)', () => {
assert.deepEqual(findFocusRingOffenders('outline: none', 'test'), []);
assert.deepEqual(findFocusRingOffenders('outline: 0', '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)', () => {
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: 4px', 'test').length > 0, 'bare 4px must fail');
});

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('.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 <px> 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', () => {
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'), []);
});
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,7 +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 } from './css-test-helpers.js';
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 () => {
Expand DownExpand Up@@ -116,12 +116,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 () => {
Expand DownExpand Up@@ -233,4 +236,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(/(?<![\w-])scale\(\s*(var\(--[\w-]+\)|[-\w.]+)\s*\)/g)) {
const value = m[1].trim();
if (value === '1') continue; // literal reset
if (/^var\(--scale-(press|hover)\)$/.test(value)) continue;
if (value === '1.1') continue; // decorative onboarding scale, whitelisted
offenders.push(`scale(${value})`);
}
for (const m of stripped.matchAll(/(?<![\w-])translateY\(\s*(var\(--[\w-]+\)|[-\w.]+)\s*\)/g)) {
const value = m[1].trim();
if (value === '0') continue; // literal reset
if (/^var\(--lift-hover\)$/.test(value)) continue;
if (value === '-3px') continue; // strong lift, whitelisted
offenders.push(`translateY(${value})`);
}
assert.deepEqual(offenders, [], `Bare transform amplitude must use var(--scale-press/hover) or var(--lift-hover). 1/-3px decorative/strong whitelisted, keyframes excluded:\n ${offenders.join('\n ')}`);
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand DownExpand Up@@ -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]*\}/);
});
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -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: ''; }",
]) {
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand Down
9 changes: 9 additions & 0 deletions apps/desktop/src/main/__tests__/css-test-helpers.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -51,6 +51,15 @@ export function stripCssComments(src: string): string {
return src.replace(/\/\*[\s\S]*?\*\//g, '');
}

/** Strip `@keyframes <name> { … }` 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`),
Expand Down
187 changes: 187 additions & 0 deletions apps/desktop/src/main/__tests__/focus-ring-recipe-contract.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
/**
* 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 <px> var(--ring)` (global *:focus-visible ring) must
* use `var(--focus-ring-width)` for the ring width.
*
* `--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';
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';

// --- 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[] = [];

// outline: <width> solid <color> — width must be var(--focus-ring-width), or none/0
for (const m of stripped.matchAll(/(?<![-\w])outline:\s*([^;}\n]+)/gi)) {
const decl = m[0].trim();
const value = m[1].trim();

// outline: none / 0 / 0px — literal disable, OK
if (/^(?:none|0(?:px)?)\b/i.test(value)) 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)) {
offenders.push(`${label}: ${decl} (bare outline width — use var(--focus-ring-width))`);
}
}

// outline-offset: must be var(--focus-ring-offset). The search-highlight
// 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(/(?<![-\w])outline-offset:\s*([^;}\n]+)/gi)) {
const decl = m[0].trim();
const value = m[1].trim();
if (/^var\(--focus-ring-offset\)/i.test(value)) continue;
const selector = enclosingSelector(stripped, m.index!);
if (/\.maka-turn\[data-search-highlight="true"\]/.test(selector)) continue;
offenders.push(`${label}: ${decl} (bare outline-offset — use var(--focus-ring-offset))`);
}

// 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
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;
}

// === 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 / --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');
});
});

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)', () => {
assert.deepEqual(findFocusRingOffenders('outline: none', 'test'), []);
assert.deepEqual(findFocusRingOffenders('outline: 0', '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)', () => {
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: 4px', 'test').length > 0, 'bare 4px must fail');
});

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('.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 <px> 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', () => {
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'), []);
});
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,7 +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 } from './css-test-helpers.js';
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 () => {
Expand DownExpand Up@@ -116,12 +116,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 () => {
Expand DownExpand Up@@ -233,4 +236,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(/(?<![\w-])scale\(\s*(var\(--[\w-]+\)|[-\w.]+)\s*\)/g)) {
const value = m[1].trim();
if (value === '1') continue; // literal reset
if (/^var\(--scale-(press|hover)\)$/.test(value)) continue;
if (value === '1.1') continue; // decorative onboarding scale, whitelisted
offenders.push(`scale(${value})`);
}
for (const m of stripped.matchAll(/(?<![\w-])translateY\(\s*(var\(--[\w-]+\)|[-\w.]+)\s*\)/g)) {
const value = m[1].trim();
if (value === '0') continue; // literal reset
if (/^var\(--lift-hover\)$/.test(value)) continue;
if (value === '-3px') continue; // strong lift, whitelisted
offenders.push(`translateY(${value})`);
}
assert.deepEqual(offenders, [], `Bare transform amplitude must use var(--scale-press/hover) or var(--lift-hover). 1/-3px decorative/strong whitelisted, keyframes excluded:\n ${offenders.join('\n ')}`);
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand DownExpand Up@@ -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]*\}/);
});
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -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: ''; }",
]) {
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand Down
9 changes: 9 additions & 0 deletions apps/desktop/src/main/__tests__/css-test-helpers.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -51,6 +51,15 @@ export function stripCssComments(src: string): string {
return src.replace(/\/\*[\s\S]*?\*\//g, '');
}

/** Strip `@keyframes <name> { … }` 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`),
Expand Down
187 changes: 187 additions & 0 deletions apps/desktop/src/main/__tests__/focus-ring-recipe-contract.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
/**
* 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 <px> var(--ring)` (global *:focus-visible ring) must
* use `var(--focus-ring-width)` for the ring width.
*
* `--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';
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';

// --- 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[] = [];

// outline: <width> solid <color> — width must be var(--focus-ring-width), or none/0
for (const m of stripped.matchAll(/(?<![-\w])outline:\s*([^;}\n]+)/gi)) {
const decl = m[0].trim();
const value = m[1].trim();

// outline: none / 0 / 0px — literal disable, OK
if (/^(?:none|0(?:px)?)\b/i.test(value)) 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)) {
offenders.push(`${label}: ${decl} (bare outline width — use var(--focus-ring-width))`);
}
}

// outline-offset: must be var(--focus-ring-offset). The search-highlight
// 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(/(?<![-\w])outline-offset:\s*([^;}\n]+)/gi)) {
const decl = m[0].trim();
const value = m[1].trim();
if (/^var\(--focus-ring-offset\)/i.test(value)) continue;
const selector = enclosingSelector(stripped, m.index!);
if (/\.maka-turn\[data-search-highlight="true"\]/.test(selector)) continue;
offenders.push(`${label}: ${decl} (bare outline-offset — use var(--focus-ring-offset))`);
}

// 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
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;
}

// === 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 / --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');
});
});

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)', () => {
assert.deepEqual(findFocusRingOffenders('outline: none', 'test'), []);
assert.deepEqual(findFocusRingOffenders('outline: 0', '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)', () => {
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: 4px', 'test').length > 0, 'bare 4px must fail');
});

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('.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 <px> 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', () => {
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'), []);
});
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,7 +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 } from './css-test-helpers.js';
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 () => {
Expand DownExpand Up@@ -116,12 +116,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 () => {
Expand DownExpand Up@@ -233,4 +236,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(/(?<![\w-])scale\(\s*(var\(--[\w-]+\)|[-\w.]+)\s*\)/g)) {
const value = m[1].trim();
if (value === '1') continue; // literal reset
if (/^var\(--scale-(press|hover)\)$/.test(value)) continue;
if (value === '1.1') continue; // decorative onboarding scale, whitelisted
offenders.push(`scale(${value})`);
}
for (const m of stripped.matchAll(/(?<![\w-])translateY\(\s*(var\(--[\w-]+\)|[-\w.]+)\s*\)/g)) {
const value = m[1].trim();
if (value === '0') continue; // literal reset
if (/^var\(--lift-hover\)$/.test(value)) continue;
if (value === '-3px') continue; // strong lift, whitelisted
offenders.push(`translateY(${value})`);
}
assert.deepEqual(offenders, [], `Bare transform amplitude must use var(--scale-press/hover) or var(--lift-hover). 1/-3px decorative/strong whitelisted, keyframes excluded:\n ${offenders.join('\n ')}`);
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand DownExpand Up@@ -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]*\}/);
});
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -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: ''; }",
]) {
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand Down
9 changes: 9 additions & 0 deletions apps/desktop/src/main/__tests__/css-test-helpers.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -51,6 +51,15 @@ export function stripCssComments(src: string): string {
return src.replace(/\/\*[\s\S]*?\*\//g, '');
}

/** Strip `@keyframes <name> { … }` 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`),
Expand Down
187 changes: 187 additions & 0 deletions apps/desktop/src/main/__tests__/focus-ring-recipe-contract.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
/**
* 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 <px> var(--ring)` (global *:focus-visible ring) must
* use `var(--focus-ring-width)` for the ring width.
*
* `--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';
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';

// --- 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[] = [];

// outline: <width> solid <color> — width must be var(--focus-ring-width), or none/0
for (const m of stripped.matchAll(/(?<![-\w])outline:\s*([^;}\n]+)/gi)) {
const decl = m[0].trim();
const value = m[1].trim();

// outline: none / 0 / 0px — literal disable, OK
if (/^(?:none|0(?:px)?)\b/i.test(value)) 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)) {
offenders.push(`${label}: ${decl} (bare outline width — use var(--focus-ring-width))`);
}
}

// outline-offset: must be var(--focus-ring-offset). The search-highlight
// 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(/(?<![-\w])outline-offset:\s*([^;}\n]+)/gi)) {
const decl = m[0].trim();
const value = m[1].trim();
if (/^var\(--focus-ring-offset\)/i.test(value)) continue;
const selector = enclosingSelector(stripped, m.index!);
if (/\.maka-turn\[data-search-highlight="true"\]/.test(selector)) continue;
offenders.push(`${label}: ${decl} (bare outline-offset — use var(--focus-ring-offset))`);
}

// 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
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;
}

// === 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 / --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');
});
});

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)', () => {
assert.deepEqual(findFocusRingOffenders('outline: none', 'test'), []);
assert.deepEqual(findFocusRingOffenders('outline: 0', '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)', () => {
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: 4px', 'test').length > 0, 'bare 4px must fail');
});

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('.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 <px> 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', () => {
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'), []);
});
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,7 +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 } from './css-test-helpers.js';
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 () => {
Expand DownExpand Up@@ -116,12 +116,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 () => {
Expand DownExpand Up@@ -233,4 +236,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(/(?<![\w-])scale\(\s*(var\(--[\w-]+\)|[-\w.]+)\s*\)/g)) {
const value = m[1].trim();
if (value === '1') continue; // literal reset
if (/^var\(--scale-(press|hover)\)$/.test(value)) continue;
if (value === '1.1') continue; // decorative onboarding scale, whitelisted
offenders.push(`scale(${value})`);
}
for (const m of stripped.matchAll(/(?<![\w-])translateY\(\s*(var\(--[\w-]+\)|[-\w.]+)\s*\)/g)) {
const value = m[1].trim();
if (value === '0') continue; // literal reset
if (/^var\(--lift-hover\)$/.test(value)) continue;
if (value === '-3px') continue; // strong lift, whitelisted
offenders.push(`translateY(${value})`);
}
assert.deepEqual(offenders, [], `Bare transform amplitude must use var(--scale-press/hover) or var(--lift-hover). 1/-3px decorative/strong whitelisted, keyframes excluded:\n ${offenders.join('\n ')}`);
});
});
Loading