Claude Code transcript - page 8/11

[Image: original 2562x1650, displayed at 2000x1288. Multiply coordinates by 1.28 to map to original image.]
Thinking

G2 is fixed — both text boxes now match Apple's tight line spacing. The 1eb960ba fix is unaffected by the CSS change (WP flow already had its own reset). Running the viewer gate, then committing:

$ Bash
Run viewer gate (tsc + Playwright)
cd /Users/phebert/pnk/viewer && npm test 2>&1 | tail -6
  ✓  5 tests/gate.spec.ts:134:1 › legacy fixture gets the legacy explanation (92ms)

  ✓  6 tests/gate.spec.ts:144:1 › encrypted fixture gets the password-protected explanation (85ms)

  6 passed (1.4s)
$ Bash
Commit and push the two blank-line fixes
cd /Users/phebert/pnk && git add viewer/src/text.ts viewer/styles.css && git commit -m "viewer: blank paragraphs occupy a line; shape text drops UA margins

Two inverse blank-line bugs, both against Apple PDF ground truth:

- An empty paragraph (items: []) rendered as an empty <p> that collapses
  to zero height — blank lines in a word-processing body vanished and the
  text fused (1eb960ba: title/attendees/body gaps gone). renderParagraph-
  Content now appends a <br> when nothing visible was emitted, giving the
  line box its strut (and keeping it measurable for pagination).

- Consecutive paragraphs in shape/textbox text showed a PHANTOM blank
  line (G2 caption box): with no pooled pStyle, applyParaStyle never runs
  and the UA's 1em <p> margin leaked in. .drawable-text now zeroes p/h*
  margins like .pages-print already did — Apple has no implicit paragraph
  margins; spacing comes only from spaceBefore/After.

Verified by eye via visual_diff on both docs; viewer gate 6/6.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018a2hmoDhN4kBq1BwfbFdAU" && git push origin main 2>&1 | tail -2
2 files changed, 15 insertions(+)
To https://github.com/peterheb/pnk.git
   77b4430..20b98bd  main -> main

Both blank-line bugs fixed, verified against Apple's PDF, and pushed as 20b98bd.

They were actually two opposite bugs with the same symptom class:

  • 1eb960ba (blank lines not blanking): the converter was emitting the empty paragraphs correctly (items: []), but the viewer rendered them as empty <p> elements, which collapse to zero height in HTML — so the gaps around the title and after the attendee list vanished. Fix: when a paragraph produces no visible content, append a <br> so the line box gets its strut at the inherited size. This also keeps blank lines measurable for WP pagination.

  • G2 (extra blank lines): the reverse — no empty paragraphs exist in the archive at all. The "Shapes can hold text too." box and the cat-photo caption are tight two-paragraph runs with no pooled paragraph style, so applyParaStyle never ran and the browser's default 1em <p> margin showed up as a phantom blank line. The WP print area already had a margin: 0 reset with the rationale "Apple has no implicit paragraph margins" — shape/textbox text (.drawable-text) just never got the same rule. It does now.

Both composites re-verified by eye: 1eb960ba's gaps are back where Apple puts them, and G2's two text boxes now match Apple line-for-line. Viewer gate 6/6, no converter changes so the golden expectations are untouched.

my :8123 is up-to-date?

Thinking
$ Bash
Check what's listening on 8123 and dist freshness
lsof -nP -iTCP:8123 -sTCP:LISTEN | tail -2; grep -n '"serve"' /Users/phebert/pnk/viewer/package.json; ls -l /Users/phebert/pnk/viewer/dist/ | head -8
COMMAND   PID    USER   FD   TYPE             DEVICE SIZE/OFF NODE NAME
esbuild 33575 phebert    3u  IPv4 0xa0812fac710435e4      0t0  TCP 127.0.0.1:8123 (LISTEN)
9:    "serve": "node_modules/.bin/esbuild --servedir=dist --serve=127.0.0.1:8123 --log-level=warning",
total 240
-rw-r--r--@ 1 phebert  staff    2778 Aug 30 11:06 index.html
-rw-r--r--@ 1 phebert  staff  101376 Aug 30 11:06 main.js
-rw-------@ 1 phebert  staff   15126 Aug 30 11:06 styles.css
drwxr-xr-x@ 6 phebert  staff     192 Aug 28 19:47 wasm
$ Bash
Confirm served files contain both fixes
curl -s http://127.0.0.1:8123/styles.css | grep -c "phantom blank line"; curl -s http://127.0.0.1:8123/main.js | grep -c "must still occupy one"
1
0
Thinking
$ Bash
Check for the br-strut fix in served bundle
curl -s http://127.0.0.1:8123/main.js | grep -c 'br, img'
1

