@ggui-ai/ui-gen 0.23.0 → 0.25.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 (123) hide show
  1. package/dist/adapter-Cg_rkpGq.d.ts +201 -0
  2. package/dist/adapters/generation-dispatch.d.ts +58 -15
  3. package/dist/adapters/generation-dispatch.js +1418 -180
  4. package/dist/adapters/generation-dispatch.js.map +1 -1
  5. package/dist/adapters/index.js +3 -1
  6. package/dist/adapters/index.js.map +1 -1
  7. package/dist/advanced/index.d.ts +12 -8
  8. package/dist/advanced/index.js +1467 -239
  9. package/dist/advanced/index.js.map +1 -1
  10. package/dist/{axes-CzLEMDeB.d.ts → axes-CKVCzR_x.d.ts} +1 -1
  11. package/dist/bank-CT4eEkta.d.ts +118 -0
  12. package/dist/blueprint-validator.js +153 -59
  13. package/dist/blueprint-validator.js.map +1 -1
  14. package/dist/boilerplate.js +24 -12
  15. package/dist/boilerplate.js.map +1 -1
  16. package/dist/check/index.d.ts +12 -4
  17. package/dist/check/index.js +94 -2
  18. package/dist/check/index.js.map +1 -1
  19. package/dist/classifier/index.d.ts +2 -2
  20. package/dist/compose.d.ts +2 -2
  21. package/dist/{create-ui-generator-D0kbWwln.d.ts → create-ui-generator-DFnnArrv.d.ts} +43 -11
  22. package/dist/{dispatch-wxaIMwZt.d.ts → dispatch-CStl9eAw.d.ts} +5 -3
  23. package/dist/evaluation/axis-checks/index.d.ts +6 -5
  24. package/dist/evaluation/axis-checks/index.js +90 -2
  25. package/dist/evaluation/axis-checks/index.js.map +1 -1
  26. package/dist/evaluation/axis-checks/registry.d.ts +4 -3
  27. package/dist/evaluation/axis-checks/registry.js +88 -1
  28. package/dist/evaluation/axis-checks/registry.js.map +1 -1
  29. package/dist/evaluation/index.d.ts +301 -23
  30. package/dist/evaluation/index.js +746 -30
  31. package/dist/evaluation/index.js.map +1 -1
  32. package/dist/evaluation/types.d.ts +9 -1
  33. package/dist/evaluation/types.js.map +1 -1
  34. package/dist/fragments/index.d.ts +3 -3
  35. package/dist/harness/check/runtime-render/index.d.ts +30 -11
  36. package/dist/harness/check/runtime-render/index.js +203 -60
  37. package/dist/harness/check/runtime-render/index.js.map +1 -1
  38. package/dist/harness/check/runtime-render/render-check-worker.d.ts +8 -1
  39. package/dist/harness/check/runtime-render/render-check-worker.js +72 -32
  40. package/dist/harness/check/runtime-render/render-check-worker.js.map +1 -1
  41. package/dist/harness/index.d.ts +5 -4
  42. package/dist/harness/index.js +33 -15
  43. package/dist/harness/index.js.map +1 -1
  44. package/dist/harness/prompts.js.map +1 -1
  45. package/dist/harness/result-types.d.ts +4 -3
  46. package/dist/harness/runtime.d.ts +11 -4
  47. package/dist/harness/runtime.js +26 -14
  48. package/dist/harness/runtime.js.map +1 -1
  49. package/dist/harness/types-public.d.ts +13 -4
  50. package/dist/harness/types-public.js +33 -15
  51. package/dist/harness/types-public.js.map +1 -1
  52. package/dist/harness/validator-trace-sink.d.ts +4 -3
  53. package/dist/hash.d.ts +1 -1
  54. package/dist/index.d.ts +59 -8
  55. package/dist/index.js +1476 -241
  56. package/dist/index.js.map +1 -1
  57. package/dist/{llm-router-DwwUK1bz.d.ts → llm-router-nfO7wPec.d.ts} +3 -1
  58. package/dist/patch.d.ts +4 -3
  59. package/dist/policy.d.ts +8 -5
  60. package/dist/policy.js.map +1 -1
  61. package/dist/tools/get-primitives-ts.d.ts +1 -1
  62. package/dist/tools/get-primitives-ts.js +1 -1
  63. package/dist/tools/get-primitives-ts.js.map +1 -1
  64. package/dist/tools/render-check-worker.d.ts +7 -1
  65. package/dist/tools/render-check-worker.js +55 -23
  66. package/dist/tools/render-check-worker.js.map +1 -1
  67. package/dist/{types-BOvHNG7K.d.ts → types-Dh1s4K9j.d.ts} +1 -1
  68. package/dist/{types-public-Ow-tNtFE.d.ts → types-public-BJTLpzWn.d.ts} +208 -15
  69. package/dist/validation/index.d.ts +1 -1
  70. package/dist/validation/index.js +1 -1
  71. package/dist/validation/index.js.map +1 -1
  72. package/dist/workflows.d.ts +4 -3
  73. package/package.json +12 -12
  74. package/src/adapters/generation-dispatch.ts +173 -98
  75. package/src/adapters/tools.ts +2 -1
  76. package/src/boilerplate/free-design-prompt.ts +1 -1
  77. package/src/boilerplate/generate.ts +26 -1
  78. package/src/boilerplate/hard-sections.ts +13 -13
  79. package/src/boilerplate/system-prompt.ts +1 -1
  80. package/src/check/index.ts +1 -0
  81. package/src/check/run-tier0.ts +80 -1
  82. package/src/check/sample-props.ts +63 -0
  83. package/src/coding-agent/tools.ts +4 -1
  84. package/src/create-harness.ts +3 -3
  85. package/src/create-ui-generator.ts +204 -95
  86. package/src/evaluation/__fixtures__/png.ts +100 -0
  87. package/src/evaluation/axis-checks/checks/state-merge.ts +1 -1
  88. package/src/evaluation/axis-checks/checks/universal.ts +111 -0
  89. package/src/evaluation/axis-checks/dispatch.ts +4 -1
  90. package/src/evaluation/criteria/bank.ts +85 -0
  91. package/src/evaluation/criteria/context.ts +52 -0
  92. package/src/evaluation/criteria/resolve.ts +159 -0
  93. package/src/evaluation/criteria/select.ts +79 -0
  94. package/src/evaluation/index.ts +23 -0
  95. package/src/evaluation/ink-extent.ts +200 -0
  96. package/src/evaluation/theme-css.ts +12 -5
  97. package/src/evaluation/types-public.ts +200 -11
  98. package/src/evaluation/types.ts +9 -0
  99. package/src/evaluation/visual-evaluator.ts +636 -30
  100. package/src/harness/check/runtime-render/adapter.ts +221 -75
  101. package/src/harness/check/runtime-render/index.ts +75 -2
  102. package/src/harness/check/runtime-render/render-check-host.ts +82 -19
  103. package/src/harness/check/runtime-render/render-check-worker.ts +83 -29
  104. package/src/harness/check/runtime-render/render-check.ts +154 -117
  105. package/src/harness/coding/generate-task-runner.ts +60 -4
  106. package/src/harness/coding/init-session.ts +13 -0
  107. package/src/harness/coding/run-coding-turn.ts +7 -3
  108. package/src/harness/coding/run-eval-round.ts +257 -15
  109. package/src/harness/policy.ts +11 -0
  110. package/src/harness/primitive-slice.ts +104 -0
  111. package/src/harness/runtime.ts +5 -0
  112. package/src/harness/types-public.ts +9 -1
  113. package/src/harness-tool-names.ts +38 -0
  114. package/src/index.ts +16 -0
  115. package/src/policy.ts +7 -4
  116. package/src/run-check.ts +2 -0
  117. package/src/tools/get-primitives-ts.ts +1 -1
  118. package/src/tools/get-wire.ts +1 -1
  119. package/src/tools/render-check-worker.ts +74 -23
  120. package/src/tools/render-check.ts +0 -60
  121. package/src/tools.ts +11 -1
  122. package/src/validation/primitives.ts +1 -1
  123. package/dist/render-check-Blwj8gB6.d.ts +0 -105
