@ggui-ai/ui-gen 0.11.0 → 0.12.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.
- package/dist/adapters/generation-dispatch.js +180 -18
- package/dist/adapters/generation-dispatch.js.map +1 -1
- package/dist/advanced/index.d.ts +1 -1
- package/dist/advanced/index.js +180 -18
- package/dist/advanced/index.js.map +1 -1
- package/dist/blueprint-validator.js +146 -0
- package/dist/blueprint-validator.js.map +1 -1
- package/dist/boilerplate.js +7 -7
- package/dist/boilerplate.js.map +1 -1
- package/dist/check/index.js +27 -11
- package/dist/check/index.js.map +1 -1
- package/dist/harness/check/runtime-render/index.d.ts +1 -1
- package/dist/harness/check/runtime-render/index.js +146 -0
- package/dist/harness/check/runtime-render/index.js.map +1 -1
- package/dist/harness/check/runtime-render/render-check-worker.js +126 -0
- package/dist/harness/check/runtime-render/render-check-worker.js.map +1 -1
- package/dist/harness/index.js +7 -7
- package/dist/harness/index.js.map +1 -1
- package/dist/harness/runtime.js +7 -7
- package/dist/harness/runtime.js.map +1 -1
- package/dist/harness/types-public.js +7 -7
- package/dist/harness/types-public.js.map +1 -1
- package/dist/index.js +180 -18
- package/dist/index.js.map +1 -1
- package/dist/provider-adapter-contract.js +1161 -387
- package/dist/provider-adapter-contract.js.map +1 -1
- package/dist/{render-check-BZoIE8-u.d.ts → render-check-WfOVO9kU.d.ts} +1 -1
- package/package.json +12 -12
- package/src/boilerplate/system-prompt.ts +7 -7
- package/src/check/run-tier0.ts +35 -16
- package/src/harness/check/runtime-render/adapter.ts +28 -0
- 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.
|
|
3
|
+
"version": "0.12.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/
|
|
192
|
-
"@ggui-ai/
|
|
193
|
-
"@ggui-ai/
|
|
191
|
+
"@ggui-ai/mcp-server-core": "0.12.0",
|
|
192
|
+
"@ggui-ai/protocol": "0.12.0",
|
|
193
|
+
"@ggui-ai/project-config": "0.12.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/
|
|
233
|
-
"@ggui-ai/
|
|
234
|
-
"@ggui-ai/
|
|
235
|
-
"@ggui-ai/
|
|
236
|
-
"@ggui-ai/
|
|
232
|
+
"@ggui-ai/design": "0.12.0",
|
|
233
|
+
"@ggui-ai/gadgets": "0.12.0",
|
|
234
|
+
"@ggui-ai/sandbox": "0.12.0",
|
|
235
|
+
"@ggui-ai/ui-visual-tester": "0.12.0",
|
|
236
|
+
"@ggui-ai/wire": "0.12.0"
|
|
237
237
|
},
|
|
238
238
|
"devDependencies": {
|
|
239
239
|
"@google/adk": "^0.5.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/mcp-server-core": "0.
|
|
252
|
-
"@ggui-ai/
|
|
253
|
-
"@ggui-ai/
|
|
251
|
+
"@ggui-ai/mcp-server-core": "0.12.0",
|
|
252
|
+
"@ggui-ai/protocol": "0.12.0",
|
|
253
|
+
"@ggui-ai/project-config": "0.12.0"
|
|
254
254
|
},
|
|
255
255
|
"engines": {
|
|
256
256
|
"node": ">=20.0.0"
|
|
@@ -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
|
|
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.
|
|
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
|
|
581
|
-
- Text: \`var(--ggui-color-onSurface
|
|
582
|
-
- Backgrounds: \`var(--ggui-color-surface
|
|
583
|
-
- Borders: \`var(--ggui-color-outline
|
|
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
|
|
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
|
package/src/check/run-tier0.ts
CHANGED
|
@@ -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
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
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 {
|
|
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
|
// ─────────────────────────────────────────────────────────────────────────────
|