@12ui/design 0.2.43 → 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 +17 -5
- 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 +41 -2
- package/dist/cli-branch-command.js.map +1 -1
- package/dist/cli-capabilities.d.ts +2 -1
- package/dist/cli-capabilities.d.ts.map +1 -1
- package/dist/cli-capabilities.js +2 -1
- 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 +17 -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/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.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-gates.d.ts +8 -0
- package/dist/prototype-gates.d.ts.map +1 -1
- package/dist/prototype-gates.js +39 -5
- package/dist/prototype-gates.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 +10 -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 +2 -0
- package/dist/prototype-revision.d.ts.map +1 -0
- package/dist/prototype-revision.js +2 -0
- package/dist/prototype-revision.js.map +1 -0
- package/dist/prototype-run-record.d.ts +6 -1
- package/dist/prototype-run-record.d.ts.map +1 -1
- package/dist/prototype-run-record.js +113 -9
- package/dist/prototype-run-record.js.map +1 -1
- package/dist/prototype-runner.d.ts +30 -1
- package/dist/prototype-runner.d.ts.map +1 -1
- package/dist/prototype-runner.js +69 -16
- package/dist/prototype-runner.js.map +1 -1
- package/dist/run-journal.d.ts +21 -1
- package/dist/run-journal.d.ts.map +1 -1
- package/dist/run-journal.js +19 -2
- package/dist/run-journal.js.map +1 -1
- package/open-design.json +2 -2
- package/package.json +2 -2
- package/prototype-kit/build.mjs +86 -43
- package/prototype-kit/lib/actions.mjs +48 -0
- package/prototype-kit/lib/shell-identity.mjs +139 -0
- package/prototype-kit/lib/shell-stitching.mjs +70 -29
- 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/prototype-kit/tests/adversarial-runtime.mjs +16 -1
- package/prototype-kit/tests/fixtures/blocking-runtime/broken-interaction.html +7 -0
- package/prototype-kit/tests/fixtures/four-dropdown/branch.plan.json +29 -0
- package/prototype-kit/tests/fixtures/four-dropdown/pages/p2-stream-proposals.html +10 -0
- package/prototype-kit/tests/fixtures/four-dropdown/pages/p4-live-stream-results.html +7 -0
- package/prototype-kit/tests/fixtures/shell-stitching-runtime/PROVENANCE.md +22 -0
- package/prototype-kit/tests/fixtures/shell-stitching-runtime/branch.plan.json +40 -0
- package/prototype-kit/tests/fixtures/shell-stitching-runtime/pages/p0-donor.html +38 -0
- package/prototype-kit/tests/fixtures/shell-stitching-runtime/pages/p1-id-collision.html +38 -0
- package/prototype-kit/tests/fixtures/shell-stitching-runtime/pages/p2-canvas-control.html +39 -0
- package/prototype-kit/tests/offline-check.mjs +1 -1
- package/prototype-kit/tests/rules-check.mjs +69 -3
- package/prototype-kit/tests/shell-stitching-runtime.mjs +116 -0
- package/skills/12ui-design/SKILL.md +4 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// left-rail: a column of page sections (brand, title, results, filters, footer …) whose left edges are ALMOST one
|
|
2
|
+
// rail snap to the dominant inset. Design-quality rule (owner, 2026-08-23: "we are no longer aiming for pixel perfect,
|
|
3
|
+
// we are trying to improve on it") — the source may itself be ragged; that is the point.
|
|
4
|
+
//
|
|
5
|
+
// Facts, per viewport section:
|
|
6
|
+
// rows the direct children of every top-level block (and of a shell host) that carry ink. A row's edge is the
|
|
7
|
+
// leftmost ink it contains — text / icon / image / button — or its own box edge when it is painted
|
|
8
|
+
// (border or opaque background): a filter panel aligns by its panel edge, a heading by its glyphs.
|
|
9
|
+
// rail cluster the row edges within 4 px; the dominant cluster is the one spanning most top-level blocks
|
|
10
|
+
// (ties → most rows, then most ink);
|
|
11
|
+
// the rail is its median edge.
|
|
12
|
+
// snap a row whose edge is 2–48 px off the rail moves onto it (translate; internal structure untouched).
|
|
13
|
+
// silent full-bleed rows (edge ≤ 2 px or ≥ 95 % width), centered rows (left inset ≈ right inset, ±24 px),
|
|
14
|
+
// rows far from the rail (> 48 px: a second column, a grid), indented sub-items (a row ≥ 12 px right of its
|
|
15
|
+
// own block's edge while that block already sits on the rail), and pages with fewer than 3 ink rows.
|
|
16
|
+
// maxShift 48 is the declared magnitude; the harm guard still rejects the rule on any page where a snap adds an
|
|
17
|
+
// overlap, cut, escape, broken column or off-center fit. Horizontal gaps adjacent to a moved row are this rule's own
|
|
18
|
+
// declared change (movesBlocks), not spacing harm.
|
|
19
|
+
export const name = 'left-rail';
|
|
20
|
+
export const maxShift = 48;
|
|
21
|
+
export const movesBlocks = true;
|
|
22
|
+
export const params = { SNAP_MAX: 48, SNAP_MIN: 2, CLUSTER: 4, INDENT: 12, CENTER_TOL: 24 };
|
|
23
|
+
|
|
24
|
+
export function browserFn() {
|
|
25
|
+
return ({ params }) => {
|
|
26
|
+
const P = Object.assign({ SNAP_MAX: 48, SNAP_MIN: 2, CLUSTER: 4, INDENT: 12, CENTER_TOL: 24 }, params || {});
|
|
27
|
+
const out = [];
|
|
28
|
+
const visible = (el) => { const s = getComputedStyle(el); if (s.display === 'none' || s.visibility === 'hidden' || parseFloat(s.opacity) === 0) return false; const r = el.getBoundingClientRect(); return r.width > 0 && r.height > 0; };
|
|
29
|
+
const parseRgb = (s) => { const m = s && s.match(/rgba?\(([^)]+)\)/); if (!m) return null; const p = m[1].split(',').map(parseFloat); return { a: p[3] === undefined ? 1 : p[3] }; };
|
|
30
|
+
const painted = (el) => { const cs = getComputedStyle(el); const bg = parseRgb(cs.backgroundColor); return (bg && bg.a > 0.5) || parseFloat(cs.borderLeftWidth) > 0; };
|
|
31
|
+
const INK = '.text, .paragraph, h1, h2, h3, h4, h5, h6, .icon, img, .image, .badge, button, .button';
|
|
32
|
+
for (const sec of document.querySelectorAll('section[id^="viewport-"]')) {
|
|
33
|
+
const sb = sec.getBoundingClientRect();
|
|
34
|
+
const edgeOf = (el) => {
|
|
35
|
+
const r = el.getBoundingClientRect();
|
|
36
|
+
let left = painted(el) ? r.left : Infinity, right = painted(el) ? r.right : -Infinity;
|
|
37
|
+
for (const k of [...(el.matches(INK) ? [el] : []), ...el.querySelectorAll(INK)]) { if (!visible(k)) continue; const kr = k.getBoundingClientRect(); if (kr.left - sb.left <= 2) continue; left = Math.min(left, kr.left); right = Math.max(right, kr.right); }
|
|
38
|
+
for (const k of el.querySelectorAll('[id]:not(.text):not(.icon):not(img):not(button)')) { if (!visible(k) || !painted(k)) continue; const kr = k.getBoundingClientRect(); if (kr.left - sb.left <= 2 || kr.width < 40) continue; left = Math.min(left, kr.left); right = Math.max(right, kr.right); } // a painted panel edge is visible ink
|
|
39
|
+
if (!Number.isFinite(left)) return null;
|
|
40
|
+
return { left: left - sb.left, right: right - sb.left };
|
|
41
|
+
};
|
|
42
|
+
// rows: direct children of top-level blocks (a block is a direct child of the section; a shell host counts)
|
|
43
|
+
const rows = [];
|
|
44
|
+
for (const block of sec.children) {
|
|
45
|
+
if (!block.id || !visible(block)) continue;
|
|
46
|
+
const be = edgeOf(block); if (!be) continue;
|
|
47
|
+
let kids = [...block.children].filter((k) => k.id && visible(k));
|
|
48
|
+
if (kids.length === 1 && kids[0].children.length > 1) kids = [...kids[0].children].filter((k) => k.id && visible(k)); // thin wrapper
|
|
49
|
+
if (!kids.length) kids = [block];
|
|
50
|
+
for (const k of kids) {
|
|
51
|
+
const e = edgeOf(k); if (!e) continue;
|
|
52
|
+
const r = k.getBoundingClientRect();
|
|
53
|
+
if (e.left <= 2 || r.width >= 0.95 * sb.width && painted(k) && !k.querySelector(INK)) continue; // full-bleed band
|
|
54
|
+
const rightInset = sb.width - e.right;
|
|
55
|
+
if ((e.right - e.left) < 0.6 * sb.width && Math.abs(e.left - rightInset) <= P.CENTER_TOL) continue; // a centered block (narrow ink, equal insets); a full-width row with ink at both ends is not
|
|
56
|
+
rows.push({ el: k, block, edge: e.left, blockEdge: be.left, ink: k.querySelectorAll(INK).length + (k.matches(INK) ? 1 : 0) });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (rows.length < 3) continue;
|
|
60
|
+
// dominant cluster
|
|
61
|
+
const sorted = [...rows].sort((a, b) => a.edge - b.edge); const clusters = [];
|
|
62
|
+
for (const r of sorted) { const c = clusters[clusters.length - 1]; if (c && r.edge - c[0].edge <= P.CLUSTER) c.push(r); else clusters.push([r]); }
|
|
63
|
+
// dominance = how many distinct top-level BLOCKS sit on the inset (a footer with five rows is still one block)
|
|
64
|
+
const blocksOf = (c) => new Set(c.map((r) => r.block)).size;
|
|
65
|
+
clusters.sort((a, b) => blocksOf(b) - blocksOf(a) || b.length - a.length || b.reduce((n, r) => n + r.ink, 0) - a.reduce((n, r) => n + r.ink, 0));
|
|
66
|
+
const dom = clusters[0]; const edges = dom.map((r) => r.edge).sort((a, b) => a - b); const rail = edges[Math.floor((edges.length - 1) / 2)];
|
|
67
|
+
for (const r of rows) {
|
|
68
|
+
const d = rail - r.edge;
|
|
69
|
+
if (Math.abs(d) < P.SNAP_MIN || Math.abs(d) > P.SNAP_MAX) continue;
|
|
70
|
+
// an indented sub-item under a block that already sits on the rail is design
|
|
71
|
+
if (r.edge - r.blockEdge >= P.INDENT && Math.abs(r.blockEdge - rail) < P.SNAP_MIN) continue;
|
|
72
|
+
out.push({ id: r.el.id, rule: 'left-rail', reason: `edge ${r.edge.toFixed(1)} → rail ${rail.toFixed(1)}`, dx: +d.toFixed(2) });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
};
|
|
77
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// overlap: nudge apart two pieces of ink from DIFFERENT parents that have been assembled into each other.
|
|
2
|
+
//
|
|
3
|
+
// Mechanism: the builder composes a page from parts that were laid out independently, so a part can land a few px
|
|
4
|
+
// into another part — a transplanted nav's "Log in" sitting on the page's own hero heading (Northshore), a topbar
|
|
5
|
+
// icon 4 px into an incident header, a comparison-table value under its own check icon.
|
|
6
|
+
//
|
|
7
|
+
// What the pixels changed. measure.mjs measures text with a Range rect, which is the LINE box: it includes the
|
|
8
|
+
// font's ascent/descent, so it is routinely 6-8 px taller than the element's declared box. On this population that
|
|
9
|
+
// makes 80 of 106 "overlaps" pure typography — a "15" stacked over "min" with a real 3 px gap reads as a 3.5 px
|
|
10
|
+
// overlap, and a transplanted brand line 2 px above a hero heading reads as 6.6 px. Nudging those apart would open
|
|
11
|
+
// gaps the design never had. So this rule works on the TIGHT rect — the element's box intersected with its line box
|
|
12
|
+
// — and fires only when the tight rects really cross. 26 of the 106 findings survive that test, 18 across parents.
|
|
13
|
+
//
|
|
14
|
+
// Fix: for a small tight overlap (≤ SMALL px on the axis we move), translate the LATER piece — the one further down
|
|
15
|
+
// (or right), tie-broken by document order — clear by the overlap + 2 px, but only when it has slack:
|
|
16
|
+
// * nothing else may sit in the region the piece sweeps through,
|
|
17
|
+
// * it must stay inside its nearest painted ancestor (the card it belongs to) and inside its section,
|
|
18
|
+
// * a piece inside a transplanted shell is never moved: the shell is the fixed frame, the page's content yields.
|
|
19
|
+
// Two heading LINES under one parent are the design's leading, not a collision, and are left alone; so are larger
|
|
20
|
+
// overlaps, which need a layout decision rather than a nudge. Both are recorded in window.__protoRuleNotes.
|
|
21
|
+
export const name = 'overlap';
|
|
22
|
+
export const maxShift = 16;
|
|
23
|
+
|
|
24
|
+
export function browserFn() {
|
|
25
|
+
return ({ metrics }) => {
|
|
26
|
+
const SMALL = 14, CLEAR = 2;
|
|
27
|
+
const out = [];
|
|
28
|
+
const notes = [];
|
|
29
|
+
const claimed = new Set();
|
|
30
|
+
const cs = (el) => getComputedStyle(el);
|
|
31
|
+
const visible = (el) => {
|
|
32
|
+
const s = cs(el);
|
|
33
|
+
if (s.display === 'none' || s.visibility === 'hidden' || parseFloat(s.opacity) === 0) return false;
|
|
34
|
+
const r = el.getBoundingClientRect();
|
|
35
|
+
return r.width > 0 && r.height > 0;
|
|
36
|
+
};
|
|
37
|
+
const isText = (el) => el.classList.contains('text') || /^H[1-6]$/.test(el.tagName);
|
|
38
|
+
// tight rect: the declared box ∩ the rendered line box. Neither alone is the ink — the box can be smaller than
|
|
39
|
+
// the type that overflows it, the line box is taller than the glyphs by the font's leading.
|
|
40
|
+
const tightRect = (el) => {
|
|
41
|
+
const b = el.getBoundingClientRect();
|
|
42
|
+
if (!isText(el)) return { left: b.left, right: b.right, top: b.top, bottom: b.bottom };
|
|
43
|
+
const range = document.createRange(); range.selectNodeContents(el);
|
|
44
|
+
const t = range.getBoundingClientRect();
|
|
45
|
+
if (!(t.width > 0)) return { left: b.left, right: b.right, top: b.top, bottom: b.bottom };
|
|
46
|
+
return { left: Math.max(b.left, t.left), right: Math.min(b.right, t.right), top: Math.max(b.top, t.top), bottom: Math.min(b.bottom, t.bottom) };
|
|
47
|
+
};
|
|
48
|
+
const cross = (u, v) => ({ ix: Math.min(u.right, v.right) - Math.max(u.left, v.left), iy: Math.min(u.bottom, v.bottom) - Math.max(u.top, v.top) });
|
|
49
|
+
const parseRgb = (s) => { const m = s && s.match(/rgba?\(([^)]+)\)/); if (!m) return null; const p = m[1].split(',').map(parseFloat); return { a: p[3] === undefined ? 1 : p[3] }; };
|
|
50
|
+
// the nearest ancestor that actually paints a box — the card the ink lives in
|
|
51
|
+
const frameOf = (el) => {
|
|
52
|
+
const sec = el.closest('section[id^="viewport-"]') || document.body;
|
|
53
|
+
let a = el.parentElement;
|
|
54
|
+
while (a && a !== sec) {
|
|
55
|
+
const s = cs(a), bg = parseRgb(s.backgroundColor);
|
|
56
|
+
if ((bg && bg.a > 0.5) || parseFloat(s.borderTopWidth) > 0) return a.getBoundingClientRect();
|
|
57
|
+
a = a.parentElement;
|
|
58
|
+
}
|
|
59
|
+
return sec.getBoundingClientRect();
|
|
60
|
+
};
|
|
61
|
+
const allInk = [...document.querySelectorAll('.text, h1, h2, h3, h4, h5, h6, .icon, img, .image, .badge')]
|
|
62
|
+
.filter((el) => visible(el) && !el.closest('#proto-overlay-root') && !el.querySelector('.text'))
|
|
63
|
+
.map((el) => ({ el, r: tightRect(el) }));
|
|
64
|
+
|
|
65
|
+
for (const f of metrics.overlap || []) {
|
|
66
|
+
const a = document.getElementById(f.a), b = document.getElementById(f.b);
|
|
67
|
+
if (!a || !b || !visible(a) || !visible(b)) continue;
|
|
68
|
+
const note = (why, extra = {}) => notes.push({ rule: 'overlap', a: f.a, b: f.b, why, ...extra });
|
|
69
|
+
if (a.parentElement === b.parentElement) { note('same parent (leading / pill) — design, left alone'); continue; }
|
|
70
|
+
|
|
71
|
+
const ar = tightRect(a), br = tightRect(b);
|
|
72
|
+
const { ix, iy } = cross(ar, br);
|
|
73
|
+
if (!(ix > 1 && iy > 1)) { note('line-box overlap only, declared boxes are clear', { ix: +ix.toFixed(1), iy: +iy.toFixed(1) }); continue; }
|
|
74
|
+
|
|
75
|
+
const shellA = !!a.closest('[data-proto-shell]'), shellB = !!b.closest('[data-proto-shell]');
|
|
76
|
+
const vertical = iy <= ix;
|
|
77
|
+
// which piece yields: never the shell; otherwise the lower/righter one, tie-broken by document order
|
|
78
|
+
let mover, other;
|
|
79
|
+
if (shellA !== shellB) { mover = shellA ? b : a; other = shellA ? a : b; }
|
|
80
|
+
else {
|
|
81
|
+
const online = Math.abs((ar.top + ar.bottom) / 2 - (br.top + br.bottom) / 2) <= 4;
|
|
82
|
+
const byX = online || !vertical;
|
|
83
|
+
const later = (byX ? (br.left - ar.left) : (br.top - ar.top)) > 0.5 ? b
|
|
84
|
+
: (byX ? (ar.left - br.left) : (ar.top - br.top)) > 0.5 ? a
|
|
85
|
+
: (a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING) ? b : a;
|
|
86
|
+
mover = later; other = later === a ? b : a;
|
|
87
|
+
}
|
|
88
|
+
if (mover.closest('[data-proto-shell]')) { note('only a shell piece could move — left alone', { ix: +ix.toFixed(1), iy: +iy.toFixed(1) }); continue; }
|
|
89
|
+
const mr = mover === a ? ar : br, or_ = mover === a ? br : ar;
|
|
90
|
+
|
|
91
|
+
// Two pieces whose centres sit on the same line are ON one line — a label and its trailing check icon, an
|
|
92
|
+
// eyebrow icon and its kicker. Pulling those apart vertically drops the label off its row (proved on the
|
|
93
|
+
// Harborlight pricing table, where "Basic visibility" fell onto the row rule while its neighbouring column
|
|
94
|
+
// stayed put). Same-line collisions are separated horizontally or not at all.
|
|
95
|
+
const sameLine = Math.abs((mr.top + mr.bottom) / 2 - (or_.top + or_.bottom) / 2) <= 4;
|
|
96
|
+
// only ever move away from the other piece, and only along an axis whose overlap is small enough to nudge
|
|
97
|
+
const canY = !sameLine && mr.top >= or_.top - 0.5 && iy <= SMALL;
|
|
98
|
+
const canX = mr.left >= or_.left - 0.5 && ix <= SMALL;
|
|
99
|
+
const axis = canY && canX ? (iy <= ix ? 'y' : 'x') : canY ? 'y' : canX ? 'x' : null;
|
|
100
|
+
if (!axis) { note('overlap needs a layout decision, not a nudge', { ix: +ix.toFixed(1), iy: +iy.toFixed(1) }); continue; }
|
|
101
|
+
const shift = +((axis === 'y' ? iy : ix) + CLEAR).toFixed(2);
|
|
102
|
+
if (!mover.id || claimed.has(mover.id)) { note('mover already nudged by another pair', { mover: mover.id }); continue; }
|
|
103
|
+
|
|
104
|
+
// swept region: the mover's rect from where it is to where it lands — nothing else may be in it
|
|
105
|
+
const swept = {
|
|
106
|
+
left: mr.left, right: mr.right + (axis === 'x' ? shift : 0),
|
|
107
|
+
top: mr.top, bottom: mr.bottom + (axis === 'y' ? shift : 0),
|
|
108
|
+
};
|
|
109
|
+
const frame = frameOf(mover), sec = (mover.closest('section[id^="viewport-"]') || document.body).getBoundingClientRect();
|
|
110
|
+
if (swept.bottom > frame.bottom - 0.5 || swept.right > frame.right - 0.5 || swept.bottom > sec.bottom - 0.5 || swept.right > sec.right - 0.5) { note('no slack: would leave its card or the section', { axis, shift }); continue; }
|
|
111
|
+
const blocked = allInk.filter(({ el, r }) => el !== mover && el !== other && !mover.contains(el) && !el.contains(mover)
|
|
112
|
+
&& Math.min(r.right, swept.right) - Math.max(r.left, swept.left) > 0.5
|
|
113
|
+
&& Math.min(r.bottom, swept.bottom) - Math.max(r.top, swept.top) > 0.5);
|
|
114
|
+
if (blocked.length) { note('no slack: ink in the way', { axis, shift, blocked: blocked.slice(0, 3).map((x) => x.el.id) }); continue; }
|
|
115
|
+
|
|
116
|
+
claimed.add(mover.id);
|
|
117
|
+
out.push({ id: mover.id, rule: 'overlap', reason: `clear ${(shift - CLEAR).toFixed(1)}px of #${other.id}`, dx: axis === 'x' ? shift : 0, dy: axis === 'y' ? shift : 0 });
|
|
118
|
+
}
|
|
119
|
+
window.__protoRuleNotes = (window.__protoRuleNotes || []).concat(notes);
|
|
120
|
+
return out;
|
|
121
|
+
};
|
|
122
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// page-cut: content that has fallen off the state's own canvas (kind 'cut' — text past the section box).
|
|
2
|
+
//
|
|
3
|
+
// Mechanism this repairs (proven on the population): a canonical shell (`[data-proto-shell]`: sidebar, header,
|
|
4
|
+
// footer) is transplanted into a page whose canvas is a different size, but the shell's CHILDREN keep the donor
|
|
5
|
+
// page's absolute `top:` / flow margins. A sidebar footer with `margin-top:88px` inside a 947 px sidebar lands at
|
|
6
|
+
// y=1035 — "Kestrel v2.14.0" floats on white 88 px below the navy rail. A footer legal row with `top:222px`
|
|
7
|
+
// inside a 212 px footer hangs 23 px below the page.
|
|
8
|
+
//
|
|
9
|
+
// Two repairs, chosen by where the culprit lives:
|
|
10
|
+
// * inside a shell / footer / header / aside / nav → TRANSLATE the whole overhanging block back up (or left)
|
|
11
|
+
// until its bottom sits at the section bottom minus a small pad, capped by the free space above it, and only
|
|
12
|
+
// if that lands it on nothing (live overlap check + the driver's harm guard).
|
|
13
|
+
// * otherwise (a stacked, in-flow marketing page) → EXTEND the section's own height so nothing is cut. The
|
|
14
|
+
// canvas follows: body/html are sized by the sections, so growing one grows the page (measured, see REPORT).
|
|
15
|
+
// Right/left/top edges are handled the same way by translation when there is slack on the opposite side.
|
|
16
|
+
//
|
|
17
|
+
// Findings under `minCut` (default 4 px) are deliberately left alone: at 1.6–3 px they are line-box overshoot
|
|
18
|
+
// (ascender/descender beyond the declared box) with no visible ink outside the canvas.
|
|
19
|
+
//
|
|
20
|
+
// Fires on: a shell sidebar footer 88 px below the rail; a footer legal row 23 px below a stacked page.
|
|
21
|
+
// Silent on: 2 px line-box overshoot; a hero band that intentionally runs to the canvas edge; anything whose
|
|
22
|
+
// lift would land on another element.
|
|
23
|
+
export const name = 'page-cut';
|
|
24
|
+
export const maxShift = 128; // honest: this rule moves a stranded footer row / sidebar block by up to ~100 px
|
|
25
|
+
export const params = { minCut: 4, pad: 8, maxLift: 200, minGroupForExtend: 1, roomTolerance: 4 };
|
|
26
|
+
|
|
27
|
+
export function browserFn() {
|
|
28
|
+
return ({ metrics, params }) => {
|
|
29
|
+
const P = Object.assign({ minCut: 4, pad: 8, maxLift: 200, minGroupForExtend: 1, roomTolerance: 4 }, params || {});
|
|
30
|
+
const secOf = (el) => el.closest('section[id^="viewport-"]') || document.body;
|
|
31
|
+
const rangeOf = (el) => { const r = document.createRange(); r.selectNodeContents(el); return r; };
|
|
32
|
+
const trOf = (el) => { const b = rangeOf(el).getBoundingClientRect(); return b.width > 0 ? b : el.getBoundingClientRect(); };
|
|
33
|
+
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; };
|
|
34
|
+
const isText = (el) => el.classList.contains('text') || /^H[1-6]$/.test(el.tagName);
|
|
35
|
+
const kindOf = (el) => isText(el) ? 'text' : el.classList.contains('icon') ? 'icon' : 'other';
|
|
36
|
+
const leaves = [...document.querySelectorAll('.text, h1, h2, h3, h4, h5, h6, .icon, img, .image, .badge, .divider')]
|
|
37
|
+
.filter((el) => vis(el) && !el.closest('#proto-overlay-root') && !el.querySelector('.text'));
|
|
38
|
+
// count text×text / text×icon collisions between M's subtree and everything else in its section
|
|
39
|
+
const collisions = (M) => {
|
|
40
|
+
const sec = secOf(M);
|
|
41
|
+
const inside = leaves.filter((e) => M.contains(e));
|
|
42
|
+
const outside = leaves.filter((e) => !M.contains(e) && secOf(e) === sec);
|
|
43
|
+
let n = 0;
|
|
44
|
+
for (const A of inside) {
|
|
45
|
+
const ar = (isText(A) ? trOf(A) : A.getBoundingClientRect());
|
|
46
|
+
const ka = kindOf(A); if (ka === 'other') continue;
|
|
47
|
+
for (const B of outside) {
|
|
48
|
+
const kb = kindOf(B); if (kb === 'other') continue;
|
|
49
|
+
if (!(ka === 'text' && kb === 'text') && !((ka === 'text') !== (kb === 'text'))) continue;
|
|
50
|
+
const br = (isText(B) ? trOf(B) : B.getBoundingClientRect());
|
|
51
|
+
const ix = Math.min(ar.right, br.right) - Math.max(ar.left, br.left);
|
|
52
|
+
const iy = Math.min(ar.bottom, br.bottom) - Math.max(ar.top, br.top);
|
|
53
|
+
if (ix > 1 && iy > 3 && ix * iy > 24) n++;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return n;
|
|
57
|
+
};
|
|
58
|
+
const probes = [...document.body.querySelectorAll('[id]')].filter((e) => !e.closest('#proto-overlay-root'));
|
|
59
|
+
// document coordinates, not viewport: measure.mjs scrolls the page while sampling backgrounds, and
|
|
60
|
+
// removing an overhang changes the scroll clamp — viewport coords would report the whole page as moved.
|
|
61
|
+
const snap = () => probes.map((e) => { const r = e.getBoundingClientRect(); return [r.left + window.scrollX, r.top + window.scrollY, r.width, r.height]; });
|
|
62
|
+
const base = snap();
|
|
63
|
+
const trial = (el, styles, probe) => {
|
|
64
|
+
const prev = el.getAttribute('style');
|
|
65
|
+
for (const k of Object.keys(styles)) el.style.setProperty(k, styles[k], 'important');
|
|
66
|
+
void document.body.offsetHeight;
|
|
67
|
+
const out = probe(el);
|
|
68
|
+
out.now = snap();
|
|
69
|
+
if (prev === null) el.removeAttribute('style'); else el.setAttribute('style', prev);
|
|
70
|
+
void document.body.offsetHeight;
|
|
71
|
+
return out;
|
|
72
|
+
};
|
|
73
|
+
// `ignoreAncestors`: a pure transform never reflows anything, but it does change the page's scroll extent,
|
|
74
|
+
// so the wrappers ABOVE the moved block legitimately shrink. Only translate trials pass this.
|
|
75
|
+
const disturbedOutside = (el, now, ignoreAncestors) => {
|
|
76
|
+
for (let i = 0; i < probes.length; i++) {
|
|
77
|
+
const p = probes[i]; if (p === el || el.contains(p) || (ignoreAncestors && p.contains(el))) continue;
|
|
78
|
+
const a = base[i], b = now[i];
|
|
79
|
+
if (!a[2] && !a[3] && !b[2] && !b[3]) continue; // never-rendered node: its rect is all zeros, ignore
|
|
80
|
+
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;
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
};
|
|
84
|
+
// the real painted extent of a block: its own box unioned with the text rects of the text leaves inside it
|
|
85
|
+
const inkBounds = (M) => {
|
|
86
|
+
const r = M.getBoundingClientRect();
|
|
87
|
+
let o = { left: r.left, top: r.top, right: r.right, bottom: r.bottom };
|
|
88
|
+
for (const t of leaves) {
|
|
89
|
+
if (!isText(t) || !M.contains(t)) continue;
|
|
90
|
+
const q = trOf(t);
|
|
91
|
+
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) };
|
|
92
|
+
}
|
|
93
|
+
return o;
|
|
94
|
+
};
|
|
95
|
+
// free travel for M in one direction inside `stop`: to the nearest blocking edge
|
|
96
|
+
const gapTo = (M, stop, dir) => {
|
|
97
|
+
const mr = M.getBoundingClientRect(), sr = stop.getBoundingClientRect(), cs = getComputedStyle(stop);
|
|
98
|
+
const pad = (k) => parseFloat(cs[k]) || 0;
|
|
99
|
+
let lim = dir === 'up' ? sr.top + pad('paddingTop') : dir === 'down' ? sr.bottom - pad('paddingBottom')
|
|
100
|
+
: dir === 'left' ? sr.left + pad('paddingLeft') : sr.right - pad('paddingRight');
|
|
101
|
+
for (const e of stop.querySelectorAll('*')) {
|
|
102
|
+
if (e === M || M.contains(e) || e.contains(M) || !vis(e)) continue;
|
|
103
|
+
const r = e.getBoundingClientRect();
|
|
104
|
+
if (dir === 'up' || dir === 'down') {
|
|
105
|
+
if (Math.min(mr.right, r.right) - Math.max(mr.left, r.left) <= 1) continue;
|
|
106
|
+
if (dir === 'up' && r.bottom <= mr.top + 0.5) lim = Math.max(lim, r.bottom);
|
|
107
|
+
if (dir === 'down' && r.top >= mr.bottom - 0.5) lim = Math.min(lim, r.top);
|
|
108
|
+
} else {
|
|
109
|
+
if (Math.min(mr.bottom, r.bottom) - Math.max(mr.top, r.top) <= 1) continue;
|
|
110
|
+
if (dir === 'left' && r.right <= mr.left + 0.5) lim = Math.max(lim, r.right);
|
|
111
|
+
if (dir === 'right' && r.left >= mr.right - 0.5) lim = Math.min(lim, r.left);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return dir === 'up' ? mr.top - lim : dir === 'down' ? lim - mr.bottom : dir === 'left' ? mr.left - lim : lim - mr.right;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const out = [];
|
|
118
|
+
const done = new Set();
|
|
119
|
+
const extendBySection = new Map();
|
|
120
|
+
// every declined finding is recorded on the page so a probe can read WHY (the driver ignores this).
|
|
121
|
+
const skip = (id, why, detail) => { (window.__polishSkips = window.__polishSkips || []).push(Object.assign({ rule: 'page-cut', id, why }, detail || {})); };
|
|
122
|
+
|
|
123
|
+
for (const f of metrics.overflow) {
|
|
124
|
+
if (f.kind !== 'cut' || f.px < P.minCut) continue;
|
|
125
|
+
const el = document.getElementById(f.id);
|
|
126
|
+
if (!el) continue;
|
|
127
|
+
const sec = secOf(el), sb = sec.getBoundingClientRect(), tr = trOf(el);
|
|
128
|
+
const over = { bottom: tr.bottom - sb.bottom, right: tr.right - sb.right, top: sb.top - tr.top, left: sb.left - tr.left };
|
|
129
|
+
const side = Object.keys(over).sort((a, b) => over[b] - over[a])[0];
|
|
130
|
+
if (over[side] < P.minCut) { skip(f.id, 'recomputes under the threshold (line-box overshoot, no ink outside)', { px: f.px, live: +over[side].toFixed(1), side }); continue; }
|
|
131
|
+
// the block that actually hangs out: outermost ancestor (still inside the section) over the same edge
|
|
132
|
+
let M = el, a = el.parentElement;
|
|
133
|
+
while (a && a !== sec) {
|
|
134
|
+
const r = a.getBoundingClientRect();
|
|
135
|
+
const o = side === 'bottom' ? r.bottom - sb.bottom : side === 'right' ? r.right - sb.right : side === 'top' ? sb.top - r.top : sb.left - r.left;
|
|
136
|
+
if (o >= 1.5) M = a;
|
|
137
|
+
a = a.parentElement;
|
|
138
|
+
}
|
|
139
|
+
if (!M.id) { skip(f.id, 'nothing addressable to move (no id on the overhanging block)', { side }); continue; }
|
|
140
|
+
if (done.has(M.id)) continue;
|
|
141
|
+
const shell = M.closest('[data-proto-shell]') || M.closest('footer, header, aside, nav');
|
|
142
|
+
const stop = shell && shell !== M ? shell : sec;
|
|
143
|
+
|
|
144
|
+
if (shell) {
|
|
145
|
+
// --- translate the stranded block back onto the canvas
|
|
146
|
+
const mr = inkBounds(M);
|
|
147
|
+
const overhang = side === 'bottom' ? mr.bottom - sb.bottom : side === 'right' ? mr.right - sb.right
|
|
148
|
+
: side === 'top' ? sb.top - mr.top : sb.left - mr.left;
|
|
149
|
+
if (overhang < 1.5 || overhang > P.maxLift) { skip(M.id, 'overhang out of range', { overhang: +overhang.toFixed(1), maxLift: P.maxLift }); continue; }
|
|
150
|
+
const dir = { bottom: 'up', right: 'left', top: 'down', left: 'right' }[side];
|
|
151
|
+
const room = gapTo(M, stop, dir);
|
|
152
|
+
// `room` is a cheap pre-filter; the real guards are the ink-collision count and the disturbance check
|
|
153
|
+
// below, so allow the block to close the last couple of px of a gap that is exactly its own overhang.
|
|
154
|
+
if (room < overhang - P.roomTolerance) { skip(M.id, 'no room to move it', { overhang: +overhang.toFixed(1), room: +room.toFixed(1), side, stop: stop.id }); continue; }
|
|
155
|
+
const pad = Math.min(P.pad, Math.max(0, room - overhang));
|
|
156
|
+
const move = overhang + pad; // always positive, along `dir`
|
|
157
|
+
const dx = dir === 'left' ? -move : dir === 'right' ? move : 0;
|
|
158
|
+
const dy = dir === 'up' ? -move : dir === 'down' ? move : 0;
|
|
159
|
+
const before = collisions(M);
|
|
160
|
+
const r = trial(M, { transform: `translate(${dx}px,${dy}px)` }, (m) => {
|
|
161
|
+
const q = inkBounds(m), live = sec.getBoundingClientRect(); // re-read: the page may have scrolled
|
|
162
|
+
const fixed = side === 'bottom' ? q.bottom <= live.bottom + 0.5 : side === 'right' ? q.right <= live.right + 0.5
|
|
163
|
+
: side === 'top' ? q.top >= live.top - 0.5 : q.left >= live.left - 0.5;
|
|
164
|
+
return { fixed, coll: collisions(m) };
|
|
165
|
+
});
|
|
166
|
+
r.disturbed = disturbedOutside(M, r.now, true);
|
|
167
|
+
if (!r.fixed || r.coll > before || r.disturbed) { skip(M.id, 'move would harm', { fixed: r.fixed, collisions: `${before}→${r.coll}`, disturbed: r.disturbed, move: +move.toFixed(1), dir }); continue; }
|
|
168
|
+
done.add(M.id);
|
|
169
|
+
out.push({
|
|
170
|
+
id: M.id, rule: 'page-cut', dx, dy,
|
|
171
|
+
reason: `${M === el ? 'text' : 'block'} ${overhang.toFixed(1)}px past section ${side} inside ${shell.id || shell.tagName.toLowerCase()} → move ${move.toFixed(1)}px ${dir} (room ${room.toFixed(0)}px)`,
|
|
172
|
+
});
|
|
173
|
+
} else if (side === 'bottom') {
|
|
174
|
+
// --- in-flow page: grow the canvas instead of moving the content
|
|
175
|
+
const cur = extendBySection.get(sec.id) || { sec, need: 0, ids: [] };
|
|
176
|
+
cur.need = Math.max(cur.need, over[side]);
|
|
177
|
+
cur.ids.push(f.id);
|
|
178
|
+
extendBySection.set(sec.id, cur);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
for (const [id, g] of extendBySection) {
|
|
183
|
+
if (!id || g.ids.length < P.minGroupForExtend) continue;
|
|
184
|
+
const sb = g.sec.getBoundingClientRect();
|
|
185
|
+
const h = Math.ceil((sb.height + g.need + P.pad) * 100) / 100;
|
|
186
|
+
const r = trial(g.sec, { height: h + 'px' }, (s) => ({ ok: s.getBoundingClientRect().height >= h - 0.5 }));
|
|
187
|
+
// everything inside the section must stay put; sections after it may only translate down by the delta
|
|
188
|
+
const delta = h - sb.height;
|
|
189
|
+
let bad = null;
|
|
190
|
+
for (let i = 0; i < probes.length; i++) {
|
|
191
|
+
const p = probes[i]; if (p === g.sec || g.sec.contains(p)) continue;
|
|
192
|
+
const a = base[i], b = r.now[i];
|
|
193
|
+
const dy = b[1] - a[1];
|
|
194
|
+
if (Math.abs(b[0] - a[0]) > 0.5 || Math.abs(b[2] - a[2]) > 0.5 || Math.abs(b[3] - a[3]) > 0.5) { bad = p.id; break; }
|
|
195
|
+
if (Math.abs(dy) > 0.5 && Math.abs(dy - delta) > 0.5) { bad = p.id; break; }
|
|
196
|
+
}
|
|
197
|
+
if (!r.ok || bad) { skip(id, 'canvas extend would reflow the page', { ok: r.ok, disturbed: bad, delta: +delta.toFixed(1) }); continue; }
|
|
198
|
+
out.push({ id, rule: 'page-cut', h, reason: `extend canvas +${delta.toFixed(1)}px so ${g.ids.length} cut item(s) fit (${g.ids[0]})` });
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return out;
|
|
202
|
+
};
|
|
203
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// photo-corners: a photo that sits flush in the corners of a rounded card paints square pixels over the card's radius.
|
|
2
|
+
// Mechanism (owner r4, Verdant Row product cards): every card declares the same border-radius, the photo child is
|
|
3
|
+
// placed flush at the card's top with radius 0 and the card does not clip — whether the corner LOOKS rounded then
|
|
4
|
+
// depends on pixels baked into each photo raster (4 of 6 rounded, 2 square: a conversion-side inconsistency, routed
|
|
5
|
+
// upstream). The HTML defect is the card whose declared radius its flush child ignores. Fix: give the photo the card's
|
|
6
|
+
// radius on exactly the corners it touches. No pixel is patched; nothing moves.
|
|
7
|
+
export const name = 'photo-corners';
|
|
8
|
+
export const maxShift = 0;
|
|
9
|
+
|
|
10
|
+
export function browserFn() {
|
|
11
|
+
return () => {
|
|
12
|
+
const out = [];
|
|
13
|
+
const seen = new Set();
|
|
14
|
+
const visible = (el) => { const s = getComputedStyle(el); if (s.display === 'none' || s.visibility === 'hidden') return false; const r = el.getBoundingClientRect(); return r.width > 0 && r.height > 0; };
|
|
15
|
+
for (const img of document.querySelectorAll('img, .image, [id$="-photo"]')) {
|
|
16
|
+
if (!img.id || seen.has(img.id) || !visible(img) || img.closest('#proto-overlay-root, .proto-holding-block')) continue;
|
|
17
|
+
const ir = img.getBoundingClientRect();
|
|
18
|
+
const ics = getComputedStyle(img);
|
|
19
|
+
if (parseFloat(ics.borderTopLeftRadius) > 1 || parseFloat(ics.borderTopRightRadius) > 1) continue; // already rounded
|
|
20
|
+
// nearest ancestor with a declared radius and no clip
|
|
21
|
+
let card = img.parentElement;
|
|
22
|
+
while (card && !(parseFloat(getComputedStyle(card).borderTopLeftRadius) >= 4)) card = card.parentElement;
|
|
23
|
+
if (!card || !card.id) continue;
|
|
24
|
+
const ccs = getComputedStyle(card);
|
|
25
|
+
if (ccs.overflow !== 'visible') continue; // the card clips already
|
|
26
|
+
const r = parseFloat(ccs.borderTopLeftRadius);
|
|
27
|
+
const cr = card.getBoundingClientRect();
|
|
28
|
+
const near = (a, b) => Math.abs(a - b) <= 1.5;
|
|
29
|
+
const tl = near(ir.left, cr.left) && near(ir.top, cr.top), tr = near(ir.right, cr.right) && near(ir.top, cr.top);
|
|
30
|
+
const bl = near(ir.left, cr.left) && near(ir.bottom, cr.bottom), br = near(ir.right, cr.right) && near(ir.bottom, cr.bottom);
|
|
31
|
+
if (!(tl || tr || bl || br)) continue; // an inset, framed photo: design
|
|
32
|
+
const radius = `${tl ? r : 0}px ${tr ? r : 0}px ${br ? r : 0}px ${bl ? r : 0}px`;
|
|
33
|
+
seen.add(img.id);
|
|
34
|
+
out.push({ id: img.id, rule: 'photo-corners', reason: `flush in ${[tl && 'TL', tr && 'TR', br && 'BR', bl && 'BL'].filter(Boolean).join('+')} of #${card.id} (radius ${r}px)`, css: { 'border-radius': radius } });
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
};
|
|
38
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// rhythm: make the whitespace inside a parent regular.
|
|
2
|
+
//
|
|
3
|
+
// Mechanism the population showed: a transplanted/assembled parent ends up with stacked same-kind children whose
|
|
4
|
+
// gaps differ by a hair — sidebar nav items at 40.47 / 41.97 px, alert rows at 29.7 / 25.6. Nothing is broken, the
|
|
5
|
+
// page just reads "hand-placed". measure.mjs flags every PAIR of gaps under one parent that differ by 0.75..6 px,
|
|
6
|
+
// so a parent with n gaps produces up to n*(n-1)/2 findings — the fix has to make ALL of a parent's gaps equal,
|
|
7
|
+
// not just the ones inside one contiguous run.
|
|
8
|
+
//
|
|
9
|
+
// Rule: per parent, collect the gaps between consecutive same-kind stacked (or side-by-side) children. If there are
|
|
10
|
+
// ≥2 gaps and every gap is within ±6 px of the parent's median gap, snap them all to that median: each contiguous
|
|
11
|
+
// run gets cumulative dy (dx) translates, the run's first child never moves, and a run is skipped if its last child
|
|
12
|
+
// would move more than 6 px or would leave the parent's content box. A gap further than +6 px from the median is a
|
|
13
|
+
// deliberate step (a section break), and its presence makes the whole parent ineligible — we never flatten design.
|
|
14
|
+
//
|
|
15
|
+
// Plus card grids: a run of ≥3 same-kind sibling BOXES whose widths (vertical run) or heights (horizontal run)
|
|
16
|
+
// differ by ≤4 px is snapped to the widest/tallest of the cluster. Growing, never shrinking: shrinking a card can
|
|
17
|
+
// re-wrap its text and invent new spill, growing cannot. The axis is chosen so the size change cannot disturb the
|
|
18
|
+
// flow of the siblings (width in a column, height in a row), and the grown strip must be empty.
|
|
19
|
+
export const name = 'rhythm';
|
|
20
|
+
export const maxShift = 6;
|
|
21
|
+
|
|
22
|
+
export function browserFn() {
|
|
23
|
+
return () => {
|
|
24
|
+
const NEAR = 6, EXACT = 0.75, CARD_SPREAD = 4;
|
|
25
|
+
const out = [];
|
|
26
|
+
const cs = (el) => getComputedStyle(el);
|
|
27
|
+
const visible = (el) => {
|
|
28
|
+
const s = cs(el);
|
|
29
|
+
if (s.display === 'none' || s.visibility === 'hidden' || parseFloat(s.opacity) === 0) return false;
|
|
30
|
+
const r = el.getBoundingClientRect();
|
|
31
|
+
return r.width > 0 && r.height > 0;
|
|
32
|
+
};
|
|
33
|
+
const isText = (el) => el.classList.contains('text') || /^H[1-6]$/.test(el.tagName);
|
|
34
|
+
const kindOf = (el) => isText(el) ? 'text'
|
|
35
|
+
: el.classList.contains('icon') ? 'icon'
|
|
36
|
+
: (el.tagName === 'IMG' || el.classList.contains('image')) ? 'image'
|
|
37
|
+
: el.classList.contains('badge') ? 'badge'
|
|
38
|
+
: el.classList.contains('divider') ? 'divider' : 'box';
|
|
39
|
+
// measure.mjs's notion of "same kind": same kind tag, and for text the same font size + line height,
|
|
40
|
+
// for boxes a matching width or height, otherwise a matching height.
|
|
41
|
+
const sameKind = (A, B) => {
|
|
42
|
+
const ka = kindOf(A.el), kb = kindOf(B.el);
|
|
43
|
+
if (ka !== kb) return false;
|
|
44
|
+
if (ka === 'text') return Math.abs(parseFloat(cs(A.el).fontSize) - parseFloat(cs(B.el).fontSize)) < 1 && Math.abs(A.r.height - B.r.height) < 3;
|
|
45
|
+
if (ka === 'box') return Math.abs(A.r.height - B.r.height) <= 4 || Math.abs(A.r.width - B.r.width) <= 4;
|
|
46
|
+
return Math.abs(A.r.height - B.r.height) < 3;
|
|
47
|
+
};
|
|
48
|
+
const LEAF = '.text, h1, h2, h3, h4, h5, h6, .icon, img, .image, .badge, .divider';
|
|
49
|
+
const CONTAINER = 'div[id], button[id], aside[id], header[id], footer[id], nav[id], article[id], form[id], main[id]';
|
|
50
|
+
const candidate = (el) => el.id && !el.classList.contains('decoration') && !el.closest('#proto-overlay-root')
|
|
51
|
+
&& ((el.matches(LEAF) && !el.querySelector('.text')) || el.matches(CONTAINER)) && visible(el);
|
|
52
|
+
const median = (xs) => [...xs].sort((a, b) => a - b)[Math.floor((xs.length - 1) / 2)];
|
|
53
|
+
const contentBox = (p) => {
|
|
54
|
+
const r = p.getBoundingClientRect(), s = cs(p);
|
|
55
|
+
return {
|
|
56
|
+
left: r.left + parseFloat(s.paddingLeft) + parseFloat(s.borderLeftWidth),
|
|
57
|
+
right: r.right - parseFloat(s.paddingRight) - parseFloat(s.borderRightWidth),
|
|
58
|
+
top: r.top + parseFloat(s.paddingTop) + parseFloat(s.borderTopWidth),
|
|
59
|
+
bottom: r.bottom - parseFloat(s.paddingBottom) - parseFloat(s.borderBottomWidth),
|
|
60
|
+
};
|
|
61
|
+
};
|
|
62
|
+
const allBoxes = [...document.querySelectorAll('*')].filter((el) => !el.closest('#proto-overlay-root') && visible(el)).map((el) => ({ el, r: el.getBoundingClientRect() }));
|
|
63
|
+
|
|
64
|
+
const parents = new Set();
|
|
65
|
+
for (const el of document.querySelectorAll(`${LEAF}, ${CONTAINER}`)) if (candidate(el) && el.parentElement) parents.add(el.parentElement);
|
|
66
|
+
|
|
67
|
+
for (const p of parents) {
|
|
68
|
+
const kids = [...p.children].filter(candidate).map((el) => ({ el, r: el.getBoundingClientRect() }));
|
|
69
|
+
if (kids.length < 3) continue;
|
|
70
|
+
for (const axis of ['y', 'x']) {
|
|
71
|
+
const ks = [...kids].sort((a, b) => axis === 'y' ? a.r.top - b.r.top : a.r.left - b.r.left);
|
|
72
|
+
// gaps between consecutive same-kind children that really are stacked / side by side (they must overlap
|
|
73
|
+
// on the cross axis, or they are not part of one column/row).
|
|
74
|
+
const gaps = [];
|
|
75
|
+
for (let i = 1; i < ks.length; i++) {
|
|
76
|
+
const prev = ks[i - 1], cur = ks[i];
|
|
77
|
+
if (!sameKind(prev, cur)) { gaps.push(null); continue; }
|
|
78
|
+
const inLine = axis === 'y'
|
|
79
|
+
? prev.r.bottom <= cur.r.top + 1 && Math.min(prev.r.right, cur.r.right) - Math.max(prev.r.left, cur.r.left) > 0
|
|
80
|
+
: prev.r.right <= cur.r.left + 1 && Math.min(prev.r.bottom, cur.r.bottom) - Math.max(prev.r.top, cur.r.top) > 0;
|
|
81
|
+
gaps.push(inLine ? (axis === 'y' ? cur.r.top - prev.r.bottom : cur.r.left - prev.r.right) : null);
|
|
82
|
+
}
|
|
83
|
+
const real = gaps.filter((g) => g != null);
|
|
84
|
+
if (real.length < 2) continue;
|
|
85
|
+
const target = median(real);
|
|
86
|
+
// a gap further than NEAR from the median is a deliberate step (a section break) → never flatten it;
|
|
87
|
+
// a parent whose gaps are already equal has nothing to snap. Card equalisation is independent of both.
|
|
88
|
+
const snappable = !real.some((g) => Math.abs(g - target) > NEAR) && real.some((g) => Math.abs(g - target) > EXACT);
|
|
89
|
+
|
|
90
|
+
// contiguous runs of linked children; first child of each run is the anchor and never moves
|
|
91
|
+
let i = 0;
|
|
92
|
+
while (i < gaps.length) {
|
|
93
|
+
if (gaps[i] == null) { i++; continue; }
|
|
94
|
+
let j = i; while (j < gaps.length && gaps[j] != null) j++; // run covers ks[i .. j]
|
|
95
|
+
const moves = [];
|
|
96
|
+
let acc = 0;
|
|
97
|
+
for (let k = i; k < j; k++) { acc += target - gaps[k]; moves.push({ node: ks[k + 1], d: acc }); }
|
|
98
|
+
const maxMove = Math.max(...moves.map((m) => Math.abs(m.d)));
|
|
99
|
+
const box = contentBox(p);
|
|
100
|
+
const last = moves[moves.length - 1];
|
|
101
|
+
const lastEdge = axis === 'y' ? last.node.r.bottom + last.d : last.node.r.right + last.d;
|
|
102
|
+
const limit = axis === 'y' ? box.bottom : box.right;
|
|
103
|
+
if (snappable && maxMove <= NEAR && lastEdge <= limit + 0.5) {
|
|
104
|
+
for (const m of moves) {
|
|
105
|
+
if (Math.abs(m.d) < 0.25) continue;
|
|
106
|
+
out.push({
|
|
107
|
+
id: m.node.el.id, rule: 'rhythm',
|
|
108
|
+
reason: `${axis === 'y' ? 'gap' : 'hgap'} →${target.toFixed(2)} in #${p.id || p.tagName.toLowerCase()}`,
|
|
109
|
+
dx: axis === 'x' ? +m.d.toFixed(2) : 0, dy: axis === 'y' ? +m.d.toFixed(2) : 0,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
// card grid: equalise the cross-axis size of a run of ≥3 same-kind boxes (grow only)
|
|
114
|
+
const run = ks.slice(i, j + 1);
|
|
115
|
+
// measure.mjs compares edges between ANY two same-kind children of a parent, not just the ones in one
|
|
116
|
+
// run. Equalising part of a row therefore invents a new edge near-miss against a same-kind child the run
|
|
117
|
+
// left out (review-invoice: a narrow "More" tab is same-kind-by-width with "Home" but same-kind-by-height
|
|
118
|
+
// with nothing). So: equalise the whole family or none of it.
|
|
119
|
+
const outsiders = kids.filter((k) => !run.includes(k) && run.some((n) => sameKind(n, k)));
|
|
120
|
+
if (run.length >= 3 && !outsiders.length && run.every((n) => kindOf(n.el) === 'box')) {
|
|
121
|
+
const prop = axis === 'y' ? 'width' : 'height';
|
|
122
|
+
const sizes = run.map((n) => n.r[prop]);
|
|
123
|
+
const lo = Math.min(...sizes), hi = Math.max(...sizes);
|
|
124
|
+
if (hi - lo > EXACT && hi - lo <= CARD_SPREAD) {
|
|
125
|
+
for (const n of run) {
|
|
126
|
+
const grow = hi - n.r[prop];
|
|
127
|
+
if (grow < 0.25) continue;
|
|
128
|
+
// the grown strip must be empty and must stay inside the parent's content box
|
|
129
|
+
const box2 = contentBox(p);
|
|
130
|
+
const strip = axis === 'y'
|
|
131
|
+
? { left: n.r.right, right: n.r.right + grow, top: n.r.top, bottom: n.r.bottom }
|
|
132
|
+
: { left: n.r.left, right: n.r.right, top: n.r.bottom, bottom: n.r.bottom + grow };
|
|
133
|
+
if (axis === 'y' ? strip.right > box2.right + 0.5 : strip.bottom > box2.bottom + 0.5) continue;
|
|
134
|
+
const blocked = allBoxes.some(({ el, r }) => el !== n.el && !n.el.contains(el) && !el.contains(n.el)
|
|
135
|
+
&& Math.min(r.right, strip.right) - Math.max(r.left, strip.left) > 0.5
|
|
136
|
+
&& Math.min(r.bottom, strip.bottom) - Math.max(r.top, strip.top) > 0.5);
|
|
137
|
+
if (blocked) continue;
|
|
138
|
+
out.push({ id: n.el.id, rule: 'rhythm', reason: `card ${prop} ${n.r[prop].toFixed(2)}→${hi.toFixed(2)}`, [axis === 'y' ? 'w' : 'h']: +hi.toFixed(2) });
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
i = j + 1;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return out;
|
|
147
|
+
};
|
|
148
|
+
}
|