@12ui/design 0.2.44 → 0.2.45
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 +7 -0
- package/SKILL.md +1 -1
- package/dist/branch-execution-types.d.ts +2 -0
- package/dist/branch-execution-types.d.ts.map +1 -1
- package/dist/branch-execution.d.ts.map +1 -1
- package/dist/branch-execution.js +3 -0
- package/dist/branch-execution.js.map +1 -1
- package/dist/branch-run-record.d.ts +1 -0
- package/dist/branch-run-record.d.ts.map +1 -1
- package/dist/branch-run-record.js.map +1 -1
- package/dist/cli-arguments.d.ts.map +1 -1
- package/dist/cli-arguments.js +1 -0
- package/dist/cli-arguments.js.map +1 -1
- package/dist/cli-branch-command.d.ts.map +1 -1
- package/dist/cli-branch-command.js +33 -2
- package/dist/cli-branch-command.js.map +1 -1
- package/dist/cli-capabilities.d.ts +1 -0
- package/dist/cli-capabilities.d.ts.map +1 -1
- package/dist/cli-capabilities.js +1 -0
- package/dist/cli-capabilities.js.map +1 -1
- package/dist/cli-help/design.d.ts +2 -2
- package/dist/cli-help/design.d.ts.map +1 -1
- package/dist/cli-help/design.js +2 -0
- package/dist/cli-help/design.js.map +1 -1
- package/dist/cli-help/index.d.ts +2 -2
- package/dist/cli-prototype-command.d.ts.map +1 -1
- package/dist/cli-prototype-command.js +14 -1
- package/dist/cli-prototype-command.js.map +1 -1
- package/dist/cli-usage.d.ts.map +1 -1
- package/dist/cli-usage.js +4 -3
- package/dist/cli-usage.js.map +1 -1
- package/dist/legacy-skill-catalog.d.ts.map +1 -1
- package/dist/legacy-skill-catalog.js +7 -0
- package/dist/legacy-skill-catalog.js.map +1 -1
- package/dist/prototype-poststep.d.ts +1 -0
- package/dist/prototype-poststep.d.ts.map +1 -1
- package/dist/prototype-poststep.js +5 -0
- package/dist/prototype-poststep.js.map +1 -1
- package/dist/prototype-process.d.ts +1 -1
- package/dist/prototype-process.d.ts.map +1 -1
- package/dist/prototype-revision.d.ts +1 -1
- package/dist/prototype-revision.js +1 -1
- package/dist/prototype-run-record.d.ts +2 -1
- package/dist/prototype-run-record.d.ts.map +1 -1
- package/dist/prototype-run-record.js +28 -6
- package/dist/prototype-run-record.js.map +1 -1
- package/dist/prototype-runner.d.ts +26 -0
- package/dist/prototype-runner.d.ts.map +1 -1
- package/dist/prototype-runner.js +55 -8
- package/dist/prototype-runner.js.map +1 -1
- package/dist/run-journal.d.ts +15 -0
- package/dist/run-journal.d.ts.map +1 -1
- package/dist/run-journal.js +9 -0
- package/dist/run-journal.js.map +1 -1
- package/open-design.json +2 -2
- package/package.json +2 -2
- package/prototype-kit/polish/ground-probe.mjs +119 -0
- package/prototype-kit/polish/harm.mjs +23 -0
- package/prototype-kit/polish/measure.mjs +333 -0
- package/prototype-kit/polish/overrides.mjs +55 -0
- package/prototype-kit/polish/polish.mjs +252 -0
- package/prototype-kit/polish/report.mjs +76 -0
- package/prototype-kit/polish/rules/align.mjs +70 -0
- package/prototype-kit/polish/rules/contrast.mjs +80 -0
- package/prototype-kit/polish/rules/escape.mjs +192 -0
- package/prototype-kit/polish/rules/fit-text.mjs +158 -0
- package/prototype-kit/polish/rules/index.mjs +61 -0
- package/prototype-kit/polish/rules/left-rail.mjs +77 -0
- package/prototype-kit/polish/rules/overlap.mjs +122 -0
- package/prototype-kit/polish/rules/page-cut.mjs +203 -0
- package/prototype-kit/polish/rules/photo-corners.mjs +38 -0
- package/prototype-kit/polish/rules/rhythm.mjs +148 -0
- package/prototype-kit/polish/rules/section-breathing.mjs +292 -0
- package/prototype-kit/polish/rules/shell-seam.mjs +48 -0
- package/prototype-kit/polish/tests/breathing-check.mjs +77 -0
- package/prototype-kit/polish/tests/fixtures/align/expect.json +1 -0
- package/prototype-kit/polish/tests/fixtures/align/fire.html +14 -0
- package/prototype-kit/polish/tests/fixtures/align/silent.html +28 -0
- package/prototype-kit/polish/tests/fixtures/contrast/expect.json +1 -0
- package/prototype-kit/polish/tests/fixtures/contrast/fallback.html +7 -0
- package/prototype-kit/polish/tests/fixtures/contrast/fire.html +9 -0
- package/prototype-kit/polish/tests/fixtures/contrast/silent.html +9 -0
- package/prototype-kit/polish/tests/fixtures/escape/expect.json +2 -0
- package/prototype-kit/polish/tests/fixtures/escape/fire.html +23 -0
- package/prototype-kit/polish/tests/fixtures/escape/silent.html +29 -0
- package/prototype-kit/polish/tests/fixtures/fit-text/expect.json +3 -0
- package/prototype-kit/polish/tests/fixtures/fit-text/fire.html +26 -0
- package/prototype-kit/polish/tests/fixtures/fit-text/silent.html +33 -0
- package/prototype-kit/polish/tests/fixtures/harm/button-overlap.html +10 -0
- package/prototype-kit/polish/tests/fixtures/harm/declared-gap.html +5 -0
- package/prototype-kit/polish/tests/fixtures/harm/offcenter.html +21 -0
- package/prototype-kit/polish/tests/fixtures/harm/page.html +17 -0
- package/prototype-kit/polish/tests/fixtures/left-rail/expect.json +1 -0
- package/prototype-kit/polish/tests/fixtures/left-rail/fire.html +8 -0
- package/prototype-kit/polish/tests/fixtures/left-rail/silent.html +8 -0
- package/prototype-kit/polish/tests/fixtures/overlap/expect.json +4 -0
- package/prototype-kit/polish/tests/fixtures/overlap/fire.html +26 -0
- package/prototype-kit/polish/tests/fixtures/overlap/silent.html +39 -0
- package/prototype-kit/polish/tests/fixtures/page-cut/expect.json +2 -0
- package/prototype-kit/polish/tests/fixtures/page-cut/fire.html +31 -0
- package/prototype-kit/polish/tests/fixtures/page-cut/silent.html +22 -0
- package/prototype-kit/polish/tests/fixtures/photo-corners/expect.json +1 -0
- package/prototype-kit/polish/tests/fixtures/photo-corners/fire.html +5 -0
- package/prototype-kit/polish/tests/fixtures/photo-corners/silent.html +9 -0
- package/prototype-kit/polish/tests/fixtures/rhythm/expect.json +6 -0
- package/prototype-kit/polish/tests/fixtures/rhythm/fire.html +22 -0
- package/prototype-kit/polish/tests/fixtures/rhythm/silent.html +27 -0
- package/prototype-kit/polish/tests/fixtures/section-breathing/attachment-fire.html +5 -0
- package/prototype-kit/polish/tests/fixtures/section-breathing/attachment-silent.html +5 -0
- package/prototype-kit/polish/tests/fixtures/section-breathing/expect.json +1 -0
- package/prototype-kit/polish/tests/fixtures/section-breathing/fire.html +6 -0
- package/prototype-kit/polish/tests/fixtures/section-breathing/floor.html +7 -0
- package/prototype-kit/polish/tests/fixtures/section-breathing/semantic-fire.html +11 -0
- package/prototype-kit/polish/tests/fixtures/section-breathing/semantic-silent.html +8 -0
- package/prototype-kit/polish/tests/fixtures/section-breathing/shadow-fire.html +5 -0
- package/prototype-kit/polish/tests/fixtures/section-breathing/shadow-silent.html +5 -0
- package/prototype-kit/polish/tests/fixtures/section-breathing/silent.html +6 -0
- package/prototype-kit/polish/tests/fixtures/section-breathing/tight.html +7 -0
- package/prototype-kit/polish/tests/fixtures/shell-seam/expect.json +1 -0
- package/prototype-kit/polish/tests/fixtures/shell-seam/fire.html +20 -0
- package/prototype-kit/polish/tests/fixtures/shell-seam/silent.html +21 -0
- package/prototype-kit/polish/tests/harm-check.mjs +71 -0
- package/prototype-kit/polish/tests/report-check.mjs +31 -0
- package/prototype-kit/polish/tests/rules-check.mjs +52 -0
- package/skills/12ui-design/SKILL.md +4 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Durable, machine-readable outcome vocabulary for the opt-in polish stage.
|
|
2
|
+
// A page/rule pair has exactly one terminal state:
|
|
3
|
+
// - fired: at least one override from the rule survived every guard;
|
|
4
|
+
// - silent: the rule proposed no change;
|
|
5
|
+
// - reverted: the rule proposed a change but none survived, or the page was
|
|
6
|
+
// published unchanged after a rule/page error.
|
|
7
|
+
|
|
8
|
+
const unique = (values) => [...new Set(values)];
|
|
9
|
+
|
|
10
|
+
export function pageRuleRecords(page, ruleNames) {
|
|
11
|
+
const accepted = new Map((page.accepted || []).map((entry) => [entry.rule, entry]));
|
|
12
|
+
const rejected = new Map();
|
|
13
|
+
for (const entry of page.rejected || []) {
|
|
14
|
+
const reasons = rejected.get(entry.rule) || [];
|
|
15
|
+
reasons.push(...(entry.harm || []));
|
|
16
|
+
rejected.set(entry.rule, reasons);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return ruleNames.map((rule) => {
|
|
20
|
+
const measured = page.rules?.[rule] || {};
|
|
21
|
+
const proposed = Number.isFinite(measured.proposed) ? measured.proposed : 0;
|
|
22
|
+
const overCap = Number.isFinite(measured.overCap) ? measured.overCap : 0;
|
|
23
|
+
const kept = accepted.get(rule)?.overrides || 0;
|
|
24
|
+
const reasons = [
|
|
25
|
+
...(rejected.get(rule) || []),
|
|
26
|
+
...(overCap > 0 ? ['over-cap'] : []),
|
|
27
|
+
...(page.error && measured.proposed === undefined ? ['page-error'] : []),
|
|
28
|
+
];
|
|
29
|
+
const status = kept > 0
|
|
30
|
+
? 'fired'
|
|
31
|
+
: proposed > 0 || reasons.length > 0
|
|
32
|
+
? 'reverted'
|
|
33
|
+
: 'silent';
|
|
34
|
+
return {
|
|
35
|
+
rule,
|
|
36
|
+
status,
|
|
37
|
+
proposed,
|
|
38
|
+
accepted: kept,
|
|
39
|
+
overCap,
|
|
40
|
+
...(reasons.length > 0 ? { reasons: unique(reasons) } : {}),
|
|
41
|
+
};
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function summarizePolishReport(report) {
|
|
46
|
+
const rules = report.rules.map((name) => ({
|
|
47
|
+
name,
|
|
48
|
+
fired: 0,
|
|
49
|
+
silent: 0,
|
|
50
|
+
reverted: 0,
|
|
51
|
+
proposed: 0,
|
|
52
|
+
accepted: 0,
|
|
53
|
+
overCap: 0,
|
|
54
|
+
}));
|
|
55
|
+
const byName = new Map(rules.map((rule) => [rule.name, rule]));
|
|
56
|
+
for (const page of report.pages) {
|
|
57
|
+
for (const record of page.ruleRecords || []) {
|
|
58
|
+
const rule = byName.get(record.rule);
|
|
59
|
+
if (!rule) continue;
|
|
60
|
+
rule[record.status] += 1;
|
|
61
|
+
rule.proposed += record.proposed;
|
|
62
|
+
rule.accepted += record.accepted;
|
|
63
|
+
rule.overCap += record.overCap;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
enabled: true,
|
|
68
|
+
policy: report.policy,
|
|
69
|
+
pageCount: report.pages.length,
|
|
70
|
+
pageErrors: report.pages.filter((page) => page.error).length,
|
|
71
|
+
fired: rules.reduce((count, rule) => count + rule.fired, 0),
|
|
72
|
+
silent: rules.reduce((count, rule) => count + rule.silent, 0),
|
|
73
|
+
reverted: rules.reduce((count, rule) => count + rule.reverted, 0),
|
|
74
|
+
rules,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// align: snap near-miss edges between same-kind siblings (≤ NEAR px apart) onto the group's modal edge.
|
|
2
|
+
// Fires on: three cards whose tops are 0, 1.02 and 2.05 px → the two strays move to 0.
|
|
3
|
+
// Silent on: edges that are equal, or further apart than NEAR (deliberate offsets), or different kinds.
|
|
4
|
+
export const name = 'align';
|
|
5
|
+
export const maxShift = 6;
|
|
6
|
+
|
|
7
|
+
export function browserFn() {
|
|
8
|
+
return ({ metrics }) => {
|
|
9
|
+
const groups = new Map(); // parent|axis → [{id, value}]
|
|
10
|
+
const axisOf = { left: 'x', right: 'r', top: 'y', bottom: 'b', center: 'cy' };
|
|
11
|
+
const rectOf = (el) => { const r = el.getBoundingClientRect(); const s = el.closest('section[id^="viewport-"]') || document.body; const b = s.getBoundingClientRect(); return { x: r.left - b.left, y: r.top - b.top, r: r.right - b.left, b: r.bottom - b.top, cy: r.top - b.top + r.height / 2 }; };
|
|
12
|
+
// an element that already belongs to a left (top) column is left-aligned by design: never snap its RIGHT (bottom)
|
|
13
|
+
// edge — that is what moved the Lands "Proposals" nav item 5 px out of its icon column (owner, 2026-08-22)
|
|
14
|
+
const leftAligned = new Set(), topAligned = new Set();
|
|
15
|
+
for (const c of metrics.columns || []) { (c.axis === 'left' ? leftAligned : topAligned).add(c.a); (c.axis === 'left' ? leftAligned : topAligned).add(c.b); }
|
|
16
|
+
for (const pr of metrics.alignment) { if (pr.axis === 'left') { leftAligned.add(pr.a); leftAligned.add(pr.b); } if (pr.axis === 'top') { topAligned.add(pr.a); topAligned.add(pr.b); } }
|
|
17
|
+
for (const pair of metrics.alignment) {
|
|
18
|
+
const a = document.getElementById(pair.a), b = document.getElementById(pair.b);
|
|
19
|
+
if (!a || !b || a.parentElement !== b.parentElement) continue;
|
|
20
|
+
if (pair.axis === 'right' && (leftAligned.has(pair.a) || leftAligned.has(pair.b))) continue;
|
|
21
|
+
if (pair.axis === 'bottom' && (topAligned.has(pair.a) || topAligned.has(pair.b))) continue;
|
|
22
|
+
const key = `${a.parentElement.id || 'root'}|${pair.axis}`;
|
|
23
|
+
if (!groups.has(key)) groups.set(key, new Map());
|
|
24
|
+
const g = groups.get(key);
|
|
25
|
+
const k = axisOf[pair.axis];
|
|
26
|
+
if (!g.has(pair.a)) g.set(pair.a, rectOf(a)[k]);
|
|
27
|
+
if (!g.has(pair.b)) g.set(pair.b, rectOf(b)[k]);
|
|
28
|
+
}
|
|
29
|
+
// the eye aligns INK, not boxes: if an element's text/icon leaves already sit in an exact column with leaves
|
|
30
|
+
// outside it, moving the element would break that column (Lands nav rows are 1 px apart while their labels and
|
|
31
|
+
// icons line up perfectly) — leave it
|
|
32
|
+
const colPartners = new Map();
|
|
33
|
+
for (const c of metrics.columns || []) { if (!colPartners.has(c.a)) colPartners.set(c.a, []); colPartners.get(c.a).push(c.b); if (!colPartners.has(c.b)) colPartners.set(c.b, []); colPartners.get(c.b).push(c.a); }
|
|
34
|
+
const breaksColumn = (el, axis) => {
|
|
35
|
+
const leaves = [el, ...el.querySelectorAll('.text, .paragraph, .icon, h1, h2, h3, h4, h5, h6')].filter((l) => l.id);
|
|
36
|
+
const wantAxis = (axis === 'left' || axis === 'right') ? 'left' : 'top';
|
|
37
|
+
for (const l of leaves) for (const partner of colPartners.get(l.id) || []) {
|
|
38
|
+
const pe = document.getElementById(partner); if (!pe || el.contains(pe)) continue;
|
|
39
|
+
const c = (metrics.columns || []).find((x) => (x.a === l.id && x.b === partner) || (x.b === l.id && x.a === partner));
|
|
40
|
+
if (c && c.axis === wantAxis) return true;
|
|
41
|
+
}
|
|
42
|
+
return false;
|
|
43
|
+
};
|
|
44
|
+
const out = [];
|
|
45
|
+
for (const [key, g] of groups) {
|
|
46
|
+
const axis = key.split('|')[1];
|
|
47
|
+
// cluster values within NEAR; anchor = the value shared by most members (ties → smallest)
|
|
48
|
+
const entries = [...g.entries()].sort((p, q) => p[1] - q[1]);
|
|
49
|
+
let i = 0;
|
|
50
|
+
while (i < entries.length) {
|
|
51
|
+
let j = i; while (j + 1 < entries.length && entries[j + 1][1] - entries[i][1] <= 6) j++;
|
|
52
|
+
const cluster = entries.slice(i, j + 1);
|
|
53
|
+
if (cluster.length >= 2) {
|
|
54
|
+
const counts = new Map();
|
|
55
|
+
for (const [, v] of cluster) { const kv = Math.round(v * 4) / 4; counts.set(kv, (counts.get(kv) || 0) + 1); }
|
|
56
|
+
const anchor = [...counts.entries()].sort((p, q) => q[1] - p[1] || p[0] - q[0])[0][0];
|
|
57
|
+
for (const [id, v] of cluster) {
|
|
58
|
+
const d = anchor - v;
|
|
59
|
+
if (Math.abs(d) < 0.5 || Math.abs(d) > 6) continue;
|
|
60
|
+
if (breaksColumn(document.getElementById(id), axis)) continue;
|
|
61
|
+
const horizontal = axis === 'left' || axis === 'right';
|
|
62
|
+
out.push({ id, rule: 'align', reason: `${axis} ${v.toFixed(1)}→${anchor}`, dx: horizontal ? d : 0, dy: horizontal ? 0 : d });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
i = j + 1;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
};
|
|
70
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// contrast: text painted in (almost) the colour of what is under it is text the reader cannot see.
|
|
2
|
+
// Mechanism (missing-text lane, 18 of 250 holding pages): build.mjs copies the heading colour of the base state's
|
|
3
|
+
// largest text onto the holding block without checking the background the block actually lands on — a light
|
|
4
|
+
// hero heading becomes rgb(247,248,248) on a white page (1.06:1). Real pages are clean (0 invisible leaves on
|
|
5
|
+
// 249 pages once the paint-stack probe was fixed), so this rule mostly fires on holdings.
|
|
6
|
+
// Fix (refined 2026-08-23, owner D1 forensics: the rule was flattening conversion-declared brand colours — Lanternfund's
|
|
7
|
+
// #e68000 — to near-black): for a text leaf under MIN contrast, KEEP ITS HUE AND SATURATION and move its lightness
|
|
8
|
+
// toward the background's opposite side until TARGET (4.5:1) is reached — or at least MIN (3:1) — within a lightness
|
|
9
|
+
// floor/ceiling that keeps the colour a colour. Only when neither direction can reach MIN does it fall back to the
|
|
10
|
+
// page's heading/body token or black/white, and the reason records 'hue unreachable'. Never touches image-backed text.
|
|
11
|
+
export const name = 'contrast';
|
|
12
|
+
export const maxShift = 0;
|
|
13
|
+
export const phase = 'post'; // colour decisions need the geometry-polished DOM: evaluate after the geometry rules
|
|
14
|
+
export const params = { MIN: 3, TARGET: 4.5, L_FLOOR: 0.16, L_CEIL: 0.88 };
|
|
15
|
+
|
|
16
|
+
export function browserFn() {
|
|
17
|
+
return ({ params }) => {
|
|
18
|
+
const MIN = params.MIN || 3;
|
|
19
|
+
const parseRgb = (s) => { const m = s && s.match(/rgba?\(([^)]+)\)/); if (!m) return null; const p = m[1].split(',').map(parseFloat); return { r: p[0], g: p[1], b: p[2], a: p[3] === undefined ? 1 : p[3] }; };
|
|
20
|
+
const lum = (c) => { const f = (v) => { v /= 255; return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; }; return 0.2126 * f(c.r) + 0.7152 * f(c.g) + 0.0722 * f(c.b); };
|
|
21
|
+
const contrast = (a, b) => { const l1 = lum(a), l2 = lum(b); return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05); };
|
|
22
|
+
const bgUnder = (el) => {
|
|
23
|
+
el.scrollIntoView({ block: 'center', inline: 'nearest' });
|
|
24
|
+
const r = el.getBoundingClientRect();
|
|
25
|
+
const stack = document.elementsFromPoint(Math.min(window.innerWidth - 1, r.left + r.width / 2), Math.min(window.innerHeight - 1, r.top + Math.min(r.height / 2, 8)));
|
|
26
|
+
for (const e of stack) {
|
|
27
|
+
if (e === el || e.closest('.proto-notice, #proto-overlay-root, .proto-toast')) continue;
|
|
28
|
+
if (e.tagName === 'IMG' || e.tagName === 'svg' || e.tagName === 'CANVAS' || getComputedStyle(e).backgroundImage !== 'none') return null;
|
|
29
|
+
const c = parseRgb(getComputedStyle(e).backgroundColor); if (c && c.a > 0.5) return c;
|
|
30
|
+
}
|
|
31
|
+
return { r: 255, g: 255, b: 255, a: 1 };
|
|
32
|
+
};
|
|
33
|
+
let tokens = {};
|
|
34
|
+
try { tokens = JSON.parse(document.getElementById('proto-state').textContent).tokens || {}; } catch (e) { /* no state block */ }
|
|
35
|
+
const candidates = (bg) => [tokens.headingColor, tokens.bodyColor, lum(bg) > 0.4 ? 'rgb(20, 20, 20)' : 'rgb(245, 245, 245)'].filter(Boolean).map((c) => ({ css: c, rgb: parseRgb(c) })).filter((c) => c.rgb);
|
|
36
|
+
// ---- hue-preserving search
|
|
37
|
+
const rgb2hsl = ({ r, g, b }) => { r /= 255; g /= 255; b /= 255; const mx = Math.max(r, g, b), mn = Math.min(r, g, b); let h = 0, s = 0; const l = (mx + mn) / 2; if (mx !== mn) { const d = mx - mn; s = l > 0.5 ? d / (2 - mx - mn) : d / (mx + mn); h = mx === r ? ((g - b) / d + (g < b ? 6 : 0)) : mx === g ? ((b - r) / d + 2) : ((r - g) / d + 4); h /= 6; } return { h, s, l }; };
|
|
38
|
+
const hsl2rgb = ({ h, s, l }) => { const k = (n) => (n + h * 12) % 12; const a = s * Math.min(l, 1 - l); const f = (n) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1))); return { r: Math.round(f(0) * 255), g: Math.round(f(8) * 255), b: Math.round(f(4) * 255), a: 1 }; };
|
|
39
|
+
const huePreserving = (fg, bg) => {
|
|
40
|
+
const hs = rgb2hsl(fg); const h = hs.h, s = hs.s < 0.08 ? 0 : hs.s; // a near-neutral stays neutral: no invented tint
|
|
41
|
+
const darkBg = lum(bg) <= 0.4;
|
|
42
|
+
// walk lightness away from the background in 1 % steps; first L that reaches TARGET wins, else the first that reaches MIN
|
|
43
|
+
const P_MIN = params.MIN || 3, P_TGT = params.TARGET || 4.5, FLOOR = params.L_FLOOR ?? 0.16, CEIL = params.L_CEIL ?? 0.88;
|
|
44
|
+
let best = null;
|
|
45
|
+
const start = rgb2hsl(fg).l;
|
|
46
|
+
const dir = darkBg ? +1 : -1;
|
|
47
|
+
for (let l = start; dir > 0 ? l <= CEIL : l >= FLOOR; l += dir * 0.01) {
|
|
48
|
+
const c = hsl2rgb({ h, s, l }); const k = contrast(c, bg);
|
|
49
|
+
if (k >= P_TGT) return { rgb: c, contrast: k, reached: 'target' };
|
|
50
|
+
if (k >= P_MIN && !best) best = { rgb: c, contrast: k, reached: 'min' };
|
|
51
|
+
}
|
|
52
|
+
return best; // null = hue unreachable within the lightness band
|
|
53
|
+
};
|
|
54
|
+
const out = [];
|
|
55
|
+
const seen = new Set();
|
|
56
|
+
const nodes = [...document.querySelectorAll('.text, .paragraph, h1, h2, h3, h4, h5, h6, .proto-holding-title, .proto-holding-sub, .proto-holding-note, .proto-holding-card-title')]
|
|
57
|
+
.filter((el) => !el.closest('#proto-overlay-root') && (el.textContent || '').trim() && !el.querySelector('.text'));
|
|
58
|
+
for (const el of nodes) {
|
|
59
|
+
const cs = getComputedStyle(el);
|
|
60
|
+
if (cs.display === 'none' || cs.visibility === 'hidden' || parseFloat(cs.opacity) === 0) continue;
|
|
61
|
+
const r = el.getBoundingClientRect(); if (!r.width || !r.height) continue;
|
|
62
|
+
const fg = parseRgb(cs.color); if (!fg || fg.a < 0.05) continue;
|
|
63
|
+
const bg = bgUnder(el); if (!bg) continue;
|
|
64
|
+
const c = contrast(fg, bg); if (c >= MIN) continue;
|
|
65
|
+
const target = el.id ? { id: el.id } : { sel: `${el.tagName.toLowerCase()}.${[...el.classList].join('.')}` };
|
|
66
|
+
const key = target.id || target.sel; if (seen.has(key)) continue; seen.add(key);
|
|
67
|
+
const hp = huePreserving(fg, bg);
|
|
68
|
+
if (hp) {
|
|
69
|
+
const css = `rgb(${hp.rgb.r}, ${hp.rgb.g}, ${hp.rgb.b})`;
|
|
70
|
+
const hue = Math.round(rgb2hsl(fg).h * 360);
|
|
71
|
+
out.push({ ...target, rule: 'contrast', reason: `${c.toFixed(2)}:1 → ${css} (hue ${hue}° kept, ${hp.contrast.toFixed(2)}:1${hp.reached === 'min' ? ', target 4.5 not reachable in hue' : ''})`, css: { color: css } });
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
const pick = candidates(bg).find((k) => contrast(k.rgb, bg) >= 4.5) || candidates(bg).sort((p, q) => contrast(q.rgb, bg) - contrast(p.rgb, bg))[0];
|
|
75
|
+
if (!pick) continue;
|
|
76
|
+
out.push({ ...target, rule: 'contrast', reason: `${c.toFixed(2)}:1 → ${pick.css} (hue unreachable: no lightness in [${(params.L_FLOOR ?? 0.16) * 100}%, ${(params.L_CEIL ?? 0.88) * 100}%] reaches ${params.MIN || 3}:1 on this background; fallback)`, css: { color: pick.css } });
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
};
|
|
80
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// escape: text that has left the opaque box it belongs to (card, pill, rail, header band).
|
|
2
|
+
//
|
|
3
|
+
// Mechanism this repairs (proven on the population): the same shell-transplant/measured-box mismatch as page-cut,
|
|
4
|
+
// but bounded by a painted container rather than by the canvas. A search pill keeps the donor page's
|
|
5
|
+
// `top:` inside a canonical header that is 58 px tall, so "⌘ K" is painted 17 px below the navy band; a footer
|
|
6
|
+
// legal row sits 8 px under the footer's dark plate; a nav label is 2 px wider than its pill.
|
|
7
|
+
//
|
|
8
|
+
// Ladder: grow the container (only when its own parent has that slack and growing it disturbs nothing) →
|
|
9
|
+
// translate the escaping block back inside (only when the container has room on the other side and the landing
|
|
10
|
+
// spot is empty) → shrink the text by at most 6 % (only when fit-text is not already handling that string).
|
|
11
|
+
// Elements that are ALSO cut by the canvas are left to page-cut, so the two rules never translate the same id twice.
|
|
12
|
+
//
|
|
13
|
+
// Fires on: "⌘ K" 17 px under its header band; a legal row 8 px below the footer plate; a 3 px-wide label bleed
|
|
14
|
+
// out of a card that has free space beside it.
|
|
15
|
+
// Silent on: escapes under 2 px; a heading intentionally overhanging a photo panel with no slack anywhere;
|
|
16
|
+
// text already owned by page-cut.
|
|
17
|
+
export const name = 'escape';
|
|
18
|
+
export const maxShift = 64;
|
|
19
|
+
export const params = { minEscape: 2, pad: 4, maxMove: 120, maxFontShrink: 0.06, cutOwnedAt: 4 };
|
|
20
|
+
|
|
21
|
+
export function browserFn() {
|
|
22
|
+
return ({ metrics, params }) => {
|
|
23
|
+
const P = Object.assign({ minEscape: 2, pad: 4, maxMove: 120, maxFontShrink: 0.06, cutOwnedAt: 4 }, params || {});
|
|
24
|
+
const secOf = (el) => el.closest('section[id^="viewport-"]') || document.body;
|
|
25
|
+
const rangeOf = (el) => { const r = document.createRange(); r.selectNodeContents(el); return r; };
|
|
26
|
+
const trOf = (el) => { const b = rangeOf(el).getBoundingClientRect(); return b.width > 0 ? b : el.getBoundingClientRect(); };
|
|
27
|
+
const nLines = (el) => { const rs = [...rangeOf(el).getClientRects()].filter((r) => r.width > 0.5 && r.height > 0.5); return Math.max(1, rs.length); };
|
|
28
|
+
const vis = (el) => { const cs = getComputedStyle(el); if (cs.display === 'none' || cs.visibility === 'hidden' || parseFloat(cs.opacity) === 0) return false; const r = el.getBoundingClientRect(); return r.width > 0 && r.height > 0; };
|
|
29
|
+
const isText = (el) => el.classList.contains('text') || /^H[1-6]$/.test(el.tagName);
|
|
30
|
+
const kindOf = (el) => isText(el) ? 'text' : el.classList.contains('icon') ? 'icon' : 'other';
|
|
31
|
+
const leaves = [...document.querySelectorAll('.text, h1, h2, h3, h4, h5, h6, .icon, img, .image, .badge, .divider')]
|
|
32
|
+
.filter((el) => vis(el) && !el.closest('#proto-overlay-root') && !el.querySelector('.text'));
|
|
33
|
+
const inkBounds = (M) => {
|
|
34
|
+
const r = M.getBoundingClientRect();
|
|
35
|
+
let o = { left: r.left, top: r.top, right: r.right, bottom: r.bottom };
|
|
36
|
+
for (const t of leaves) {
|
|
37
|
+
if (!isText(t) || !M.contains(t)) continue;
|
|
38
|
+
const q = trOf(t);
|
|
39
|
+
o = { left: Math.min(o.left, q.left), top: Math.min(o.top, q.top), right: Math.max(o.right, q.right), bottom: Math.max(o.bottom, q.bottom) };
|
|
40
|
+
}
|
|
41
|
+
return o;
|
|
42
|
+
};
|
|
43
|
+
const collisions = (M) => {
|
|
44
|
+
const sec = secOf(M);
|
|
45
|
+
const inside = leaves.filter((e) => M.contains(e));
|
|
46
|
+
const outside = leaves.filter((e) => !M.contains(e) && secOf(e) === sec);
|
|
47
|
+
let n = 0;
|
|
48
|
+
for (const A of inside) {
|
|
49
|
+
const ka = kindOf(A); if (ka === 'other') continue;
|
|
50
|
+
const ar = isText(A) ? trOf(A) : A.getBoundingClientRect();
|
|
51
|
+
for (const B of outside) {
|
|
52
|
+
const kb = kindOf(B); if (kb === 'other') continue;
|
|
53
|
+
if (ka === 'icon' && kb === 'icon') continue;
|
|
54
|
+
const br = isText(B) ? trOf(B) : B.getBoundingClientRect();
|
|
55
|
+
const ix = Math.min(ar.right, br.right) - Math.max(ar.left, br.left);
|
|
56
|
+
const iy = Math.min(ar.bottom, br.bottom) - Math.max(ar.top, br.top);
|
|
57
|
+
if (ix > 1 && iy > 3 && ix * iy > 24) n++;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return n;
|
|
61
|
+
};
|
|
62
|
+
const probes = [...document.body.querySelectorAll('[id]')].filter((e) => !e.closest('#proto-overlay-root'));
|
|
63
|
+
// document coordinates, not viewport: measure.mjs scrolls the page while sampling backgrounds, and
|
|
64
|
+
// removing an overhang changes the scroll clamp — viewport coords would report the whole page as moved.
|
|
65
|
+
const snap = () => probes.map((e) => { const r = e.getBoundingClientRect(); return [r.left + window.scrollX, r.top + window.scrollY, r.width, r.height]; });
|
|
66
|
+
const base = snap();
|
|
67
|
+
const trial = (el, styles, probe) => {
|
|
68
|
+
const prev = el.getAttribute('style');
|
|
69
|
+
for (const k of Object.keys(styles)) el.style.setProperty(k, styles[k], 'important');
|
|
70
|
+
void document.body.offsetHeight;
|
|
71
|
+
const out = probe(el);
|
|
72
|
+
out.now = snap();
|
|
73
|
+
if (prev === null) el.removeAttribute('style'); else el.setAttribute('style', prev);
|
|
74
|
+
void document.body.offsetHeight;
|
|
75
|
+
return out;
|
|
76
|
+
};
|
|
77
|
+
// `ignoreAncestors`: a pure transform never reflows anything, but it does change the page's scroll extent,
|
|
78
|
+
// so the wrappers ABOVE the moved block legitimately shrink. Only translate trials pass this.
|
|
79
|
+
const disturbedOutside = (el, now, ignoreAncestors) => {
|
|
80
|
+
for (let i = 0; i < probes.length; i++) {
|
|
81
|
+
const p = probes[i]; if (p === el || el.contains(p) || (ignoreAncestors && p.contains(el))) continue;
|
|
82
|
+
const a = base[i], b = now[i];
|
|
83
|
+
if (!a[2] && !a[3] && !b[2] && !b[3]) continue; // never-rendered node: its rect is all zeros, ignore
|
|
84
|
+
if (Math.abs(a[0] - b[0]) > 0.5 || Math.abs(a[1] - b[1]) > 0.5 || Math.abs(a[2] - b[2]) > 0.5 || Math.abs(a[3] - b[3]) > 0.5) return p.id || p.tagName;
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
};
|
|
88
|
+
const gapTo = (M, stop, dir) => {
|
|
89
|
+
const mr = M.getBoundingClientRect(), sr = stop.getBoundingClientRect(), cs = getComputedStyle(stop);
|
|
90
|
+
const pad = (k) => parseFloat(cs[k]) || 0;
|
|
91
|
+
let lim = dir === 'up' ? sr.top + pad('paddingTop') : dir === 'down' ? sr.bottom - pad('paddingBottom')
|
|
92
|
+
: dir === 'left' ? sr.left + pad('paddingLeft') : sr.right - pad('paddingRight');
|
|
93
|
+
for (const e of stop.querySelectorAll('*')) {
|
|
94
|
+
if (e === M || M.contains(e) || e.contains(M) || !vis(e)) continue;
|
|
95
|
+
const r = e.getBoundingClientRect();
|
|
96
|
+
if (dir === 'up' || dir === 'down') {
|
|
97
|
+
if (Math.min(mr.right, r.right) - Math.max(mr.left, r.left) <= 1) continue;
|
|
98
|
+
if (dir === 'up' && r.bottom <= mr.top + 0.5) lim = Math.max(lim, r.bottom);
|
|
99
|
+
if (dir === 'down' && r.top >= mr.bottom - 0.5) lim = Math.min(lim, r.top);
|
|
100
|
+
} else {
|
|
101
|
+
if (Math.min(mr.bottom, r.bottom) - Math.max(mr.top, r.top) <= 1) continue;
|
|
102
|
+
if (dir === 'left' && r.right <= mr.left + 0.5) lim = Math.max(lim, r.right);
|
|
103
|
+
if (dir === 'right' && r.left >= mr.right - 0.5) lim = Math.min(lim, r.left);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return dir === 'up' ? mr.top - lim : dir === 'down' ? lim - mr.bottom : dir === 'left' ? mr.left - lim : lim - mr.right;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const ownedByCut = new Set(metrics.overflow.filter((o) => o.kind === 'cut' && o.px >= P.cutOwnedAt).map((o) => o.id));
|
|
110
|
+
const bigSpill = new Set(metrics.overflow.filter((o) => o.kind === 'spill' && o.px >= 4).map((o) => o.id));
|
|
111
|
+
const out = [];
|
|
112
|
+
const done = new Set();
|
|
113
|
+
// every declined finding is recorded on the page so a probe can read WHY (the driver ignores this).
|
|
114
|
+
const skip = (id, why, detail) => { (window.__polishSkips = window.__polishSkips || []).push(Object.assign({ rule: 'escape', id, why }, detail || {})); };
|
|
115
|
+
|
|
116
|
+
for (const f of metrics.overflow) {
|
|
117
|
+
if (f.kind !== 'escape' || f.px < P.minEscape) continue;
|
|
118
|
+
if (ownedByCut.has(f.id)) { skip(f.id, 'owned by page-cut (also off the canvas)', { px: f.px }); continue; }
|
|
119
|
+
const el = document.getElementById(f.id);
|
|
120
|
+
const C = f.container ? document.getElementById(f.container) : null;
|
|
121
|
+
if (!el || !C || !C.contains(el)) continue;
|
|
122
|
+
const tr = trOf(el), cr = C.getBoundingClientRect();
|
|
123
|
+
const over = { right: tr.right - cr.right, bottom: tr.bottom - cr.bottom, left: cr.left - tr.left, top: cr.top - tr.top };
|
|
124
|
+
const side = Object.keys(over).sort((a, b) => over[b] - over[a])[0];
|
|
125
|
+
const need = over[side];
|
|
126
|
+
if (need < P.minEscape) continue;
|
|
127
|
+
// the block that actually sticks out of C
|
|
128
|
+
let M = el, a = el.parentElement;
|
|
129
|
+
while (a && a !== C) {
|
|
130
|
+
const r = a.getBoundingClientRect();
|
|
131
|
+
const o = side === 'right' ? r.right - cr.right : side === 'bottom' ? r.bottom - cr.bottom : side === 'left' ? cr.left - r.left : cr.top - r.top;
|
|
132
|
+
if (o >= 1.5) M = a;
|
|
133
|
+
a = a.parentElement;
|
|
134
|
+
}
|
|
135
|
+
const key = M.id || f.id;
|
|
136
|
+
if (done.has(key)) continue;
|
|
137
|
+
let chosen = null;
|
|
138
|
+
|
|
139
|
+
// 1. grow the container, if its own parent leaves that room and nothing else moves
|
|
140
|
+
if (!chosen && (side === 'right' || side === 'bottom') && C.id && !C.hasAttribute('data-proto-shell') && C.parentElement) {
|
|
141
|
+
const dir = side === 'right' ? 'right' : 'down';
|
|
142
|
+
if (gapTo(C, C.parentElement, dir) >= need + P.pad) {
|
|
143
|
+
const prop = side === 'right' ? 'width' : 'height';
|
|
144
|
+
const v = Math.ceil((( side === 'right' ? cr.width : cr.height) + need + P.pad) * 100) / 100;
|
|
145
|
+
const r = trial(C, { [prop]: v + 'px' }, (c) => {
|
|
146
|
+
const q = c.getBoundingClientRect(), t = trOf(el);
|
|
147
|
+
return { ok: side === 'right' ? t.right <= q.right + 0.5 : t.bottom <= q.bottom + 0.5 };
|
|
148
|
+
});
|
|
149
|
+
if (r.ok && !disturbedOutside(C, r.now)) chosen = { id: C.id, [prop === 'width' ? 'w' : 'h']: v, reason: `grow ${C.id} ${prop} +${(need + P.pad).toFixed(1)}px so ${f.id} fits (free space in parent)` };
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
// 2. translate the escaping block back inside
|
|
153
|
+
if (!chosen && M.id) {
|
|
154
|
+
const dir = { right: 'left', bottom: 'up', left: 'right', top: 'down' }[side];
|
|
155
|
+
const mr = inkBounds(M);
|
|
156
|
+
const mNeed = side === 'right' ? mr.right - cr.right : side === 'bottom' ? mr.bottom - cr.bottom : side === 'left' ? cr.left - mr.left : cr.top - mr.top;
|
|
157
|
+
const room = gapTo(M, C, dir);
|
|
158
|
+
if (mNeed >= 1 && mNeed <= P.maxMove && room >= mNeed + 1) {
|
|
159
|
+
const pad = Math.min(P.pad, Math.max(0, room - mNeed));
|
|
160
|
+
const move = mNeed + pad;
|
|
161
|
+
const dx = dir === 'left' ? -move : dir === 'right' ? move : 0;
|
|
162
|
+
const dy = dir === 'up' ? -move : dir === 'down' ? move : 0;
|
|
163
|
+
const before = collisions(M);
|
|
164
|
+
const r = trial(M, { transform: `translate(${dx}px,${dy}px)` }, (m) => {
|
|
165
|
+
const q = inkBounds(m), live = C.getBoundingClientRect(); // re-read: the page may have scrolled
|
|
166
|
+
const ok = side === 'right' ? q.right <= live.right + 0.5 : side === 'bottom' ? q.bottom <= live.bottom + 0.5
|
|
167
|
+
: side === 'left' ? q.left >= live.left - 0.5 : q.top >= live.top - 0.5;
|
|
168
|
+
return { ok, coll: collisions(m) };
|
|
169
|
+
});
|
|
170
|
+
if (r.ok && r.coll <= before && !disturbedOutside(M, r.now, true)) {
|
|
171
|
+
chosen = { id: M.id, dx, dy, reason: `${M === el ? 'text' : 'block'} ${mNeed.toFixed(1)}px outside ${C.id} → move ${move.toFixed(1)}px ${dir} (room ${room.toFixed(0)}px)` };
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
// 3. last resort: shrink the string's type, unless fit-text already owns it
|
|
176
|
+
if (!chosen && (side === 'right' || side === 'left') && !(bigSpill.has(f.id) && nLines(el) === 1)) {
|
|
177
|
+
const fs = parseFloat(getComputedStyle(el).fontSize) || 16;
|
|
178
|
+
for (let i = 1; i <= Math.round(P.maxFontShrink * 100); i++) {
|
|
179
|
+
const v = (fs * (1 - i / 100)).toFixed(2) + 'px';
|
|
180
|
+
const r = trial(el, { 'font-size': v }, () => {
|
|
181
|
+
const t = trOf(el), q = C.getBoundingClientRect();
|
|
182
|
+
return { ok: side === 'right' ? t.right <= q.right + 0.5 : t.left >= q.left - 0.5 };
|
|
183
|
+
});
|
|
184
|
+
if (r.ok && !disturbedOutside(el, r.now)) { chosen = { id: f.id, css: { 'font-size': v }, reason: `font -${i}% so ${f.id} stays inside ${C.id}` }; break; }
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
if (chosen) { done.add(key); out.push(Object.assign({ rule: 'escape' }, chosen)); }
|
|
188
|
+
else skip(f.id, 'no grow / move / shrink fits', { px: +need.toFixed(1), side, container: C.id, block: M.id });
|
|
189
|
+
}
|
|
190
|
+
return out;
|
|
191
|
+
};
|
|
192
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// fit-text: make a text leaf fit the box the design measured for it.
|
|
2
|
+
//
|
|
3
|
+
// Mechanism this repairs (proven on the population): every element carries a px width/height copied from the
|
|
4
|
+
// design measurement, and every text leaf renders with `white-space:pre`. When the runtime font is a hair wider
|
|
5
|
+
// than the design font the line box is wider than the declared box ("spill"). The box is what the rest of the
|
|
6
|
+
// layout is aligned to, so the label drifts out of its column / card / pill.
|
|
7
|
+
//
|
|
8
|
+
// Ladder, in order, first one that provably works wins (every step is TRIED in the live DOM and reverted):
|
|
9
|
+
// 1. free space on the overflow side (parent content edge, and the nearest sibling that vertically overlaps)
|
|
10
|
+
// >= spill + sidePad → LEAVE IT. The label renders into empty canvas; nothing is clipped, covered or
|
|
11
|
+
// collided with, so there is nothing to repair. (Widening `w` to swallow the spill was tried and dropped:
|
|
12
|
+
// measured on two real pages, 17 widen overrides changed 0 pixels — `white-space:pre` text is painted from
|
|
13
|
+
// the start edge whatever the declared width — while moving the box centre enough that measure.mjs's
|
|
14
|
+
// `shift` metric tripped the driver's harm guard and threw away the real repairs on 113 of 249 pages.)
|
|
15
|
+
// 2. tighten letter-spacing, down to -0.02em.
|
|
16
|
+
// 3. reduce font-size, by at most 6 %.
|
|
17
|
+
// 4. both at their caps.
|
|
18
|
+
// 5. give up — the string itself is too long; that is the text lane's job, not geometry's.
|
|
19
|
+
// Multi-line text that is CLIPPED vertically: grow the clipping ancestor's height, but only when doing so
|
|
20
|
+
// moves nothing else (i.e. it has real slack below).
|
|
21
|
+
//
|
|
22
|
+
// Fires on: a 6 px spill fixable by tracking; a 9 px spill needing a 4 % font trim; a clipped wrapped
|
|
23
|
+
// paragraph inside a card with slack below it.
|
|
24
|
+
// Silent on: spill under 4 px; a label spilling into 450 px of open space beside it; a display heading
|
|
25
|
+
// deliberately bleeding out of its column on BOTH sides (over an image); a badge whose text fills it
|
|
26
|
+
// exactly; anything wrapping (no white-space changes).
|
|
27
|
+
export const name = 'fit-text';
|
|
28
|
+
export const maxShift = 2; // this rule never translates; the cap covers sub-pixel reflow only
|
|
29
|
+
export const params = { minSpill: 4, maxLetterSpacingEm: 0.02, maxFontShrink: 0.06, sidePad: 4, bleedBothSides: 2 };
|
|
30
|
+
|
|
31
|
+
export function browserFn() {
|
|
32
|
+
return ({ metrics, params }) => {
|
|
33
|
+
const P = Object.assign({ minSpill: 4, maxLetterSpacingEm: 0.02, maxFontShrink: 0.06, sidePad: 4, bleedBothSides: 2 }, params || {});
|
|
34
|
+
const secOf = (el) => el.closest('section[id^="viewport-"]') || document.body;
|
|
35
|
+
const rangeOf = (el) => { const r = document.createRange(); r.selectNodeContents(el); return r; };
|
|
36
|
+
const trOf = (el) => { const b = rangeOf(el).getBoundingClientRect(); return b.width > 0 ? b : el.getBoundingClientRect(); };
|
|
37
|
+
const nLines = (el) => { const rs = [...rangeOf(el).getClientRects()].filter((r) => r.width > 0.5 && r.height > 0.5); return Math.max(1, rs.length); };
|
|
38
|
+
const spillOf = (el) => { const er = el.getBoundingClientRect(), tr = trOf(el); return Math.max(tr.right - er.right, er.left - tr.left); };
|
|
39
|
+
|
|
40
|
+
// --- movement probe: every id'd element's rect, so a trial that disturbs the page can be rejected outright
|
|
41
|
+
const probes = [...document.body.querySelectorAll('[id]')].filter((e) => !e.closest('#proto-overlay-root'));
|
|
42
|
+
// document coordinates, not viewport: measure.mjs scrolls the page while sampling backgrounds, and
|
|
43
|
+
// removing an overhang changes the scroll clamp — viewport coords would report the whole page as moved.
|
|
44
|
+
const snap = () => probes.map((e) => { const r = e.getBoundingClientRect(); return [r.left + window.scrollX, r.top + window.scrollY, r.width, r.height]; });
|
|
45
|
+
const base = snap();
|
|
46
|
+
const disturbed = (el) => {
|
|
47
|
+
const now = snap();
|
|
48
|
+
for (let i = 0; i < probes.length; i++) {
|
|
49
|
+
const p = probes[i]; if (p === el || el.contains(p)) continue;
|
|
50
|
+
const a = base[i], b = now[i];
|
|
51
|
+
if (!a[2] && !a[3] && !b[2] && !b[3]) continue; // never-rendered node: its rect is all zeros, ignore
|
|
52
|
+
if (Math.abs(a[0] - b[0]) > 0.5 || Math.abs(a[1] - b[1]) > 0.5 || Math.abs(a[2] - b[2]) > 0.5 || Math.abs(a[3] - b[3]) > 0.5) return p.id || p.tagName;
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
};
|
|
56
|
+
const trial = (el, styles, probe) => {
|
|
57
|
+
const prev = el.getAttribute('style');
|
|
58
|
+
for (const k of Object.keys(styles)) el.style.setProperty(k, styles[k], 'important');
|
|
59
|
+
void document.body.offsetHeight;
|
|
60
|
+
const out = probe(el);
|
|
61
|
+
out.moved = disturbed(el);
|
|
62
|
+
if (prev === null) el.removeAttribute('style'); else el.setAttribute('style', prev);
|
|
63
|
+
void document.body.offsetHeight;
|
|
64
|
+
return out;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const freeOn = (el, side) => {
|
|
68
|
+
const er = el.getBoundingClientRect(), p = el.parentElement;
|
|
69
|
+
if (!p) return 0;
|
|
70
|
+
const pr = p.getBoundingClientRect(), pcs = getComputedStyle(p);
|
|
71
|
+
let limit = side === 'right'
|
|
72
|
+
? pr.right - (parseFloat(pcs.paddingRight) || 0) - (parseFloat(pcs.borderRightWidth) || 0)
|
|
73
|
+
: pr.left + (parseFloat(pcs.paddingLeft) || 0) + (parseFloat(pcs.borderLeftWidth) || 0);
|
|
74
|
+
for (const sib of p.children) {
|
|
75
|
+
if (sib === el) continue;
|
|
76
|
+
const sr = sib.getBoundingClientRect();
|
|
77
|
+
if (sr.width < 0.5 || sr.height < 0.5) continue;
|
|
78
|
+
if (Math.min(er.bottom, sr.bottom) - Math.max(er.top, sr.top) <= 1) continue;
|
|
79
|
+
if (side === 'right' && sr.left >= er.right - 0.5) limit = Math.min(limit, sr.left);
|
|
80
|
+
if (side === 'left' && sr.right <= er.left + 0.5) limit = Math.max(limit, sr.right);
|
|
81
|
+
}
|
|
82
|
+
return side === 'right' ? limit - er.right : er.left - limit;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const out = [];
|
|
86
|
+
const seen = new Set();
|
|
87
|
+
|
|
88
|
+
// ---------- horizontal spill on single-line text
|
|
89
|
+
for (const f of metrics.overflow) {
|
|
90
|
+
if (f.kind !== 'spill' || f.px < P.minSpill || seen.has(f.id)) continue;
|
|
91
|
+
const el = document.getElementById(f.id);
|
|
92
|
+
if (!el) continue;
|
|
93
|
+
const need = spillOf(el);
|
|
94
|
+
if (need < P.minSpill) continue; // stale finding
|
|
95
|
+
if (nLines(el) !== 1) continue; // wrapped/multi-line: never touched here
|
|
96
|
+
const er = el.getBoundingClientRect(), tr = trOf(el);
|
|
97
|
+
const overRight = tr.right - er.right, overLeft = er.left - tr.left;
|
|
98
|
+
if (overRight > P.bleedBothSides && overLeft > P.bleedBothSides) continue; // deliberate two-sided bleed
|
|
99
|
+
const side = overRight >= overLeft ? 'right' : 'left';
|
|
100
|
+
const cs = getComputedStyle(el);
|
|
101
|
+
const fs = parseFloat(cs.fontSize) || 16;
|
|
102
|
+
const ls0 = parseFloat(cs.letterSpacing) || 0;
|
|
103
|
+
const ok = (el2) => ({ ok: spillOf(el2) < 1.5 });
|
|
104
|
+
let chosen = null;
|
|
105
|
+
|
|
106
|
+
// 1. the text spills into open canvas: not clipped, not covered, hits nothing — leave the type alone
|
|
107
|
+
if (freeOn(el, side) >= need + P.sidePad) continue;
|
|
108
|
+
// 2. letter-spacing
|
|
109
|
+
if (!chosen) {
|
|
110
|
+
for (const em of [0.004, 0.008, 0.012, 0.016, P.maxLetterSpacingEm]) {
|
|
111
|
+
const v = (ls0 - em * fs).toFixed(3) + 'px';
|
|
112
|
+
const r = trial(el, { 'letter-spacing': v }, ok);
|
|
113
|
+
if (r.ok && !r.moved) { chosen = { css: { 'letter-spacing': v }, reason: `tracking -${em}em (spill ${need.toFixed(1)}px)` }; break; }
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
// 3. font-size
|
|
117
|
+
if (!chosen) {
|
|
118
|
+
for (let i = 1; i <= 6; i++) {
|
|
119
|
+
const sc = 1 - i / 100;
|
|
120
|
+
if (1 - sc > P.maxFontShrink + 1e-6) break;
|
|
121
|
+
const v = (fs * sc).toFixed(2) + 'px';
|
|
122
|
+
const r = trial(el, { 'font-size': v }, ok);
|
|
123
|
+
if (r.ok && !r.moved) { chosen = { css: { 'font-size': v }, reason: `font -${i}% (spill ${need.toFixed(1)}px)` }; break; }
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
// 4. both at their caps
|
|
127
|
+
if (!chosen) {
|
|
128
|
+
const styles = { 'letter-spacing': (ls0 - P.maxLetterSpacingEm * fs).toFixed(3) + 'px', 'font-size': (fs * (1 - P.maxFontShrink)).toFixed(2) + 'px' };
|
|
129
|
+
const r = trial(el, styles, ok);
|
|
130
|
+
if (r.ok && !r.moved) chosen = { css: styles, reason: `tracking+font at cap (spill ${need.toFixed(1)}px)` };
|
|
131
|
+
}
|
|
132
|
+
// 5. else: the string is too long for the box — text lane's problem, not geometry's.
|
|
133
|
+
if (chosen) { seen.add(f.id); out.push(Object.assign({ id: f.id, rule: 'fit-text' }, chosen)); }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ---------- vertical clipping of wrapped text: give the clipping ancestor its missing height
|
|
137
|
+
for (const f of metrics.overflow) {
|
|
138
|
+
if (f.kind !== 'clipped') continue;
|
|
139
|
+
const el = document.getElementById(f.id);
|
|
140
|
+
if (!el) continue;
|
|
141
|
+
const sec = secOf(el);
|
|
142
|
+
let a = el.parentElement, clip = null;
|
|
143
|
+
while (a && a !== sec) { if (getComputedStyle(a).overflow !== 'visible') { clip = a; break; } a = a.parentElement; }
|
|
144
|
+
if (!clip || seen.has(clip.id) || !clip.id) continue;
|
|
145
|
+
// measure.mjs scrolls clipped ancestors while sampling background colour (scrollIntoView); undo that so
|
|
146
|
+
// we read the geometry the page actually loads with.
|
|
147
|
+
if (clip.scrollTop || clip.scrollLeft) { clip.scrollTop = 0; clip.scrollLeft = 0; void document.body.offsetHeight; }
|
|
148
|
+
const tr = trOf(el), cr = clip.getBoundingClientRect();
|
|
149
|
+
const need = tr.bottom - cr.bottom;
|
|
150
|
+
if (need < 1.5) continue; // horizontal clipping is the spill branch's job
|
|
151
|
+
const h = Math.ceil((cr.height + need + 2) * 100) / 100;
|
|
152
|
+
const r = trial(clip, { height: h + 'px' }, (c) => ({ ok: trOf(el).bottom <= c.getBoundingClientRect().bottom + 0.5 }));
|
|
153
|
+
if (r.ok && !r.moved) { seen.add(clip.id); out.push({ id: clip.id, rule: 'fit-text', h, reason: `unclip ${el.id}: +${need.toFixed(1)}px height (slack below)` }); }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return out;
|
|
157
|
+
};
|
|
158
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Rule registry and the single owner of production polish policy. A rule module
|
|
2
|
+
// exports { name, maxShift, params?, browserFn }. browserFn() returns a function
|
|
3
|
+
// evaluated in the page with ({ metrics, params }) and emits deterministic CSS
|
|
4
|
+
// overrides (see ../overrides.mjs).
|
|
5
|
+
//
|
|
6
|
+
// Keep this list explicit: adding a file must never silently enable an unstudied
|
|
7
|
+
// rule. These eleven rules are the population-retained set from the canonical
|
|
8
|
+
// prototype-polish study through owner-feedback round 8.
|
|
9
|
+
import * as align from './align.mjs';
|
|
10
|
+
import * as contrast from './contrast.mjs';
|
|
11
|
+
import * as escape from './escape.mjs';
|
|
12
|
+
import * as fitText from './fit-text.mjs';
|
|
13
|
+
import * as leftRail from './left-rail.mjs';
|
|
14
|
+
import * as overlap from './overlap.mjs';
|
|
15
|
+
import * as pageCut from './page-cut.mjs';
|
|
16
|
+
import * as photoCorners from './photo-corners.mjs';
|
|
17
|
+
import * as rhythm from './rhythm.mjs';
|
|
18
|
+
import * as sectionBreathing from './section-breathing.mjs';
|
|
19
|
+
import * as shellSeam from './shell-seam.mjs';
|
|
20
|
+
|
|
21
|
+
export const RETAINED_RULES = Object.freeze([
|
|
22
|
+
'align',
|
|
23
|
+
'contrast',
|
|
24
|
+
'escape',
|
|
25
|
+
'fit-text',
|
|
26
|
+
'left-rail',
|
|
27
|
+
'overlap',
|
|
28
|
+
'page-cut',
|
|
29
|
+
'photo-corners',
|
|
30
|
+
'rhythm',
|
|
31
|
+
'section-breathing',
|
|
32
|
+
'shell-seam',
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
export const ALL = RETAINED_RULES;
|
|
36
|
+
|
|
37
|
+
const MODULES = new Map([
|
|
38
|
+
['align', align],
|
|
39
|
+
['contrast', contrast],
|
|
40
|
+
['escape', escape],
|
|
41
|
+
['fit-text', fitText],
|
|
42
|
+
['left-rail', leftRail],
|
|
43
|
+
['overlap', overlap],
|
|
44
|
+
['page-cut', pageCut],
|
|
45
|
+
['photo-corners', photoCorners],
|
|
46
|
+
['rhythm', rhythm],
|
|
47
|
+
['section-breathing', sectionBreathing],
|
|
48
|
+
['shell-seam', shellSeam],
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
export async function loadRules(names = []) {
|
|
52
|
+
const wanted = names.length ? names : ALL;
|
|
53
|
+
const rules = [];
|
|
54
|
+
for (const n of wanted) {
|
|
55
|
+
if (!ALL.includes(n)) throw new Error(`unknown polish rule: ${n} (have ${ALL.join(', ')})`);
|
|
56
|
+
const mod = MODULES.get(n);
|
|
57
|
+
if (!mod.name || typeof mod.browserFn !== 'function') throw new Error(`rule ${n} must export name + browserFn()`);
|
|
58
|
+
rules.push(mod);
|
|
59
|
+
}
|
|
60
|
+
return rules;
|
|
61
|
+
}
|