Yes — :8123 is the esbuild static server over viewer/dist/, which I rebuilt at 11:06 before the last verification pass, and I just confirmed the live responses contain both fixes (the "br, img" strut check in main.js and the new margin reset in styles.css). Just hard-refresh the tab (⌘⇧R) in case your browser cached the old bundle.

I still find the leading between these lines in G2 to be excessive:

Source (CC0): https://commons.wikimedia.org/wiki/File:Tabby_cat_with_blue_eyes-3336579.jpg

Thinking

Let me zoom into that caption box in both renders and check the actual paragraph styles in the JSON.

$ Bash
Crop caption region from both renders
cd /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad && python3 -c "
from PIL import Image
im = Image.open('vd-g2b/composites/composite-page-1.png')
w,h = im.size
print(w,h)
# apple half is left 0..1281, ours right. Crop caption area both sides
im.crop((100, 730, 500, 830)).resize((800,200)).save('g2-cap-apple.png')
im.crop((1430, 620, 1830, 740)).resize((800,240)).save('g2-cap-ours.png')
"
Exit code 1
Traceback (most recent call last):
  File "<string>", line 2, in <module>
    from PIL import Image
ModuleNotFoundError: No module named 'PIL'
$ Bash
Crop caption region from both renders
cd /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad && uv run --with pillow python3 -c "
from PIL import Image
im = Image.open('vd-g2b/composites/composite-page-1.png')
print(im.size)
im.crop((100, 730, 560, 840)).resize((920,220)).save('g2-cap-apple.png')
im.crop((1420, 610, 1880, 720)).resize((920,220)).save('g2-cap-ours.png')
"
(2562, 1650)
Shell cwd was reset to /Users/phebert/pnk
Read
{
  "file_path": "/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/g2-cap-apple.png"
}
Read
{
  "file_path": "/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/g2-cap-ours.png"
}
Thinking
Thinking

The extra gap comes from an inherited line-height rather than paragraph spacing, likely set on body in the app chrome and leaking into the drawable text. I'll target that line-height rule to fix it.

