@ggui-ai/ui-gen 0.11.0 → 0.13.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 (32) hide show
  1. package/dist/adapters/generation-dispatch.js +180 -18
  2. package/dist/adapters/generation-dispatch.js.map +1 -1
  3. package/dist/advanced/index.d.ts +1 -1
  4. package/dist/advanced/index.js +180 -18
  5. package/dist/advanced/index.js.map +1 -1
  6. package/dist/blueprint-validator.js +146 -0
  7. package/dist/blueprint-validator.js.map +1 -1
  8. package/dist/boilerplate.js +7 -7
  9. package/dist/boilerplate.js.map +1 -1
  10. package/dist/check/index.js +27 -11
  11. package/dist/check/index.js.map +1 -1
  12. package/dist/harness/check/runtime-render/index.d.ts +1 -1
  13. package/dist/harness/check/runtime-render/index.js +146 -0
  14. package/dist/harness/check/runtime-render/index.js.map +1 -1
  15. package/dist/harness/check/runtime-render/render-check-worker.js +126 -0
  16. package/dist/harness/check/runtime-render/render-check-worker.js.map +1 -1
  17. package/dist/harness/index.js +7 -7
  18. package/dist/harness/index.js.map +1 -1
  19. package/dist/harness/runtime.js +7 -7
  20. package/dist/harness/runtime.js.map +1 -1
  21. package/dist/harness/types-public.js +7 -7
  22. package/dist/harness/types-public.js.map +1 -1
  23. package/dist/index.js +180 -18
  24. package/dist/index.js.map +1 -1
  25. package/dist/provider-adapter-contract.js +1161 -387
  26. package/dist/provider-adapter-contract.js.map +1 -1
  27. package/dist/{render-check-BZoIE8-u.d.ts → render-check-WfOVO9kU.d.ts} +1 -1
  28. package/package.json +13 -13
  29. package/src/boilerplate/system-prompt.ts +7 -7
  30. package/src/check/run-tier0.ts +35 -16
  31. package/src/harness/check/runtime-render/adapter.ts +28 -0
  32. package/src/harness/check/runtime-render/render-check.ts +224 -1
@@ -10,7 +10,7 @@ import { JsonObject, DataContract } from '@ggui-ai/protocol';
10
10
  */
11
11
  type CheckOutcome = "verified" | "failed" | "unverified" | "skipped";
12
12
  interface RenderCheckIssue {
13
- readonly check: "render-no-throw" | "action-wiring" | "prop-coverage" | "stream-rerender" | "optional-props-omitted";
13
+ readonly check: "render-no-throw" | "action-wiring" | "selection-identity" | "prop-coverage" | "prop-sensitivity" | "stream-rerender" | "optional-props-omitted";
14
14
  readonly outcome: CheckOutcome;
15
15
  readonly subject?: string;
16
16
  readonly reason: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ggui-ai/ui-gen",
3
- "version": "0.11.0",
3
+ "version": "0.13.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/project-config": "0.11.0",
192
- "@ggui-ai/mcp-server-core": "0.11.0",
193
- "@ggui-ai/protocol": "0.11.0"
191
+ "@ggui-ai/mcp-server-core": "0.13.0",
192
+ "@ggui-ai/protocol": "0.13.0",
193
+ "@ggui-ai/project-config": "0.13.0"
194
194
  },
