@ggui-ai/ui-gen 0.16.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 (105) hide show
  1. package/dist/adapters/generation-dispatch.d.ts +6 -3
  2. package/dist/adapters/generation-dispatch.js +5161 -4312
  3. package/dist/adapters/generation-dispatch.js.map +1 -1
  4. package/dist/adapters/index.js +8 -5
  5. package/dist/adapters/index.js.map +1 -1
  6. package/dist/advanced/index.d.ts +12 -8
  7. package/dist/advanced/index.js +4685 -3801
  8. package/dist/advanced/index.js.map +1 -1
  9. package/dist/blueprint-validator.js +8 -12
  10. package/dist/blueprint-validator.js.map +1 -1
  11. package/dist/boilerplate.d.ts +5 -2
  12. package/dist/boilerplate.js +120 -13
  13. package/dist/boilerplate.js.map +1 -1
  14. package/dist/check/index.d.ts +2 -2
  15. package/dist/check/index.js +72 -0
  16. package/dist/check/index.js.map +1 -1
  17. package/dist/compose.js +1 -1
  18. package/dist/compose.js.map +1 -1
  19. package/dist/{create-ui-generator-CCIeQaEe.d.ts → create-ui-generator-C8Ki9Wz1.d.ts} +18 -2
  20. package/dist/{design-mode-BHEE7flj.d.ts → design-mode-DLufhrNp.d.ts} +0 -6
  21. package/dist/{dispatch-BA5dJcg-.d.ts → dispatch-D2htzi9h.d.ts} +2 -2
  22. package/dist/evaluation/axis-checks/index.d.ts +4 -4
  23. package/dist/evaluation/axis-checks/index.js +347 -13
  24. package/dist/evaluation/axis-checks/index.js.map +1 -1
  25. package/dist/evaluation/axis-checks/registry.d.ts +2 -2
  26. package/dist/evaluation/axis-checks/registry.js +302 -1
  27. package/dist/evaluation/axis-checks/registry.js.map +1 -1
  28. package/dist/evaluation/index.d.ts +160 -8
  29. package/dist/evaluation/index.js +752 -81
  30. package/dist/evaluation/index.js.map +1 -1
  31. package/dist/fragments/index.js +1 -1
  32. package/dist/fragments/index.js.map +1 -1
  33. package/dist/{generate-8L_bc2iP.d.ts → generate-9IeNT4ZY.d.ts} +1 -1
  34. package/dist/harness/check/runtime-render/index.d.ts +3 -2
  35. package/dist/harness/check/runtime-render/index.js +8 -12
  36. package/dist/harness/check/runtime-render/index.js.map +1 -1
  37. package/dist/harness/index.d.ts +3 -2
  38. package/dist/harness/index.js +410 -255
  39. package/dist/harness/index.js.map +1 -1
  40. package/dist/harness/prompts.d.ts +2 -2
  41. package/dist/harness/prompts.js +7 -3
  42. package/dist/harness/prompts.js.map +1 -1
  43. package/dist/harness/result-types.d.ts +3 -3
  44. package/dist/harness/runtime.d.ts +13 -4
  45. package/dist/harness/runtime.js +131 -20
  46. package/dist/harness/runtime.js.map +1 -1
  47. package/dist/harness/types-public.d.ts +13 -2
  48. package/dist/harness/types-public.js +410 -255
  49. package/dist/harness/types-public.js.map +1 -1
  50. package/dist/harness/validator-trace-sink.d.ts +2 -2
  51. package/dist/index.d.ts +29 -7
  52. package/dist/index.js +4691 -3805
  53. package/dist/index.js.map +1 -1
  54. package/dist/patch.d.ts +3 -2
  55. package/dist/{prompts-k6m6reqe.d.ts → prompts-YU9piOgs.d.ts} +12 -46
  56. package/dist/styling-profile-CxfvV48g.d.ts +35 -0
  57. package/dist/tools/get-primitives-ts.d.ts +1 -1
  58. package/dist/tools/get-primitives-ts.js +1 -1
  59. package/dist/tools/get-primitives-ts.js.map +1 -1
  60. package/dist/{types-public-D24JLJgl.d.ts → types-public-DVSssdc-.d.ts} +39 -3
  61. package/dist/validation/index.d.ts +1 -1
  62. package/dist/validation/index.js +1 -1
  63. package/dist/validation/index.js.map +1 -1
  64. package/dist/workflows.d.ts +3 -2
  65. package/package.json +12 -12
  66. package/src/adapters/generation-dispatch.ts +6 -1
  67. package/src/boilerplate/free-design-prompt.ts +9 -0
  68. package/src/boilerplate/generate.ts +8 -1
  69. package/src/boilerplate/hard-sections.ts +45 -1
  70. package/src/boilerplate/styling-profile.ts +166 -0
  71. package/src/boilerplate/system-prompt.ts +22 -8
  72. package/src/boilerplate/templates/layouts/fullscreen-desktop.tsx.tmpl +5 -3
  73. package/src/boilerplate/templates/layouts/fullscreen-mobile.tsx.tmpl +3 -3
  74. package/src/boilerplate/templates/layouts/fullscreen-universal.tsx.tmpl +5 -3
  75. package/src/check/run-tier0.ts +154 -0
  76. package/src/contract-context.ts +28 -14
  77. package/src/create-harness.ts +4 -0
  78. package/src/create-ui-generator.ts +41 -2
  79. package/src/design-mode.ts +11 -1
  80. package/src/design-system-docs.ts +7 -4
  81. package/src/effort.ts +48 -0
  82. package/src/evaluation/axis-checks/checks/grid.ts +148 -0
  83. package/src/evaluation/axis-checks/checks/universal.ts +308 -0
  84. package/src/evaluation/axis-checks/dispatch.ts +78 -13
  85. package/src/evaluation/axis-checks/helpers.ts +12 -0
  86. package/src/evaluation/axis-checks/registry.ts +2 -0
  87. package/src/evaluation/design-identity.ts +96 -0
  88. package/src/evaluation/index.ts +17 -0
  89. package/src/evaluation/llm-evaluator.ts +13 -1
  90. package/src/evaluation/theme-css.ts +14 -0
  91. package/src/evaluation/types-public.ts +35 -1
  92. package/src/evaluation/visual-evaluator.ts +478 -45
  93. package/src/fragments/render.ts +1 -1
  94. package/src/harness/check/runtime-render/prepare-mockup.ts +18 -11
  95. package/src/harness/coding/init-session.ts +1 -0
  96. package/src/harness/coding/run-coding-turn.ts +6 -14
  97. package/src/harness/coding/run-eval-round.ts +31 -3
  98. package/src/harness/runtime.ts +10 -0
  99. package/src/harness/types-public.ts +11 -0
  100. package/src/index.ts +2 -0
  101. package/src/pin-fixtures.ts +98 -0
  102. package/src/run-check.ts +10 -10
  103. package/src/tools/get-primitives-ts.ts +1 -1
  104. package/src/tools.ts +2 -1
  105. package/src/validation/primitives.ts +1 -1
