@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
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `space.edge`'s measurement (#1663): the expression a capture evaluates in the page to produce {@link EdgeBlock}s for
|
|
3
|
+
* {@link judgeEdges}. It runs where the screenshot is taken, on the same DOM at the same width, so the rule reads what
|
|
4
|
+
* the judge's frame shows. It only reads layout; it never writes to the page.
|
|
5
|
+
*
|
|
6
|
+
* What it measures, under `#root`, in CSS px from the card's left edge:
|
|
7
|
+
*
|
|
8
|
+
* - **Text blocks.** Text is grouped by its nearest non-inline element. A block's edge is its first visible character
|
|
9
|
+
* in ink: text in a transparent colour, private-use icon-font glyphs and formatting characters are not characters of
|
|
10
|
+
* the block. Its extent is the union of its inked line boxes. A marker before the text (an icon or image that paints,
|
|
11
|
+
* or a number or bullet on the same line) makes it a hanging marker: `edge` is the marker's, `textEdge` the text's.
|
|
12
|
+
* - **Control groups.** Native and ARIA controls, and bounded boxes the visitor clicks (a pointer cursor or a tab stop,
|
|
13
|
+
* holding no control of their own), group by their parent and are measured at the first control: the border box
|
|
14
|
+
* when bounded, else its text. A control's contents are never blocks of the enclosing column.
|
|
15
|
+
* - **Surfaces.** A non-inline box with a border on at least three sides, its own fill different from what it sits on,
|
|
16
|
+
* or a shadow. A table is a surface measured at its rule and not descended into. A box with no text, control, image
|
|
17
|
+
* or icon inside (a loading bar) is a `placeholder`, which is not a block.
|
|
18
|
+
* - **Roles.** A list item is `list-item` when its list follows a paragraph; text centred in a box wider than it, or
|
|
19
|
+
* controls in a centring row, are `centred`.
|
|
20
|
+
* - **Rows, first, read by geometry.** Items set on one line, however the CSS lays them out: an item after another
|
|
21
|
+
* measured item on its line that holds one block (a text, a control, or a box holding at most one text) belongs to
|
|
22
|
+
* the row and is not measured. An item with a stack inside it is a column.
|
|
23
|
+
* - **Ornaments:** text with no letters or digits is not measured.
|
|
24
|
+
*
|
|
25
|
+
* Hidden elements (through any ancestor), zero-size ones and transparent text are skipped. What the rows and ornament
|
|
26
|
+
* rules set aside is counted. A frame with no text in ink is reported as placeholders only. The expression returns a
|
|
27
|
+
* JSON value; {@link parseEdgeProbe} is its typed read. *
|
|
28
|
+
* **Validated instrument (#1663, 2026-10-02):** this file as of `cec465319` (with `7bb4da13e`) passed a fresh sealed
|
|
29
|
+
* blind draw at the registered bar (27/30, κ 0.76), stricter than the design lead's reading on its disagreements. Any
|
|
30
|
+
* later change to what this file measures or decides is a new instrument until another fresh sealed draw.
|
|
31
|
+
*/
|
|
32
|
+
import type { EdgeBlock } from './edge.js';
|
|
33
|
+
|
|
34
|
+
/** What the probe returns: the measured blocks, and whether the frame showed nothing to evaluate. */
|
|
35
|
+
export interface EdgeProbe {
|
|
36
|
+
readonly blocks: readonly EdgeBlock[];
|
|
37
|
+
readonly placeholdersOnly: boolean;
|
|
38
|
+
/** What the probe saw and did not measure, by rule, so a dropped block is counted rather than silent. */
|
|
39
|
+
readonly skipped: { readonly rowItems: number; readonly ornaments: number };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Plain ES2020 for the page: no imports, no template literals, no helpers a bundler might rename.
|
|
43
|
+
export const EDGE_BLOCKS_EXPRESSION = String.raw`(() => {
|
|
44
|
+
const root = document.getElementById('root');
|
|
45
|
+
if (!root) return null;
|
|
46
|
+
const rootBox = root.getBoundingClientRect();
|
|
47
|
+
const x0 = rootBox.left;
|
|
48
|
+
const px = (v) => Math.round((v - x0) * 2) / 2;
|
|
49
|
+
const CONTROL = 'button,input,select,textarea,[role="button"],[role="tab"],[role="switch"],[role="checkbox"],[role="radio"],[role="option"],[role="link"],a[href]';
|
|
50
|
+
const ALNUM = /[\p{L}\p{N}]/u;
|
|
51
|
+
const css = (el) => getComputedStyle(el);
|
|
52
|
+
const clear = (c) => !c || c === 'transparent' || /rgba\([^)]*,\s*0(\.0+)?\)$/.test(c);
|
|
53
|
+
// Visible through every ancestor (an ancestor at opacity 0 or visibility hidden hides it), and not degenerate.
|
|
54
|
+
const shown = (el) => {
|
|
55
|
+
const s = css(el);
|
|
56
|
+
if (s.display === 'none' || s.visibility === 'hidden' || Number(s.opacity) === 0) return false;
|
|
57
|
+
if (typeof el.checkVisibility === 'function' && !el.checkVisibility({ opacityProperty: true, visibilityProperty: true })) return false;
|
|
58
|
+
const r = el.getBoundingClientRect();
|
|
59
|
+
return r.width > 1 && r.height > 1;
|
|
60
|
+
};
|
|
61
|
+
const inline = (s) => /^inline/.test(s.display) && s.display !== 'inline-block' && s.display !== 'inline-flex' && s.display !== 'inline-grid';
|
|
62
|
+
const groundOf = (el) => {
|
|
63
|
+
for (let p = el.parentElement; p; p = p.parentElement) { const b = css(p).backgroundColor; if (!clear(b)) return b; }
|
|
64
|
+
return 'rgb(255, 255, 255)';
|
|
65
|
+
};
|
|
66
|
+
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;
|
|
67
|
+
const bounded = (el) => { const s = css(el); return sides(s) >= 1 || (!clear(s.backgroundColor) && s.backgroundColor !== groundOf(el)); };
|
|
68
|
+
// A control: a native or ARIA one, or a bounded box the visitor clicks (a pointer cursor or a tab stop) that holds no
|
|
69
|
+
// control of its own and is not the whole card. Its contents are not blocks of the column (decided 2026-10-02).
|
|
70
|
+
const isControl = (el) => {
|
|
71
|
+
const s = css(el);
|
|
72
|
+
if (inline(s)) return false;
|
|
73
|
+
if (el.matches(CONTROL)) return true;
|
|
74
|
+
if (el === root) return false;
|
|
75
|
+
const clickable = s.cursor === 'pointer' || (el.hasAttribute('tabindex') && el.tabIndex >= 0);
|
|
76
|
+
if (!clickable || !bounded(el) || el.querySelector(CONTROL)) return false;
|
|
77
|
+
const r = el.getBoundingClientRect();
|
|
78
|
+
return r.width * r.height < 0.6 * rootBox.width * rootBox.height;
|
|
79
|
+
};
|
|
80
|
+
const isTable = (el) => el.matches('table,[role="table"],[role="grid"]');
|
|
81
|
+
const isSurface = (el) => {
|
|
82
|
+
if (el === root || isControl(el)) return false;
|
|
83
|
+
const s = css(el);
|
|
84
|
+
if (/^inline/.test(s.display) || s.display === 'contents') return false;
|
|
85
|
+
if (isTable(el)) return true;
|
|
86
|
+
return sides(s) >= 3 || (!clear(s.backgroundColor) && s.backgroundColor !== groundOf(el)) || (s.boxShadow && s.boxShadow !== 'none');
|
|
87
|
+
};
|
|
88
|
+
const path = (el) => {
|
|
89
|
+
const parts = [];
|
|
90
|
+
for (let e = el; e && e !== root && parts.length < 4; e = e.parentElement) {
|
|
91
|
+
const i = e.parentElement ? Array.prototype.indexOf.call(e.parentElement.children, e) + 1 : 1;
|
|
92
|
+
parts.unshift(e.tagName.toLowerCase() + ':' + i);
|
|
93
|
+
}
|
|
94
|
+
return parts.join('>');
|
|
95
|
+
};
|
|
96
|
+
const label = (el) => { const t = (el.innerText || '').replace(/\s+/g, ' ').trim(); return path(el) + (t ? ' "' + t.slice(0, 24) + '"' : ''); };
|
|
97
|
+
const surfaces = [];
|
|
98
|
+
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; };
|
|
99
|
+
const inTable = (el) => { for (let p = el.parentElement; p && p !== root; p = p.parentElement) if (isTable(p)) return true; return false; };
|
|
100
|
+
const inControl = (el) => { for (let p = el; p && p !== root; p = p.parentElement) if (isControl(p)) return p; return null; };
|
|
101
|
+
const all = Array.prototype.slice.call(root.querySelectorAll('*')).filter(shown);
|
|
102
|
+
for (const el of all) if (isSurface(el) && !inTable(el) && !inControl(el)) surfaces.push(el);
|
|
103
|
+
const ids = new Map();
|
|
104
|
+
const idOf = (el) => { if (!ids.has(el)) ids.set(el, label(el)); return ids.get(el); };
|
|
105
|
+
const blocks = [];
|
|
106
|
+
// Per block: the element it stands for and its first line's box, for rows.
|
|
107
|
+
const owners = [];
|
|
108
|
+
const lines = [];
|
|
109
|
+
// Per block: its raw line boxes and left, so a row reads the line two items share, not a wrapped item's widest line.
|
|
110
|
+
const rects = [];
|
|
111
|
+
const rawLeft = [];
|
|
112
|
+
const words = [];
|
|
113
|
+
const within = (el, self) => { const s = surfaceOf(el, self); return s ? { within: idOf(s) } : {}; };
|
|
114
|
+
for (const s of surfaces) {
|
|
115
|
+
const r = s.getBoundingClientRect();
|
|
116
|
+
owners.push(s); lines.push({ top: r.top, bottom: r.bottom }); rects.push([r]); rawLeft.push(r.left); words.push('');
|
|
117
|
+
// A placeholder: a box with no text, no control and no image or icon inside it (a loading bar). Not a block.
|
|
118
|
+
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 || '');
|
|
119
|
+
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' }));
|
|
120
|
+
}
|
|
121
|
+
const blockOf = (node) => { for (let p = node.parentElement; p && p !== root; p = p.parentElement) if (!inline(css(p))) return p; return root; };
|
|
122
|
+
const groups = new Map();
|
|
123
|
+
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
|
124
|
+
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
|
|
125
|
+
if (!/\S/.test(n.data) || !n.parentElement || n.parentElement.closest('style,script,noscript,template') || !shown(n.parentElement) || inControl(n.parentElement) || inTable(n.parentElement)) continue;
|
|
126
|
+
const b = blockOf(n);
|
|
127
|
+
if (!groups.has(b)) groups.set(b, []);
|
|
128
|
+
groups.get(b).push(n);
|
|
129
|
+
}
|
|
130
|
+
// Ink: text whose own colour is not transparent. The edge is the first visible character in ink (decided 2026-10-02):
|
|
131
|
+
// leading invisible text, private-use icon-font glyphs and formatting characters are not characters of the block,
|
|
132
|
+
// while a currency sign or a quotation mark is.
|
|
133
|
+
const inked = (n) => !clear(css(n.parentElement).color) && ALNUM.test(n.data);
|
|
134
|
+
const textRects = (nodes) => {
|
|
135
|
+
const rs = [];
|
|
136
|
+
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); }
|
|
137
|
+
return rs;
|
|
138
|
+
};
|
|
139
|
+
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; };
|
|
140
|
+
const firstGlyph = (nodes) => {
|
|
141
|
+
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; }
|
|
142
|
+
return null;
|
|
143
|
+
};
|
|
144
|
+
// A marker paints: a loaded image, an SVG with a filled or stroked shape, or an element with its own fill, border
|
|
145
|
+
// or visible text. An element that paints nothing is not a marker.
|
|
146
|
+
const painted = (m) => {
|
|
147
|
+
if (!shown(m)) return false;
|
|
148
|
+
if (m.tagName === 'IMG') return m.complete && m.naturalWidth > 0;
|
|
149
|
+
if (m.tagName.toLowerCase() === 'svg' || m.closest('svg')) {
|
|
150
|
+
const shapes = m.querySelectorAll('path,circle,rect,line,polyline,polygon,ellipse,use,text');
|
|
151
|
+
return Array.prototype.some.call(shapes, (sh) => { const st = css(sh); return (st.fill !== 'none' && !clear(st.fill)) || (st.stroke !== 'none' && !clear(st.stroke)); });
|
|
152
|
+
}
|
|
153
|
+
const s = css(m);
|
|
154
|
+
if (!clear(s.backgroundColor) || sides(s) >= 1 || ((m.textContent || '').trim().length > 0 && !clear(s.color))) return true;
|
|
155
|
+
// A wrapper that paints nothing itself is a marker when an image or icon inside it paints.
|
|
156
|
+
return Array.prototype.some.call(m.querySelectorAll('svg,img'), (k) => painted(k));
|
|
157
|
+
};
|
|
158
|
+
const markerBefore = (b, textLeft, lineTop, lineBottom) => {
|
|
159
|
+
const cands = [];
|
|
160
|
+
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); };
|
|
161
|
+
for (const m of b.querySelectorAll('svg,img,[aria-hidden="true"],i')) if ((m.textContent || '').trim().length <= 2) consider(m);
|
|
162
|
+
const p = b.parentElement;
|
|
163
|
+
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); }
|
|
164
|
+
return cands.length ? Math.min.apply(null, cands) : null;
|
|
165
|
+
};
|
|
166
|
+
let texts = 0;
|
|
167
|
+
let ornaments = 0;
|
|
168
|
+
for (const [b, nodes] of groups) {
|
|
169
|
+
if (!nodes.some((n) => ALNUM.test(n.data))) { ornaments += 1; continue; }
|
|
170
|
+
const rs = textRects(nodes);
|
|
171
|
+
const g = firstGlyph(nodes);
|
|
172
|
+
if (!rs.length || g === null) continue;
|
|
173
|
+
texts += 1;
|
|
174
|
+
const first = g.left;
|
|
175
|
+
const left = Math.min.apply(null, rs.map((r) => r.left));
|
|
176
|
+
const right = Math.max.apply(null, rs.map((r) => r.right));
|
|
177
|
+
const top = Math.min.apply(null, rs.map((r) => r.top));
|
|
178
|
+
const marker = markerBefore(b, first, g.top, g.bottom);
|
|
179
|
+
const s = css(b);
|
|
180
|
+
const box = b.getBoundingClientRect();
|
|
181
|
+
const parent = b.parentElement ? css(b.parentElement) : null;
|
|
182
|
+
const centredText = s.textAlign === 'center' && right - left < box.width * 0.9;
|
|
183
|
+
const centredBox = parent && /flex/.test(parent.display) && /column/.test(parent.flexDirection) && parent.alignItems === 'center';
|
|
184
|
+
const list = b.matches('li') || s.display === 'list-item' ? b.closest('ul,ol') : null;
|
|
185
|
+
const inProse = list && list.previousElementSibling && list.previousElementSibling.matches('p');
|
|
186
|
+
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) };
|
|
187
|
+
if (marker !== null) block.textEdge = px(first);
|
|
188
|
+
if (centredText || centredBox) block.role = 'centred';
|
|
189
|
+
else if (inProse) block.role = 'list-item';
|
|
190
|
+
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());
|
|
191
|
+
blocks.push(Object.assign(block, within(b, true)));
|
|
192
|
+
}
|
|
193
|
+
const ctlGroups = new Map();
|
|
194
|
+
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); }
|
|
195
|
+
let controls = 0;
|
|
196
|
+
for (const [p, els] of ctlGroups) {
|
|
197
|
+
const rs = els.map((e) => e.getBoundingClientRect());
|
|
198
|
+
const topRow = Math.min.apply(null, rs.map((r) => r.top));
|
|
199
|
+
let firstEl = null; let firstR = null;
|
|
200
|
+
els.forEach((e, i) => { const r = rs[i]; if (r.top - topRow < r.height / 2 && (!firstR || r.left < firstR.left)) { firstEl = e; firstR = r; } });
|
|
201
|
+
let edge = firstR.left;
|
|
202
|
+
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; }
|
|
203
|
+
controls += 1;
|
|
204
|
+
const ps = css(p);
|
|
205
|
+
const centred = ps.justifyContent === 'center' || (ps.textAlign === 'center' && !/flex|grid/.test(ps.display));
|
|
206
|
+
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) };
|
|
207
|
+
if (centred) block.role = 'centred';
|
|
208
|
+
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('');
|
|
209
|
+
blocks.push(Object.assign(block, within(firstEl, true)));
|
|
210
|
+
}
|
|
211
|
+
// Rows (decided 2026-10-01, read by geometry 2026-10-02): items set on one line, however the CSS sets them. An item
|
|
212
|
+
// after another measured item on its line that holds one block (a text, a control, or a box holding at most one
|
|
213
|
+
// text) belongs to the row and is not measured; an item with a stack inside it is a column.
|
|
214
|
+
const dropped = new Set();
|
|
215
|
+
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;
|
|
216
|
+
const contains = (o, el) => o === el || o.contains(el);
|
|
217
|
+
const innerTexts = (i) => owners.map((el, k) => k).filter((k) => k !== i && blocks[k].kind !== 'surface' && contains(owners[i], owners[k]));
|
|
218
|
+
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));
|
|
219
|
+
const itemOf = (el, other) => { let e = el; while (e.parentElement && e.parentElement !== root && !e.parentElement.contains(other)) e = e.parentElement; return e; };
|
|
220
|
+
for (let i = 0; i < blocks.length; i += 1) {
|
|
221
|
+
if (blocks[i].kind === 'surface' && !isPill(i)) continue;
|
|
222
|
+
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));
|
|
223
|
+
if (before < 0) continue;
|
|
224
|
+
// 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
|
|
225
|
+
// edges, the marker's and the text's.
|
|
226
|
+
if (blocks[i].kind === 'text' && blocks[before].kind === 'text' && /^[\p{N}\p{P}\p{S}]{1,3}$/u.test(words[before])) {
|
|
227
|
+
blocks[i] = Object.assign({}, blocks[i], { edge: blocks[before].edge, left: Math.min(blocks[i].left, blocks[before].left), textEdge: blocks[i].edge });
|
|
228
|
+
delete blocks[i].role;
|
|
229
|
+
dropped.add(before);
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
const item = itemOf(owners[i], owners[before]);
|
|
233
|
+
const inside = owners.map((el, k) => k).filter((k) => contains(item, owners[k]) && blocks[k].kind !== 'surface');
|
|
234
|
+
const boxes = owners.map((el, k) => k).filter((k) => contains(item, owners[k]) && blocks[k].kind === 'surface' && k !== i);
|
|
235
|
+
if (blocks[i].kind === 'surface') { if (boxes.length === 0) { dropped.add(i); for (const k of innerTexts(i)) dropped.add(k); } continue; }
|
|
236
|
+
if (inside.length === 1 && boxes.length === 0) dropped.add(i);
|
|
237
|
+
}
|
|
238
|
+
const kept = blocks.filter((_, i) => !dropped.has(i));
|
|
239
|
+
// Placeholders: a frame with no text in ink shows nothing to evaluate, whatever controls or boxes it draws.
|
|
240
|
+
return { blocks: kept, placeholdersOnly: texts === 0, skipped: { rowItems: dropped.size, ornaments } };
|
|
241
|
+
})()`;
|
|
242
|
+
|
|
243
|
+
const KINDS: readonly EdgeBlock['kind'][] = ['text', 'controls', 'surface'];
|
|
244
|
+
const ROLES: readonly NonNullable<EdgeBlock['role']>[] = ['list-item', 'table', 'centred', 'placeholder'];
|
|
245
|
+
const isKind = (v: unknown): v is EdgeBlock['kind'] => KINDS.some((k) => k === v);
|
|
246
|
+
const isRole = (v: unknown): v is NonNullable<EdgeBlock['role']> => ROLES.some((r) => r === v);
|
|
247
|
+
const num = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v);
|
|
248
|
+
const field = (o: object, key: string): unknown => Reflect.get(o, key);
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* The probe's typed read: `null` when the page had no `#root` or returned something else, so a failed measurement is
|
|
252
|
+
* reported as unmeasured and never read as "no offsets". A malformed block fails the whole read for the same reason.
|
|
253
|
+
*/
|
|
254
|
+
export function parseEdgeProbe(value: unknown): EdgeProbe | null {
|
|
255
|
+
if (typeof value !== 'object' || value === null) return null;
|
|
256
|
+
const raw = field(value, 'blocks');
|
|
257
|
+
const placeholdersOnly = field(value, 'placeholdersOnly');
|
|
258
|
+
if (!Array.isArray(raw) || typeof placeholdersOnly !== 'boolean') return null;
|
|
259
|
+
const skippedRaw = field(value, 'skipped');
|
|
260
|
+
if (typeof skippedRaw !== 'object' || skippedRaw === null) return null;
|
|
261
|
+
const rowItems = field(skippedRaw, 'rowItems');
|
|
262
|
+
const ornaments = field(skippedRaw, 'ornaments');
|
|
263
|
+
if (!num(rowItems) || !num(ornaments)) return null;
|
|
264
|
+
const blocks: EdgeBlock[] = [];
|
|
265
|
+
for (const entry of raw) {
|
|
266
|
+
if (typeof entry !== 'object' || entry === null) return null;
|
|
267
|
+
const [id, kind, edge, left, right, top] = ['id', 'kind', 'edge', 'left', 'right', 'top'].map((k) => field(entry, k));
|
|
268
|
+
const [within, textEdge, role] = ['within', 'textEdge', 'role'].map((k) => field(entry, k));
|
|
269
|
+
if (typeof id !== 'string' || !isKind(kind) || !num(edge) || !num(left) || !num(right) || !num(top)) return null;
|
|
270
|
+
if (within !== undefined && typeof within !== 'string') return null;
|
|
271
|
+
if (textEdge !== undefined && !num(textEdge)) return null;
|
|
272
|
+
if (role !== undefined && !isRole(role)) return null;
|
|
273
|
+
blocks.push({
|
|
274
|
+
id,
|
|
275
|
+
kind,
|
|
276
|
+
edge,
|
|
277
|
+
left,
|
|
278
|
+
right,
|
|
279
|
+
top,
|
|
280
|
+
...(within !== undefined ? { within } : {}),
|
|
281
|
+
...(textEdge !== undefined ? { textEdge } : {}),
|
|
282
|
+
...(role !== undefined ? { role } : {}),
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
return { blocks, placeholdersOnly, skipped: { rowItems, ornaments } };
|
|
286
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `space.edge` as an instrument (#1663): the alignment rule applied to measured blocks, never to a model's estimate.
|
|
3
|
+
*
|
|
4
|
+
* The rule (docs/design/direction/visual-criteria.md, "The edge rule, measurable"): in each column of a card, the things
|
|
5
|
+
* that stack start on one edge. A block may start in only two other places — inside a box, on the box's inner edge,
|
|
6
|
+
* which everything inside the box then shares; or next to a box or a band, on the box's own outer edge or on the edge
|
|
7
|
+
* of the text inside it. Same edge means within 2 px. A card fails if any captured width fails; this function judges one
|
|
8
|
+
* width.
|
|
9
|
+
*
|
|
10
|
+
* Levels that are allowed: a hanging marker (a block with two edges, the marker's and its text's); a list inside
|
|
11
|
+
* running text (list items share an edge of their own); a table (measured at its rule, its cells not re-checked);
|
|
12
|
+
* columns side by side (each with its own edge); a centred group (one centre axis). A frame that shows only loading
|
|
13
|
+
* placeholders is not evaluated.
|
|
14
|
+
*
|
|
15
|
+
* Input is the measured block list (a DOM probe at capture produces it); output is the verdict plus every offset in
|
|
16
|
+
* px, so a near miss reads apart from a split. This module measures nothing itself. *
|
|
17
|
+
* **Validated instrument (#1663, 2026-10-02):** this file as of `cec465319` (with `7bb4da13e`) passed a fresh sealed
|
|
18
|
+
* blind draw at the registered bar (27/30, κ 0.76), stricter than the design lead's reading on its disagreements. Any
|
|
19
|
+
* later change to what this file measures or decides is a new instrument until another fresh sealed draw.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** The rule's tolerance: two edges within this many px are the same edge. */
|
|
23
|
+
export const EDGE_TOLERANCE_PX = 2;
|
|
24
|
+
|
|
25
|
+
/** One block of a captured card, at one canvas width. All x in CSS px from the card's left. */
|
|
26
|
+
export interface EdgeBlock {
|
|
27
|
+
/** A stable label for evidence (a DOM path in production, a name in tests). */
|
|
28
|
+
readonly id: string;
|
|
29
|
+
/** A text block, a control group (measured at its first control), or a surface (a box: border or its own fill). */
|
|
30
|
+
readonly kind: 'text' | 'controls' | 'surface';
|
|
31
|
+
/**
|
|
32
|
+
* The block's edge: a text's first-character origin, a bounded control's or a surface's border box, an unbounded
|
|
33
|
+
* control's text. For a hanging marker, the marker's edge.
|
|
34
|
+
*/
|
|
35
|
+
readonly edge: number;
|
|
36
|
+
/** Horizontal extent, for telling side-by-side columns apart. */
|
|
37
|
+
readonly left: number;
|
|
38
|
+
readonly right: number;
|
|
39
|
+
/** Vertical position, for finding a surface's first text. */
|
|
40
|
+
readonly top: number;
|
|
41
|
+
/** The surface this block sits inside, by id; absent at the card's own level. */
|
|
42
|
+
readonly within?: string;
|
|
43
|
+
/** A hanging marker's text edge: what stacks with the block may line up with this or with `edge`. */
|
|
44
|
+
readonly textEdge?: number;
|
|
45
|
+
/** Structure the DOM names: a list item, a table (measured at its rule), a member of a centred group. */
|
|
46
|
+
readonly role?: 'list-item' | 'table' | 'centred' | 'placeholder';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface EdgeOffset {
|
|
50
|
+
/** The container checked: the card, or a surface's id. */
|
|
51
|
+
readonly container: string;
|
|
52
|
+
/** The block that sits off every allowed edge. */
|
|
53
|
+
readonly block: string;
|
|
54
|
+
/** Its edge, and the nearest edge the rule allowed it, in px. */
|
|
55
|
+
readonly at: number;
|
|
56
|
+
readonly nearest: number;
|
|
57
|
+
readonly offPx: number;
|
|
58
|
+
/** Why the block is off when it is not plain distance (a centred block among edge-aligned ones). */
|
|
59
|
+
readonly reason?: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface EdgeVerdict {
|
|
63
|
+
readonly verdict: 'pass' | 'fail' | 'n/a';
|
|
64
|
+
/** Every block that sits off the allowed edges, with its offset; empty on a pass. */
|
|
65
|
+
readonly offsets: readonly EdgeOffset[];
|
|
66
|
+
readonly evidence: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const CARD = 'card';
|
|
70
|
+
const same = (a: number, b: number): boolean => Math.abs(a - b) <= EDGE_TOLERANCE_PX;
|
|
71
|
+
const overlaps = (a: EdgeBlock, b: EdgeBlock): boolean => a.left < b.right && b.left < a.right;
|
|
72
|
+
const axis = (b: EdgeBlock): number => (b.left + b.right) / 2;
|
|
73
|
+
|
|
74
|
+
/** A surface's content edge: where its first text starts (its topmost text block's edge), or undefined if it has none. */
|
|
75
|
+
function contentEdge(surface: EdgeBlock, blocks: readonly EdgeBlock[]): number | undefined {
|
|
76
|
+
const texts = blocks.filter((b) => b.within === surface.id && b.kind !== 'surface' && b.role !== 'centred');
|
|
77
|
+
if (texts.length === 0) return undefined;
|
|
78
|
+
const first = texts.reduce((a, b) => (b.top < a.top ? b : a));
|
|
79
|
+
return first.textEdge ?? first.edge;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Columns among a container's direct blocks: non-surface blocks that overlap horizontally, transitively. */
|
|
83
|
+
function columns(direct: readonly EdgeBlock[]): EdgeBlock[][] {
|
|
84
|
+
const all = direct.filter((b) => b.kind !== 'surface');
|
|
85
|
+
// A block that spans two columns side by side (it overlaps two blocks that do not overlap each other) belongs to
|
|
86
|
+
// their parent column, never to either (decided 2026-10-02): it must not chain them into one.
|
|
87
|
+
const bridges = (b: EdgeBlock): boolean => {
|
|
88
|
+
const under = all.filter((x) => x !== b && overlaps(x, b));
|
|
89
|
+
return under.some((x, i) => under.slice(i + 1).some((y) => !overlaps(x, y)));
|
|
90
|
+
};
|
|
91
|
+
const flow = all.filter((b) => !bridges(b));
|
|
92
|
+
const parentPool: EdgeBlock[] = all.filter((b) => bridges(b));
|
|
93
|
+
const parent = flow.map((_, i) => i);
|
|
94
|
+
const find = (i: number): number => (parent[i] === i ? i : (parent[i] = find(parent[i]!)));
|
|
95
|
+
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);
|
|
96
|
+
const groups = new Map<number, EdgeBlock[]>();
|
|
97
|
+
flow.forEach((b, i) => groups.set(find(i), [...(groups.get(find(i)) ?? []), b]));
|
|
98
|
+
const cols = [...groups.values()];
|
|
99
|
+
// A surface joins the one column whose text or controls it overlaps. One that overlaps two or more columns spans
|
|
100
|
+
// them and goes to their parent, like a spanning text block; one that overlaps none stands with the other
|
|
101
|
+
// unclaimed blocks. Only flow blocks decide, so a surface never chains another surface into a column.
|
|
102
|
+
for (const s of direct.filter((b) => b.kind === 'surface')) {
|
|
103
|
+
const hit = cols.filter((c) => c.some((b) => b.kind !== 'surface' && overlaps(b, s)));
|
|
104
|
+
if (hit.length === 1) hit[0]!.push(s);
|
|
105
|
+
else parentPool.push(s);
|
|
106
|
+
}
|
|
107
|
+
// The parent's blocks and the unclaimed surfaces form columns among themselves: blocks that overlap stack.
|
|
108
|
+
const lp = parentPool.map((_, i) => i);
|
|
109
|
+
const lfind = (i: number): number => (lp[i] === i ? i : (lp[i] = lfind(lp[i]!)));
|
|
110
|
+
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);
|
|
111
|
+
const lgroups = new Map<number, EdgeBlock[]>();
|
|
112
|
+
parentPool.forEach((b, i) => lgroups.set(lfind(i), [...(lgroups.get(lfind(i)) ?? []), b]));
|
|
113
|
+
cols.push(...lgroups.values());
|
|
114
|
+
return cols;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** One column's check: one edge, or a stacked surface's content edge (the surface is then a ground). */
|
|
118
|
+
function checkColumn(container: string, col: readonly EdgeBlock[], blocks: readonly EdgeBlock[]): EdgeOffset[] {
|
|
119
|
+
const surfaces = col.filter((b) => b.kind === 'surface');
|
|
120
|
+
const grounds = surfaces.flatMap((s) => {
|
|
121
|
+
const c = s.role === 'table' ? undefined : contentEdge(s, blocks);
|
|
122
|
+
return c === undefined ? [] : [c];
|
|
123
|
+
});
|
|
124
|
+
const flow = col.filter((b) => b.kind !== 'surface' && b.role !== 'centred' && b.role !== 'list-item');
|
|
125
|
+
const listItems = col.filter((b) => b.role === 'list-item');
|
|
126
|
+
// Every edge a flow block or a surface offers; the common edge E must sit on one of them.
|
|
127
|
+
const candidates = [...flow.flatMap((b) => [b.edge, ...(b.textEdge !== undefined ? [b.textEdge] : [])]), ...surfaces.flatMap((s) => [s.edge]), ...grounds];
|
|
128
|
+
const blockEdges = (b: EdgeBlock): number[] => [b.edge, ...(b.textEdge !== undefined ? [b.textEdge] : [])];
|
|
129
|
+
// The end edges another block of the column offers: its boxes' and its other control sets'.
|
|
130
|
+
const endEdgesBesides = (b: EdgeBlock): number[] => [...surfaces.map((s) => s.right), ...flow.filter((x) => x !== b && x.kind === 'controls').map((x) => x.right)];
|
|
131
|
+
// A column of only list items or only a centred group has no common edge to find; their own checks below still run.
|
|
132
|
+
let best: EdgeOffset[] | null = candidates.length === 0 ? [] : null;
|
|
133
|
+
for (const E of candidates) {
|
|
134
|
+
const allowed = [E, ...grounds];
|
|
135
|
+
const off: EdgeOffset[] = [];
|
|
136
|
+
for (const b of flow) {
|
|
137
|
+
if (blockEdges(b).some((x) => allowed.some((a) => same(x, a)))) continue;
|
|
138
|
+
// A control set alone on its line may END on the end edge its column's boxes or other controls share.
|
|
139
|
+
if (b.kind === 'controls' && endEdgesBesides(b).some((r) => same(b.right, r))) continue;
|
|
140
|
+
const nearest = allowed.reduce((n, a) => (Math.abs(a - b.edge) < Math.abs(n - b.edge) ? a : n));
|
|
141
|
+
off.push({ container, block: b.id, at: b.edge, nearest, offPx: Math.abs(b.edge - nearest) });
|
|
142
|
+
}
|
|
143
|
+
for (const s of surfaces) {
|
|
144
|
+
const c = s.role === 'table' ? undefined : contentEdge(s, blocks);
|
|
145
|
+
if (same(s.edge, E) || (c !== undefined && same(c, E))) continue;
|
|
146
|
+
const nearest = c !== undefined && Math.abs(c - E) < Math.abs(s.edge - E) ? c : s.edge;
|
|
147
|
+
off.push({ container, block: s.id, at: nearest, nearest: E, offPx: Math.abs(nearest - E) });
|
|
148
|
+
}
|
|
149
|
+
if (best === null || off.length < best.length || (off.length === best.length && sum(off) < sum(best))) best = off;
|
|
150
|
+
if (best.length === 0) break;
|
|
151
|
+
}
|
|
152
|
+
const offsets = best ?? [];
|
|
153
|
+
// A list inside running text: its items share an edge of their own.
|
|
154
|
+
if (listItems.length > 1) {
|
|
155
|
+
const e0 = listItems[0]!.edge;
|
|
156
|
+
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) });
|
|
157
|
+
}
|
|
158
|
+
// A centred group: its members share one centre axis, and it must be the whole content of its column. A column is
|
|
159
|
+
// edge-aligned or centred, never both (decided 2026-10-02).
|
|
160
|
+
const centred = col.filter((b) => b.role === 'centred');
|
|
161
|
+
// A centred group of two or more may follow a label or heading when nothing edge-aligned follows it (an empty state
|
|
162
|
+
// under its section label). One centred block among edge-aligned ones, or a column that returns to an edge after
|
|
163
|
+
// the group, fails (decided 2026-10-02).
|
|
164
|
+
const edgeAligned = col.filter((b) => b.role !== 'centred');
|
|
165
|
+
const groupTop = centred.length > 0 ? Math.min(...centred.map((c) => c.top)) : 0;
|
|
166
|
+
const groupEndsColumn = centred.length > 1 && edgeAligned.every((b) => b.top < groupTop);
|
|
167
|
+
if (centred.length > 0 && edgeAligned.some((b) => b.kind !== 'surface') && !groupEndsColumn) {
|
|
168
|
+
const E = flow[0]?.edge ?? edgeAligned[0]!.edge;
|
|
169
|
+
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' });
|
|
170
|
+
} else if (centred.length > 1) {
|
|
171
|
+
const a0 = axis(centred[0]!);
|
|
172
|
+
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) });
|
|
173
|
+
}
|
|
174
|
+
return offsets;
|
|
175
|
+
}
|
|
176
|
+
const sum = (o: readonly EdgeOffset[]): number => o.reduce((s, x) => s + x.offPx, 0);
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Judge one captured width. `placeholdersOnly`: the frame shows only loading placeholders, so the row is not evaluated.
|
|
180
|
+
*/
|
|
181
|
+
export function judgeEdges(measured: readonly EdgeBlock[], opts: { readonly placeholdersOnly?: boolean } = {}): EdgeVerdict {
|
|
182
|
+
if (opts.placeholdersOnly === true) return { verdict: 'n/a', offsets: [], evidence: 'only loading placeholders are shown; not evaluated' };
|
|
183
|
+
let blocks = measured;
|
|
184
|
+
// A placeholder (a box with nothing in it, shown while content loads) is not a block, and the rule needs two blocks to
|
|
185
|
+
// compare: with fewer than two once placeholders are set aside, the frame is not evaluated (decided 2026-10-02).
|
|
186
|
+
// The two blocks are content, text or controls: a box holds them and is not one of them for this count.
|
|
187
|
+
const kept = blocks.filter((b) => b.role !== 'placeholder');
|
|
188
|
+
const content = kept.filter((b) => b.kind !== 'surface').length;
|
|
189
|
+
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' };
|
|
190
|
+
blocks = kept;
|
|
191
|
+
const offsets: EdgeOffset[] = [];
|
|
192
|
+
const containers = [CARD, ...blocks.filter((b) => b.kind === 'surface' && b.role !== 'table').map((b) => b.id)];
|
|
193
|
+
for (const container of containers) {
|
|
194
|
+
const direct = blocks.filter((b) => (b.within ?? CARD) === container);
|
|
195
|
+
for (const col of columns(direct)) offsets.push(...checkColumn(container, col, blocks));
|
|
196
|
+
}
|
|
197
|
+
if (offsets.length === 0) return { verdict: 'pass', offsets, evidence: 'every column shares its edge, or sits on a stacked surface’s content edge, within 2 px' };
|
|
198
|
+
const worst = offsets.reduce((a, b) => (b.offPx > a.offPx ? b : a));
|
|
199
|
+
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 !== undefined ? `; ${o.reason}` : ''})`);
|
|
200
|
+
return { verdict: 'fail', offsets, evidence: `${offsets.length} block(s) off the allowed edges, largest ${round(worst.offPx)} px: ${lines.join('; ')}` };
|
|
201
|
+
}
|
|
202
|
+
const round = (n: number): string => (Math.round(n * 10) / 10).toString();
|