@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.
@@ -0,0 +1,244 @@
1
+ // The `semantic-interactive` guard `x new` ships: an element that answers a click IS a control.
2
+ // The cheapest a11y win there is, and the one an agent undoes by reflex — WebAIM's annual survey of
3
+ // a million home pages finds pages using ARIA average ~41% MORE detected errors than pages using
4
+ // none, because `role="button"` is a PROMISE (Space, Enter, focus, disabled) and only the native
5
+ // element keeps it. Nothing in the gate could see a `<div onClick>` before this file existed.
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 = 'semantic-interactive';
17
+ const CODE = guardCode(NAME);
18
+
19
+ const source =
20
+ (): string => `// semantic-interactive: a click is answered by a control, and a role is a promise.
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
+ * Elements with no behaviour of their own: no tab stop, no Enter, no Space, no disabled state.
31
+ * A handler on one of these reaches a mouse and nothing else.
32
+ */
33
+ const INERT = new Set([
34
+ 'div',
35
+ 'span',
36
+ 'li',
37
+ 'p',
38
+ 'section',
39
+ 'article',
40
+ 'aside',
41
+ 'header',
42
+ 'footer',
43
+ 'main',
44
+ 'nav',
45
+ 'ul',
46
+ 'ol',
47
+ 'td',
48
+ 'tr',
49
+ 'h1',
50
+ 'h2',
51
+ 'h3',
52
+ 'h4',
53
+ 'h5',
54
+ 'h6',
55
+ ]);
56
+
57
+ /** A role whose native element already exists, and the element to write instead of the role. */
58
+ const NATIVE = new Map([
59
+ ['button', { tag: 'button', write: '<button type="button">' }],
60
+ ['link', { tag: 'a', write: '<a href="…">' }],
61
+ ['checkbox', { tag: 'input', write: '<input type="checkbox">' }],
62
+ ]);
63
+
64
+ /** \`(?<![\\w-])\` on every one: \`data-role\` is not \`role\`, and \`maxWidth\` is not \`width\`. */
65
+ const HANDLER = /(?<![\\w-])(on(?:Click|MouseDown|KeyDown))\\s*=/;
66
+ const ROLE = /(?<![\\w-])role\\s*=\\s*["']([a-z]+)["']/;
67
+ const TABINDEX = /(?<![\\w-])tab[Ii]ndex\\s*=/;
68
+ const KEYS = /(?<![\\w-])onKey(?:Down|Up|Press)\\s*=/;
69
+
70
+ export interface SourceFile {
71
+ /** App-root-relative POSIX path, so the finding names the file an author opens. */
72
+ readonly path: string;
73
+ readonly source: string;
74
+ }
75
+
76
+ interface Tag {
77
+ readonly name: string;
78
+ readonly attrs: string;
79
+ readonly index: number;
80
+ }
81
+
82
+ /** Comments blanked IN PLACE — not deleted — so a reported line number still points at the source. */
83
+ const blank = (text: string): string =>
84
+ text
85
+ .replaceAll(/\\/\\*[\\s\\S]*?\\*\\//g, (match) => match.replaceAll(/[^\\n]/g, ' '))
86
+ .replaceAll(/(?<![:\\w])\\/\\/[^\\n]*/g, (match) => ' '.repeat(match.length));
87
+
88
+ const lineOf = (text: string, index: number): number => text.slice(0, index).split('\\n').length;
89
+
90
+ const NAME_AT = /^([A-Za-z][\\w.-]*)/;
91
+
92
+ /**
93
+ * Every opening tag, with its attribute text. The tag ends at the first \`>\` OUTSIDE braces and
94
+ * quotes, which is the whole reason this is a scanner and not a regex: \`onClick={() => save()}\`
95
+ * holds a \`>\` that closes nothing, so a pattern reading to the next \`>\` cuts the element in half
96
+ * and misses every handler written as an arrow — the way all of them are written.
97
+ */
98
+ function openingTags(text: string): readonly Tag[] {
99
+ const tags: Tag[] = [];
100
+ for (let i = 0; i < text.length; i += 1) {
101
+ if (text[i] !== '<') continue;
102
+ const name = NAME_AT.exec(text.slice(i + 1, i + 64))?.[1];
103
+ if (name === undefined) continue;
104
+ const from = i + 1 + name.length;
105
+ let depth = 0;
106
+ let quote = '';
107
+ let end = from;
108
+ for (; end < text.length; end += 1) {
109
+ const ch = text[end];
110
+ if (quote !== '') {
111
+ if (ch === quote) quote = '';
112
+ continue;
113
+ }
114
+ if (ch === '"' || ch === "'" || ch === '\`') quote = ch;
115
+ else if (ch === '{') depth += 1;
116
+ else if (ch === '}') depth -= 1;
117
+ else if (depth === 0 && (ch === '>' || ch === '<')) break;
118
+ }
119
+ tags.push({ name, attrs: text.slice(from, end), index: i });
120
+ // Continue from just after the NAME, never after the tag: a nested element inside a brace
121
+ // expression — \`{items.map((i) => <li onClick={…}>…)}\` — is a tag this rule has to see.
122
+ i = from - 1;
123
+ }
124
+ return tags;
125
+ }
126
+
127
+ /** Pure — the caller does the I/O — so the rule is testable without a filesystem. */
128
+ export function semanticInteractive(files: readonly SourceFile[]): readonly Finding[] {
129
+ const findings: Finding[] = [];
130
+ for (const file of files) {
131
+ const text = blank(file.source);
132
+ for (const tag of openingTags(text)) {
133
+ // A role, a tab stop and a key handler together are the COMPLETE set the ARIA practices
134
+ // guide asks for — a deliberate widget, written by someone who read the obligation. This
135
+ // rule is about the incomplete ones, so all three present is the one shape it passes over.
136
+ if (ROLE.test(tag.attrs) && TABINDEX.test(tag.attrs) && KEYS.test(tag.attrs)) continue;
137
+ const at = \`\${file.path}:\${lineOf(text, tag.index)}\`;
138
+ const role = ROLE.exec(tag.attrs)?.[1] ?? '';
139
+ const native = NATIVE.get(role);
140
+ // One finding per tag, role first: \`<div role="button" onClick>\` is one mistake with two
141
+ // symptoms, and two findings would make the reader fix it twice.
142
+ if (native !== undefined && tag.name !== native.tag) {
143
+ findings.push({
144
+ code: CODE,
145
+ cause: \`\${at} puts role="\${role}" on <\${tag.name}> — a role is a promise, and this one obliges the element to answer Space, Enter, focus and disabled, which the native element already does\`,
146
+ fix: \`replace <\${tag.name} role="\${role}"> in \${file.path} with \${native.write}, then: x verify\`,
147
+ at: file.path,
148
+ });
149
+ continue;
150
+ }
151
+ const handler = INERT.has(tag.name) ? HANDLER.exec(tag.attrs)?.[1] : undefined;
152
+ if (handler === undefined) continue;
153
+ findings.push({
154
+ code: CODE,
155
+ cause: \`\${at} hangs \${handler} on <\${tag.name}>, which takes no focus and answers no key — a keyboard, a screen reader and a switch reach a mouse handler on an inert element in exactly one way, which is not at all\`,
156
+ fix: \`write <button type="button"> — or the native control this element means — in place of <\${tag.name}> at \${at}, then: x verify\`,
157
+ at: file.path,
158
+ });
159
+ }
160
+ }
161
+ return findings;
162
+ }
163
+
164
+ export const guard: Guard = {
165
+ summary: 'a click is answered by a control, never by a div with a handler',
166
+ async check(root) {
167
+ const files: SourceFile[] = [];
168
+ // TWO globs, and the rule is that a brace ALTERNATIVE may not contain a \`/\`. Measured on Bun
169
+ // 1.4.0 against \`examples/dummy\`: \`{apps/*/{site,app},packages/*/src}/**/*.tsx\` and
170
+ // \`{apps/web,packages/ui}/**/*.tsx\` each match ZERO files, where \`apps/*/{site,app}/**/*.tsx\`
171
+ // matches 17 — so folding these into one line silently turns the guard off, which is worse than
172
+ // the hole it closes. A LEADING group is fine and four guards here rely on it:
173
+ // \`{apps,packages}/**/*.scss\` matches all 15.
174
+ for (const pattern of ['apps/*/{site,app}/**/*.tsx', 'packages/*/src/**/*.tsx']) {
175
+ for await (const entry of new Bun.Glob(pattern).scan({ cwd: root, absolute: false })) {
176
+ const path = entry.split('\\\\').join('/');
177
+ if (path.includes('node_modules/') || /\\.test\\.tsx?$/.test(path)) continue;
178
+ files.push({ path, source: await Bun.file(\`\${root}/\${path}\`).text() });
179
+ }
180
+ }
181
+ return semanticInteractive(files);
182
+ },
183
+ };
184
+ `;
185
+
186
+ const test =
187
+ (): string => `// The rule, driven directly. Failure case first: a guard whose rule silently stopped matching is
188
+ // a green gate over the convention it was written to enforce.
189
+
190
+ import { expect, unitTest } from '@ultimat3/testing';
191
+ import { semanticInteractive } from './semantic-interactive';
192
+
193
+ const file = (source: string) => [{ path: 'apps/web/app/post/page.tsx', source }];
194
+
195
+ unitTest('a div with a click handler is refused, and the finding names the line', () => {
196
+ const findings = semanticInteractive(
197
+ file('<main>\\n <div onClick={() => save()}>Save</div>\\n</main>'),
198
+ );
199
+ expect(findings).toHaveLength(1);
200
+ expect(findings[0]?.code).toBe('${CODE}');
201
+ expect(findings[0]?.cause).toContain(':2');
202
+ expect(findings[0]?.fix).toContain('<button type="button">');
203
+ });
204
+
205
+ unitTest('role="button" on a div names the native element to write instead', () => {
206
+ const findings = semanticInteractive(file('<div role="button" onClick={go}>Go</div>'));
207
+ expect(findings).toHaveLength(1);
208
+ expect(findings[0]?.cause).toContain('a role is a promise');
209
+ expect(findings[0]?.fix).toContain('<button type="button">');
210
+ });
211
+
212
+ unitTest('the native control it names is not itself a finding', () => {
213
+ expect(semanticInteractive(file('<button type="button" onClick={go}>Go</button>'))).toEqual([]);
214
+ expect(semanticInteractive(file('<a href="/x" onClick={go}>Go</a>'))).toEqual([]);
215
+ });
216
+
217
+ // The boundary, stated: a role, a tab stop and a key handler together are the complete set the
218
+ // ARIA practices guide asks for. This rule reports the INCOMPLETE widget, never the deliberate one.
219
+ unitTest('a role with a tab stop and a key handler is a deliberate widget', () => {
220
+ const widget = '<div role="button" tabindex="0" onClick={go} onKeyDown={go}>Go</div>';
221
+ expect(semanticInteractive(file(widget))).toEqual([]);
222
+ });
223
+
224
+ unitTest('data-role is not role, and a live region is not a control', () => {
225
+ expect(semanticInteractive(file('<p data-role="status" role="status">Saved</p>'))).toEqual([]);
226
+ });
227
+
228
+ // The reason the tag end is scanned rather than matched: an arrow function's \`>\` closes nothing,
229
+ // and a pattern that stopped at it would read the element as ending before its own handler.
230
+ unitTest('an arrow function in an earlier attribute does not end the tag', () => {
231
+ const source = '<div class={cx(() => a > b)} onClick={go}>Go</div>';
232
+ expect(semanticInteractive(file(source))).toHaveLength(1);
233
+ });
234
+
235
+ unitTest('a commented-out handler is a note, not an element', () => {
236
+ expect(semanticInteractive(file('// <div onClick={go}>Go</div>\\nconst a = 1;'))).toEqual([]);
237
+ });
238
+ `;
239
+
240
+ /** `guards/semantic-interactive.ts` and its test. The directory is the registration. */
241
+ export const semanticInteractiveGuardFiles = (): readonly GeneratedFile[] => [
242
+ { path: 'guards/semantic-interactive.ts', contents: source() },
243
+ { path: 'guards/semantic-interactive.test.ts', contents: test() },
244
+ ];
@@ -36,8 +36,6 @@ const CODE = '${CODE}';
36
36
  * rule wants: a parent whose children are elements has no text of its own.
