@ggui-ai/ui-gen 0.17.0 → 0.18.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.
Files changed (36) hide show
  1. package/dist/adapters/generation-dispatch.js +252 -108
  2. package/dist/adapters/generation-dispatch.js.map +1 -1
  3. package/dist/adapters/index.js.map +1 -1
  4. package/dist/advanced/index.js +261 -113
  5. package/dist/advanced/index.js.map +1 -1
  6. package/dist/boilerplate.js +27 -3
  7. package/dist/boilerplate.js.map +1 -1
  8. package/dist/check/index.js +70 -0
  9. package/dist/check/index.js.map +1 -1
  10. package/dist/evaluation/axis-checks/index.js +50 -0
  11. package/dist/evaluation/axis-checks/index.js.map +1 -1
  12. package/dist/evaluation/axis-checks/registry.js +50 -0
  13. package/dist/evaluation/axis-checks/registry.js.map +1 -1
  14. package/dist/evaluation/index.js +50 -0
  15. package/dist/evaluation/index.js.map +1 -1
  16. package/dist/harness/index.js +27 -3
  17. package/dist/harness/index.js.map +1 -1
  18. package/dist/harness/prompts.js +4 -0
  19. package/dist/harness/prompts.js.map +1 -1
  20. package/dist/harness/runtime.js +27 -3
  21. package/dist/harness/runtime.js.map +1 -1
  22. package/dist/harness/types-public.js +27 -3
  23. package/dist/harness/types-public.js.map +1 -1
  24. package/dist/index.js +261 -113
  25. package/dist/index.js.map +1 -1
  26. package/package.json +12 -12
  27. package/src/boilerplate/free-design-prompt.ts +2 -0
  28. package/src/boilerplate/generate.ts +8 -1
  29. package/src/boilerplate/hard-sections.ts +31 -2
  30. package/src/boilerplate/system-prompt.ts +2 -0
  31. package/src/boilerplate/templates/layouts/fullscreen-desktop.tsx.tmpl +5 -3
  32. package/src/boilerplate/templates/layouts/fullscreen-mobile.tsx.tmpl +3 -3
  33. package/src/boilerplate/templates/layouts/fullscreen-universal.tsx.tmpl +5 -3
  34. package/src/check/run-tier0.ts +152 -0
  35. package/src/contract-context.ts +13 -0
  36. package/src/evaluation/axis-checks/checks/universal.ts +64 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ggui-ai/ui-gen",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
4
4
  "description": "UI generation harness for ggui — the LLM system prompt, multi-turn coding loop, and evaluation engine that turn a DataContract into a React component. Implements the UiGenerator contract from @ggui-ai/mcp-server-core; consumed by @ggui-ai/mcp-server.",
5
5
  "license": "Apache-2.0",
6
6
  "keywords": [
@@ -188,9 +188,9 @@
188
188
  "puppeteer-core": "^24.40.0 || ^25.0.0",
189
189
  "react": "^18.0.0 || ^19.0.0",
190
190
  "react-dom": "^18.0.0 || ^19.0.0",
191
- "@ggui-ai/mcp-server-core": "0.17.0",
192
- "@ggui-ai/protocol": "0.17.0",
193
- "@ggui-ai/project-config": "0.17.0"
191
+ "@ggui-ai/mcp-server-core": "0.18.0",
192
+ "@ggui-ai/project-config": "0.18.0",
193
+ "@ggui-ai/protocol": "0.18.0"
194
194
  },