@@ -0,0 +1,148 @@
1
+ // packages/ui-gen/src/evaluation/axis-checks/checks/grid.ts
2
+ //
3
+ // Checks gated on `render=grid`.
4
+ //
5
+ // ── grid.board_columns_side_by_side (ggui#1046) ─────────────────────────
6
+ // A kanban classifies as `render=grid` (the classifier's GRID_RX carries
7
+ // `kanban`), and three of four constrained mints on the candidate-15 image
8
+ // composed the board's columns as a vertical <Stack> of sections — no <Grid>
9
+ // anywhere — then read below the judge's bar; the tier-2 evaluator saw the
10
+ // stacking only as a `warn`, which never costs a fix turn. This check reads
11
+ // the source the way the fix turn must: find the JSX map over the board's
12
+ // columns array and name the layout primitive that ENCLOSES it. <Grid> or
13
+ // <Row> = side by side; <Stack> / <Card> / <Box> / a bare block / nothing =
14
+ // stacked. Stands down in `free` design mode, where the primitive is optional.
15
+
16
+ import type { EvalIssue } from "../../types-public.js";
17
+ import type { AxisCheck, AxisCheckInput } from "../types.js";
18
+ import { axisCheckTraceEnabled, mkIssue } from "../helpers.js";
19
+
20
+ /** Prop names a board's column array goes by. */
21
+ const BOARD_PROP_RX = /^(columns?|lanes?|stages?|lists?|buckets?|swimlanes?)$/i;
22
+ const BOARD_PROMPT_RX = /\b(kanban|board)\b/i;
23
+ /** The names a board's column array goes by, for the prompt and source paths. */
24
+ const BOARD_NAMES = ["columns", "lanes", "stages", "lists", "buckets", "swimlanes"] as const;
25
+ /** Layout primitives whose nesting decides side-by-side vs stacked. */
26
+ const LAYOUT_TAG_RX = /<(\/?)(Grid|Row|Stack|Card|Box|Container|ScrollArea|div|section)\b([^<>]*?)>/g;
27
+ const SIDE_BY_SIDE = new Set(["Grid", "Row"]);
28
+
29
+ interface PropShapeLike {
30
+ type?: string;
31
+ schema?: { type?: string };
32
+ }
33
+
34
+ /** The board prop names the contract declares as arrays. */
35
+ export function boardPropNames(contract: AxisCheckInput["contract"]): string[] {
36
+ const propsField = contract?.propsSpec as { properties?: Record<string, PropShapeLike> } | undefined;
37
+ const properties = propsField?.properties ?? {};
38
+ return Object.entries(properties)
39
+ .filter(([name, p]) => BOARD_PROP_RX.test(name) && (p?.type === "array" || p?.schema?.type === "array"))
40
+ .map(([name]) => name);
41
+ }
42
+
43
+ /**
44
+ * Every JSX map over `name`, by index — an expression-body arrow (`name.map((col) => (<…`)
45
+ * or a block-body one (`name.map((col) => { … return (<…`); the serving deployment's candidate-18 kanban
46
+ * mints composed two of three stacked boards through the block form (ggui#1046), which the
47
+ * first reader never matched.
48
+ */
49
+ function jsxMapPositions(sourceCode: string, name: string): number[] {
50
+ const rx = new RegExp(
51
+ `\\b${name}(?:\\?\\.)?\\.map\\(\\s*\\(?\\s*\\w+(?:\\s*,\\s*\\w+)?\\s*\\)?\\s*=>\\s*(?:\\(?\\s*<|\\{[^{}]{0,600}?\\breturn\\s*\\(?\\s*<)`,
52
+ "g",
53
+ );
54
+ return [...sourceCode.matchAll(rx)].map((m) => m.index ?? 0);
55
+ }
56
+
57
+ /** The layout primitive enclosing `pos`, walked as a tag stack over the source before it. */
58
+ export function enclosingLayoutTag(sourceCode: string, pos: number): string | undefined {
59
+ const stack: string[] = [];
60
+ for (const m of sourceCode.slice(0, pos).matchAll(LAYOUT_TAG_RX)) {
61
+ const closing = m[1] === "/";
62
+ const name = m[2] ?? "";
63
+ const attrs = m[3] ?? "";
64
+ if (closing) {
65
+ if (stack[stack.length - 1] === name) stack.pop();
66
+ continue;
67
+ }
68
+ if (attrs.trimEnd().endsWith("/")) continue; // self-closing
69
+ stack.push(name);
70
+ }
71
+ return stack[stack.length - 1];
72
+ }
73
+
74
+ /** What the check read (ggui#1046): the board names it walked and, per name, every JSX map's enclosing layout tag. */
75
+ export interface BoardRead {
76
+ readonly declared: readonly string[];
77
+ readonly names: readonly string[];
78
+ readonly perName: ReadonlyArray<{ readonly name: string; readonly maps: number; readonly enclosing: readonly string[] }>;
79
+ }
80
+
81
+ /** The board check's own trace line (ggui#1046). */
82
+ export interface BoardColumnsTrace extends BoardRead {
83
+ readonly designMode: string;
84
+ readonly standDown: boolean;
85
+ readonly result: "pass" | "fail" | "stand-down";
86
+ }
87
+
88
+ export function describeBoardRead(input: AxisCheckInput): BoardRead {
89
+ // Which array the board's columns are: the contract names it; else the prompt says
90
+ // "board" / "kanban"; else the SOURCE itself does — a JSX map over a board-shaped name
91
+ // (ggui#1046: on candidate 20 the check flagged every stacked source locally and none on
92
+ // the serving deployment, where the contract and prompt reach it in another shape; the
93
+ // source is the one input every path hands over verbatim).
94
+ const declared = boardPropNames(input.contract);
95
+ const names = declared.length > 0
96
+ ? declared
97
+ : BOARD_PROMPT_RX.test(input.originalPrompt)
98
+ ? [...BOARD_NAMES]
99
+ : BOARD_NAMES.filter((n) => jsxMapPositions(input.sourceCode, n).length > 0);
100
+ const perName = names.map((name) => {
101
+ const positions = jsxMapPositions(input.sourceCode, name);
102
+ return { name, maps: positions.length, enclosing: positions.map((pos) => enclosingLayoutTag(input.sourceCode, pos) ?? "nothing") };
103
+ });
104
+ return { declared, names, perName };
105
+ }
106
+
107
+ /** Board maps composed under something other than <Grid> / <Row>: `[propName, enclosing tag or "nothing"]`. */
108
+ export function findStackedBoardColumns(input: AxisCheckInput): Array<readonly [string, string]> {
109
+ const stacked: Array<readonly [string, string]> = [];
110
+ for (const { name, enclosing } of describeBoardRead(input).perName) {
111
+ for (const tag of enclosing) if (!SIDE_BY_SIDE.has(tag)) stacked.push([name, tag]);
112
+ }
113
+ return stacked;
114
+ }
115
+
116
+ function runBoardColumnsSideBySide(input: AxisCheckInput): EvalIssue[] {
117
+ if (input.compiledCode === null) return [];
118
+ const standDown = input.designMode === "free";
119
+ const stacked = standDown ? [] : findStackedBoardColumns(input);
120
+ if (axisCheckTraceEnabled()) {
121
+ // The check's own line beside the dispatcher's (ggui#1046): what it read, PASS and stand-down included.
122
+ const line: BoardColumnsTrace = {
123
+ ...describeBoardRead(input),
124
+ designMode: input.designMode ?? "constrained",
125
+ standDown,
126
+ result: standDown ? "stand-down" : stacked.length > 0 ? "fail" : "pass",
127
+ };
128
+ console.log(JSON.stringify({ boardColumnsTrace: line }));
129
+ }
130
+ if (stacked.length === 0) return [];
131
+ const [name, tag] = stacked[0]!;
132
+ return [
133
+ mkIssue(
134
+ "grid.board_columns_side_by_side",
135
+ `The board's \`${name}\` are composed under <${tag}> — a vertical stack of column sections; at md and wider the columns must sit side by side.`,
136
+ `Wrap the ${name} map in <Grid columns={{ base: 1, md: ${name}.length }} gap="md"> (each column its own <Stack> of cards); never a <Stack> of column sections.`,
137
+ ),
138
+ ];
139
+ }
140
+
141
+ export const GRID_CHECKS: readonly AxisCheck[] = [
142
+ {
143
+ id: "grid.board_columns_side_by_side",
144
+ axis: "render",
145
+ values: ["grid"],
146
+ run: runBoardColumnsSideBySide,
147
+ },
148
+ ];
@@ -4,6 +4,7 @@
4
4
  // component. Dispatched via a "universal" gate that matches every render
