@ggui-ai/ui-gen 0.24.0 → 0.26.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/README.md +2 -2
- package/dist/{adapter-Du7P_5Fx.d.ts → adapter-BJwn74rx.d.ts} +49 -1
- package/dist/adapters/generation-dispatch.d.ts +32 -7
- package/dist/adapters/generation-dispatch.js +1997 -216
- package/dist/adapters/generation-dispatch.js.map +1 -1
- package/dist/adapters/index.js +29 -6
- package/dist/adapters/index.js.map +1 -1
- package/dist/advanced/index.d.ts +15 -12
- package/dist/advanced/index.js +2013 -270
- package/dist/advanced/index.js.map +1 -1
- package/dist/{axes-CzLEMDeB.d.ts → axes-CKVCzR_x.d.ts} +1 -1
- package/dist/bank-Ctdp3FUA.d.ts +118 -0
- package/dist/blueprint-validator.js +416 -92
- package/dist/blueprint-validator.js.map +1 -1
- package/dist/boilerplate.d.ts +2 -2
- package/dist/boilerplate.js +34 -15
- package/dist/boilerplate.js.map +1 -1
- package/dist/check/index.d.ts +13 -5
- package/dist/check/index.js +56 -3
- package/dist/check/index.js.map +1 -1
- package/dist/classifier/index.d.ts +2 -2
- package/dist/compose.d.ts +2 -2
- package/dist/{create-ui-generator-GIhzx08x.d.ts → create-ui-generator-N4gRXpcL.d.ts} +11 -4
- package/dist/{design-mode-DLufhrNp.d.ts → design-mode-DGWN_Gkm.d.ts} +1 -1
- package/dist/{dispatch-BnAH56p4.d.ts → dispatch-BUoXRj_r.d.ts} +6 -4
- package/dist/evaluation/axis-checks/index.d.ts +7 -6
- package/dist/evaluation/axis-checks/index.js +90 -2
- package/dist/evaluation/axis-checks/index.js.map +1 -1
- package/dist/evaluation/axis-checks/registry.d.ts +6 -5
- package/dist/evaluation/axis-checks/registry.js +88 -1
- package/dist/evaluation/axis-checks/registry.js.map +1 -1
- package/dist/evaluation/index.d.ts +189 -344
- package/dist/evaluation/index.js +1272 -59
- package/dist/evaluation/index.js.map +1 -1
- package/dist/evaluation/types.d.ts +26 -1
- package/dist/evaluation/types.js.map +1 -1
- package/dist/fragments/index.d.ts +3 -3
- package/dist/{generate-9IeNT4ZY.d.ts → generate-tSO_shQ0.d.ts} +1 -1
- package/dist/harness/check/runtime-render/index.d.ts +7 -7
- package/dist/harness/check/runtime-render/index.js +416 -92
- package/dist/harness/check/runtime-render/index.js.map +1 -1
- package/dist/harness/check/runtime-render/render-check-worker.js +393 -88
- package/dist/harness/check/runtime-render/render-check-worker.js.map +1 -1
- package/dist/harness/index.d.ts +5 -6
- package/dist/harness/index.js +69 -20
- package/dist/harness/index.js.map +1 -1
- package/dist/harness/prompts.d.ts +2 -2
- package/dist/harness/prompts.js +10 -1
- package/dist/harness/prompts.js.map +1 -1
- package/dist/harness/result-types.d.ts +19 -5
- package/dist/harness/result-types.js.map +1 -1
- package/dist/harness/runtime.d.ts +21 -7
- package/dist/harness/runtime.js +36 -17
- package/dist/harness/runtime.js.map +1 -1
- package/dist/harness/types-public.d.ts +6 -4
- package/dist/harness/types-public.js +43 -18
- package/dist/harness/types-public.js.map +1 -1
- package/dist/harness/validator-trace-sink.d.ts +6 -5
- package/dist/hash.d.ts +1 -1
- package/dist/index.d.ts +64 -13
- package/dist/index.js +2023 -270
- package/dist/index.js.map +1 -1
- package/dist/patch.d.ts +5 -5
- package/dist/policy.d.ts +1 -1
- package/dist/{prompts-YU9piOgs.d.ts → prompts-BhNUuRoZ.d.ts} +1 -1
- package/dist/tools/get-primitives-ts.d.ts +1 -1
- package/dist/tools/get-primitives-ts.js +1 -1
- package/dist/tools/get-primitives-ts.js.map +1 -1
- package/dist/tools/render-check-worker.js +8 -1
- package/dist/tools/render-check-worker.js.map +1 -1
- package/dist/{types-BOvHNG7K.d.ts → types-Dh1s4K9j.d.ts} +1 -1
- package/dist/types-public-B7wn4ce5.d.ts +959 -0
- package/dist/validation/index.d.ts +1 -1
- package/dist/validation/index.js +1 -1
- package/dist/validation/index.js.map +1 -1
- package/dist/visual-evaluator-C9L-o6ap.d.ts +639 -0
- package/dist/workflows.d.ts +5 -5
- package/package.json +12 -12
- package/src/adapters/generation-dispatch.ts +38 -2
- package/src/adapters/tools.ts +2 -1
- package/src/boilerplate/free-design-prompt.ts +4 -4
- package/src/boilerplate/generate.ts +26 -1
- package/src/boilerplate/hard-sections.ts +14 -14
- package/src/boilerplate/system-prompt.ts +1 -1
- package/src/boilerplate/templates/layouts/chat-mobile.tsx.tmpl +3 -1
- package/src/boilerplate/templates/layouts/chat-universal.tsx.tmpl +3 -1
- package/src/boilerplate/templates/layouts/fullscreen-desktop.tsx.tmpl +3 -1
- package/src/boilerplate/templates/layouts/fullscreen-universal.tsx.tmpl +3 -1
- package/src/check/index.ts +1 -0
- package/src/check/run-tier0.ts +6 -3
- package/src/check/sample-props.ts +63 -0
- package/src/coding-agent/tools.ts +4 -1
- package/src/contract-context.ts +1 -1
- package/src/create-harness.ts +3 -3
- package/src/create-ui-generator.ts +20 -1
- package/src/evaluation/__fixtures__/png.ts +100 -0
- package/src/evaluation/axis-checks/checks/state-merge.ts +1 -1
- package/src/evaluation/axis-checks/checks/universal.ts +111 -0
- package/src/evaluation/axis-checks/dispatch.ts +4 -1
- package/src/evaluation/cost-tracker.ts +54 -6
- package/src/evaluation/criteria/bank.ts +85 -0
- package/src/evaluation/criteria/context.ts +52 -0
- package/src/evaluation/criteria/edge-probe.ts +286 -0
- package/src/evaluation/criteria/edge.ts +202 -0
- package/src/evaluation/criteria/resolve.ts +207 -0
- package/src/evaluation/criteria/select.ts +79 -0
- package/src/evaluation/index.ts +27 -0
- package/src/evaluation/ink-extent.ts +200 -0
- package/src/evaluation/judge-spend.ts +48 -0
- package/src/evaluation/theme-css.ts +12 -5
- package/src/evaluation/types-public.ts +221 -3
- package/src/evaluation/types.ts +19 -0
- package/src/evaluation/visual-evaluator.ts +853 -51
- package/src/harness/check/runtime-render/adapter.ts +18 -0
- package/src/harness/check/runtime-render/probe-pending.ts +111 -0
- package/src/harness/check/runtime-render/render-check-host.ts +10 -6
- package/src/harness/check/runtime-render/render-check-worker.ts +6 -3
- package/src/harness/check/runtime-render/render-check.ts +417 -133
- package/src/harness/check/runtime-render/walk.ts +200 -0
- package/src/harness/coding/assemble-result.ts +2 -0
- package/src/harness/coding/generate-task-runner.ts +30 -0
- package/src/harness/coding/init-session.ts +5 -3
- package/src/harness/coding/run-coding-turn.ts +12 -4
- package/src/harness/coding/run-eval-round.ts +82 -3
- package/src/harness/in-loop-evaluation.ts +39 -0
- package/src/harness/llm-router.ts +58 -1
- package/src/harness/result-types.ts +9 -0
- package/src/harness/runtime.ts +14 -0
- package/src/harness/types-public.ts +10 -1
- package/src/harness-tool-names.ts +38 -0
- package/src/index.ts +17 -0
- package/src/internal/verdict-line.ts +33 -0
- package/src/run-check.ts +2 -0
- package/src/taught-tokens.ts +32 -0
- package/src/tools/get-primitives-ts.ts +1 -1
- package/src/tools/get-wire.ts +1 -1
- package/src/tools/render-check-worker.ts +2 -1
- package/src/tools/render-check.ts +8 -67
- package/src/tools.ts +11 -1
- package/src/validation/primitives.ts +1 -1
- package/dist/llm-router-DwwUK1bz.d.ts +0 -265
- package/dist/types-public-Bk6d1WLN.d.ts +0 -451
package/dist/evaluation/index.js
CHANGED
|
@@ -5,7 +5,8 @@ import { z } from 'zod';
|
|
|
5
5
|
import { LUCIDE_ICON_NAMES, maxWidth } from '@ggui-ai/design';
|
|
6
6
|
import { createHash } from 'crypto';
|
|
7
7
|
import { build } from 'esbuild';
|
|
8
|
-
import { getCssTokens, composeThemeCss, fillFitRule } from '@ggui-ai/design/rendering';
|
|
8
|
+
import { EXPANDED_FRAME, getCssTokens, composeThemeCss, expandedFrameScrimDecls, fillFitRule, expandedFramePanelRule } from '@ggui-ai/design/rendering';
|
|
9
|
+
import { inflateSync } from 'zlib';
|
|
9
10
|
import { readFileSync, readdirSync, statSync, mkdtempSync, writeFileSync, rmSync, existsSync } from 'fs';
|
|
10
11
|
import { resolve, relative, dirname } from 'path';
|
|
11
12
|
import { fileURLToPath } from 'url';
|
|
@@ -815,6 +816,26 @@ async function collectLoginTurn(stream, opts) {
|
|
|
815
816
|
}
|
|
816
817
|
|
|
817
818
|
// src/harness/llm-router.ts
|
|
819
|
+
function servedModelOf(name) {
|
|
820
|
+
return typeof name === "string" && name.length > 0 ? { servedModel: name } : {};
|
|
821
|
+
}
|
|
822
|
+
function anthropicFinishReason(stopReason) {
|
|
823
|
+
if (stopReason === null || stopReason === void 0) return void 0;
|
|
824
|
+
if (stopReason === "end_turn" || stopReason === "stop_sequence") return "stop";
|
|
825
|
+
if (stopReason === "max_tokens") return "length";
|
|
826
|
+
if (stopReason === "refusal") return "content-filter";
|
|
827
|
+
return "other";
|
|
828
|
+
}
|
|
829
|
+
function googleFinishReason(finishReason) {
|
|
830
|
+
if (finishReason === null || finishReason === void 0) return void 0;
|
|
831
|
+
if (finishReason === "STOP") return "stop";
|
|
832
|
+
if (finishReason === "MAX_TOKENS") return "length";
|
|
833
|
+
if (["SAFETY", "RECITATION", "BLOCKLIST", "PROHIBITED_CONTENT", "SPII", "IMAGE_SAFETY"].includes(finishReason)) return "content-filter";
|
|
834
|
+
return "other";
|
|
835
|
+
}
|
|
836
|
+
function visionFinish(finishReason) {
|
|
837
|
+
return finishReason !== void 0 ? { finishReason } : {};
|
|
838
|
+
}
|
|
818
839
|
function addStrictSchemaConstraints(schema) {
|
|
819
840
|
const result = { ...schema };
|
|
820
841
|
if (result.type === "object" && result.properties) {
|
|
@@ -1155,7 +1176,9 @@ var AnthropicAgent = class extends LLMAgent {
|
|
|
1155
1176
|
return {
|
|
1156
1177
|
text,
|
|
1157
1178
|
inputTokens: response.usage.input_tokens,
|
|
1158
|
-
outputTokens: response.usage.output_tokens
|
|
1179
|
+
outputTokens: response.usage.output_tokens,
|
|
1180
|
+
...visionFinish(anthropicFinishReason(response.stop_reason)),
|
|
1181
|
+
...servedModelOf(response.model)
|
|
1159
1182
|
};
|
|
1160
1183
|
} catch (e) {
|
|
1161
1184
|
emitLlmTraceEvent({
|
|
@@ -1766,7 +1789,9 @@ var GoogleAgent = class extends LLMAgent {
|
|
|
1766
1789
|
text,
|
|
1767
1790
|
// ggui#1186 (third site): `promptTokenCount` includes `cachedContentTokenCount` — report the non-cached input.
|
|
1768
1791
|
inputTokens: splitCacheInclusiveUsage(usage?.promptTokenCount ?? 0, usage?.candidatesTokenCount ?? 0, usage?.cachedContentTokenCount ?? 0).tokens.input,
|
|
1769
|
-
outputTokens: usage?.candidatesTokenCount ?? 0
|
|
1792
|
+
outputTokens: usage?.candidatesTokenCount ?? 0,
|
|
1793
|
+
...visionFinish(googleFinishReason(response.candidates?.[0]?.finishReason)),
|
|
1794
|
+
...servedModelOf(response.modelVersion)
|
|
1770
1795
|
};
|
|
1771
1796
|
const endedAt = Date.now();
|
|
1772
1797
|
emitLlmTraceEvent({
|
|
@@ -2513,12 +2538,12 @@ async function runEvaluationLoop(options) {
|
|
|
2513
2538
|
const evaluationResults = [];
|
|
2514
2539
|
let currentCode = context.compiledCode;
|
|
2515
2540
|
let currentSourceCode = context.sourceCode;
|
|
2516
|
-
let
|
|
2541
|
+
let round2 = 0;
|
|
2517
2542
|
const maxRounds = Math.min(config.maxRounds ?? 3, MAX_EVAL_ROUNDS_HARD_LIMIT);
|
|
2518
|
-
while (
|
|
2519
|
-
|
|
2520
|
-
onProgress?.({ type: "evaluating", round });
|
|
2521
|
-
console.log(`[eval] Round ${
|
|
2543
|
+
while (round2 < maxRounds) {
|
|
2544
|
+
round2++;
|
|
2545
|
+
onProgress?.({ type: "evaluating", round: round2 });
|
|
2546
|
+
console.log(`[eval] Round ${round2}: evaluating...`);
|
|
2522
2547
|
const evalContext = {
|
|
2523
2548
|
...context,
|
|
2524
2549
|
sourceCode: currentSourceCode || context.sourceCode
|
|
@@ -2526,13 +2551,13 @@ async function runEvaluationLoop(options) {
|
|
|
2526
2551
|
const evalResult = await runEvaluation(evalContext, config);
|
|
2527
2552
|
evaluationResults.push(evalResult);
|
|
2528
2553
|
console.log(
|
|
2529
|
-
`[eval] Round ${
|
|
2554
|
+
`[eval] Round ${round2}: score=${evalResult.finalScore}, passed=${evalResult.passed}, issues=${evalResult.issues.length}`
|
|
2530
2555
|
);
|
|
2531
|
-
if (evalResult.passed ||
|
|
2556
|
+
if (evalResult.passed || round2 >= maxRounds) {
|
|
2532
2557
|
break;
|
|
2533
2558
|
}
|
|
2534
|
-
onProgress?.({ type: "fixing", round });
|
|
2535
|
-
console.log(`[eval] Round ${
|
|
2559
|
+
onProgress?.({ type: "fixing", round: round2 });
|
|
2560
|
+
console.log(`[eval] Round ${round2}: fixing...`);
|
|
2536
2561
|
const fixPrompt = buildFixPrompt(evalResult, context.originalPrompt);
|
|
2537
2562
|
const cliPath = process.env.CLAUDE_WRAPPER_PATH;
|
|
2538
2563
|
const env = {};
|
|
@@ -2572,7 +2597,7 @@ async function runEvaluationLoop(options) {
|
|
|
2572
2597
|
}
|
|
2573
2598
|
if (fixedCompiledCode) {
|
|
2574
2599
|
currentCode = fixedCompiledCode;
|
|
2575
|
-
console.log(`[eval] Round ${
|
|
2600
|
+
console.log(`[eval] Round ${round2}: fixed code captured (${currentCode.length} bytes)`);
|
|
2576
2601
|
}
|
|
2577
2602
|
if (fixedSourceCode) {
|
|
2578
2603
|
currentSourceCode = fixedSourceCode;
|
|
@@ -3295,6 +3320,81 @@ function runActionLabelDropped(input) {
|
|
|
3295
3320
|
)
|
|
3296
3321
|
);
|
|
3297
3322
|
}
|
|
3323
|
+
var MIN_ECHO_CHARS = 12;
|
|
3324
|
+
function collectStrings(value, out) {
|
|
3325
|
+
if (typeof value === "string") {
|
|
3326
|
+
out.push(value);
|
|
3327
|
+
} else if (Array.isArray(value)) {
|
|
3328
|
+
for (const v of value) collectStrings(v, out);
|
|
3329
|
+
} else if (value !== null && typeof value === "object") {
|
|
3330
|
+
for (const v of Object.values(value)) collectStrings(v, out);
|
|
3331
|
+
}
|
|
3332
|
+
}
|
|
3333
|
+
function fixtureOnlyStrings(input) {
|
|
3334
|
+
if (input.fixtureProps === void 0) return [];
|
|
3335
|
+
const values = [];
|
|
3336
|
+
collectStrings(input.fixtureProps, values);
|
|
3337
|
+
const stated = `${input.originalPrompt}
|
|
3338
|
+
${input.contract === void 0 ? "" : JSON.stringify(input.contract)}`;
|
|
3339
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3340
|
+
const out = [];
|
|
3341
|
+
for (const v of values) {
|
|
3342
|
+
const t = v.trim();
|
|
3343
|
+
if (t.length < MIN_ECHO_CHARS || !t.includes(" ") || seen.has(t)) continue;
|
|
3344
|
+
seen.add(t);
|
|
3345
|
+
if (stated.includes(t)) continue;
|
|
3346
|
+
out.push(t);
|
|
3347
|
+
}
|
|
3348
|
+
return out;
|
|
3349
|
+
}
|
|
3350
|
+
function runFixtureDefaultEcho(input) {
|
|
3351
|
+
if (input.compiledCode === null) return [];
|
|
3352
|
+
const src = input.sourceCode;
|
|
3353
|
+
const issues = [];
|
|
3354
|
+
for (const value of fixtureOnlyStrings(input)) {
|
|
3355
|
+
if (!src.includes(value)) continue;
|
|
3356
|
+
issues.push(
|
|
3357
|
+
mkIssue(
|
|
3358
|
+
"universal.fixture_default_echo",
|
|
3359
|
+
`A literal in the source equals a fixture value the request never stated: "${value.length > 60 ? `${value.slice(0, 57)}\u2026` : value}". The fixture is judging data; a visitor sees this copy whenever the agent leaves the prop out.`,
|
|
3360
|
+
"Take that copy from the prop it renders and drop the literal \u2014 no default, fallback or placeholder that repeats a fixture value. A default may only carry text the request itself states.",
|
|
3361
|
+
"warn"
|
|
3362
|
+
)
|
|
3363
|
+
);
|
|
3364
|
+
}
|
|
3365
|
+
return issues;
|
|
3366
|
+
}
|
|
3367
|
+
var FORM_OPEN_RX = /<form\b[^<>]*\bonSubmit\s*=[^<>]*>/g;
|
|
3368
|
+
var CONTROL_TAG_RX = /<(?:Button|button|input)\b[^<>]*>/g;
|
|
3369
|
+
var SUBMIT_TYPE_RX = /\btype\s*=\s*(?:["']submit["']|\{\s*["']submit["']\s*\})/;
|
|
3370
|
+
function submitForms(sourceCode) {
|
|
3371
|
+
const out = [];
|
|
3372
|
+
for (const m of sourceCode.matchAll(FORM_OPEN_RX)) {
|
|
3373
|
+
const start = m.index ?? 0;
|
|
3374
|
+
const end = sourceCode.indexOf("</form>", start + m[0].length);
|
|
3375
|
+
out.push(end === -1 ? sourceCode.slice(start) : sourceCode.slice(start, end + "</form>".length));
|
|
3376
|
+
}
|
|
3377
|
+
return out;
|
|
3378
|
+
}
|
|
3379
|
+
function runFormSubmitControl(input) {
|
|
3380
|
+
if (input.compiledCode === null) return [];
|
|
3381
|
+
const issues = [];
|
|
3382
|
+
for (const form of submitForms(input.sourceCode)) {
|
|
3383
|
+
const controls = [...form.matchAll(CONTROL_TAG_RX)].map((m) => m[0]);
|
|
3384
|
+
if (controls.length === 0) continue;
|
|
3385
|
+
if (controls.some((tag) => SUBMIT_TYPE_RX.test(tag))) continue;
|
|
3386
|
+
const named = controls.find((tag) => /<Button\b/.test(tag)) ?? controls[0];
|
|
3387
|
+
issues.push(
|
|
3388
|
+
mkIssue(
|
|
3389
|
+
"universal.form_submit_control",
|
|
3390
|
+
`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)}\u2026` : named} does nothing \u2014 only Enter in a field submits.`,
|
|
3391
|
+
'Give the control that submits this form type="submit" \u2014 a Button inside a form defaults to type="button" and never submits on click; keep type="button" on the others.',
|
|
3392
|
+
"fail"
|
|
3393
|
+
)
|
|
3394
|
+
);
|
|
3395
|
+
}
|
|
3396
|
+
return issues;
|
|
3397
|
+
}
|
|
3298
3398
|
var UNIVERSAL_CHECKS = [
|
|
3299
3399
|
{
|
|
3300
3400
|
id: "universal.icon_name_known",
|
|
@@ -3332,6 +3432,18 @@ var UNIVERSAL_CHECKS = [
|
|
|
3332
3432
|
values: ALL_RENDER_VALUES,
|
|
3333
3433
|
run: runTerminalActionUnguarded
|
|
3334
3434
|
},
|
|
3435
|
+
{
|
|
3436
|
+
id: "universal.form_submit_control",
|
|
3437
|
+
axis: "render",
|
|
3438
|
+
values: ALL_RENDER_VALUES,
|
|
3439
|
+
run: runFormSubmitControl
|
|
3440
|
+
},
|
|
3441
|
+
{
|
|
3442
|
+
id: "universal.fixture_default_echo",
|
|
3443
|
+
axis: "render",
|
|
3444
|
+
values: ALL_RENDER_VALUES,
|
|
3445
|
+
run: runFixtureDefaultEcho
|
|
3446
|
+
},
|
|
3335
3447
|
{
|
|
3336
3448
|
id: "universal.accent_text_ink",
|
|
3337
3449
|
axis: "render",
|
|
@@ -3455,7 +3567,7 @@ function runMapKeyIsId(input) {
|
|
|
3455
3567
|
mkIssue(
|
|
3456
3568
|
"render.map_key_is_id",
|
|
3457
3569
|
`Array key uses index variable "${key}" \u2014 reorders and stream merges will break React reconciliation.`,
|
|
3458
|
-
`Replace key={${key}} with key={item.id}
|
|
3570
|
+
`Replace key={${key}} with key={item.id} \u2014 or, when the items carry no id, with the field that names each one (item.label / item.symbol / item.name).`
|
|
3459
3571
|
)
|
|
3460
3572
|
);
|
|
3461
3573
|
}
|
|
@@ -4391,7 +4503,8 @@ function runAxisChecks(classification, input) {
|
|
|
4391
4503
|
originalPrompt: input.originalPrompt,
|
|
4392
4504
|
classification,
|
|
4393
4505
|
...input.designMode !== void 0 ? { designMode: input.designMode } : {},
|
|
4394
|
-
...input.canvas !== void 0 ? { canvas: input.canvas } : {}
|
|
4506
|
+
...input.canvas !== void 0 ? { canvas: input.canvas } : {},
|
|
4507
|
+
...input.fixtureProps !== void 0 ? { fixtureProps: input.fixtureProps } : {}
|
|
4395
4508
|
};
|
|
4396
4509
|
if (input.compiledCode === null) {
|
|
4397
4510
|
traceAxisChecksSkipped(facts, "compiledCode null \u2014 no check ran");
|
|
@@ -4498,6 +4611,337 @@ function buildStylingProfileJudgeBlock(profile) {
|
|
|
4498
4611
|
if (quote.length === 0) return "";
|
|
4499
4612
|
return ["## Declared styling profile", "", STYLING_PROFILE_JUDGE_FRAME, "", quote].join("\n");
|
|
4500
4613
|
}
|
|
4614
|
+
var CRITERION_ID = /^[a-z][a-z0-9-]*(?:\.[a-zA-Z][a-zA-Z0-9-]*)+$/;
|
|
4615
|
+
var criteriaScopeSchema = z.object({
|
|
4616
|
+
kind: z.string().min(1).optional(),
|
|
4617
|
+
canvas: z.string().min(1).optional(),
|
|
4618
|
+
preset: z.string().min(1).optional()
|
|
4619
|
+
});
|
|
4620
|
+
var bankRowSchema = z.object({
|
|
4621
|
+
id: z.string().regex(CRITERION_ID, "a criterion id is family.property[.facet], lower-case family"),
|
|
4622
|
+
group: z.string().optional(),
|
|
4623
|
+
scope: criteriaScopeSchema.default({}),
|
|
4624
|
+
/** Importance, independent of whether anything automated checks it yet. */
|
|
4625
|
+
severity: z.enum(["must", "should"]),
|
|
4626
|
+
/** The method that produces the verdict. */
|
|
4627
|
+
evaluation: z.enum(["instrument", "judge", "human"]),
|
|
4628
|
+
/** The method's readiness — the only source of an `n/a`. */
|
|
4629
|
+
status: z.enum(["live", "reference", "planned", "manual"]),
|
|
4630
|
+
/** The question, answerable yes/no from the frame. */
|
|
4631
|
+
text: z.string().min(1),
|
|
4632
|
+
/** The evidence rule: what the one-sentence evidence must name (a region or element). */
|
|
4633
|
+
evidence: z.string().min(1),
|
|
4634
|
+
/** The templated fix line a feedback channel may consume later; never read in v1. */
|
|
4635
|
+
fix: z.string().optional()
|
|
4636
|
+
});
|
|
4637
|
+
var criteriaBankSchema = z.object({
|
|
4638
|
+
version: z.string().min(1),
|
|
4639
|
+
applies: z.object({
|
|
4640
|
+
/** `["*"]` = every kind. */
|
|
4641
|
+
kind: z.array(z.string().min(1)).optional(),
|
|
4642
|
+
canvases: z.array(z.string().min(1)).optional()
|
|
4643
|
+
}).optional(),
|
|
4644
|
+
criteria: z.array(bankRowSchema).min(1),
|
|
4645
|
+
recipes: z.array(bankRowSchema).optional()
|
|
4646
|
+
}).superRefine((bank, ctx) => {
|
|
4647
|
+
const seen = /* @__PURE__ */ new Set();
|
|
4648
|
+
const check = (rows, key) => {
|
|
4649
|
+
rows.forEach((c, i) => {
|
|
4650
|
+
if (seen.has(c.id)) ctx.addIssue({ code: "custom", path: [key, i, "id"], message: `duplicate criterion id ${c.id}` });
|
|
4651
|
+
seen.add(c.id);
|
|
4652
|
+
});
|
|
4653
|
+
};
|
|
4654
|
+
check(bank.criteria, "criteria");
|
|
4655
|
+
check(bank.recipes ?? [], "recipes");
|
|
4656
|
+
});
|
|
4657
|
+
function bankRows(bank) {
|
|
4658
|
+
return [...bank.criteria, ...bank.recipes ?? []];
|
|
4659
|
+
}
|
|
4660
|
+
function parseCriteriaBank(raw) {
|
|
4661
|
+
const parsed = criteriaBankSchema.safeParse(raw);
|
|
4662
|
+
if (!parsed.success) {
|
|
4663
|
+
const issues = parsed.error.issues.map((i) => `${i.path.map(String).join(".") || "<root>"}: ${i.message}`).join("; ");
|
|
4664
|
+
throw new Error(`criteria bank invalid \u2014 ${issues}`);
|
|
4665
|
+
}
|
|
4666
|
+
return parsed.data;
|
|
4667
|
+
}
|
|
4668
|
+
|
|
4669
|
+
// src/evaluation/criteria/select.ts
|
|
4670
|
+
var CRITERIA_SELECTOR_VERSION = "selector@2";
|
|
4671
|
+
function canonicalCriteriaContext(ctx) {
|
|
4672
|
+
const sortKeys = (value) => value !== null && typeof value === "object" && !Array.isArray(value) ? Object.fromEntries(
|
|
4673
|
+
Object.entries(value).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([k, v]) => [k, sortKeys(v)])
|
|
4674
|
+
) : value;
|
|
4675
|
+
return JSON.stringify(sortKeys(ctx));
|
|
4676
|
+
}
|
|
4677
|
+
function criteriaSetIdFor(bankVersion, ctx) {
|
|
4678
|
+
return createHash("sha256").update(`${bankVersion}|${CRITERIA_SELECTOR_VERSION}|${canonicalCriteriaContext(ctx)}`, "utf8").digest("hex").slice(0, 16);
|
|
4679
|
+
}
|
|
4680
|
+
function matchedScope(row, ctx) {
|
|
4681
|
+
const s = row.scope;
|
|
4682
|
+
const matched = [];
|
|
4683
|
+
if (s.kind !== void 0) {
|
|
4684
|
+
if (ctx.kind !== s.kind) return null;
|
|
4685
|
+
matched.push(`kind=${s.kind}`);
|
|
4686
|
+
}
|
|
4687
|
+
if (s.canvas !== void 0) {
|
|
4688
|
+
if (ctx.canvas !== s.canvas) return null;
|
|
4689
|
+
matched.push(`canvas=${s.canvas}`);
|
|
4690
|
+
}
|
|
4691
|
+
if (s.preset !== void 0) {
|
|
4692
|
+
if (ctx.preset !== s.preset) return null;
|
|
4693
|
+
matched.push(`preset=${s.preset}`);
|
|
4694
|
+
}
|
|
4695
|
+
return matched;
|
|
4696
|
+
}
|
|
4697
|
+
function selectCriteria(bank, ctx) {
|
|
4698
|
+
const criteriaSetId = criteriaSetIdFor(bank.version, ctx);
|
|
4699
|
+
const applies = bank.applies;
|
|
4700
|
+
if (applies?.canvases !== void 0 && !applies.canvases.includes(ctx.canvas)) return { criteriaSetId, selection: [] };
|
|
4701
|
+
if (applies?.kind !== void 0 && ctx.kind !== void 0 && !applies.kind.includes("*") && !applies.kind.includes(ctx.kind)) {
|
|
4702
|
+
return { criteriaSetId, selection: [] };
|
|
4703
|
+
}
|
|
4704
|
+
const selection = [];
|
|
4705
|
+
for (const row of bankRows(bank)) {
|
|
4706
|
+
const matched = matchedScope(row, ctx);
|
|
4707
|
+
if (matched === null) continue;
|
|
4708
|
+
selection.push(matched.length === 0 ? { id: row.id, source: "static", reason: "static" } : { id: row.id, source: "context", reason: `context: ${matched.join(", ")}` });
|
|
4709
|
+
}
|
|
4710
|
+
return { criteriaSetId, selection };
|
|
4711
|
+
}
|
|
4712
|
+
|
|
4713
|
+
// src/evaluation/criteria/resolve.ts
|
|
4714
|
+
var INSTRUMENT_BY_ID = {
|
|
4715
|
+
"comp.fit": "fit",
|
|
4716
|
+
"floor.fit": "fit",
|
|
4717
|
+
"finish.canvas.inhabited": "fill"
|
|
4718
|
+
};
|
|
4719
|
+
var INSTRUMENT_BESIDE_JUDGE = {
|
|
4720
|
+
"space.edge": "edge"
|
|
4721
|
+
};
|
|
4722
|
+
function besideRead(kind, m) {
|
|
4723
|
+
if (m.edge === void 0) return { verdict: "n/a", evidence: "not measured: this capture had no page to read" };
|
|
4724
|
+
if (m.edge === null) return { verdict: "n/a", evidence: "edge probe failed on this capture" };
|
|
4725
|
+
return { verdict: m.edge.verdict, evidence: m.edge.evidence };
|
|
4726
|
+
}
|
|
4727
|
+
function majority(id, answers, unansweredReason) {
|
|
4728
|
+
const votes = answers.flatMap((a) => a.filter((x) => x.id === id));
|
|
4729
|
+
if (votes.length === 0) return { verdict: "n/a", evidence: unansweredReason ?? "not answered" };
|
|
4730
|
+
const counts = /* @__PURE__ */ new Map();
|
|
4731
|
+
for (const v of votes) counts.set(v.verdict, (counts.get(v.verdict) ?? 0) + 1);
|
|
4732
|
+
const ranked = [...counts.entries()].sort((a, b) => b[1] - a[1]);
|
|
4733
|
+
const top = ranked[0];
|
|
4734
|
+
if (ranked.length > 1 && ranked[1][1] === top[1]) return { verdict: "n/a", evidence: "no majority" };
|
|
4735
|
+
const evidence = votes.find((v) => v.verdict === top[0])?.evidence ?? "";
|
|
4736
|
+
return { verdict: top[0], evidence };
|
|
4737
|
+
}
|
|
4738
|
+
function instrumentRead(kind, m) {
|
|
4739
|
+
if (kind === "fit") {
|
|
4740
|
+
if (m.contentHeight === null) return { verdict: "n/a", evidence: "content height unmeasurable" };
|
|
4741
|
+
return { verdict: m.overflow ? "fail" : "pass", evidence: `content ${m.contentHeight}px against a ${m.viewportHeight}px box` };
|
|
4742
|
+
}
|
|
4743
|
+
if (m.inkRatio === null) return { verdict: "n/a", evidence: "capture unreadable" };
|
|
4744
|
+
return { verdict: "n/a", evidence: `fill ${m.inkRatio.toFixed(3)} reported; no floor ruled` };
|
|
4745
|
+
}
|
|
4746
|
+
function readRow(row, answers, m, unansweredReason) {
|
|
4747
|
+
if (row.evaluation === "judge") return majority(row.id, answers, unansweredReason);
|
|
4748
|
+
if (row.evaluation === "human") return { verdict: "n/a", evidence: `reader's column (status ${row.status})` };
|
|
4749
|
+
if (row.status !== "live") return { verdict: "n/a", evidence: `not implemented (status ${row.status})` };
|
|
4750
|
+
const kind = INSTRUMENT_BY_ID[row.id];
|
|
4751
|
+
return kind === void 0 ? { verdict: "n/a", evidence: "instrument not wired (status live, no binding)" } : instrumentRead(kind, m);
|
|
4752
|
+
}
|
|
4753
|
+
function resolveCriteriaBlock(args) {
|
|
4754
|
+
const byId = new Map(bankRows(args.bank).map((r) => [r.id, r]));
|
|
4755
|
+
const verdicts = [];
|
|
4756
|
+
for (const s of args.selected.selection) {
|
|
4757
|
+
const row = byId.get(s.id);
|
|
4758
|
+
if (row === void 0) continue;
|
|
4759
|
+
const read = readRow(row, args.answers, args.measurements, args.unansweredReason);
|
|
4760
|
+
const beside = INSTRUMENT_BESIDE_JUDGE[row.id];
|
|
4761
|
+
verdicts.push({
|
|
4762
|
+
id: row.id,
|
|
4763
|
+
severity: row.severity,
|
|
4764
|
+
method: row.evaluation,
|
|
4765
|
+
status: row.status,
|
|
4766
|
+
source: s.source,
|
|
4767
|
+
verdict: read.verdict,
|
|
4768
|
+
evidence: read.evidence,
|
|
4769
|
+
...beside !== void 0 && row.evaluation === "judge" ? { instrument: besideRead(beside, args.measurements) } : {}
|
|
4770
|
+
});
|
|
4771
|
+
}
|
|
4772
|
+
return {
|
|
4773
|
+
criteriaSetId: args.selected.criteriaSetId,
|
|
4774
|
+
bankVersion: args.bank.version,
|
|
4775
|
+
selectorVersion: CRITERIA_SELECTOR_VERSION,
|
|
4776
|
+
context: args.context,
|
|
4777
|
+
selection: args.selected.selection,
|
|
4778
|
+
verdicts,
|
|
4779
|
+
...args.call !== void 0 ? {
|
|
4780
|
+
call: {
|
|
4781
|
+
answer: args.call.answer,
|
|
4782
|
+
// Kept answers whose id names no row of the bank: the judge answered something nobody asked.
|
|
4783
|
+
unknownIds: args.answers.flat().filter((a) => !byId.has(a.id)).length,
|
|
4784
|
+
...args.call.finishReason !== void 0 ? { finishReason: args.call.finishReason } : {}
|
|
4785
|
+
}
|
|
4786
|
+
} : {}
|
|
4787
|
+
};
|
|
4788
|
+
}
|
|
4789
|
+
var CRITERIA_PROPS_MAX_CHARS = 2e3;
|
|
4790
|
+
function criteriaFrameLine(frame) {
|
|
4791
|
+
const box = `${frame.width}\xD7${frame.height}`;
|
|
4792
|
+
if (frame.canvas === "xs-chat-card") {
|
|
4793
|
+
const framedBy = frame.hostLabel !== void 0 ? `inside the host's own frame (${frame.hostLabel}), on the host's ground` : "inside a generic stand-in for the host's frame \u2014 the card's surface with a thin ring and rounded corners, on the page ground";
|
|
4794
|
+
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.`;
|
|
4795
|
+
}
|
|
4796
|
+
return `This frame is ${box}, a fixed full-screen box the card fills${frame.hostLabel !== void 0 ? ` (${frame.hostLabel})` : ""}: judge composition and space against the whole box, edge to edge.`;
|
|
4797
|
+
}
|
|
4798
|
+
function criteriaPropsSection(propsJson) {
|
|
4799
|
+
const text = propsJson.length > CRITERIA_PROPS_MAX_CHARS ? `${propsJson.slice(0, CRITERIA_PROPS_MAX_CHARS)}\u2026 (truncated)` : propsJson;
|
|
4800
|
+
return `The one input besides the frame \u2014 the props this card was rendered with. Read them ONLY for rows whose evidence names props (for example, whether a rendered string came from them); every other row is read off the frame alone:
|
|
4801
|
+
${text}
|
|
4802
|
+
`;
|
|
4803
|
+
}
|
|
4804
|
+
function buildCriteriaJudgeBlock(bank, selected, inputs = {}) {
|
|
4805
|
+
const byId = new Map(bankRows(bank).map((r) => [r.id, r]));
|
|
4806
|
+
const asked = selected.selection.map((s) => byId.get(s.id)).filter((r) => r !== void 0 && r.evaluation === "judge");
|
|
4807
|
+
if (asked.length === 0) return "";
|
|
4808
|
+
const lines = asked.map((r) => `- ${r.id} (${r.severity}): ${r.text} Evidence must name: ${r.evidence}`);
|
|
4809
|
+
return "## Criteria \u2014 answer each one from the FRAME only\nRead these off the screenshot. Never infer them from the request, the styling profile or any direction text above.\n" + (inputs.frame !== void 0 ? `${criteriaFrameLine(inputs.frame)}
|
|
4810
|
+
` : "") + (inputs.propsJson !== void 0 ? criteriaPropsSection(inputs.propsJson) : "") + `${lines.join("\n")}
|
|
4811
|
+
Add to your JSON a top-level "criteria" array with exactly these ids: [{ "id": "<id>", "verdict": "pass" | "fail" | "n/a", "evidence": "<one sentence naming the region or element you read it from>" }]. Every row here was selected as applicable to this card, so "n/a" never means "does not apply": use it ONLY when this frame cannot show the criterion \u2014 it is then recorded as not evaluated.`;
|
|
4812
|
+
}
|
|
4813
|
+
var INK_TOLERANCE = 8;
|
|
4814
|
+
var PNG_SIGNATURE = [137, 80, 78, 71, 13, 10, 26, 10];
|
|
4815
|
+
function decodePng(png) {
|
|
4816
|
+
const buf = Buffer.from(png.buffer, png.byteOffset, png.byteLength);
|
|
4817
|
+
if (buf.length < 8 + 25) return { reason: `not a PNG: ${buf.length} bytes` };
|
|
4818
|
+
for (let i = 0; i < PNG_SIGNATURE.length; i++) {
|
|
4819
|
+
if (buf[i] !== PNG_SIGNATURE[i]) return { reason: "not a PNG: signature mismatch" };
|
|
4820
|
+
}
|
|
4821
|
+
let width = 0;
|
|
4822
|
+
let height = 0;
|
|
4823
|
+
let channels = null;
|
|
4824
|
+
const idat = [];
|
|
4825
|
+
let offset = 8;
|
|
4826
|
+
while (offset + 8 <= buf.length) {
|
|
4827
|
+
const length = buf.readUInt32BE(offset);
|
|
4828
|
+
const type = buf.toString("latin1", offset + 4, offset + 8);
|
|
4829
|
+
const start = offset + 8;
|
|
4830
|
+
if (start + length + 4 > buf.length) return { reason: `truncated PNG: ${type} chunk runs past the end` };
|
|
4831
|
+
const data = buf.subarray(start, start + length);
|
|
4832
|
+
if (type === "IHDR") {
|
|
4833
|
+
if (length !== 13) return { reason: `malformed IHDR: ${length} bytes` };
|
|
4834
|
+
width = data.readUInt32BE(0);
|
|
4835
|
+
height = data.readUInt32BE(4);
|
|
4836
|
+
const bitDepth = data[8];
|
|
4837
|
+
const colorType = data[9];
|
|
4838
|
+
const interlace = data[12];
|
|
4839
|
+
if (bitDepth !== 8) return { reason: `unsupported PNG: bit depth ${bitDepth}` };
|
|
4840
|
+
if (interlace !== 0) return { reason: "unsupported PNG: interlaced" };
|
|
4841
|
+
if (colorType === 2) channels = 3;
|
|
4842
|
+
else if (colorType === 6) channels = 4;
|
|
4843
|
+
else return { reason: `unsupported PNG: colour type ${colorType}` };
|
|
4844
|
+
} else if (type === "IDAT") {
|
|
4845
|
+
idat.push(data);
|
|
4846
|
+
} else if (type === "IEND") {
|
|
4847
|
+
break;
|
|
4848
|
+
}
|
|
4849
|
+
offset = start + length + 4;
|
|
4850
|
+
}
|
|
4851
|
+
if (channels === null) return { reason: "malformed PNG: no IHDR" };
|
|
4852
|
+
if (width === 0 || height === 0) return { reason: `empty PNG: ${width}\xD7${height}` };
|
|
4853
|
+
if (idat.length === 0) return { reason: "malformed PNG: no IDAT" };
|
|
4854
|
+
let raw;
|
|
4855
|
+
try {
|
|
4856
|
+
raw = inflateSync(Buffer.concat(idat));
|
|
4857
|
+
} catch (error) {
|
|
4858
|
+
return { reason: `corrupt IDAT stream: ${error instanceof Error ? error.message : String(error)}` };
|
|
4859
|
+
}
|
|
4860
|
+
const stride = width * channels;
|
|
4861
|
+
if (raw.length !== (stride + 1) * height) {
|
|
4862
|
+
return { reason: `malformed PNG: ${raw.length} raw bytes for ${width}\xD7${height}\xD7${channels}` };
|
|
4863
|
+
}
|
|
4864
|
+
const pixels = new Uint8Array(stride * height);
|
|
4865
|
+
const bpp = channels;
|
|
4866
|
+
for (let y = 0; y < height; y++) {
|
|
4867
|
+
const filter = raw[y * (stride + 1)];
|
|
4868
|
+
const inRow = y * (stride + 1) + 1;
|
|
4869
|
+
const outRow = y * stride;
|
|
4870
|
+
const prevRow = outRow - stride;
|
|
4871
|
+
for (let i = 0; i < stride; i++) {
|
|
4872
|
+
const x = raw[inRow + i];
|
|
4873
|
+
const a = i >= bpp ? pixels[outRow + i - bpp] : 0;
|
|
4874
|
+
const b = y > 0 ? pixels[prevRow + i] : 0;
|
|
4875
|
+
const c = y > 0 && i >= bpp ? pixels[prevRow + i - bpp] : 0;
|
|
4876
|
+
let value;
|
|
4877
|
+
switch (filter) {
|
|
4878
|
+
case 0:
|
|
4879
|
+
value = x;
|
|
4880
|
+
break;
|
|
4881
|
+
case 1:
|
|
4882
|
+
value = x + a;
|
|
4883
|
+
break;
|
|
4884
|
+
case 2:
|
|
4885
|
+
value = x + b;
|
|
4886
|
+
break;
|
|
4887
|
+
case 3:
|
|
4888
|
+
value = x + (a + b >> 1);
|
|
4889
|
+
break;
|
|
4890
|
+
case 4: {
|
|
4891
|
+
const p = a + b - c;
|
|
4892
|
+
const pa = Math.abs(p - a);
|
|
4893
|
+
const pb = Math.abs(p - b);
|
|
4894
|
+
const pc = Math.abs(p - c);
|
|
4895
|
+
value = x + (pa <= pb && pa <= pc ? a : pb <= pc ? b : c);
|
|
4896
|
+
break;
|
|
4897
|
+
}
|
|
4898
|
+
default:
|
|
4899
|
+
return { reason: `malformed PNG: scanline filter ${filter} at row ${y}` };
|
|
4900
|
+
}
|
|
4901
|
+
pixels[outRow + i] = value & 255;
|
|
4902
|
+
}
|
|
4903
|
+
}
|
|
4904
|
+
return { width, height, channels, pixels };
|
|
4905
|
+
}
|
|
4906
|
+
function readInkExtent(png, insetPx = 0) {
|
|
4907
|
+
const image = decodePng(png);
|
|
4908
|
+
if ("reason" in image) return image;
|
|
4909
|
+
const region = { x: insetPx, y: insetPx, width: image.width - 2 * insetPx, height: image.height - 2 * insetPx };
|
|
4910
|
+
if (insetPx < 0 || region.width <= 0 || region.height <= 0) {
|
|
4911
|
+
return { reason: `an inset of ${insetPx}px leaves nothing of a ${image.width}\xD7${image.height} image` };
|
|
4912
|
+
}
|
|
4913
|
+
const { channels, pixels, width } = image;
|
|
4914
|
+
const counts = /* @__PURE__ */ new Map();
|
|
4915
|
+
let ground = 0;
|
|
4916
|
+
let groundCount = 0;
|
|
4917
|
+
for (let y = region.y; y < region.y + region.height; y++) {
|
|
4918
|
+
for (let x = region.x; x < region.x + region.width; x++) {
|
|
4919
|
+
const i = (y * width + x) * channels;
|
|
4920
|
+
const key = pixels[i] << 16 | pixels[i + 1] << 8 | pixels[i + 2];
|
|
4921
|
+
const n = (counts.get(key) ?? 0) + 1;
|
|
4922
|
+
counts.set(key, n);
|
|
4923
|
+
if (n > groundCount) {
|
|
4924
|
+
groundCount = n;
|
|
4925
|
+
ground = key;
|
|
4926
|
+
}
|
|
4927
|
+
}
|
|
4928
|
+
}
|
|
4929
|
+
const gr = ground >> 16 & 255;
|
|
4930
|
+
const gg = ground >> 8 & 255;
|
|
4931
|
+
const gb = ground & 255;
|
|
4932
|
+
let lastInkRow = null;
|
|
4933
|
+
for (let y = region.y; y < region.y + region.height; y++) {
|
|
4934
|
+
for (let x = region.x; x < region.x + region.width; x++) {
|
|
4935
|
+
const i = (y * width + x) * channels;
|
|
4936
|
+
if (Math.abs(pixels[i] - gr) > INK_TOLERANCE || Math.abs(pixels[i + 1] - gg) > INK_TOLERANCE || Math.abs(pixels[i + 2] - gb) > INK_TOLERANCE) {
|
|
4937
|
+
lastInkRow = y - region.y;
|
|
4938
|
+
break;
|
|
4939
|
+
}
|
|
4940
|
+
}
|
|
4941
|
+
}
|
|
4942
|
+
const ratio = lastInkRow === null ? 0 : Math.round((lastInkRow + 1) / region.height * 1e3) / 1e3;
|
|
4943
|
+
return { ratio, lastInkRow, region, ground: [gr, gg, gb] };
|
|
4944
|
+
}
|
|
4501
4945
|
function isDesignRenderingInput(relativePath) {
|
|
4502
4946
|
const rel = relativePath.split("\\").join("/");
|
|
4503
4947
|
if (rel.startsWith("__tests__/") || rel.includes("/__tests__/")) return false;
|
|
@@ -4544,10 +4988,360 @@ function judgeDesignIdentity(src) {
|
|
|
4544
4988
|
function resetJudgeDesignIdentityCache() {
|
|
4545
4989
|
identityCache.clear();
|
|
4546
4990
|
}
|
|
4991
|
+
|
|
4992
|
+
// src/evaluation/criteria/edge.ts
|
|
4993
|
+
var EDGE_TOLERANCE_PX = 2;
|
|
4994
|
+
var CARD = "card";
|
|
4995
|
+
var same = (a, b) => Math.abs(a - b) <= EDGE_TOLERANCE_PX;
|
|
4996
|
+
var overlaps = (a, b) => a.left < b.right && b.left < a.right;
|
|
4997
|
+
var axis = (b) => (b.left + b.right) / 2;
|
|
4998
|
+
function contentEdge(surface, blocks) {
|
|
4999
|
+
const texts = blocks.filter((b) => b.within === surface.id && b.kind !== "surface" && b.role !== "centred");
|
|
5000
|
+
if (texts.length === 0) return void 0;
|
|
5001
|
+
const first = texts.reduce((a, b) => b.top < a.top ? b : a);
|
|
5002
|
+
return first.textEdge ?? first.edge;
|
|
5003
|
+
}
|
|
5004
|
+
function columns(direct) {
|
|
5005
|
+
const all = direct.filter((b) => b.kind !== "surface");
|
|
5006
|
+
const bridges = (b) => {
|
|
5007
|
+
const under = all.filter((x) => x !== b && overlaps(x, b));
|
|
5008
|
+
return under.some((x, i) => under.slice(i + 1).some((y) => !overlaps(x, y)));
|
|
5009
|
+
};
|
|
5010
|
+
const flow = all.filter((b) => !bridges(b));
|
|
5011
|
+
const parentPool = all.filter((b) => bridges(b));
|
|
5012
|
+
const parent = flow.map((_, i) => i);
|
|
5013
|
+
const find = (i) => parent[i] === i ? i : parent[i] = find(parent[i]);
|
|
5014
|
+
for (let i = 0; i < flow.length; i += 1) for (let j = i + 1; j < flow.length; j += 1) if (overlaps(flow[i], flow[j])) parent[find(i)] = find(j);
|
|
5015
|
+
const groups = /* @__PURE__ */ new Map();
|
|
5016
|
+
flow.forEach((b, i) => groups.set(find(i), [...groups.get(find(i)) ?? [], b]));
|
|
5017
|
+
const cols = [...groups.values()];
|
|
5018
|
+
for (const s of direct.filter((b) => b.kind === "surface")) {
|
|
5019
|
+
const hit = cols.filter((c) => c.some((b) => b.kind !== "surface" && overlaps(b, s)));
|
|
5020
|
+
if (hit.length === 1) hit[0].push(s);
|
|
5021
|
+
else parentPool.push(s);
|
|
5022
|
+
}
|
|
5023
|
+
const lp = parentPool.map((_, i) => i);
|
|
5024
|
+
const lfind = (i) => lp[i] === i ? i : lp[i] = lfind(lp[i]);
|
|
5025
|
+
for (let i = 0; i < parentPool.length; i += 1) for (let j = i + 1; j < parentPool.length; j += 1) if (overlaps(parentPool[i], parentPool[j])) lp[lfind(i)] = lfind(j);
|
|
5026
|
+
const lgroups = /* @__PURE__ */ new Map();
|
|
5027
|
+
parentPool.forEach((b, i) => lgroups.set(lfind(i), [...lgroups.get(lfind(i)) ?? [], b]));
|
|
5028
|
+
cols.push(...lgroups.values());
|
|
5029
|
+
return cols;
|
|
5030
|
+
}
|
|
5031
|
+
function checkColumn(container, col, blocks) {
|
|
5032
|
+
const surfaces = col.filter((b) => b.kind === "surface");
|
|
5033
|
+
const grounds = surfaces.flatMap((s) => {
|
|
5034
|
+
const c = s.role === "table" ? void 0 : contentEdge(s, blocks);
|
|
5035
|
+
return c === void 0 ? [] : [c];
|
|
5036
|
+
});
|
|
5037
|
+
const flow = col.filter((b) => b.kind !== "surface" && b.role !== "centred" && b.role !== "list-item");
|
|
5038
|
+
const listItems = col.filter((b) => b.role === "list-item");
|
|
5039
|
+
const candidates = [...flow.flatMap((b) => [b.edge, ...b.textEdge !== void 0 ? [b.textEdge] : []]), ...surfaces.flatMap((s) => [s.edge]), ...grounds];
|
|
5040
|
+
const blockEdges = (b) => [b.edge, ...b.textEdge !== void 0 ? [b.textEdge] : []];
|
|
5041
|
+
const endEdgesBesides = (b) => [...surfaces.map((s) => s.right), ...flow.filter((x) => x !== b && x.kind === "controls").map((x) => x.right)];
|
|
5042
|
+
let best = candidates.length === 0 ? [] : null;
|
|
5043
|
+
for (const E of candidates) {
|
|
5044
|
+
const allowed = [E, ...grounds];
|
|
5045
|
+
const off = [];
|
|
5046
|
+
for (const b of flow) {
|
|
5047
|
+
if (blockEdges(b).some((x) => allowed.some((a) => same(x, a)))) continue;
|
|
5048
|
+
if (b.kind === "controls" && endEdgesBesides(b).some((r) => same(b.right, r))) continue;
|
|
5049
|
+
const nearest = allowed.reduce((n, a) => Math.abs(a - b.edge) < Math.abs(n - b.edge) ? a : n);
|
|
5050
|
+
off.push({ container, block: b.id, at: b.edge, nearest, offPx: Math.abs(b.edge - nearest) });
|
|
5051
|
+
}
|
|
5052
|
+
for (const s of surfaces) {
|
|
5053
|
+
const c = s.role === "table" ? void 0 : contentEdge(s, blocks);
|
|
5054
|
+
if (same(s.edge, E) || c !== void 0 && same(c, E)) continue;
|
|
5055
|
+
const nearest = c !== void 0 && Math.abs(c - E) < Math.abs(s.edge - E) ? c : s.edge;
|
|
5056
|
+
off.push({ container, block: s.id, at: nearest, nearest: E, offPx: Math.abs(nearest - E) });
|
|
5057
|
+
}
|
|
5058
|
+
if (best === null || off.length < best.length || off.length === best.length && sum(off) < sum(best)) best = off;
|
|
5059
|
+
if (best.length === 0) break;
|
|
5060
|
+
}
|
|
5061
|
+
const offsets = best ?? [];
|
|
5062
|
+
if (listItems.length > 1) {
|
|
5063
|
+
const e0 = listItems[0].edge;
|
|
5064
|
+
for (const li of listItems.slice(1)) if (!same(li.edge, e0)) offsets.push({ container, block: li.id, at: li.edge, nearest: e0, offPx: Math.abs(li.edge - e0) });
|
|
5065
|
+
}
|
|
5066
|
+
const centred = col.filter((b) => b.role === "centred");
|
|
5067
|
+
const edgeAligned = col.filter((b) => b.role !== "centred");
|
|
5068
|
+
const groupTop = centred.length > 0 ? Math.min(...centred.map((c) => c.top)) : 0;
|
|
5069
|
+
const groupEndsColumn = centred.length > 1 && edgeAligned.every((b) => b.top < groupTop);
|
|
5070
|
+
if (centred.length > 0 && edgeAligned.some((b) => b.kind !== "surface") && !groupEndsColumn) {
|
|
5071
|
+
const E = flow[0]?.edge ?? edgeAligned[0].edge;
|
|
5072
|
+
for (const c of centred) offsets.push({ container, block: c.id, at: c.edge, nearest: E, offPx: Math.abs(c.edge - E), reason: "a centred block in an edge-aligned column" });
|
|
5073
|
+
} else if (centred.length > 1) {
|
|
5074
|
+
const a0 = axis(centred[0]);
|
|
5075
|
+
for (const c of centred.slice(1)) if (!same(axis(c), a0)) offsets.push({ container, block: c.id, at: axis(c), nearest: a0, offPx: Math.abs(axis(c) - a0) });
|
|
5076
|
+
}
|
|
5077
|
+
return offsets;
|
|
5078
|
+
}
|
|
5079
|
+
var sum = (o) => o.reduce((s, x) => s + x.offPx, 0);
|
|
5080
|
+
function judgeEdges(measured, opts = {}) {
|
|
5081
|
+
if (opts.placeholdersOnly === true) return { verdict: "n/a", offsets: [], evidence: "only loading placeholders are shown; not evaluated" };
|
|
5082
|
+
let blocks = measured;
|
|
5083
|
+
const kept = blocks.filter((b) => b.role !== "placeholder");
|
|
5084
|
+
const content = kept.filter((b) => b.kind !== "surface").length;
|
|
5085
|
+
if (content < 2) return { verdict: "n/a", offsets: [], evidence: kept.length === 0 ? "no blocks were measured" : "fewer than two blocks once placeholders are set aside; not evaluated" };
|
|
5086
|
+
blocks = kept;
|
|
5087
|
+
const offsets = [];
|
|
5088
|
+
const containers = [CARD, ...blocks.filter((b) => b.kind === "surface" && b.role !== "table").map((b) => b.id)];
|
|
5089
|
+
for (const container of containers) {
|
|
5090
|
+
const direct = blocks.filter((b) => (b.within ?? CARD) === container);
|
|
5091
|
+
for (const col of columns(direct)) offsets.push(...checkColumn(container, col, blocks));
|
|
5092
|
+
}
|
|
5093
|
+
if (offsets.length === 0) return { verdict: "pass", offsets, evidence: "every column shares its edge, or sits on a stacked surface\u2019s content edge, within 2 px" };
|
|
5094
|
+
const worst = offsets.reduce((a, b) => b.offPx > a.offPx ? b : a);
|
|
5095
|
+
const lines = offsets.map((o) => `${o.block} at x${round(o.at)} is ${round(o.offPx)} px from x${round(o.nearest)} (in ${o.container}${o.reason !== void 0 ? `; ${o.reason}` : ""})`);
|
|
5096
|
+
return { verdict: "fail", offsets, evidence: `${offsets.length} block(s) off the allowed edges, largest ${round(worst.offPx)} px: ${lines.join("; ")}` };
|
|
5097
|
+
}
|
|
5098
|
+
var round = (n) => (Math.round(n * 10) / 10).toString();
|
|
5099
|
+
|
|
5100
|
+
// src/evaluation/criteria/edge-probe.ts
|
|
5101
|
+
var EDGE_BLOCKS_EXPRESSION = String.raw`(() => {
|
|
5102
|
+
const root = document.getElementById('root');
|
|
5103
|
+
if (!root) return null;
|
|
5104
|
+
const rootBox = root.getBoundingClientRect();
|
|
5105
|
+
const x0 = rootBox.left;
|
|
5106
|
+
const px = (v) => Math.round((v - x0) * 2) / 2;
|
|
5107
|
+
const CONTROL = 'button,input,select,textarea,[role="button"],[role="tab"],[role="switch"],[role="checkbox"],[role="radio"],[role="option"],[role="link"],a[href]';
|
|
5108
|
+
const ALNUM = /[\p{L}\p{N}]/u;
|
|
5109
|
+
const css = (el) => getComputedStyle(el);
|
|
5110
|
+
const clear = (c) => !c || c === 'transparent' || /rgba\([^)]*,\s*0(\.0+)?\)$/.test(c);
|
|
5111
|
+
// Visible through every ancestor (an ancestor at opacity 0 or visibility hidden hides it), and not degenerate.
|
|
5112
|
+
const shown = (el) => {
|
|
5113
|
+
const s = css(el);
|
|
5114
|
+
if (s.display === 'none' || s.visibility === 'hidden' || Number(s.opacity) === 0) return false;
|
|
5115
|
+
if (typeof el.checkVisibility === 'function' && !el.checkVisibility({ opacityProperty: true, visibilityProperty: true })) return false;
|
|
5116
|
+
const r = el.getBoundingClientRect();
|
|
5117
|
+
return r.width > 1 && r.height > 1;
|
|
5118
|
+
};
|
|
5119
|
+
const inline = (s) => /^inline/.test(s.display) && s.display !== 'inline-block' && s.display !== 'inline-flex' && s.display !== 'inline-grid';
|
|
5120
|
+
const groundOf = (el) => {
|
|
5121
|
+
for (let p = el.parentElement; p; p = p.parentElement) { const b = css(p).backgroundColor; if (!clear(b)) return b; }
|
|
5122
|
+
return 'rgb(255, 255, 255)';
|
|
5123
|
+
};
|
|
5124
|
+
const sides = (s) => ['Top', 'Right', 'Bottom', 'Left'].filter((k) => parseFloat(s['border' + k + 'Width']) > 0 && s['border' + k + 'Style'] !== 'none' && !clear(s['border' + k + 'Color'])).length;
|
|
5125
|
+
const bounded = (el) => { const s = css(el); return sides(s) >= 1 || (!clear(s.backgroundColor) && s.backgroundColor !== groundOf(el)); };
|
|
5126
|
+
// A control: a native or ARIA one, or a bounded box the visitor clicks (a pointer cursor or a tab stop) that holds no
|
|
5127
|
+
// control of its own and is not the whole card. Its contents are not blocks of the column (decided 2026-10-02).
|
|
5128
|
+
const isControl = (el) => {
|
|
5129
|
+
const s = css(el);
|
|
5130
|
+
if (inline(s)) return false;
|
|
5131
|
+
if (el.matches(CONTROL)) return true;
|
|
5132
|
+
if (el === root) return false;
|
|
5133
|
+
const clickable = s.cursor === 'pointer' || (el.hasAttribute('tabindex') && el.tabIndex >= 0);
|
|
5134
|
+
if (!clickable || !bounded(el) || el.querySelector(CONTROL)) return false;
|
|
5135
|
+
const r = el.getBoundingClientRect();
|
|
5136
|
+
return r.width * r.height < 0.6 * rootBox.width * rootBox.height;
|
|
5137
|
+
};
|
|
5138
|
+
const isTable = (el) => el.matches('table,[role="table"],[role="grid"]');
|
|
5139
|
+
const isSurface = (el) => {
|
|
5140
|
+
if (el === root || isControl(el)) return false;
|
|
5141
|
+
const s = css(el);
|
|
5142
|
+
if (/^inline/.test(s.display) || s.display === 'contents') return false;
|
|
5143
|
+
if (isTable(el)) return true;
|
|
5144
|
+
return sides(s) >= 3 || (!clear(s.backgroundColor) && s.backgroundColor !== groundOf(el)) || (s.boxShadow && s.boxShadow !== 'none');
|
|
5145
|
+
};
|
|
5146
|
+
const path = (el) => {
|
|
5147
|
+
const parts = [];
|
|
5148
|
+
for (let e = el; e && e !== root && parts.length < 4; e = e.parentElement) {
|
|
5149
|
+
const i = e.parentElement ? Array.prototype.indexOf.call(e.parentElement.children, e) + 1 : 1;
|
|
5150
|
+
parts.unshift(e.tagName.toLowerCase() + ':' + i);
|
|
5151
|
+
}
|
|
5152
|
+
return parts.join('>');
|
|
5153
|
+
};
|
|
5154
|
+
const label = (el) => { const t = (el.innerText || '').replace(/\s+/g, ' ').trim(); return path(el) + (t ? ' "' + t.slice(0, 24) + '"' : ''); };
|
|
5155
|
+
const surfaces = [];
|
|
5156
|
+
const surfaceOf = (el, self) => { for (let p = self ? el : el.parentElement; p && p !== root; p = p.parentElement) if (surfaces.indexOf(p) >= 0) return p; return null; };
|
|
5157
|
+
const inTable = (el) => { for (let p = el.parentElement; p && p !== root; p = p.parentElement) if (isTable(p)) return true; return false; };
|
|
5158
|
+
const inControl = (el) => { for (let p = el; p && p !== root; p = p.parentElement) if (isControl(p)) return p; return null; };
|
|
5159
|
+
const all = Array.prototype.slice.call(root.querySelectorAll('*')).filter(shown);
|
|
5160
|
+
for (const el of all) if (isSurface(el) && !inTable(el) && !inControl(el)) surfaces.push(el);
|
|
5161
|
+
const ids = new Map();
|
|
5162
|
+
const idOf = (el) => { if (!ids.has(el)) ids.set(el, label(el)); return ids.get(el); };
|
|
5163
|
+
const blocks = [];
|
|
5164
|
+
// Per block: the element it stands for and its first line's box, for rows.
|
|
5165
|
+
const owners = [];
|
|
5166
|
+
const lines = [];
|
|
5167
|
+
// Per block: its raw line boxes and left, so a row reads the line two items share, not a wrapped item's widest line.
|
|
5168
|
+
const rects = [];
|
|
5169
|
+
const rawLeft = [];
|
|
5170
|
+
const words = [];
|
|
5171
|
+
const within = (el, self) => { const s = surfaceOf(el, self); return s ? { within: idOf(s) } : {}; };
|
|
5172
|
+
for (const s of surfaces) {
|
|
5173
|
+
const r = s.getBoundingClientRect();
|
|
5174
|
+
owners.push(s); lines.push({ top: r.top, bottom: r.bottom }); rects.push([r]); rawLeft.push(r.left); words.push('');
|
|
5175
|
+
// A placeholder: a box with no text, no control and no image or icon inside it (a loading bar). Not a block.
|
|
5176
|
+
const holds = Array.prototype.some.call(s.querySelectorAll('*'), (el) => shown(el) && (el.matches(CONTROL) || el.tagName === 'IMG' || el.tagName.toLowerCase() === 'svg')) || ALNUM.test(s.innerText || '');
|
|
5177
|
+
blocks.push(Object.assign({ id: idOf(s), kind: 'surface', edge: px(r.left), left: px(r.left), right: px(r.right), top: Math.round(r.top) }, within(s, false), isTable(s) ? { role: 'table' } : holds ? {} : { role: 'placeholder' }));
|
|
5178
|
+
}
|
|
5179
|
+
const blockOf = (node) => { for (let p = node.parentElement; p && p !== root; p = p.parentElement) if (!inline(css(p))) return p; return root; };
|
|
5180
|
+
const groups = new Map();
|
|
5181
|
+
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
|
5182
|
+
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
|
|
5183
|
+
if (!/\S/.test(n.data) || !n.parentElement || n.parentElement.closest('style,script,noscript,template') || !shown(n.parentElement) || inControl(n.parentElement) || inTable(n.parentElement)) continue;
|
|
5184
|
+
const b = blockOf(n);
|
|
5185
|
+
if (!groups.has(b)) groups.set(b, []);
|
|
5186
|
+
groups.get(b).push(n);
|
|
5187
|
+
}
|
|
5188
|
+
// Ink: text whose own colour is not transparent. The edge is the first visible character in ink (decided 2026-10-02):
|
|
5189
|
+
// leading invisible text, private-use icon-font glyphs and formatting characters are not characters of the block,
|
|
5190
|
+
// while a currency sign or a quotation mark is.
|
|
5191
|
+
const inked = (n) => !clear(css(n.parentElement).color) && ALNUM.test(n.data);
|
|
5192
|
+
const textRects = (nodes) => {
|
|
5193
|
+
const rs = [];
|
|
5194
|
+
for (const n of nodes) { if (!inked(n)) continue; const range = document.createRange(); range.selectNodeContents(n); for (const r of range.getClientRects()) if (r.width > 0 && r.height > 0) rs.push(r); }
|
|
5195
|
+
return rs;
|
|
5196
|
+
};
|
|
5197
|
+
const glyphAt = (n, i) => { const range = document.createRange(); range.setStart(n, i); range.setEnd(n, i + 1); const r = range.getClientRects()[0]; return r || null; };
|
|
5198
|
+
const firstGlyph = (nodes) => {
|
|
5199
|
+
for (const n of nodes) { if (!inked(n)) continue; const i = n.data.search(/[^\s\p{Co}\p{Cf}]/u); if (i < 0) continue; const r = glyphAt(n, i); if (r) return r; }
|
|
5200
|
+
return null;
|
|
5201
|
+
};
|
|
5202
|
+
// A marker paints: a loaded image, an SVG with a filled or stroked shape, or an element with its own fill, border
|
|
5203
|
+
// or visible text. An element that paints nothing is not a marker.
|
|
5204
|
+
const painted = (m) => {
|
|
5205
|
+
if (!shown(m)) return false;
|
|
5206
|
+
if (m.tagName === 'IMG') return m.complete && m.naturalWidth > 0;
|
|
5207
|
+
if (m.tagName.toLowerCase() === 'svg' || m.closest('svg')) {
|
|
5208
|
+
const shapes = m.querySelectorAll('path,circle,rect,line,polyline,polygon,ellipse,use,text');
|
|
5209
|
+
return Array.prototype.some.call(shapes, (sh) => { const st = css(sh); return (st.fill !== 'none' && !clear(st.fill)) || (st.stroke !== 'none' && !clear(st.stroke)); });
|
|
5210
|
+
}
|
|
5211
|
+
const s = css(m);
|
|
5212
|
+
if (!clear(s.backgroundColor) || sides(s) >= 1 || ((m.textContent || '').trim().length > 0 && !clear(s.color))) return true;
|
|
5213
|
+
// A wrapper that paints nothing itself is a marker when an image or icon inside it paints.
|
|
5214
|
+
return Array.prototype.some.call(m.querySelectorAll('svg,img'), (k) => painted(k));
|
|
5215
|
+
};
|
|
5216
|
+
const markerBefore = (b, textLeft, lineTop, lineBottom) => {
|
|
5217
|
+
const cands = [];
|
|
5218
|
+
const consider = (m) => { if (!painted(m)) return; const r = m.getBoundingClientRect(); if (r.left < textLeft - 2 && r.bottom > lineTop && r.top < lineBottom) cands.push(r.left); };
|
|
5219
|
+
for (const m of b.querySelectorAll('svg,img,[aria-hidden="true"],i')) if ((m.textContent || '').trim().length <= 2) consider(m);
|
|
5220
|
+
const p = b.parentElement;
|
|
5221
|
+
if (p && p !== root && /flex/.test(css(p).display) && !/column/.test(css(p).flexDirection)) for (const sib of p.children) { if (sib === b) break; if (sib.matches('svg,img,i,[aria-hidden="true"]') || !(sib.textContent || '').trim()) consider(sib); }
|
|
5222
|
+
return cands.length ? Math.min.apply(null, cands) : null;
|
|
5223
|
+
};
|
|
5224
|
+
let texts = 0;
|
|
5225
|
+
let ornaments = 0;
|
|
5226
|
+
for (const [b, nodes] of groups) {
|
|
5227
|
+
if (!nodes.some((n) => ALNUM.test(n.data))) { ornaments += 1; continue; }
|
|
5228
|
+
const rs = textRects(nodes);
|
|
5229
|
+
const g = firstGlyph(nodes);
|
|
5230
|
+
if (!rs.length || g === null) continue;
|
|
5231
|
+
texts += 1;
|
|
5232
|
+
const first = g.left;
|
|
5233
|
+
const left = Math.min.apply(null, rs.map((r) => r.left));
|
|
5234
|
+
const right = Math.max.apply(null, rs.map((r) => r.right));
|
|
5235
|
+
const top = Math.min.apply(null, rs.map((r) => r.top));
|
|
5236
|
+
const marker = markerBefore(b, first, g.top, g.bottom);
|
|
5237
|
+
const s = css(b);
|
|
5238
|
+
const box = b.getBoundingClientRect();
|
|
5239
|
+
const parent = b.parentElement ? css(b.parentElement) : null;
|
|
5240
|
+
const centredText = s.textAlign === 'center' && right - left < box.width * 0.9;
|
|
5241
|
+
const centredBox = parent && /flex/.test(parent.display) && /column/.test(parent.flexDirection) && parent.alignItems === 'center';
|
|
5242
|
+
const list = b.matches('li') || s.display === 'list-item' ? b.closest('ul,ol') : null;
|
|
5243
|
+
const inProse = list && list.previousElementSibling && list.previousElementSibling.matches('p');
|
|
5244
|
+
const block = { id: idOf(b), kind: 'text', edge: px(marker !== null ? marker : first), left: px(Math.min(left, marker !== null ? marker : left)), right: px(right), top: Math.round(top) };
|
|
5245
|
+
if (marker !== null) block.textEdge = px(first);
|
|
5246
|
+
if (centredText || centredBox) block.role = 'centred';
|
|
5247
|
+
else if (inProse) block.role = 'list-item';
|
|
5248
|
+
owners.push(b); lines.push({ top: g.top, bottom: g.bottom }); rects.push(rs); rawLeft.push(Math.min(left, marker !== null ? marker : left)); words.push(nodes.map((n) => n.data).join('').trim());
|
|
5249
|
+
blocks.push(Object.assign(block, within(b, true)));
|
|
5250
|
+
}
|
|
5251
|
+
const ctlGroups = new Map();
|
|
5252
|
+
for (const el of all) { if (!isControl(el) || inTable(el) || (el.parentElement && inControl(el.parentElement))) continue; const p = el.parentElement || root; if (!ctlGroups.has(p)) ctlGroups.set(p, []); ctlGroups.get(p).push(el); }
|
|
5253
|
+
let controls = 0;
|
|
5254
|
+
for (const [p, els] of ctlGroups) {
|
|
5255
|
+
const rs = els.map((e) => e.getBoundingClientRect());
|
|
5256
|
+
const topRow = Math.min.apply(null, rs.map((r) => r.top));
|
|
5257
|
+
let firstEl = null; let firstR = null;
|
|
5258
|
+
els.forEach((e, i) => { const r = rs[i]; if (r.top - topRow < r.height / 2 && (!firstR || r.left < firstR.left)) { firstEl = e; firstR = r; } });
|
|
5259
|
+
let edge = firstR.left;
|
|
5260
|
+
if (!bounded(firstEl)) { const tw = document.createTreeWalker(firstEl, NodeFilter.SHOW_TEXT); const ns = []; for (let n = tw.nextNode(); n; n = tw.nextNode()) ns.push(n); const g = firstGlyph(ns); if (g) edge = g.left; }
|
|
5261
|
+
controls += 1;
|
|
5262
|
+
const ps = css(p);
|
|
5263
|
+
const centred = ps.justifyContent === 'center' || (ps.textAlign === 'center' && !/flex|grid/.test(ps.display));
|
|
5264
|
+
const block = { id: idOf(p) + ' [controls]', kind: 'controls', edge: px(edge), left: px(Math.min.apply(null, rs.map((r) => r.left))), right: px(Math.max.apply(null, rs.map((r) => r.right))), top: Math.round(topRow) };
|
|
5265
|
+
if (centred) block.role = 'centred';
|
|
5266
|
+
owners.push(firstEl); lines.push({ top: firstR.top, bottom: firstR.bottom }); rects.push(rs); rawLeft.push(Math.min.apply(null, rs.map((r) => r.left))); words.push('');
|
|
5267
|
+
blocks.push(Object.assign(block, within(firstEl, true)));
|
|
5268
|
+
}
|
|
5269
|
+
// Rows (decided 2026-10-01, read by geometry 2026-10-02): items set on one line, however the CSS sets them. An item
|
|
5270
|
+
// after another measured item on its line that holds one block (a text, a control, or a box holding at most one
|
|
5271
|
+
// text) belongs to the row and is not measured; an item with a stack inside it is a column.
|
|
5272
|
+
const dropped = new Set();
|
|
5273
|
+
const sameLine = (a, b) => Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top) > Math.min(a.bottom - a.top, b.bottom - b.top) / 2;
|
|
5274
|
+
const contains = (o, el) => o === el || o.contains(el);
|
|
5275
|
+
const innerTexts = (i) => owners.map((el, k) => k).filter((k) => k !== i && blocks[k].kind !== 'surface' && contains(owners[i], owners[k]));
|
|
5276
|
+
const isPill = (i) => blocks[i].kind === 'surface' && innerTexts(i).length <= 1 && !owners.some((el, k) => k !== i && blocks[k].kind === 'surface' && contains(owners[i], el));
|
|
5277
|
+
const itemOf = (el, other) => { let e = el; while (e.parentElement && e.parentElement !== root && !e.parentElement.contains(other)) e = e.parentElement; return e; };
|
|
5278
|
+
for (let i = 0; i < blocks.length; i += 1) {
|
|
5279
|
+
if (blocks[i].kind === 'surface' && !isPill(i)) continue;
|
|
5280
|
+
const before = blocks.findIndex((bj, j) => j !== i && bj.within === blocks[i].within && !(bj.kind === 'surface' && !isPill(j)) && !contains(owners[j], owners[i]) && !contains(owners[i], owners[j]) && rects[j].some((r) => sameLine(r, lines[i]) && r.right <= rawLeft[i] + 1));
|
|
5281
|
+
if (before < 0) continue;
|
|
5282
|
+
// A number or bullet in front of a text on its line is a hanging marker, not a row: the pair is one block with two
|
|
5283
|
+
// edges, the marker's and the text's.
|
|
5284
|
+
if (blocks[i].kind === 'text' && blocks[before].kind === 'text' && /^[\p{N}\p{P}\p{S}]{1,3}$/u.test(words[before])) {
|
|
5285
|
+
blocks[i] = Object.assign({}, blocks[i], { edge: blocks[before].edge, left: Math.min(blocks[i].left, blocks[before].left), textEdge: blocks[i].edge });
|
|
5286
|
+
delete blocks[i].role;
|
|
5287
|
+
dropped.add(before);
|
|
5288
|
+
continue;
|
|
5289
|
+
}
|
|
5290
|
+
const item = itemOf(owners[i], owners[before]);
|
|
5291
|
+
const inside = owners.map((el, k) => k).filter((k) => contains(item, owners[k]) && blocks[k].kind !== 'surface');
|
|
5292
|
+
const boxes = owners.map((el, k) => k).filter((k) => contains(item, owners[k]) && blocks[k].kind === 'surface' && k !== i);
|
|
5293
|
+
if (blocks[i].kind === 'surface') { if (boxes.length === 0) { dropped.add(i); for (const k of innerTexts(i)) dropped.add(k); } continue; }
|
|
5294
|
+
if (inside.length === 1 && boxes.length === 0) dropped.add(i);
|
|
5295
|
+
}
|
|
5296
|
+
const kept = blocks.filter((_, i) => !dropped.has(i));
|
|
5297
|
+
// Placeholders: a frame with no text in ink shows nothing to evaluate, whatever controls or boxes it draws.
|
|
5298
|
+
return { blocks: kept, placeholdersOnly: texts === 0, skipped: { rowItems: dropped.size, ornaments } };
|
|
5299
|
+
})()`;
|
|
5300
|
+
var KINDS = ["text", "controls", "surface"];
|
|
5301
|
+
var ROLES = ["list-item", "table", "centred", "placeholder"];
|
|
5302
|
+
var isKind = (v) => KINDS.some((k) => k === v);
|
|
5303
|
+
var isRole = (v) => ROLES.some((r) => r === v);
|
|
5304
|
+
var num = (v) => typeof v === "number" && Number.isFinite(v);
|
|
5305
|
+
var field = (o, key) => Reflect.get(o, key);
|
|
5306
|
+
function parseEdgeProbe(value) {
|
|
5307
|
+
if (typeof value !== "object" || value === null) return null;
|
|
5308
|
+
const raw = field(value, "blocks");
|
|
5309
|
+
const placeholdersOnly = field(value, "placeholdersOnly");
|
|
5310
|
+
if (!Array.isArray(raw) || typeof placeholdersOnly !== "boolean") return null;
|
|
5311
|
+
const skippedRaw = field(value, "skipped");
|
|
5312
|
+
if (typeof skippedRaw !== "object" || skippedRaw === null) return null;
|
|
5313
|
+
const rowItems = field(skippedRaw, "rowItems");
|
|
5314
|
+
const ornaments = field(skippedRaw, "ornaments");
|
|
5315
|
+
if (!num(rowItems) || !num(ornaments)) return null;
|
|
5316
|
+
const blocks = [];
|
|
5317
|
+
for (const entry of raw) {
|
|
5318
|
+
if (typeof entry !== "object" || entry === null) return null;
|
|
5319
|
+
const [id, kind, edge, left, right, top] = ["id", "kind", "edge", "left", "right", "top"].map((k) => field(entry, k));
|
|
5320
|
+
const [within, textEdge, role] = ["within", "textEdge", "role"].map((k) => field(entry, k));
|
|
5321
|
+
if (typeof id !== "string" || !isKind(kind) || !num(edge) || !num(left) || !num(right) || !num(top)) return null;
|
|
5322
|
+
if (within !== void 0 && typeof within !== "string") return null;
|
|
5323
|
+
if (textEdge !== void 0 && !num(textEdge)) return null;
|
|
5324
|
+
if (role !== void 0 && !isRole(role)) return null;
|
|
5325
|
+
blocks.push({
|
|
5326
|
+
id,
|
|
5327
|
+
kind,
|
|
5328
|
+
edge,
|
|
5329
|
+
left,
|
|
5330
|
+
right,
|
|
5331
|
+
top,
|
|
5332
|
+
...within !== void 0 ? { within } : {},
|
|
5333
|
+
...textEdge !== void 0 ? { textEdge } : {},
|
|
5334
|
+
...role !== void 0 ? { role } : {}
|
|
5335
|
+
});
|
|
5336
|
+
}
|
|
5337
|
+
return { blocks, placeholdersOnly, skipped: { rowItems, ornaments } };
|
|
5338
|
+
}
|
|
5339
|
+
|
|
5340
|
+
// src/evaluation/visual-evaluator.ts
|
|
4547
5341
|
function canvasFitPolicy(canvas) {
|
|
4548
5342
|
switch (canvas) {
|
|
4549
5343
|
case "xs-chat-card":
|
|
4550
|
-
return { capture: "
|
|
5344
|
+
return { capture: "natural", overflow: "fail" };
|
|
4551
5345
|
case "mobile-fullscreen-small":
|
|
4552
5346
|
return { capture: "full-page", overflow: "warn" };
|
|
4553
5347
|
default:
|
|
@@ -4555,9 +5349,65 @@ function canvasFitPolicy(canvas) {
|
|
|
4555
5349
|
}
|
|
4556
5350
|
}
|
|
4557
5351
|
var JUDGE_SCOPE_CLASS = "ggui-judge-scope";
|
|
5352
|
+
var JUDGE_PANEL_CLASS = "ggui-judge-panel";
|
|
5353
|
+
var JUDGE_INK_INSET_PX = EXPANDED_FRAME.insetPx + 24 + 1;
|
|
4558
5354
|
function canvasFit(canvas) {
|
|
4559
5355
|
return displayModeForCanvas(canvas) === "fullscreen" ? "fill" : void 0;
|
|
4560
5356
|
}
|
|
5357
|
+
var JUDGE_GROUND_MARGIN_PX = EXPANDED_FRAME.insetPx;
|
|
5358
|
+
var JUDGE_GROUND_CLASS = "ggui-judge-ground";
|
|
5359
|
+
var JUDGE_INLINE_FRAME_CLASS = "ggui-judge-inline-frame";
|
|
5360
|
+
var JUDGE_INLINE_FRAME_BORDER_PX = 1;
|
|
5361
|
+
var JUDGE_INLINE_FRAME_RADIUS_PX = 16;
|
|
5362
|
+
var JUDGE_INLINE_PAD_PX = JUDGE_GROUND_MARGIN_PX + JUDGE_INLINE_FRAME_BORDER_PX;
|
|
5363
|
+
var JUDGE_INLINE_INK_INSET_PX = JUDGE_INLINE_PAD_PX + JUDGE_INLINE_FRAME_RADIUS_PX;
|
|
5364
|
+
var HOST_COLOUR_PATTERN = /^#[0-9a-f]{6}$/;
|
|
5365
|
+
var HOST_FRAME_BOUNDS = {
|
|
5366
|
+
ringWidthPx: { min: 0, max: 8 },
|
|
5367
|
+
ringAlpha: { min: 0, max: 1 },
|
|
5368
|
+
radiusPx: { min: 0, max: 64 },
|
|
5369
|
+
minHeightPx: { min: 0, max: 2560 }
|
|
5370
|
+
};
|
|
5371
|
+
function resolvePresentation(canvas, p) {
|
|
5372
|
+
if (p === void 0) return void 0;
|
|
5373
|
+
const ignored = (reason, why) => {
|
|
5374
|
+
console.warn(`[visual-eval] host presentation for ${canvas} ignored (${reason}): ${why}`);
|
|
5375
|
+
return { status: "ignored", reason };
|
|
5376
|
+
};
|
|
5377
|
+
const inline = canvasFitPolicy(canvas).capture === "natural";
|
|
5378
|
+
if (typeof p.label !== "string" || p.label.length === 0) return ignored("label_missing", "no label");
|
|
5379
|
+
if (p.ground !== void 0 && !HOST_COLOUR_PATTERN.test(p.ground)) return ignored("color_not_hex", "ground is not #rrggbb");
|
|
5380
|
+
if (inline && p.ground !== void 0) return ignored("ground_on_inline", "the inline card's room is its frame's ground");
|
|
5381
|
+
const f = p.frame;
|
|
5382
|
+
if (f !== void 0) {
|
|
5383
|
+
if (!inline) return ignored("frame_off_inline", "a frame on a canvas that is not the inline card");
|
|
5384
|
+
if (![f.surface, f.ground, f.ring.color].every((c) => HOST_COLOUR_PATTERN.test(c))) return ignored("color_not_hex", "a frame colour is not #rrggbb");
|
|
5385
|
+
const B = HOST_FRAME_BOUNDS;
|
|
5386
|
+
const inRange = (n, b) => Number.isFinite(n) && n >= b.min && n <= b.max;
|
|
5387
|
+
if (!inRange(f.ring.widthPx, B.ringWidthPx) || !inRange(f.ring.alpha, B.ringAlpha) || !inRange(f.radiusPx, B.radiusPx)) return ignored("frame_malformed", "a frame number out of range");
|
|
5388
|
+
if (f.minHeightPx !== void 0 && !inRange(f.minHeightPx, B.minHeightPx)) return ignored("frame_malformed", "minHeightPx out of range");
|
|
5389
|
+
}
|
|
5390
|
+
return { status: "applied", applied: p };
|
|
5391
|
+
}
|
|
5392
|
+
function inlineGeometry(frame) {
|
|
5393
|
+
if (frame === void 0) return { padPx: JUDGE_INLINE_PAD_PX, inkInsetPx: JUDGE_INLINE_INK_INSET_PX, floorPx: void 0 };
|
|
5394
|
+
const padPx = JUDGE_GROUND_MARGIN_PX + frame.ring.widthPx;
|
|
5395
|
+
return { padPx, inkInsetPx: padPx + frame.radiusPx, floorPx: frame.minHeightPx };
|
|
5396
|
+
}
|
|
5397
|
+
function canvasChrome(canvas) {
|
|
5398
|
+
if (canvasFitPolicy(canvas).capture === "natural") return "ground";
|
|
5399
|
+
return canvasFit(canvas) === "fill" && canvas !== "mobile-fullscreen-small" ? "panel" : void 0;
|
|
5400
|
+
}
|
|
5401
|
+
function judgeWindow(viewport, chrome, inlinePadPx = JUDGE_INLINE_PAD_PX) {
|
|
5402
|
+
if (chrome === void 0) return viewport;
|
|
5403
|
+
const gap = 2 * (chrome === "ground" ? inlinePadPx : EXPANDED_FRAME.insetPx);
|
|
5404
|
+
return { width: viewport.width + gap, height: viewport.height + gap };
|
|
5405
|
+
}
|
|
5406
|
+
function cardHeight(documentHeight, chrome) {
|
|
5407
|
+
if (documentHeight === null || chrome === void 0 || chrome === "ground") return documentHeight;
|
|
5408
|
+
const gap = 2 * EXPANDED_FRAME.insetPx;
|
|
5409
|
+
return documentHeight < gap ? null : documentHeight - gap;
|
|
5410
|
+
}
|
|
4561
5411
|
function integerBox(viewport) {
|
|
4562
5412
|
return { width: Math.round(viewport.width), height: Math.round(viewport.height) };
|
|
4563
5413
|
}
|
|
@@ -4571,11 +5421,32 @@ function canvasOverflowIssue(canvas, viewport, contentHeight, verdict) {
|
|
|
4571
5421
|
const box = isDeclaredViewport(canvas, viewport) ? `declared ${viewport.width}\xD7${viewport.height}; class box ${classBox.width}\xD7${classBox.height}` : `${viewport.width}\xD7${viewport.height}`;
|
|
4572
5422
|
return {
|
|
4573
5423
|
dimension: "canvas-overflow",
|
|
5424
|
+
origin: "instrument",
|
|
4574
5425
|
severity: verdict === "fail" ? "critical" : "major",
|
|
4575
5426
|
description: `Rendered content is ${contentHeight}px tall on the ${canvas} canvas (${box}) \u2014 ${hidden}px ${verdict === "fail" ? "is cut off: the inline card does not scroll" : "sits below the first screen"}.`,
|
|
4576
5427
|
fix: "Fit the composition to the canvas: fewer and shorter sections, one compact row of chips, no hero taller than a third of the box, no fixed min-heights or tall paddings \u2014 measure against the canvas, not the page."
|
|
4577
5428
|
};
|
|
4578
5429
|
}
|
|
5430
|
+
function canvasOverflowXIssue(canvas, viewport, hiddenPx, verdict) {
|
|
5431
|
+
return {
|
|
5432
|
+
dimension: "canvas-overflow-x",
|
|
5433
|
+
origin: "instrument",
|
|
5434
|
+
severity: verdict === "fail" ? "critical" : "major",
|
|
5435
|
+
description: `Content runs ${hiddenPx}px past the ${canvas} card's ${viewport.width}px width \u2014 the card's frame clips it, so a visitor sees a cut-off row or a sideways scroll.`,
|
|
5436
|
+
fix: "Keep every row within the card: wrap long text (overflow-wrap: anywhere), let a wide table or code block scroll inside its own container, and give no child a fixed width wider than the card."
|
|
5437
|
+
};
|
|
5438
|
+
}
|
|
5439
|
+
function canvasBlankIssue(canvas, viewport) {
|
|
5440
|
+
const classBox = CANVAS_VIEWPORTS[canvas];
|
|
5441
|
+
const box = isDeclaredViewport(canvas, viewport) ? `declared ${viewport.width}\xD7${viewport.height}; class box ${classBox.width}\xD7${classBox.height}` : `${viewport.width}\xD7${viewport.height}`;
|
|
5442
|
+
return {
|
|
5443
|
+
dimension: "canvas-blank",
|
|
5444
|
+
origin: "instrument",
|
|
5445
|
+
severity: "critical",
|
|
5446
|
+
description: `Nothing painted on the ${canvas} canvas (${box}): the capture is one flat colour \u2014 the component mounted and rendered no pixels.`,
|
|
5447
|
+
fix: "Return and paint the component tree: a root that renders nothing is a blank, not an empty state. Check that the default export returns its JSX, that no early return yields null on the sample props, and that the first paint shows visible content."
|
|
5448
|
+
};
|
|
5449
|
+
}
|
|
4579
5450
|
function resolveUiGenPackageDir() {
|
|
4580
5451
|
const selfDir = dirname(fileURLToPath(import.meta.url));
|
|
4581
5452
|
return resolve(selfDir, "..", "..");
|
|
@@ -4691,7 +5562,16 @@ try {
|
|
|
4691
5562
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
4692
5563
|
}
|
|
4693
5564
|
}
|
|
4694
|
-
function
|
|
5565
|
+
function inlineFrameDecls(frame) {
|
|
5566
|
+
if (frame === void 0) {
|
|
5567
|
+
return `background: var(--ggui-color-container); border: ${JUDGE_INLINE_FRAME_BORDER_PX}px solid color-mix(in srgb, var(--ggui-color-onContainer) 8%, transparent); border-radius: ${JUDGE_INLINE_FRAME_RADIUS_PX}px; overflow: hidden;`;
|
|
5568
|
+
}
|
|
5569
|
+
const alphaPct = Math.round(frame.ring.alpha * 1e3) / 10;
|
|
5570
|
+
return `background: ${frame.surface}; border: ${frame.ring.widthPx}px solid color-mix(in srgb, ${frame.ring.color} ${alphaPct}%, transparent); border-radius: ${frame.radiusPx}px; overflow: hidden;`;
|
|
5571
|
+
}
|
|
5572
|
+
function buildRenderHTML(bundledCode, cssTokens, fit, chrome, presentation) {
|
|
5573
|
+
if (chrome === "panel" && fit !== "fill") throw new Error("the judge's panel frames a fill canvas only (ggui#1083 cut 3)");
|
|
5574
|
+
if (chrome === "ground" && fit === "fill") throw new Error("the host ground frames an inline card only \u2014 a fill canvas has no natural height (ggui#1475)");
|
|
4695
5575
|
const tokens = cssTokens ?? getCssTokens();
|
|
4696
5576
|
return `<!DOCTYPE html>
|
|
4697
5577
|
<html>
|
|
@@ -4706,15 +5586,16 @@ function buildRenderHTML(bundledCode, cssTokens, fit) {
|
|
|
4706
5586
|
/* ggui#1083 \u2014 the host stand-in (neutral-50) under the theme's scrim (its tint at
|
|
4707
5587
|
its opacity): the ground a frosted host puts under the card, so the judge scores
|
|
4708
5588
|
the card where the visitor sees it. */
|
|
4709
|
-
background:
|
|
5589
|
+
${presentation?.frame !== void 0 ? `background: ${presentation.frame.ground};` : presentation?.ground !== void 0 ? `background: ${presentation.ground};` : expandedFrameScrimDecls()}
|
|
4710
5590
|
color: var(--ggui-color-neutral-900, #111827);
|
|
4711
5591
|
}
|
|
4712
5592
|
.error { color: #dc2626; padding: 16px; font-family: monospace; white-space: pre-wrap; }
|
|
4713
|
-
${fit === "fill" ? fillFitRule(JUDGE_SCOPE_CLASS) : ""}
|
|
5593
|
+
${fit === "fill" ? fillFitRule(JUDGE_SCOPE_CLASS) + (chrome === "panel" ? expandedFramePanelRule(JUDGE_PANEL_CLASS, JUDGE_SCOPE_CLASS) : "") : ""}
|
|
5594
|
+
${chrome === "ground" ? `.${JUDGE_GROUND_CLASS} { padding: ${JUDGE_GROUND_MARGIN_PX}px; } .${JUDGE_INLINE_FRAME_CLASS} { ${inlineFrameDecls(presentation?.frame)} }` : ""}
|
|
4714
5595
|
</style>
|
|
4715
5596
|
</head>
|
|
4716
5597
|
<body>
|
|
4717
|
-
<div id="root"${fit === "fill" ? ` class="${JUDGE_SCOPE_CLASS}"` : ""}></div>
|
|
5598
|
+
${chrome === "panel" ? `<div class="${JUDGE_PANEL_CLASS}">` : chrome === "ground" ? `<div class="${JUDGE_GROUND_CLASS}"><div class="${JUDGE_INLINE_FRAME_CLASS}">` : ""}<div id="root"${fit === "fill" ? ` class="${JUDGE_SCOPE_CLASS}"` : ""}></div>${chrome === "panel" ? "</div>" : chrome === "ground" ? "</div></div>" : ""}
|
|
4718
5599
|
<script type="importmap">
|
|
4719
5600
|
{
|
|
4720
5601
|
"imports": {
|
|
@@ -4762,16 +5643,40 @@ async function launchWithPuppeteer(options) {
|
|
|
4762
5643
|
return puppeteer.default.launch(options);
|
|
4763
5644
|
}
|
|
4764
5645
|
var CONTENT_HEIGHT_EXPRESSION = "Math.max(document.documentElement.scrollHeight, document.body ? document.body.scrollHeight : 0)";
|
|
4765
|
-
|
|
5646
|
+
function cardHeightExpression(padPx) {
|
|
5647
|
+
return `(() => { const el = document.documentElement; const prev = el.style.height; el.style.height = 'max-content'; const h = Math.ceil(el.getBoundingClientRect().height); el.style.height = prev; return h - ${2 * padPx}; })()`;
|
|
5648
|
+
}
|
|
5649
|
+
var CARD_HEIGHT_EXPRESSION = cardHeightExpression(JUDGE_INLINE_PAD_PX);
|
|
5650
|
+
var OVERFLOW_X_ROUNDING_PX = 1;
|
|
5651
|
+
var CARD_OVERFLOW_X_EXPRESSION = "(() => { const r = document.getElementById('root'); return r ? Math.max(0, Math.ceil(r.scrollWidth - r.clientWidth)) : null; })()";
|
|
5652
|
+
function naturalClip(window, cardHeightPx, padPx = JUDGE_INLINE_PAD_PX) {
|
|
5653
|
+
const m = padPx;
|
|
5654
|
+
const box = window.height - 2 * m;
|
|
5655
|
+
return { x: 0, y: 0, width: window.width, height: Math.max(1, Math.min(Math.ceil(cardHeightPx), box)) + 2 * m };
|
|
5656
|
+
}
|
|
5657
|
+
async function measureContentHeight(page, expression = CONTENT_HEIGHT_EXPRESSION) {
|
|
4766
5658
|
try {
|
|
4767
|
-
const h = await page.evaluate(
|
|
5659
|
+
const h = await page.evaluate(expression);
|
|
4768
5660
|
return typeof h === "number" && Number.isFinite(h) ? Math.round(h) : null;
|
|
4769
5661
|
} catch (e) {
|
|
4770
5662
|
console.warn(`[visual-eval] content height unavailable: ${e instanceof Error ? e.message : String(e)}`);
|
|
4771
5663
|
return null;
|
|
4772
5664
|
}
|
|
4773
5665
|
}
|
|
4774
|
-
async function
|
|
5666
|
+
async function measureEdges(page) {
|
|
5667
|
+
try {
|
|
5668
|
+
const probe = parseEdgeProbe(await page.evaluate(EDGE_BLOCKS_EXPRESSION));
|
|
5669
|
+
if (probe === null) {
|
|
5670
|
+
console.warn("[visual-eval] edge probe unavailable: the page returned no readable blocks");
|
|
5671
|
+
return null;
|
|
5672
|
+
}
|
|
5673
|
+
return judgeEdges(probe.blocks, { placeholdersOnly: probe.placeholdersOnly });
|
|
5674
|
+
} catch (e) {
|
|
5675
|
+
console.warn(`[visual-eval] edge probe unavailable: ${e instanceof Error ? e.message : String(e)}`);
|
|
5676
|
+
return null;
|
|
5677
|
+
}
|
|
5678
|
+
}
|
|
5679
|
+
async function captureScreenshotDetailed(html, viewport = DEFAULT_SCREENSHOT_VIEWPORT, deps = {}, capture = "full-page", natural = { padPx: JUDGE_INLINE_PAD_PX }) {
|
|
4775
5680
|
try {
|
|
4776
5681
|
const launchOptions = await resolveLaunchOptions(viewport, deps);
|
|
4777
5682
|
const browser = await (deps.launch ?? launchWithPuppeteer)(launchOptions);
|
|
@@ -4783,9 +5688,23 @@ async function captureScreenshotDetailed(html, viewport = DEFAULT_SCREENSHOT_VIE
|
|
|
4783
5688
|
await page.waitForSelector("#root > *", { timeout: 1e4 }).catch(() => {
|
|
4784
5689
|
});
|
|
4785
5690
|
await new Promise((r) => setTimeout(r, deps.settleMs ?? 1e3));
|
|
5691
|
+
if (capture === "natural") {
|
|
5692
|
+
const cardHeightPx = await measureContentHeight(page, natural.padPx === JUDGE_INLINE_PAD_PX ? CARD_HEIGHT_EXPRESSION : cardHeightExpression(natural.padPx));
|
|
5693
|
+
const overflowX = await measureContentHeight(page, CARD_OVERFLOW_X_EXPRESSION);
|
|
5694
|
+
const floorPx = natural.floorPx;
|
|
5695
|
+
if (floorPx !== void 0 && cardHeightPx !== null && cardHeightPx < floorPx) {
|
|
5696
|
+
await page.evaluate(`(() => { const f = document.querySelector('.${JUDGE_INLINE_FRAME_CLASS}'); if (f) f.style.minHeight = '${floorPx}px'; })()`);
|
|
5697
|
+
}
|
|
5698
|
+
const framed = cardHeightPx === null ? viewport.height - 2 * natural.padPx : floorPx !== void 0 ? Math.max(cardHeightPx, floorPx) : cardHeightPx;
|
|
5699
|
+
const clip = naturalClip(viewport, framed, natural.padPx);
|
|
5700
|
+
const edge2 = await measureEdges(page);
|
|
5701
|
+
const screenshot2 = await page.screenshot({ type: "png", fullPage: false, clip });
|
|
5702
|
+
return { png: Buffer.from(screenshot2), contentHeight: cardHeightPx, overflowX, edge: edge2 };
|
|
5703
|
+
}
|
|
4786
5704
|
const contentHeight = await measureContentHeight(page);
|
|
5705
|
+
const edge = await measureEdges(page);
|
|
4787
5706
|
const screenshot = await page.screenshot({ type: "png", fullPage: capture === "full-page" });
|
|
4788
|
-
return { png: Buffer.from(screenshot), contentHeight };
|
|
5707
|
+
return { png: Buffer.from(screenshot), contentHeight, edge };
|
|
4789
5708
|
} finally {
|
|
4790
5709
|
await browser.close();
|
|
4791
5710
|
}
|
|
@@ -4841,12 +5760,14 @@ Respond with ONLY a JSON object (no markdown, no explanation):
|
|
|
4841
5760
|
var VISUAL_JUDGE_MAX_OUTPUT_TOKENS = 4096;
|
|
4842
5761
|
var VISUAL_JUDGE_PROMPT_VERSION = "v2-bounded-issues";
|
|
4843
5762
|
var VISUAL_JUDGE_PROMPT_DIGEST = createHash("sha256").update(VISUAL_EVAL_PROMPT, "utf8").digest("hex");
|
|
4844
|
-
async function callMultimodalLLM(config, model, prompt, screenshot, originalPrompt, profileBlock = "") {
|
|
5763
|
+
async function callMultimodalLLM(config, model, prompt, screenshot, originalPrompt, profileBlock = "", criteriaBlock = "") {
|
|
4845
5764
|
const userPrompt = `## Original Request
|
|
4846
5765
|
${originalPrompt}
|
|
4847
5766
|
|
|
4848
5767
|
` + (profileBlock.length > 0 ? `${profileBlock}
|
|
4849
5768
|
|
|
5769
|
+
` : "") + (criteriaBlock.length > 0 ? `${criteriaBlock}
|
|
5770
|
+
|
|
4850
5771
|
` : "") + "Evaluate the screenshot of the generated component.";
|
|
4851
5772
|
const agent = createVisionAgent({
|
|
4852
5773
|
provider: config.provider === "claude" ? "anthropic" : config.provider,
|
|
@@ -4861,20 +5782,110 @@ ${originalPrompt}
|
|
|
4861
5782
|
VISUAL_JUDGE_MAX_OUTPUT_TOKENS
|
|
4862
5783
|
);
|
|
4863
5784
|
}
|
|
4864
|
-
|
|
5785
|
+
function readInk(png, chrome, canvas, inlineInkInsetPx = JUDGE_INLINE_INK_INSET_PX) {
|
|
5786
|
+
if (png === null) return null;
|
|
5787
|
+
const ink = readInkExtent(png, chrome === "panel" ? JUDGE_INK_INSET_PX : chrome === "ground" ? inlineInkInsetPx : 0);
|
|
5788
|
+
if ("reason" in ink) {
|
|
5789
|
+
console.warn(`[visual-eval] ink extent unreadable at canvas ${canvas}: ${ink.reason}`);
|
|
5790
|
+
return null;
|
|
5791
|
+
}
|
|
5792
|
+
return ink;
|
|
5793
|
+
}
|
|
5794
|
+
function blankVerdict(canvas, frame) {
|
|
5795
|
+
if (frame.chrome === "ground" && frame.attempt.contentHeight !== null && frame.attempt.contentHeight <= 0) {
|
|
5796
|
+
return canvasBlankIssue(canvas, frame.viewport);
|
|
5797
|
+
}
|
|
5798
|
+
return frame.ink !== null && frame.ink.lastInkRow === null ? canvasBlankIssue(canvas, frame.viewport) : null;
|
|
5799
|
+
}
|
|
5800
|
+
async function frameCanvas(bundledCode, context, html, canvas, canvasViewports, deps, hostPresentations) {
|
|
4865
5801
|
const declaredBox = canvasViewports?.[canvas];
|
|
4866
5802
|
const viewport = declaredBox !== void 0 ? integerBox(declaredBox) : CANVAS_VIEWPORTS[canvas];
|
|
4867
5803
|
const policy = canvasFitPolicy(canvas);
|
|
4868
5804
|
const fit = canvasFit(canvas);
|
|
4869
|
-
const
|
|
4870
|
-
const
|
|
4871
|
-
|
|
5805
|
+
const chrome = canvasChrome(canvas);
|
|
5806
|
+
const outcome = resolvePresentation(canvas, hostPresentations?.[canvas]);
|
|
5807
|
+
const presentation = outcome?.status === "applied" ? outcome.applied : void 0;
|
|
5808
|
+
const geometry = inlineGeometry(presentation?.frame);
|
|
5809
|
+
const canvasHtml = fit !== void 0 || chrome !== void 0 ? buildRenderHTML(bundledCode, context.cssTokens, fit, chrome, presentation) : html;
|
|
5810
|
+
const captured = await captureScreenshotDetailed(canvasHtml, judgeWindow(viewport, chrome, geometry.padPx), deps, policy.capture, {
|
|
5811
|
+
padPx: geometry.padPx,
|
|
5812
|
+
...geometry.floorPx !== void 0 ? { floorPx: geometry.floorPx } : {}
|
|
5813
|
+
});
|
|
5814
|
+
const attempt = { ...captured, contentHeight: cardHeight(captured.contentHeight, chrome) };
|
|
5815
|
+
return {
|
|
5816
|
+
viewport,
|
|
5817
|
+
policy,
|
|
5818
|
+
fit,
|
|
5819
|
+
chrome,
|
|
5820
|
+
declared: declaredBox !== void 0,
|
|
5821
|
+
attempt,
|
|
5822
|
+
ink: readInk(captured.png, chrome, canvas, geometry.inkInsetPx),
|
|
5823
|
+
...outcome !== void 0 ? { presentation: outcome } : {}
|
|
5824
|
+
};
|
|
4872
5825
|
}
|
|
4873
5826
|
function fitVerdict(canvas, frame) {
|
|
4874
5827
|
const contentHeight = frame.attempt.contentHeight;
|
|
4875
5828
|
if (contentHeight === null || contentHeight <= frame.viewport.height || frame.policy.overflow === "none") return null;
|
|
4876
5829
|
return canvasOverflowIssue(canvas, frame.viewport, contentHeight, frame.policy.overflow);
|
|
4877
5830
|
}
|
|
5831
|
+
function overflowXVerdict(canvas, frame) {
|
|
5832
|
+
const px = frame.attempt.overflowX;
|
|
5833
|
+
if (px === void 0 || px === null || px <= OVERFLOW_X_ROUNDING_PX || frame.policy.overflow === "none") return null;
|
|
5834
|
+
return canvasOverflowXIssue(canvas, frame.viewport, px, frame.policy.overflow);
|
|
5835
|
+
}
|
|
5836
|
+
function judgeSalvageOf(error, text, finishReason) {
|
|
5837
|
+
const cause = finishReason === void 0 ? "unknown" : finishReason === "length" ? "output-cap" : finishReason === "stop" ? "malformed" : "stopped";
|
|
5838
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
5839
|
+
const position = /position (\d+)/.exec(message);
|
|
5840
|
+
const start = text.indexOf("{");
|
|
5841
|
+
const offset = position !== null ? (start > 0 ? start : 0) + Number(position[1]) : void 0;
|
|
5842
|
+
const parseError = message.startsWith("Visual evaluator did not return valid JSON") ? "no complete JSON object in the answer" : message.replace(/"(?:[^"\\]|\\.)*"/g, '"\u2026"').slice(0, 160);
|
|
5843
|
+
return { cause, ...finishReason !== void 0 ? { finishReason } : {}, parseError, ...offset !== void 0 ? { offset } : {}, chars: text.length };
|
|
5844
|
+
}
|
|
5845
|
+
function judgeRecordOf(k, parsed, samples, criteria, requestedModel) {
|
|
5846
|
+
const salvaged = parsed.flatMap((a) => a.salvage !== void 0 ? [a.salvage.cause] : []);
|
|
5847
|
+
const answered = [...parsed, ...criteria?.kind === "ok" ? [criteria] : []];
|
|
5848
|
+
const served = [...new Set(answered.flatMap((a) => a.servedModel !== void 0 ? [a.servedModel] : []))];
|
|
5849
|
+
const criteriaSalvaged = criteria?.kind === "ok" ? criteria.salvage?.cause : void 0;
|
|
5850
|
+
return {
|
|
5851
|
+
k,
|
|
5852
|
+
rule: "median",
|
|
5853
|
+
samples,
|
|
5854
|
+
sigma: populationSigma(samples),
|
|
5855
|
+
notes: parsed.map((a) => a.result.critique ?? ""),
|
|
5856
|
+
...salvaged.length > 0 ? { salvaged } : {},
|
|
5857
|
+
...criteriaSalvaged !== void 0 ? { criteriaSalvaged } : {},
|
|
5858
|
+
models: { requested: requestedModel, served }
|
|
5859
|
+
};
|
|
5860
|
+
}
|
|
5861
|
+
function callFacts(response) {
|
|
5862
|
+
return {
|
|
5863
|
+
...response.finishReason !== void 0 ? { finishReason: response.finishReason } : {},
|
|
5864
|
+
...response.servedModel !== void 0 ? { servedModel: response.servedModel } : {}
|
|
5865
|
+
};
|
|
5866
|
+
}
|
|
5867
|
+
var NO_CRITERIA_ARRAY = { array: false, received: 0, kept: 0, dropped: { notObject: 0, badId: 0, badVerdict: 0 } };
|
|
5868
|
+
function criteriaCallOf(criteria) {
|
|
5869
|
+
if (criteria?.kind !== "ok") return {};
|
|
5870
|
+
const answer = criteria.result.criteriaCounts ?? NO_CRITERIA_ARRAY;
|
|
5871
|
+
return { call: { answer, ...criteria.finishReason !== void 0 ? { finishReason: criteria.finishReason } : {} } };
|
|
5872
|
+
}
|
|
5873
|
+
async function judgeFrame(judge, config, model, png, originalPrompt, profileBlock, canvas, k, criteriaBlock) {
|
|
5874
|
+
const [scoring, criteria] = await Promise.all([
|
|
5875
|
+
Promise.all(Array.from({ length: k }, () => judgeAndParse(judge, config, model, png, originalPrompt, profileBlock, canvas))),
|
|
5876
|
+
criteriaBlock.length > 0 ? judgeAndParse(judge, config, model, png, originalPrompt, profileBlock, canvas, criteriaBlock) : void 0
|
|
5877
|
+
]);
|
|
5878
|
+
return { scoring, criteria };
|
|
5879
|
+
}
|
|
5880
|
+
function criteriaCallTokens(criteria) {
|
|
5881
|
+
return criteria?.kind === "ok" ? { inputTokens: criteria.inputTokens, outputTokens: criteria.outputTokens } : void 0;
|
|
5882
|
+
}
|
|
5883
|
+
function criteriaReads(criteria) {
|
|
5884
|
+
return criteria?.kind === "ok" && criteria.salvage === void 0 ? [criteria.result.criteriaAnswers ?? []] : [];
|
|
5885
|
+
}
|
|
5886
|
+
function criteriaUnansweredReason(criteria) {
|
|
5887
|
+
return criteria?.kind === "ok" && criteria.salvage !== void 0 ? { unansweredReason: `not answered: the criteria answer was salvaged (${criteria.salvage.cause})` } : {};
|
|
5888
|
+
}
|
|
4878
5889
|
function judgeCountFor(config, canvas) {
|
|
4879
5890
|
const k = Math.max(1, Math.floor(config.judgeK ?? 1));
|
|
4880
5891
|
if (k === 1) return 1;
|
|
@@ -4890,18 +5901,22 @@ function populationSigma(values) {
|
|
|
4890
5901
|
const variance = values.reduce((a, b) => a + (b - mean) ** 2, 0) / values.length;
|
|
4891
5902
|
return Math.round(Math.sqrt(variance) * 10) / 10;
|
|
4892
5903
|
}
|
|
4893
|
-
async function judgeAndParse(judge, config, model, screenshot, originalPrompt, profileBlock, canvas) {
|
|
5904
|
+
async function judgeAndParse(judge, config, model, screenshot, originalPrompt, profileBlock, canvas, criteriaBlock = "") {
|
|
4894
5905
|
let firstReason;
|
|
4895
5906
|
for (let attempt = 1; attempt <= 2; attempt++) {
|
|
4896
|
-
const response = await judge(config, model, VISUAL_EVAL_PROMPT, screenshot, originalPrompt, profileBlock);
|
|
5907
|
+
const response = await judge(config, model, VISUAL_EVAL_PROMPT, screenshot, originalPrompt, profileBlock, criteriaBlock);
|
|
4897
5908
|
try {
|
|
4898
5909
|
const result = parseVisualResponse(response.text, config.passThreshold);
|
|
4899
|
-
return { kind: "ok", result, inputTokens: response.inputTokens, outputTokens: response.outputTokens };
|
|
5910
|
+
return { kind: "ok", result, inputTokens: response.inputTokens, outputTokens: response.outputTokens, ...callFacts(response) };
|
|
4900
5911
|
} catch (e) {
|
|
4901
5912
|
const salvaged = salvageTruncatedVisualAnswer(response.text, config.passThreshold);
|
|
4902
5913
|
if (salvaged !== null) {
|
|
4903
|
-
|
|
4904
|
-
|
|
5914
|
+
const salvage = judgeSalvageOf(e, response.text, response.finishReason);
|
|
5915
|
+
const token = criteriaBlock.length > 0 ? "visual_criteria_salvaged" : "visual_judge_salvaged";
|
|
5916
|
+
console.warn(
|
|
5917
|
+
`[visual-eval] ${token}${canvas ? ` canvas=${canvas}` : ""} cause=${salvage.cause} finish=${salvage.finishReason ?? "unreported"} at=${salvage.offset ?? "?"}/${salvage.chars} (${salvage.parseError}): score recovered from the closed prefix, issues list partial`
|
|
5918
|
+
);
|
|
5919
|
+
return { kind: "ok", result: salvaged, inputTokens: response.inputTokens, outputTokens: response.outputTokens, salvage, ...callFacts(response) };
|
|
4905
5920
|
}
|
|
4906
5921
|
const reason = `judge answer unparsable: ${e instanceof Error ? e.message : String(e)}`;
|
|
4907
5922
|
if (attempt === 1) {
|
|
@@ -4942,7 +5957,7 @@ async function runVisualEvaluationDetailed(context, config, deps = {}) {
|
|
|
4942
5957
|
const perCanvas = [];
|
|
4943
5958
|
const perCanvasResults = [];
|
|
4944
5959
|
for (const canvas of config.canvases) {
|
|
4945
|
-
const frame = await frameCanvas(bundledCode, context, html, canvas, config.canvasViewports, deps);
|
|
5960
|
+
const frame = await frameCanvas(bundledCode, context, html, canvas, config.canvasViewports, deps, config.hostPresentations);
|
|
4946
5961
|
const { viewport, policy, fit, attempt: attempt2 } = frame;
|
|
4947
5962
|
const screenshot2 = attempt2.png;
|
|
4948
5963
|
if (!screenshot2) {
|
|
@@ -4952,8 +5967,22 @@ async function runVisualEvaluationDetailed(context, config, deps = {}) {
|
|
|
4952
5967
|
}
|
|
4953
5968
|
const k = judgeCountFor(config, canvas);
|
|
4954
5969
|
const profileBlock = buildStylingProfileJudgeBlock(context.profile);
|
|
4955
|
-
const
|
|
4956
|
-
|
|
5970
|
+
const criteriaContext = context.criteria !== void 0 ? { ...context.criteria.context, canvas } : void 0;
|
|
5971
|
+
const criteriaSelected = context.criteria !== void 0 && criteriaContext !== void 0 ? selectCriteria(context.criteria.bank, criteriaContext) : void 0;
|
|
5972
|
+
const criteriaBlock = context.criteria !== void 0 && criteriaSelected !== void 0 ? buildCriteriaJudgeBlock(context.criteria.bank, criteriaSelected, {
|
|
5973
|
+
frame: { canvas, width: viewport.width, height: viewport.height, ...frame.presentation?.status === "applied" ? { hostLabel: frame.presentation.applied.label } : {} },
|
|
5974
|
+
...config.sampleProps !== void 0 ? { propsJson: JSON.stringify(config.sampleProps) } : {}
|
|
5975
|
+
}) : "";
|
|
5976
|
+
const { scoring: answers, criteria: criteriaAnswer } = await judgeFrame(
|
|
5977
|
+
judge,
|
|
5978
|
+
config,
|
|
5979
|
+
model,
|
|
5980
|
+
screenshot2,
|
|
5981
|
+
context.originalPrompt,
|
|
5982
|
+
profileBlock,
|
|
5983
|
+
canvas,
|
|
5984
|
+
k,
|
|
5985
|
+
criteriaBlock
|
|
4957
5986
|
);
|
|
4958
5987
|
const parsed = answers.filter((a) => a.kind === "ok");
|
|
4959
5988
|
if (parsed.length === 0) {
|
|
@@ -4966,16 +5995,11 @@ async function runVisualEvaluationDetailed(context, config, deps = {}) {
|
|
|
4966
5995
|
const result2 = representative.result;
|
|
4967
5996
|
result2.finalScore = median;
|
|
4968
5997
|
result2.passed = median >= config.passThreshold;
|
|
4969
|
-
result2.inputTokens = parsed.reduce((
|
|
4970
|
-
result2.outputTokens = parsed.reduce((
|
|
5998
|
+
result2.inputTokens = parsed.reduce((sum2, a) => sum2 + a.inputTokens, 0);
|
|
5999
|
+
result2.outputTokens = parsed.reduce((sum2, a) => sum2 + a.outputTokens, 0);
|
|
4971
6000
|
const response2 = { inputTokens: result2.inputTokens, outputTokens: result2.outputTokens };
|
|
4972
|
-
const
|
|
4973
|
-
|
|
4974
|
-
rule: "median",
|
|
4975
|
-
samples,
|
|
4976
|
-
sigma: populationSigma(samples),
|
|
4977
|
-
notes: parsed.map((a) => a.result.critique ?? "")
|
|
4978
|
-
};
|
|
6001
|
+
const criteriaTokens = criteriaCallTokens(criteriaAnswer);
|
|
6002
|
+
const judgeRecord = judgeRecordOf(k, parsed, samples, criteriaAnswer, model);
|
|
4979
6003
|
const contentHeight = attempt2.contentHeight;
|
|
4980
6004
|
const overflow = contentHeight !== null && contentHeight > viewport.height;
|
|
4981
6005
|
const fitIssue = fitVerdict(canvas, frame);
|
|
@@ -4983,6 +6007,26 @@ async function runVisualEvaluationDetailed(context, config, deps = {}) {
|
|
|
4983
6007
|
result2.issues.push(fitIssue);
|
|
4984
6008
|
if (policy.overflow === "fail") result2.passed = false;
|
|
4985
6009
|
}
|
|
6010
|
+
const overflowXIssue = overflowXVerdict(canvas, frame);
|
|
6011
|
+
if (overflowXIssue !== null) {
|
|
6012
|
+
result2.issues.push(overflowXIssue);
|
|
6013
|
+
if (policy.overflow === "fail") result2.passed = false;
|
|
6014
|
+
}
|
|
6015
|
+
const blankIssue = blankVerdict(canvas, frame);
|
|
6016
|
+
if (blankIssue !== null) {
|
|
6017
|
+
result2.issues.push(blankIssue);
|
|
6018
|
+
result2.passed = false;
|
|
6019
|
+
}
|
|
6020
|
+
const inkRatio = frame.ink?.ratio ?? null;
|
|
6021
|
+
const criteria = context.criteria !== void 0 && criteriaContext !== void 0 && criteriaSelected !== void 0 ? resolveCriteriaBlock({
|
|
6022
|
+
bank: context.criteria.bank,
|
|
6023
|
+
context: criteriaContext,
|
|
6024
|
+
selected: criteriaSelected,
|
|
6025
|
+
answers: criteriaReads(criteriaAnswer),
|
|
6026
|
+
measurements: { overflow, contentHeight, viewportHeight: viewport.height, inkRatio, edge: attempt2.edge ?? null },
|
|
6027
|
+
...criteriaUnansweredReason(criteriaAnswer),
|
|
6028
|
+
...criteriaCallOf(criteriaAnswer)
|
|
6029
|
+
}) : void 0;
|
|
4986
6030
|
perCanvasResults.push(result2);
|
|
4987
6031
|
perCanvas.push({
|
|
4988
6032
|
canvas,
|
|
@@ -4992,12 +6036,16 @@ async function runVisualEvaluationDetailed(context, config, deps = {}) {
|
|
|
4992
6036
|
screenshotPng: screenshot2,
|
|
4993
6037
|
contentHeight,
|
|
4994
6038
|
overflow,
|
|
6039
|
+
inkRatio,
|
|
4995
6040
|
judge: judgeRecord,
|
|
4996
6041
|
...fit !== void 0 ? { fit } : {},
|
|
4997
|
-
...frame.declared ? { declared: true } : {}
|
|
6042
|
+
...frame.declared ? { declared: true } : {},
|
|
6043
|
+
...criteria !== void 0 ? { criteria } : {},
|
|
6044
|
+
...criteriaTokens !== void 0 ? { criteriaTokens } : {},
|
|
6045
|
+
...frame.presentation !== void 0 ? { presentation: frame.presentation } : {}
|
|
4998
6046
|
});
|
|
4999
6047
|
console.log(
|
|
5000
|
-
`[visual-eval] canvas=${canvas} ${viewport.width}\xD7${viewport.height} score=${result2.finalScore}${k > 1 ? ` (median of ${judgeRecord.samples.length}/${k}: ${judgeRecord.samples.join(",")} \u03C3=${judgeRecord.sigma})` : ""} ${result2.passed ? "pass" : "FAIL"} | content=${contentHeight ?? "?"}px${overflow ? ` OVERFLOW (${policy.overflow})` : ""} | in=${response2.inputTokens} out=${response2.outputTokens}`
|
|
6048
|
+
`[visual-eval] canvas=${canvas} ${viewport.width}\xD7${viewport.height} score=${result2.finalScore}${k > 1 ? ` (median of ${judgeRecord.samples.length}/${k}: ${judgeRecord.samples.join(",")} \u03C3=${judgeRecord.sigma})` : ""} ${result2.passed ? "pass" : "FAIL"} | content=${contentHeight ?? "?"}px${overflow ? ` OVERFLOW (${policy.overflow})` : ""} ink=${inkRatio ?? "?"}${blankIssue !== null ? " BLANK" : ""} | in=${response2.inputTokens} out=${response2.outputTokens}` + (criteriaTokens !== void 0 ? ` | criteria in=${criteriaTokens.inputTokens} out=${criteriaTokens.outputTokens}` : "")
|
|
5001
6049
|
);
|
|
5002
6050
|
}
|
|
5003
6051
|
const aggregate = aggregateCanvasResults(perCanvas, perCanvasResults);
|
|
@@ -5039,7 +6087,7 @@ function canvasScopedIssue(canvas, issue) {
|
|
|
5039
6087
|
}
|
|
5040
6088
|
function aggregateCanvasResults(perCanvas, results) {
|
|
5041
6089
|
const n = results.length;
|
|
5042
|
-
const mean = (pick) => Math.round(results.reduce((
|
|
6090
|
+
const mean = (pick) => Math.round(results.reduce((sum2, r) => sum2 + pick(r), 0) / n);
|
|
5043
6091
|
const dimensions = {
|
|
5044
6092
|
completeness: mean((r) => r.dimensions.completeness),
|
|
5045
6093
|
visualPolish: mean((r) => r.dimensions.visualPolish),
|
|
@@ -5058,7 +6106,19 @@ function aggregateCanvasResults(perCanvas, results) {
|
|
|
5058
6106
|
inputTokens += r.inputTokens ?? 0;
|
|
5059
6107
|
outputTokens += r.outputTokens ?? 0;
|
|
5060
6108
|
});
|
|
6109
|
+
const criteriaSpent = perCanvas.flatMap((c) => c.criteriaTokens !== void 0 ? [c.criteriaTokens] : []);
|
|
6110
|
+
const judgeSalvage = {
|
|
6111
|
+
scoring: {
|
|
6112
|
+
salvaged: perCanvas.reduce((sum2, c) => sum2 + (c.judge.salvaged?.length ?? 0), 0),
|
|
6113
|
+
answers: perCanvas.reduce((sum2, c) => sum2 + c.judge.samples.length, 0)
|
|
6114
|
+
},
|
|
6115
|
+
criteria: {
|
|
6116
|
+
salvaged: perCanvas.filter((c) => c.judge.criteriaSalvaged !== void 0).length,
|
|
6117
|
+
answered: perCanvas.filter((c) => c.criteriaTokens !== void 0).length
|
|
6118
|
+
}
|
|
6119
|
+
};
|
|
5061
6120
|
return {
|
|
6121
|
+
judgeSalvage,
|
|
5062
6122
|
passed: perCanvas.every((c) => c.passed),
|
|
5063
6123
|
finalScore: mean((r) => r.finalScore),
|
|
5064
6124
|
dimensions,
|
|
@@ -5066,6 +6126,12 @@ function aggregateCanvasResults(perCanvas, results) {
|
|
|
5066
6126
|
...critiques.length > 0 ? { critique: critiques.join("\n") } : {},
|
|
5067
6127
|
inputTokens,
|
|
5068
6128
|
outputTokens,
|
|
6129
|
+
...criteriaSpent.length > 0 ? {
|
|
6130
|
+
criteriaTokens: {
|
|
6131
|
+
inputTokens: criteriaSpent.reduce((sum2, t) => sum2 + t.inputTokens, 0),
|
|
6132
|
+
outputTokens: criteriaSpent.reduce((sum2, t) => sum2 + t.outputTokens, 0)
|
|
6133
|
+
}
|
|
6134
|
+
} : {},
|
|
5069
6135
|
canvases: [...perCanvas]
|
|
5070
6136
|
};
|
|
5071
6137
|
}
|
|
@@ -5078,9 +6144,17 @@ function summarizeVisualResult(result) {
|
|
|
5078
6144
|
passed: c.passed,
|
|
5079
6145
|
contentHeight: c.contentHeight,
|
|
5080
6146
|
overflow: c.overflow,
|
|
6147
|
+
inkRatio: c.inkRatio,
|
|
5081
6148
|
judge: c.judge,
|
|
5082
|
-
...c.fit !== void 0 ? { fit: c.fit } : {}
|
|
6149
|
+
...c.fit !== void 0 ? { fit: c.fit } : {},
|
|
6150
|
+
...c.criteria !== void 0 ? { criteria: c.criteria } : {},
|
|
6151
|
+
...c.presentation !== void 0 ? { presentation: c.presentation } : {}
|
|
5083
6152
|
}));
|
|
6153
|
+
const blocks = result.canvases.map((c) => c.criteria).filter((b) => b !== void 0);
|
|
6154
|
+
const idsWhere = (severity, verdict) => [
|
|
6155
|
+
...new Set(blocks.flatMap((b) => b.verdicts.filter((v) => v.severity === severity && v.verdict === verdict).map((v) => v.id)))
|
|
6156
|
+
];
|
|
6157
|
+
const rollup = blocks.length > 0 ? { mustFailed: idsWhere("must", "fail"), shouldFailed: idsWhere("should", "fail"), mustNa: idsWhere("must", "n/a") } : void 0;
|
|
5084
6158
|
const fitCanvas = result.canvases.find((c) => canvasFitPolicy(c.canvas).overflow === "fail" && c.contentHeight !== null);
|
|
5085
6159
|
const fit = fitCanvas !== void 0 && fitCanvas.contentHeight !== null ? {
|
|
5086
6160
|
canvas: fitCanvas.canvas,
|
|
@@ -5095,7 +6169,74 @@ function summarizeVisualResult(result) {
|
|
|
5095
6169
|
canvases,
|
|
5096
6170
|
...fit !== void 0 ? { fit } : {},
|
|
5097
6171
|
...result.design !== void 0 ? { design: result.design } : {},
|
|
5098
|
-
...result.themeMode !== void 0 ? { themeMode: result.themeMode } : {}
|
|
6172
|
+
...result.themeMode !== void 0 ? { themeMode: result.themeMode } : {},
|
|
6173
|
+
...rollup !== void 0 ? { criteria: rollup } : {}
|
|
6174
|
+
};
|
|
6175
|
+
}
|
|
6176
|
+
async function judgeStoredCapture(input, config, deps = {}) {
|
|
6177
|
+
const judge = deps.judge ?? callMultimodalLLM;
|
|
6178
|
+
const model = config.model ?? getDefaultVisualModel(config.provider);
|
|
6179
|
+
const { canvas, viewport, png } = input;
|
|
6180
|
+
const chrome = canvasChrome(canvas);
|
|
6181
|
+
const ink = readInk(png, chrome, canvas);
|
|
6182
|
+
const inkRatio = ink?.ratio ?? null;
|
|
6183
|
+
const k = judgeCountFor(config, canvas);
|
|
6184
|
+
const profileBlock = buildStylingProfileJudgeBlock(input.profile);
|
|
6185
|
+
const criteriaContext = input.criteria !== void 0 ? { ...input.criteria.context, canvas } : void 0;
|
|
6186
|
+
const criteriaSelected = input.criteria !== void 0 && criteriaContext !== void 0 ? selectCriteria(input.criteria.bank, criteriaContext) : void 0;
|
|
6187
|
+
const criteriaBlock = input.criteria !== void 0 && criteriaSelected !== void 0 ? buildCriteriaJudgeBlock(input.criteria.bank, criteriaSelected, {
|
|
6188
|
+
frame: { canvas, width: viewport.width, height: viewport.height },
|
|
6189
|
+
...input.sampleProps !== void 0 ? { propsJson: JSON.stringify(input.sampleProps) } : {}
|
|
6190
|
+
}) : "";
|
|
6191
|
+
const { scoring: answers, criteria: criteriaAnswer } = await judgeFrame(judge, config, model, png, input.originalPrompt, profileBlock, canvas, k, criteriaBlock);
|
|
6192
|
+
const parsed = answers.filter((a) => a.kind === "ok");
|
|
6193
|
+
if (parsed.length === 0) {
|
|
6194
|
+
const first = answers.find((a) => a.kind === "unparsable");
|
|
6195
|
+
return { kind: "unavailable", reason: first?.reason ?? "judge answer unparsable" };
|
|
6196
|
+
}
|
|
6197
|
+
const samples = parsed.map((a) => a.result.finalScore);
|
|
6198
|
+
const median = medianOf(samples);
|
|
6199
|
+
const result = parsed[samples.indexOf(median)].result;
|
|
6200
|
+
const issues = [...result.issues];
|
|
6201
|
+
let passed = median >= config.passThreshold;
|
|
6202
|
+
const contentHeight = input.contentHeight;
|
|
6203
|
+
const overflow = contentHeight !== null && contentHeight > viewport.height;
|
|
6204
|
+
const policy = canvasFitPolicy(canvas);
|
|
6205
|
+
if (overflow && contentHeight !== null && policy.overflow !== "none") {
|
|
6206
|
+
issues.push(canvasOverflowIssue(canvas, viewport, contentHeight, policy.overflow));
|
|
6207
|
+
if (policy.overflow === "fail") passed = false;
|
|
6208
|
+
}
|
|
6209
|
+
if (ink !== null && ink.lastInkRow === null) {
|
|
6210
|
+
issues.push(canvasBlankIssue(canvas, viewport));
|
|
6211
|
+
passed = false;
|
|
6212
|
+
}
|
|
6213
|
+
const criteria = input.criteria !== void 0 && criteriaContext !== void 0 && criteriaSelected !== void 0 ? resolveCriteriaBlock({
|
|
6214
|
+
bank: input.criteria.bank,
|
|
6215
|
+
context: criteriaContext,
|
|
6216
|
+
selected: criteriaSelected,
|
|
6217
|
+
answers: criteriaReads(criteriaAnswer),
|
|
6218
|
+
measurements: { overflow, contentHeight, viewportHeight: viewport.height, inkRatio },
|
|
6219
|
+
...criteriaUnansweredReason(criteriaAnswer),
|
|
6220
|
+
...criteriaCallOf(criteriaAnswer)
|
|
6221
|
+
}) : void 0;
|
|
6222
|
+
const criteriaTokens = criteriaCallTokens(criteriaAnswer);
|
|
6223
|
+
return {
|
|
6224
|
+
kind: "ok",
|
|
6225
|
+
verdict: {
|
|
6226
|
+
canvas,
|
|
6227
|
+
viewport,
|
|
6228
|
+
score: median,
|
|
6229
|
+
passed,
|
|
6230
|
+
issues,
|
|
6231
|
+
judge: judgeRecordOf(k, parsed, samples, criteriaAnswer, model),
|
|
6232
|
+
contentHeight,
|
|
6233
|
+
overflow,
|
|
6234
|
+
inkRatio,
|
|
6235
|
+
...criteria !== void 0 ? { criteria } : {},
|
|
6236
|
+
inputTokens: parsed.reduce((sum2, a) => sum2 + a.inputTokens, 0),
|
|
6237
|
+
outputTokens: parsed.reduce((sum2, a) => sum2 + a.outputTokens, 0),
|
|
6238
|
+
...criteriaTokens !== void 0 ? { criteriaTokens } : {}
|
|
6239
|
+
}
|
|
5099
6240
|
};
|
|
5100
6241
|
}
|
|
5101
6242
|
function salvageTruncatedVisualAnswer(text, passThreshold) {
|
|
@@ -5132,7 +6273,7 @@ function salvageTruncatedVisualAnswer(text, passThreshold) {
|
|
|
5132
6273
|
hierarchy,
|
|
5133
6274
|
aesthetics,
|
|
5134
6275
|
issues,
|
|
5135
|
-
critique: `[
|
|
6276
|
+
critique: `[salvaged judge answer \u2014 it did not parse; score recovered from the closed prefix; ${issues.length} issue(s) kept, the rest and the critique lost]`
|
|
5136
6277
|
});
|
|
5137
6278
|
return parseVisualResponse(rebuilt, passThreshold);
|
|
5138
6279
|
}
|
|
@@ -5161,18 +6302,44 @@ function parseVisualResponse(text, passThreshold) {
|
|
|
5161
6302
|
};
|
|
5162
6303
|
const issues = (raw.issues || []).map((i) => ({
|
|
5163
6304
|
dimension: i.dimension || "visual",
|
|
6305
|
+
origin: "judge",
|
|
5164
6306
|
severity: i.severity || "minor",
|
|
5165
6307
|
description: i.description || "",
|
|
5166
6308
|
fix: i.fix || ""
|
|
5167
6309
|
}));
|
|
6310
|
+
const criteria = parseCriteriaAnswers(raw.criteria);
|
|
5168
6311
|
return {
|
|
5169
6312
|
passed: finalScore >= passThreshold,
|
|
5170
6313
|
finalScore,
|
|
5171
6314
|
dimensions,
|
|
5172
6315
|
issues,
|
|
5173
|
-
critique: raw.critique
|
|
6316
|
+
critique: raw.critique,
|
|
6317
|
+
...criteria.answers !== void 0 ? { criteriaAnswers: criteria.answers } : {},
|
|
6318
|
+
criteriaCounts: criteria.counts
|
|
5174
6319
|
};
|
|
5175
6320
|
}
|
|
6321
|
+
function parseCriteriaAnswers(raw) {
|
|
6322
|
+
if (!Array.isArray(raw)) return { counts: NO_CRITERIA_ARRAY };
|
|
6323
|
+
const answers = [];
|
|
6324
|
+
const dropped = { notObject: 0, badId: 0, badVerdict: 0 };
|
|
6325
|
+
for (const entry of raw) {
|
|
6326
|
+
if (typeof entry !== "object" || entry === null) {
|
|
6327
|
+
dropped.notObject += 1;
|
|
6328
|
+
continue;
|
|
6329
|
+
}
|
|
6330
|
+
const o = entry;
|
|
6331
|
+
if (typeof o.id !== "string") {
|
|
6332
|
+
dropped.badId += 1;
|
|
6333
|
+
continue;
|
|
6334
|
+
}
|
|
6335
|
+
if (o.verdict !== "pass" && o.verdict !== "fail" && o.verdict !== "n/a") {
|
|
6336
|
+
dropped.badVerdict += 1;
|
|
6337
|
+
continue;
|
|
6338
|
+
}
|
|
6339
|
+
answers.push({ id: o.id, verdict: o.verdict, evidence: typeof o.evidence === "string" ? o.evidence : "" });
|
|
6340
|
+
}
|
|
6341
|
+
return { answers, counts: { array: true, received: raw.length, kept: answers.length, dropped } };
|
|
6342
|
+
}
|
|
5176
6343
|
function getDefaultVisualModel(provider) {
|
|
5177
6344
|
switch (provider) {
|
|
5178
6345
|
case "claude":
|
|
@@ -5191,7 +6358,12 @@ async function runVisualEval(context, config, deps = {}) {
|
|
|
5191
6358
|
const issues = (result.issues || []).map(toEvalIssue);
|
|
5192
6359
|
const summary = summarizeVisualResult(result);
|
|
5193
6360
|
const coverage = { status: "ran" };
|
|
5194
|
-
|
|
6361
|
+
const tokens = result.inputTokens !== void 0 && result.outputTokens !== void 0 ? {
|
|
6362
|
+
inputTokens: result.inputTokens,
|
|
6363
|
+
outputTokens: result.outputTokens,
|
|
6364
|
+
...result.criteriaTokens !== void 0 ? { criteria: result.criteriaTokens } : {}
|
|
6365
|
+
} : void 0;
|
|
6366
|
+
return { issues, ...summary !== void 0 ? { summary } : {}, coverage, ...tokens !== void 0 ? { tokens } : {} };
|
|
5195
6367
|
}
|
|
5196
6368
|
function toEvalIssue(issue) {
|
|
5197
6369
|
return {
|
|
@@ -5201,14 +6373,55 @@ function toEvalIssue(issue) {
|
|
|
5201
6373
|
subcategory: issue.dimension,
|
|
5202
6374
|
severity: issue.severity === "critical" ? "critical" : "major",
|
|
5203
6375
|
description: issue.description,
|
|
5204
|
-
fix: issue.fix || ""
|
|
6376
|
+
fix: issue.fix || "",
|
|
6377
|
+
...issue.origin !== void 0 ? { origin: issue.origin } : {}
|
|
5205
6378
|
};
|
|
5206
6379
|
}
|
|
5207
|
-
function cssTokensForAppTheme(theme, mode = "light") {
|
|
5208
|
-
if (theme === void 0) return getCssTokens(mode);
|
|
5209
|
-
return composeThemeCss({
|
|
6380
|
+
function cssTokensForAppTheme(theme, mode = "light", themeId) {
|
|
6381
|
+
if (theme === void 0 && themeId === void 0) return getCssTokens(mode);
|
|
6382
|
+
return composeThemeCss({
|
|
6383
|
+
layer: "page",
|
|
6384
|
+
mode: theme?.mode ?? mode,
|
|
6385
|
+
...themeId !== void 0 ? { themeId } : {},
|
|
6386
|
+
...theme !== void 0 ? { appTheme: theme } : {}
|
|
6387
|
+
});
|
|
6388
|
+
}
|
|
6389
|
+
|
|
6390
|
+
// src/evaluation/criteria/context.ts
|
|
6391
|
+
var PRIMARY_500 = /--ggui-color-primary-500:\s*(#[0-9a-fA-F]{3}|#[0-9a-fA-F]{6})\s*;/;
|
|
6392
|
+
var ACHROMATIC_SPREAD = 0.08;
|
|
6393
|
+
function chromaOfCssTokens(cssTokens) {
|
|
6394
|
+
const m = cssTokens?.match(PRIMARY_500);
|
|
6395
|
+
if (!m) return "unknown";
|
|
6396
|
+
const hex = m[1].slice(1);
|
|
6397
|
+
const full = hex.length === 3 ? hex.split("").map((c) => c + c).join("") : hex;
|
|
6398
|
+
const channels = [0, 2, 4].map((i) => parseInt(full.slice(i, i + 2), 16));
|
|
6399
|
+
const spread = (Math.max(...channels) - Math.min(...channels)) / 255;
|
|
6400
|
+
return spread < ACHROMATIC_SPREAD ? "achromatic" : "chromatic";
|
|
6401
|
+
}
|
|
6402
|
+
function criteriaContextFor(src) {
|
|
6403
|
+
const v = src.classification.vector;
|
|
6404
|
+
const preset = src.profile?.aesthetic?.id;
|
|
6405
|
+
return {
|
|
6406
|
+
hasActions: Object.keys(src.contract?.actionSpec ?? {}).length > 0,
|
|
6407
|
+
riskTier: src.classification.riskTier,
|
|
6408
|
+
axes: { render: v.render, state: v.state, writes: v.writes, fetch: v.fetch, layout: v.layout },
|
|
6409
|
+
chroma: chromaOfCssTokens(src.cssTokens),
|
|
6410
|
+
profilePresent: src.profile !== void 0,
|
|
6411
|
+
shell: src.shell,
|
|
6412
|
+
...src.kind !== void 0 ? { kind: src.kind } : {},
|
|
6413
|
+
...preset !== void 0 ? { preset } : {}
|
|
6414
|
+
};
|
|
6415
|
+
}
|
|
6416
|
+
|
|
6417
|
+
// src/evaluation/judge-spend.ts
|
|
6418
|
+
function judgedFrameTokens(result) {
|
|
6419
|
+
return {
|
|
6420
|
+
inputTokens: (result.inputTokens ?? 0) + (result.criteriaTokens?.inputTokens ?? 0),
|
|
6421
|
+
outputTokens: (result.outputTokens ?? 0) + (result.criteriaTokens?.outputTokens ?? 0)
|
|
6422
|
+
};
|
|
5210
6423
|
}
|
|
5211
6424
|
|
|
5212
|
-
export { CANVAS_BREAKPOINTS, CANVAS_CLASSES, CANVAS_DESCRIPTORS, CANVAS_VIEWPORTS, CONTENT_HEIGHT_EXPRESSION, CRITERIA, DEFAULT_EVAL_MAX_ROUNDS, DEFAULT_QUALITY_CONFIG, LLM_EVAL_STATIC_CRITERIA, MAX_EVAL_ROUNDS_HARD_LIMIT, VISUAL_EVAL_PROMPT, VISUAL_JUDGE_MAX_OUTPUT_TOKENS, VISUAL_JUDGE_PROMPT_DIGEST, VISUAL_JUDGE_PROMPT_VERSION, buildCodingCriteriaSummary, buildFixPrompt, callMultimodalLLM, canvasDisplayModeForShell, canvasFitPolicy, canvasForRendering, canvasForViewportWidth, canvasOverflowIssue, captureScreenshot, captureScreenshotDetailed, computeEvaluationScore, createEvaluationToolsServer, criteriaFor, cssTokensForAppTheme, describeCanvas, designTreeSha256, extractCompiledCode, extractCompiledCodeFromMessage, extractEvalResult, extractSourceCode, extractSourceCodeFromMessage, extractToolResultTexts, getActionableIssues, getCriteriaByPriority, getCriterionById, getEvaluatorSystemPrompt, getLLMCriteria, isBlocked, judgeDesignIdentity, matches, notApplicableCoverage, priorityForIssue, resetJudgeDesignIdentityCache, resolveDesignPackageDir, resolveLaunchOptions, runAxisChecks, runEvaluation, runEvaluationLoop, runVisualEval, runVisualEvaluation, runVisualEvaluationDetailed, summarizeVisualResult };
|
|
6425
|
+
export { CANVAS_BREAKPOINTS, CANVAS_CLASSES, CANVAS_DESCRIPTORS, CANVAS_VIEWPORTS, CARD_HEIGHT_EXPRESSION, CONTENT_HEIGHT_EXPRESSION, CRITERIA, CRITERIA_SELECTOR_VERSION, DEFAULT_EVAL_MAX_ROUNDS, DEFAULT_QUALITY_CONFIG, EDGE_BLOCKS_EXPRESSION, EDGE_TOLERANCE_PX, HOST_COLOUR_PATTERN, HOST_FRAME_BOUNDS, INK_TOLERANCE, INSTRUMENT_BESIDE_JUDGE, INSTRUMENT_BY_ID, JUDGE_GROUND_MARGIN_PX, JUDGE_INK_INSET_PX, LLM_EVAL_STATIC_CRITERIA, MAX_EVAL_ROUNDS_HARD_LIMIT, VISUAL_EVAL_PROMPT, VISUAL_JUDGE_MAX_OUTPUT_TOKENS, VISUAL_JUDGE_PROMPT_DIGEST, VISUAL_JUDGE_PROMPT_VERSION, bankRows, buildCodingCriteriaSummary, buildCriteriaJudgeBlock, buildFixPrompt, callMultimodalLLM, canonicalCriteriaContext, canvasBlankIssue, canvasDisplayModeForShell, canvasFitPolicy, canvasForRendering, canvasForViewportWidth, canvasOverflowIssue, captureScreenshot, captureScreenshotDetailed, chromaOfCssTokens, computeEvaluationScore, createEvaluationToolsServer, criteriaBankSchema, criteriaContextFor, criteriaFor, criteriaSetIdFor, cssTokensForAppTheme, decodePng, describeCanvas, designTreeSha256, extractCompiledCode, extractCompiledCodeFromMessage, extractEvalResult, extractSourceCode, extractSourceCodeFromMessage, extractToolResultTexts, getActionableIssues, getCriteriaByPriority, getCriterionById, getEvaluatorSystemPrompt, getLLMCriteria, isBlocked, judgeDesignIdentity, judgeEdges, judgeStoredCapture, judgedFrameTokens, matches, notApplicableCoverage, parseCriteriaBank, parseEdgeProbe, priorityForIssue, readInkExtent, resetJudgeDesignIdentityCache, resolveCriteriaBlock, resolveDesignPackageDir, resolveLaunchOptions, runAxisChecks, runEvaluation, runEvaluationLoop, runVisualEval, runVisualEvaluation, runVisualEvaluationDetailed, selectCriteria, summarizeVisualResult };
|
|
5213
6426
|
//# sourceMappingURL=index.js.map
|
|
5214
6427
|
//# sourceMappingURL=index.js.map
|