195
195
  "peerDependenciesMeta": {
196
196
  "@google/adk": {
@@ -229,11 +229,11 @@
229
229
  "openai": "^6.29.0",
230
230
  "typescript": "^5.0.0",
231
231
  "zod": "^4.3.6",
232
- "@ggui-ai/gadgets": "0.11.0",
233
- "@ggui-ai/sandbox": "0.11.0",
234
- "@ggui-ai/ui-visual-tester": "0.11.0",
235
- "@ggui-ai/wire": "0.11.0",
236
- "@ggui-ai/design": "0.11.0"
232
+ "@ggui-ai/design": "0.13.0",
233
+ "@ggui-ai/gadgets": "0.13.0",
234
+ "@ggui-ai/ui-visual-tester": "0.13.0",
235
+ "@ggui-ai/sandbox": "0.13.0",
236
+ "@ggui-ai/wire": "0.13.0"
237
237
  },
238
238
  "devDependencies": {
239
239
  "@google/adk": "^0.5.0",
@@ -248,16 +248,16 @@
248
248
  "react-dom": "^19.0.0",
249
249
  "tsup": "^8.5.0",
250
250
  "vitest": "^3.2.6",
251
- "@ggui-ai/mcp-server-core": "0.11.0",
252
- "@ggui-ai/project-config": "0.11.0",
253
- "@ggui-ai/protocol": "0.11.0"
251
+ "@ggui-ai/mcp-server-core": "0.13.0",
252
+ "@ggui-ai/project-config": "0.13.0",
253
+ "@ggui-ai/protocol": "0.13.0"
254
254
  },
255
255
  "engines": {
256
256
  "node": ">=20.0.0"
257
257
  },
258
258
  "author": "ggui contributors <hello@ggui.ai>",
259
259
  "scripts": {
260
- "build": "NODE_OPTIONS=--max-old-space-size=8192 tsup",
260
+ "build": "NODE_OPTIONS=--max-old-space-size=8192 TSUP_OUT_DIR=dist.staging tsup && node ../scripts/atomic-swap.mjs dist.staging dist",
261
261
  "dev": "tsup --watch",
262
262
  "typecheck": "tsc --noEmit",
263
263
  "test": "vitest run",
@@ -572,15 +572,15 @@ EVERY color, spacing, typography, shadow, and radius value MUST come from design
572
572
 
573
573
  MANDATORY:
574
574
  1. NEVER use hardcoded hex colors like \`#7c3aed\` — ONLY \`var(--ggui-color-*)\` tokens.
575
- 2. NEVER use CSS gradients with custom colors. If you need a gradient: \`linear-gradient(to bottom, var(--ggui-color-primary-500, #0ea5e9), var(--ggui-color-primary-700, #0369a1))\`.
575
+ 2. NEVER use CSS gradients with custom colors. If you need a gradient: \`linear-gradient(to bottom, var(--ggui-color-primary-500), var(--ggui-color-primary-700))\`.
576
576
  3. NEVER invent your own palette. The system provides primary, neutral, success, warning, error, and info — use ONLY these.
577
- 4. ALWAYS include fallback values: \`var(--ggui-color-primary-600, #0284c7)\`.
577
+ 4. NEVER include literal fallback values in color tokens — write \`var(--ggui-color-primary-600)\` bare, never \`var(--ggui-color-primary-600, #0284c7)\`. The runtime injects every token; a literal fallback paints the wrong brand exactly when the operator's theme matters most.
578
578
 
579
579
  Token categories:
580
- - Brand: \`var(--ggui-color-primary-600, #0284c7)\`, \`var(--ggui-color-primary-50, #f0f9ff)\`
581
- - Text: \`var(--ggui-color-onSurface, #18181b)\`, \`var(--ggui-color-onSurfaceVariant, #52525b)\`
582
- - Backgrounds: \`var(--ggui-color-surface, #fafafa)\`, \`var(--ggui-color-surfaceVariant, #f4f4f5)\`
583
- - Borders: \`var(--ggui-color-outline, #d4d4d8)\`
580
+ - Brand: \`var(--ggui-color-primary-600)\`, \`var(--ggui-color-primary-50)\`
581
+ - Text: \`var(--ggui-color-onSurface)\`, \`var(--ggui-color-onSurfaceVariant)\`
582
+ - Backgrounds: \`var(--ggui-color-surface)\`, \`var(--ggui-color-surfaceVariant)\`
583
+ - Borders: \`var(--ggui-color-outline)\`
584
584
  - Spacing: \`var(--ggui-spacing-4, 16px)\`, \`var(--ggui-spacing-6, 24px)\`
585
585
  - Typography: \`var(--ggui-font-size-sm, 14px)\`, \`var(--ggui-font-weight-semibold, 600)\`
586
586
  - Shadows: \`var(--ggui-shape-shadow-sm)\`, \`var(--ggui-shape-shadow-md)\`, \`var(--ggui-shape-shadow-lg)\`
@@ -765,7 +765,7 @@ Hero sections should feel airy. Use \`padding="xl"\` (32px) on the main card, no
765
765
 
766
766
  - [ ] Imports ONLY from: react, @ggui-ai/design, @ggui-ai/wire
767
767
  - [ ] No raw HTML elements (\`<button>\`, \`<input>\`, \`<div>\` for layout) — uses primitives
768
- - [ ] ZERO hardcoded hex colors — every color is \`var(--ggui-color-*, fallback)\`
768
+ - [ ] ZERO hardcoded hex colors — every color is a bare \`var(--ggui-color-*)\`, no literal fallback
769
769
  - [ ] No raw pixel values for spacing — all via \`var(--ggui-spacing-*)\` tokens
770
770
  - [ ] Primary palette used throughout (headers, borders, icons) — not just buttons
771
771
  - [ ] Typed Props interface exported; request-specific data is a prop with default
@@ -508,22 +508,41 @@ export async function runTier0Checks(
508
508
  // typed escape on Box (`assetColor` + `assetSemantic`) — see
509
509
  // T-4 follow-up.
510
510
  const hexMatch = line.match(/#[0-9a-fA-F]{3,8}\b/);
511
- if (
512
- hexMatch &&
513
- !line.includes('var(--ggui-') &&
514
- !line.includes('// fallback') &&
515
- !assetEscapedLines.has(lineNum)
516
- ) {
517
- issues.push({
518
- tier: 0,
519
- result: 'fail',
520
- category: 'tokens',
521
- subcategory: 'hex-color',
522
- severity: 'critical',
523
- description: `Hardcoded color "${hexMatch[0]}" breaks theme switching — use design tokens.`,
524
- fix: `Replace with a primitive variant (Button variant="primary"|Badge variant="success"|...) OR a token reference like var(--ggui-color-primary-500, ${hexMatch[0]}). Hardcoded colors mean the operator's theme has no effect on this surface.`,
525
- line: lineNum,
526
- });
511
+ if (hexMatch && !assetEscapedLines.has(lineNum)) {
512
+ // ggui#598 slice 4 (origin-kill): hex inside a `var(--ggui-*, #hex)`
513
+ // fallback is ALSO a violation now — the runtime injects every
514
+ // token, so a literal fallback can only ever paint default-brand
515
+ // pixels under a failed injection, which must be visible (unset)
516
+ // rather than silently sky-blue under someone's brand. The old
517
+ // exemption plus a remediation text that suggested
518
+ // `var(--x, ${hex})` actively taught the model to launder its
519
+ // invented hex INTO fallbacks (adversarial-cycle find).
520
+ const fallbackVarMatch = line.match(
521
+ /var\((--ggui-[a-zA-Z0-9-]+)\s*,[^)]*#[0-9a-fA-F]{3,8}/,
522
+ );
523
+ if (fallbackVarMatch) {
524
+ issues.push({
525
+ tier: 0,
526
+ result: 'fail',
527
+ category: 'tokens',
528
+ subcategory: 'token-fallback',
529
+ severity: 'critical',
530
+ description: `Literal fallback "${hexMatch[0]}" inside a token reference — the runtime injects every token, and the fallback paints the wrong brand exactly when the theme matters most.`,
531
+ fix: `Drop the fallback: write var(${fallbackVarMatch[1]}) bare. If the token might not exist, that is a vocabulary problem to fix by choosing a token from the design-system docs, never by a literal.`,
532
+ line: lineNum,
533
+ });
534
+ } else if (!line.includes('// fallback')) {
535
+ issues.push({
536
+ tier: 0,
537
+ result: 'fail',
538
+ category: 'tokens',
539
+ subcategory: 'hex-color',
540
+ severity: 'critical',
541
+ description: `Hardcoded color "${hexMatch[0]}" breaks theme switching — use design tokens.`,
542
+ fix: `Replace with a primitive variant (Button variant="primary"|Badge variant="success"|...) OR a bare token reference like var(--ggui-color-primary-500) — never a literal fallback. Hardcoded colors mean the operator's theme has no effect on this surface.`,
543
+ line: lineNum,
544
+ });
545
+ }
527
546
  }