37
37
  */
38
38
  const ELEMENT = /<([A-Za-z][\\w.:-]*)(?:\\s[^<>]*)?>([^<>]*?)<\\/\\1>/g;
39
- /** A \`{…}\` child is an expression — \`{t('key')}\`, \`{props.row.title}\` — never typed prose. */
40
- const EXPRESSION = /\\{[^{}]*\\}/g;
41
39
  /** Two word characters in a row. One is \`&\`, \`×\`, an initial — never a sentence. */
42
40
  const PROSE = /[\\p{L}\\p{N}]{2,}/u;
43
41
 
@@ -55,13 +53,43 @@ const blank = (text: string): string =>
55
53
 
56
54
  const lineOf = (text: string, index: number): number => text.slice(0, index).split('\\n').length;
57
55
 
56
+ /**
57
+ * Every \`{…}\` child removed — \`{t('key')}\`, \`{props.row.title}\` — leaving only what was TYPED
58
+ * between the tags.
59
+ *
60
+ * A depth scan and never a regex, because a JSX expression NESTS and a regex does not:
61
+ * \`/\\{[^{}]*\\}/g\` strips the INNER group of
62
+ * \`{t('app.feed.heading', { org: actor.org.name })}\` first, leaves the unbalanced remnant
63
+ * \`{t('app.feed.heading', )}\`, and then reads that remnant as prose — so every \`t()\` call with an
64
+ * interpolation object or a template-literal key was reported as an untranslated string, which is
65
+ * the exact opposite of the rule. Measured at 12 findings, all false, before this scan replaced it.
66
+ *
67
+ * A closing brace with nothing open is kept: it is a stray character, and prose it is not.
68
+ */
69
+ const withoutExpressions = (children: string): string => {
70
+ let out = '';
71
+ let depth = 0;
72
+ for (const character of children) {
73
+ if (character === '{') {
74
+ depth += 1;
75
+ continue;
76
+ }
77
+ if (character === '}' && depth > 0) {
78
+ depth -= 1;
79
+ continue;
80
+ }
81
+ if (depth === 0) out += character;
82
+ }
83
+ return out;
84
+ };
85
+
58
86
  /** Pure — the caller does the I/O — so the rule is testable without a filesystem. */