5
5
  // value.
6
6
 
7
+ import { LUCIDE_ICON_NAMES } from "@ggui-ai/design";
7
8
  import type { EvalIssue } from "../../types-public.js";
8
9
  import type { AxisCheck, AxisCheckInput } from "../types.js";
9
10
  import {
@@ -119,7 +120,314 @@ function runPropSeedNoResync(input: AxisCheckInput): EvalIssue[] {
119
120
  return issues;
120
121
  }
121
122
 
123
+ // ── universal.icon_name_known (ggui#1015) ────────────────────────────
124
+ // The Icon primitive renders ONLY its curated Lucide subset; an unknown
125
+ // name renders an empty box. The model reaches for names from the full
126
+ // Lucide set ("sparkles", "arrow-up-right" on the hello frames), so the
127
+ // check flags every string-literal `<Icon name>` outside the subset —
128
+ // the fix turn has `get_available_icons`. Dynamic names (`name={x}`)
129
+ // and emoji/unicode (rendered as text) are outside the check's scope.
130
+
131
+ /** Icon.tsx's resolver rule: exact key, else dashes/underscores stripped + lowercased. */
132
+ const KNOWN_ICON_KEYS: ReadonlySet<string> = new Set(
133
+ LUCIDE_ICON_NAMES.map((n) => n.replace(/[-_]/g, "").toLowerCase()),
134
+ );
135
+
136
+ export function isKnownIconName(name: string): boolean {
137
+ return KNOWN_ICON_KEYS.has(name.replace(/[-_]/g, "").toLowerCase());
138
+ }
139
+
140
+ const ICON_NAME_LITERAL_RX =
141
+ /<Icon\b[^>]*?\bname\s*=\s*(?:"([^"]*)"|'([^']*)'|\{\s*"([^"]*)"\s*\}|\{\s*'([^']*)'\s*\})/g;
142
+
143
+ export function findUnknownIconNames(sourceCode: string): string[] {
144
+ const unknown = new Set<string>();
145
+ for (const m of sourceCode.matchAll(ICON_NAME_LITERAL_RX)) {
146
+ const name = m[1] ?? m[2] ?? m[3] ?? m[4] ?? "";
147
+ if (name.length === 0) continue;
148
+ if (/[^\x20-\x7e]/.test(name)) continue; // emoji / unicode passthrough renders as text
149
+ if (!isKnownIconName(name)) unknown.add(name);
150
+ }
151
+ return [...unknown];
152
+ }
153
+
154
+ function runIconNameKnown(input: AxisCheckInput): EvalIssue[] {
155
+ if (input.compiledCode === null) return [];
156
+ const unknown = findUnknownIconNames(input.sourceCode);
157
+ if (unknown.length === 0) return [];
158
+ const list = unknown.map((n) => `"${n}"`).join(", ");
159
+ return [
160
+ mkIssue(
161
+ "universal.icon_name_known",
162
+ `<Icon name=…> uses ${unknown.length} name(s) outside the design system's Lucide subset: ${list} — an unknown name renders an empty box.`,
163
+ "Call get_available_icons and pick a listed name (kebab-case), or use an emoji directly.",
164
+ ),
165
+ ];
166
+ }
167
+
168
+ // ── universal.accent_text_ink (ggui#1039) ────────────────────────────
169
+ // The brand ladder's mid stops (300–600) are FILL colours. On a
170
+ // low-saturation brand the 600 stop is mid-grey on the surface, and every
171
+ // one of four brand-directed hello frames painted its eyebrow and inline
172
+ // arrows with it — two of them at 3.5:1 and 2.91:1 on the ground. Accent
173
+ // TEXT is the theme's readable ink `--ggui-color-link` (derived per mode to
174
+ // clear 4.5:1, ggui#1035) or `<Text tone="emphasized">`; `link` resolves to
175
+ // the very same 600 wherever 600 already reads, so the fix never regresses a
176
+ // saturated brand. Out of scope by design: tints as text (50–200 — the
177
+ // on-fill idiom, `primary-50` on a `primary-600` bubble) and the deep stops
178
+ // (700–900 — text on a light primary tint), both documented uses. Matches the
179
+ // `color` property in every spelling the model writes — JSX prop, style
180
+ // object (source and compiled), CSS declaration — and never a compound
181
+ // property (`backgroundColor`, `border-color`, `accent-color`).
182
+
183
+ const LADDER_STOP_AS_TEXT_RX =
184
+ /(?<![\w-])color\s*(?:=\s*\{?\s*|:\s*)["'`]?\s*var\(--ggui-color-primary-(\d{2,3})\s*[,)]/g;
185
+ const TEXT_INK_STOP_MIN = 300;
186
+ const TEXT_INK_STOP_MAX = 600;
187
+
188
+ /** Distinct `primary-<stop>` tokens (300–600) the source paints as a text `color`, in source order. */
189
+ export function findLadderStopsAsText(sourceCode: string): string[] {
190
+ const found = new Set<string>();
191
+ for (const m of sourceCode.matchAll(LADDER_STOP_AS_TEXT_RX)) {
192
+ const digits = m[1] ?? "";
193
+ const stop = Number(digits);
194
+ if (stop >= TEXT_INK_STOP_MIN && stop <= TEXT_INK_STOP_MAX) found.add(`primary-${digits}`);
195
+ }
196
+ return [...found];
197
+ }
198
+
199
+ function runAccentTextInk(input: AxisCheckInput): EvalIssue[] {
200
+ if (input.compiledCode === null) return [];
201
+ const stops = findLadderStopsAsText(input.sourceCode);
202
+ if (stops.length === 0) return [];
203
+ const list = stops.map((s) => `\`${s}\``).join(", ");
204
+ return [
205
+ mkIssue(
206
+ "universal.accent_text_ink",
207
+ `Text is painted with a brand-ladder fill stop as its color (${list}) — on a low-saturation brand these stops are mid-grey on the surface and the text fails readability.`,
208
+ 'Accent text (eyebrows, taglines, links, labels, inline arrows) takes var(--ggui-color-link) or <Text tone="emphasized">; body text takes var(--ggui-color-onContainer); keep primary-* stops on fills and borders.',
209
+ ),
210
+ ];
211
+ }
212
+
213
+ // ── universal.scoped_surface_owns_ground (ggui#1047) ─────────────────
214
+ // A SCOPED surface — `surface="inverted"` or `"hero"` on Card / Box — owns its
215
+ // ground: the primitive paints it, the scope rule swaps every ink beneath it.
216
+ // The served Mosaic hello repainted an inverted card's ground with the page's
217
+ // (`style={{ background: 'var(--ggui-color-ground)' }}`) and its hero text
218
+ // read 1:1 — the swapped inks over a ground the scope did not own. The design
219
+ // package now strips that background at render; this check makes the fix turn
220
+ // remove it at the source, so the author's intent never silently vanishes.
221
+ // Stands down in `free` design mode (the primitive is optional there).
222
+
223
+ const SCOPED_SURFACE_TAG_RX = /<(Card|Box)\b([^<>]*?)surface\s*=\s*["'](inverted|hero)["']([^<>]*?)>/g;
224
+ const STYLE_BACKGROUND_RX = /\bstyle\s*=\s*\{\{[^}]*\b(background|backgroundColor|backgroundImage)\s*:/;
225
+
226
+ /** `[tag, surface]` for every scoped Card / Box whose own `style` sets a background. */
227
+ export function findScopedSurfaceBackgrounds(sourceCode: string): Array<readonly [string, string]> {
228
+ const hits: Array<readonly [string, string]> = [];
229
+ for (const m of sourceCode.matchAll(SCOPED_SURFACE_TAG_RX)) {
230
+ const attrs = `${m[2] ?? ""} ${m[4] ?? ""}`;
231
+ if (STYLE_BACKGROUND_RX.test(attrs)) hits.push([m[1] ?? "", m[3] ?? ""]);
232
+ }
233
+ return hits;
234
+ }
235
+
236
+ function runScopedSurfaceOwnsGround(input: AxisCheckInput): EvalIssue[] {
237
+ if (input.compiledCode === null) return [];
238
+ if (input.designMode === "free") return [];
239
+ const hits = findScopedSurfaceBackgrounds(input.sourceCode);
240
+ if (hits.length === 0) return [];
241
+ const [tag, surface] = hits[0]!;
242
+ return [
243
+ mkIssue(
244
+ "universal.scoped_surface_owns_ground",
245
+ `<${tag} surface="${surface}"> sets its own background in style — a scoped surface owns its ground; repainting it puts the scope's swapped inks over the wrong ground (ink on ink).`,
246
+ `Remove the background from that ${tag}'s style — surface="${surface}" paints the ground; pick another surface if a different ground is wanted.`,
247
+ ),
248
+ ];
249
+ }
250
+
251
+ // ── universal.caps_label_invented (ggui#1075 Track A) ─────────────────
252
+ // A caps label (`<Text caps>` — the eyebrow / kicker / overline treatment)
253
+ // whose entire content is a string literal is copy the model INVENTED: the
254
+ // contract carries no such member, so nothing the agent sends can change
255
+ // it, and every served card wears it ("LET'S BEGIN", "WELCOME" — 59 of 63
256
+ // generated sources in one sample, none bound to a prop). Copy comes from the
257
+ // props; a caps label exists only when a prop supplies its text. A label
258
+ // whose content is an expression (`{props.section}`, `{label ?? …}`) is
259
+ // bound and passes; a `<Text caps>` with no text children is not a label.
260
+ const CAPS_TEXT_RX = /<Text\b(?=[^>]*\bcaps\b)[^>]*>([\s\S]*?)<\/Text>/g;
261
+
262
+ /** Every `<Text caps>` whose content is nothing but literal text: `[literal]`. */
263
+ export function findInventedCapsLabels(sourceCode: string): string[] {
264
+ const out: string[] = [];
265
+ for (const m of sourceCode.matchAll(CAPS_TEXT_RX)) {
266
+ const inner = (m[1] ?? "").trim();
267
+ if (inner.length === 0) continue;
268
+ if (inner.includes("{")) continue; // bound to an expression — the props speak
269
+ out.push(inner.replace(/\s+/g, " ").slice(0, 60));
270
+ }
271
+ return out;
272
+ }
273
+
274
+ function runCapsLabelInvented(input: AxisCheckInput): EvalIssue[] {
275
+ if (input.compiledCode === null) return [];
276
+ const invented = findInventedCapsLabels(input.sourceCode);
277
+ if (invented.length === 0) return [];
278
+ const list = invented.map((t) => `"${t}"`).join(", ");
279
+ return [
280
+ mkIssue(
281
+ "universal.caps_label_invented",
282
+ `${invented.length} caps label(s) carry invented copy the props do not supply: ${list} — an eyebrow, kicker or overline the agent cannot change is chatter on every render.`,
283
+ "Remove the label, or bind its text to a prop (`<Text caps>{props.section}</Text>`); copy comes from the contract, never from the component.",
284
+ ),
285
+ ];
286
+ }
287
+
288
+ // ── universal.viewport_sized (ggui#1075 Track A/B, receipted on #1096) ─
289
+ // An element sized to the viewport — `100vh` / `100dvh` / `100svh` / `100lvh`
290
+ // (bare or inside `calc()`) as a `height` / `min-height`, or the `h-screen` /
291
+ // `min-h-screen` utility classes. The frame owns the height: a content-sized
292
+ // frame is measured FROM the content, so a viewport-sized root can never
293
+ // shrink and grows the frame on every re-measure; under fullscreen the frame
294
+ // already stretches the root to its height. 20 of 48 minted cells in one
295
+ // sample carried `minHeight: "100vh"` on their root — one an inline chat
296
+ // card told "compact". A `max-height` cap on a scroll region is not sizing
297
+ // and passes; so do `height: 100%`, `flex: 1` and pixel heights.
298
+ // The unit, with a `calc(…)` wrapper closed when present — so the quoted
299
+ // snippet reads whole (`height: "calc(100dvh - 64px)"`).
300
+ const VIEWPORT_UNIT = String.raw`(?:calc\(\s*)?100(?:vh|dvh|svh|lvh)\b(?:[^)"'\x60;\n]*\))?`;
301
+ const VIEWPORT_STYLE_OBJECT_RX = new RegExp(
302
+ String.raw`\b(?:minHeight|height|minBlockSize|blockSize)\s*:\s*["'\x60]\s*${VIEWPORT_UNIT}["'\x60]?`,
303
+ "g",
304
+ );
305
+ const VIEWPORT_CSS_RX = new RegExp(
306
+ String.raw`(?<![\w-])(?:min-)?(?:height|block-size)\s*:\s*${VIEWPORT_UNIT}`,
307
+ "g",
308
+ );
309
+ const VIEWPORT_CLASS_RX = /(?<![\w-])(?:min-)?h-(?:screen|dvh|svh|lvh)\b/g;
310
+
311
+ /** Every viewport-sized height in the source, as written (trimmed, ≤ 60 chars), in order. */
312
+ export function findViewportSizing(sourceCode: string): string[] {
313
+ const hits: Array<{ at: number; text: string }> = [];
314
+ for (const rx of [VIEWPORT_STYLE_OBJECT_RX, VIEWPORT_CSS_RX, VIEWPORT_CLASS_RX]) {
315
+ for (const m of sourceCode.matchAll(rx)) {
316
+ hits.push({ at: m.index ?? 0, text: m[0].replace(/\s+/g, " ").trim().slice(0, 60) });
317
+ }
318
+ }
319
+ return hits.sort((a, b) => a.at - b.at).map((h) => h.text);
320
+ }
321
+
322
+ function runViewportSized(input: AxisCheckInput): EvalIssue[] {
323
+ if (input.compiledCode === null) return [];
324
+ const sized = findViewportSizing(input.sourceCode);
325
+ if (sized.length === 0) return [];
326
+ const list = sized.map((t) => "`" + t + "`").join(", ");
327
+ return [
328
+ mkIssue(
329
+ "universal.viewport_sized",
330
+ `${sized.length} element(s) are sized to the viewport: ${list} — the frame owns the height; a content-sized frame is measured from the content, so a viewport-sized root never shrinks and grows the frame on every re-measure.`,
331
+ "Remove the viewport height — size to content, never to the viewport. Under fullscreen the frame stretches your root already (centre inside it with `display: flex; flex-direction: column; justify-content: center`); to fill a parent that has a height use `height: 100%` or `flex: 1`.",
332
+ ),
333
+ ];
334
+ }
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
+
122
394
  export const UNIVERSAL_CHECKS: readonly AxisCheck[] = [
395
+ {
396
+ id: "universal.icon_name_known",
397
+ axis: "render",
398
+ values: ALL_RENDER_VALUES,
399
+ run: runIconNameKnown,
400
+ },
401
+ {
402
+ id: "universal.caps_label_invented",
403
+ axis: "render",
404
+ values: ALL_RENDER_VALUES,
405
+ run: runCapsLabelInvented,
406
+ },
407
+ {
408
+ id: "universal.viewport_sized",
409
+ axis: "render",
410
+ values: ALL_RENDER_VALUES,
411
+ run: runViewportSized,
412
+ },
413
+ {
414
+ id: "universal.terminal_action_unguarded",
415
+ axis: "render",
416
+ values: ALL_RENDER_VALUES,
417
+ run: runTerminalActionUnguarded,
418
+ },
419
+ {
420
+ id: "universal.accent_text_ink",
421
+ axis: "render",
422
+ values: ALL_RENDER_VALUES,
423
+ run: runAccentTextInk,
424
+ },
425
+ {
426
+ id: "universal.scoped_surface_owns_ground",
427
+ axis: "render",
428
+ values: ALL_RENDER_VALUES,
429
+ run: runScopedSurfaceOwnsGround,
430
+ },
123
431
  {
124
432
  id: "universal.prop_coverage",
125
433
  axis: "render",
@@ -6,7 +6,9 @@ import type { DataContract } from "@ggui-ai/protocol";
6
6
  import type { Classification } from "../../classifier/index.js";
7
7
  import type { EvalIssue } from "../types-public.js";
8
8
  import { REGISTRY } from "./registry.js";
9
- import { matches, type AxisCheckInput } from "./types.js";
9
+ import { matches, type AxisCheck, type AxisCheckInput } from "./types.js";
10
+ import { axisCheckTraceEnabled } from "./helpers.js";
11
+ import { createHash } from "node:crypto";
10
12
  import type { DesignMode } from "../../design-mode.js";
11
13
 
12
14
  export interface RunAxisChecksInput {
@@ -22,25 +24,88 @@ export function runAxisChecks(
22
24
  classification: Classification,
23
25
  input: RunAxisChecksInput,
24
26
  ): EvalIssue[] {
25
- if (input.compiledCode === null) return [];
26
-
27
- const axisInput: AxisCheckInput = {
27
+ const facts: AxisTraceFacts = {
28
28
  sourceCode: input.sourceCode,
29
- compiledCode: input.compiledCode,
30
29
  ...(input.contract !== undefined ? { contract: input.contract } : {}),
31
30
  originalPrompt: input.originalPrompt,
32
31
  classification,
33
32
  ...(input.designMode !== undefined ? { designMode: input.designMode } : {}),
34
33
  };
34
+ if (input.compiledCode === null) {
35
+ traceAxisChecksSkipped(facts, "compiledCode null — no check ran");
36
+ return [];
37
+ }
38
+ const axisInput: AxisCheckInput = { ...facts, compiledCode: input.compiledCode };
39
+ return runGatedAxisChecks(
40
+ REGISTRY.filter((check) => matches(classification.vector, check)),
41
+ axisInput,
42
+ ).issues;
43
+ }
44
+
45
+ export interface AxisRunResult {
46
+ readonly issues: EvalIssue[];
47
+ /** Check ids run, in registry order, each once. */
48
+ readonly firedIds: string[];
49
+ }
35
50
 
51
+ /**
52
+ * Run a PRE-GATED list of checks once each — the one runner the served
53
+ * loop (`run-check.ts`, over the harness's pre-filtered `axisChecks`) and
54
+ * `runAxisChecks` (gate + run) share, so the ggui#1046 trace line prints on
55
+ * both paths.
56
+ */
57
+ export function runGatedAxisChecks(checks: readonly AxisCheck[], input: AxisCheckInput): AxisRunResult {
36
58
  const issues: EvalIssue[] = [];
37
- const firedIds = new Set<string>();
38
- for (const check of REGISTRY) {
39
- if (!matches(classification.vector, check)) continue;
40
- // Dedup by id — a check may be registered under multiple gates.
41
- if (firedIds.has(check.id)) continue;
42
- firedIds.add(check.id);
43
- issues.push(...check.run(axisInput));
59
+ const firedIds: string[] = [];
60
+ const seen = new Set<string>();
61
+ for (const check of checks) {
62
+ if (seen.has(check.id)) continue;
63
+ seen.add(check.id);
64
+ firedIds.push(check.id);
65
+ issues.push(...check.run(input));
66
+ }
67
+ if (axisCheckTraceEnabled()) {
68
+ const line: AxisCheckTrace = {
69
+ ...traceFacts(input),
70
+ matched: [...firedIds],
71
+ issues: issues.map((i) => ({ id: i.subcategory ?? i.category, result: i.result })),
72
+ };
73
+ console.log(JSON.stringify({ axisCheckTrace: line }));
44
74
  }
45
- return issues;
75
+ return { issues, firedIds };
76
+ }
77
+
78
+ /** The dispatcher's line for a round where no check could run (a null compile), flag-gated like the rest. */
79
+ export function traceAxisChecksSkipped(facts: AxisTraceFacts, note: string): void {
80
+ if (!axisCheckTraceEnabled()) return;
81
+ const line: AxisCheckTrace = { ...traceFacts(facts), matched: [], issues: [], note };
82
+ console.log(JSON.stringify({ axisCheckTrace: line }));
83
+ }
84
+
85
+ /** The input facts a round's verdict is built from — `AxisCheckInput` minus the compile. */
86
+ export type AxisTraceFacts = Omit<AxisCheckInput, "compiledCode">;
87
+
88
+ /** One trace line per round (ggui#1046): the input facts a verdict was built from, the gates that matched, the issues that came out. */
89
+ export interface AxisCheckTrace {
90
+ readonly sourceSha256: string;
91
+ readonly originalPrompt: string;
92
+ readonly designMode: string;
93
+ readonly classification: Classification["vector"];
94
+ readonly propsSpecKeys: readonly string[];
95
+ readonly propsSpecPropertyKeys: readonly string[];
96
+ readonly matched: readonly string[];
97
+ readonly issues: ReadonlyArray<{ readonly id: string; readonly result: EvalIssue["result"] }>;
98
+ readonly note?: string;
99
+ }
100
+
101
+ function traceFacts(input: AxisTraceFacts): Omit<AxisCheckTrace, "matched" | "issues"> {
102
+ const propsSpec = input.contract?.propsSpec;
103
+ return {
104
+ sourceSha256: createHash("sha256").update(input.sourceCode, "utf8").digest("hex"),
105
+ originalPrompt: input.originalPrompt,
106
+ designMode: input.designMode ?? "constrained",
107
+ classification: input.classification.vector,
108
+ propsSpecKeys: Object.keys(propsSpec ?? {}),
109
+ propsSpecPropertyKeys: Object.keys(propsSpec?.properties ?? {}),
110
+ };
46
111
  }
@@ -300,3 +300,15 @@ export function mkIssue(
300
300
  export function cap(s: string): string {
301
301
  return s.charAt(0).toUpperCase() + s.slice(1);
302
302
  }
303
+
304
+ /**
305
+ * Trace flag (ggui#1046): `GGUI_AXIS_CHECK_TRACE=1` makes the dispatcher and the checks
306
+ * that opt in print one JSON line per evaluation round with the facts their verdict was
307
+ * built from — what a reader of a served cell cannot recover from the round's result
308
+ * alone (the source the checks saw, the prompt, the design mode, the classification,
309
+ * the contract's prop keys, which gates matched, which issues came out). Off by default;
310
+ * a diagnostic channel, never part of the result.
311
+ */
312
+ export function axisCheckTraceEnabled(): boolean {
313
+ return process.env["GGUI_AXIS_CHECK_TRACE"] === "1";
314
+ }
@@ -11,6 +11,7 @@ import { REALTIME_CHECKS } from "./checks/realtime.js";
11
11
  import { WRITES_CHECKS } from "./checks/writes.js";
12
12
  import { STATE_PAYLOAD_CHECKS } from "./checks/state-payload.js";
13
13
  import { TOOLING_CHECKS } from "./checks/tooling.js";
14
+ import { GRID_CHECKS } from "./checks/grid.js";
14
15
  import { EXTRA_CHECKS } from "./extras.js";
15
16
 
16
17
  export const REGISTRY: readonly AxisCheck[] = [
@@ -21,5 +22,6 @@ export const REGISTRY: readonly AxisCheck[] = [
21
22
  ...WRITES_CHECKS,
22
23
  ...STATE_PAYLOAD_CHECKS,
23
24
  ...TOOLING_CHECKS,
25
+ ...GRID_CHECKS,
24
26
  ...EXTRA_CHECKS,
25
27
  ];