528
547
 
529
548
  // Hardcoded rgba()/hsl() color functions — same reasoning as above.
@@ -250,6 +250,34 @@ function toEvalIssue(issue: RenderCheckIssue): EvalIssue | null {
250
250
  };
251
251
  }
252
252
 
253
+ case "selection-identity":
254
+ return {
255
+ tier: 0,
256
+ result,
257
+ category: "contract",
258
+ subcategory,
259
+ severity: "critical",
260
+ description: `${issue.reason}${elementHint}${diagSuffix}`,
261
+ fix:
262
+ `Key the action payload (and any selection state) on the item's ID from the data, never on its display text. ` +
263
+ `Common fix: map over items and pass the item's id into the handler — onClick={() => ${subject}({ ...payload, id: item.id })} — ` +
264
+ `and store selection as the id (setSelected(item.id)), not the label.`,
265
+ };
266
+
267
+ case "prop-sensitivity":
268
+ return {
269
+ tier: 0,
270
+ result,
271
+ category: "contract",
272
+ subcategory,
273
+ severity: "critical",
274
+ description: `${issue.reason}${diagSuffix}`,
275
+ fix:
276
+ `Derive the display AND any logic from props.${subject} — render {props.${subject}} and branch on the prop's value, ` +
277
+ `never on the literal value the request happened to mention (e.g. props.${subject} ?? 'January' followed by ` +
278
+ `January-special-cased logic bakes the request into the component). The contract delivers the value; trust it.`,
279
+ };
280
+
253
281
  case "prop-coverage":