59
87
  export function untranslatedStrings(files: readonly SourceFile[]): readonly Finding[] {
60
88
  const findings: Finding[] = [];
61
89
  for (const file of files) {
62
90
  const text = blank(file.source);
63
91
  for (const match of text.matchAll(ELEMENT)) {
64
- const typed = (match[2] ?? '').replaceAll(EXPRESSION, ' ').trim();
92
+ const typed = withoutExpressions(match[2] ?? '').trim();
65
93
  if (!PROSE.test(typed)) continue;
66
94
  findings.push({
67
95
  code: CODE,
@@ -83,9 +111,12 @@ export const guard: Guard = {
83
111
  // a hardcoded string there used to be green. \`api/\` renders nothing and \`shared/\` is a leaf of
84
112
  // helpers; \`packages/*/dist\` is a build output, not source.
85
113
  //
86
- // TWO globs, never one with a leading \`{a,b}\` group: \`Bun.Glob.scan()\` matches nothing at all
87
- // for a pattern that starts with a brace group — measured — so folding these into one line
88
- // silently turns the guard off, which is worse than the hole it closes.
114
+ // TWO globs, and the rule is that a brace ALTERNATIVE may not contain a \`/\`. Measured on Bun
115
+ // 1.4.0 against \`examples/dummy\`: \`{apps/*/{site,app},packages/*/src}/**/*.tsx\` and
116
+ // \`{apps/web,packages/ui}/**/*.tsx\` each match ZERO files, where \`apps/*/{site,app}/**/*.tsx\`
117
+ // matches 17 — so folding these into one line silently turns the guard off, which is worse than
118
+ // the hole it closes. A LEADING group is fine and four guards here rely on it:
119
+ // \`{apps,packages}/**/*.scss\` matches all 15.
89
120
  for (const pattern of ['apps/*/{site,app}/**/*.tsx', 'packages/*/src/**/*.tsx']) {
90
121
  for await (const entry of new Bun.Glob(pattern).scan({ cwd: root, absolute: false })) {
91
122
  const path = entry.split('\\\\').join('/');
@@ -119,6 +150,23 @@ unitTest('a t() child satisfies it, and so does any other expression', () => {
119
150
  expect(untranslatedStrings(file('<li class={styles.item}>{row.title}</li>'))).toEqual([]);
120
151
  });
121
152
 
153
+ // The legitimate lookalike, and the one this rule got wrong: a JSX expression NESTS. A mask that
154
+ // does not strips the inner \`{ org: … }\` first and reads the unbalanced remnant as prose, so the
155
+ // guard reported the very calls it exists to require.
156
+ unitTest('a t() call carrying an interpolation object is not typed prose', () => {
157
+ const interpolated = "<h1>{t('app.feed.heading', { org: actor.org.name })}</h1>";
158
+ expect(untranslatedStrings(file(interpolated))).toEqual([]);
159
+ expect(untranslatedStrings(file(\`<span>{t(\\\`plans.\\\${plan}.name\\\`)}</span>\`))).toEqual([]);
160
+ });
161
+
162
+ // The other direction: masking a nested child may not swallow the prose beside it.
163
+ unitTest('prose beside an interpolating t() call is still refused', () => {
164
+ const mixed = "<h1>{t('app.feed.heading', { org: actor.org.name })} Welcome back</h1>";
165
+ const findings = untranslatedStrings(file(mixed));
166
+ expect(findings).toHaveLength(1);
167
+ expect(findings[0]?.cause).toContain('Welcome back');
168
+ });
169
+
122
170
  // The reason the rule reads a CLOSING tag: a generic type argument is a > followed by source that
123
171
  // looks exactly like prose, and a pattern reading to the next < reports every one of them.
124
172
  unitTest('a generic type argument is not a JSX text node', () => {
@@ -5,7 +5,7 @@
5
5
  // compiles. All three are pinned by the emitted test, which builds the chunk and mounts it.
6
6
 
7
7
  import type { GeneratedFile } from './naming';
8
- import { kebab, pascal } from './naming';
8
+ import { camel, kebab, pascal } from './naming';
9
9
 
10
10
  export interface IslandOptions {
11
11
  /** Directory the entry lands in, app-root-relative and POSIX — normally a route's own folder. */
@@ -170,12 +170,55 @@ describe('the ${name} island', () => {
170
170
  });
171
171
  `;
172
172
 
173
+ /**
174
+ * The states file beside the entry, and it ships WITH the island rather than after it: `x verify`'s
175
+ * `boundaries` step refuses an island that declares none (`guards/island-without-states.ts`), so a
176
+ * generator that wrote only the component would scaffold a file that fails the app's own gate on
177
+ * the next command. The second state is the point of the whole mechanism — a label a translation
178
+ * is three times as long in is a state nobody can reach by clicking in the locale they develop in.
179
+ */
180
+ const islandStates = (name: string, dir: string): string => {
181
+ const Name = pascal(name);
182
+ return `// The states \`${name}\` can be photographed in. \`x shot --island ${name} --json\` takes one
183
+ // picture per state per theme into \`.x/shot/island/${name}/\`, and the states worth declaring are
184
+ // the ones a running app will not produce on request.
185
+ //
186
+ // PURE DATA. No JSX, no \`solid-js\`, and the one import below is \`import type\`, which
187
+ // \`verbatimModuleSyntax\` erases entirely — the command that takes the pictures has to know the
188
+ // complete expected list before a browser exists. \`X_TEST_ISLAND_STATES_NOT_PURE\` is the refusal.
189
+ //
190
+ // The labels are literals here and that is not a \`t()\` violation: an island's props cross the seam
191
+ // as JSON inside the document, so the SERVER translates and the browser is handed text.
192
+
193
+ import { defineIslandStates } from '@ultimat3/testing';
194
+ import type { ${Name}Props } from './${name}.island';
195
+
196
+ export const ${camel(name)}States = defineIslandStates({
197
+ island: '${dir}/${name}.island.tsx',
198
+ states: [
199
+ {
200
+ id: 'idle',
201
+ title: 'the first paint, before anything has been clicked',
202
+ props: { label: 'Open' } satisfies ${Name}Props,
203
+ },
204
+ {
205
+ id: 'long-label',
206
+ title: 'the label a translation is three times as long in',
207
+ note: 'you cannot reach this by clicking: it needs a locale whose word for this is long, and the one this was written in is not it',
208
+ props: { label: 'Abrechnungseinstellungen anzeigen' } satisfies ${Name}Props,
209
+ },
210
+ ],
211
+ });
212
+ `;
213
+ };
214
+
173
215
  export function islandFiles(rawName: string, options: IslandOptions): readonly GeneratedFile[] {
174
216
  const name = kebab(rawName);
175
217
  const dir = options.dir.replace(/\/+$/, '');
176
218
  return [
177
219
  { path: `${dir}/${name}.island.tsx`, contents: islandSource(name) },
178
220
  { path: `${dir}/${name}.module.scss`, contents: islandStyle() },
221
+ { path: `${dir}/${name}.island.states.ts`, contents: islandStates(name, dir) },
179
222
  { path: `${dir}/${name}.island.test.ts`, contents: islandTest(name, dir) },
180
223
  ];
181
224
  }
@@ -265,6 +265,69 @@ describe('the ${feature.kebab} form island', () => {
265
265
  });
266
266
  `;
267
267
 
268
+ /**
269
+ * The states file beside the entry, and it ships WITH the island rather than after it: `x verify`'s
270
+ * `boundaries` step refuses an island that declares none (`guards/island-without-states.ts`), so a
271
+ * generator that wrote only the component would scaffold a file that fails the app's own gate on
272
+ * the next command.
273
+ */
274
+ const formIslandStates = (
275
+ feature: NameSet,
276
+ dir: string,
277
+ ): string => `// The states the ${feature.kebab} form can be photographed in. \`x shot --island ${feature.kebab}-form --json\`
278
+ // takes one picture per state per theme into \`.x/shot/island/${feature.kebab}-form/\`, and the states
279
+ // worth declaring are the ones a running app will not produce on request.
280
+ //
281
+ // PURE DATA. No JSX, no \`solid-js\`, and the one import below is \`import type\`, which
282
+ // \`verbatimModuleSyntax\` erases entirely — the command that takes the pictures has to know the
283
+ // complete expected list before a browser exists. \`X_TEST_ISLAND_STATES_NOT_PURE\` is the refusal.
284
+ //
285
+ // The labels are literals here and that is not a \`t()\` violation: an island's props cross the seam
286
+ // as JSON inside the document, so the SERVER translates and the browser is handed text. These are
287
+ // the text the server would have handed it.
288
+
289
+ import { defineIslandStates } from '@ultimat3/testing';
290
+ import type { ${feature.pascal}FormProps } from './${feature.kebab}-form.island';
291
+
292
+ /** What a working render hands the form — the baseline the state below departs from. */
293
+ const BASE = {
294
+ endpoint: '/api/create-${feature.kebab}',
295
+ locale: 'en',
296
+ labels: {
297
+ title: 'Title',
298
+ submit: 'Save',
299
+ saved: 'Saved',
300
+ retry: 'That did not save. Try again.',
301
+ },
302
+ } satisfies ${feature.pascal}FormProps;
303
+
304
+ export const ${feature.camel}FormStates = defineIslandStates({
305
+ island: '${dir}/${feature.kebab}-form.island.tsx',
306
+ states: [
307
+ {
308
+ id: 'idle',
309
+ title: 'the first paint, before anything has been typed',
310
+ props: BASE satisfies ${feature.pascal}FormProps,
311
+ },
312
+ {
313
+ id: 'long-labels',
314
+ title: 'the same form in a locale whose words are three times as long',
315
+ note: 'you cannot reach this by clicking: it needs a translation, and the locale this was written in is the one that fits',
316
+ props: {
317
+ ...BASE,
318
+ locale: 'de',
319
+ labels: {
320
+ title: 'Bezeichnung des Beitrags',
321
+ submit: 'Änderungen speichern',
322
+ saved: 'Änderungen gespeichert',
323
+ retry: 'Das konnte nicht gespeichert werden. Bitte erneut versuchen.',
324
+ },
325
+ } satisfies ${feature.pascal}FormProps,
326
+ },
327
+ ],
328
+ });
329
+ `;
330
+
268
331
  /**
269
332
  * The slice's form, as the one client shape: `<dir>/<feature>-form.island.tsx` plus its test.
270
333
  *
@@ -281,6 +344,10 @@ export function formIslandFiles(
281
344
  path: `${dir}/${feature.kebab}-form.island.tsx`,
282
345
  contents: formIslandSource(feature, formIslandSpecifier(feature, dir, pageDir)),
283
346
  },
347
+ {
348
+ path: `${dir}/${feature.kebab}-form.island.states.ts`,
349
+ contents: formIslandStates(feature, dir),
350
+ },
284
351
  {
285
352
  path: `${dir}/${feature.kebab}-form.island.test.ts`,
286
353
  contents: formIslandTest(feature, dir),
@@ -131,8 +131,17 @@ calls. A change outside that set is a collision — report it, do not make it.
131
131
  | Semantic tokens only | never a raw hex, in a component or a stylesheet |
132
132
  | Dates name a zone | explicit IANA time zone at every call site, no ambient default |
133
133
  | A page calls primitives | queries and actions, never a repo and never the database |
134
+ | A click needs a control | \`<button>\`, \`<a href>\`, \`<input>\` — never a \`<div onClick>\`, and never \`role="button"\` where the tag exists. A role is a promise: it obliges you to answer Space, Enter, focus and disabled |
135
+ | The focus ring is replaced | \`outline: none\` on its own leaves a keyboard user with nothing. \`@include tokens.focus-ring\`, or a \`:focus-visible\` box-shadow beside it |
136
+ | Every image has a box | width + height, or an aspect-ratio. An unsized image moves everything under it when its bytes land, and the priority one is never \`loading="lazy"\` |
137
+ | Animate transform and opacity | nothing else composites: a \`top\`, a \`width\` or a \`box-shadow\` costs a layout pass every frame, and \`transition: all\` animates properties nobody chose. \`tokens.duration()\` / \`tokens.easing()\` are the values |
138
+ | An island declares its states | a sibling \`<name>.island.states.ts\`, so \`x shot --island <name> --json\` can photograph the failures nobody can click to |
134
139
 
135
- Inspect before you change: \`x routes --json\`, and \`x i18n check\` for catalog gaps.
140
+ Every one of the last five is a build error, not a preference — \`guards/\` holds them and \`x verify\`'s
141
+ \`boundaries\` step runs them. Read the guard rather than guessing at the rule.
142
+
143
+ Inspect before you change: \`x routes --json\`, \`x i18n check\` for catalog gaps, and
144
+ \`x shot <route> --json\` when you need to see what you built.
136
145
 
137
146
  Checks: \`bun test <path>/page.test.ts\`, \`bunx biome check --write <paths>\`, and \`bun run typecheck\`
138
147
  once when you are otherwise done. Never \`x verify\` — that belongs to whoever coordinates you.
@@ -6,7 +6,7 @@
6
6
  import type { GeneratedFile, NameSet } from './naming';
7
7
 
8
8
  const feature = (app: NameSet): string => `---
9
- description: Build or fix one thing in ${app.kebab} end to end — name the primitive, generate it, wire it inside the boundaries, gate it with \`x verify\`.
9
+ description: Build or fix one thing in ${app.kebab} end to end — name the primitive, generate it, wire it inside the boundaries, gate it with \`bin/check\`.
10
10
  argument-hint: <what you want built or fixed, plain language>
11
11
  allowed-tools: Read, Write, Edit, Glob, Grep, Bash, Agent, Skill
12
12
  ---
@@ -16,7 +16,7 @@ allowed-tools: Read, Write, Edit, Glob, Grep, Bash, Agent, Skill
16
16
  You are a senior engineer on **${app.kebab}**, an Ultimate app. Read \`AGENTS.md\` before designing
17
17
  anything — it is the short form of every rule below, and it wins where the two disagree.
18
18
 
19
- **Done means \`x verify\` green.** A passing unit test is not done. A working \`x dev\` is not done.
19
+ **Done means \`bin/check\` green.** A passing unit test is not done. A working \`x dev\` is not done.
20
20
  Report what you actually ran, never what you assume passed.
21
21
 
22
22
  ## Request
@@ -77,10 +77,16 @@ the filename never is. One interactive control on a 0kb page is an island: \`x g
77
77
  ## 4. Gate it
78
78
 
79
79
  \`\`\`sh
80
- x verify # the gate. green = shippable
81
- x verify --json # the same steps, machine-readable
80
+ bin/check # the gate. green = shippable
81
+ bin/check --json # the same, machine-readable
82
82
  \`\`\`
83
83
 
84
+ \`bin/check\` is \`x build --target static\` and THEN \`x verify\`, and the order is the whole point:
85
+ the \`budgets\` step measures \`.x/build-stats.json\`, which only a build writes, so \`x verify\` on
86
+ its own reports X_BUDGET_UNMEASURED on a tree that is fine. \`--json\` is forwarded to both halves;
87
+ a machine consumer takes the last line. CI runs this same script — \`.github/workflows/ci.yml\` is
88
+ \`bin/setup\` and then \`bin/check\`, nothing else.
89
+
84
90
  Red is instructions, not a verdict: **every finding carries an executable \`fix:\` — run it verbatim
85
91
  before improvising**, and \`x errors explain <CODE>\` expands any code it names. Never narrow the gate
86
92
  to make it pass: there is no \`--only\` and no \`--skip\`, on purpose, and disabling a lint rule or
@@ -112,7 +118,7 @@ ISO code, never a float. Bun only.
112
118
  Primitive: <which of the eight> Slice: <dir>
113
119
  Generated: <the x g invocations you ran>
114
120
  Changed: <files>
115
- Gate: x verify ✓ | ✗ <failing steps>
121
+ Gate: bin/check ✓ | ✗ <failing steps>
116
122
  Deferred: <what you did not do, and why> [never omit this line]
117
123
  \`\`\`
118
124
  `;
@@ -166,7 +172,7 @@ slice it lives in. If it fits none, the design is wrong — say so here instead
166
172
  - What to add, next to the source as \`<file>.test.ts\`. Command to run it.
167
173
 
168
174
  ## Done when
169
- - Acceptance criteria, ending in \`x verify\` green.
175
+ - Acceptance criteria, ending in \`bin/check\` green.
170
176
 
171
177
  ## Risks
172
178
  - Anything the executor must decide, and every claim in the ask the code disproves.
@@ -179,7 +185,7 @@ slice it lives in. If it fits none, the design is wrong — say so here instead
179
185
  - No checkboxes. The plan is a map, not a tracker.
180
186
  - The plan must obey the app's own rules — one way to do each thing, generators over hand-written
181
187
  files, imports that never cross a surface boundary, a stable error code with a runnable \`fix:\` for
182
- every new failure, and \`x verify\` green as the last line of *Done when*.
188
+ every new failure, and \`bin/check\` green as the last line of *Done when*.
183
189
 
184
190
  ## Output
185
191
 
@@ -196,13 +202,18 @@ allowed-tools: Read, Write, Edit, Glob, Grep, Bash
196
202
 
197
203
  # /verify
198
204
 
199
- Run \`x verify\`.
205
+ Run \`bin/check\`.
206
+
207
+ It is \`x build --target static\` and then \`x verify\`, and it is THE gate — the one CI runs
208
+ (\`.github/workflows/ci.yml\`) and the one \`README.md\` names. \`x verify\` alone is half of it:
209
+ the \`budgets\` step measures \`.x/build-stats.json\`, which only the build writes, so a bare
210
+ \`x verify\` reports X_BUDGET_UNMEASURED on a tree with nothing wrong with it.
200
211
 
201
212
  Green: say so and stop.
202
213
 
203
214
  Red: fix every finding, then re-run until green. Each finding carries a stable code, a cause and an
204
215
  executable \`fix:\` — **run the \`fix:\` verbatim before improvising**, and use \`x errors explain <CODE>\`
205
- when the cause is not enough. \`x verify --json\` gives the same steps machine-readably; \`x doctor\`
216
+ when the cause is not enough. \`bin/check --json\` gives the same steps machine-readably; \`x doctor\`
206
217
  covers the case where the environment, not the code, is what is broken.
207
218
 
208
219
  Do not narrow the gate to make it pass. There is no \`--only\` and no \`--skip\`; disabling a lint rule,