@@ -585,6 +585,105 @@ function runActionLabelDropped(input: AxisCheckInput): EvalIssue[] {
585
585
  );
586
586
  }
587
587
 
588
+ // ── universal.fixture_default_echo (ggui#1285 — the fixture echo) ────────
589
+ // The probe and the judge render the source with fixture props, the judge's
590
+ // critique quotes what it saw, and an eval-fix turn can write that text back
591
+ // into the source as a default, a fallback or a placeholder. The fixture is
592
+ // judging data, not the request; a visitor then sees it whenever the agent
593
+ // leaves the prop out (guuey#1642: a builder's system-prompt line became a
594
+ // hello's baked copy). Rule 3 of Data Parameterization stays true — defaults
595
+ // come from the REQUEST — so a fixture value the request (or the contract's
596
+ // own text) states is allowed; only a fixture-only value is the echo. WARN,
597
+ // never fail: the copy is wrong, the card is not broken. Prose-like values
598
+ // only (≥ MIN_ECHO_CHARS and a space), so a city name or a number is never a
599
+ // hit.
600
+ export const MIN_ECHO_CHARS = 12;
601
+ function collectStrings(value: unknown, out: string[]): void {
602
+ if (typeof value === "string") {
603
+ out.push(value);
604
+ } else if (Array.isArray(value)) {
605
+ for (const v of value) collectStrings(v, out);
606
+ } else if (value !== null && typeof value === "object") {
607
+ for (const v of Object.values(value)) collectStrings(v, out);
608
+ }
609
+ }
610
+ /** The fixture's prose-like string values the request never stated, verbatim. */
611
+ export function fixtureOnlyStrings(input: Pick<AxisCheckInput, "fixtureProps" | "originalPrompt" | "contract">): string[] {
612
+ if (input.fixtureProps === undefined) return [];
613
+ const values: string[] = [];
614
+ collectStrings(input.fixtureProps, values);
615
+ const stated = `${input.originalPrompt}\n${input.contract === undefined ? "" : JSON.stringify(input.contract)}`;
616
+ const seen = new Set<string>();
617
+ const out: string[] = [];
618
+ for (const v of values) {
619
+ const t = v.trim();
620
+ if (t.length < MIN_ECHO_CHARS || !t.includes(" ") || seen.has(t)) continue;
621
+ seen.add(t);
622
+ if (stated.includes(t)) continue;
623
+ out.push(t);
624
+ }
625
+ return out;
626
+ }
627
+ function runFixtureDefaultEcho(input: AxisCheckInput): EvalIssue[] {
628
+ if (input.compiledCode === null) return [];
629
+ const src = input.sourceCode;
630
+ const issues: EvalIssue[] = [];
631
+ for (const value of fixtureOnlyStrings(input)) {
632
+ if (!src.includes(value)) continue;
633
+ issues.push(
634
+ mkIssue(
635
+ "universal.fixture_default_echo",
636
+ `A literal in the source equals a fixture value the request never stated: "${value.length > 60 ? `${value.slice(0, 57)}…` : value}". The fixture is judging data; a visitor sees this copy whenever the agent leaves the prop out.`,
637
+ "Take that copy from the prop it renders and drop the literal — no default, fallback or placeholder that repeats a fixture value. A default may only carry text the request itself states.",
638
+ "warn",
639
+ ),
640
+ );
641
+ }
642
+ return issues;
643
+ }
644
+
645
+ // ── universal.form_submit_control (ggui#1443 — a form whose buttons cannot submit it) ──
646
+ // The design `Button` defaults to `type="button"` (the safe default: no accidental
647
+ // submits from secondary controls), so the control that submits a `<form onSubmit>`
648
+ // must say `type="submit"`. A generated composer rendered its Send as a type-less
649
+ // `Button` inside the form: clicking it did nothing, only Enter submitted, and the
650
+ // card passed quality (#1398's dumps: 5 of 9 form cells, both arms). FAIL: a form
651
+ // with buttons and no submitting control is a control that does not work. A form
652
+ // without any button is not this check's claim; a form without `onSubmit` is not
653
+ // read (nothing to submit to).
654
+ const FORM_OPEN_RX = /<form\b[^<>]*\bonSubmit\s*=[^<>]*>/g;
655
+ const CONTROL_TAG_RX = /<(?:Button|button|input)\b[^<>]*>/g;
656
+ const SUBMIT_TYPE_RX = /\btype\s*=\s*(?:["']submit["']|\{\s*["']submit["']\s*\})/;
657
+ /** Every `<form … onSubmit=…>…</form>` block in the source, verbatim (up to the first `</form>` after each opening tag). */
658
+ export function submitForms(sourceCode: string): string[] {
659
+ const out: string[] = [];
660
+ for (const m of sourceCode.matchAll(FORM_OPEN_RX)) {
661
+ const start = m.index ?? 0;
662
+ const end = sourceCode.indexOf("</form>", start + m[0].length);
663
+ out.push(end === -1 ? sourceCode.slice(start) : sourceCode.slice(start, end + "</form>".length));
664
+ }
665
+ return out;
666
+ }
667
+ function runFormSubmitControl(input: AxisCheckInput): EvalIssue[] {
668
+ if (input.compiledCode === null) return [];
669
+ const issues: EvalIssue[] = [];
670
+ for (const form of submitForms(input.sourceCode)) {
671
+ const controls = [...form.matchAll(CONTROL_TAG_RX)].map((m) => m[0]);
672
+ if (controls.length === 0) continue;
673
+ if (controls.some((tag) => SUBMIT_TYPE_RX.test(tag))) continue;
674
+ const named = controls.find((tag) => /<Button\b/.test(tag)) ?? controls[0]!;
675
+ issues.push(
676
+ mkIssue(
677
+ "universal.form_submit_control",
678
+ `A <form onSubmit> renders ${controls.length} control(s) and none submits it: the design Button defaults to type="button", so clicking ${named.length > 60 ? `${named.slice(0, 57)}…` : named} does nothing — only Enter in a field submits.`,
679
+ 'Give the control that submits this form type="submit" — a Button inside a form defaults to type="button" and never submits on click; keep type="button" on the others.',
680
+ "fail",
681
+ ),
682
+ );
683
+ }
684
+ return issues;
685
+ }
686
+
588
687
  export const UNIVERSAL_CHECKS: readonly AxisCheck[] = [
589
688
  {
590
689
  id: "universal.icon_name_known",
@@ -622,6 +721,18 @@ export const UNIVERSAL_CHECKS: readonly AxisCheck[] = [
622
721
  values: ALL_RENDER_VALUES,
623
722
  run: runTerminalActionUnguarded,
624
723
  },
724
+ {
725
+ id: "universal.form_submit_control",
726
+ axis: "render",
727
+ values: ALL_RENDER_VALUES,
728
+ run: runFormSubmitControl,
729
+ },
730
+ {
731
+ id: "universal.fixture_default_echo",
732
+ axis: "render",
733
+ values: ALL_RENDER_VALUES,
734
+ run: runFixtureDefaultEcho,
735
+ },
625
736
  {
626
737
  id: "universal.accent_text_ink",
627
738
  axis: "render",
@@ -2,7 +2,7 @@
2
2
  // whose gate matches the classification's axis vector, and accumulates
3
3
  // issues.
4
4
 
5
- import type { DataContract } from "@ggui-ai/protocol";
5
+ import type { DataContract, JsonObject } 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";
@@ -20,6 +20,8 @@ export interface RunAxisChecksInput {
20
20
  designMode?: DesignMode;
21
21
  /** The canvas the source is judged for — see `AxisCheckInput.canvas` (ggui#1117); absent = no rendering context. */
22
22
  canvas?: CanvasClass;
23
+ /** The props the source was rendered with — see `AxisCheckInput.fixtureProps` (ggui#1285); absent = the echo check stands down. */
24
+ fixtureProps?: JsonObject;
23
25
  }
24
26
 
25
27
  export function runAxisChecks(
@@ -33,6 +35,7 @@ export function runAxisChecks(
33
35
  classification,
34
36
  ...(input.designMode !== undefined ? { designMode: input.designMode } : {}),
35
37
  ...(input.canvas !== undefined ? { canvas: input.canvas } : {}),
38
+ ...(input.fixtureProps !== undefined ? { fixtureProps: input.fixtureProps } : {}),
36
39
  };
37
40
  if (input.compiledCode === null) {
38
41
  traceAxisChecksSkipped(facts, "compiledCode null — no check ran");
@@ -0,0 +1,85 @@
1
+ /**
2
+ * The visual criteria bank — ggui#1436 (step 1 of the founder's visual-assessment steer).
3
+ *
4
+ * The bank is DATA, never code (#1437 authors it; #1060's bar-and-judge policy): a versioned set of
5
+ * criteria the judge, an instrument or a person answers off the frame, split as the founder ruled —
6
+ * `severity` is importance, `evaluation` the method, `status` the method's readiness (the only source
7
+ * of an `n/a`), `scope` where a row applies ({} = every card; {kind} / {canvas} / {preset} = one). The
8
+ * general rows are `criteria`; a preset's own expression is a `recipes` row carrying `scope.preset`.
9
+ * This module is the ONE loader: a malformed bank refuses at launch with the offending path named,
10
+ * never mid-run. Fields the loader does not read (`examples`, `fields`, `groups`, `seed`) are page
11
+ * matter and are dropped here; an additive field tomorrow does not break today's loader.
12
+ */
13
+ import { z } from 'zod';
14
+
15
+ /** `family.property[.facet]` — `read.contrast`, `hello.field`. */
16
+ const CRITERION_ID = /^[a-z][a-z0-9-]*(?:\.[a-zA-Z][a-zA-Z0-9-]*)+$/;
17
+
18
+ /** Where a row applies; every key named must match the card's context. */
19
+ export const criteriaScopeSchema = z.object({
20
+ kind: z.string().min(1).optional(),
21
+ canvas: z.string().min(1).optional(),
22
+ preset: z.string().min(1).optional(),
23
+ });
24
+
25
+ export const bankRowSchema = z.object({
26
+ id: z.string().regex(CRITERION_ID, 'a criterion id is family.property[.facet], lower-case family'),
27
+ group: z.string().optional(),
28
+ scope: criteriaScopeSchema.default({}),
29
+ /** Importance, independent of whether anything automated checks it yet. */
30
+ severity: z.enum(['must', 'should']),
31
+ /** The method that produces the verdict. */
32
+ evaluation: z.enum(['instrument', 'judge', 'human']),
33
+ /** The method's readiness — the only source of an `n/a`. */
34
+ status: z.enum(['live', 'reference', 'planned', 'manual']),
35
+ /** The question, answerable yes/no from the frame. */
36
+ text: z.string().min(1),
37
+ /** The evidence rule: what the one-sentence evidence must name (a region or element). */
38
+ evidence: z.string().min(1),
39
+ /** The templated fix line a feedback channel may consume later; never read in v1. */
40
+ fix: z.string().optional(),
41
+ });
42
+
43
+ export const criteriaBankSchema = z
44
+ .object({
45
+ version: z.string().min(1),
46
+ applies: z
47
+ .object({
48
+ /** `["*"]` = every kind. */
49
+ kind: z.array(z.string().min(1)).optional(),
50
+ canvases: z.array(z.string().min(1)).optional(),
51
+ })
52
+ .optional(),
53
+ criteria: z.array(bankRowSchema).min(1),
54
+ recipes: z.array(bankRowSchema).optional(),
55
+ })
56
+ .superRefine((bank, ctx) => {
57
+ const seen = new Set<string>();
58
+ const check = (rows: readonly { id: string }[], key: 'criteria' | 'recipes'): void => {
59
+ rows.forEach((c, i) => {
60
+ if (seen.has(c.id)) ctx.addIssue({ code: 'custom', path: [key, i, 'id'], message: `duplicate criterion id ${c.id}` });
61
+ seen.add(c.id);
62
+ });
63
+ };
64
+ check(bank.criteria, 'criteria');
65
+ check(bank.recipes ?? [], 'recipes');
66
+ });
67
+
68
+ export type CriteriaBank = z.infer<typeof criteriaBankSchema>;
69
+ export type BankRow = z.infer<typeof bankRowSchema>;
70
+ export type CriteriaScope = z.infer<typeof criteriaScopeSchema>;
71
+
72
+ /** Every row of the bank — the general criteria, then the recipes. */
73
+ export function bankRows(bank: CriteriaBank): readonly BankRow[] {
74
+ return [...bank.criteria, ...(bank.recipes ?? [])];
75
+ }
76
+
77
+ /** The one door for bank data: refuses loudly, naming the path, before anything is judged. */
78
+ export function parseCriteriaBank(raw: unknown): CriteriaBank {
79
+ const parsed = criteriaBankSchema.safeParse(raw);
80
+ if (!parsed.success) {
81
+ const issues = parsed.error.issues.map((i) => `${i.path.map(String).join('.') || '<root>'}: ${i.message}`).join('; ');
82
+ throw new Error(`criteria bank invalid — ${issues}`);
83
+ }
84
+ return parsed.data;
85
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * The selector's inputs for one card — ggui#1436. Every key here is hashed into `criteriaSetId`
3
+ * and STORED in the block (the CTO's A1), so a stored verdict reproduces its own set from the
4
+ * record alone. `canvas` is added per canvas inside the judge; the rest is the harness's.
5
+ */
6
+ import type { DataContract } from '@ggui-ai/protocol';
7
+ import type { Classification } from '../../classifier/axes.js';
8
+ import type { GenerationProfileInput } from '../../boilerplate/styling-profile.js';
9
+ import type { CriteriaChroma, CriteriaContext } from '../types-public.js';
10
+
11
+ /** The primary at its 500 step, as the design's tokens render it (`--ggui-color-primary-500: #rrggbb;`). */
12
+ const PRIMARY_500 = /--ggui-color-primary-500:\s*(#[0-9a-fA-F]{3}|#[0-9a-fA-F]{6})\s*;/;
13
+ /** Below this max−min spread (0..1) a primary reads as achromatic — an inversion, not a hue. */
14
+ const ACHROMATIC_SPREAD = 0.08;
15
+
16
+ /** Read the theme's chroma off its tokens; `unknown` when no primary is named. */
17
+ export function chromaOfCssTokens(cssTokens: string | undefined): CriteriaChroma {
18
+ const m = cssTokens?.match(PRIMARY_500);
19
+ if (!m) return 'unknown';
20
+ const hex = m[1]!.slice(1);
21
+ const full = hex.length === 3 ? hex.split('').map((c) => c + c).join('') : hex;
22
+ const channels = [0, 2, 4].map((i) => parseInt(full.slice(i, i + 2), 16));
23
+ const spread = (Math.max(...channels) - Math.min(...channels)) / 255;
24
+ return spread < ACHROMATIC_SPREAD ? 'achromatic' : 'chromatic';
25
+ }
26
+
27
+ export interface CriteriaContextSources {
28
+ readonly classification: Classification;
29
+ readonly contract: DataContract | undefined;
30
+ readonly cssTokens: string | undefined;
31
+ readonly profile: GenerationProfileInput | undefined;
32
+ readonly shell: string;
33
+ readonly kind?: string;
34
+ }
35
+
36
+ /** The context minus `canvas` — the judge fills that per canvas. */
37
+ export type CriteriaContextInput = Omit<CriteriaContext, 'canvas'>;
38
+
39
+ export function criteriaContextFor(src: CriteriaContextSources): CriteriaContextInput {
40
+ const v = src.classification.vector;
41
+ const preset = src.profile?.aesthetic?.id;
42
+ return {
43
+ hasActions: Object.keys(src.contract?.actionSpec ?? {}).length > 0,
44
+ riskTier: src.classification.riskTier,
45
+ axes: { render: v.render, state: v.state, writes: v.writes, fetch: v.fetch, layout: v.layout },
46
+ chroma: chromaOfCssTokens(src.cssTokens),
47
+ profilePresent: src.profile !== undefined,
48
+ shell: src.shell,
49
+ ...(src.kind !== undefined ? { kind: src.kind } : {}),
50
+ ...(preset !== undefined ? { preset } : {}),
51
+ };
52
+ }
@@ -0,0 +1,159 @@
1
+ /**
2
+ * From K judge answers and the capture's measurements to one `CriteriaBlock` — ggui#1436.
3
+ *
4
+ * The judge is asked every `evaluation: judge` row (this block IS their implementation; the row's
5
+ * `status` is carried on the verdict as the author wrote it). An `instrument` row is answered by a
6
+ * measurement the capture already ran when its status is `live` and a binding exists; otherwise it
7
+ * reads `n/a` with the reason from `status`. A `human` row reads `n/a` for the judge. K-majority per
8
+ * id; a tie or an unanswered id reads `n/a` (the CTO's A2) — a `must` at `n/a` is never a pass when
9
+ * a gate later reads the set.
10
+ */
11
+ import type { CriteriaAnswer } from '../types.js';
12
+ import type { CriteriaBlock, CriteriaContext, CriteriaVerdict, CriterionVerdict } from '../types-public.js';
13
+ import { bankRows, type BankRow, type CriteriaBank } from './bank.js';
14
+ import { CRITERIA_SELECTOR_VERSION, type CriteriaSelectionResult } from './select.js';
15
+
16
+ /** The instruments the capture runs today, by the bank ids they answer (v1 binding; a bank field may replace it). */
17
+ export const INSTRUMENT_BY_ID: Readonly<Record<string, 'fit' | 'fill'>> = {
18
+ 'comp.fit': 'fit',
19
+ 'floor.fit': 'fit',
20
+ 'finish.canvas.inhabited': 'fill',
21
+ };
22
+
23
+ export interface CriteriaMeasurements {
24
+ readonly overflow: boolean;
25
+ readonly contentHeight: number | null;
26
+ readonly viewportHeight: number;
27
+ readonly inkRatio: number | null;
28
+ }
29
+
30
+ type Read = { verdict: CriteriaVerdict; evidence: string };
31
+
32
+ function majority(id: string, answers: readonly CriteriaAnswer[][]): Read {
33
+ const votes = answers.flatMap((a) => a.filter((x) => x.id === id));
34
+ if (votes.length === 0) return { verdict: 'n/a', evidence: 'not answered' };
35
+ const counts = new Map<CriteriaVerdict, number>();
36
+ for (const v of votes) counts.set(v.verdict, (counts.get(v.verdict) ?? 0) + 1);
37
+ const ranked = [...counts.entries()].sort((a, b) => b[1] - a[1]);
38
+ const top = ranked[0]!;
39
+ if (ranked.length > 1 && ranked[1]![1] === top[1]) return { verdict: 'n/a', evidence: 'no majority' };
40
+ const evidence = votes.find((v) => v.verdict === top[0])?.evidence ?? '';
41
+ return { verdict: top[0], evidence };
42
+ }
43
+
44
+ function instrumentRead(kind: 'fit' | 'fill', m: CriteriaMeasurements): Read {
45
+ if (kind === 'fit') {
46
+ if (m.contentHeight === null) return { verdict: 'n/a', evidence: 'content height unmeasurable' };
47
+ return { verdict: m.overflow ? 'fail' : 'pass', evidence: `content ${m.contentHeight}px against a ${m.viewportHeight}px box` };
48
+ }
49
+ if (m.inkRatio === null) return { verdict: 'n/a', evidence: 'capture unreadable' };
50
+ // Reported, never gated: no fill floor is ruled (hello-finish property 1).
51
+ return { verdict: 'n/a', evidence: `fill ${m.inkRatio.toFixed(3)} reported; no floor ruled` };
52
+ }
53
+
54
+ function readRow(row: BankRow, answers: readonly CriteriaAnswer[][], m: CriteriaMeasurements): Read {
55
+ if (row.evaluation === 'judge') return majority(row.id, answers);
56
+ if (row.evaluation === 'human') return { verdict: 'n/a', evidence: `reader's column (status ${row.status})` };
57
+ if (row.status !== 'live') return { verdict: 'n/a', evidence: `not implemented (status ${row.status})` };
58
+ const kind = INSTRUMENT_BY_ID[row.id];
59
+ return kind === undefined ? { verdict: 'n/a', evidence: 'instrument not wired (status live, no binding)' } : instrumentRead(kind, m);
60
+ }
61
+
62
+ export function resolveCriteriaBlock(args: {
63
+ readonly bank: CriteriaBank;
64
+ readonly context: CriteriaContext;
65
+ readonly selected: CriteriaSelectionResult;
66
+ readonly answers: readonly CriteriaAnswer[][];
67
+ readonly measurements: CriteriaMeasurements;
68
+ }): CriteriaBlock {
69
+ const byId = new Map<string, BankRow>(bankRows(args.bank).map((r) => [r.id, r]));
70
+ const verdicts: CriterionVerdict[] = [];
71
+ for (const s of args.selected.selection) {
72
+ const row = byId.get(s.id);
73
+ if (row === undefined) continue;
74
+ const read = readRow(row, args.answers, args.measurements);
75
+ verdicts.push({ id: row.id, severity: row.severity, method: row.evaluation, status: row.status, source: s.source, verdict: read.verdict, evidence: read.evidence });
76
+ }
77
+ return {
78
+ criteriaSetId: args.selected.criteriaSetId,
79
+ bankVersion: args.bank.version,
80
+ selectorVersion: CRITERIA_SELECTOR_VERSION,
81
+ context: args.context,
82
+ selection: args.selected.selection,
83
+ verdicts,
84
+ };
85
+ }
86
+
87
+ /** The judge's prompt block for the selected `judge` rows; empty when none is selected. */
88
+ /** The capture the judge is handed: its canvas class and the box it was taken at (the class box, or a declared one). */
89
+ export interface CriteriaJudgeFrame {
90
+ readonly canvas: CriteriaContext['canvas'];
91
+ readonly width: number;
92
+ readonly height: number;
93
+ /** ggui#1492 — the host whose presentation framed this canvas (e.g. "a chat widget, inline, reference theme"); absent ⇒ the judge's own stand-in. */
94
+ readonly hostLabel?: string;
95
+ }
96
+
97
+ /** What the block carries besides the selected rows: the frame's box, and the props the card was rendered with. */
98
+ export interface CriteriaJudgeInputs {
99
+ readonly frame?: CriteriaJudgeFrame;
100
+ /** JSON text of the props the card was rendered with; bounded by {@link CRITERIA_PROPS_MAX_CHARS}. */
101
+ readonly propsJson?: string;
102
+ }
103
+
104
+ /** Bound on the props text handed to the judge beside the frame, in characters. */
105
+ export const CRITERIA_PROPS_MAX_CHARS = 2000;
106
+
107
+ /**
108
+ * The frame's box and how the card occupies it, named so the judge reads the capture it was handed and
109
+ * never guesses it: without it, phone rows described a 390×844 frame as a 768×1024 tablet, and the
110
+ * inline card's verdicts flipped between near-identical frames (the evidence audit on ggui#1438).
111
+ * The inline chat card is sized to its content by the host (the box grows and shrinks with the card; a
112
+ * `maxHeight` caps and scrolls), so the judge captures it at its natural height on the host's ground
113
+ * (ggui#1475) and composition is judged within the card's extent; every other canvas is a fixed box the
114
+ * card fills.
115
+ */
116
+ export function criteriaFrameLine(frame: CriteriaJudgeFrame): string {
117
+ const box = `${frame.width}×${frame.height}`;
118
+ if (frame.canvas === 'xs-chat-card') {
119
+ const framedBy =
120
+ frame.hostLabel !== undefined
121
+ ? `inside the host's own frame (${frame.hostLabel}), on the host's ground`
122
+ : "inside a generic stand-in for the host's frame — the card's surface with a thin ring and rounded corners, on the page ground";
123
+ return `This frame is an inline chat card captured at its natural height (at most ${box}, the host's ceiling), ${framedBy}: the host sizes the card to its content and draws that frame, so judge composition and space within the card's own extent, and read the frame and the ground round it as the host's, not the card's.`;
124
+ }
125
+ return `This frame is ${box}, a fixed full-screen box the card fills${frame.hostLabel !== undefined ? ` (${frame.hostLabel})` : ''}: judge composition and space against the whole box, edge to edge.`;
126
+ }
127
+
128
+ /**
129
+ * The one declared input besides the frame. Rows whose evidence names props (whether a rendered string
130
+ * came from content or props, whether the primary action is the one the props and contract name) cannot
131
+ * be answered from pixels alone; every other row stays frame-only.
132
+ */
133
+ function criteriaPropsSection(propsJson: string): string {
134
+ const text = propsJson.length > CRITERIA_PROPS_MAX_CHARS ? `${propsJson.slice(0, CRITERIA_PROPS_MAX_CHARS)}… (truncated)` : propsJson;
135
+ return (
136
+ 'The one input besides the frame — the props this card was rendered with. Read them ONLY for rows whose evidence names props ' +
137
+ '(for example, whether a rendered string came from them); every other row is read off the frame alone:\n' +
138
+ `${text}\n`
139
+ );
140
+ }
141
+
142
+ /** The judge's prompt block for the selected `judge` rows; empty when none is selected. */
143
+ export function buildCriteriaJudgeBlock(bank: CriteriaBank, selected: CriteriaSelectionResult, inputs: CriteriaJudgeInputs = {}): string {
144
+ const byId = new Map<string, BankRow>(bankRows(bank).map((r) => [r.id, r]));
145
+ const asked = selected.selection.map((s) => byId.get(s.id)).filter((r): r is BankRow => r !== undefined && r.evaluation === 'judge');
146
+ if (asked.length === 0) return '';
147
+ const lines = asked.map((r) => `- ${r.id} (${r.severity}): ${r.text} Evidence must name: ${r.evidence}`);
148
+ return (
149
+ '## Criteria — answer each one from the FRAME only\n' +
150
+ 'Read these off the screenshot. Never infer them from the request, the styling profile or any direction text above.\n' +
151
+ (inputs.frame !== undefined ? `${criteriaFrameLine(inputs.frame)}\n` : '') +
152
+ (inputs.propsJson !== undefined ? criteriaPropsSection(inputs.propsJson) : '') +
153
+ `${lines.join('\n')}\n` +
154
+ 'Add to your JSON a top-level "criteria" array with exactly these ids: ' +
155
+ '[{ "id": "<id>", "verdict": "pass" | "fail" | "n/a", "evidence": "<one sentence naming the region or element you read it from>" }]. ' +
156
+ 'Every row here was selected as applicable to this card, so "n/a" never means "does not apply": ' +
157
+ 'use it ONLY when this frame cannot show the criterion — it is then recorded as not evaluated.'
158
+ );
159
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * The selector — ggui#1436's "dynamic" layer, deterministic and recorded: a pure function of the
3
+ * bank and the card's context. Same context → same `criteriaSetId` → same selection, so verdicts
4
+ * pair across arms and replay from the record. A row the selector excludes is absent from the
5
+ * block (the selector's decision); a selected row the judge cannot read comes back `n/a` (the
6
+ * judge's answer). The two are different signals.
7
+ */
8
+ import { createHash } from 'node:crypto';
9
+ import type { CriteriaContext, CriteriaSelection } from '../types-public.js';
10
+ import { bankRows, type BankRow, type CriteriaBank } from './bank.js';
11
+
12
+ /** Bump when a selection rule changes: it is hashed into every `criteriaSetId`. */
13
+ export const CRITERIA_SELECTOR_VERSION = 'selector@2';
14
+
15
+ /** The context as one string, keys sorted at every level — what the id hashes and what the record stores. */
16
+ export function canonicalCriteriaContext(ctx: CriteriaContext): string {
17
+ const sortKeys = (value: unknown): unknown =>
18
+ value !== null && typeof value === 'object' && !Array.isArray(value)
19
+ ? Object.fromEntries(
20
+ Object.entries(value as Record<string, unknown>)
21
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
22
+ .map(([k, v]) => [k, sortKeys(v)]),
23
+ )
24
+ : value;
25
+ return JSON.stringify(sortKeys(ctx));
26
+ }
27
+
28
+ /** sha256(bank version | selector version | canonical context), 16 hex — a label at this scale, not a security digest. */
29
+ export function criteriaSetIdFor(bankVersion: string, ctx: CriteriaContext): string {
30
+ return createHash('sha256')
31
+ .update(`${bankVersion}|${CRITERIA_SELECTOR_VERSION}|${canonicalCriteriaContext(ctx)}`, 'utf8')
32
+ .digest('hex')
33
+ .slice(0, 16);
34
+ }
35
+
36
+ /** The scope keys that hold for this context, or `null` when one fails; `[]` for an empty scope (static). */
37
+ function matchedScope(row: BankRow, ctx: CriteriaContext): string[] | null {
38
+ const s = row.scope;
39
+ const matched: string[] = [];
40
+ if (s.kind !== undefined) {
41
+ if (ctx.kind !== s.kind) return null;
42
+ matched.push(`kind=${s.kind}`);
43
+ }
44
+ if (s.canvas !== undefined) {
45
+ if (ctx.canvas !== s.canvas) return null;
46
+ matched.push(`canvas=${s.canvas}`);
47
+ }
48
+ if (s.preset !== undefined) {
49
+ if (ctx.preset !== s.preset) return null;
50
+ matched.push(`preset=${s.preset}`);
51
+ }
52
+ return matched;
53
+ }
54
+
55
+ export interface CriteriaSelectionResult {
56
+ readonly criteriaSetId: string;
57
+ readonly selection: CriteriaSelection[];
58
+ }
59
+
60
+ /**
61
+ * Select the bank's rows for one card. The bank-level `applies` gate is read first (canvases always;
62
+ * kind only when the caller named one and the bank names kinds other than `*`); then each row's
63
+ * `scope`. An empty scope is `static`; a matched scope is `context` with the keys that matched.
64
+ */
65
+ export function selectCriteria(bank: CriteriaBank, ctx: CriteriaContext): CriteriaSelectionResult {
66
+ const criteriaSetId = criteriaSetIdFor(bank.version, ctx);
67
+ const applies = bank.applies;
68
+ if (applies?.canvases !== undefined && !applies.canvases.includes(ctx.canvas)) return { criteriaSetId, selection: [] };
69
+ if (applies?.kind !== undefined && ctx.kind !== undefined && !applies.kind.includes('*') && !applies.kind.includes(ctx.kind)) {
70
+ return { criteriaSetId, selection: [] };
71
+ }
72
+ const selection: CriteriaSelection[] = [];
73
+ for (const row of bankRows(bank)) {
74
+ const matched = matchedScope(row, ctx);
75
+ if (matched === null) continue;
76
+ selection.push(matched.length === 0 ? { id: row.id, source: 'static', reason: 'static' } : { id: row.id, source: 'context', reason: `context: ${matched.join(', ')}` });
77
+ }
78
+ return { criteriaSetId, selection };
79
+ }
@@ -26,6 +26,10 @@ export type {
26
26
  VisualEvalSummary,
27
27
  CanvasJudgeRecord,
28
28
  CanvasVisualSummary,
29
+ CanvasHostPresentation,
30
+ HostInlineFrame,
31
+ CanvasPresentationOutcome,
32
+ HostPresentationIgnoredReason,
29
33
  EvalCriterion,
30
34
  CriterionCoverage,
31
35
  CriterionRunStatus,
@@ -103,10 +107,18 @@ export {
103
107
  captureScreenshotDetailed,
104
108
  canvasFitPolicy,
105
109
  canvasOverflowIssue,
110
+ canvasBlankIssue,
111
+ JUDGE_INK_INSET_PX,
112
+ JUDGE_GROUND_MARGIN_PX,
106
113
  CONTENT_HEIGHT_EXPRESSION,
114
+ CARD_HEIGHT_EXPRESSION,
115
+ HOST_COLOUR_PATTERN,
116
+ HOST_FRAME_BOUNDS,
107
117
  runVisualEval,
108
118
  summarizeVisualResult,
109
119
  } from './visual-evaluator.js';
120
+ export { readInkExtent, decodePng, INK_TOLERANCE } from './ink-extent.js';
121
+ export type { InkExtent, InkUnreadable, PixelRegion, DecodedImage } from './ink-extent.js';
110
122
  export type {
111
123
  VisualEvalConfig,
112
124
  VisualEvalContext,
@@ -116,12 +128,15 @@ export type {
116
128
  VisualEvalDetailed,
117
129
  ScreenshotAttempt,
118
130
  CanvasVisualResult,
131
+ CriteriaCallTokens,
119
132
  CanvasFitPolicy,
120
133
  CaptureMode,
121
134
  ScreenshotDeps,
122
135
  ScreenshotLauncher,
123
136
  ScreenshotBrowser,
124
137
  ScreenshotPage,
138
+ ScreenshotClip,
139
+ NaturalCaptureOptions,
125
140
  ChromiumProvider,
126
141
  } from './visual-evaluator.js';
127
142
 
@@ -148,3 +163,11 @@ export type {
148
163
  } from '../design-mode.js';
149
164
  export { cssTokensForAppTheme } from './theme-css.js';
150
165
  export { designTreeSha256, judgeDesignIdentity, resetJudgeDesignIdentityCache, type JudgeDesignIdentity } from './design-identity.js';
166
+
167
+ // ggui#1436 — the visual criteria: the bank loader (data), the selector, the resolver.
168
+ export { bankRows, criteriaBankSchema, parseCriteriaBank, type BankRow, type CriteriaBank, type CriteriaScope } from './criteria/bank.js';
169
+ export { CRITERIA_SELECTOR_VERSION, canonicalCriteriaContext, criteriaSetIdFor, selectCriteria, type CriteriaSelectionResult } from './criteria/select.js';
170
+ export { chromaOfCssTokens, criteriaContextFor, type CriteriaContextInput, type CriteriaContextSources } from './criteria/context.js';
171
+ export { INSTRUMENT_BY_ID, buildCriteriaJudgeBlock, resolveCriteriaBlock, type CriteriaMeasurements } from './criteria/resolve.js';
172
+ export { judgeStoredCapture, type StoredCaptureInput, type StoredCaptureOutcome, type StoredCaptureVerdict } from './visual-evaluator.js';
173
+