254
282
  return {
255
283
  tier: 0,
@@ -21,7 +21,12 @@ import type {
21
21
  PropsSpec,
22
22
  } from "@ggui-ai/protocol";
23
23
  import { HOOK_NAME_RE, listContractGadgets } from "@ggui-ai/protocol";
24
- import { createProbe, createProbeWireConfig, type Probe } from "./probe.js";
24
+ import {
25
+ createProbe,
26
+ createProbeWireConfig,
27
+ type ActionFiredEvent,
28
+ type Probe,
29
+ } from "./probe.js";
25
30
  import { loadComponent } from "./load-component.js";
26
31
  import { findWiring, type WiringDetection } from "./find-wiring.js";
27
32
  import { installProductionActShim } from "./production-act-shim.js";
@@ -70,7 +75,9 @@ export interface RenderCheckIssue {
70
75
  readonly check:
71
76
  | "render-no-throw"
72
77
  | "action-wiring"
78
+ | "selection-identity"
73
79
  | "prop-coverage"
80
+ | "prop-sensitivity"
74
81
  | "stream-rerender"
75
82
  | "optional-props-omitted";
76
83
  readonly outcome: CheckOutcome;
@@ -519,6 +526,35 @@ export async function runRenderCheckInProcess(
519
526
  }
520
527
  }
521
528
 
529
+ // ── Check 2b: Selection identity — duplicate-label cells (#601) ───
530
+ // The live bug: a slot grid rendered several cells with the same
531
+ // visible label ("09:00" across stylist rows); clicking one
532
+ // selected ALL of them because the generated code keyed
533
+ // selection/payload on the DISPLAY VALUE instead of the cell's
534
+ // identity — even though the action schema carried an id field.
535
+ // Wire-behavior check, DOM-shape independent: click two
536
+ // same-label clickables and require their fired payloads to
537
+ // differ on the id-like field. Conservative: any ambiguity
538
+ // (no duplicate labels, clicks that fire nothing, payloads
539
+ // missing the field) yields NO issue — those belong to other
540
+ // checks.
541
+ if (input.contract?.actionSpec) {
542
+ for (const [name, entry] of Object.entries(input.contract.actionSpec)) {
543
+ const idField = idLikeRequiredStringField(
544
+ entry.schema as Record<string, unknown> | undefined,
545
+ );
546
+ if (idField === null) continue;
547
+ const issue = await checkSelectionIdentity({
548
+ container,
549
+ probe,
550
+ user,
551
+ actionName: name,
552
+ idField,
553
+ });
554
+ if (issue) issues.push(issue);
555
+ }
556
+ }
557
+
522
558
  // ── Check 3: Agent-tool wiring — RETIRED ──────────────────────────
523
559
  // `useWiredTool` is retired. The contract's `agentCapabilities.tools`
524
560
  // is a catalog of tools the AGENT invokes — there is no component hook
@@ -545,6 +581,72 @@ export async function runRenderCheckInProcess(
545
581
  }
546
582
  }
547
583
 