$ Bash
Find line-height rules
grep -n "line-height" /Users/phebert/pnk/viewer/styles.css | head -20
265:  padding: 40px 56px; max-width: 820px; margin: 18px auto; line-height: 1.45;
290:.pages-print { display: flow-root; color: #1d1d1f; line-height: 1.2; font-size: 11px; }
$ Bash
Check body-level typography
grep -n "^body\|body {" /Users/phebert/pnk/viewer/styles.css; sed -n 1,20p /Users/phebert/pnk/viewer/styles.css | grep -n "line-height\|font"
40:body {
10:  font-size: 15px;
Read
{
  "file_path": "/Users/phebert/pnk/viewer/styles.css",
  "offset": 1,
  "limit": 55
}
1	/* pnk viewer — plain CSS, no framework, no runtime fetches. */
2	
3	:root {
4	  --ink: #1d1d1f;
5	  --muted: #6e6e73;
6	  --accent: #d0403c;      /* pnk */
7	  --surface: #f5f5f7;
8	  --card: #ffffff;
9	  --line: #d2d2d7;
10	  font-size: 15px;
11	  color-scheme: light dark;
12	}
13	
14	/* Dark chrome — the app shell follows the OS; document canvases stay paper
15	   (.canvas-frame/.canvas-inner/.sheet-* keep their explicit whites). */
16	@media (prefers-color-scheme: dark) {
17	  :root {
18	    --ink: #ececf0;
19	    --muted: #9a9aa2;
20	    --surface: #1c1c1f;
21	    --card: #28282c;
22	    --line: #414147;
23	  }
24	  #drop-target.dragover { background: #3a2523; }
25	  #drag-overlay { background: rgba(28, 28, 31, .82); }
26	  /* document content areas render on paper regardless of OS theme
27	     (body-prefixed: this block sits above the base rules, and media queries
28	     don't add specificity — source order would otherwise undo these) */
29	  body .sheet-area, body .pages-flow, body .notes-panel, body .error-card { color: #1d1d1f; }
30	  body .sheet-area, body .pages-flow { background: #ffffff; border-color: #d2d2d7; }
31	  .drag-overlay-card { background: #28282c; }
32	  #parse-status { background: #ececf0; color: #1d1d1f; }
33	}
34	
35	* { box-sizing: border-box; }
36	.hidden { display: none !important; }
37	.muted { color: var(--muted); }
38	.spacer { flex: 1; }
39	
40	body {
41	  margin: 0;
42	  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
43	  color: var(--ink);
44	  background: var(--surface);
45	}
46	
47	#app { max-width: 1100px; margin: 0 auto; padding: 16px; }
48	
49	/* ---- landing / drop zone ------------------------------------------------ */
50	
51	#drop-zone { padding: 8vh 0; }
52	#drop-card {
53	  background: var(--card);
54	  border: 1px solid var(--line);
55	  border-radius: 16px;
Thinking
Thinking

Body has no line-height issue, but .drawable-text p uses white-space: pre-wrap, which could turn trailing \n characters into extra visible lines. I'll check the raw text for those trailing newlines.

$ Bash
Inspect caption run text for embedded newlines
./target/release/pnk2json --pretty fixtures/golden/G2-golden-pages-layout.pages 2>/dev/null | python3 -c "
import json, sys
d = json.load(sys.stdin)
doc = d.get('document', d)
def walk(dr):
    if dr.get('type') in ('shape','textbox') and dr.get('text'):
        for p in dr['text'].get('paragraphs', []):
            if not isinstance(p, str):
                for it in p.get('items', []):
                    t = it if isinstance(it, str) else it.get('text')
                    if t and 'Source' in t or (t and 'commons' in t):
                        print(repr(t))
                        print('paraStyle:', p.get('pStyle'))
    for c in dr.get('children', []): walk(c)
for page in doc.get('floating', []) or []:
    for dr in page.get('drawables', []): walk(dr)
print('---- para styles pool:')
for i, ps in enumerate(doc.get('styles',{}).get('para',[])[:6]): print(i, ps)
"
'Source (CC0):'
paraStyle: None
'https://commons.wikimedia.org/wiki/File:Tabby_cat_with_blue_eyes-3336579.jpg'
paraStyle: None
---- para styles pool:
0 {'horizontalAlignment': 'center', 'defaultTabStopPt': 46.0, 'keepLinesTogether': True}
1 {'horizontalAlignment': 'center'}
2 {'dropCap': {'lines': 3, 'characterScale': 0.8}}
Thinking

Text is clean, no styles. But the gap appears within the wrapped URL paragraph too, so it's genuinely line-height. Let me check applyCharStyle and the canvas font-size context:

$ Bash
Read applyCharStyle
grep -n "function applyCharStyle" -A 30 /Users/phebert/pnk/viewer/src/text.ts | head -36
34:export function applyCharStyle(el: HTMLElement, cs: CharStyle | undefined): void {
35-  if (!cs) return;
36-  const s = el.style;
37-  if (cs.fontName) {
38-    const flat = cs.fontName.replace(/[\s-]+/g, "");
39-    const fb = FONT_FALLBACKS.find(([re]) => re.test(flat))?.[1] ?? "sans-serif";
40-    s.fontFamily = `"${cs.fontName}", ${fb}`;
41-  }
42-  if (cs.fontSizePt) s.fontSize = `${cs.fontSizePt}px`;
43-  if (cs.bold) s.fontWeight = "700";
44-  if (cs.italic) s.fontStyle = "italic";
45-  if (cs.underline && cs.underline !== "none") s.textDecorationLine = "underline";
46-  if (cs.strikethrough && cs.strikethrough !== "none") {
47-    s.textDecorationLine = `${s.textDecorationLine === "underline" ? "underline " : ""}line-through`;
48-  }
49-  if (cs.capitalization === "all-caps") s.textTransform = "uppercase";
50-  else if (cs.capitalization === "small-caps") s.fontVariant = "small-caps";
51-  else if (cs.capitalization === "title") s.textTransform = "capitalize";
52-  if (cs.baseline === "superscript") s.verticalAlign = "super";
53-  else if (cs.baseline === "subscript") s.verticalAlign = "sub";
54-  if (cs.baselineShiftPt) s.verticalAlign = `${cs.baselineShiftPt}px`;
55-  if (cs.trackingPt) s.letterSpacing = `${cs.trackingPt}px`;
56-  if (cs.fontColor) s.color = cs.fontColor;
57-  if (cs.backgroundColor) s.backgroundColor = cs.backgroundColor;
58-}
59-
60-export function applyParaStyle(el: HTMLElement, ps: ParaStyle): void {
61-  const s = el.style;
62-  const align = ps.horizontalAlignment;
63-  if (align === "center" || align === "right" || align === "justify") s.textAlign = align;
64-  // TSWP indents: first_line_indent is ABSOLUTE from the margin while
Thinking
Thinking

Found the issue: applyCharStyle only sets font-size on run spans, leaving the paragraph block at the app's 15px root, so its line-height strut doubles the leading for smaller text. I'll fix this by sizing the paragraph block to match its largest visible run.

Read
{
  "file_path": "/Users/phebert/pnk/viewer/src/text.ts",
  "offset": 196,
  "limit": 12
}
196	
197	  const level = style?.outlineLevel ?? 0;
198	  const el = level >= 1 && level <= 5
199	    ? document.createElement(`h${level}`)
200	    : document.createElement("p");
201	
202	  if (!hasMarker) {
203	    listState.lastKey = null;
204	    if (style) applyParaStyle(el, style);
205	  } else {
206	    // numbering: the stored restart flag (surfaced as list.start on the
207	    // paragraph's pooled style) resets the counter; otherwise numbering
✏️ Edit text.ts
/Users/phebert/pnk/viewer/src/text.ts
  const level = style?.outlineLevel ?? 0;
  const el = level >= 1 && level <= 5
    ? document.createElement(`h${level}`)
    : document.createElement("p");

  if (!hasMarker) {
+
  const level = style?.outlineLevel ?? 0;
  const el = level >= 1 && level <= 5
    ? document.createElement(`h${level}`)
    : document.createElement("p");

  // The block's own font-size feeds the line-box STRUT: run spans carry
  // their sizes but the <p> inherited the chrome's 15px, so every line of
  // smaller text was padded to ~18px pitch (G2's 9pt caption rendered with
  // double leading — visible even between the wrapped lines of one
  // paragraph). Size the block to its largest visible run: Apple derives
  // line height from the tallest run in the line.
  const runSizes = p.items
    .map((it) =>
      typeof it === "string" || "type" in it ? undefined : charStyleOf(doc, (it as TextRun).cStyle)?.fontSizePt,
    )
    .filter((n): n is number => !!n);
  if (runSizes.length) el.style.fontSize = `${Math.max(...runSizes)}px`;

  if (!hasMarker) {
The file /Users/phebert/pnk/viewer/src/text.ts has been updated successfully. (file state is current in your context — no need to Read it back)
$ Bash
Rebuild and re-diff G2
bash scripts/build_viewer.sh 2>&1 | tail -1 && uv run --with pillow --with pyobjc-framework-Quartz --with pymupdf python3 scripts/visual_diff.py --app pages --fixture fixtures/golden/G2-golden-pages-layout.pages --out /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/vd-g2c --base-url http://127.0.0.1:8123 2>&1 | tail -2
viewer built: viewer/dist/  (serve: cd viewer && npm run serve)
[visual_diff] artifacts: /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/vd-g2c
[visual_diff] summary: /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/vd-g2c/summary.md
$ Bash
Crop new caption render
cd /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad && uv run --with pillow python3 -c "
from PIL import Image
im = Image.open('vd-g2c/composites/composite-page-1.png')
im.crop((1420, 590, 1880, 720)).resize((920,260)).save('g2-cap-ours2.png')
"
Shell cwd was reset to /Users/phebert/pnk
Read
{
  "file_path": "/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/g2-cap-ours2.png"
}
Thinking

Leading now matches Apple. This change touches all paragraph rendering, so let me sanity-check the full G2 page, the 1eb960ba doc, and one Keynote deck before committing:

$ Bash
Re-diff the WP doc after strut change
uv run --with pillow --with pyobjc-framework-Quartz --with pymupdf python3 scripts/visual_diff.py --app pages --fixture ~/Development/pnk-fixtures/crawl/1eb960ba4ad8f155d70037395ca50b28b3993cf82d03a3d787daced092ddbd54.pages --out /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/vd-1eb-b --base-url http://127.0.0.1:8123 2>&1 | tail -1
[visual_diff] summary: /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/vd-1eb-b/summary.md
Read
{
  "file_path": "/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/vd-g2c/composites/composite-page-1.png"
}
[Image: original 2562x1650, displayed at 2000x1288. Multiply coordinates by 1.28 to map to original image.]
Read
{
  "file_path": "/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/vd-1eb-b/composites/composite-page-1.png"
}
[Image: original 2490x1754, displayed at 2000x1409. Multiply coordinates by 1.25 to map to original image.]
Thinking

G2 and the WP doc both verified. Since this strut change touches every text block, one Keynote spot-check before committing:

$ Bash
Find prior sweep artifacts for a known-good deck
ls /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/ | head -30
1eb960ba.err
1eb960ba.json
a9.json
console_check.mjs
content_census.py
content-census.tsv
doc-dark.png
doc-light.png
drawable_census.py
drawable-census.tsv
drawables-campaign-targets.md
drop-overlay.png
droptest.mjs
ds
focus-test
foda-ids.txt
g1-now.json
G1.json
g2-cap-apple.png
g2-cap-ours.png
g2-cap-ours2.png
G2.json
g5-apple-crop.png
g5-storages.txt
G5.json
G5b.json
G5c.json
k
k2
k3
$ Bash
Find a known keynote deck name
ls /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/k3/ 2>/dev/null | head; cat /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/drawables-campaign-targets.md 2>/dev/null | grep -m3 "\.key"
bicep
brief
dbg
dns
dns-final
final
g2
json
lisa
ppd
$ Bash
Recover the dns deck fixture path
ls /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/k3/dns-final/ 2>/dev/null | head -4; ls /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/k3/dns-final/*.md 2>/dev/null && grep -m2 "fixture\|\.key" /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/k3/dns-final/summary.md 2>/dev/null
apple
apple-work
composites
crops
/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/k3/dns-final/summary.md
- Fixture: `/Users/phebert/Development/pnk-fixtures/crawl/0f9df553f4a7afb8147288b9ffb4352624edea6448e946b859516768ed99d573.key`
$ Bash
Keynote regression check after strut change
uv run --with pillow --with pyobjc-framework-Quartz --with pymupdf python3 scripts/visual_diff.py --app keynote --fixture /Users/phebert/Development/pnk-fixtures/crawl/0f9df553f4a7afb8147288b9ffb4352624edea6448e946b859516768ed99d573.key --out /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/vd-dns-strut --base-url http://127.0.0.1:8123 2>&1 | tail -2
[visual_diff] artifacts: /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/vd-dns-strut
[visual_diff] summary: /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/vd-dns-strut/summary.md
$ Bash
List keynote composites
ls /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/vd-dns-strut/composites/
composite-page-1.png
composite-page-10.png
composite-page-11.png
composite-page-12.png
composite-page-13.png
composite-page-14.png
composite-page-15.png
composite-page-16.png
composite-page-17.png
composite-page-18.png
composite-page-19.png
composite-page-2.png
composite-page-20.png
composite-page-21.png
composite-page-22.png
composite-page-23.png
composite-page-24.png
composite-page-25.png
composite-page-26.png
composite-page-27.png
composite-page-28.png
composite-page-29.png
composite-page-3.png
composite-page-30.png
composite-page-31.png
composite-page-32.png
composite-page-33.png
composite-page-34.png
composite-page-35.png
composite-page-36.png
composite-page-37.png
composite-page-38.png
composite-page-39.png
composite-page-4.png
composite-page-40.png
composite-page-41.png
composite-page-42.png
composite-page-43.png
composite-page-44.png
composite-page-45.png
composite-page-46.png
composite-page-47.png
composite-page-48.png
composite-page-49.png
composite-page-5.png
composite-page-50.png
composite-page-51.png
composite-page-52.png
composite-page-53.png
composite-page-54.png
composite-page-55.png
composite-page-56.png
composite-page-57.png
composite-page-58.png
composite-page-59.png
composite-page-6.png
composite-page-7.png
composite-page-8.png
composite-page-9.png
Read
{
  "file_path": "/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/vd-dns-strut/composites/composite-page-2.png"
}
Read
{
  "file_path": "/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/vd-dns-strut/composites/composite-page-5.png"
}
Thinking
Thinking

The left/right slide mismatch is a pre-existing artifact from Apple's PDF export skipping hidden slides, not something caused by my strut change. Now I'll compare today's render against the K3-era composite for the same page to confirm nothing shifted.

Read
{
  "file_path": "/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/k3/dns-final/composites/composite-page-2.png"
}
Thinking

Identical layout — no Keynote regression (large text already dominated its strut, as expected). Gate and ship:

$ Bash
Viewer gate (tsc + Playwright)
cd /Users/phebert/pnk/viewer && npm test 2>&1 | tail -3
  ✓  6 tests/gate.spec.ts:144:1 › encrypted fixture gets the password-protected explanation (84ms)

  6 passed (1.4s)
$ Bash
Commit and push the strut fix
cd /Users/phebert/pnk && git add viewer/src/text.ts && git commit -m "viewer: paragraph block font-size = largest run — kills phantom leading

The run spans carried their own sizes but the <p>/<h*> block inherited the
app chrome's 15px, and the block's STRUT (the invisible zero-width inline
with the block's font metrics) sets each line box's minimum height. Small
text paid for it: G2's 9pt caption rendered at ~18px pitch — double
leading, visible even between the wrapped lines of a single paragraph,
where paragraph margins can't be the cause.

Size the block to its largest visible run: the strut then matches the
text, which is also Apple's rule (line height derives from the tallest
run in the line). Large text is unaffected — a 28pt run's own inline box
already exceeded the 15px strut, verified no-drift on the dns keynote
deck against its k3-sweep composite.

Verified by eye: G2 caption + heading box now match Apple's leading;
1eb960ba unchanged; viewer gate 6/6.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018a2hmoDhN4kBq1BwfbFdAU" && git push origin main 2>&1 | tail -1
1 file changed, 13 insertions(+)
   20b98bd..5daf1d3  main -> main

Fixed and pushed as 5daf1d3. You were right that it was still excessive — the margin fix was only half the story.

The remaining leading came from the line-box strut: the run spans carry their own font sizes (9pt for that caption), but the <p> block itself was inheriting the app chrome's 15px. Every line box is at least as tall as the block's own font metrics demand, so small text got ~18px line pitch — which is why the gap appeared even between the two wrapped lines inside the URL paragraph, where margins couldn't be the cause.

The fix sizes the paragraph block to its largest visible run, which matches Apple's own rule (line height derives from the tallest run in the line). Verified by eye against Apple's PDF: the caption now sits at Apple's tight three-line leading, the heading box matches, 1eb960ba is unchanged, and the dns Keynote deck renders pixel-identical to its pre-change sweep composite (big text already exceeded the old strut, so it never noticed). Gate 6/6. Hard-refresh your :8123 tab to pick up the new bundle.

One unrelated thing I noticed in that Keynote composite: Apple's PDF export omits slides marked "skipped" while our viewer shows all slides, so side-by-side pages drift out of register on decks with skipped slides. Harmless for the viewer itself (showing skipped slides is arguably a feature), but say the word if you'd rather have the viewer badge or hide them.