195
195
  "peerDependenciesMeta": {
196
196
  "@google/adk": {
@@ -229,11 +229,11 @@
229
229
  "openai": "^7.9.0",
230
230
  "typescript": "^5.0.0",
231
231
  "zod": "^4.3.6",
232
- "@ggui-ai/sandbox": "0.17.0",
233
- "@ggui-ai/gadgets": "0.17.0",
234
- "@ggui-ai/design": "0.17.0",
235
- "@ggui-ai/wire": "0.17.0",
236
- "@ggui-ai/ui-visual-tester": "0.17.0"
232
+ "@ggui-ai/design": "0.18.0",
233
+ "@ggui-ai/sandbox": "0.18.0",
234
+ "@ggui-ai/ui-visual-tester": "0.18.0",
235
+ "@ggui-ai/wire": "0.18.0",
236
+ "@ggui-ai/gadgets": "0.18.0"
237
237
  },
238
238
  "devDependencies": {
239
239
  "@google/adk": "^2.0.0",
@@ -248,9 +248,9 @@
248
248
  "react-dom": "^19.0.0",
249
249
  "tsup": "^8.5.0",
250
250
  "vitest": "^3.2.6",
251
- "@ggui-ai/project-config": "0.17.0",
252
- "@ggui-ai/mcp-server-core": "0.17.0",
253
- "@ggui-ai/protocol": "0.17.0"
251
+ "@ggui-ai/project-config": "0.18.0",
252
+ "@ggui-ai/protocol": "0.18.0",
253
+ "@ggui-ai/mcp-server-core": "0.18.0"
254
254
  },
255
255
  "engines": {
256
256
  "node": ">=22.0.0"
@@ -29,6 +29,7 @@ import {
29
29
  DATA_PARAMETERIZATION,
30
30
  DEFENSIVE_CODING,
31
31
  FRAME_SIZING,
32
+ TERMINAL_ACTIONS,
32
33
  GESTURE_ROUTING,
33
34
  PROTOCOL_NOTES,
34
35
  renderGadgetCatalogSection,
@@ -310,6 +311,7 @@ ${axisSection}
310
311
  CONTRACT_SURFACE,
311
312
  DEFENSIVE_CODING,
312
313
  GESTURE_ROUTING,
314
+ TERMINAL_ACTIONS,
313
315
  FREE_GESTURE_SURFACE,
314
316
  ANTI_PATTERNS,
315
317
  CROSS_REFERENCE_RULES,
@@ -252,7 +252,14 @@ export function generateBoilerplate(
252
252
  // Inline signature tells the LLM exactly how to call this action
253
253
  const callSig = tsType === "void" ? "() => void — fire and forget" : `(data: ${tsType}) => void`;
254
254
  const toolHint = tool ? ` → nextStep: ${tool}` : "";
255
- actionHookCalls.push(` const ${key} = useAction<${typeName}>('${key}'); // ${callSig}${toolHint}`);
255
+ // ggui#1108 (mitigation): the reminder sits at the EXACT site the model
256
+ // writes the handler. It is a hint, not the rule — the rule is the prompt's
257
+ // `TERMINAL_ACTIONS` section, because only the request and the contract say
258
+ // which actions are meant once. A site comment alone moves generation by
259
+ // roughly nothing (Exp 002b: adoption 1/36), which is why it is the
260
+ // smallest of the three legs and never the one relied on.
261
+ const onceHint = " — if this is meant ONCE, disable its control after it fires";
262
+ actionHookCalls.push(` const ${key} = useAction<${typeName}>('${key}'); // ${callSig}${toolHint}${onceHint}`);
256
263
  actionReturnFields.push(key);
257
264
  }
258
265
 
@@ -216,6 +216,28 @@ Rules:
216
216
  4. Copy comes from the props. NEVER invent display text the contract does not carry — no eyebrow / kicker / overline (a \`<Text caps>\` label), no helper line, no status text, no tagline. A caps label exists only when a prop supplies its text (\`<Text caps>{props.section}</Text>\`); a heading's fallback is the request's own words, never a slogan of your own. Text the agent cannot change is chatter on every render.
217
217
  5. Props interface must be typed and exported.`;
218
218
 
219
+ /**
220
+ * A control that fires an action the user would not mean to repeat must not
221
+ * stay armed (ggui#1108 MITIGATION — the fix is one-shot-ness DECLARED on the
222
+ * action and enforced by the runtime; this reduces exposure meanwhile). Both
223
+ * arms, one HARD section: the model knows from the request and the contract
224
+ * which actions are terminal, which no static rule of ours can infer.
225
+ */
226
+ export const TERMINAL_ACTIONS = `## Actions the user means once
227
+
228
+ A second press is a mistake only when it would make a SECOND THING HAPPEN IN THE WORLD — a second booking, a second charge, a second order, a second application sent. If pressing again would only write the SAME STATE AGAIN — saving, updating, renaming, setting a preference, toggling — nothing is duplicated and the control stays armed.
229
+
230
+ Guard a control only where you have evidence, not a feeling about the verb: the request itself names a one-time commitment — "submit the application", "place the order", "confirm the booking".
231
+
232
+ **When in doubt, leave it armed.** A control the user cannot press a second time has lost them something that worked — save, edit, save again is ordinary. A control left armed is only what every interface does by default.
233
+
234
+ When you do guard one:
235
+ - Hold a small piece of state for it and set it in the handler: \`const [submitted, setSubmitted] = useState(false)\`.
236
+ - \`disabled={submitted}\` on the control that fires it, and say so in the label or beside it ("Submitted", "Request sent") — a control that is disabled without explanation reads as broken.
237
+ - A card re-rendered with new props starts fresh; the state above lives with the component, not with the request.
238
+
239
+ Nothing in the wire DECLARES one-shot-ness yet, which is why the test is what a second press would DO — not how final the word sounds.`;
240
+
219
241
  /**
220
242
  * The frame owns the height — the component never sizes itself to the
221
243
  * viewport (ggui#1075 Track A/B; receipted on #1096: 20 of 48 minted cells
@@ -223,12 +245,19 @@ Rules:
223
245
  * card told "compact"). Identical in both modes; the eval leg's tier-0
224
246
  * `universal.viewport_sized` fails what slips through.
225
247
  */
226
- export const FRAME_SIZING = `## Sizing — the frame owns the height
248
+ export const FRAME_SIZING = `## Sizing — the frame owns the height and the width
227
249
 
228
250
  The host sizes the iframe; the component never sizes itself to the viewport. NEVER write \`100vh\` / \`100dvh\` / \`100svh\` (or an \`h-screen\` / \`min-h-screen\` class) as a height on any element:
229
251
  - An inline card takes its natural height. A content-sized frame is measured FROM the content, so a \`100vh\` root can never shrink and grows the frame on every re-measure.
230
252
  - Under fullscreen the frame already stretches your root to its full height. Centre inside it with flex (\`display: flex; flex-direction: column; justify-content: center\`), never with a viewport height.
231
- - To fill a parent that has a height use \`height: 100%\` or \`flex: 1\`; a viewport unit is only ever a \`max-height\` cap on a scroll region.`;
253
+ - To fill a parent that has a height use \`height: 100%\` or \`flex: 1\`; a viewport unit is only ever a \`max-height\` cap on a scroll region.
254
+
255
+ The width works the same way, and the mistake is the mirror image: **your outermost element FILLS the frame; a width cap belongs INSIDE it, on the column that holds text.**
256
+
257
+ - Never put \`maxWidth\` (or a \`max-w-*\` class) on the element you return at the top. On a wide canvas that turns your card into a narrow strip with the frame's ground on either side of it, and on a narrow one it does nothing — the same composition reading differently at two sizes.
258
+ - DO cap the reading measure inside: a column of prose is easiest to read at 60–70 characters, so wrap the TEXT in a \`<Container maxWidth="sm">\` (or your own \`max-width\`) and leave the root filling. A hero band, a toolbar, a table or an image then still uses the whole width, which is what makes a wide card look composed rather than padded.
259
+ - Centre that inner column with the container's own centring, not by shrinking the root.
260
+ - **They are two different elements.** Even when the whole card is one column of text, the root still fills and the column sits INSIDE it — \`<Box padding="lg"><Container maxWidth="sm">…</Container></Box>\`, never a root that is itself the container. A centred card is centring inside a filled frame, not a shrunken frame.`;
232
261
 
233
262
  /** Helper-component structure guidance (JSX depth 3–5, helpers above `Component`). Identical in both modes. */
234
263
  export const COMPONENT_STRUCTURE = `## Component Structure
@@ -41,6 +41,7 @@ import {
41
41
  DEFENSIVE_CODING,
42
42
  FRAME_SIZING,
43
43
  GESTURE_ROUTING,
44
+ TERMINAL_ACTIONS,
44
45
  INTERACTIVE_TRAITS,
45
46
  PROTOCOL_NOTES,
46
47
  renderGadgetCatalogSection,
@@ -383,6 +384,7 @@ ${axisSection}
383
384
  CONTRACT_SURFACE,
384
385
  DEFENSIVE_CODING,
385
386
  GESTURE_ROUTING,
387
+ TERMINAL_ACTIONS,
386
388
  INTERACTIVE_TRAITS,
387
389
  ANTI_PATTERNS,
388
390
  CROSS_REFERENCE_RULES,
@@ -1,3 +1,5 @@
1
- <Container maxWidth="xl" padding="lg">
2
- {/* implement your UI here */}
3
- </Container>
1
+ <Box padding="lg">
2
+ {/* implement your UI here — this root FILLS the frame.
3
+ Bands, toolbars, tables and images use the whole width;
4
+ wrap PROSE in <Container maxWidth="sm"> to cap the reading measure. */}
5
+ </Box>
@@ -1,3 +1,3 @@
1
- <Container maxWidth="sm" padding="md">
2
- {/* implement your UI here */}
3
- </Container>
1
+ <Box padding="md">
2
+ {/* implement your UI here — this root FILLS the frame (a phone IS the measure). */}
3
+ </Box>
@@ -1,3 +1,5 @@
1
- <Container maxWidth="xl" padding="lg">
2
- {/* implement your UI here */}
3
- </Container>
1
+ <Box padding="lg">
2
+ {/* implement your UI here — this root FILLS the frame.
3
+ Bands, toolbars, tables and images use the whole width;
4
+ wrap PROSE in <Container maxWidth="sm"> to cap the reading measure. */}
5
+ </Box>
@@ -352,6 +352,137 @@ function isGadgetExportImported(
352
352
  return false;
353
353
  }
354
354
 
355
+ /**
356
+ * AST-precise detector for the compiles-but-renders-nothing class: the
357
+ * default-exported component's body never returns a tree.
358
+ *
359
+ * The boilerplate HANDS the model the wrapper — `base.tsx.tmpl` and
360
+ * `base-free.tsx.tmpl` both emit `return (` … `);` around `{{LAYOUT}}`.
361
+ * A model rewriting the root block can delete those two lines with it,
362
+ * leaving the JSX as a bare expression statement. That is legal TSX: it
363
+ * parses, esbuild compiles it, `typecheck()` passes it, and the
364
+ * component renders a blank frame. Seen once in twenty takes of the
365
+ * ggui#1113 A/B (`xs-chat-card`, run 2 — the judge spent a screenshot
366
+ * and an LLM call to report "completely blank").
367
+ *
368
+ * `compile/default-export` already guards the scaffold's
369
+ * `export default function` line and `types/props-interface` its
370
+ * `interface Props` line. The `return (` was the third scaffold line
371
+ * with nothing watching it.
372
+ *
373
+ * Conservative by construction — it reports ONLY when it positively
374
+ * identified a block-bodied default-export component:
375
+ * - a concise arrow body (`export default () => <X/>`) returns by
376
+ * construction → silent
377
+ * - `export default memo(Component)` or any other expression it
378
+ * cannot resolve to a function → silent
379
+ * - a `return` inside a nested helper is not the component's own (the
380
+ * walk stops at every nested function boundary), but ONE own-level
381
+ * `return <value>` anywhere — an early `return null` included —
382
+ * satisfies it.
383
+ *
384
+ * Returns `null` when the component returns (or could not be
385
+ * identified), else the 1-based line of the first bare JSX expression
386
+ * statement in its body — the line the fix belongs on — or `undefined`
387
+ * when the body has no JSX statement to point at either.
388
+ */
389
+ function detectComponentNeverReturns(
390
+ sourceCode: string,
391
+ ): { jsxStatementLine: number | undefined } | null {
392
+ const sf = ts.createSourceFile(
393
+ 'source.tsx',
394
+ sourceCode,
395
+ ts.ScriptTarget.Latest,
396
+ /* setParentNodes */ true,
397
+ ts.ScriptKind.TSX,
398
+ );
399
+
400
+ const isFunctionLike = (n: ts.Node): boolean =>
401
+ ts.isFunctionDeclaration(n) ||
402
+ ts.isFunctionExpression(n) ||
403
+ ts.isArrowFunction(n) ||
404
+ ts.isMethodDeclaration(n) ||
405
+ ts.isClassDeclaration(n) ||
406
+ ts.isClassExpression(n);
407
+
408
+ /** The top-level declaration `name` binds to, when it is a function. */
409
+ function resolveTopLevel(
410
+ name: string,
411
+ ): ts.FunctionDeclaration | ts.FunctionExpression | ts.ArrowFunction | undefined {
412
+ for (const st of sf.statements) {
413
+ if (ts.isFunctionDeclaration(st) && st.name?.text === name) return st;
414
+ if (ts.isVariableStatement(st)) {
415
+ for (const decl of st.declarationList.declarations) {
416
+ if (
417
+ ts.isIdentifier(decl.name) &&
418
+ decl.name.text === name &&
419
+ decl.initializer !== undefined &&
420
+ (ts.isArrowFunction(decl.initializer) || ts.isFunctionExpression(decl.initializer))
421
+ ) {
422
+ return decl.initializer;
423
+ }
424
+ }
425
+ }
426
+ }
427
+ return undefined;
428
+ }
429
+
430
+ let component:
431
+ | ts.FunctionDeclaration
432
+ | ts.FunctionExpression
433
+ | ts.ArrowFunction
434
+ | undefined;
435
+ for (const st of sf.statements) {
436
+ if (
437
+ ts.isFunctionDeclaration(st) &&
438
+ st.modifiers?.some((m) => m.kind === ts.SyntaxKind.DefaultKeyword)
439
+ ) {
440
+ component = st;
441
+ break;
442
+ }
443
+ if (ts.isExportAssignment(st) && st.isExportEquals !== true) {
444
+ const expr = st.expression;
445
+ if (ts.isArrowFunction(expr) || ts.isFunctionExpression(expr)) component = expr;
446
+ else if (ts.isIdentifier(expr)) component = resolveTopLevel(expr.text);
447
+ break;
448
+ }
449
+ }
450
+ // No default export at all, or one this detector cannot resolve to a
451
+ // function — `compile/default-export` owns the first case and the
452
+ // second is not ours to guess at.
453
+ if (component === undefined) return null;
454
+
455
+ const body = component.body;
456
+ // A concise arrow body IS the returned value.
457
+ if (body === undefined || !ts.isBlock(body)) return null;
458
+
459
+ let returnsValue = false;
460
+ let jsxStatementLine: number | undefined;
461
+ const visit = (node: ts.Node): void => {
462
+ if (returnsValue) return;
463
+ if (ts.isReturnStatement(node)) {
464
+ if (node.expression !== undefined) returnsValue = true;
465
+ return;
466
+ }
467
+ if (
468
+ jsxStatementLine === undefined &&
469
+ ts.isExpressionStatement(node) &&
470
+ (ts.isJsxElement(node.expression) ||
471
+ ts.isJsxFragment(node.expression) ||
472
+ ts.isJsxSelfClosingElement(node.expression))
473
+ ) {
474
+ jsxStatementLine = sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1;
475
+ }
476
+ node.forEachChild((child) => {
477
+ // A helper component's own `return` is not this component's.
478
+ if (!isFunctionLike(child)) visit(child);
479
+ });
480
+ };
481
+ for (const st of body.statements) if (!isFunctionLike(st)) visit(st);
482
+
483
+ return returnsValue ? null : { jsxStatementLine };
484
+ }
485
+
355
486
  /**
356
487
  * Run all tier 0 (deterministic, no-LLM) checks against component source.
357
488
  *
@@ -982,6 +1113,27 @@ export async function runTier0Checks(
982
1113
  });
983
1114
  }
984
1115
 
1116
+ // ── Component never returns its tree ──────────────────────
1117
+ // The scaffold hands the model `return (` … `);` around the layout.
1118
+ // A root-block rewrite can delete them with it, and bare JSX at
1119
+ // statement position compiles, type-checks and renders NOTHING.
1120
+ const neverReturns = detectComponentNeverReturns(sourceCode);
1121
+ if (neverReturns !== null) {
1122
+ issues.push({
1123
+ tier: 0,
1124
+ result: 'fail',
1125
+ category: 'compile',
1126
+ subcategory: 'renders-nothing',
1127
+ severity: 'critical',
1128
+ description:
1129
+ neverReturns.jsxStatementLine === undefined
1130
+ ? 'The component never returns its tree — it renders a blank frame'
1131
+ : `The component never returns its tree: the JSX at line ${neverReturns.jsxStatementLine} sits at statement position, so it is built and thrown away and the frame renders blank`,
1132
+ fix: 'Wrap the tree in the return the boilerplate gave you: `return (` before the root element and `);` after it.',
1133
+ ...(neverReturns.jsxStatementLine === undefined ? {} : { line: neverReturns.jsxStatementLine }),
1134
+ });
1135
+ }
1136
+
985
1137
  // (Controller-View checks removed — generated components use a
986
1138
  // single-function pattern.)
987
1139
 
@@ -202,6 +202,19 @@ export function buildContractsContext(
202
202
  if (entry.nextStep) {
203
203
  line += `\n Next-step hint: agent intends to call \`${entry.nextStep}\``;
204
204
  }
205
+ // ggui#1108: `confirm` was the one declared action field the contract
206
+ // renderer dropped — every other author field (label, description,
207
+ // example, nextStep) reaches the model and this one did not. It is
208
+ // rendered as INFORMATION, not as a rule: the terminal-actions section
209
+ // deliberately does NOT cite it, because citing it measured ZERO —
210
+ // two wordings, n=4 each, `save` + `confirm: true` came back armed 0/4
211
+ // and 0/4. The field's own doc says "whether to show confirmation
212
+ // BEFORE triggering", which is gravity, not one-shot-ness, and the
213
+ // model reads it that way. Emitted only when true, so an absent flag
214
+ // leaves the prompt bytes identical to before this line existed.
215
+ if (entry.confirm === true) {
216
+ line += `\n Author flag: \`confirm\` — the author marked this action grave enough to confirm before it fires, so a second press is a mistake.`;
217
+ }
205
218
  return line;
206
219
  })
207
220
  .join('\n');
@@ -333,6 +333,64 @@ function runViewportSized(input: AxisCheckInput): EvalIssue[] {
333
333
  ];
334
334
  }
335
335
 
336
+ // ── universal.terminal_action_unguarded (ggui#1108, mitigation) ────────
337
+ // A control whose action the user means ONCE — submit, confirm, approve,
338
+ // schedule, reserve, book, pay, checkout, place order — that stays armed after
339
+ // it fires is one double-click away from doing it twice. The check is a WARN,
340
+ // deliberately: terminality lives in the request and the contract, not in a
341
+ // name, so a static rule can suspect it and must not refuse over it. It fires
342
+ // only when a terminal-sounding action or control exists AND the source guards
343
+ // nothing at all (no `disabled=`, no `aria-disabled`), which is the shape with
344
+ // no reading under which the control is safe.
345
+ /** The words that make an action read as meant-once. Matched as WORDS, never as substrings. */
346
+ const TERMINAL_WORDS = new Set([
347
+ "submit", "confirm", "approve", "schedule", "reserve", "book", "pay", "checkout", "purchase", "order",
348
+ ]);
349
+ /** In prose (a control's label) the same words, with real boundaries. */
350
+ const TERMINAL_LABEL_RX = /\b(submit|confirm|approve|schedule|reserve|book|pay|checkout|purchase|place\s+order)\b/i;
351
+ const GUARD_RX = /\b(disabled|aria-disabled)\s*=/;
352
+
353
+ /** `submitBooking` → ['submit','booking']; `place_order` → ['place','order']. A NAME is words, not a string to search. */
354
+ function nameWords(name: string): string[] {
355
+ return name
356
+ .split(/(?=[A-Z])|[_\-\s]+/)
357
+ .map((w) => w.toLowerCase())
358
+ .filter((w) => w.length > 0);
359
+ }
360
+
361
+ /** The terminal-sounding action names and control labels a source carries, deduped, in order. */
362
+ export function findUnguardedTerminalControls(sourceCode: string): string[] {
363
+ if (GUARD_RX.test(sourceCode)) return [];
364
+ const names = new Set<string>();
365
+ for (const m of sourceCode.matchAll(/useAction(?:<[^>]*>)?\(\s*['"`]([^'"`]+)['"`]/g)) {
366
+ const name = m[1] ?? "";
367
+ if (nameWords(name).some((w) => TERMINAL_WORDS.has(w))) names.add(name);
368
+ }
369
+ // A control's own words, taken as the plain text immediately before its close
370
+ // tag — attributes can carry `=>`, so a label is read from the text, never by
371
+ // trying to skip an attribute list with a regex.
372
+ for (const m of sourceCode.matchAll(/>([^<>{}]{1,60})<\/Button>/g)) {
373
+ const label = (m[1] ?? "").replace(/\s+/g, " ").trim();
374
+ if (label.length > 0 && TERMINAL_LABEL_RX.test(label)) names.add(label.slice(0, 40));
375
+ }
376
+ return [...names];
377
+ }
378
+
379
+ function runTerminalActionUnguarded(input: AxisCheckInput): EvalIssue[] {
380
+ if (input.compiledCode === null) return [];
381
+ const found = findUnguardedTerminalControls(input.sourceCode);
382
+ if (found.length === 0) return [];
383
+ const list = found.map((t) => `"${t}"`).join(", ");
384
+ return [
385
+ mkIssue(
386
+ "universal.terminal_action_unguarded",
387
+ `${list} reads as an action the user means once, and nothing in this component disables the control after it fires — a double-click sends it twice.`,
388
+ "Hold the fired state (`const [submitted, setSubmitted] = useState(false)`), set it in the handler, and pass `disabled={submitted}` to that control with a word that says so (\"Submitted\"). If the action IS meant to repeat — sending a message, adding an item — leave it armed and ignore this.",
389
+ "warn",
390
+ ),
391
+ ];
392
+ }
393
+
336
394
  export const UNIVERSAL_CHECKS: readonly AxisCheck[] = [
337
395
  {
338
396
  id: "universal.icon_name_known",
@@ -352,6 +410,12 @@ export const UNIVERSAL_CHECKS: readonly AxisCheck[] = [
352
410
  values: ALL_RENDER_VALUES,
353
411
  run: runViewportSized,
354
412
  },
413
+ {
414
+ id: "universal.terminal_action_unguarded",
415
+ axis: "render",
416
+ values: ALL_RENDER_VALUES,
417
+ run: runTerminalActionUnguarded,
418
+ },
355
419
  {
356
420
  id: "universal.accent_text_ink",
357
421
  axis: "render",