584
+ // ── Check 5c: Prop sensitivity (#563) ─────────────────────────────
585
+ // The measured class (economy/001 run 6): generated code renders a
586
+ // value the contract delivers via props but BAKES the request's
587
+ // literal — `props.month ?? 'January'` date math, hardcoded display
588
+ // text — so a cached blueprint reused under a different value serves
589
+ // the wrong surface. Deterministic detection for the DISPLAYED-value
590
+ // half: re-render with a distinct value for each required scalar
591
+ // prop whose original value visibly appears in the DOM; if the
592
+ // rendered text is IDENTICAL under the change, the prop's value is
593
+ // dead — baked. Scope honesty: value-ANCHORED logic that still
594
+ // reacts (differently-wrong branches) is not deterministically
595
+ // decidable here; the matcher-side pair armor and the judge track
596
+ // that half. Conservative nulls: non-displayed props (ids are
597
+ // legitimately non-visual), varied-value crashes (the code READ the
598
+ // prop — not dead), and non-scalar props are all skipped.
599
+ if (input.contract?.propsSpec) {
600
+ const propsSpec5c = input.contract.propsSpec as PropsSpec;
601
+ const normalize = (s: string | null): string =>
602
+ (s ?? "").replace(/\s+/g, " ").trim();
603
+ const baseText = normalize(container.textContent);
604
+ for (const [propName, entry] of Object.entries(propsSpec5c.properties)) {
605
+ if (!entry.required) continue;
606
+ const orig = input.mockupProps[propName];
607
+ if (typeof orig !== "string" && typeof orig !== "number") continue;
608
+ const marker = pickScalarMarker(orig);
609
+ if (marker === null || !baseText.includes(marker)) continue;
610
+ // Distinct same-type value. A token that never substring-matches
611
+ // the original, so value-anchored branches (`includes(orig)`)
612
+ // genuinely flip.
613
+ const varied: string | number =
614
+ typeof orig === "string" ? "Zzyzx Qw" : (orig as number) + 137731;
615
+ // Isolated probe/wire per varied mount — events must not leak
616
+ // into the primary probe's fire log.
617
+ const variedProbe = createProbe();
618
+ const variedConfig = createProbeWireConfig(variedProbe);
619
+ let variedResult: ReturnType<typeof render> | undefined;
620
+ try {
621
+ variedResult = render(
622
+ React.createElement(GguiWireProvider, {
623
+ config: variedConfig,
624
+ children: React.createElement(ProbeErrorBoundary, {
625
+ children: React.createElement(Component, {
626
+ ...input.mockupProps,
627
+ [propName]: varied,
628
+ }),
629
+ }),
630
+ }),
631
+ );
632
+ } catch {
633
+ continue; // crash on varied value ⇒ the prop is read, not dead
634
+ }
635
+ await flushPromises();
636
+ const variedText = normalize(variedResult.container.textContent);
637
+ variedResult.unmount();
638
+ if (variedText === baseText) {
639
+ issues.push({
640
+ check: "prop-sensitivity",
641
+ outcome: "failed",
642
+ subject: propName,
643
+ reason:
644
+ `props.${propName} is displayed ('${marker}' appears in the DOM) but changing its value to '${String(varied)}' left the rendered output BYTE-IDENTICAL — the value is baked into the component instead of derived from the prop. A cached reuse of this blueprint under a different ${propName} would show the original value.`,
645
+ });
646
+ }
647
+ }
648
+ }
649
+
548
650
  // ── Check 6: Stream re-render (WARN) ──────────────────────────────
