bigpowers 2.4.0 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/.pi/package.json +2 -2
  2. package/.pi/prompts/align-grid.md +102 -0
  3. package/.pi/prompts/compose-workflow.md +1 -1
  4. package/.pi/prompts/diagnose-root.md +1 -1
  5. package/.pi/prompts/evolve-skill.md +1 -1
  6. package/.pi/prompts/grill-with-docs.md +1 -1
  7. package/.pi/prompts/research-first.md +1 -1
  8. package/.pi/prompts/reset-baseline.md +1 -1
  9. package/.pi/prompts/run-evals.md +1 -1
  10. package/.pi/prompts/scope-work.md +1 -1
  11. package/.pi/prompts/search-skills.md +1 -1
  12. package/.pi/prompts/setup-environment.md +1 -1
  13. package/.pi/prompts/simulate-agents.md +1 -1
  14. package/.pi/prompts/slice-tasks.md +1 -1
  15. package/.pi/prompts/stocktake-skills.md +1 -1
  16. package/.pi/prompts/verify-work.md +1 -1
  17. package/.pi/skills/align-grid/SKILL.md +104 -0
  18. package/.pi/skills/assess-impact/SKILL.md +2 -1
  19. package/.pi/skills/audit-code/SKILL.md +1 -0
  20. package/.pi/skills/build-epic/SKILL.md +1 -0
  21. package/.pi/skills/change-request/SKILL.md +1 -0
  22. package/.pi/skills/commit-message/SKILL.md +1 -0
  23. package/.pi/skills/compose-workflow/SKILL.md +2 -1
  24. package/.pi/skills/craft-skill/SKILL.md +1 -0
  25. package/.pi/skills/deepen-architecture/SKILL.md +1 -0
  26. package/.pi/skills/define-language/SKILL.md +2 -1
  27. package/.pi/skills/define-success/SKILL.md +2 -1
  28. package/.pi/skills/delegate-task/SKILL.md +1 -0
  29. package/.pi/skills/design-interface/SKILL.md +2 -1
  30. package/.pi/skills/develop-tdd/SKILL.md +1 -0
  31. package/.pi/skills/diagnose-root/SKILL.md +2 -1
  32. package/.pi/skills/dispatch-agents/SKILL.md +1 -0
  33. package/.pi/skills/edit-document/SKILL.md +1 -0
  34. package/.pi/skills/elaborate-spec/SKILL.md +2 -1
  35. package/.pi/skills/enforce-first/SKILL.md +2 -1
  36. package/.pi/skills/evolve-skill/SKILL.md +2 -1
  37. package/.pi/skills/execute-plan/SKILL.md +1 -0
  38. package/.pi/skills/fix-bug/SKILL.md +1 -0
  39. package/.pi/skills/grill-me/SKILL.md +2 -1
  40. package/.pi/skills/grill-with-docs/SKILL.md +2 -1
  41. package/.pi/skills/guard-git/SKILL.md +1 -0
  42. package/.pi/skills/hook-commits/SKILL.md +1 -0
  43. package/.pi/skills/inspect-quality/SKILL.md +2 -1
  44. package/.pi/skills/investigate-bug/SKILL.md +2 -1
  45. package/.pi/skills/kickoff-branch/SKILL.md +2 -1
  46. package/.pi/skills/map-codebase/SKILL.md +2 -1
  47. package/.pi/skills/migrate-spec/SKILL.md +1 -0
  48. package/.pi/skills/model-domain/SKILL.md +1 -0
  49. package/.pi/skills/orchestrate-project/SKILL.md +1 -0
  50. package/.pi/skills/organize-workspace/SKILL.md +2 -1
  51. package/.pi/skills/plan-refactor/SKILL.md +1 -0
  52. package/.pi/skills/plan-release/SKILL.md +2 -1
  53. package/.pi/skills/plan-work/SKILL.md +2 -1
  54. package/.pi/skills/release-branch/SKILL.md +2 -1
  55. package/.pi/skills/request-review/SKILL.md +1 -0
  56. package/.pi/skills/research-first/SKILL.md +2 -1
  57. package/.pi/skills/reset-baseline/SKILL.md +2 -1
  58. package/.pi/skills/respond-review/SKILL.md +1 -0
  59. package/.pi/skills/run-evals/SKILL.md +2 -1
  60. package/.pi/skills/run-planning/SKILL.md +2 -1
  61. package/.pi/skills/scope-work/SKILL.md +2 -1
  62. package/.pi/skills/search-skills/SKILL.md +2 -1
  63. package/.pi/skills/seed-conventions/SKILL.md +1 -0
  64. package/.pi/skills/session-state/SKILL.md +1 -0
  65. package/.pi/skills/setup-environment/SKILL.md +2 -1
  66. package/.pi/skills/simulate-agents/SKILL.md +2 -1
  67. package/.pi/skills/slice-tasks/SKILL.md +2 -1
  68. package/.pi/skills/spike-prototype/SKILL.md +2 -1
  69. package/.pi/skills/stocktake-skills/SKILL.md +2 -1
  70. package/.pi/skills/survey-context/SKILL.md +1 -0
  71. package/.pi/skills/terse-mode/SKILL.md +2 -1
  72. package/.pi/skills/trace-requirement/SKILL.md +2 -1
  73. package/.pi/skills/using-bigpowers/SKILL.md +2 -1
  74. package/.pi/skills/validate-fix/SKILL.md +2 -1
  75. package/.pi/skills/verify-work/SKILL.md +2 -1
  76. package/.pi/skills/visual-dashboard/SKILL.md +1 -0
  77. package/.pi/skills/wire-observability/SKILL.md +1 -0
  78. package/.pi/skills/write-document/SKILL.md +1 -0
  79. package/CHANGELOG.md +14 -0
  80. package/SKILL-INDEX.md +34 -33
  81. package/align-grid/SKILL.md +108 -0
  82. package/align-grid/scripts/grid_tokens.py +201 -0
  83. package/align-grid/scripts/verify_grid.js +140 -0
  84. package/dashboard/src/web/client.html +191 -249
  85. package/package.json +1 -1
  86. package/scripts/generate-reference-tables.sh +1 -1
  87. package/scripts/sync-skills.sh +22 -10
  88. package/scripts/validate-skill-yaml.py +73 -0
  89. package/visual-dashboard/scripts/cockpit.html +123 -16
  90. package/visual-dashboard/scripts/frame-template.html +181 -45
  91. package/countable-story-format.md +0 -293
