@ultimat3/cli 19.4.0 → 20.0.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.
- package/CLAUDE.md +56 -3
- package/package.json +29 -29
- package/src/cmd-shot-island.ts +120 -15
- package/src/cmd-shot.ts +45 -20
- package/src/island-capture.ts +278 -0
- package/src/island-harness-script.ts +10 -1
- package/src/island-shot-index.ts +155 -0
- package/src/island-shot.ts +106 -276
- package/src/island-verdict.ts +90 -1
- package/src/messages.ts +2 -1
- package/src/templates/guard-animated-layout-property.ts +269 -0
- package/src/templates/guard-focus-visible.ts +240 -0
- package/src/templates/guard-image-dimensions.ts +225 -0
- package/src/templates/guard-island-without-states.ts +128 -0
- package/src/templates/guard-raw-colour.ts +112 -8
- package/src/templates/guard-semantic-interactive.ts +244 -0
- package/src/templates/guard-untranslated-string.ts +54 -6
- package/src/templates/island.ts +44 -1
- package/src/templates/resource-form-island.ts +67 -0
- package/src/templates/scaffold-claude-agents.ts +10 -1
- package/src/templates/scaffold-docs.ts +11 -0
- package/src/templates/scaffold-guards.ts +15 -0
- package/src/templates/scaffold-repo.ts +12 -1
|
@@ -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
|
+
];
|