549
651
  if (input.contract?.streamSpec) {
550
652
  const streamSpec = input.contract.streamSpec;
@@ -1081,6 +1183,127 @@ async function checkActionWiring(input: CheckActionInput): Promise<RenderCheckIs
1081
1183
  // `streamSpec[*].source.tool` ride through the action / stream check
1082
1184
  // arms in `runRenderCheck` instead.
1083
1185
 
1186
+ // ─────────────────────────────────────────────────────────────────────────────
1187
+ // Selection identity (#601) — duplicate-label cells must carry their own id
1188
+ // ─────────────────────────────────────────────────────────────────────────────
1189
+
1190
+ /**
1191
+ * Find the id-like required string property in an action's payload
1192
+ * schema, or null. "id-like" = a required property whose name ends in
1193
+ * `id`/`Id`/`ID` and whose declared type is string — the shape the
1194
+ * matcher's booking/selection contracts carry (`bookSlot { slotId }`).
1195
+ */
1196
+ function idLikeRequiredStringField(
1197
+ schema: Record<string, unknown> | undefined,
1198
+ ): string | null {
1199
+ if (schema === undefined) return null;
1200
+ const properties = schema['properties'];
1201
+ const required = schema['required'];
1202
+ if (properties === null || typeof properties !== 'object') return null;
1203
+ if (!Array.isArray(required)) return null;
1204
+ for (const [propName, propSchema] of Object.entries(
1205
+ properties as Record<string, unknown>,
1206
+ )) {
1207
+ if (!/id$/i.test(propName)) continue;
1208
+ if (!required.includes(propName)) continue;
1209
+ const t =
1210
+ propSchema !== null && typeof propSchema === 'object'
1211
+ ? (propSchema as Record<string, unknown>)['type']
1212
+ : undefined;
1213
+ if (t === 'string') return propName;
1214
+ }
1215
+ return null;
1216
+ }
1217
+
1218
+ interface CheckSelectionIdentityInput {
1219
+ container: MinimalElement;
1220
+ probe: Probe;
1221
+ user: { click: (el: MinimalElement) => Promise<void> };
1222
+ actionName: string;
1223
+ idField: string;
1224
+ }
1225
+
1226
+ /**
1227
+ * Click two clickables that share a visible label and require the two
1228
+ * fired payloads to DIFFER on the id-like field. Identical values mean
1229
+ * the component keys the action on the display value, not the element's
1230
+ * identity — the ggui#601 class (every "09:00" cell selecting together).
1231
+ *
1232
+ * Emits at most one `failed` issue; every ambiguous situation returns
1233
+ * null (covered by other checks or genuinely unjudgeable here).
1234
+ */
1235
+ async function checkSelectionIdentity(
1236
+ input: CheckSelectionIdentityInput,
1237
+ ): Promise<RenderCheckIssue | null> {
1238
+ const { container, probe, user, actionName, idField } = input;
1239
+
1240
+ const clickables = Array.from(
1241
+ container.querySelectorAll<MinimalElement>('button, [role="button"]'),
1242
+ );
1243
+ const byLabel = new Map<string, MinimalElement[]>();
1244
+ for (const el of clickables) {
1245
+ const label = (el.textContent ?? '').replace(/\s+/g, ' ').trim();
1246
+ if (label.length === 0) continue;
1247
+ const group = byLabel.get(label);
1248
+ if (group) group.push(el);
1249
+ else byLabel.set(label, [el]);
1250
+ }
1251
+
1252
+ for (const [label, group] of byLabel) {
1253
+ if (group.length < 2) continue;
1254
+
1255
+ const payloadFor = async (
1256
+ el: MinimalElement,
1257
+ ): Promise<unknown | undefined> => {
1258
+ const before = probe.getFireLog().length;
1259
+ try {
1260
+ await user.click(el);
1261
+ } catch {
1262
+ return undefined;
1263
+ }
1264
+ await flushPromises();
1265
+ const events = probe
1266
+ .getFireLog()
1267
+ .slice(before)
1268
+ .filter(
1269
+ (e): e is ActionFiredEvent =>
1270
+ e.kind === 'action.fired' && e.name === actionName,
1271
+ );
1272
+ return events.length > 0 ? events[events.length - 1]!.payload : undefined;
1273
+ };
1274
+
1275
+ const first = await payloadFor(group[0]!);
1276
+ const second = await payloadFor(group[1]!);
1277
+ // Either click firing nothing for this action = not this check's
1278
+ // question (wiring check owns "does it fire").
1279
+ if (first === undefined || second === undefined) continue;
1280
+
1281
+ const idOf = (payload: unknown): string | undefined => {
1282
+ if (payload === null || typeof payload !== 'object') return undefined;
1283
+ const v = (payload as Record<string, unknown>)[idField];
1284
+ return typeof v === 'string' ? v : undefined;
1285
+ };
1286
+ const firstId = idOf(first);
1287
+ const secondId = idOf(second);
1288
+ // Payloads missing the required field = schema territory, not ours.
1289
+ if (firstId === undefined || secondId === undefined) continue;
1290
+
1291
+ if (firstId === secondId) {
1292
+ return {
1293
+ check: 'selection-identity',
1294
+ outcome: 'failed',
1295
+ subject: actionName,
1296
+ reason:
1297
+ `Two clickable elements share the visible label '${label}', and clicking EACH fired '${actionName}' with the same ${idField} ('${firstId}') — the action payload is keyed on the display value, not the element's identity. Distinct cells must send their own ${idField} (the contract requires it), or selecting one "${label}" acts on every "${label}".`,
1298
+ elementHint: describeElement(group[0]!),
1299
+ };
1300
+ }
1301
+ // Distinct ids on the first duplicate group — identity keying holds.
1302
+ return null;
1303
+ }
1304
+ return null;
1305
+ }
1306
+
1084
1307
  // ─────────────────────────────────────────────────────────────────────────────
1085
1308
  // Trigger simulators — narrow set, deterministic only
1086
1309
  // ─────────────────────────────────────────────────────────────────────────────