@@ -0,0 +1,201 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ grid_tokens.py — Müller-Brockmann editorial grid scaffold generator.
4
+
5
+ Emits a battle-tested, self-contained CSS + JS scaffold for building an
6
+ editorial/magazine webpage on a REAL, VISIBLE, VERIFIED modular grid:
7
+
8
+ • ONE source of truth: all grid params live in :root CSS variables.
9
+ • The grid-toggle OVERLAY reads the SAME variables and lives in the SAME
10
+ content box as the content, so its columns ARE the content columns
11
+ (this is the fix for the "grid is just slapped on top / misaligned" bug
12
+ that happens when the overlay is a full-width sibling of a centered
13
+ max-width container).
14
+ • Subgrid "bands" so every element is placed by column LINE, not eyeballed.
15
+ • Vertical rhythm locked to an 8px baseline (24px leading).
16
+ • Runtime OPTICAL ALIGNMENT: display type is nudged so its INK (not its box)
17
+ lands on the column line — large letterforms carry a left side-bearing, so
18
+ a headline whose box is on the grid still looks misaligned vs body text.
19
+
20
+ No network, no credentials. Deterministic.
21
+
22
+ Usage:
23
+ python3 grid_tokens.py # print CSS + JS block
24
+ python3 grid_tokens.py --scaffold # print a full minimal HTML page
25
+ python3 grid_tokens.py --cols 12 --baseline 8 --gutter 24 --margin 72 \
26
+ --maxw 1296 --accent "#e4002b"
27
+ """
28
+ import argparse, sys
29
+
30
+ def build(cfg):
31
+ c = cfg
32
+ lh = c.baseline * 3 # leading = 3 baselines
33
+ css = f""":root{{
34
+ --cols:{c.cols};
35
+ --bl:{c.baseline}px; /* baseline unit */
36
+ --lh:{lh}px; /* leading = 3 x baseline */
37
+ --gutter:{c.gutter}px;
38
+ --margin:{c.margin}px;
39
+ --pad:{c.baseline*12}px; /* spread top/bottom pad (x baseline) */
40
+ --maxw:{c.maxw}px;
41
+
42
+ --paper:#ffffff;
43
+ --ink:#111315;
44
+ --ink-soft:#5b6066;
45
+ --accent:{c.accent};
46
+
47
+ --g-col:rgba(228,0,43,.075); /* column field fill (re-tint to taste) */
48
+ --g-edge:rgba(228,0,43,.40); /* column edge / margin line */
49
+ --g-base:rgba(0,150,140,.34); /* major baseline line ({lh}px) */
50
+ --g-base-min:rgba(0,150,140,.12);/* minor baseline line ({c.baseline}px) */
51
+ }}
52
+ *{{box-sizing:border-box;}}
53
+ body{{margin:0;background:var(--paper);color:var(--ink);
54
+ font-family:"Inter",system-ui,sans-serif;font-size:16px;line-height:var(--lh);
55
+ -webkit-font-smoothing:antialiased;}}
56
+ img{{display:block;width:100%;height:100%;object-fit:cover;}}
57
+
58
+ /* ---- spread + grid scaffold (ONE source of truth) ---- */
59
+ .spread{{position:relative;width:100%;}}
60
+ .wrap{{position:relative;max-width:var(--maxw);margin:0 auto;padding:var(--pad) var(--margin);}}
61
+ .grid{{display:grid;grid-template-columns:repeat(var(--cols),1fr);
62
+ column-gap:var(--gutter);row-gap:var(--lh);}}
63
+ /* a band spans all columns and re-exposes them as a subgrid so children
64
+ align to the SAME lines as everything else on the page */
65
+ .band{{grid-column:1 / -1;display:grid;grid-template-columns:subgrid;
66
+ column-gap:var(--gutter);row-gap:var(--lh);align-items:start;}}
67
+ @supports not (grid-template-columns:subgrid){{
68
+ .band{{grid-template-columns:repeat(var(--cols),1fr);}}
69
+ }}
70
+ /* place children with: style="grid-column: <startline> / <endline>" */
71
+
72
+ /* ---- the grid OVERLAY (same content box -> columns match exactly) ---- */
73
+ .guides{{position:absolute;inset:0;pointer-events:none;z-index:60;opacity:0;
74
+ transition:opacity .26s ease;}}
75
+ body.grid-on .guides{{opacity:1;}}
76
+ .guides .cols{{position:absolute;top:0;bottom:0;left:var(--margin);right:var(--margin);
77
+ display:grid;grid-template-columns:repeat(var(--cols),1fr);column-gap:var(--gutter);}}
78
+ .guides .col{{background:var(--g-col);
79
+ box-shadow:inset 1px 0 0 var(--g-edge),inset -1px 0 0 var(--g-edge);position:relative;}}
80
+ .guides .col span{{position:absolute;top:{c.baseline*4}px;left:0;right:0;text-align:center;
81
+ font-family:"Space Mono",monospace;font-size:10px;line-height:1;color:var(--accent);}}
82
+ .guides .rows{{position:absolute;left:var(--margin);right:var(--margin);top:var(--pad);bottom:0;
83
+ background-image:
84
+ repeating-linear-gradient(to bottom,var(--g-base) 0 1px,transparent 1px var(--lh)),
85
+ repeating-linear-gradient(to bottom,var(--g-base-min) 0 1px,transparent 1px var(--bl));}}
86
+ .guides .mline{{position:absolute;top:0;bottom:0;width:1px;background:var(--g-edge);}}
87
+ .guides .mline.l{{left:var(--margin);}} .guides .mline.r{{right:var(--margin);}}
88
+
89
+ /* ---- vertical rhythm helpers (keep ALL spacing a multiple of --bl) ----
90
+ line-heights for display type MUST be px multiples of --bl, never unitless,
91
+ or the box height drifts off the baseline. Media heights = multiples of --lh
92
+ so photo top AND bottom land on lines. */
93
+ .toggle{{position:fixed;top:18px;right:18px;z-index:200;display:flex;align-items:center;gap:10px;
94
+ background:var(--ink);color:#fff;border:none;cursor:pointer;font-family:"Space Mono",monospace;
95
+ font-size:12px;letter-spacing:.14em;text-transform:uppercase;padding:11px 14px;}}
96
+ .toggle .dot{{width:9px;height:9px;border-radius:50%;background:#555;}}
97
+ body.grid-on .toggle{{background:var(--accent);}} body.grid-on .toggle .dot{{background:#fff;}}"""
98
+
99
+ js = """/* toggle: button + 'G' key */
100
+ var btn=document.getElementById('gridToggle');
101
+ function setGrid(on){document.body.classList.toggle('grid-on',on);
102
+ if(btn){btn.setAttribute('aria-pressed',on?'true':'false');
103
+ var l=btn.querySelector('.lbl'); if(l) l.textContent=on?'Hide grid':'Show grid';}}
104
+ if(btn) btn.addEventListener('click',function(){setGrid(!document.body.classList.contains('grid-on'));});
105
+ document.addEventListener('keydown',function(e){
106
+ if((e.key==='g'||e.key==='G')&&!e.metaKey&&!e.ctrlKey&&!e.altKey){
107
+ setGrid(!document.body.classList.contains('grid-on'));}});
108
+
109
+ /* populate every overlay's column guides (numbered) */
110
+ document.querySelectorAll('.guides .cols').forEach(function(h){
111
+ var n=getComputedStyle(document.documentElement).getPropertyValue('--cols').trim()||'12';
112
+ for(var i=1;i<=parseInt(n,10);i++){var c=document.createElement('div');c.className='col';
113
+ var s=document.createElement('span');s.textContent=i;c.appendChild(s);h.appendChild(c);}});
114
+
115
+ /* ---- OPTICAL ALIGNMENT --------------------------------------------------
116
+ Large display glyphs carry a left side-bearing: the ink sits inside the
117
+ layout box, so a headline whose BOX is on the column line still LOOKS
118
+ indented (or overhangs) vs body text. Measure each display glyph's actual
119
+ ink offset and nudge the element so its visible ink lands on the line.
120
+ Scales with fluid type; re-runs after the webfont loads and on resize.
121
+ Add the selector list to match your display elements. */
122
+ (function(){
123
+ var cvs=document.createElement('canvas'),ctx=cvs.getContext('2d');
124
+ var sel='.masthead, .numeral, .shead h2, .h2b'; /* <-- your display selectors */
125
+ function align(){
126
+ document.querySelectorAll(sel).forEach(function(el){
127
+ el.style.marginLeft='0px';
128
+ var cs=getComputedStyle(el),ch=(el.textContent||'').trim().charAt(0); if(!ch) return;
129
+ if(cs.textTransform==='uppercase') ch=ch.toUpperCase();
130
+ ctx.font=cs.fontStyle+' '+cs.fontWeight+' '+cs.fontSize+' '+cs.fontFamily;
131
+ ctx.textAlign='left';
132
+ var abl=ctx.measureText(ch).actualBoundingBoxLeft; /* +ve = ink overhangs left */
133
+ if(isFinite(abl)) el.style.marginLeft=abl.toFixed(2)+'px'; /* ink -> on the line */
134
+ });
135
+ }
136
+ if(document.fonts&&document.fonts.ready){document.fonts.ready.then(align);}
137
+ align();
138
+ var t;window.addEventListener('resize',function(){clearTimeout(t);t=setTimeout(align,120);});
139
+ })();"""
140
+
141
+ band = """ <!-- a band: children placed by column LINE -->
142
+ <div class="band">
143
+ <div style="grid-column:1 / 6;"><!-- text col --></div>
144
+ <figure style="grid-column:6 / 13;"><!-- image col (height = x --lh) --></figure>
145
+ </div>"""
146
+
147
+ overlay = """ <div class="guides" aria-hidden="true">
148
+ <div class="cols"></div><div class="rows"></div>
149
+ <div class="mline l"></div><div class="mline r"></div>
150
+ </div>"""
151
+
152
+ if cfg.scaffold:
153
+ return f"""<!DOCTYPE html>
154
+ <html lang="en"><head><meta charset="UTF-8">
155
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
156
+ <title>Editorial — modular grid</title>
157
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
158
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet">
159
+ <style>
160
+ {css}
161
+ </style></head>
162
+ <body>
163
+ <button class="toggle" id="gridToggle" aria-pressed="false"><span class="dot"></span><span class="lbl">Show grid</span></button>
164
+
165
+ <section class="spread">
166
+ <div class="wrap">
167
+ <div class="grid">
168
+ {band}
169
+ </div>
170
+ {overlay}
171
+ </div>
172
+ </section>
173
+
174
+ <script>
175
+ {js}
176
+ </script>
177
+ </body></html>"""
178
+ else:
179
+ return ("/* ===== CSS (paste in <style>) ===== */\n" + css +
180
+ "\n\n/* ===== JS (paste in <script>, after the DOM) ===== */\n" + js +
181
+ "\n\n/* ===== band markup pattern ===== */\n" + band +
182
+ "\n\n/* ===== per-spread overlay markup ===== */\n" + overlay + "\n")
183
+
184
+ def main():
185
+ ap = argparse.ArgumentParser(description="Müller-Brockmann editorial grid scaffold generator")
186
+ ap.add_argument("--cols", type=int, default=12)
187
+ ap.add_argument("--baseline", type=int, default=8, help="baseline unit in px (leading = 3x)")
188
+ ap.add_argument("--gutter", type=int, default=24)
189
+ ap.add_argument("--margin", type=int, default=72)
190
+ ap.add_argument("--maxw", type=int, default=1296)
191
+ ap.add_argument("--accent", default="#e4002b")
192
+ ap.add_argument("--scaffold", action="store_true", help="emit a full minimal HTML page")
193
+ cfg = ap.parse_args()
194
+ for name, v in (("gutter", cfg.gutter), ("margin", cfg.margin)):
195
+ if v % cfg.baseline != 0:
196
+ print(f"# WARNING: --{name} ({v}) is not a multiple of --baseline ({cfg.baseline}); "
197
+ f"vertical/spacing rhythm will drift off the grid.", file=sys.stderr)
198
+ sys.stdout.write(build(cfg) + "\n")
199
+
200
+ if __name__ == "__main__":
201
+ main()
@@ -0,0 +1,140 @@
1
+ #!/usr/bin/env node
2
+ /*
3
+ * verify_grid.js — prove an editorial page actually sits on its grid.
4
+ *
5
+ * Renders the page with headless Chrome (Puppeteer) and asserts, at several
6
+ * viewport widths (including > and < the content max-width, to catch the
7
+ * centered-container drift):
8
+ *
9
+ * 1. COLUMN ADHERENCE — every placed `.band > *` element's left edge snaps
10
+ * to a column START line and its right edge to a column END line (~0px).
11
+ * NB: build BOTH the start-set and end-set of x-coords. A grid item that
12
+ * spans "to line N" ends at the FAR side of the gutter, so naive single
13
+ * edge math falsely reports a one-gutter (gutter-px) error.
14
+ * 2. OVERLAY MATCH — each `.guides .col` rect equals the computed column
15
+ * rect (~0px), i.e. the overlay really is the content grid.
16
+ * 3. BASELINE — text element tops, modulo the baseline unit, ~0.
17
+ * 4. OPTICAL INK — display elements' visible INK-left equals the column
18
+ * line (measure canvas actualBoundingBoxLeft with the LOADED font).
19
+ * CAVEAT: side-bearing is font-specific. In a sandbox the webfont is often
20
+ * absent and canvas falls back to a different grotesque (we measured -16px
21
+ * fallback vs -7px real Inter). To verify optics offline, EMBED the real
22
+ * webfont via @font-face(local TTF). In production the page's runtime JS
23
+ * measures the actually-loaded font, so it is correct for the user.
24
+ *
25
+ * Env / args:
26
+ * CHROME = path to chrome binary (required)
27
+ * PUP = path to puppeteer-core module (required)
28
+ * arg1 = file:// URL or http URL of the page (default: file://$PWD/index.html)
29
+ * --widths=1440,1180,900 --baseline=8
30
+ *
31
+ * Sandbox chrome flags that work here:
32
+ * --no-sandbox --disable-gpu --disable-dbus --use-gl=angle --use-angle=swiftshader
33
+ * (file:// works for non-ES-module pages; CLI --screenshot can hang on tall
34
+ * pages, so we drive via Puppeteer and screenshot per-viewport.)
35
+ */
36
+ const puppeteer = require(process.env.PUP || 'puppeteer-core');
37
+ const path = require('path');
38
+
39
+ const args = process.argv.slice(2);
40
+ const url = (args.find(a => !a.startsWith('--'))) ||
41
+ ('file://' + path.join(process.cwd(), 'index.html'));
42
+ const opt = k => { const a = args.find(x => x.startsWith('--' + k + '=')); return a ? a.split('=')[1] : null; };
43
+ const widths = (opt('widths') || '1440,1180,900').split(',').map(Number);
44
+ const BL = Number(opt('baseline') || 8);
45
+
46
+ (async () => {
47
+ const browser = await puppeteer.launch({
48
+ executablePath: process.env.CHROME, headless: 'new',
49
+ args: ['--no-sandbox','--disable-gpu','--disable-dbus','--use-gl=angle','--use-angle=swiftshader','--hide-scrollbars']
50
+ });
51
+ const page = await browser.newPage();
52
+ let failed = false;
53
+
54
+ for (const W of widths) {
55
+ await page.setViewport({ width: W, height: 1000, deviceScaleFactor: 1 });
56
+ await page.goto(url, { waitUntil: 'load', timeout: 25000 });
57
+ try { await page.evaluate(() => document.fonts && document.fonts.ready); } catch (e) {}
58
+ await new Promise(r => setTimeout(r, 500));
59
+
60
+ const res = await page.evaluate((BL) => {
61
+ const OPT = '.opt-align'; // display elements: optically aligned by INK, not box
62
+ const grid = document.querySelector('.grid');
63
+ const cs = getComputedStyle(grid);
64
+ const tracks = cs.gridTemplateColumns.split(' ').map(parseFloat);
65
+ const gap = parseFloat(cs.columnGap);
66
+ const gr = grid.getBoundingClientRect();
67
+ // build column START (L) and END (R) coordinate sets
68
+ const L = [], R = []; let x = gr.left;
69
+ for (let i = 0; i < tracks.length; i++) { L.push(x); x += tracks[i]; R.push(x); if (i < tracks.length - 1) x += gap; }
70
+ const nr = (v, arr) => arr.reduce((m, e) => Math.min(m, Math.abs(e - v)), 1e9);
71
+ const nearest = (v, arr) => arr.reduce((b, e) => Math.abs(e - v) < Math.abs(b - v) ? e : b, arr[0]);
72
+
73
+ // 1. column adherence — exclude optical display elements (their box is
74
+ // deliberately offset by the glyph side-bearing so the INK lands on
75
+ // the line; they are validated by check 4 instead).
76
+ let colErr = 0, worst = null;
77
+ document.querySelectorAll('.band > *').forEach(el => {
78
+ if (el.matches(OPT)) return;
79
+ const r = el.getBoundingClientRect(); if (r.width < 2) return;
80
+ const e = Math.max(nr(r.left, L), nr(r.right, R));
81
+ if (e > colErr) { colErr = e; worst = (el.className || el.tagName).toString().slice(0, 28); }
82
+ });
83
+
84
+ // 2. overlay match
85
+ let ovErr = 0;
86
+ document.querySelectorAll('.guides .cols .col').forEach((c, i) => {
87
+ const r = c.getBoundingClientRect();
88
+ if (L[i] != null) ovErr = Math.max(ovErr, Math.abs(r.left - L[i]));
89
+ if (R[i] != null) ovErr = Math.max(ovErr, Math.abs(r.right - R[i]));
90
+ });
91
+
92
+ // 3. baseline (tops modulo BL, per spread relative to its rows-top)
93
+ let baseErr = 0;
94
+ document.querySelectorAll('.spread').forEach(sp => {
95
+ const rowsEl = sp.querySelector('.guides .rows'); if (!rowsEl) return;
96
+ const top = rowsEl.getBoundingClientRect().top;
97
+ sp.querySelectorAll('.body,.lede,.cap,.toc li,.dishes li,.kicker').forEach(el => {
98
+ const t = el.getBoundingClientRect().top - top; const m = ((t % BL) + BL) % BL;
99
+ baseErr = Math.max(baseErr, Math.min(m, BL - m));
100
+ });
101
+ });
102
+
103
+ // 4. optical ink offset — each display element's visible INK-left must
104
+ // sit on ITS OWN column line (the nearest column-start to its box),
105
+ // not always line 1 (headlines can start on any column).
106
+ const cvs = document.createElement('canvas'), ctx = cvs.getContext('2d');
107
+ let inkErr = 0, inkWorst = null;
108
+ document.querySelectorAll(OPT).forEach(el => {
109
+ const c = getComputedStyle(el); let ch = (el.textContent || '').trim().charAt(0); if (!ch) return;
110
+ if (c.textTransform === 'uppercase') ch = ch.toUpperCase();
111
+ ctx.font = c.fontStyle + ' ' + c.fontWeight + ' ' + c.fontSize + ' ' + c.fontFamily; ctx.textAlign = 'left';
112
+ const abl = ctx.measureText(ch).actualBoundingBoxLeft;
113
+ const box = el.getBoundingClientRect().left;
114
+ const target = nearest(box, L); // the column line this element sits on
115
+ const ink = box - abl; // visible ink-left
116
+ const e = Math.abs(ink - target);
117
+ if (e > inkErr) { inkErr = e; inkWorst = (el.className || '').toString().slice(0, 20) + ' "' + ch + '"'; }
118
+ });
119
+
120
+ return {
121
+ track: +tracks[0].toFixed(1),
122
+ maxColErrPx: +colErr.toFixed(2), worstCol: worst,
123
+ overlayErrPx: +ovErr.toFixed(2),
124
+ maxBaselineOffPx: +baseErr.toFixed(2),
125
+ maxInkOffPx: +inkErr.toFixed(2), worstInk: inkWorst,
126
+ fontFamily: getComputedStyle(document.querySelector('.masthead') || document.body).fontFamily.split(',')[0]
127
+ };
128
+ }, BL);
129
+
130
+ // baseline tolerance = half a baseline unit (element border-box top vs line is a proxy; leading does the real work)
131
+ const pass = res.maxColErrPx <= 0.5 && res.overlayErrPx <= 0.5 && res.maxBaselineOffPx <= (BL / 2) && res.maxInkOffPx <= 1.0;
132
+ if (!pass) failed = true;
133
+ console.log(`[${pass ? 'PASS' : 'FAIL'}] vw=${W} col=${res.maxColErrPx}px overlay=${res.overlayErrPx}px ` +
134
+ `baseline=${res.maxBaselineOffPx}px ink=${res.maxInkOffPx}px ` +
135
+ `(worstCol=${res.worstCol}, worstInk=${res.worstInk}, font=${res.fontFamily})`);
136
+ }
137
+ await browser.close();
138
+ if (failed) { console.error('GRID VERIFY: FAIL'); process.exit(1); }
139
+ console.log('GRID VERIFY: PASS');
140
+ })().catch(e => { console.error('ERR', e.message); process.exit(2); });