@ultimat3/cli 19.4.0 → 20.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -129,8 +129,15 @@ const staticAssets = (islands: IslandBundle, styles: StyleBundle): readonly Prec
129
129
  * silently controls a subdirectory. The `catch` because a registration that throws in a browser
130
130
  * with service workers disabled — an incognito profile, an enterprise policy — must not take an
131
131
  * otherwise working page down with it.
132
+ *
133
+ * EXPORTED because a static export has to put these bytes on disk BEFORE it renders, not after.
134
+ * The file is named by every document and weighed by `measureDocumentJs`, and it used to be
135
+ * written last, beside `sw.js` — so the measurement read a file that did not exist yet on a clean
136
+ * output directory, and the PREVIOUS build's copy on a reused one. `sw.js` still comes last, for
137
+ * the reason its own comment gives (its precache manifest is built from the rendered documents'
138
+ * content hashes); this half depends on nothing but two constants, so it can and must come first.
132
139
  */
133
- const registerSource = (): string =>
140
+ export const serviceWorkerRegistration = (): string =>
134
141
  `if ('serviceWorker' in navigator) {
135
142
  addEventListener('load', function () {
136
143
  navigator.serviceWorker
@@ -199,7 +206,7 @@ export function serviceWorkerArtifacts(
199
206
  );
200
207
  return {
201
208
  source: output.source,
202
- register: registerSource(),
209
+ register: serviceWorkerRegistration(),
203
210
  head,
204
211
  precache: output.precache,
205
212
  // `output.warnings` IS `output.precache.warnings` — the generator returns the manifest's list
@@ -0,0 +1,74 @@
1
+ // The CI half of what `x new` writes: one workflow that runs the app's OWN two commands.
2
+ //
3
+ // Nothing under `templates/` emitted `.github` until this file, so every scaffolded app started
4
+ // life with a gate that ran on exactly one machine — the author's. The two commands are `bin/setup`
5
+ // and `bin/check` and NOT a restatement of their steps: a workflow that spelled out `bun install`,
6
+ // `x db migrate`, `x build` and `x verify` is a second definition of the gate, free to drift from
7
+ // the scripts a human runs, and axiom 1 has one way to do each thing. `bin/check` is also not a
8
+ // synonym for `x verify` — it BUILDS first, and the build is what makes the gate's `budgets` step
9
+ // measurable rather than X_BUDGET_UNMEASURED.
10
+
11
+ import { REQUIRED_BUN } from '../../app-root';
12
+ import type { GeneratedFile, NameSet } from '../naming';
13
+
14
+ /** Where the workflow lands. GitHub reads this path and no other. */
15
+ export const CI_WORKFLOW_PATH = '.github/workflows/ci.yml';
16
+
17
+ /**
18
+ * Pinned by commit SHA with the version in the trailing comment, because this step runs before any
19
+ * code of yours does and `@v2` is a tag its owner can move. `actions/*` follows its major tag
20
+ * instead: GitHub owns the runner and the tag together. Same rule the framework's own
21
+ * `.github/actions/setup/action.yml` states, and the same SHA.
22
+ */
23
+ const SETUP_BUN = 'oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0';
24
+
25
+ const ci = (app: NameSet): string => `name: ci
26
+
27
+ # The gate for ${app.kebab}: the same two commands \`README.md\` tells a human to run, in the same
28
+ # order, on a machine that has never seen this repository. A check that exists only in CI is one
29
+ # nobody can reproduce locally, and a CI file that restates the gate's steps is a second gate.
30
+ #
31
+ # \`bin/check\` is \`x build --target static\` and THEN \`x verify\`. The build is not a convenience:
32
+ # the gate's \`budgets\` step compares declared limits against measured bytes in
33
+ # \`.x/build-stats.json\`, so a run with no build reports X_BUDGET_UNMEASURED. Run \`x verify\` here
34
+ # instead and this workflow is red for a reason that has nothing to do with the commit.
35
+ #
36
+ # No \`services:\` block, deliberately — \`bin/setup\` brings up embedded Postgres in-process, so
37
+ # there is nothing to provision and nothing to wait on. Add one the day this app needs a real
38
+ # server, beside the \`DATABASE_URL\` that selects it.
39
+
40
+ # EVERY push, not just the default branch's. \`x new\` runs a plain \`git init\` and takes whatever
41
+ # \`init.defaultBranch\` this machine already agreed on, so a \`branches: [main]\` filter silently
42
+ # runs nothing on a repository whose branch is called something else — and \`CLAUDE.md\` promises a
43
+ # run on every push, which a filter would make untrue for the app that read it.
44
+ on:
45
+ push:
46
+ pull_request:
47
+
48
+ permissions:
49
+ contents: read
50
+
51
+ jobs:
52
+ check:
53
+ runs-on: ubuntu-latest
54
+ # A bound on a HANG, not on cost: a step that never returns holds a runner until GitHub's
55
+ # six-hour default expires, and the failure is invisible for all six of them.
56
+ timeout-minutes: 20
57
+ steps:
58
+ - uses: actions/checkout@v7
59
+ - uses: ${SETUP_BUN}
60
+ with:
61
+ # \`package.json\`'s \`engines.bun\` floor, exactly — the OLDEST runtime this app declares
62
+ # it supports, so a call that needs a newer Bun fails here rather than in the install of
63
+ # whoever took the app at its word. Never \`latest\`: a Bun minor landing unannounced is a
64
+ # runtime change nobody chose.
65
+ bun-version: '${REQUIRED_BUN}'
66
+ # Two steps rather than one \`&&\`, so the log names which half failed and times each.
67
+ - run: bin/setup
68
+ - run: bin/check
69
+ `;
70
+
71
+ /** The CI a new app is born with, in the order a reader meets it. */
72
+ export function githubFiles(app: NameSet): readonly GeneratedFile[] {
73
+ return [{ path: CI_WORKFLOW_PATH, contents: ci(app) }];
74
+ }
@@ -0,0 +1,269 @@
1
+ // The `animated-layout-property` guard `x new` ships: only `transform` and `opacity` animate for
2
+ // free. Everything else — a `top`, a `width`, a `margin`, a `box-shadow` — costs the browser a
3
+ // layout or a paint on EVERY frame, on the same main thread the app's own JavaScript is on, and
4
+ // feeds the layout-shift metric while it does it. `transition: all` is worse than any single one of
5
+ // them: it animates properties nobody chose, including the ones that have not been written yet.
6
+
7
+ import { guardCode } from './guard';
8
+ import type { GeneratedFile } from './naming';
9
+
10
+ /**
11
+ * Derived from the guard's name, never written as a literal — the same rule `x g guard` follows.
12
+ * An `X_*` literal in framework source is a FRAMEWORK code: `error-catalog.test.ts` refuses one the
13
+ * registry does not hold, and `wiki/Error-Codes.md` would owe it a row. The APP owns the codes its
14
+ * own conventions raise, so this one is spelled by the file it lands in and nowhere else.
15
+ */
16
+ const NAME = 'animated-layout-property';
17
+ const CODE = guardCode(NAME);
18
+
19
+ const source =
20
+ (): string => `// animated-layout-property: an animation moves \`transform\` and \`opacity\`, never the layout.
21
+ // \`x verify\` discovers every file in \`guards/\` and runs its \`guard\` inside the \`boundaries\`
22
+ // step — nothing registers this file, so nothing can forget to. Delete it to drop the rule.
23
+
24
+ import type { Finding, Guard } from '@ultimat3/cli';
25
+
26
+ /** The app owns the codes its own conventions raise — this one is named for the guard. */
27
+ const CODE = '${CODE}';
28
+
29
+ /**
30
+ * What each layout property should have been, so the fix is an edit and not a category. A property
31
+ * with no obvious equivalent takes the default below rather than an invented one.
32
+ */
33
+ const INSTEAD = new Map([
34
+ ['top', 'transform: translateY(…)'],
35
+ ['bottom', 'transform: translateY(…)'],
36
+ ['left', 'transform: translateX(…)'],
37
+ ['right', 'transform: translateX(…)'],
38
+ ['inset', 'transform: translate(…)'],
39
+ ['width', 'transform: scaleX(…)'],
40
+ ['height', 'transform: scaleY(…)'],
41
+ ['box-shadow', 'opacity on a shadow layer that is already painted'],
42
+ ]);
43
+
44
+ /** A property whose change the browser cannot composite: it re-lays-out or re-paints the frame. */
45
+ const isLayoutProperty = (property: string): boolean =>
46
+ INSTEAD.has(property) ||
47
+ property.startsWith('margin') ||
48
+ property.startsWith('padding') ||
49
+ /^(?:min|max)-(?:width|height)$/.test(property);
50
+
51
+ /**
52
+ * A DECLARATION, never a whole line: a selector carries no colon, so \`.transition { … }\` is not a
53
+ * value and is never reported. The value stops at the first \`;\`, \`{\` or \`}\`.
54
+ */
55
+ const DECLARATION = /([\\w-]+)\\s*:\\s*([^;{}]+)/g;
56
+
57
+ /** A CSS identifier that could name a property. \`200ms\`, \`0.2s\` and \`cubic-bezier(…)\` cannot. */
58
+ const IDENTIFIER = /^-?[a-z][a-z-]*$/i;
59
+ /**
60
+ * Identifiers that appear in a \`transition\` and are never the property being animated —
61
+ * \`none\` included, which is what makes the \`transition: none\` branch below load-bearing rather
62
+ * than decorative: without \`none\` here it reads as a property name, and with it the declaration
63
+ * that turns every transition OFF would be reported as one that animates everything.
64
+ */
65
+ const TIMING = new Set([
66
+ 'ease',
67
+ 'ease-in',
68
+ 'ease-out',
69
+ 'ease-in-out',
70
+ 'linear',
71
+ 'none',
72
+ 'step-start',
73
+ 'step-end',
74
+ 'normal',
75
+ 'infinite',
76
+ 'alternate',
77
+ 'forwards',
78
+ 'backwards',
79
+ 'both',
80
+ ]);
81
+
82
+ export interface StyleFile {
83
+ /** App-root-relative POSIX path, so the finding names the file an author opens. */
84
+ readonly path: string;
85
+ readonly scss: string;
86
+ }
87
+
88
+ /**
89
+ * Comments blanked rather than removed, so the reported line number still points at the source
90
+ * line. \`//\` is skipped when a \`:\` precedes it — \`url(https://…)\` is a value, not a comment.
91
+ */
92
+ const blankComments = (scss: string): string =>
93
+ scss
94
+ .replaceAll(/\\/\\*[\\s\\S]*?\\*\\//g, (match) => match.replaceAll(/[^\\n]/g, ' '))
95
+ .replaceAll(/(?<![:\\w])\\/\\/[^\\n]*/g, (match) => ' '.repeat(match.length));
96
+
97
+ const lineOf = (text: string, index: number): number => text.slice(0, index).split('\\n').length;
98
+
99
+ /** Which \`@keyframes\` block an index sits in, so an animated property can be named with its rule. */
100
+ function keyframeRanges(scss: string): ReadonlyMap<string, readonly [number, number]> {
101
+ const ranges = new Map<string, readonly [number, number]>();
102
+ for (const match of scss.matchAll(/@keyframes\\s+([\\w-]+)[^{]*\\{/g)) {
103
+ const from = match.index + match[0].length;
104
+ let depth = 0;
105
+ let to = scss.length;
106
+ for (let i = from; i < scss.length; i += 1) {
107
+ const ch = scss[i];
108
+ if (ch === '{') depth += 1;
109
+ else if (ch === '}') {
110
+ if (depth === 0) {
111
+ to = i;
112
+ break;
113
+ }
114
+ depth -= 1;
115
+ }
116
+ }
117
+ ranges.set(match[1] ?? 'the animation', [from, to]);
118
+ }
119
+ return ranges;
120
+ }
121
+
122
+ /**
123
+ * The property each comma-separated part of a \`transition\` names, or \`undefined\` where the part
124
+ * names none — which is not "nothing to animate": an omitted property IS \`all\`, so
125
+ * \`transition: 200ms ease\` animates every property this element will ever have.
126
+ */
127
+ function transitionProperties(value: string): readonly (string | undefined)[] {
128
+ return value.split(',').map((part) => {
129
+ const tokens = part.trim().split(/\\s+/);
130
+ return tokens
131
+ .find((token) => IDENTIFIER.test(token) && !TIMING.has(token.toLowerCase()))
132
+ ?.toLowerCase();
133
+ });
134
+ }
135
+
136
+ /** Pure — the caller does the I/O — so the rule is testable without a filesystem. */
137
+ export function animatedLayoutProperties(files: readonly StyleFile[]): readonly Finding[] {
138
+ const findings: Finding[] = [];
139
+ for (const file of files) {
140
+ const scss = blankComments(file.scss);
141
+ const keyframes = keyframeRanges(scss);
142
+ for (const match of scss.matchAll(DECLARATION)) {
143
+ const property = (match[1] ?? '').toLowerCase();
144
+ const value = (match[2] ?? '').trim();
145
+ const at = \`\${file.path}:\${lineOf(scss, match.index)}\`;
146
+ const animation = [...keyframes].find(
147
+ ([, [from, to]]) => match.index > from && match.index < to,
148
+ );
149
+ if (animation !== undefined) {
150
+ if (!isLayoutProperty(property)) continue;
151
+ findings.push(layoutFinding(file.path, at, property, \`@keyframes \${animation[0]}\`));
152
+ continue;
153
+ }
154
+ if (property !== 'transition' && property !== 'transition-property') continue;
155
+ // \`transition: none\` turns transitions OFF. It is the one value that names no property and
156
+ // animates nothing, so reading it as the implicit \`all\` below would report the opposite.
157
+ if (value.toLowerCase() === 'none') continue;
158
+ for (const animated of transitionProperties(value)) {
159
+ if (animated === undefined || animated === 'all') {
160
+ findings.push({
161
+ code: CODE,
162
+ cause: \`\${at} transitions every property of this element — \${animated === undefined ? \`\\\`\${property}: \${value}\\\` names none, and an omitted property is \\\`all\\\`\` : 'written out as \`all\`'} — so it animates properties nobody chose, the ones that cost a layout pass included, and no part of it can be composited\`,
163
+ fix: \`name the properties in \${at} — \\\`transition: transform tokens.duration('fast') tokens.easing('out')\\\` — then: x verify\`,
164
+ at: file.path,
165
+ });
166
+ continue;
167
+ }
168
+ if (!isLayoutProperty(animated)) continue;
169
+ findings.push(layoutFinding(file.path, at, animated, 'this transition'));
170
+ }
171
+ }
172
+ }
173
+ return findings;
174
+ }
175
+
176
+ function layoutFinding(path: string, at: string, property: string, where: string): Finding {
177
+ const instead = INSTEAD.get(property) ?? 'transform or opacity, which the compositor owns';
178
+ return {
179
+ code: CODE,
180
+ cause: \`\${at} animates \${property} in \${where} — only transform and opacity are compositor-only, so every frame of this costs a full layout or paint pass on the same main thread the app runs on, and a moving box feeds the layout-shift metric while it does it\`,
181
+ fix: \`animate \${instead} instead of \${property} at \${at}, then: x verify\`,
182
+ at: path,
183
+ };
184
+ }
185
+
186
+ export const guard: Guard = {
187
+ summary: 'an animation moves transform and opacity, never the layout',
188
+ async check(root) {
189
+ const files: StyleFile[] = [];
190
+ for await (const entry of new Bun.Glob('{apps,packages}/**/*.scss').scan({
191
+ cwd: root,
192
+ absolute: false,
193
+ })) {
194
+ const path = entry.split('\\\\').join('/');
195
+ if (path.includes('node_modules/')) continue;
196
+ files.push({ path, scss: await Bun.file(\`\${root}/\${path}\`).text() });
197
+ }
198
+ return animatedLayoutProperties(files);
199
+ },
200
+ };
201
+ `;
202
+
203
+ const test =
204
+ (): string => `// The rule, driven directly. Failure case first: a guard whose rule silently stopped matching is
205
+ // a green gate over the convention it was written to enforce.
206
+
207
+ import { expect, unitTest } from '@ultimat3/testing';
208
+ import { animatedLayoutProperties } from './animated-layout-property';
209
+
210
+ const sheet = (scss: string) => [{ path: 'apps/web/app/post/ui.module.scss', scss }];
211
+
212
+ unitTest('transitioning a layout property is refused, and the fix names the equivalent', () => {
213
+ const findings = animatedLayoutProperties(sheet('.panel {\\n transition: left 200ms ease;\\n}'));
214
+ expect(findings).toHaveLength(1);
215
+ expect(findings[0]?.code).toBe('${CODE}');
216
+ expect(findings[0]?.cause).toContain(':2');
217
+ expect(findings[0]?.fix).toContain('translateX');
218
+ });
219
+
220
+ unitTest('transition: all is refused outright', () => {
221
+ const findings = animatedLayoutProperties(sheet('.panel { transition: all 200ms ease; }'));
222
+ expect(findings).toHaveLength(1);
223
+ expect(findings[0]?.cause).toContain('every property');
224
+ });
225
+
226
+ // An omitted property IS \`all\` — the same defect, one word shorter, and the one an author does
227
+ // not read as a choice.
228
+ unitTest('a transition naming no property at all is the same rule', () => {
229
+ const findings = animatedLayoutProperties(sheet('.panel { transition: 200ms ease; }'));
230
+ expect(findings).toHaveLength(1);
231
+ expect(findings[0]?.cause).toContain('an omitted property');
232
+ });
233
+
234
+ unitTest('a layout property inside @keyframes is animated too', () => {
235
+ const scss = '@keyframes slide {\\n from { margin-left: 0; }\\n to { margin-left: 40px; }\\n}';
236
+ const findings = animatedLayoutProperties(sheet(scss));
237
+ expect(findings).toHaveLength(2);
238
+ expect(findings[0]?.cause).toContain('@keyframes slide');
239
+ });
240
+
241
+ unitTest('transform and opacity are what this rule exists to leave alone', () => {
242
+ const scss = '.panel { transition: transform tokens.duration("fast"), opacity 120ms linear; }';
243
+ expect(animatedLayoutProperties(sheet(scss))).toEqual([]);
244
+ const frames = '@keyframes fade { from { opacity: 0; } to { opacity: 1; transform: none; } }';
245
+ expect(animatedLayoutProperties(sheet(frames))).toEqual([]);
246
+ });
247
+
248
+ // The one value that names no property and still animates nothing. Read as the implicit \`all\`
249
+ // above, it would report the declaration that turns the whole thing off.
250
+ unitTest('transition: none turns transitions off and is not one', () => {
251
+ expect(animatedLayoutProperties(sheet('.panel { transition: none; }'))).toEqual([]);
252
+ });
253
+
254
+ unitTest('a plain layout declaration outside an animation is just layout', () => {
255
+ expect(animatedLayoutProperties(sheet('.panel { margin-left: 40px; width: 100%; }'))).toEqual([]);
256
+ });
257
+
258
+ unitTest('a commented-out transition is a note, not a declaration', () => {
259
+ expect(
260
+ animatedLayoutProperties(sheet('// transition: all 200ms;\\n.panel { padding: 0; }')),
261
+ ).toEqual([]);
262
+ });
263
+ `;
264
+
265
+ /** `guards/animated-layout-property.ts` and its test. The directory is the registration. */
266
+ export const animatedLayoutPropertyGuardFiles = (): readonly GeneratedFile[] => [
267
+ { path: 'guards/animated-layout-property.ts', contents: source() },
268
+ { path: 'guards/animated-layout-property.test.ts', contents: test() },
269
+ ];
@@ -0,0 +1,240 @@
1
+ // The `focus-visible` guard `x new` ships: a stylesheet never takes the focus ring away and leaves
2
+ // nothing in its place. `outline: none` is the single most-copied line on the web and it deletes
3
+ // the only thing telling a keyboard user where they are — WCAG 2.2 SC 2.4.11 and 2.4.13 ask for an
4
+ // indicator at least 2px around the control at 3:1 against what is behind it. Nothing in the gate
5
+ // could see it: `x verify`'s `seo` and `i18n` steps read pages, and lint reads TypeScript.
6
+
7
+ import { guardCode } from './guard';
8
+ import type { GeneratedFile } from './naming';
9
+
10
+ /**
11
+ * Derived from the guard's name, never written as a literal — the same rule `x g guard` follows.
12
+ * An `X_*` literal in framework source is a FRAMEWORK code: `error-catalog.test.ts` refuses one the
13
+ * registry does not hold, and `wiki/Error-Codes.md` would owe it a row. The APP owns the codes its
14
+ * own conventions raise, so this one is spelled by the file it lands in and nowhere else.
15
+ */
16
+ const NAME = 'focus-visible';
17
+ const CODE = guardCode(NAME);
18
+
19
+ const source = (): string => `// focus-visible: the focus ring is replaced, never only removed.
20
+ // \`x verify\` discovers every file in \`guards/\` and runs its \`guard\` inside the \`boundaries\`
21
+ // step — nothing registers this file, so nothing can forget to. Delete it to drop the rule.
22
+
23
+ import type { Finding, Guard } from '@ultimat3/cli';
24
+
25
+ /** The app owns the codes its own conventions raise — this one is named for the guard. */
26
+ const CODE = '${CODE}';
27
+
28
+ /**
29
+ * A DECLARATION, never a whole line: a selector carries no colon, so \`.outline { … }\` is not a
30
+ * value and is never reported. The value stops at the first \`;\`, \`{\` or \`}\`.
31
+ */
32
+ const DECLARATION = /([\\w-]+)\\s*:\\s*([^;{}]+)/g;
33
+
34
+ /** The three spellings that take the ring away. \`outline-offset\` moves it and is not one. */
35
+ const REMOVES = new Set(['outline', 'outline-style', 'outline-width']);
36
+ const NOTHING = /^(?:none|0(?:px|em|rem)?)$/i;
37
+
38
+ /**
39
+ * What counts as painting one back, and its shape is load-bearing twice over. The lookahead reads
40
+ * the WHOLE value, not its first token: anchored on a prefix, \`box-shadow: 0 0 0 2px …\` — the
41
+ * canonical focus ring — read as the removal spelled again. And it sits directly after the \`:\`
42
+ * rather than after the space that follows it, because a \`\\s*\` OUTSIDE a negative lookahead
43
+ * backtracks to zero width and hands the lookahead a space to fail against: measured,
44
+ * \`outline: none\` then counted as an indicator and every removal in the tree read as replaced.
45
+ */
46
+ const INDICATOR =
47
+ /(?<![\\w-])(?:box-shadow|outline)\\s*:(?!\\s*(?:none|0(?:px|em|rem)?)\\s*[;}])\\s*[^;{}]+/;
48
+ const FOCUS_VISIBLE = /:focus-visible\\b/;
49
+ /** \`@include tokens.focus-ring\` emits the whole rule, and no text scan can see inside a mixin. */
50
+ const FOCUS_MIXIN = /@include\\s+[\\w.-]*focus[\\w-]*/i;
51
+ /**
52
+ * \`&:focus:not(:focus-visible) { outline: none }\` is the CORRECT idiom, not the defect: it removes
53
+ * the ring for a mouse press and leaves the keyboard one alone. Reported, it would teach an author
54
+ * to switch this guard off — which is how a rule stops existing.
55
+ */
56
+ const MOUSE_ONLY = /:not\\(\\s*:focus-visible\\s*\\)/;
57
+
58
+ /**
59
+ * How far past a rule a replacement still counts as beside it. A sibling \`:focus-visible\` rule is
60
+ * the second of the two shapes authors write; further than this it is a different component, and a
61
+ * rule that searched the whole file would be satisfied by one focus style anywhere in it.
62
+ */
63
+ const ADJACENT_CHARS = 600;
64
+
65
+ export interface StyleFile {
66
+ /** App-root-relative POSIX path, so the finding names the file an author opens. */
67
+ readonly path: string;
68
+ readonly scss: string;
69
+ }
70
+
71
+ /**
72
+ * Comments blanked rather than removed, so the reported line number still points at the source
73
+ * line. \`//\` is skipped when a \`:\` precedes it — \`url(https://…)\` is a value, not a comment.
74
+ */
75
+ const blankComments = (scss: string): string =>
76
+ scss
77
+ .replaceAll(/\\/\\*[\\s\\S]*?\\*\\//g, (match) => match.replaceAll(/[^\\n]/g, ' '))
78
+ .replaceAll(/(?<![:\\w])\\/\\/[^\\n]*/g, (match) => ' '.repeat(match.length));
79
+
80
+ const lineOf = (text: string, index: number): number => text.slice(0, index).split('\\n').length;
81
+
82
+ /**
83
+ * The innermost \`{ … }\` the index sits inside — back to the nearest unmatched \`{\`, forward to the
84
+ * \`}\` that closes it. Nested rules are INSIDE the answer, which is what makes the common Sass
85
+ * shape (\`.btn { outline: none; &:focus-visible { box-shadow: … } }\`) read as one scope.
86
+ */
87
+ function blockAround(
88
+ text: string,
89
+ index: number,
90
+ ): { readonly start: number; readonly end: number } {
91
+ let depth = 0;
92
+ let start = 0;
93
+ for (let i = index; i >= 0; i -= 1) {
94
+ const ch = text[i];
95
+ if (ch === '}') depth += 1;
96
+ else if (ch === '{') {
97
+ if (depth === 0) {
98
+ start = i;
99
+ break;
100
+ }
101
+ depth -= 1;
102
+ }
103
+ }
104
+ depth = 0;
105
+ let end = text.length;
106
+ for (let i = start + 1; i < text.length; i += 1) {
107
+ const ch = text[i];
108
+ if (ch === '{') depth += 1;
109
+ else if (ch === '}') {
110
+ if (depth === 0) {
111
+ end = i;
112
+ break;
113
+ }
114
+ depth -= 1;
115
+ }
116
+ }
117
+ return { start, end };
118
+ }
119
+
120
+ /** The selector this block belongs to: back to whatever ended the statement before it. */
121
+ function selectorBefore(text: string, start: number): string {
122
+ let from = 0;
123
+ for (let i = start - 1; i >= 0; i -= 1) {
124
+ const ch = text[i];
125
+ if (ch === '{' || ch === '}' || ch === ';') {
126
+ from = i + 1;
127
+ break;
128
+ }
129
+ }
130
+ return text.slice(from, start);
131
+ }
132
+
133
+ /** Pure — the caller does the I/O — so the rule is testable without a filesystem. */
134
+ export function unreplacedFocusRings(files: readonly StyleFile[]): readonly Finding[] {
135
+ const findings: Finding[] = [];
136
+ for (const file of files) {
137
+ const scss = blankComments(file.scss);
138
+ for (const match of scss.matchAll(DECLARATION)) {
139
+ const property = (match[1] ?? '').toLowerCase();
140
+ const value = (match[2] ?? '').trim();
141
+ if (!REMOVES.has(property) || !NOTHING.test(value)) continue;
142
+ const block = blockAround(scss, match.index);
143
+ const selector = selectorBefore(scss, block.start);
144
+ if (MOUSE_ONLY.test(selector)) continue;
145
+ // The rule itself, its own selector, and what sits directly after it — the two shapes an
146
+ // author writes a replacement in, and nothing wider, so a finding never has to be argued with.
147
+ const scope =
148
+ selector + scss.slice(block.start, Math.min(block.end + ADJACENT_CHARS, scss.length));
149
+ if (FOCUS_MIXIN.test(scope)) continue;
150
+ if (FOCUS_VISIBLE.test(scope) && INDICATOR.test(scope)) continue;
151
+ findings.push({
152
+ code: CODE,
153
+ cause: \`\${file.path}:\${lineOf(scss, match.index)} removes the focus ring with \${property}: \${value} and nothing in the rule or beside it paints one back — a keyboard user loses every trace of where they are, and WCAG 2.2 asks for an indicator at least 2px around the control at 3:1 against what is behind it\`,
154
+ fix: \`add \\\`@include tokens.focus-ring;\\\` to the rule at \${file.path}:\${lineOf(scss, match.index)}, or a sibling \\\`:focus-visible\\\` rule with a box-shadow — which follows the border radius where an outline does not — then: x verify\`,
155
+ at: file.path,
156
+ });
157
+ }
158
+ }
159
+ return findings;
160
+ }
161
+
162
+ export const guard: Guard = {
163
+ summary: 'a stylesheet replaces the focus ring, never only removes it',
164
+ async check(root) {
165
+ const files: StyleFile[] = [];
166
+ for await (const entry of new Bun.Glob('{apps,packages}/**/*.scss').scan({
167
+ cwd: root,
168
+ absolute: false,
169
+ })) {
170
+ const path = entry.split('\\\\').join('/');
171
+ if (path.includes('node_modules/')) continue;
172
+ files.push({ path, scss: await Bun.file(\`\${root}/\${path}\`).text() });
173
+ }
174
+ return unreplacedFocusRings(files);
175
+ },
176
+ };
177
+ `;
178
+
179
+ const test =
180
+ (): string => `// The rule, driven directly. Failure case first: a guard whose rule silently stopped matching is
181
+ // a green gate over the convention it was written to enforce.
182
+
183
+ import { expect, unitTest } from '@ultimat3/testing';
184
+ import { unreplacedFocusRings } from './focus-visible';
185
+
186
+ const sheet = (scss: string) => [{ path: 'apps/web/app/post/ui.module.scss', scss }];
187
+
188
+ unitTest('outline: none with nothing in its place is refused', () => {
189
+ const findings = unreplacedFocusRings(sheet('.trigger {\\n outline: none;\\n}\\n'));
190
+ expect(findings).toHaveLength(1);
191
+ expect(findings[0]?.code).toBe('${CODE}');
192
+ expect(findings[0]?.cause).toContain(':2');
193
+ expect(findings[0]?.fix).toContain('focus-ring');
194
+ });
195
+
196
+ unitTest('outline: 0 is the same removal spelled differently', () => {
197
+ expect(unreplacedFocusRings(sheet('.trigger { outline: 0; }'))).toHaveLength(1);
198
+ expect(unreplacedFocusRings(sheet('.trigger { outline-style: none; }'))).toHaveLength(1);
199
+ });
200
+
201
+ unitTest('a nested :focus-visible that paints one back satisfies it', () => {
202
+ const scss =
203
+ '.trigger {\\n outline: none;\\n &:focus-visible { box-shadow: 0 0 0 2px tokens.role("accent"); }\\n}';
204
+ expect(unreplacedFocusRings(sheet(scss))).toEqual([]);
205
+ });
206
+
207
+ unitTest('a sibling :focus-visible rule beside it counts too', () => {
208
+ const scss =
209
+ '.trigger { outline: none; }\\n.trigger:focus-visible { outline: 2px solid tokens.role("accent"); }';
210
+ expect(unreplacedFocusRings(sheet(scss))).toEqual([]);
211
+ });
212
+
213
+ // The idiom this rule must never report: removing the ring for a MOUSE press and leaving the
214
+ // keyboard one alone is the correct thing to write, and reporting it teaches an author to delete
215
+ // the guard.
216
+ unitTest(':focus:not(:focus-visible) is the correct removal, not the defect', () => {
217
+ expect(
218
+ unreplacedFocusRings(sheet('.trigger:focus:not(:focus-visible) { outline: none; }')),
219
+ ).toEqual([]);
220
+ });
221
+
222
+ unitTest('a focus mixin emits the rule no text scan can read into', () => {
223
+ const scss = '.trigger {\\n @include tokens.focus-ring;\\n outline: none;\\n}';
224
+ expect(unreplacedFocusRings(sheet(scss))).toEqual([]);
225
+ });
226
+
227
+ unitTest('outline-offset moves the ring and does not remove it', () => {
228
+ expect(unreplacedFocusRings(sheet('.trigger { outline-offset: 0; }'))).toEqual([]);
229
+ });
230
+
231
+ unitTest('a commented-out removal is a note, not a declaration', () => {
232
+ expect(unreplacedFocusRings(sheet('// outline: none;\\n.trigger { padding: 0; }'))).toEqual([]);
233
+ });
234
+ `;
235
+
236
+ /** `guards/focus-visible.ts` and its test. The directory is the registration. */
237
+ export const focusVisibleGuardFiles = (): readonly GeneratedFile[] => [
238
+ { path: 'guards/focus-visible.ts', contents: source() },
239
+ { path: 'guards/focus-visible.test.ts', contents: test() },
240
+ ];