@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.
@@ -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.
@@ -28,11 +28,22 @@ not exist — five of these had an empty column and were each measured green on
28
28
  | Time | store UTC, format with an explicit IANA time zone | \`guards/unzoned-date.ts\` |
29
29
  | Strings | every user-facing string goes through \`t()\` | \`guards/untranslated-string.ts\` |
30
30
  | Colour | semantic tokens only, never a raw hex | \`guards/raw-colour.ts\` |
31
+ | Interaction | a click is answered by a control — never a \`<div onClick>\`, and never a \`role=\` where the native tag exists | \`guards/semantic-interactive.ts\` |
32
+ | Focus | \`outline: none\` replaces the ring in the same rule or the one beside it, or it does not remove it | \`guards/focus-visible.ts\` |
33
+ | Images | every image carries width + height or an aspect-ratio, and the priority one is never \`loading="lazy"\` | \`guards/image-dimensions.ts\` |
34
+ | Motion | animate \`transform\` and \`opacity\` — never a layout property, never \`transition: all\` | \`guards/animated-layout-property.ts\` |
35
+ | Islands | every \`*.island.tsx\` has a sibling \`*.island.states.ts\`, so \`x shot --island\` can photograph its failures | \`guards/island-without-states.ts\` |
31
36
  | Size | one file, one job — ${LINE_CEILING} lines of reviewable logic, and split past it | \`X_FILE_TOO_LONG\` |
32
37
 
33
38
  \`guards/\` is yours: each file is one rule, discovered by \`x verify\` and run inside its
34
39
  \`boundaries\` step. Delete one to drop the rule, and \`x g guard <name>\` writes the next.
35
40
 
41
+ The last five rows are about what this app is like to USE, and each is decidable from the file
42
+ alone — which is why they are those five and not the many that are not. A role is a promise:
43
+ \`role="button"\` obliges you to answer Space, Enter, focus and disabled, and the native element
44
+ already does. Only \`transform\` and \`opacity\` animate without a layout pass. An image with no box
45
+ moves everything under it when its bytes land.
46
+
36
47
  Size is a hard line and not a style note: past ${LINE_CEILING} lines a file has stopped being the
37
48
  unit of review, and \`x verify\` refuses it. The one exemption is a file that is nothing but re-exports —
38
49
  it has one job by construction, and its length tracks the API's size rather than its complexity;
@@ -6,9 +6,19 @@
6
6
  // (`packages/cli/src/guards.ts`). What was missing is any guard to discover. Four of the five are
7
7
  // here; the fifth, money-as-float, has no static signature and is answered by the `Money` type
8
8
  // instead — `scaffold-docs.ts` says so where an author reads it.
9
+ //
10
+ // The other five are about what the app is like to USE, which no row of `AGENTS.md` had ever been
11
+ // about: a control a keyboard cannot reach, a focus ring taken away and not replaced, an image with
12
+ // no box, an animation the compositor cannot run, and an island nobody has seen fail. Each is
13
+ // statically decidable from the file alone — the reason those five and not the many that are not.
9
14
 
15
+ import { animatedLayoutPropertyGuardFiles } from './guard-animated-layout-property';
10
16
  import { bareErrorGuardFiles } from './guard-bare-error';
17
+ import { focusVisibleGuardFiles } from './guard-focus-visible';
18
+ import { imageDimensionsGuardFiles } from './guard-image-dimensions';
19
+ import { islandWithoutStatesGuardFiles } from './guard-island-without-states';
11
20
  import { rawColourGuardFiles } from './guard-raw-colour';
21
+ import { semanticInteractiveGuardFiles } from './guard-semantic-interactive';
12
22
  import { untranslatedStringGuardFiles } from './guard-untranslated-string';
13
23
  import { unzonedDateGuardFiles } from './guard-unzoned-date';
14
24
  import type { GeneratedFile } from './naming';
@@ -19,8 +29,13 @@ import type { GeneratedFile } from './naming';
19
29
  * `x g guard <name>`, the same shape.
20
30
  */
21
31
  export const scaffoldGuardFiles = (): readonly GeneratedFile[] => [
32
+ ...animatedLayoutPropertyGuardFiles(),
22
33
  ...bareErrorGuardFiles(),
34
+ ...focusVisibleGuardFiles(),
35
+ ...imageDimensionsGuardFiles(),
36
+ ...islandWithoutStatesGuardFiles(),
23
37
  ...rawColourGuardFiles(),
38
+ ...semanticInteractiveGuardFiles(),
24
39
  ...untranslatedStringGuardFiles(),
25
40
  ...unzonedDateGuardFiles(),
26
41
  ];
@@ -26,6 +26,17 @@ import { uiPackageFiles } from './scaffold-ui-package';
26
26
  */
27
27
  const BIOME_VERSION = '2.5.8';
28
28
 
29
+ /**
30
+ * The checker a scaffolded app typechecks with, and it must not lag the one the FRAMEWORK is built
31
+ * and gated on: `@ultimat3/*` ships `.d.ts` emitted by this compiler, so an app pinned a major
32
+ * behind reads the types its own dependencies were written against through an older checker.
33
+ * It drifted for exactly the reason `BIOME_VERSION` did not — Biome's was spelled once as a
34
+ * constant and TypeScript's was a literal buried in a dependency block, so the framework moved to
35
+ * 7.x and every `x new` kept scaffolding `^6.0.3`. `scaffold-repo.test.ts` now pins this against
36
+ * the repo's own root `package.json`, so the next bump cannot leave the scaffold behind in silence.
37
+ */
38
+ const TYPESCRIPT_VERSION = '^7.0.2';
39
+
29
40
  // `version` is not decoration: the manifest's app version IS the contract's compatibility gate,
30
41
  // and the manifest never fabricates one — so an app scaffolded without it failed `x manifest`,
31
42
  // the `manifest` verify step and every production boot with X_APP_PACKAGE_INVALID.
@@ -54,7 +65,7 @@ const rootPackage = (app: NameSet, version: string): string => `{
54
65
  "@electric-sql/pglite": "^0.5.4",
55
66
  "@types/bun": "^1.4.0",
56
67
  "@ultimat3/testing": "^${version}",
57
- "typescript": "^6.0.3"
68
+ "typescript": "${TYPESCRIPT_VERSION}"
58
69
  },
59
70
  "dependencies": {
60
71
  "@ultimat3/action": "^${version}",