@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.
- package/CLAUDE.md +56 -3
- package/package.json +29 -29
- package/src/budgets.ts +47 -2
- package/src/cmd-doctor.ts +97 -3
- package/src/cmd-shot-island.ts +120 -15
- package/src/cmd-shot.ts +45 -20
- package/src/index.ts +8 -2
- 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/prerender.ts +16 -1
- package/src/sw-artifacts.ts +9 -2
- package/src/templates/github/ci.yml.ts +74 -0
- 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-claude-commands.ts +20 -9
- package/src/templates/scaffold-docs.ts +40 -5
- package/src/templates/scaffold-guards.ts +15 -0
- package/src/templates/scaffold-repo.ts +22 -2
package/src/sw-artifacts.ts
CHANGED
|
@@ -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
|
|
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:
|
|
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
|
+
];
|