Claude Code transcript - page 9/11

G2 has something odd where the green shadow appears to apply to the black diamond instead of the green pentagon?

Thinking

Let me zoom into that region in the latest composite to see exactly what's happening.

$ Bash
Crop pentagon+diamond region both sides
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')
# apple: pentagon ~ (890-1180, 360-680); ours right half starts ~1281
im.crop((860, 350, 1200, 700)).resize((680,700)).save('g2-pent-apple.png')
im.crop((1870, 400, 2080, 700)).resize((630,900)).save('g2-pent-ours.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-pent-apple.png"
}
Read
{
  "file_path": "/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/g2-pent-ours.png"
}
Thinking
Thinking

The render is showing the pentagon's green reflection under the diamond instead, while the pentagon lost its shadow and reflection—I'll check the JSON to see which drawable actually holds those effects.

$ Bash
Dump drawable shadow/reflection assignments
./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, depth=0):
    c = dr.get('common', {})
    fill = dr.get('fill') or (dr.get('style') or {}).get('fill')
    print(' '*depth, dr.get('type'),
          'pos=', c.get('position'), 'size=', c.get('size'),
          'fill=', json.dumps(fill)[:60] if fill else None,
          'shadow=', 'shadow' in c or 'shadow' in dr,
          'reflection=', 'reflection' in c or 'reflection' in dr)
    for ch in dr.get('children', []): walk(ch, depth+2)
for page in doc.get('floating', []) or []:
    for dr in page.get('drawables', []): walk(dr)
"
 textbox pos= {'x': 75.97708129882812, 'y': 62.400001525878906} size= {'width': 250.0, 'height': 128.0} fill= None shadow= False reflection= False
 shape pos= {'x': 306.0, 'y': 62.400001525878906} size= {'width': 100.0, 'height': 100.0} fill= None shadow= False reflection= False
 shape pos= {'x': 431.16876220703125, 'y': 62.400001525878906} size= {'width': 100.0, 'height': 100.0} fill= None shadow= False reflection= False
 shape pos= {'x': 306.0, 'y': 176.9499969482422} size= {'width': 100.0, 'height': 100.0} fill= None shadow= False reflection= False
 shape pos= {'x': 426.16876220703125, 'y': 176.9499969482422} size= {'width': 110.0, 'height': 110.0} fill= None shadow= True reflection= True
 shape pos= {'x': 306.0, 'y': 296.0} size= {'width': 100.0, 'height': 100.0} fill= None shadow= True reflection= False
 shape pos= {'x': 428.86065673828125, 'y': 296.0} size= {'width': 100.0, 'height': 100.0} fill= None shadow= False reflection= False
 shape pos= {'x': 296.0, 'y': 438.5062561035156} size= {'width': 120.0, 'height': 60.0} fill= None shadow= False reflection= False
 shape pos= {'x': 442.9926452636719, 'y': 441.3953552246094} size= {'width': 120.35222625732422, 'height': 57.11088943481445} fill= None shadow= False reflection= False
 shape pos= {'x': 443.09375, 'y': 543.1658935546875} size= {'width': 114.37886810302734, 'height': 57.22382354736328} fill= None shadow= False reflection= False
 textbox pos= {'x': 63.0, 'y': 368.0} size= {'width': 207.7375030517578, 'height': 31.0} fill= None shadow= False reflection= False
 image pos= {'x': 63.0, 'y': 246.0} size= {'width': 94.43827056884766, 'height': 113.32592010498047} fill= None shadow= False reflection= False
 group pos= {'x': 245.95416259765625, 'y': 524.7249755859375} size= {'width': 160.04583740234375, 'height': 100.0} fill= None shadow= False reflection= False
   shape pos= {'x': 60.04583740234375, 'y': 0.0} size= {'width': 100.0, 'height': 100.0} fill= None shadow= False reflection= False
   shape pos= {'x': 0.0, 'y': 50.0} size= {'width': 57.045833587646484, 'height': 0.0} fill= None shadow= False reflection= False
 textbox pos= {'x': 181.0, 'y': 148.1999969482422} size= {'width': 250.0, 'height': 128.0} fill= None shadow= False reflection= False
 textbox pos= {'x': 87.80000305175781, 'y': 285.3999938964844} size= {'width': 250.0, 'height': 128.0} fill= None shadow= False reflection= False
Thinking
Thinking

The converter is correct—the pentagon has both shadow and reflection while the diamond has neither, so the bug is in how the viewer positions the reflection (rendering it ~100px too low). I'll check the reflection rendering code next.

$ Bash
Find reflection code
grep -n "reflection" /Users/phebert/pnk/viewer/src/drawables.ts | head
711:  if (c.reflection) {
712:    // Chromium/WebKit only; other engines just skip the reflection.
714:    // the contact line: Apple's reflection is strongest there (~opacity)
720:      `below 0px linear-gradient(rgba(0,0,0,${c.reflection.opacity}), transparent 55%)`,
Read
{
  "file_path": "/Users/phebert/pnk/viewer/src/drawables.ts",
  "offset": 660,
  "limit": 70
}
660	        dot.setAttribute("fill", "#fff");
661	        dot.setAttribute("stroke", color);
662	        dot.setAttribute("stroke-width", "2");
663	        svg.appendChild(dot);
664	      }
665	    });
666	    return svg;
667	  }
668	  const barW = (groupW * 0.7) / chart.series.length;
669	  chart.series.forEach((s, si) => {
670	    s.values.forEach((v, vi) => {
671	      if (typeof v !== "number") return;
672	      const bar = document.createElementNS(NS, "rect");
673	      const bh = (v / max) * (h - 8);
674	      bar.setAttribute("x", String(vi * groupW + groupW * 0.15 + si * barW));
675	      bar.setAttribute("y", String(h - bh));
676	      bar.setAttribute("width", String(barW));
677	      bar.setAttribute("height", String(bh));
678	      bar.setAttribute("fill", colors[si % colors.length]);
679	      svg.appendChild(bar);
680	    });
681	  });
682	  return svg;
683	}
684	
685	// ---------------------------------------------------------------------------
686	// Public entry points
687	// ---------------------------------------------------------------------------
688	
689	/** Position + rotate + opacity from DrawableCommon, 1pt = 1px. */
690	export function applyCommonGeometry(div: HTMLElement, c: DrawableCommon): void {
691	  const s = div.style;
692	  if (c.position) {
693	    s.left = `${c.position.x}px`;
694	    s.top = `${c.position.y}px`;
695	  }
696	  if (c.size) {
697	    s.width = `${c.size.width}px`;
698	    s.height = `${c.size.height}px`;
699	  }
700	  if (c.angleDeg) s.transform = `rotate(${-c.angleDeg}deg)`;
701	  if (c.opacity !== undefined) s.opacity = String(c.opacity);
702	  if (c.shadow && c.shadow.kind === "drop") {
703	    // Angle convention fixture-verified on G2's pentagon (angle 45, offset 5
704	    // renders down-right in Apple's raster): dx = cos, dy = sin, CSS y-down.
705	    const a = (c.shadow.angleDeg * Math.PI) / 180;
706	    const dx = Math.cos(a) * c.shadow.offsetPt;
707	    const dy = Math.sin(a) * c.shadow.offsetPt;
708	    const [r, g, b] = hexRgb(c.shadow.color);
709	    s.filter = `drop-shadow(${dx.toFixed(1)}px ${dy.toFixed(1)}px ${c.shadow.radiusPt}px rgba(${r},${g},${b},${c.shadow.opacity}))`;
710	  }
711	  if (c.reflection) {
712	    // Chromium/WebKit only; other engines just skip the reflection.
713	    // The mask paints over the MIRRORED copy top-down, so its top edge is
714	    // the contact line: Apple's reflection is strongest there (~opacity)
715	    // and dies out ~55% down (3c844ac1 logos vs Keynote's export) — the
716	    // previous transparent-to-opaque ramp faded the wrong way, drawing a
717	    // readable mirrored logo at full strength far from the object.
718	    s.setProperty(
719	      "-webkit-box-reflect",
720	      `below 0px linear-gradient(rgba(0,0,0,${c.reflection.opacity}), transparent 55%)`,
721	    );
722	  }
723	}
724	
725	function hexRgb(hex: string): [number, number, number] {
726	  const v = parseInt(hex.replace("#", "").slice(0, 6), 16);
727	  return [(v >> 16) & 255, (v >> 8) & 255, v & 255];
728	}
729	
Thinking
Thinking

The JSON logic is fine, but the reflection gradient mask is running backward and the pentagon's drop shadow is missing—both introduced in applyCommonGeometry (drawables.ts:702-722). I'll write a minimal test to verify Chromium's actual box-reflect mask direction rather than guessing.

📝 Write reflect-probe.html
/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/reflect-probe.html
<!doctype html>
<meta charset="utf-8">
<style>
  body { background: #fff; margin: 20px; display: flex; gap: 60px; }
  .box { width: 100px; height: 100px; }
  .grad { background: linear-gradient(#0a0, #0f0); }
  /* A: current viewer ramp: opaque at gradient-top -> transparent 55% */
  .a { -webkit-box-reflect: below 0px linear-gradient(rgba(0,0,0,.5), transparent 55%); }
  /* B: inverse ramp: transparent 45% -> opaque at gradient-bottom */
  .b { -webkit-box-reflect: below 0px linear-gradient(transparent 45%, rgba(0,0,0,.5)); }
  /* C: A plus a drop-shadow filter, to see if filter+reflect coexist */
  .c { -webkit-box-reflect: below 0px linear-gradient(rgba(0,0,0,.5), transparent 55%);
       filter: drop-shadow(4px 4px 3px rgba(0,0,0,.6)); }
</style>
<div class="box grad a"></div>
<div class="box grad b"></div>
<div class="box grad c"></div>
File created successfully at: /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/reflect-probe.html (file state is current in your context — no need to Read it back)
Thinking
$ Bash
Screenshot the box-reflect direction probe
cd /Users/phebert/pnk/viewer && node -e "
const { chromium } = require('playwright');
(async () => {
  const b = await chromium.launch();
  const p = await b.newPage({ viewport: { width: 560, height: 280 } });
  await p.goto('file:///private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/reflect-probe.html');
  await p.screenshot({ path: '/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/reflect-probe.png' });
  await b.close();
})();
"
(Bash completed with no output)
Read
{
  "file_path": "/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/reflect-probe.png"
}
Thinking
Thinking

The probe confirms Chromium flips the mask with the reflection, so the current gradient direction causes the G2 gap bug, while reversing it (transparent → opaque) matches Apple's look. Before making that change, I'll check what the specialist actually modified and why, since their comment claims the opposite direction broke a logos fixture.

$ Bash
History of the reflection code block
cd /Users/phebert/pnk && git log -L 700,725:viewer/src/drawables.ts --oneline 2>/dev/null | head -60
43879e8 drawables: reflection fades FROM the contact edge, not toward it

diff --git a/viewer/src/drawables.ts b/viewer/src/drawables.ts
--- a/viewer/src/drawables.ts
+++ b/viewer/src/drawables.ts
@@ -700,21 +700,26 @@
   if (c.angleDeg) s.transform = `rotate(${-c.angleDeg}deg)`;
   if (c.opacity !== undefined) s.opacity = String(c.opacity);
   if (c.shadow && c.shadow.kind === "drop") {
     // Angle convention fixture-verified on G2's pentagon (angle 45, offset 5
     // renders down-right in Apple's raster): dx = cos, dy = sin, CSS y-down.
     const a = (c.shadow.angleDeg * Math.PI) / 180;
     const dx = Math.cos(a) * c.shadow.offsetPt;
     const dy = Math.sin(a) * c.shadow.offsetPt;
     const [r, g, b] = hexRgb(c.shadow.color);
     s.filter = `drop-shadow(${dx.toFixed(1)}px ${dy.toFixed(1)}px ${c.shadow.radiusPt}px rgba(${r},${g},${b},${c.shadow.opacity}))`;
   }
   if (c.reflection) {
     // Chromium/WebKit only; other engines just skip the reflection.
+    // The mask paints over the MIRRORED copy top-down, so its top edge is
+    // the contact line: Apple's reflection is strongest there (~opacity)
+    // and dies out ~55% down (3c844ac1 logos vs Keynote's export) — the
+    // previous transparent-to-opaque ramp faded the wrong way, drawing a
+    // readable mirrored logo at full strength far from the object.
     s.setProperty(
       "-webkit-box-reflect",
-      `below 0px linear-gradient(transparent 30%, rgba(0,0,0,${c.reflection.opacity}))`,
+      `below 0px linear-gradient(rgba(0,0,0,${c.reflection.opacity}), transparent 55%)`,
     );
   }
 }
 
 function hexRgb(hex: string): [number, number, number] {
5fe7d1d viewer: regular-polygon + double-arrow presets, drop/contact shadows, reflections

diff --git a/viewer/src/drawables.ts b/viewer/src/drawables.ts
--- a/viewer/src/drawables.ts
+++ b/viewer/src/drawables.ts
@@ -367,2 +387,21 @@
   if (c.angleDeg) s.transform = `rotate(${-c.angleDeg}deg)`;
   if (c.opacity !== undefined) s.opacity = String(c.opacity);
+  if (c.shadow && c.shadow.kind === "drop") {
+    // Angle convention fixture-verified on G2's pentagon (angle 45, offset 5
+    // renders down-right in Apple's raster): dx = cos, dy = sin, CSS y-down.
+    const a = (c.shadow.angleDeg * Math.PI) / 180;
+    const dx = Math.cos(a) * c.shadow.offsetPt;
+    const dy = Math.sin(a) * c.shadow.offsetPt;
+    const [r, g, b] = hexRgb(c.shadow.color);
+    s.filter = `drop-shadow(${dx.toFixed(1)}px ${dy.toFixed(1)}px ${c.shadow.radiusPt}px rgba(${r},${g},${b},${c.shadow.opacity}))`;
+  }
+  if (c.reflection) {
+    // Chromium/WebKit only; other engines just skip the reflection.
+    s.setProperty(
+      "-webkit-box-reflect",
+      `below 0px linear-gradient(transparent 30%, rgba(0,0,0,${c.reflection.opacity}))`,
+    );
+  }
+}
+
+function hexRgb(hex: string): [number, number, number] {
Thinking
Thinking

Confirmed the original transparent-to-opaque ramp direction was correct, and the specialist's inversion stemmed from misreading flip semantics; I'll keep their measured geometry (strongest at contact, fading by ~55%) but apply the right orientation. Next I'll check the pentagon's shadow values since they seemed missing in our render.

$ Bash
Dump shadow/reflection params
./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):
    c = dr.get('common', {})
    if c.get('shadow') or c.get('reflection'):
        print(dr.get('type'), c.get('position'), 'shadow=', c.get('shadow'), 'refl=', c.get('reflection'))
    for ch in dr.get('children', []): walk(ch)
for page in doc.get('floating', []) or []:
    for dr in page.get('drawables', []): walk(dr)
"
shape {'x': 426.16876220703125, 'y': 176.9499969482422} shadow= {'color': '#000000', 'angleDeg': 45.0, 'offsetPt': 5.0, 'radiusPt': 4.0, 'opacity': 0.5, 'kind': 'drop'} refl= {'opacity': 0.5}
shape {'x': 306.0, 'y': 296.0} shadow= {'color': '#000000', 'angleDeg': 0.0, 'offsetPt': 9.0, 'radiusPt': 30.0, 'opacity': 0.75, 'kind': 'contact', 'contact': {'height': 0.25}} refl= None
Thinking
Thinking

The pentagon's shadow settings are fine — the real issue is the inverted mask. I'll flip it back using the measured falloff stops (strong at contact, fading out by ~55%).

✏️ Edit drawables.ts
/Users/phebert/pnk/viewer/src/drawables.ts
  if (c.reflection) {
    // Chromium/WebKit only; other engines just skip the reflection.
    // The mask paints over the MIRRORED copy top-down, so its top edge is
    // the contact line: Apple's reflection is strongest there (~opacity)
    // and dies out ~55% down (3c844ac1 logos vs Keynote's export) — the
    // previous transparent-to-opaque ramp faded the wrong way, drawing a
    // readable mirrored logo at full strength far from the object.
    s.setProperty(
      "-webkit-box-reflect",
      `below 0px linear-gradient(rgba(0,0,0,${c.reflection.opacity}), transparent 55%)`,
    );
  }
+
  if (c.reflection) {
    // Chromium/WebKit only; other engines just skip the reflection.
    // Mask orientation, probe-verified on Chromium 151 (scratchpad
    // reflect-probe.html): the gradient is flipped WITH the mirrored copy,
    // so gradient-BOTTOM lands on the contact line. Apple's reflection is
    // strongest at contact (~opacity) and dead ~55% out (measured on
    // 3c844ac1 logos vs Keynote's export), so: transparent through the far
    // 45%, ramping to opaque at the bottom. The previous opaque-to-
    // transparent ramp painted the ghost far from the shape and nothing at
    // contact (G2's pentagon reflection appeared under the diamond).
    s.setProperty(
      "-webkit-box-reflect",
      `below 0px linear-gradient(transparent 45%, rgba(0,0,0,${c.reflection.opacity}))`,
    );
  }
The file /Users/phebert/pnk/viewer/src/drawables.ts has been updated successfully. (file state is current in your context — no need to Read it back)
$ Bash
Rebuild, re-diff G2, locate logos fixture
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-g2d --base-url http://127.0.0.1:8123 2>&1 | tail -1; find ~/Development/pnk-fixtures -name "3c844ac1*" | head -1
viewer built: viewer/dist/  (serve: cd viewer && npm run serve)
[visual_diff] summary: /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/vd-g2d/summary.md
/Users/phebert/Development/pnk-fixtures/crawl/3c844ac16fa2f0abceaf0d5de44904411bcfdf70b339d7322c7d264f406ee593.key
$ Bash
Crop pentagon region after fix
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-g2d/composites/composite-page-1.png')
im.crop((860, 350, 1200, 700)).resize((680,700)).save('g2-pent-apple2.png')
im.crop((1870, 400, 2080, 700)).resize((630,900)).save('g2-pent-ours3.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-pent-ours3.png"
}
Thinking

Reflection now hugs the contact line and fades out, with the diamond correctly painting over it. Comparing directly with Apple's crop:

Read
{
  "file_path": "/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/g2-pent-apple2.png"
}
Thinking
Thinking

The reflection now matches Apple's style closely enough. Now I'll run the regression check on the logos deck that the specialist's inverted fix was based on.

$ Bash
Visual diff the 3c844ac1 logos deck
uv run --with pillow --with pyobjc-framework-Quartz --with pymupdf python3 scripts/visual_diff.py --app keynote --fixture /Users/phebert/Development/pnk-fixtures/crawl/3c844ac16fa2f0abceaf0d5de44904411bcfdf70b339d7322c7d264f406ee593.key --out /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/vd-logos --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-logos
[visual_diff] summary: /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/vd-logos/summary.md
$ Bash
Find which slides carry reflections
./target/release/pnk2json --pretty /Users/phebert/Development/pnk-fixtures/crawl/3c844ac16fa2f0abceaf0d5de44904411bcfdf70b339d7322c7d264f406ee593.key 2>/dev/null | python3 -c "
import json, sys
d = json.load(sys.stdin)
doc = d.get('document', d)
for i, sl in enumerate(doc.get('slides', []), 1):
    n = 0
    def walk(dr):
        global n
        if (dr.get('common') or {}).get('reflection'): n += 1
        for ch in dr.get('children', []): walk(ch)
    for dr in sl.get('drawables', []) + sl.get('masterDrawables', []): walk(dr)
    if n: print('slide', i, 'reflections:', n)
" | head
slide 1 reflections: 2
Read
{
  "file_path": "/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/vd-logos/composites/composite-page-1.png"
}
Thinking
Thinking

The logos deck confirms both logos now correctly mirror at the contact line, matching Apple's export — the flip direction is fixed. I'll gate and ship this now.

$ Bash
Gate, commit, push reflection fix
cd /Users/phebert/pnk/viewer && npm test 2>&1 | tail -2 && git add viewer/src/drawables.ts && git commit -m "drawables: reflection mask direction re-flipped — gradient-bottom is the contact line

G2's pentagon reflection was painting its ghost ~a shape-height away
(peeking out from under the black diamond, reading as a green shadow on
the wrong drawable) with NOTHING at the contact line — exactly inverse of
Apple. Root cause: -webkit-box-reflect flips the mask gradient WITH the
mirrored copy, so gradient-TOP lands on the FAR edge; the previous commit
(43879e8) assumed top = contact and inverted a correct ramp.

Settled empirically this time: scratchpad/reflect-probe.html renders both
ramp directions plus a filter+reflect combo on Chromium 151 — transparent-
to-opaque puts the strength at contact; drop-shadow coexists fine.

Kept 43879e8's measured Apple geometry (strongest ~opacity at contact,
dead ~55% out), now correctly oriented: transparent through the far 45%,
ramping to opaque at the bottom.

Verified by eye vs Apple PDF on BOTH fixtures: G2 pentagon (reflection at
contact, diamond over it, drop shadow intact) and the 3c844ac1 logos deck
43879e8 cited (IIT/Tarides logos mirror at contact, fade down). 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
Exit code 128

  6 passed (1.4s)
warning: could not open directory 'viewer/viewer/src/': No such file or directory
fatal: pathspec 'viewer/src/drawables.ts' did not match any files
$ Bash
Commit and push from repo root
git add viewer/src/drawables.ts && git commit -m "drawables: reflection mask direction re-flipped — gradient-bottom is the contact line

G2's pentagon reflection was painting its ghost ~a shape-height away
(peeking out from under the black diamond, reading as a green shadow on
the wrong drawable) with NOTHING at the contact line — exactly inverse of
Apple. Root cause: -webkit-box-reflect flips the mask gradient WITH the
mirrored copy, so gradient-TOP lands on the FAR edge; the previous commit
(43879e8) assumed top = contact and inverted a correct ramp.

Settled empirically this time: scratchpad/reflect-probe.html renders both
ramp directions plus a filter+reflect combo on Chromium 151 — transparent-
to-opaque puts the strength at contact; drop-shadow coexists fine.

Kept 43879e8's measured Apple geometry (strongest ~opacity at contact,
dead ~55% out), now correctly oriented: transparent through the far 45%,
ramping to opaque at the bottom.

Verified by eye vs Apple PDF on BOTH fixtures: G2 pentagon (reflection at
contact, diamond over it, drop shadow intact) and the 3c844ac1 logos deck
43879e8 cited (IIT/Tarides logos mirror at contact, fade down). 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, 9 insertions(+), 6 deletions(-)
   5daf1d3..01021ad  main -> main

Fixed and pushed as 01021ad. Good catch — and your instinct about what was happening was exactly right, just with the causality inverted: the green wasn't the diamond's shadow, it was the pentagon's reflection being painted a full shape-height too low, so only its far end peeked out from under the diamond while the contact line got nothing.

The story: the converter was blameless (pentagon correctly owns both the shadow and reflection in the JSON). The viewer bug was in the -webkit-box-reflect mask — Chromium flips the gradient mask together with the mirrored copy, so the gradient's top edge lands on the far edge of the reflection, not the contact line. The Drawables specialist assumed the opposite yesterday (commit 43879e8) and inverted what had been a correct ramp. Rather than guess again, I wrote a three-box probe page and screenshotted it in Chromium 151 to pin down the semantics, then restored the transparent→opaque direction while keeping the specialist's measured Apple geometry (full strength at contact, dead ~55% out).

Verified by eye on both sides of the dispute: G2's pentagon now shows the green wash right at its base with the diamond painting over it (drop shadow intact), and the 3c844ac1 logos deck — the very fixture the specialist cited — now matches Apple's export too, with IIT/Tarides mirroring at contact and fading down. Gate 6/6. Hard-refresh :8123 to see it.

I want to work on some subtle issues with: 0d5851c0105958b602c58646b98ffaee1b453def8478f9b872aca37319d2ed5f.key - all keynote thumbnails on the left seem to be a little truncated (their right sides and bottoms cut off?) - on the title slide 1, text alignment of How hot is your network? is way off - slide 2: arrow bullets indentation are missing - slide 9: the two text areas are equally centered in Keynote but not in ours - slide 10: presenter notes have excessive leading - slide 19: aside from the existing known indentation/bullet issues, this is a good demonstration of some subtle leading/layout differences we have (our line spacing seems a little loose) - slide 29: centered quote is wrong width/position Also, I would prefer the keynote viewer UI to show all of the slides in one continuous view (like a Pages file) so a presentation is easier to scroll through. figure out what to do with the presentation notes to make them space-efficient please when they are present and out of the way when not.

Thinking

Solid list — several of these look like shared root causes (the notes leading smells like the same UA-margin leak I fixed for shapes, and the loose line spacing is probably line-height: normal vs Apple's tighter default). I'll run ground truth on the deck first, then work through the fidelity items, then rebuild the Keynote UI as a continuous scroll.

$ Bash
Run visual diff on the 0d5851c0 deck
ls -la ~/Development/pnk-fixtures/crawl/0d5851c0105958b602c58646b98ffaee1b453def8478f9b872aca37319d2ed5f.key && uv run --with pillow --with pyobjc-framework-Quartz --with pymupdf python3 scripts/visual_diff.py --app keynote --fixture ~/Development/pnk-fixtures/crawl/0d5851c0105958b602c58646b98ffaee1b453def8478f9b872aca37319d2ed5f.key --out /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/vd-0d58 --base-url http://127.0.0.1:8123 2>&1 | tail -3
-rw-rw-r--@ 1 phebert  staff  3603213 Aug 28 18:09 /Users/phebert/Development/pnk-fixtures/crawl/0d5851c0105958b602c58646b98ffaee1b453def8478f9b872aca37319d2ed5f.key
[visual_diff] wrote 0 region crops
[visual_diff] artifacts: /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/vd-0d58
[visual_diff] summary: /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/vd-0d58/summary.md
Read
{
  "file_path": "/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/vd-0d58/composites/composite-page-1.png"
}
Read
{
  "file_path": "/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/vd-0d58/composites/composite-page-2.png"
}
Thinking
Read
{
  "file_path": "/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/vd-0d58/composites/composite-page-9.png"
}
Read
{
  "file_path": "/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/vd-0d58/composites/composite-page-10.png"
}
Thinking
Read
{
  "file_path": "/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/vd-0d58/composites/composite-page-19.png"
}
Read
{
  "file_path": "/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/vd-0d58/composites/composite-page-29.png"
}
Thinking

Slide 29 confirmed too — the quote box renders ~90px wide and wraps 4 lines. Let me pull the JSON facts for slides 1, 2, 9, 29 and the notes:

$ Bash
Inspect slide 1 geometry
./target/release/pnk2json --pretty ~/Development/pnk-fixtures/crawl/0d5851c0105958b602c58646b98ffaee1b453def8478f9b872aca37319d2ed5f.key > /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/0d58.json 2>&1; python3 -c "
import json
d = json.load(open('/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/0d58.json'))
doc = d.get('document', d)
print('slideSize:', doc.get('slideSize') or doc.get('size'))
sl = doc['slides'][0]
def dump(dr, depth=0):
    c = dr.get('common', {})
    txt = ''
    if dr.get('text'):
        for p in dr['text'].get('paragraphs', [])[:2]:
            if isinstance(p, dict):
                txt += '|'.join((it if isinstance(it,str) else it.get('text',''))[:30] for it in p.get('items',[]) if isinstance(it,(str,dict)) and (isinstance(it,str) or 'text' in it))[:45]
    print(' '*depth, dr.get('type'), c.get('position'), c.get('size'), 'fit=', dr.get('textFit'), repr(txt[:45]))
    for ch in dr.get('children', []): dump(ch, depth+2)
print('--- slide 1 drawables:')
for dr in sl.get('drawables', []): dump(dr)
print('--- slide 1 masterDrawables:')
for dr in sl.get('masterDrawables', []): dump(dr)
"
slideSize: {'width': 1024.0, 'height': 768.0}
--- slide 1 drawables:
 image {'x': 0.0, 'y': 53.29889678955078} {'width': 1024.0, 'height': 471.40216064453125} fit= None ''
 image {'x': 0.0, 'y': 692.1768188476562} {'width': 1024.0, 'height': 75.64632415771484} fit= None ''
 image {'x': -1.1203429698944092, 'y': -8.733123779296875} {'width': 1026.24072265625, 'height': 861.4662475585938} fit= None ''
 image {'x': 181.5, 'y': 619.0} {'width': 643.0, 'height': 36.0} fit= None ''
 shape {'x': 512.0, 'y': 638.0} {'width': 500.7637939453125, 'height': 36.0} fit= None 'How hot is your network?'
 textbox {'x': 511.5, 'y': 728.5} {'width': 0.0, 'height': 0.0} fit= None ''
--- slide 1 masterDrawables:
Thinking
Thinking

Slide 1's title uses center-anchored geometry (x=512, y=638) matching the blue band, consistent with this being an early Keynote format 1.5.0 deck. I'll now check slides 9 and 29 to see if the same pattern applies.

$ Bash
Inspect slides 9 and 29 geometry
python3 -c "
import json
d = json.load(open('/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/0d58.json'))
doc = d.get('document', d)
for idx in (8, 28):
    sl = doc['slides'][idx]
    print(f'--- slide {idx+1}:')
    def dump(dr, depth=0):
        c = dr.get('common', {})
        txt = ''
        if dr.get('text'):
            for p in dr['text'].get('paragraphs', [])[:1]:
                if isinstance(p, dict):
                    txt += '|'.join((it if isinstance(it,str) else it.get('text',''))[:35] for it in p.get('items',[]) if isinstance(it,(str,dict)) and (isinstance(it,str) or 'text' in it))[:40]
        print(' '*depth, dr.get('type'), c.get('position'), c.get('size'), 'fit=', dr.get('textFit'), repr(txt))
        for ch in dr.get('children', []): dump(ch, depth+2)
    for dr in sl.get('drawables', []): dump(dr)
"
--- slide 9:
 textbox {'x': 100.0, 'y': 129.0} {'width': 824.0, 'height': 260.0} fit= shrink '8,906,901'
 textbox {'x': 100.0, 'y': 396.0} {'width': 824.0, 'height': 89.0} fit= shrink 'Unique IP addresses listed by XBL\u2028o'
 textbox {'x': 511.5, 'y': 728.5} {'width': 0.0, 'height': 0.0} fit= None ''
--- slide 29:
 textbox {'x': 60.0, 'y': 158.0} {'width': 873.9999389648438, 'height': 75.64632415771484} fit= None 'ISPs'
 textbox {'x': 59.99997329711914, 'y': 246.9429473876953} {'width': 874.0, 'height': 427.0} fit= None 'But no single benefit has the same '
 shape {'x': 271.0579833984375, 'y': 397.3497009277344} {'width': 0.0, 'height': 0.0} fit= None ''
 textbox {'x': 511.5, 'y': 728.5} {'width': 0.0, 'height': 0.0} fit= None ''
Thinking
Thinking

Found the cause: slide 29's quote shape lacks Apple's natural width so it's wrapping too narrow, and slide 9's boxes are symmetric but likely misaligned/shrunk vertically. Next, I'll look into slide 2's missing arrow bullets.

$ Bash
Slide 2 paragraphs + list styles
python3 -c "
import json
d = json.load(open('/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/0d58.json'))
doc = d.get('document', d)
sl = doc['slides'][1]
for dr in sl.get('drawables', []):
    if dr.get('text'):
        for p in dr['text'].get('paragraphs', []):
            if isinstance(p, dict):
                print('pStyle=', p.get('pStyle'), [(it if isinstance(it,str) else it.get('text',''))[:40] for it in p.get('items',[]) if isinstance(it,(str,dict))])
print('--- para styles with list:')
for i, ps in enumerate(doc.get('styles',{}).get('para',[])):
    if 'list' in ps or 'outlineLevel' in ps: print(i, json.dumps(ps)[:220])
" | head -30
pStyle= 5 ['Introduction']
pStyle= 6 ['“If you can’t measure it, you can’t mana']
pStyle= 6 ['The importance of monitoring how others ']
pStyle= 6 ['And comparing yourself to your peers']
pStyle= 22 ['Every network could do better!']
pStyle= 0 ['']
--- para styles with list:
1 {"horizontalAlignment": "center", "defaultTabStopPt": 46.0, "list": {"level": 1, "markerKind": "none", "markerIndentPt": 18.0}, "keepLinesTogether": true}
2 {"horizontalAlignment": "center", "defaultTabStopPt": 46.0, "list": {"level": 2, "markerKind": "none", "markerIndentPt": 36.0}, "keepLinesTogether": true}
3 {"horizontalAlignment": "center", "defaultTabStopPt": 46.0, "list": {"level": 3, "markerKind": "none", "markerIndentPt": 54.0}, "keepLinesTogether": true}
4 {"horizontalAlignment": "center", "defaultTabStopPt": 46.0, "list": {"level": 4, "markerKind": "none", "markerIndentPt": 72.0}, "keepLinesTogether": true}
6 {"horizontalAlignment": "left", "spaceBeforePt": 18.0, "lineSpacingExactPt": 40.0, "defaultTabStopPt": 46.0, "list": {"level": 0, "markerKind": "image", "markerImage": {"dataId": "5501", "fileName": "rightArrow-5501.png"
7 {"horizontalAlignment": "left", "lineSpacingExactPt": 40.0, "defaultTabStopPt": 46.0, "list": {"level": 1, "markerKind": "image", "markerImage": {"dataId": "5501", "fileName": "rightArrow-5501.png", "preferredFileName": 
8 {"horizontalAlignment": "left", "lineSpacingExactPt": 40.0, "defaultTabStopPt": 46.0, "list": {"level": 2, "markerKind": "image", "markerImage": {"dataId": "5501", "fileName": "rightArrow-5501.png", "preferredFileName": 
9 {"horizontalAlignment": "left", "lineSpacingExactPt": 40.0, "defaultTabStopPt": 46.0, "list": {"level": 3, "markerKind": "image", "markerImage": {"dataId": "5501", "fileName": "rightArrow-5501.png", "preferredFileName": 
10 {"horizontalAlignment": "left", "lineSpacingExactPt": 40.0, "defaultTabStopPt": 46.0, "list": {"level": 4, "markerKind": "image", "markerImage": {"dataId": "5501", "fileName": "rightArrow-5501.png", "preferredFileName": 
11 {"horizontalAlignment": "left", "spaceBeforePt": 32.0, "defaultTabStopPt": 46.0, "list": {"level": 0, "markerKind": "string", "markerText": "\u2022", "markerScale": 0.75}, "keepLinesTogether": true}
12 {"horizontalAlignment": "left", "spaceBeforePt": 32.0, "defaultTabStopPt": 46.0, "list": {"level": 1, "markerKind": "string", "markerText": "\u2022", "markerIndentPt": 27.0, "markerScale": 0.75}, "keepLinesTogether": tru
13 {"horizontalAlignment": "left", "spaceBeforePt": 32.0, "defaultTabStopPt": 46.0, "list": {"level": 2, "markerKind": "string", "markerText": "\u2022", "markerIndentPt": 54.0, "markerScale": 0.75}, "keepLinesTogether": tru
14 {"horizontalAlignment": "left", "spaceBeforePt": 32.0, "defaultTabStopPt": 46.0, "list": {"level": 3, "markerKind": "string", "markerText": "\u2022", "markerIndentPt": 81.0, "markerScale": 0.75}, "keepLinesTogether": tru
15 {"horizontalAlignment": "left", "spaceBeforePt": 32.0, "defaultTabStopPt": 46.0, "list": {"level": 4, "markerKind": "string", "markerText": "\u2022", "markerIndentPt": 108.0, "markerScale": 0.75}, "keepLinesTogether": tr
16 {"horizontalAlignment": "left", "lineSpacingExactPt": 46.0, "defaultTabStopPt": 46.0, "list": {"level": 0, "markerKind": "image", "markerImage": {"dataId": "2089", "fileName": "rightArrow-2089.png", "preferredFileName": 
17 {"horizontalAlignment": "left", "lineSpacingExactPt": 46.0, "defaultTabStopPt": 46.0, "list": {"level": 1, "markerKind": "image", "markerImage": {"dataId": "2089", "fileName": "rightArrow-2089.png", "preferredFileName": 
18 {"horizontalAlignment": "left", "lineSpacingExactPt": 46.0, "defaultTabStopPt": 46.0, "list": {"level": 2, "markerKind": "image", "markerImage": {"dataId": "2089", "fileName": "rightArrow-2089.png", "preferredFileName": 
19 {"horizontalAlignment": "left", "lineSpacingExactPt": 46.0, "defaultTabStopPt": 46.0, "list": {"level": 3, "markerKind": "image", "markerImage": {"dataId": "2089", "fileName": "rightArrow-2089.png", "preferredFileName": 
20 {"horizontalAlignment": "left", "lineSpacingExactPt": 46.0, "defaultTabStopPt": 46.0, "list": {"level": 4, "markerKind": "image", "markerImage": {"dataId": "2089", "fileName": "rightArrow-2089.png", "preferredFileName": 
22 {"horizontalAlignment": "left", "spaceBeforePt": 18.0, "lineSpacingExactPt": 40.0, "defaultTabStopPt": 46.0, "list": {"level": 1, "markerKind": "image", "markerImage": {"dataId": "5501", "fileName": "rightArrow-5501.png"
25 {"horizontalAlignment": "left", "spaceBeforePt": 18.0, "lineSpacingExactPt": 40.0, "defaultTabStopPt": 46.0, "list": {"level": 0, "markerKind": "image", "markerImage": {"dataId": "5501", "fileName": "rightArrow-5501.png"
Thinking
Thinking

Slide 2/19's arrows use an image-based marker, which the viewer skips since it only renders string/number markers, explaining the missing glyph and indent. Moving on to check slide 9/29's details.

$ Bash
Full detail on slides 9 and 29 drawables
python3 -c "
import json
d = json.load(open('/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/0d58.json'))
doc = d.get('document', d)
import re
# slide 9 full textboxes
for idx in (8, 28):
    print(f'=== slide {idx+1}')
    for dr in doc['slides'][idx].get('drawables', []):
        print(json.dumps({k:v for k,v in dr.items() if k not in ('text','common')}, default=str)[:200])
        print('  common keys:', {k: v for k,v in (dr.get('common') or {}).items() if k not in ('position','size')})
        if dr.get('text'):
            for p in dr['text'].get('paragraphs', []):
                if isinstance(p, dict):
                    print('   p pStyle=', p.get('pStyle'), [(it if isinstance(it,str) else (it.get('text') or it.get('type','?')))[:38] for it in p.get('items',[])])
"
=== slide 9
{"type": "textbox", "verticalAlignment": "bottom", "textFit": "shrink"}
  common keys: {'angleDeg': 0.0, 'locked': False, 'textWrap': {'kind': 'right', 'marginPt': 12.0}, 'style': {}, 'opacity': 1.0, 'placeholder': {'role': 'title'}}
   p pStyle= 0 ['8,906,901']
{"type": "textbox", "verticalAlignment": "top", "textFit": "shrink"}
  common keys: {'angleDeg': 0.0, 'locked': False, 'textWrap': {'kind': 'right', 'marginPt': 12.0}, 'style': {}, 'opacity': 1.0, 'placeholder': {'role': 'body'}}
   p pStyle= 0 ['Unique IP addresses listed by XBL\u2028on 1']
   p pStyle= 0 []
{"type": "textbox", "verticalAlignment": "top"}
  common keys: {'angleDeg': 0.0, 'locked': False, 'textWrap': {'kind': 'right', 'marginPt': 12.0}, 'style': {}, 'opacity': 1.0, 'placeholder': {'role': 'slide-number'}}
   p pStyle= 0 ['field']
=== slide 29
{"type": "textbox", "verticalAlignment": "middle"}
  common keys: {'angleDeg': 0.0, 'locked': False, 'textWrap': {'kind': 'right', 'marginPt': 12.0}, 'style': {}, 'opacity': 1.0, 'placeholder': {'role': 'title'}}
   p pStyle= 5 ['ISPs']
{"type": "textbox", "verticalAlignment": "top"}
  common keys: {'angleDeg': 0.0, 'locked': False, 'textWrap': {'kind': 'right', 'marginPt': 12.0}, 'style': {}, 'opacity': 1.0, 'placeholder': {'role': 'body'}}
   p pStyle= 6 ['But no single benefit has the same pun']
{"type": "shape", "geometry": {"naturalSize": {"width": 0.0, "height": 0.0}, "path": {"elements": [{"type": "move", "points": [0.0, 0.0]}, {"type": "line", "points": [100.0, 0.0]}, {"type": "line", "p
  common keys: {'angleDeg': 0.0, 'locked': False, 'textWrap': {'kind': 'right', 'marginPt': 12.0}, 'style': {}, 'opacity': 1.0}
   p pStyle= 23 []
   p pStyle= 23 ['“Adds 3.4% to GDP”']
   p pStyle= 23 []
{"type": "textbox", "verticalAlignment": "top"}
  common keys: {'angleDeg': 0.0, 'locked': False, 'textWrap': {'kind': 'right', 'marginPt': 12.0}, 'style': {}, 'opacity': 1.0, 'placeholder': {'role': 'slide-number'}}
   p pStyle= 0 ['field']
Thinking
Thinking

Found the causes: slide 9's number box uses bottom alignment with shrink-to-fit that our pass likely ignores, and slide 29's quote is a 0×0 shape positioned via paragraph alignment. Now checking the viewer's shrink/grow logic, 0×0 shape handling, and keynote.ts for thumbnails/notes/layout.

$ Bash
Quote styles + textFit code locations
python3 -c "
import json
d = json.load(open('/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/0d58.json'))
doc = d.get('document', d)
print('pStyle 23:', json.dumps(doc['styles']['para'][23]))
print('pStyle 0:', json.dumps(doc['styles']['para'][0]))
print('pStyle 5:', json.dumps(doc['styles']['para'][5]))
print('pStyle 6 full:', json.dumps(doc['styles']['para'][6]))
sl = doc['slides'][28]
q = sl['drawables'][2]
for p in q['text']['paragraphs']:
    for it in p.get('items', []):
        if isinstance(it, dict) and 'text' in it: print('quote run cStyle:', it.get('cStyle'), json.dumps(doc['styles']['char'][it['cStyle']]) if it.get('cStyle') is not None else None)
"; grep -n "shrink\|grow" /Users/phebert/pnk/viewer/src/drawables.ts | head -20
pStyle 23: {"horizontalAlignment": "left", "spaceBeforePt": 18.0, "lineSpacingExactPt": 40.0, "defaultTabStopPt": 46.0, "keepLinesTogether": true}
pStyle 0: {"horizontalAlignment": "center", "defaultTabStopPt": 46.0, "keepLinesTogether": true}
pStyle 5: {"horizontalAlignment": "left", "defaultTabStopPt": 46.0, "keepLinesTogether": true}
pStyle 6 full: {"horizontalAlignment": "left", "spaceBeforePt": 18.0, "lineSpacingExactPt": 40.0, "defaultTabStopPt": 46.0, "list": {"level": 0, "markerKind": "image", "markerImage": {"dataId": "5501", "fileName": "rightArrow-5501.png", "preferredFileName": "rightArrow.png"}, "markerFontName": "STIXGeneral-Regular", "markerScale": 0.3499999940395355}, "keepLinesTogether": true}
quote run cStyle: 34 {"fontName": "AppleSDGothicNeo-Regular", "fontSizePt": 51.0, "fontColor": "#002452", "outline": {"widthPt": 0.0}}
127:      // scalar = pointiness (0..1): inner radius shrinks as it grows
506:  // both fixes active would double-shrink — keep exactly one.)
514:// Text fit (model textFit: "grow" | "shrink"; absent = fixed box, clipped)
518: * "grow": a plain Keynote/Pages text box auto-sizes its height to its
521: * let the box grow downward instead of clipping the last line.
522: * "shrink": tag the box for the post-attach measurement pass (applyTextFit).
527:  fit: "grow" | "shrink" | undefined,
530:  if (fit === "grow") {
542:  } else if (fit === "shrink") {
543:    div.dataset.textFit = "shrink";
549:    // shrink bounded (>=0.6) to absorb that drift — never past it, so truly
556: * Post-attach pass for boxes tagged "shrink" (Keynote's "shrink text on
582:    // "shrink" = Keynote's shrink-on-overflow (scale as far as needed);
583:    // "tolerance" = fixed box, bounded shrink for font-metric drift only.
584:    const minScale = box.dataset.textFit === "shrink" ? 0.35 : 0.6;
835:        // Shapes keep their geometry: a shape never grows for its text, so
836:        // "grow" degrades to the fixed-box tolerance mode.
837:        applyTextFitMode(div, layer, d.textFit === "grow" ? undefined : d.textFit, d.verticalAlignment);
Read
{
  "file_path": "/Users/phebert/pnk/viewer/src/drawables.ts",
  "offset": 514,
  "limit": 115
}
514	// Text fit (model textFit: "grow" | "shrink"; absent = fixed box, clipped)
515	// ---------------------------------------------------------------------------
516	
517	/**
518	 * "grow": a plain Keynote/Pages text box auto-sizes its height to its
519	 * content; the stored height is Apple's layout under Apple's font metrics,
520	 * so browsers (taller line boxes, fallback fonts) treat it as a MINIMUM and
521	 * let the box grow downward instead of clipping the last line.
522	 * "shrink": tag the box for the post-attach measurement pass (applyTextFit).
523	 */
524	function applyTextFitMode(
525	  div: HTMLElement,
526	  layer: HTMLElement,
527	  fit: "grow" | "shrink" | undefined,
528	  verticalAlignment?: string,
529	): void {
530	  if (fit === "grow") {
531	    // keep the stored height as a minimum so vertical alignment still works
532	    // when the content is shorter than the box
533	    const storedH = div.style.height;
534	    div.style.height = "auto";
535	    if (storedH && storedH !== "auto") div.style.minHeight = storedH;
536	    div.style.display = "flex";
537	    div.style.flexDirection = "column";
538	    div.style.justifyContent = verticalAlignStyle({ verticalAlignment });
539	    layer.style.position = "relative";
540	    layer.style.overflow = "visible";
541	    layer.style.height = "auto";
542	  } else if (fit === "shrink") {
543	    div.dataset.textFit = "shrink";
544	  } else {
545	    // Fixed box (no flag). Apple laid the stored frame out with ITS font
546	    // metrics; browser fallback faces + line boxes run taller, so text that
547	    // fits exactly in Keynote clips mid-line here (0f9df553 byline: 132px of
548	    // content in a 102pt box). Tolerance mode: the measurement pass may
549	    // shrink bounded (>=0.6) to absorb that drift — never past it, so truly
550	    // authored overflow still clips like Apple's fixed frames do.
551	    div.dataset.textFit = "tolerance";
552	  }
553	}
554	
555	/**
556	 * Post-attach pass for boxes tagged "shrink" (Keynote's "shrink text on
557	 * overflow"): when the laid-out text is taller than its box, scale it down.
558	 * `scale(s)` with an inverse width (100/s %) reproduces a font-size
559	 * reduction — same wrap width in text space — and the transform origin
560	 * follows the box's vertical alignment. MUST run with `root` attached and
561	 * displayed (measurement). Idempotent: safe to re-run.
562	 */
563	export function applyTextFit(root: HTMLElement): void {
564	  for (const box of root.querySelectorAll<HTMLElement>("[data-text-fit]")) {
565	    const layer = box.querySelector<HTMLElement>(":scope > .drawable-text");
566	    const inner = layer?.querySelector<HTMLElement>(":scope > .drawable-text-inner");
567	    if (!layer || !inner) continue;
568	    if (box.dataset.textFit === "edge-clamp") {
569	      // Zero-size auto box: scale down (bounded) when the laid-out label
570	      // spills past the canvas' right edge — Apple's metrics fit it inside.
571	      const canvas = box.closest<HTMLElement>(".canvas-inner");
572	      if (!canvas) continue;
573	      inner.style.transform = "";
574	      const spill = box.offsetLeft + box.offsetWidth - canvas.clientWidth;
575	      if (spill > 1 && box.offsetWidth > 0) {
576	        const s = Math.max((box.offsetWidth - spill) / box.offsetWidth, 0.7);
577	        inner.style.transform = `scale(${s.toFixed(4)})`;
578	        inner.style.transformOrigin = "left top";
579	      }
580	      continue;
581	    }
582	    // "shrink" = Keynote's shrink-on-overflow (scale as far as needed);
583	    // "tolerance" = fixed box, bounded shrink for font-metric drift only.
584	    const minScale = box.dataset.textFit === "shrink" ? 0.35 : 0.6;
585	    inner.style.transform = "";
586	    inner.style.width = "";
587	    const boxH = layer.clientHeight;
588	    if (boxH <= 0) continue;
589	    const align = layer.style.alignItems;
590	    const origin = align === "flex-end" ? "left bottom" : align === "center" ? "left center" : "left top";
591	    let s = 1;
592	    // Rewrapping at the compensated width changes the height, so iterate;
593	    // s only ever decreases, which converges without oscillation.
594	    for (let i = 0; i < 3; i++) {
595	      const contentH = inner.offsetHeight;
596	      if (contentH * s <= boxH + 0.5) break;
597	      s = Math.max(Math.min(s, boxH / contentH), minScale);
598	      inner.style.width = `${(100 / s).toFixed(3)}%`;
599	      inner.style.transform = `scale(${s.toFixed(4)})`;
600	      inner.style.transformOrigin = origin;
601	    }
602	  }
603	}
604	
605	// ---------------------------------------------------------------------------
606	// Chart (minimal: inline numeric series -> SVG bars, else a summary card)
607	// ---------------------------------------------------------------------------
608	
609	function chartSummary(d: Extract<Drawable, { type: "chart" }>): HTMLElement {
610	  const card = el("div", "unknown-drawable");
611	  const c = d.chart;
612	  const seriesDesc = c.series.map((s) => s.name ?? "series").join(", ");
613	  card.textContent = `${c.type}${c.threeD ? " (3D)" : ""} chart — ${c.series.length} series, ${c.categories.length} categories${seriesDesc ? `: ${seriesDesc}` : ""}`;
614	  return card;
615	}
616	
617	function chartSvg(chart: ChartModel, w: number, h: number): SVGSVGElement | null {
618	  const numeric = chart.series.every((s) => s.values.every((v) => v === null || typeof v === "number"));
619	  if (!numeric || chart.series.length === 0 || chart.categories.length === 0) return null;
620	  const NS = "http://www.w3.org/2000/svg";
621	  const svg = document.createElementNS(NS, "svg") as SVGSVGElement;
622	  svg.setAttribute("viewBox", `0 0 ${w} ${h}`);
623	  const max = Math.max(...chart.series.flatMap((s) => s.values.map((v) => (typeof v === "number" ? v : 0))), 1e-9);
624	  const colors = chart.seriesColors ?? ["#4a90d9", "#e0762e", "#7bb662", "#b0578d", "#5b6abf"];
625	  const groupW = w / chart.categories.length;
626	  const lineKinds = ["line", "area", "stacked-area", "scatter"];
627	  if (lineKinds.includes(chart.type)) {
628	    // Line family: one polyline per series through the category centers,
Thinking
Thinking

I'll check verticalAlignStyle and use Playwright to inspect live DOM measurements, since the shrink pass relies on layer.style.alignItems and bottom-align isn't landing as expected.

$ Bash
verticalAlignStyle + harness load mechanism
grep -n "function verticalAlignStyle" -A 8 /Users/phebert/pnk/viewer/src/drawables.ts; grep -n "setInputFiles\|dispatchEvent\|DataTransfer\|drop" /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/vd-0d58/render.js | head
493:function verticalAlignStyle(d: { verticalAlignment?: string }): string {
494-  return d.verticalAlignment === "middle" ? "center" : d.verticalAlignment === "bottom" ? "flex-end" : "flex-start";
495-}
496-
497-/** Text content of a textbox/shape, filling the positioned container. */
498-function textLayer(d: Drawable & { text?: unknown; common?: DrawableCommon }, doc: HydratedDoc, ctx: ViewerCtx): HTMLElement | null {
499-  if (!("text" in d) || !d.text || (d.text as { paragraphs?: unknown[] }).paragraphs === undefined) return null;
500-  const layer = el("div", "drawable-text");
501-  layer.style.alignItems = verticalAlignStyle(d as { verticalAlignment?: string });
13:  await page.setInputFiles("#file-input", fixture);
📝 Write probe-0d58.mjs
/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/probe-0d58.mjs
import { chromium } from "playwright";

const fixture = "/Users/phebert/Development/pnk-fixtures/crawl/0d5851c0105958b602c58646b98ffaee1b453def8478f9b872aca37319d2ed5f.key";
const b = await chromium.launch();
const page = await b.newPage({ viewport: { width: 1400, height: 1000 } });
await page.goto("http://127.0.0.1:8123/");
await page.setInputFiles("#file-input", fixture);
await page.waitForSelector(".canvas-inner", { timeout: 30000 });
await page.waitForTimeout(1500);

const info = await page.evaluate((n) => {
  // find slide canvas N (however the viewer structures slides)
  const canvases = [...document.querySelectorAll(".canvas-inner")];
  const out = { canvasCount: canvases.length };
  const canvas = canvases[n - 1];
  if (!canvas) return out;
  const cr = canvas.getBoundingClientRect();
  out.canvas = { w: cr.width, h: cr.height };
  out.drawables = [...canvas.querySelectorAll(":scope > .canvas-drawable")].map((d) => {
    const r = d.getBoundingClientRect();
    const layer = d.querySelector(":scope > .drawable-text");
    const inner = layer?.querySelector(".drawable-text-inner");
    const ir = inner?.getBoundingClientRect();
    return {
      cls: d.className,
      box: { x: +(r.x - cr.x).toFixed(1), y: +(r.y - cr.y).toFixed(1), w: +r.width.toFixed(1), h: +r.height.toFixed(1) },
      text: (d.textContent || "").trim().slice(0, 30),
      alignItems: layer?.style.alignItems,
      layerDisplay: layer ? getComputedStyle(layer).display : undefined,
      innerBox: ir ? { y: +(ir.y - cr.y).toFixed(1), h: +ir.height.toFixed(1) } : undefined,
      transform: inner?.style.transform,
      dataFit: d.dataset.textFit,
    };
  });
  return out;
}, 9);
console.log(JSON.stringify(info, null, 1));
await b.close();
File created successfully at: /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/probe-0d58.mjs (file state is current in your context — no need to Read it back)
$ Bash
Probe slide 9 DOM geometry
cd /Users/phebert/pnk/viewer && node /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/probe-0d58.mjs
Exit code 1
node:internal/modules/package_json_reader:314
  throw new ERR_MODULE_NOT_FOUND(packageName, fileURLToPath(base), null);
        ^

Error [ERR_MODULE_NOT_FOUND]: Cannot find package 'playwright' imported from /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/probe-0d58.mjs
    at Object.getPackageJSONURL (node:internal/modules/package_json_reader:314:9)
    at packageResolve (node:internal/modules/esm/resolve:768:81)
    at moduleResolve (node:internal/modules/esm/resolve:855:18)
    at defaultResolve (node:internal/modules/esm/resolve:985:11)
    at #cachedDefaultResolve (node:internal/modules/esm/loader:747:20)
    at ModuleLoader.resolve (node:internal/modules/esm/loader:724:38)
    at ModuleLoader.getModuleJobForImport (node:internal/modules/esm/loader:320:38)
    at ModuleJob._link (node:internal/modules/esm/module_job:182:49) {
  code: 'ERR_MODULE_NOT_FOUND'
}

Node.js v22.23.2
$ Bash
Run probe from viewer dir for playwright resolution
cp /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/probe-0d58.mjs /Users/phebert/pnk/viewer/probe-tmp.mjs && cd /Users/phebert/pnk/viewer && node probe-tmp.mjs
{
 "canvasCount": 33,
 "canvas": {
  "w": 168,
  "h": 126
 },
 "drawables": [
  {
   "cls": "canvas-drawable",
   "box": {
    "x": 0,
    "y": 0.2,
    "w": 168,
    "h": 23.5
   },
   "text": ""
  },
  {
   "cls": "canvas-drawable",
   "box": {
    "x": 0,
    "y": 113.6,
    "w": 168,
    "h": 12.4
   },
   "text": ""
  },
  {
   "cls": "canvas-drawable",
   "box": {
    "x": 16.4,
    "y": 21.2,
    "w": 135.2,
    "h": 42.7
   },
   "text": "8,906,901",
   "alignItems": "flex-end",
   "layerDisplay": "flex",
   "innerBox": {
    "y": 47.3,
    "h": 16.6
   },
   "transform": "",
   "dataFit": "shrink"
  },
  {
   "cls": "canvas-drawable",
   "box": {
    "x": 16.4,
    "y": 65,
    "w": 135.2,
    "h": 14.6
   },
   "text": "Unique IP addresses listed by ",
   "alignItems": "flex-start",
   "layerDisplay": "flex",
   "innerBox": {
    "y": 65,
    "h": 14.6
   },
   "transform": "scale(0.9082)",
   "dataFit": "shrink"
  }
 ]
}
Thinking
Thinking

I found that probe hit the slide 9 thumbnail instead of the main canvas, and confirmed there are 33 mini canvases (relevant to the truncation issue too). Now I'll navigate to slide 9 in the main view and measure it at full size.

$ Bash
How harness/viewer navigate slides
grep -n "slide-list-item\|thumb\|click\|current" /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/vd-0d58/render.js | head; grep -n "slide-list-item\|slide-stage\|main.*canvas\|renderSlide" /Users/phebert/pnk/viewer/src/keynote.ts | head -20
18:  // Deck mode (.key): click through thumbnails and screenshot the STAGE only,
21:  const items = page.locator(".slide-list-item");
27:      await items.nth(i).click();
299:  stage.className = "slide-stage";
349:  stageSlot.className = "slide-stage-slot";
366:    item.className = "slide-list-item";
$ Bash
Measure slide 9 main stage geometry
cat > probe-tmp.mjs <<'EOF'
import { chromium } from "playwright";
const fixture = "/Users/phebert/Development/pnk-fixtures/crawl/0d5851c0105958b602c58646b98ffaee1b453def8478f9b872aca37319d2ed5f.key";
const b = await chromium.launch();
const page = await b.newPage({ viewport: { width: 1400, height: 1000 } });
await page.goto("http://127.0.0.1:8123/");
await page.setInputFiles("#file-input", fixture);
await page.waitForSelector(".slide-list-item", { timeout: 30000 });
await page.locator(".slide-list-item").nth(8).click();
await page.waitForTimeout(800);
const info = await page.evaluate(() => {
  const stage = document.querySelector(".slide-stage");
  const canvas = stage.querySelector(".canvas-inner");
  const cr = canvas.getBoundingClientRect();
  const scale = cr.width / 1024;
  return {
    canvas: { w: cr.width, h: cr.height, scale },
    drawables: [...canvas.querySelectorAll(":scope > .canvas-drawable")].map((d) => {
      const r = d.getBoundingClientRect();
      const layer = d.querySelector(":scope > .drawable-text");
      const inner = layer?.querySelector(".drawable-text-inner");
      const ir = inner?.getBoundingClientRect();
      return {
        text: (d.textContent || "").trim().slice(0, 25),
        docBox: { x: +((r.x - cr.x) / scale).toFixed(0), y: +((r.y - cr.y) / scale).toFixed(0), w: +(r.width / scale).toFixed(0), h: +(r.height / scale).toFixed(0) },
        innerDoc: ir ? { x: +((ir.x - cr.x) / scale).toFixed(0), y: +((ir.y - cr.y) / scale).toFixed(0), w: +(ir.width / scale).toFixed(0), h: +(ir.height / scale).toFixed(0) } : null,
        alignItems: layer?.style.alignItems, transform: inner?.style.transform, width: inner?.style.width,
      };
    }),
  };
});
console.log(JSON.stringify(info, null, 1));
await b.close();
EOF
node probe-tmp.mjs
{
 "canvas": {
  "w": 860,
  "h": 645,
  "scale": 0.83984375
 },
 "drawables": [
  {
   "text": "",
   "docBox": {
    "x": 0,
    "y": 1,
    "w": 1024,
    "h": 143
   },
   "innerDoc": null
  },
  {
   "text": "",
   "docBox": {
    "x": 0,
    "y": 692,
    "w": 1024,
    "h": 76
   },
   "innerDoc": null
  },
  {
   "text": "8,906,901",
   "docBox": {
    "x": 100,
    "y": 129,
    "w": 824,
    "h": 260
   },
   "innerDoc": {
    "x": 100,
    "y": 288,
    "w": 824,
    "h": 101
   },
   "alignItems": "flex-end",
   "transform": "",
   "width": ""
  },
  {
   "text": "Unique IP addresses liste",
   "docBox": {
    "x": 100,
    "y": 396,
    "w": 824,
    "h": 89
   },
   "innerDoc": {
    "x": 100,
    "y": 396,
    "w": 733,
    "h": 89
   },
   "alignItems": "flex-start",
   "transform": "scale(0.89)",
   "width": "112.36%"
  }
 ]
}
Thinking
Thinking

Found the slide 9 bug: the shrink pass's width compensation gets overridden because the flex container's default flex-shrink squashes it back, causing left-drift on centered text — a one-line fix. Now I'll dig into slide 1's raw archive to investigate its center-anchored geometry from the older 1.5.0 format.

$ Bash
Find the title text in the archive dump
cargo run --release -p iwadump -- ~/Development/pnk-fixtures/crawl/0d5851c0105958b602c58646b98ffaee1b453def8478f9b872aca37319d2ed5f.key 2>/dev/null | grep -i "how hot" | head -5
(Bash completed with no output)
$ Bash
iwadump usage
cargo run --release -p iwadump -- --help 2>/dev/null | head -20; ls crates/iwadump/src/
Exit code 1
Dump the structure of an iWork '13+ document (.pages/.numbers/.key)

Usage: iwadump [OPTIONS] <FILE>

Arguments:
  <FILE>  Document: flat .pages/.numbers/.key file or package directory

Options:
      --list               List container members only (no IWA decode)
      --archive <ARCHIVE>  Decode a single IWA stream (member name, or a unique suffix like `Document.iwa`)
      --message <MESSAGE>  Dump one payload as hex + best-effort field walk, by archive local id
      --json               Machine-readable JSON output
      --limit <LIMIT>      Limit messages listed per stream (default: all)
      --legacy-ok          List legacy (pre-'13) files as raw zip members instead of rejecting
  -h, --help               Print help
  -V, --version            Print version
ls: crates/iwadump/src/: No such file or directory
$ Bash
Dump archive structure overview
cargo run --release -p iwadump -- ~/Development/pnk-fixtures/crawl/0d5851c0105958b602c58646b98ffaee1b453def8478f9b872aca37319d2ed5f.key 2>/dev/null | head -40
/Users/phebert/Development/pnk-fixtures/crawl/0d5851c0105958b602c58646b98ffaee1b453def8478f9b872aca37319d2ed5f.key — flat zip, 130 members, 52 iwa streams, app: keynote
  Index/Document.iwa — 1 blocks, 7658 B compressed → 11980 B decoded, 73 archives
    id=1      KN.DocumentArchive                            len=40       ok
    id=2405   TSA.FunctionBrowserStateArchive               len=17       ok
    id=2406   KN.ShowArchive                                len=66       ok
    id=2409   KN.ThemeArchive                               len=2417     ok
    id=2962   KN.SlideNodeArchive                           len=89       ok
    id=2970   KN.ClassicThemeRecordArchive                  len=103      ok
    id=3193   KN.ClassicStylesheetRecordArchive             len=708      ok
    id=52343  TSCH.ChartStylePreset                         len=78       ok
    id=2969   KN.SlideNodeArchive                           len=89       ok
    id=2968   KN.SlideNodeArchive                           len=89       ok
    id=2467   TST.TableStylePresetArchive                   len=7        ok
    id=2728   TST.TableStyleNetworkArchive                  len=57       ok
    id=2960   KN.SlideNodeArchive                           len=89       ok
    id=2475   TSCH.ChartStylePreset                         len=158      ok
    id=2957   TSCH.ChartStylePreset                         len=78       ok
    id=2959   KN.SlideNodeArchive                           len=89       ok
    id=2468   TST.TableStylePresetArchive                   len=7        ok
    id=2544   TST.TableStyleNetworkArchive                  len=57       ok
    id=2472   TST.TableStylePresetArchive                   len=7        ok
    id=2731   TST.TableStyleNetworkArchive                  len=57       ok
    id=2470   TST.TableStylePresetArchive                   len=7        ok
    id=2729   TST.TableStyleNetworkArchive                  len=57       ok
    id=2958   KN.SlideNodeArchive                           len=89       ok
    id=2477   TSCH.ChartStylePreset                         len=153      ok
    id=2473   TSCH.ChartStylePreset                         len=158      ok
    id=2963   KN.SlideNodeArchive                           len=89       ok
    id=2476   TSCH.ChartStylePreset                         len=153      ok
    id=2964   KN.SlideNodeArchive                           len=89       ok
    id=2961   KN.SlideNodeArchive                           len=89       ok
    id=2471   TST.TableStylePresetArchive                   len=7        ok
    id=2573   TST.TableStyleNetworkArchive                  len=57       ok
    id=2965   KN.SlideNodeArchive                           len=89       ok
    id=2967   KN.SlideNodeArchive                           len=88       ok
    id=2478   TSCH.ChartStylePreset                         len=143      ok
    id=2956   TST.TableStylePresetArchive                   len=7        ok
    id=3345   TST.TableStyleNetworkArchive                  len=57       ok
    id=2474   TSCH.ChartStylePreset                         len=153      ok
    id=2469   TST.TableStylePresetArchive                   len=7        ok
Thinking
$ Bash
List slide iwa members
cargo run --release -p iwadump -- --list ~/Development/pnk-fixtures/crawl/0d5851c0105958b602c58646b98ffaee1b453def8478f9b872aca37319d2ed5f.key 2>/dev/null | grep -i "slide\|\.iwa" | head -20
      7658  Index/Document.iwa
      1372  Index/MasterSlide-6.iwa
      1365  Index/MasterSlide-5.iwa
      1351  Index/MasterSlide-7.iwa
       983  Index/Slide29.iwa
       862  Index/Slide5.iwa
       884  Index/Slide30.iwa
       908  Index/Slide11.iwa
      1357  Index/MasterSlide.iwa
       824  Index/Slide21.iwa
      1058  Index/Slide10.iwa
      1329  Index/Slide6.iwa
       962  Index/Slide2.iwa
      1261  Index/Slide25.iwa
       859  Index/Slide12.iwa
       827  Index/Slide7/Slide2.iwa
       915  Index/Slide19.iwa
      1366  Index/MasterSlide-4.iwa
      1353  Index/MasterSlide-2.iwa
      1207  Index/Slide14.iwa
$ Bash
Find slide 1's archive
for m in Slide.iwa Slide1.iwa Slide-1.iwa; do echo "== $m"; cargo run --release -p iwadump -- --archive "$m" ~/Development/pnk-fixtures/crawl/0d5851c0105958b602c58646b98ffaee1b453def8478f9b872aca37319d2ed5f.key 2>/dev/null | head -20; done
== Slide.iwa
/Users/phebert/Development/pnk-fixtures/crawl/0d5851c0105958b602c58646b98ffaee1b453def8478f9b872aca37319d2ed5f.key — flat zip, 130 members, 1 iwa streams, app: keynote
  Index/MasterSlide.iwa — 1 blocks, 1357 B compressed → 2709 B decoded, 13 archives
    id=2881   KN.SlideArchive                               len=221      ok
    id=3434   KN.PlaceholderArchive                         len=188      ok
    id=3638   TSWP.StorageArchive                           len=60       ok
    id=3433   KN.PlaceholderArchive                         len=188      ok
    id=3641   TSWP.StorageArchive                           len=62       ok
    id=3431   KN.PlaceholderArchive                         len=188      ok
    id=3639   TSWP.StorageArchive                           len=73       ok
    id=3432   KN.PlaceholderArchive                         len=188      ok
    id=3637   TSWP.StorageArchive                           len=188      ok
    id=2882   TSD.ImageArchive                              len=115      ok
    id=3640   TSD.MaskArchive                               len=170      ok
    id=3435   TSD.GuideStorageArchive                       len=0        ok
    id=3436   KN.ClassicStylesheetRecordArchive             len=743      ok
== Slide1.iwa
/Users/phebert/Development/pnk-fixtures/crawl/0d5851c0105958b602c58646b98ffaee1b453def8478f9b872aca37319d2ed5f.key — flat zip, 130 members, 2 iwa streams, app: keynote
  Index/Slide7/Slide1.iwa — 1 blocks, 1031 B compressed → 1535 B decoded, 11 archives
    id=2924   KN.SlideArchive                               len=111      ok
    id=3821   KN.NoteArchive                                len=5        ok
    id=3962   TSWP.StorageArchive                           len=62       ok
    id=3817   KN.PlaceholderArchive                         len=188      ok
    id=3961   TSWP.StorageArchive                           len=75       ok
    id=3818   KN.PlaceholderArchive                         len=188      ok
    id=3960   TSWP.StorageArchive                           len=386      ok
    id=3819   KN.PlaceholderArchive                         len=188      ok
    id=3959   TSWP.StorageArchive                           len=76       ok
    id=4021   TSWP.NumberAttachmentArchive                  len=8        ok
    id=3820   TSD.GuideStorageArchive                       len=0        ok
  Index/Slide1.iwa — 1 blocks, 1280 B compressed → 2300 B decoded, 17 archives
    id=2892   KN.SlideArchive                               len=148      ok
    id=3369   KN.PlaceholderArchive                         len=188      ok
    id=3546   TSWP.StorageArchive                           len=76       ok
    id=3722   TSWP.NumberAttachmentArchive                  len=8        ok
    id=43271  TSD.ImageArchive                              len=108      ok
    id=3366   TSWP.ShapeInfoArchive                         len=183      ok
== Slide-1.iwa
/Users/phebert/Development/pnk-fixtures/crawl/0d5851c0105958b602c58646b98ffaee1b453def8478f9b872aca37319d2ed5f.key — flat zip, 130 members, 1 iwa streams, app: keynote
  Index/MasterSlide-1.iwa — 1 blocks, 1526 B compressed → 3128 B decoded, 15 archives
    id=2883   KN.SlideArchive                               len=211      ok
    id=3487   TSD.ImageArchive                              len=110      ok
    id=27664  TSWP.ShapeInfoArchive                         len=184      ok
    id=27665  TSWP.StorageArchive                           len=236      ok
    id=3491   KN.PlaceholderArchive                         len=188      ok
    id=3678   TSWP.StorageArchive                           len=62       ok
    id=3490   KN.PlaceholderArchive                         len=188      ok
    id=3680   TSWP.StorageArchive                           len=188      ok
    id=3494   KN.ClassicStylesheetRecordArchive             len=743      ok
    id=3489   KN.PlaceholderArchive                         len=188      ok
    id=3679   TSWP.StorageArchive                           len=72       ok
    id=3488   TSD.ImageArchive                              len=110      ok
    id=3492   KN.PlaceholderArchive                         len=188      ok
    id=3677   TSWP.StorageArchive                           len=60       ok
    id=3493   TSD.GuideStorageArchive                       len=0        ok
Thinking
$ Bash
Dump the title ShapeInfoArchive raw fields
cargo run --release -p iwadump -- --archive Index/Slide1.iwa --message 3366 ~/Development/pnk-fixtures/crawl/0d5851c0105958b602c58646b98ffaee1b453def8478f9b872aca37319d2ed5f.key 2>/dev/null | head -60
stream: Index/Slide1.iwa
local id: 3366
type: 2011 (TSWP.ShapeInfoArchive)
payload length: 183 bytes
status: decoded (TSWP.ShapeInfoArchive)

hex:
00000000  0a af 01 0a 3e 0a 1f 0a  0a 0d 00 00 00 44 15 00  |....>........D..|
00000010  80 1f 44 12 0a 0d c4 61  fa 43 15 00 00 10 42 18  |..D....a.C....B.|
00000020  00 25 00 00 00 00 12 03  08 cc 16 1a 12 08 04 10  |.%..............|
00000030  02 18 01 25 00 00 40 41  2d 00 00 00 3f 30 00 28  |...%..@A-...?0.(|
00000040  00 38 00 12 03 08 fa 12  1a 68 08 00 10 00 2a 62  |.8.......h....*b|
00000050  12 0a 0d c5 61 fa 43 15  00 00 10 42 1a 54 0a 0e  |....a.C....B.T..|
00000060  08 01 12 0a 0d 00 00 00  00 15 00 00 00 00 0a 0e  |................|
00000070  08 02 12 0a 0d c5 61 fa  43 15 00 00 00 00 0a 0e  |......a.C.......|
00000080  08 02 12 0a 0d c5 61 fa  43 15 00 00 10 42 0a 0e  |......a.C....B..|
00000090  08 02 12 0a 0d 00 00 00  00 15 00 00 10 42 0a 02  |.............B..|
000000a0  08 05 0a 0e 08 01 12 0a  0d 00 00 00 00 15 00 00  |................|
000000b0  00 00 12 03 08 d7 1b                              |.......|

best-effort field walk:
    1: len (175 B) → nested message:
      1: len (62 B) → nested message:
        1: len (31 B) → nested message:
          1: len (10 B) → nested message:
            1: fixed32 = 0x44000000
            2: fixed32 = 0x441f8000
          2: len (10 B) → nested message:
            1: fixed32 = 0x43fa61c4
            2: fixed32 = 0x42100000
          3: varint = 0
          4: fixed32 = 0x00000000
        2: len (3 B) → nested message:
          1: varint = 2892
        3: len (18 B) → nested message:
          1: varint = 4
          2: varint = 2
          3: varint = 1
          4: fixed32 = 0x41400000
          5: fixed32 = 0x3f000000
          6: varint = 0
        5: varint = 0
        7: varint = 0
      2: len (3 B) → nested message:
        1: varint = 2426
      3: len (104 B) → nested message:
        1: varint = 0
        2: varint = 0
        5: len (98 B) → nested message:
          2: len (10 B) → nested message:
            1: fixed32 = 0x43fa61c5
            2: fixed32 = 0x42100000
          3: len (84 B) → nested message:
            1: len (14 B) → nested message:
              1: varint = 1
              2: len (10 B) → nested message:
                1: fixed32 = 0x00000000
                2: fixed32 = 0x00000000
            1: len (14 B) → nested message:
              1: varint = 2
Thinking
Thinking

I noticed the title shape's text appears centered on its position point rather than filling its stored bounding box, suggesting anchored-text semantics. I'll check the format docs on TSD.GeometryArchive flags and shape text storage before drawing conclusions.

$ Bash
Search format docs for geometry flags semantics
grep -rn "flags" /Users/phebert/pnk/docs/format/*.md | grep -i "geometry\|anchor\|natural" | head; grep -rln "ShapeInfo" /Users/phebert/pnk/docs/format/ | head -3
/Users/phebert/pnk/docs/format/text.md
/Users/phebert/pnk/docs/format/pages.md
/Users/phebert/pnk/docs/format/drawables.md
$ Bash
GeometryArchive doc section
grep -n -i "GeometryArchive" -A 12 /Users/phebert/pnk/docs/format/drawables.md | head -40; grep -n -i "anchor\|natural.size" /Users/phebert/pnk/docs/format/text.md | head
16:- `geometry = 1` — inline `TSD.GeometryArchive`.
17-- `parent = 2` — `TSP.Reference` to the containing drawable (a group, or the
18-  canvas owner's container).
19-- `exterior_text_wrap = 3` — `TSD.ExteriorTextWrapArchive` (lines 466-473:
20-  `type`/`direction`/`fit_type`/`margin`/`alpha_threshold`/`is_html_wrap`) for
21-  wrap-around-object text flow. [proto]
22-- `hyperlink_url = 4`, `locked = 5`, `comment = 6` (`TSP.Reference`),
23-  `aspect_ratio_locked = 7`, `accessibility_description = 8`,
24-  `pencil_annotations = 9` (repeated `TSP.Reference`), `title = 10`,
25-  `caption = 11`, `title_hidden = 12`, `caption_hidden = 13`. [proto]
26-
27:Geometry lives in `TSD.GeometryArchive` (lines 21-26): `position = 1`
28-(`TSP.Point { x = 1, y = 2 }`, floats — `TSPMessages.proto:45-48`),
29-`size = 2` (`TSP.Size { width = 1, height = 2 }` — `TSPMessages.proto:61-64`),
30-`flags = 3` (uint32 bitfield; semantics not documented in any local proto
31-[inferred: flags exist but no local source defines the bits]),
32-`angle = 4` (float, **degrees** [inferred→fixture-verified 2026-08-29: the
33-24_Briefing.key master's tick rules store `angle = 90.0` and Keynote's own PDF
34-export renders them vertical — 90 radians would display as ≡116.6°]). [proto]
35-
36-Every concrete drawable embeds the base as `super` (required, field 1) — the
37-Apple-protobuf subclassing idiom used throughout iWork (same pattern as
38-`TST.TableInfoArchive` → `TSD.DrawableArchive` in [tables.md](tables.md)):
39-
112:- Inline/anchored objects occupy a position in the text via `table_attachment`
120:  v_offset_type = 4, v_offset = 5 }` — anchored drawables with offset semantics
Thinking
$ Bash
Find textFit extraction in converter
grep -rn "shrink\|autosize\|auto_size\|maximum_size\|natural" /Users/phebert/pnk/crates/pnk2json/src/*.rs | head -15
/Users/phebert/pnk/crates/pnk2json/src/drawables.rs:345:    // Text-fit semantics: "shrink text on overflow" (resolved flag) scales
/Users/phebert/pnk/crates/pnk2json/src/drawables.rs:351:    let text_fit = if frame.shrink_to_fit == Some(true) {
/Users/phebert/pnk/crates/pnk2json/src/drawables.rs:446:    /// .shrink_to_fit (field 1) [proto: TSWPArchives.proto:502]; the older
/Users/phebert/pnk/crates/pnk2json/src/drawables.rs:447:    /// TSWP.ColumnStyleArchive keeps it in column_properties.shrink_to_fit
/Users/phebert/pnk/crates/pnk2json/src/drawables.rs:450:    shrink_to_fit: Option<bool>,
/Users/phebert/pnk/crates/pnk2json/src/drawables.rs:455:/// shrink_to_fit (field 1) read off the text shape's TSWP.ShapeStyleArchive
/Users/phebert/pnk/crates/pnk2json/src/drawables.rs:462:    let mut props = TextFrameProps { vertical_alignment: None, shrink_to_fit: None };
/Users/phebert/pnk/crates/pnk2json/src/drawables.rs:486:            if props.shrink_to_fit.is_none() {
/Users/phebert/pnk/crates/pnk2json/src/drawables.rs:488:                    props.shrink_to_fit = Some(b);
/Users/phebert/pnk/crates/pnk2json/src/drawables.rs:491:            if props.vertical_alignment.is_some() && props.shrink_to_fit.is_some() {
/Users/phebert/pnk/crates/pnk2json/src/drawables.rs:519:            natural_size: None,
/Users/phebert/pnk/crates/pnk2json/src/drawables.rs:574:    let natural_size = m.size(9).map(|(w, h)| Size { width: w, height: h });
/Users/phebert/pnk/crates/pnk2json/src/drawables.rs:587:                natural_size: None,
/Users/phebert/pnk/crates/pnk2json/src/drawables.rs:601:    Drawable::Image { common, image, original, thumbnail, svg, natural_size, mask, adjustments }
/Users/phebert/pnk/crates/pnk2json/src/drawables.rs:951:            natural_size: None,
Read
{
  "file_path": "/Users/phebert/pnk/crates/pnk2json/src/drawables.rs",
  "offset": 340,
  "limit": 40
}
340	            promote_para_font(ctx, sid, t);
341	        }
342	    }
343	    let is_text_box = info.boolean(6).unwrap_or(false);
344	    let frame = shape_text_frame_props(ctx, &shape);
345	    // Text-fit semantics: "shrink text on overflow" (resolved flag) scales
346	    // text down to the stored box; a plain text box (is_text_box, not a
347	    // placeholder) auto-grows its height as content wraps — Keynote stores
348	    // the height laid out with Apple's font metrics, so renderers with
349	    // different metrics must treat it as a minimum, not a clip [inferred:
350	    // Keynote app behavior; placeholders keep layout-fixed frames].
351	    let text_fit = if frame.shrink_to_fit == Some(true) {
352	        Some(TextFit::Shrink)
353	    } else if is_text_box && placeholder_role.is_none() {
354	        Some(TextFit::Grow)
355	    } else {
356	        None
357	    };
358	
359	    let mut drawable = if is_text_box || type_id == 7 || type_id == 12 {
360	        // Textbox (or placeholder, which renders like a textbox).
361	        let mut common = common_from_shape(ctx, &shape);
362	        if let Some(role) = placeholder_role {
363	            common.placeholder = Some(PlaceholderInfo { role, inherited: None });
364	        }
365	        Drawable::Textbox {
366	            common,
367	            text: text.unwrap_or_default(),
368	            vertical_alignment: frame.vertical_alignment,
369	            text_insets: None,
370	            text_fit,
371	        }
372	    } else {
373	        let mut d = shape_drawable(ctx, &shape, text, frame.vertical_alignment);
374	        if text_fit.is_some() {
375	            if let Drawable::Shape { text_fit: tf, .. } = &mut d {
376	                *tf = text_fit;
377	            }
378	        }
379	        if let Some(role) = placeholder_role {
Thinking

The shape lacks is_text_box (a true Shape with text). Let me probe our slide-1 DOM to see exactly how we lay out that title text before deciding the rule:

$ Bash
Probe slide 1 DOM + title styles
sed -i '' 's/nth(8)/nth(0)/' probe-tmp.mjs && node probe-tmp.mjs 2>&1 | python3 -c "
import json,sys
d = json.load(sys.stdin)
for dr in d['drawables']:
    print(dr['text'][:25].ljust(26), dr['docBox'], dr.get('innerDoc'), dr.get('alignItems'), dr.get('transform'))
"; python3 -c "
import json
d = json.load(open('/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/0d58.json'))
doc = d.get('document', d)
sl = doc['slides'][0]
sh = sl['drawables'][4]
print('shape keys:', [k for k in sh.keys()])
print('verticalAlignment:', sh.get('verticalAlignment'))
for p in sh['text']['paragraphs']:
    print('pStyle', p.get('pStyle'), '->', json.dumps(doc['styles']['para'][p['pStyle']]) if p.get('pStyle') is not None else None)
    for it in p.get('items', []):
        if isinstance(it, dict) and 'cStyle' in it: print('  cStyle', it['cStyle'], json.dumps(doc['styles']['char'][it['cStyle']]))
"
                           {'x': 0, 'y': 53, 'w': 1024, 'h': 471} None None None
                           {'x': 0, 'y': 692, 'w': 1024, 'h': 76} None None None
                           {'x': -1, 'y': -9, 'w': 1026, 'h': 861} None None None
                           {'x': 182, 'y': 619, 'w': 643, 'h': 36} None None None
How hot is your network?   {'x': 512, 'y': 638, 'w': 501, 'h': 36} {'x': 512, 'y': 639, 'w': 501, 'h': 35} center 
shape keys: ['type', 'common', 'geometry', 'text', 'verticalAlignment']
verticalAlignment: middle
pStyle 0 -> {"horizontalAlignment": "center", "defaultTabStopPt": 46.0, "keepLinesTogether": true}
  cStyle 19 {"fontName": "AppleSDGothicNeo-Heavy", "fontSizePt": 28.0, "bold": true, "fontColor": "#ffffff", "outline": {"widthPt": 0.0, "color": "#ffffff"}}
Thinking
Thinking

I've confirmed that in this classic deck, ShapeInfo position represents the shape's center (matching slide 29's quote anchor too). Now I'll check if modern shapes use different geometry flags by dumping G2's pentagon data.

$ Bash
List G2 iwa members
cargo run --release -p iwadump -- --list fixtures/golden/G2-golden-pages-layout.pages 2>/dev/null | grep -i iwa | head; 
(Bash completed with no output)
$ Bash
List G2 members with errors shown
cargo run --release -p iwadump -- --list fixtures/golden/G2-golden-pages-layout.pages 2>&1 | head -15
    Finished `release` profile [optimized] target(s) in 0.02s
     Running `/Users/phebert/pnk/target/release/iwadump --list fixtures/golden/G2-golden-pages-layout.pages`
iwadump: cannot read fixtures/golden/G2-golden-pages-layout.pages: No such file or directory (os error 2) [container]
$ Bash
List G2 iwa members from repo root
cd /Users/phebert/pnk && ./target/release/iwadump --list fixtures/golden/G2-golden-pages-layout.pages | grep "\.iwa" | head -8
     11605  Index/Document.iwa
       725  Index/ViewState.iwa
       546  Index/CalculationEngine-1732636.iwa
        23  Index/AnnotationAuthorStorage-1732635.iwa
     46313  Index/DocumentStylesheet.iwa
       230  Index/DocumentMetadata.iwa
     19389  Index/Metadata.iwa
$ Bash
Find ShapeInfo ids in G2
./target/release/iwadump --archive Index/Document.iwa fixtures/golden/G2-golden-pages-layout.pages | grep -n "ShapeInfo" | head -4
60:    id=1732749 TSWP.ShapeInfoArchive                         len=209      ok
64:    id=1734362 TSWP.ShapeInfoArchive                         len=131      ok
68:    id=1734939 TSWP.ShapeInfoArchive                         len=324      ok
72:    id=1735565 TSWP.ShapeInfoArchive                         len=193      ok
$ Bash
G2 pentagon geometry fields
./target/release/iwadump --archive Index/Document.iwa --message 1734939 fixtures/golden/G2-golden-pages-layout.pages | sed -n '/field walk/,/^$/p' | head -25
best-effort field walk:
    1: len (307 B) → nested message:
      1: len (73 B) → nested message:
        1: len (31 B) → nested message:
          1: len (10 B) → nested message:
            1: fixed32 = 0x43d7959a
            2: fixed32 = 0x4279999a
          2: len (10 B) → nested message:
            1: fixed32 = 0x42c80000
            2: fixed32 = 0x42c80000
          3: varint = 3
          4: fixed32 = 0x00000000
        3: len (18 B) → nested message:
          1: varint = 5
          2: varint = 2
          3: varint = 1
          4: fixed32 = 0x41400000
          5: fixed32 = 0x3f000000
          6: varint = 0
        5: varint = 0
        7: varint = 0
       10: len (4 B) → nested message:
          1: varint = 1734937
       11: len (4 B) → nested message:
          1: varint = 1734942
Thinking
Thinking

I've found a promising discriminator: G2's modern pentagon has flags = 3 while the mis-anchored classic title has flags = 0. I'll now test this against the correctly-positioned band image on slide 1 and the slide-29 quote to validate it.

$ Bash
Flags of classic band image; slide 29 archive ids
./target/release/iwadump --archive Index/Slide1.iwa --message 43271 ~/Development/pnk-fixtures/crawl/0d5851c0105958b602c58646b98ffaee1b453def8478f9b872aca37319d2ed5f.key | sed -n '/field walk/,$p' | head -14; echo ====; ./target/release/iwadump --archive Index/Slide29.iwa ~/Development/pnk-fixtures/crawl/0d5851c0105958b602c58646b98ffaee1b453def8478f9b872aca37319d2ed5f.key | head -14
Exit code 1
best-effort field walk:
    1: len (62 B) → nested message:
      1: len (31 B) → nested message:
        1: len (10 B) → nested message:
          1: fixed32 = 0x43358000
          2: fixed32 = 0x441ac000
        2: len (10 B) → nested message:
          1: fixed32 = 0x4420c000
          2: fixed32 = 0x42100000
        3: varint = 3
        4: fixed32 = 0x00000000
      2: len (3 B) → nested message:
        1: varint = 2892
      3: len (18 B) → nested message:
(eval):1: === not found
Thinking

The classic deck's correctly-placed band image has flags=3, the broken title has flags=0 — the discriminator holds so far. Checking slide 29's quote shape (expect 0):

$ Bash
Slide 29 ShapeInfo id
./target/release/iwadump --archive Index/Slide29.iwa ~/Development/pnk-fixtures/crawl/0d5851c0105958b602c58646b98ffaee1b453def8478f9b872aca37319d2ed5f.key | grep ShapeInfo
(Bash completed with no output)
$ Bash
List slide 29 archives
./target/release/iwadump --archive Index/Slide29.iwa ~/Development/pnk-fixtures/crawl/0d5851c0105958b602c58646b98ffaee1b453def8478f9b872aca37319d2ed5f.key | head -20
/Users/phebert/Development/pnk-fixtures/crawl/0d5851c0105958b602c58646b98ffaee1b453def8478f9b872aca37319d2ed5f.key — flat zip, 130 members, 1 iwa streams, app: keynote
  Index/Slide29.iwa — 1 blocks, 983 B compressed → 1507 B decoded, 11 archives
    id=46476  KN.SlideArchive                               len=120      ok
    id=46484  TSD.GuideStorageArchive                       len=0        ok
    id=46481  KN.PlaceholderArchive                         len=190      ok
    id=46487  TSWP.StorageArchive                           len=84       ok
    id=46482  KN.PlaceholderArchive                         len=190      ok
    id=46488  TSWP.StorageArchive                           len=317      ok
    id=46483  KN.PlaceholderArchive                         len=190      ok
    id=46489  TSWP.StorageArchive                           len=77       ok
    id=46491  TSWP.NumberAttachmentArchive                  len=8        ok
    id=46485  KN.NoteArchive                                len=6        ok
    id=46490  TSWP.StorageArchive                           len=62       ok
Thinking
$ Bash
Find all ShapeInfo archives across slides
for f in $(./target/release/iwadump --list ~/Development/pnk-fixtures/crawl/0d5851c0105958b602c58646b98ffaee1b453def8478f9b872aca37319d2ed5f.key | grep -o "Index/Slide[^ ]*\.iwa"); do
  out=$(./target/release/iwadump --archive "$f" ~/Development/pnk-fixtures/crawl/0d5851c0105958b602c58646b98ffaee1b453def8478f9b872aca37319d2ed5f.key 2>/dev/null | grep "TSWP.ShapeInfoArchive")
  [ -n "$out" ] && echo "$f: $out"
done
Exit code 1
Index/Slide10.iwa:     id=44725  TSWP.ShapeInfoArchive                         len=186      ok
Index/Slide14.iwa:     id=45474  TSWP.ShapeInfoArchive                         len=186      ok
Index/Slide15.iwa:     id=45581  TSWP.ShapeInfoArchive                         len=186      ok
Index/Slide27.iwa:     id=51720  TSWP.ShapeInfoArchive                         len=185      ok
Index/Slide28.iwa:     id=52255  TSWP.ShapeInfoArchive                         len=185      ok
Index/Slide1.iwa:     id=3366   TSWP.ShapeInfoArchive                         len=183      ok
$ Bash
Geometry of remaining classic shapes
for pair in "Index/Slide27.iwa 51720" "Index/Slide28.iwa 52255" "Index/Slide10.iwa 44725"; do
  set -- $pair
  echo "== $1 $2"
  ./target/release/iwadump --archive "$1" --message "$2" ~/Development/pnk-fixtures/crawl/0d5851c0105958b602c58646b98ffaee1b453def8478f9b872aca37319d2ed5f.key | sed -n '/field walk/,$p' | sed -n '3,12p'
done
== Index/Slide27.iwa 51720 
error: invalid value '' for '--message <MESSAGE>': cannot parse integer from empty string

For more information, try '--help'.
== Index/Slide28.iwa 52255 
error: invalid value '' for '--message <MESSAGE>': cannot parse integer from empty string

For more information, try '--help'.
== Index/Slide10.iwa 44725 
error: invalid value '' for '--message <MESSAGE>': cannot parse integer from empty string

For more information, try '--help'.
$ Bash
Dump three classic shape geometries
K=~/Development/pnk-fixtures/crawl/0d5851c0105958b602c58646b98ffaee1b453def8478f9b872aca37319d2ed5f.key
./target/release/iwadump --archive Index/Slide27.iwa --message 51720 $K | sed -n '/field walk/,$p' | sed -n '2,12p'
echo "-- 28"
./target/release/iwadump --archive Index/Slide28.iwa --message 52255 $K | sed -n '/field walk/,$p' | sed -n '2,12p'
echo "-- 10"
./target/release/iwadump --archive Index/Slide10.iwa --message 44725 $K | sed -n '/field walk/,$p' | sed -n '2,12p'
    1: len (176 B) → nested message:
      1: len (63 B) → nested message:
        1: len (31 B) → nested message:
          1: len (10 B) → nested message:
            1: fixed32 = 0x4387876c
            2: fixed32 = 0x43c6acc3
          2: len (10 B) → nested message:
            1: fixed32 = 0x00000000
            2: fixed32 = 0x00000000
          3: varint = 0
          4: fixed32 = 0x00000000
-- 28
    1: len (176 B) → nested message:
      1: len (63 B) → nested message:
        1: len (31 B) → nested message:
          1: len (10 B) → nested message:
            1: fixed32 = 0x4387876c
            2: fixed32 = 0x43c6acc3
          2: len (10 B) → nested message:
            1: fixed32 = 0x00000000
            2: fixed32 = 0x00000000
          3: varint = 0
          4: fixed32 = 0x00000000
-- 10
    1: len (177 B) → nested message:
      1: len (63 B) → nested message:
        1: len (31 B) → nested message:
          1: len (10 B) → nested message:
            1: fixed32 = 0x41900000
            2: fixed32 = 0x430c0000
          2: len (10 B) → nested message:
            1: fixed32 = 0x446c131c
            2: fixed32 = 0x440b9ed5
          3: varint = 3
          4: fixed32 = 0x00000000
Thinking
Thinking

I've found a consistent pattern: flags=3 marks top-left positioning, while flags=0 either indicates a text anchor point or a shape's center depending on size. Now I'll verify this against shapes from a modern Keynote deck before updating the converter.

$ Bash
Find ShapeInfo in modern dns deck
K2=/Users/phebert/Development/pnk-fixtures/crawl/0f9df553f4a7afb8147288b9ffb4352624edea6448e946b859516768ed99d573.key
for f in $(./target/release/iwadump --list $K2 | grep -o "Index/[^ ]*\.iwa" | head 2>/dev/null); do :; done
./target/release/iwadump $K2 2>/dev/null | grep -m3 "ShapeInfo"
    id=4125   TSWP.ShapeInfoArchive                         len=105      ok
    id=4141   TSWP.ShapeInfoArchive                         len=105      ok
    id=4121   TSWP.ShapeInfoArchive                         len=105      ok
$ Bash
Sample geometry flags across modern deck shapes
K2=/Users/phebert/Development/pnk-fixtures/crawl/0f9df553f4a7afb8147288b9ffb4352624edea6448e946b859516768ed99d573.key
./target/release/iwadump $K2 2>/dev/null | grep -B2 "id=4125" | head -4
for m in $(./target/release/iwadump --list $K2 | grep -o "Index/[^ ]*\.iwa"); do
  ids=$(./target/release/iwadump --archive "$m" $K2 2>/dev/null | grep "ShapeInfo" | grep -o "id=[0-9]*" | head -2)
  for id in $ids; do
    fl=$(./target/release/iwadump --archive "$m" --message "${id#id=}" $K2 2>/dev/null | sed -n '/field walk/,$p' | grep -m1 "3: varint")
    echo "$m ${id}: geo-flags $fl"
  done
done 2>/dev/null | head -12
    id=3110   TSD.GroupArchive                              len=74       ok
    id=3106   TSD.GroupArchive                              len=279      ok
    id=4125   TSWP.ShapeInfoArchive                         len=105      ok
Index/Slide.iwa id=4125
id=4141: geo-flags 
Index/Slide-15.iwa id=3978
id=3977: geo-flags 
Index/Slide-120.iwa id=4299
id=4310: geo-flags 
Index/Slide-138.iwa id=4591
id=4590: geo-flags
Thinking
$ Bash
Geometry flags in modern deck, fixed loop
K2=/Users/phebert/Development/pnk-fixtures/crawl/0f9df553f4a7afb8147288b9ffb4352624edea6448e946b859516768ed99d573.key
for m in Index/Slide.iwa Index/Slide-15.iwa Index/Slide-120.iwa; do
  for id in $(./target/release/iwadump --archive "$m" $K2 2>/dev/null | grep "ShapeInfo" | sed 's/.*id=\([0-9]*\).*/\1/' | head -2); do
    fl=$(./target/release/iwadump --archive "$m" --message "$id" $K2 2>/dev/null | awk '/field walk/,0' | awk '/3: varint/{print $3; exit}')
    echo "$m id=$id flags=$fl"
  done
done
Index/Slide.iwa id=4125 flags==
Index/Slide.iwa id=4141 flags==
Index/Slide-15.iwa id=3978 flags==
Index/Slide-15.iwa id=3977 flags==
Index/Slide-120.iwa id=4299 flags==
Index/Slide-120.iwa id=4310 flags==
$ Bash
Direct dump of one modern shape
K2=/Users/phebert/Development/pnk-fixtures/crawl/0f9df553f4a7afb8147288b9ffb4352624edea6448e946b859516768ed99d573.key
./target/release/iwadump --archive Index/Slide.iwa --message 4125 $K2 | sed -n '/field walk/,$p' | head -14
best-effort field walk:
    1: len (98 B) → nested message:
      1: len (62 B) → nested message:
        1: len (31 B) → nested message:
          1: len (10 B) → nested message:
            1: fixed32 = 0x4106b600
            2: fixed32 = 0x41663a40
          2: len (10 B) → nested message:
            1: fixed32 = 0x401112bc
            2: fixed32 = 0x4116a3fe
          3: varint = 7
          4: fixed32 = 0x43340000
        2: len (3 B) → nested message:
          1: varint = 3106
Thinking
Thinking

Modern shapes always show flags=3 or 7, so this repositioning rule only applies to classic imports. I'll implement the fix in the converter—re-anchoring position from center to top-left when a text shape has flags=0 and nonzero size—starting by locating the geometry parsing code.

$ Bash
Locate geometry parser
grep -n "fn.*geometry\|angle\|flags" crates/pnk2json/src/tsd.rs crates/pnk2json/src/drawables.rs | head -20
crates/pnk2json/src/drawables.rs:123:            // Geometry angle is DEGREES, not radians — fixture-verified:
crates/pnk2json/src/drawables.rs:127:            c.angle_deg = Some(deg as f64);
crates/pnk2json/src/drawables.rs:181:    // Per-property "seen at a nearer chain level" flags:
crates/pnk2json/src/drawables.rs:676:fn anchor_geometry(ctx: &Ctx, aid: u64) -> Option<(Point, Size)> {
crates/pnk2json/src/drawables.rs:757:                common.angle_deg = None;
crates/pnk2json/src/tsd.rs:174:pub fn shape_geometry(pathsource: &Msg) -> ShapeGeometry {
crates/pnk2json/src/tsd.rs:312:    let mut gradient = Gradient { kind, stops, angle_deg: None, start_point: None, end_point: None };
crates/pnk2json/src/tsd.rs:314:        // TSD.AngleGradientArchive.gradientangle is RADIANS (fixture-verified:
crates/pnk2json/src/tsd.rs:316:        // top-to-bottom backdrop) — unlike TSD.Geometry.angle, which is
crates/pnk2json/src/tsd.rs:318:        gradient.angle_deg = a.f32v(2).map(|v| (v as f64).to_degrees());
crates/pnk2json/src/tsd.rs:385:        angle_deg: m.f32v(2).unwrap_or(315.0) as f64,
Read
{
  "file_path": "/Users/phebert/pnk/crates/pnk2json/src/drawables.rs",
  "offset": 95,
  "limit": 75
}
95	                ),
96	                format!("0x{:x}", rec.type_id),
97	            );
98	            Drawable::Unknown {
99	                common: drawable_common(ctx, &msg).ok(),
100	                type_id: format!("0x{:x}", rec.type_id),
101	                type_name,
102	                reason: "recognized object type has no drawable model".into(),
103	            }
104	        }
105	    }
106	}
107	
108	/// Extract `DrawableCommon` from a `TSD.DrawableArchive` payload. `Err(())`
109	/// when the payload carries no geometry at all.
110	fn drawable_common(ctx: &mut Ctx, m: &Msg) -> Result<DrawableCommon, ()> {
111	    let mut c = DrawableCommon::default();
112	    let mut any = false;
113	    if let Some(g) = m.msg(1) {
114	        if let Some((x, y)) = g.point(1) {
115	            c.position = Some(Point { x, y });
116	            any = true;
117	        }
118	        if let Some((w, h)) = g.size(2) {
119	            c.size = Some(Size { width: w, height: h });
120	            any = true;
121	        }
122	        if let Some(deg) = g.f32v(4) {
123	            // Geometry angle is DEGREES, not radians — fixture-verified:
124	            // 24_Briefing master ticks store 90.0 and Apple's own PDF export
125	            // renders them vertical (a radians read would give 5156.6°≡117°,
126	            // the diagonal we used to draw). docs/format/drawables.md updated.
127	            c.angle_deg = Some(deg as f64);
128	            any = true;
129	        }
130	    }
131	    if let Some(w) = m.msg(3) {
132	        let kind = match w.varint(1).unwrap_or(0) {
133	            0 => TextWrapKind::None,
134	            1 => TextWrapKind::Around,
135	            2 => TextWrapKind::AboveBelow,
136	            3 => TextWrapKind::Left,
137	            4 => TextWrapKind::Right,
138	            5 => TextWrapKind::Largest,
139	            _ => TextWrapKind::Around,
140	        };
141	        c.text_wrap = Some(TextWrap { kind, margin_pt: w.f32v(4).map(|v| v as f64) });
142	        any = true;
143	    }
144	    if let Some(h) = m.string(4) {
145	        if !h.is_empty() {
146	            c.hyperlink = Some(h);
147	        }
148	        any = any || m.has(4);
149	    }
150	    if let Some(l) = m.boolean(5) {
151	        c.locked = Some(l);
152	        any = true;
153	    }
154	    if let Some(a) = m.string(8) {
155	        if !a.is_empty() {
156	            c.accessibility_description = Some(a);
157	        }
158	        any = any || m.has(8);
159	    }
160	    if any {
161	        Ok(c)
162	    } else {
163	        Err(())
164	    }
165	}
166	
167	/// Style from TSD.ShapeStyleArchive (shape_properties = 11) or
168	/// TSD.MediaStyleArchive (media_properties = 11), resolved through the
169	/// TSS.StyleArchive `parent = 3` chain (docs/format/styles.md): a shape
Thinking
Read
{
  "file_path": "/Users/phebert/pnk/crates/pnk2json/src/drawables.rs",
  "offset": 290,
  "limit": 55
}
290	    type_id: u32,
291	    type_name: Option<String>,
292	) -> Drawable {
293	    // Placeholder types: unwrap `{ super, kind }` → the ShapeInfoArchive.
294	    let super_info =
295	        if type_id == 7 || type_id == 12 { m.msg(1) } else { None };
296	    let info = super_info.as_ref().unwrap_or(m);
297	    let kind = if type_id == 7 || type_id == 12 { m.varint(2) } else { None };
298	    let Some(shape) = info.msg(1) else {
299	        return Drawable::Unknown {
300	            common: None,
301	            type_id: format!("0x{type_id:x}"),
302	            type_name,
303	            reason: "ShapeInfoArchive without a ShapeArchive super".into(),
304	        };
305	    };
306	    let placeholder_role = match type_id {
307	        // KN.PlaceholderArchive.Kind (KNArchives.proto:203-209)
308	        7 | 12 if type_name.as_deref().map(|n| n.starts_with("KN.")).unwrap_or(false) => {
309	            Some(
310	                match kind.unwrap_or(0) {
311	                    1 => "slide-number",
312	                    2 => "title",
313	                    3 => "body",
314	                    4 => "object",
315	                    _ => "placeholder",
316	                }
317	                .to_string(),
318	            )
319	        }
320	        7 | 12 => Some("placeholder".to_string()), // TP.PlaceholderArchive
321	        _ => None,
322	    };
323	
324	    // Text: owned_storage (4) wins, else text_flow (3) → FlowInfo.text_storage (1),
325	    // else deprecated_storage (2) — older docs (pre-flow) reference the
326	    // StorageArchive there directly [proto: TSWPArchives.proto ShapeInfoArchive
327	    // field 2, deprecated=true; fixture 5008407355… stores its template text so].
328	    let storage_id = info
329	        .reference(4)
330	        .or_else(|| info.msg(3).and_then(|f| f.reference(1)))
331	        .or_else(|| info.reference(2));
332	    let mut text = storage_id.and_then(|sid| crate::text::extract(ctx, sid)).map(|e| e.text);
333	    // Keynote placeholders and title/body shapes keep their look on the
334	    // referenced paragraph style (their storage char-style tables hold null
335	    // overrides), so runs without their own character style inherit the
336	    // paragraph style's font name/size/color. Keynote-scoped: Pages storages
337	    // carry real character styles and are golden-pinned.
338	    if ctx.app_kind == crate::model::AppKind::Keynote {
339	        if let (Some(sid), Some(t)) = (storage_id, text.as_mut()) {
340	            promote_para_font(ctx, sid, t);
341	        }
342	    }
343	    let is_text_box = info.boolean(6).unwrap_or(false);
344	    let frame = shape_text_frame_props(ctx, &shape);
Read
{
  "file_path": "/Users/phebert/pnk/crates/pnk2json/src/drawables.rs",
  "offset": 358,
  "limit": 45
}
358	
359	    let mut drawable = if is_text_box || type_id == 7 || type_id == 12 {
360	        // Textbox (or placeholder, which renders like a textbox).
361	        let mut common = common_from_shape(ctx, &shape);
362	        if let Some(role) = placeholder_role {
363	            common.placeholder = Some(PlaceholderInfo { role, inherited: None });
364	        }
365	        Drawable::Textbox {
366	            common,
367	            text: text.unwrap_or_default(),
368	            vertical_alignment: frame.vertical_alignment,
369	            text_insets: None,
370	            text_fit,
371	        }
372	    } else {
373	        let mut d = shape_drawable(ctx, &shape, text, frame.vertical_alignment);
374	        if text_fit.is_some() {
375	            if let Drawable::Shape { text_fit: tf, .. } = &mut d {
376	                *tf = text_fit;
377	            }
378	        }
379	        if let Some(role) = placeholder_role {
380	            if let Drawable::Shape { common, .. } = &mut d {
381	                common.placeholder = Some(PlaceholderInfo { role, inherited: None });
382	            }
383	        }
384	        d
385	    };
386	
387	    // Distinguish a bare empty textbox: if the shape has no pathsource and no
388	    // style at all it is still a textbox; nothing more to do here.
389	    let _ = id;
390	    drawable
391	}
392	
393	fn common_from_shape(ctx: &mut Ctx, shape: &Msg) -> DrawableCommon {
394	    // shape: super = 1 (TSD.DrawableArchive), style = 2, pathsource = 3
395	    let mut common = shape
396	        .msg(1)
397	        .and_then(|d| drawable_common(ctx, &d).ok())
398	        .unwrap_or_default();
399	    let (style, extras) = drawable_style(ctx, shape.reference(2), false);
400	    common.style = style;
401	    merge_extras(&mut common, extras);
402	    common
Thinking

Landing the converter re-anchor:

✏️ Edit drawables.rs
/Users/phebert/pnk/crates/pnk2json/src/drawables.rs
    // Distinguish a bare empty textbox: if the shape has no pathsource and no
    // style at all it is still a textbox; nothing more to do here.
    let _ = id;
    drawable
}
+
    // Classic-import anchored geometry: Keynote-'09-converted decks (format
    // 1.5) store some text shapes' geometry with flags == 0 and position =
    // the shape's CENTER, not its top-left. 0d5851c0 slide 1: the title
    // stores (512, 638) — the slide's horizontal center — and Apple lays the
    // 500×36 rect out at 262..762 with its centered text on x=512; modern
    // archives (G2, 0f9df553) always write flags 3 (7 when rotated).
    // Re-anchor to top-left here so the model's geometry contract holds and
    // the viewer never learns about the flag. A 0×0 anchored label is
    // unaffected (shift of half-zero), and rotation is left alone — no
    // rotated flags==0 sample exists to verify against. [inferred: flag-bit
    // semantics are undocumented; behavior verified against Apple's own
    // render of 0d5851c0 slides 1/27/28]
    if shape.msg(1).and_then(|d| d.msg(1)).and_then(|g| g.varint(3)) == Some(0) {
        if let Drawable::Shape { common, .. } | Drawable::Textbox { common, .. } = &mut drawable {
            if common.angle_deg.unwrap_or(0.0) == 0.0 {
                if let (Some(p), Some(s)) = (common.position.as_mut(), common.size.as_ref()) {
                    p.x -= s.width / 2.0;
                    p.y -= s.height / 2.0;
                }
            }
        }
    }

    // Distinguish a bare empty textbox: if the shape has no pathsource and no
    // style at all it is still a textbox; nothing more to do here.
    let _ = id;
    drawable
}
The file /Users/phebert/pnk/crates/pnk2json/src/drawables.rs has been updated successfully. (file state is current in your context — no need to Read it back)
$ Bash
Build converter and verify title re-anchored
cargo build --release -p pnk2json 2>&1 | tail -3 && ./target/release/pnk2json --pretty ~/Development/pnk-fixtures/crawl/0d5851c0105958b602c58646b98ffaee1b453def8478f9b872aca37319d2ed5f.key 2>/dev/null | python3 -c "
import json, sys
d = json.load(sys.stdin)
doc = d.get('document', d)
sh = doc['slides'][0]['drawables'][4]
print('title now at:', sh['common']['position'], sh['common']['size'])
"
warning: `pnk2json` (lib) generated 12 warnings (run `cargo fix --lib -p pnk2json` to apply 7 suggestions)
    Finished `release` profile [optimized] target(s) in 4.67s
title now at: {'x': 261.61810302734375, 'y': 620.0} {'width': 500.7637939453125, 'height': 36.0}
Thinking

Title now lands at 262..762 × 620..656 — exactly Apple's placement. Now the viewer fixes. First the flex-shrink bug in the shrink pass, then the 0×0 natural-width issue:

$ Bash
Find zero-size anchor handling
grep -n "edge-clamp\|0×0\|zero\|offsetWidth === 0\|width === 0" /Users/phebert/pnk/viewer/src/drawables.ts | head
59:  // Apple emits placeholder dash arrays of all zeros for solid strokes; SVG
60:  // would render those as invisible zero-length dashes.
568:    if (box.dataset.textFit === "edge-clamp") {
780:      // Zero-size textboxes (Keynote emits some badge labels at 0×0) carry
782:      if (!c.size || (c.size.width === 0 && c.size.height === 0)) {
809:          div.dataset.textFit = "edge-clamp";
874:    // connection lines have a 0-width/0-height frame, and a zero (or 1e-7)
Read
{
  "file_path": "/Users/phebert/pnk/viewer/src/drawables.ts",
  "offset": 770,
  "limit": 75
}
770	    // background if only the text layer paints (monster deck: lavender
771	    // "Transverse density" tags, dark-red banner boxes rendered as ghost
772	    // text on nothing).
773	    const bg = fillToCss(c.style?.fill);
774	    if (bg) div.style.background = bg;
775	    if (c.style?.stroke && c.style.stroke.widthPt > 0) {
776	      div.style.border = `${c.style.stroke.widthPt}px solid ${c.style.stroke.color}`;
777	    }
778	    const layer = textLayer(d, doc, ctx);
779	    if (layer) {
780	      // Zero-size textboxes (Keynote emits some badge labels at 0×0) carry
781	      // their text unclipped: let the content size the box instead.
782	      if (!c.size || (c.size.width === 0 && c.size.height === 0)) {
783	        div.style.width = "auto";
784	        div.style.height = "auto";
785	        div.style.overflow = "visible";
786	        layer.style.overflow = "visible";
787	        layer.style.position = "relative";
788	        layer.style.whiteSpace = "nowrap";
789	        layer.style.width = "max-content"; // percentage of an auto box is meaningless
790	        layer.style.height = "auto";
791	        // A 0-size box is a point ANCHOR: text laid out in it overflows
792	        // equally per its alignment, so centered paragraphs center ON the
793	        // stored position and right-aligned ones end there; same vertically
794	        // via the box's own vertical alignment. Monster deck fixture: the
795	        // centered "DGLAP, ERBL" tag stores (888, 477) and Apple draws its
796	        // box spanning 767..1016 x 456..507 — dead-center on the point;
797	        // the left-aligned "Transverse" label anchors top-left as before.
798	        const firstPara = d.text.paragraphs?.[0];
799	        const hAlign = paraStyleOf(doc, firstPara?.pStyle)?.horizontalAlignment;
800	        const tx = hAlign === "center" ? "-50%" : hAlign === "right" ? "-100%" : "0%";
801	        const ty = d.verticalAlignment === "middle" ? "-50%" : d.verticalAlignment === "bottom" ? "-100%" : "0%";
802	        if (tx !== "0%" || ty !== "0%") {
803	          div.style.transform = `${div.style.transform ?? ""} translate(${tx}, ${ty})`.trim();
804	        } else {
805	          // Auto-sized boxes lay out at Apple's metrics; browser faces run
806	          // wider, so a label Apple fits to the slide edge can spill past it
807	          // ("James 3:13-18" bottom-right badges). The measurement pass
808	          // clamps (offset-based, so only valid untransformed).
809	          div.dataset.textFit = "edge-clamp";
810	        }
811	      } else {
812	        applyTextFitMode(div, layer, d.textFit, d.verticalAlignment);
813	      }
814	      div.appendChild(layer);
815	    } else div.textContent = "";
816	  } else if (d.type === "shape") {
817	    // A 0-height shape whose PATH carries a real natural height is a full
818	    // box stored degenerate (proteger-les-donnees red banner: size 471x0,
819	    // path 471x32) — adopt the path height so its white caption gets the
820	    // band as its layout/fit box instead of spilling invisibly below it.
821	    const naturalH = d.geometry.naturalSize?.height ?? 0;
822	    const effH = h === 0 && d.geometry.path && naturalH > 1 ? naturalH : h;
823	    if (effH !== h) div.style.height = `${effH}px`;
824	    const svg = shapeSvg(d.geometry, w, effH, c.style);
825	    div.appendChild(svg);
826	    const layer = textLayer({ ...d, text: d.text, verticalAlignment: d.verticalAlignment, common: c }, doc, ctx);
827	    if (layer) {
828	      if (effH === 0) {
829	        // 0-height shape carrying text (RIPE ea785d2e subtitle): the box is
830	        // an anchor, not a clip — let the text flow down from it.
831	        layer.style.bottom = "auto";
832	        layer.style.height = "auto";
833	        layer.style.overflow = "visible";
834	      } else {
835	        // Shapes keep their geometry: a shape never grows for its text, so
836	        // "grow" degrades to the fixed-box tolerance mode.
837	        applyTextFitMode(div, layer, d.textFit === "grow" ? undefined : d.textFit, d.verticalAlignment);
838	      }
839	      div.appendChild(layer);
840	    }
841	  } else if (d.type === "image") {
842	    const img = imageEl(d.image.dataId, d.image.preferredFileName ?? d.image.fileName, ctx, d.image.preferredFileName, d.thumbnail);
843	    const m = d.mask?.common;
844	    if (m?.position && m.size && m.size.width > 0 && m.size.height > 0) {
Thinking

Three viewer fixes now. First check the marker fields in the model and the exact indent values:

$ Bash
Model marker fields + indent values
grep -n "markerImage\|markerIndent\|markerKind" /Users/phebert/pnk/model/src/shared.ts | head; python3 -c "
import json
d = json.load(open('/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/0d58.json'))
doc = d.get('document', d)
for i in (6, 22):
    ps = doc['styles']['para'][i]
    print(i, json.dumps(ps['list']))
"
6 {"level": 0, "markerKind": "image", "markerImage": {"dataId": "5501", "fileName": "rightArrow-5501.png", "preferredFileName": "rightArrow.png"}, "markerFontName": "STIXGeneral-Regular", "markerScale": 0.3499999940395355}
22 {"level": 1, "markerKind": "image", "markerImage": {"dataId": "5501", "fileName": "rightArrow-5501.png", "preferredFileName": "rightArrow.png"}, "markerIndentPt": 48.0, "markerFontName": "STIXGeneral-Regular", "markerScale": 0.3499999940395355}
$ Bash
Check TS ListFormat has markerImage
grep -n "markerImage\|markerKind" /Users/phebert/pnk/model/src/shared.ts
(Bash completed with no output)
$ Bash
Find ListFormat in TS model + Rust
grep -rn "markerKind\|ListFormat" /Users/phebert/pnk/model/src/*.ts | head; grep -rn "marker_image\|markerImage" /Users/phebert/pnk/crates/pnk2json/src/model.rs | head -3
/Users/phebert/pnk/model/src/primitives.ts:362:  list?: ListFormat;
/Users/phebert/pnk/model/src/primitives.ts:415:export interface ListFormat {
/Users/phebert/pnk/model/src/primitives.ts:419:  markerKind: "none" | "string" | "number" | "image";
/Users/phebert/pnk/model/src/primitives.ts:420:  /** Literal marker text when markerKind = "string" (e.g. "•", "→"). */
/Users/phebert/pnk/model/src/primitives.ts:423:   * Numbering scheme when markerKind = "number"
/Users/phebert/pnk/model/src/primitives.ts:429:  /** Marker image when markerKind = "image". */
/Users/phebert/pnk/model/src/primitives.ts:432:   * Number surround when markerKind = "number": "1." (period, the default —
/Users/phebert/pnk/crates/pnk2json/src/model.rs:433:    pub marker_image: Option<MediaRef>,
Thinking

Model's ready (markerImage exists both sides) — the viewer just never renders it. Making all three fixes:

✏️ Edit text.ts
/Users/phebert/pnk/viewer/src/text.ts
  const hasMarker = !!list && hasContent && list.markerKind !== "none" &&
    (list.markerKind === "string" ? !!list.markerText : list.markerKind === "number");
+
  const hasMarker = !!list && hasContent && list.markerKind !== "none" &&
    (list.markerKind === "string"
      ? !!list.markerText
      : list.markerKind === "image"
        ? !!list.markerImage
        : list.markerKind === "number");
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)
Read
{
  "file_path": "/Users/phebert/pnk/viewer/src/text.ts",
  "offset": 220,
  "limit": 18
}
220	    listState.lastKey = null;
221	    if (style) applyParaStyle(el, style);
222	  } else {
223	    // numbering: the stored restart flag (surfaced as list.start on the
224	    // paragraph's pooled style) resets the counter; otherwise numbering
225	    // CONTINUES the counter for this key — even across intervening
226	    // paragraphs or nested levels, which is Pages' own "continue from
227	    // previous" semantics (G5: "Four (Numbered, continued)" resumes 4 after
228	    // a nested run; "Restart One" carries start=1).
229	    const key = `${list!.level}:${list!.markerKind}:${list!.markerKind === "number" ? list!.numberKind : list!.markerText}`;
230	    const n = list!.start !== undefined
231	      ? list!.start
232	      : (listState.counters.get(key) ?? 0) + 1;
233	    listState.counters.set(key, n);
234	    listState.lastKey = key;
235	    const markerText = list!.markerKind === "number"
236	      ? numberMarker(n, list!.numberKind, list!.numberSurround)
237	      : (list!.markerText ?? "•");
Read
{
  "file_path": "/Users/phebert/pnk/viewer/src/text.ts",
  "offset": 255,
  "limit": 50
}
255	    if (!style?.leftIndentPt && list!.markerIndentPt && list!.markerIndentPt > 0) {
256	      wrap.style.marginLeft = `${list!.markerIndentPt}px`;
257	    }
258	    const marker = document.createElement("span");
259	    marker.className = "list-marker";
260	    marker.textContent = markerText;
261	    marker.style.minWidth = "18px";
262	    // The marker inherits the first run's look (size + color): an unstyled
263	    // span rendered 15px near-black bullets INVISIBLE on dark decks (RIPE
264	    // slides 2/5: 28pt white body, default marker). Apple actually colors
265	    // markers from the list style's own font_color/scale — not yet in the
266	    // model (proposal sent) — so the run style is the faithful fallback.
267	    // Style source: the first run with visible text (writers prepend empty
268	    // runs whose styles carry no size — RIPE), preferring one that resolves
269	    // to an explicit font size.
270	    const runs = p.items.filter(
271	      (it): it is TextRun => typeof it !== "string" && !("type" in it) && it.text.length > 0,
272	    );
273	    const runCs =
274	      runs.map((r) => charStyleOf(doc, r.cStyle)).find((cs) => cs?.fontSizePt) ??
275	      (runs.length ? charStyleOf(doc, runs[0].cStyle) : undefined);
276	    if (runCs) applyCharStyle(marker, runCs);
277	    marker.style.paddingRight = "0.3em"; // marker-to-text gap, scales with size
278	    // The list style's OWN marker look wins over run inheritance when stored
279	    // (ListFormat markerColor/markerFontName/markerScale — RIPE orange dots).
280	    // markerScale multiplies the RUN size (LabelGeometry scale_with_text), so
281	    // resolve to px against it — an em here would key off the wrapper's
282	    // default 15px, not the paragraph's size.
283	    if (list!.markerColor) marker.style.color = list!.markerColor;
284	    if (list!.markerFontName) marker.style.fontFamily = `"${list!.markerFontName}", sans-serif`;
285	    // Symbol-font markers (Wingdings/Webdings/Symbol) address glyphs through
286	    // the U+F0xx private-use range; machines without the font draw tofu.
287	    // Substitute the Unicode equivalent and let any real font draw it.
288	    if (/^[-]$/.test(markerText)) {
289	      const uni = PUA_MARKERS[`${(list!.markerFontName ?? "").replace(/\s+/g, "").toLowerCase()}:${markerText.charCodeAt(0) & 0xff}`];
290	      marker.textContent = uni ?? "•";
291	      marker.style.fontFamily = "";
292	    }
293	    if (list!.markerScale) {
294	      const basePt = runCs?.fontSizePt;
295	      marker.style.fontSize = basePt
296	        ? `${basePt * list!.markerScale}px`
297	        : `${list!.markerScale}em`;
298	    }
299	    if (list!.markerBaselineOffsetPt) marker.style.verticalAlign = `${list!.markerBaselineOffsetPt}px`;
300	    wrap.appendChild(marker);
301	    wrap.appendChild(el);
302	    renderParagraphContent(el, p, doc, ctx, style?.dropCap);
303	    return wrap;
304	  }
✏️ Edit text.ts
/Users/phebert/pnk/viewer/src/text.ts
    const marker = document.createElement("span");
    marker.className = "list-marker";
    marker.textContent = markerText;
    marker.style.minWidth = "18px";
+
    const marker = document.createElement("span");
    marker.className = "list-marker";
    if (list!.markerKind !== "image") marker.textContent = markerText;
    marker.style.minWidth = "18px";
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)
✏️ Edit text.ts
/Users/phebert/pnk/viewer/src/text.ts
    if (list!.markerScale) {
      const basePt = runCs?.fontSizePt;
      marker.style.fontSize = basePt
        ? `${basePt * list!.markerScale}px`
        : `${list!.markerScale}em`;
    }
    if (list!.markerBaselineOffsetPt) marker.style.verticalAlign = `${list!.markerBaselineOffsetPt}px`;
+
    if (list!.markerScale && list!.markerKind !== "image") {
      const basePt = runCs?.fontSizePt;
      marker.style.fontSize = basePt
        ? `${basePt * list!.markerScale}px`
        : `${list!.markerScale}em`;
    }
    if (list!.markerBaselineOffsetPt) marker.style.verticalAlign = `${list!.markerBaselineOffsetPt}px`;
    // Image marker (0d5851c0 rightArrow bullets): the PNG scales with the
    // text like a glyph would — markerScale × the run's size (same
    // scale_with_text rule as string markers), hung on the baseline.
    if (list!.markerKind === "image" && list!.markerImage) {
      const img = document.createElement("img");
      img.src = ctx.url(list!.markerImage.dataId);
      const basePt = runCs?.fontSizePt;
      const hPx = (list!.markerScale ?? 0.5) * (basePt ?? 15);
      img.style.height = `${hPx.toFixed(1)}px`;
      img.style.width = "auto";
      img.style.verticalAlign = "baseline";
      marker.appendChild(img);
    }
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)
Thinking

Now the flex-shrink fix and the 0×0-shape anchor in drawables.ts:

✏️ Edit drawables.ts
/Users/phebert/pnk/viewer/src/drawables.ts
    const minScale = box.dataset.textFit === "shrink" ? 0.35 : 0.6;
    inner.style.transform = "";
    inner.style.width = "";
+
    const minScale = box.dataset.textFit === "shrink" ? 0.35 : 0.6;
    inner.style.transform = "";
    inner.style.width = "";
    // The inner is a flex ITEM (.drawable-text is the flex container for
    // vertical alignment); its default flex-shrink:1 silently squashed the
    // compensated >100% width back to the container's, so text wrapped at
    // the ORIGINAL width and the scale left a right-side gap — centered
    // lines drifted left by (1−s)/2·width (0d5851c0 slide 9 subtitle,
    // ~45pt off-center at s=0.89).
    inner.style.flex = "0 0 auto";
The file /Users/phebert/pnk/viewer/src/drawables.ts has been updated successfully. (file state is current in your context — no need to Read it back)
Thinking
✏️ Edit drawables.ts
/Users/phebert/pnk/viewer/src/drawables.ts
      // Zero-size textboxes (Keynote emits some badge labels at 0×0) carry
      // their text unclipped: let the content size the box instead.
      if (!c.size || (c.size.width === 0 && c.size.height === 0)) {
        div.style.width = "auto";
        div.style.height = "auto";
        div.style.overflow = "visible";
        layer.style.overflow = "visible";
        layer.style.position = "relative";
        layer.style.whiteSpace = "nowrap";
        layer.style.width = "max-content"; // percentage of an auto box is meaningless
        layer.style.height = "auto";
        // A 0-size box is a point ANCHOR: text laid out in it overflows
        // equally per its alignment, so centered paragraphs center ON the
        // stored position and right-aligned ones end there; same vertically
        // via the box's own vertical alignment. Monster deck fixture: the
        // centered "DGLAP, ERBL" tag stores (888, 477) and Apple draws its
        // box spanning 767..1016 x 456..507 — dead-center on the point;
        // the left-aligned "Transverse" label anchors top-left as before.
        const firstPara = d.text.paragraphs?.[0];
        const hAlign = paraStyleOf(doc, firstPara?.pStyle)?.horizontalAlignment;
        const tx = hAlign === "center" ? "-50%" : hAlign === "right" ? "-100%" : "0%";
        const ty = d.verticalAlignment === "middle" ? "-50%" : d.verticalAlignment === "bottom" ? "-100%" : "0%";
        if (tx !== "0%" || ty !== "0%") {
          div.style.transform = `${div.style.transform ?? ""} translate(${tx}, ${ty})`.trim();
        } else {
          // Auto-sized boxes lay out at Apple's metrics; browser faces run
          // wider, so a label Apple fits to the slide edge can spill past it
          // ("James 3:13-18" bottom-right badges). The measurement pass
          // clamps (offset-based, so only valid untransformed).
          div.dataset.textFit = "edge-clamp";
        }
      } else {
+
      // Zero-size textboxes (Keynote emits some badge labels at 0×0) carry
      // their text unclipped: let the content size the box instead.
      if (!c.size || (c.size.width === 0 && c.size.height === 0)) {
        anchorZeroSizeText(div, layer, d.text, d.verticalAlignment, doc);
      } else {
The file /Users/phebert/pnk/viewer/src/drawables.ts has been updated successfully. (file state is current in your context — no need to Read it back)
✏️ Edit drawables.ts
/Users/phebert/pnk/viewer/src/drawables.ts
    const layer = textLayer({ ...d, text: d.text, verticalAlignment: d.verticalAlignment, common: c }, doc, ctx);
    if (layer) {
      if (effH === 0) {
        // 0-height shape carrying text (RIPE ea785d2e subtitle): the box is
        // an anchor, not a clip — let the text flow down from it.
        layer.style.bottom = "auto";
        layer.style.height = "auto";
        layer.style.overflow = "visible";
      } else {
+
    const layer = textLayer({ ...d, text: d.text, verticalAlignment: d.verticalAlignment, common: c }, doc, ctx);
    if (layer) {
      if (w === 0 && effH === 0) {
        // 0×0 shape carrying text: a point anchor exactly like the 0×0
        // textbox labels (0d5851c0 slide 29's 51pt quote — Apple lays it
        // out natural-width from the anchor; our 0-width box wrapped it
        // into a 4-line sliver).
        anchorZeroSizeText(div, layer, d.text, d.verticalAlignment, doc);
      } else if (effH === 0) {
        // 0-height shape carrying text (RIPE ea785d2e subtitle): the box is
        // an anchor, not a clip — let the text flow down from it.
        layer.style.bottom = "auto";
        layer.style.height = "auto";
        layer.style.overflow = "visible";
      } else {
The file /Users/phebert/pnk/viewer/src/drawables.ts has been updated successfully. (file state is current in your context — no need to Read it back)

Now the shared helper (extracted from the old textbox block):

✏️ Edit drawables.ts
/Users/phebert/pnk/viewer/src/drawables.ts
/** Position + rotate + opacity from DrawableCommon, 1pt = 1px. */
export function applyCommonGeometry(div: HTMLElement, c: DrawableCommon): void {
+
/**
 * A 0-size box is a point ANCHOR: text laid out in it overflows per its
 * alignment, so centered paragraphs center ON the stored position and
 * right-aligned ones end there; same vertically via the box's own vertical
 * alignment. Monster deck fixture: the centered "DGLAP, ERBL" tag stores
 * (888, 477) and Apple draws its box spanning 767..1016 x 456..507 —
 * dead-center on the point; the left-aligned "Transverse" label anchors
 * top-left as before. Applies to 0×0 textboxes and 0×0 shapes alike.
 */
function anchorZeroSizeText(
  div: HTMLElement,
  layer: HTMLElement,
  text: { paragraphs?: Paragraph[] },
  verticalAlignment: string | undefined,
  doc: HydratedDoc,
): void {
  div.style.width = "auto";
  div.style.height = "auto";
  div.style.overflow = "visible";
  layer.style.overflow = "visible";
  layer.style.position = "relative";
  layer.style.whiteSpace = "nowrap";
  layer.style.width = "max-content"; // percentage of an auto box is meaningless
  layer.style.height = "auto";
  const firstPara = text.paragraphs?.find((p) => typeof p !== "string" && p.items.length > 0) ?? text.paragraphs?.[0];
  const hAlign = paraStyleOf(doc, typeof firstPara === "string" ? undefined : firstPara?.pStyle)?.horizontalAlignment;
  const tx = hAlign === "center" ? "-50%" : hAlign === "right" ? "-100%" : "0%";
  const ty = verticalAlignment === "middle" ? "-50%" : verticalAlignment === "bottom" ? "-100%" : "0%";
  if (tx !== "0%" || ty !== "0%") {
    div.style.transform = `${div.style.transform ?? ""} translate(${tx}, ${ty})`.trim();
  } else {
    // Auto-sized boxes lay out at Apple's metrics; browser faces run
    // wider, so a label Apple fits to the slide edge can spill past it
    // ("James 3:13-18" bottom-right badges). The measurement pass
    // clamps (offset-based, so only valid untransformed).
    div.dataset.textFit = "edge-clamp";
  }
}

/** Position + rotate + opacity from DrawableCommon, 1pt = 1px. */
export function applyCommonGeometry(div: HTMLElement, c: DrawableCommon): void {
The file /Users/phebert/pnk/viewer/src/drawables.ts has been updated successfully. (file state is current in your context — no need to Read it back)
Thinking
$ Bash
Typecheck and rebuild viewer
cd /Users/phebert/pnk/viewer && npx tsc --noEmit 2>&1 | head; bash scripts/build_viewer.sh 2>&1 | tail -1
src/drawables.ts(708,24): error TS2304: Cannot find name 'Paragraph'.
src/drawables.ts(852,40): error TS2345: Argument of type 'StyledText | undefined' is not assignable to parameter of type '{ paragraphs?: Paragraph[] | undefined; }'.
  Type 'undefined' is not assignable to type '{ paragraphs?: Paragraph[] | undefined; }'.
src/text.ts(305,7): error TS2322: Type 'string | undefined' is not assignable to type 'string'.
  Type 'undefined' is not assignable to type 'string'.
bash: scripts/build_viewer.sh: No such file or directory
$ Bash
Check imports and error sites
grep -n "^import\|from \"../../model" /Users/phebert/pnk/viewer/src/drawables.ts | head -8; sed -n '300,310p' /Users/phebert/pnk/viewer/src/text.ts; sed -n '848,856p' /Users/phebert/pnk/viewer/src/drawables.ts
11:import type {
20:} from "../../model/src/shared";
21:import type { ViewerCtx } from "./ctx";
22:import { renderTable } from "./tables";
23:import { renderStyledText } from "./text";
24:import { paraStyleOf, type HydratedDoc } from "./hydrate";
    // Image marker (0d5851c0 rightArrow bullets): the PNG scales with the
    // text like a glyph would — markerScale × the run's size (same
    // scale_with_text rule as string markers), hung on the baseline.
    if (list!.markerKind === "image" && list!.markerImage) {
      const img = document.createElement("img");
      img.src = ctx.url(list!.markerImage.dataId);
      const basePt = runCs?.fontSizePt;
      const hPx = (list!.markerScale ?? 0.5) * (basePt ?? 15);
      img.style.height = `${hPx.toFixed(1)}px`;
      img.style.width = "auto";
      img.style.verticalAlign = "baseline";
        // 0×0 shape carrying text: a point anchor exactly like the 0×0
        // textbox labels (0d5851c0 slide 29's 51pt quote — Apple lays it
        // out natural-width from the anchor; our 0-width box wrapped it
        // into a 4-line sliver).
        anchorZeroSizeText(div, layer, d.text, d.verticalAlignment, doc);
      } else if (effH === 0) {
        // 0-height shape carrying text (RIPE ea785d2e subtitle): the box is
        // an anchor, not a clip — let the text flow down from it.
        layer.style.bottom = "auto";
$ Bash
See import list and ctx.url signature
sed -n '11,20p' /Users/phebert/pnk/viewer/src/drawables.ts; grep -n "url(" /Users/phebert/pnk/viewer/src/ctx.ts | head -3; grep -n "url" /Users/phebert/pnk/viewer/src/ctx.ts | head -6
import type {
  ChartModel,
  CurveElement,
  Drawable,
  DrawableCommon,
  Fill,
  LineEnd,
  ShapeGeometry,
  Stroke,
} from "../../model/src/shared";
32:  /** Register bytes for a DataInfo id; later `url()` calls hand back a blob URL. */
43:  url(dataId: string): string | undefined {
30:  private urls = new Map<string, string>();
32:  /** Register bytes for a DataInfo id; later `url()` calls hand back a blob URL. */
40:    this.urls.set(dataId, URL.createObjectURL(blob));
43:  url(dataId: string): string | undefined {
44:    return this.urls.get(dataId);
49:    for (const u of this.urls.values()) URL.revokeObjectURL(u);
Thinking
✏️ Edit drawables.ts
/Users/phebert/pnk/viewer/src/drawables.ts
  ShapeGeometry,
  Stroke,
} from "../../model/src/shared";
+
  ShapeGeometry,
  Stroke,
  StyledText,
} from "../../model/src/shared";
The file /Users/phebert/pnk/viewer/src/drawables.ts has been updated successfully. (file state is current in your context — no need to Read it back)
✏️ Edit drawables.ts
/Users/phebert/pnk/viewer/src/drawables.ts
function anchorZeroSizeText(
  div: HTMLElement,
  layer: HTMLElement,
  text: { paragraphs?: Paragraph[] },
  verticalAlignment: string | undefined,
  doc: HydratedDoc,
): void {
+
function anchorZeroSizeText(
  div: HTMLElement,
  layer: HTMLElement,
  text: StyledText | undefined,
  verticalAlignment: string | undefined,
  doc: HydratedDoc,
): void {
The file /Users/phebert/pnk/viewer/src/drawables.ts has been updated successfully. (file state is current in your context — no need to Read it back)
✏️ Edit drawables.ts
/Users/phebert/pnk/viewer/src/drawables.ts
  const firstPara = text.paragraphs?.find((p) => typeof p !== "string" && p.items.length > 0) ?? text.paragraphs?.[0];
  const hAlign = paraStyleOf(doc, typeof firstPara === "string" ? undefined : firstPara?.pStyle)?.horizontalAlignment;
+
  const paras = text?.paragraphs;
  const firstPara = paras?.find((p) => typeof p !== "string" && p.items.length > 0) ?? paras?.[0];
  const hAlign = paraStyleOf(doc, typeof firstPara === "string" ? undefined : firstPara?.pStyle)?.horizontalAlignment;
The file /Users/phebert/pnk/viewer/src/drawables.ts has been updated successfully. (file state is current in your context — no need to Read it back)
✏️ Edit text.ts
/Users/phebert/pnk/viewer/src/text.ts
    if (list!.markerKind === "image" && list!.markerImage) {
      const img = document.createElement("img");
      img.src = ctx.url(list!.markerImage.dataId);
      const basePt = runCs?.fontSizePt;
      const hPx = (list!.markerScale ?? 0.5) * (basePt ?? 15);
      img.style.height = `${hPx.toFixed(1)}px`;
      img.style.width = "auto";
      img.style.verticalAlign = "baseline";
      marker.appendChild(img);
    }
+
    if (list!.markerKind === "image" && list!.markerImage) {
      const url = ctx.url(list!.markerImage.dataId);
      if (url) {
        const img = document.createElement("img");
        img.src = url;
        const basePt = runCs?.fontSizePt;
        const hPx = (list!.markerScale ?? 0.5) * (basePt ?? 15);
        img.style.height = `${hPx.toFixed(1)}px`;
        img.style.width = "auto";
        img.style.verticalAlign = "baseline";
        marker.appendChild(img);
      } else marker.textContent = "•"; // media bytes missing: glyph fallback
    }
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 typecheck
cd /Users/phebert/pnk && bash scripts/build_viewer.sh 2>&1 | tail -1 && cd viewer && npx tsc --noEmit && echo TSC-OK
viewer built: viewer/dist/  (serve: cd viewer && npm run serve)
TSC-OK
$ Bash
Re-run visual diff on the deck
cd /Users/phebert/pnk && uv run --with pillow --with pyobjc-framework-Quartz --with pymupdf python3 scripts/visual_diff.py --app keynote --fixture ~/Development/pnk-fixtures/crawl/0d5851c0105958b602c58646b98ffaee1b453def8478f9b872aca37319d2ed5f.key --out /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/vd-0d58b --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-0d58b/summary.md
Read
{
  "file_path": "/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/vd-0d58b/composites/composite-page-1.png"
}
Read
{
  "file_path": "/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/vd-0d58b/composites/composite-page-2.png"
}
Thinking

Slide 1 title centered correctly, slide 2 arrows + nesting present. Checking 9, 19, 29:

Read
{
  "file_path": "/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/vd-0d58b/composites/composite-page-9.png"
}
Read
{
  "file_path": "/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/vd-0d58b/composites/composite-page-29.png"
}
Thinking

Slide 9's centering and slide 29's quote are fixed. Checking slide 19's leading:

Read
{
  "file_path": "/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/vd-0d58b/composites/composite-page-19.png"
}
Thinking

Slide 19's arrows are in; the mono block runs ~6% loose. Let me check what spacing those paragraphs actually specify:

$ Bash
Slide 19 paragraph spacing specs
python3 -c "
import json
d = json.load(open('/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/0d58.json'))
doc = d.get('document', d)
sl = doc['slides'][18]
seen = set()
for dr in sl.get('drawables', []):
    if dr.get('text'):
        for p in dr['text'].get('paragraphs', []):
            if isinstance(p, dict) and p.get('pStyle') not in seen:
                seen.add(p.get('pStyle'))
                txt = [(it if isinstance(it,str) else it.get('text',''))[:25] for it in p.get('items',[]) if isinstance(it,(str,dict))][:1]
                ps = doc['styles']['para'][p['pStyle']] if p.get('pStyle') is not None else {}
                cs = None
                for it in p.get('items', []):
                    if isinstance(it, dict) and it.get('cStyle') is not None:
                        cs = doc['styles']['char'][it['cStyle']]; break
                print(p.get('pStyle'), txt, '| spacing:', {k:v for k,v in ps.items() if 'pacing' in k or 'pace' in k}, '| font:', (cs or {}).get('fontName'), (cs or {}).get('fontSizePt'))
"
5 ['Spamhaus XBL'] | spacing: {} | font: AppleSDGothicNeo-Bold 52.0
6 ['Detailed exploit intellig'] | spacing: {'spaceBeforePt': 18.0, 'lineSpacingExactPt': 40.0} | font: AppleSDGothicNeo-Regular 32.0
23 ['188.51.94.89, '] | spacing: {'spaceBeforePt': 18.0, 'lineSpacingExactPt': 40.0} | font: CourierNewPSMT 28.0
24 ['AS25019, '] | spacing: {'lineSpacingExactPt': 40.0} | font: CourierNewPSMT 28.0
0 [''] | spacing: {} | font: None None
Thinking

Specs say exactly 40px pitch — let me measure the live DOM to see what we actually produce:

$ Bash
Measure slide 19 paragraph pitch in DOM
cd /Users/phebert/pnk/viewer && cat > probe-tmp.mjs <<'EOF'
import { chromium } from "playwright";
const fixture = "/Users/phebert/Development/pnk-fixtures/crawl/0d5851c0105958b602c58646b98ffaee1b453def8478f9b872aca37319d2ed5f.key";
const b = await chromium.launch();
const page = await b.newPage({ viewport: { width: 1400, height: 1000 } });
await page.goto("http://127.0.0.1:8123/");
await page.setInputFiles("#file-input", fixture);
await page.waitForSelector(".slide-list-item", { timeout: 30000 });
await page.locator(".slide-list-item").nth(18).click();
await page.waitForTimeout(600);
const info = await page.evaluate(() => {
  const stage = document.querySelector(".slide-stage");
  const canvas = stage.querySelector(".canvas-inner");
  const cr = canvas.getBoundingClientRect();
  const scale = cr.width / 1024;
  const ps = [...canvas.querySelectorAll(".drawable-text p, .drawable-text .list-item")];
  return ps.map((p) => {
    const r = p.getBoundingClientRect();
    const cs = getComputedStyle(p.tagName === "P" ? p : p.querySelector("p") ?? p);
    return {
      t: (p.textContent || "").trim().slice(0, 22),
      yDoc: +((r.y - cr.y) / scale).toFixed(1),
      hDoc: +(r.height / scale).toFixed(1),
      lh: cs.lineHeight, fs: cs.fontSize, mt: cs.marginTop,
    };
  });
});
console.log(JSON.stringify(info, null, 0).replace(/\},/g, "},\n"));
await b.close();
EOF
node probe-tmp.mjs
[{"t":"Spamhaus XBL","yDoc":162.8,"hDoc":66,"lh":"normal","fs":"52px","mt":"0px"},
{"t":"Detailed exploit intel","yDoc":264,"hDoc":40.7,"lh":"40px","fs":"32px","mt":"0px"},
{"t":"Detailed exploit intel","yDoc":264,"hDoc":40.7,"lh":"40px","fs":"32px","mt":"0px"},
{"t":"188.51.94.89, IP DETEC","yDoc":321.7,"hDoc":41.7,"lh":"40px","fs":"28px","mt":"18px"},
{"t":"AS25019, ASN","yDoc":363.4,"hDoc":41.7,"lh":"40px","fs":"28px","mt":"0px"},
{"t":"188.51.64.0/19, IP RAN","yDoc":405.1,"hDoc":41.7,"lh":"40px","fs":"28px","mt":"0px"},
{"t":"SA, COUNTRY","yDoc":446.7,"hDoc":41.7,"lh":"40px","fs":"28px","mt":"0px"},
{"t":"saudi.net.sa, ISP/NETW","yDoc":488.4,"hDoc":41.7,"lh":"40px","fs":"28px","mt":"0px"},
{"t":", CONTACT","yDoc":530.1,"hDoc":41.7,"lh":"40px","fs":"28px","mt":"0px"},
{"t":"1426757771, TIMESTAMP ","yDoc":571.7,"hDoc":41.7,"lh":"40px","fs":"28px","mt":"0px"},
{"t":"BOT type 9000 s_zeus 8","yDoc":613.4,"hDoc":40.7,"lh":"40px","fs":"22px","mt":"0px"},
{"t":"BOT NAME              ","yDoc":654.1,"hDoc":38.8,"lh":"40px","fs":"20px","mt":"0px"}]
Thinking
Thinking

Found the issue: baseline-shifted red label spans are inflating row height from 40px to 41.7px. I'll fix this by setting line-height: 0 on those shifted spans so they stop pushing the line box.

✏️ Edit text.ts
/Users/phebert/pnk/viewer/src/text.ts
  if (cs.baseline === "superscript") s.verticalAlign = "super";
  else if (cs.baseline === "subscript") s.verticalAlign = "sub";
  if (cs.baselineShiftPt) s.verticalAlign = `${cs.baselineShiftPt}px`;
+
  if (cs.baseline === "superscript") s.verticalAlign = "super";
  else if (cs.baseline === "subscript") s.verticalAlign = "sub";
  if (cs.baselineShiftPt) s.verticalAlign = `${cs.baselineShiftPt}px`;
  // A shifted run must not grow the LINE box (Apple's baseline shifts never
  // change line spacing; 0d5851c0 slide 19's raised red labels stretched
  // 40px-exact rows to 41.7px — visibly loose over 8 rows). line-height: 0
  // removes the shifted inline box from line-height calculation.
  if (cs.baseline === "superscript" || cs.baseline === "subscript" || cs.baselineShiftPt) s.lineHeight = "0";
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)
Thinking

Now the UI rework. Reading keynote.ts:

Read
{
  "file_path": "/Users/phebert/pnk/viewer/src/keynote.ts"
}
1	// Keynote (.key): slide list + per-slide canvas + presenter notes.
2	
3	import type { Fill } from "../../model/src/shared";
4	import type { Drawable, DrawableCommon, KeynoteDocument, Slide } from "../../model/src/keynote";
5	import type { ViewerCtx } from "./ctx";
6	import { applyTextFit, fillToCss, renderCanvasDrawable } from "./drawables";
7	import { renderStyledText } from "./text";
8	import type { HydratedDoc } from "./hydrate";
9	
10	const THUMB_WIDTH = 168;
11	
12	function buildCanvas(
13	  slide: Slide,
14	  doc: KeynoteDocument,
15	  hdoc: HydratedDoc,
16	  ctx: ViewerCtx,
17	  widthPx: number,
18	  slideNumber: number,
19	): HTMLElement {
20	  const { width, height } = doc.slideSize;
21	  const scale = widthPx / width;
22	
23	  const frame = document.createElement("div");
24	  frame.className = "canvas-frame";
25	  frame.style.aspectRatio = `${width} / ${height}`;
26	
27	  const inner = document.createElement("div");
28	  inner.className = "canvas-inner";
29	  inner.style.width = `${width}px`;
30	  inner.style.height = `${height}px`;
31	  inner.style.transform = `scale(${scale})`;
32	
33	  // Resolved-inheritance contract (docs/model-review.md §3b): the converter
34	  // emits Slide.background already master-resolved and masterDrawables as the
35	  // filtered underlay — paint background, underlay, drawables, verbatim.
36	  applyStageBackground(inner, slide.background ?? null, ctx);
37	  for (const d of slide.masterDrawables ?? []) inner.appendChild(renderCanvasDrawable(d, hdoc, ctx));
38	  for (const d of slide.drawables) {
39	    // The slide-number placeholder paints only when the slide shows numbers
40	    // (Apple hides it otherwise); its page-number field bakes to the real
41	    // index so it reads "2", not a field label.
42	    if (roleOf(d) === "slide-number") {
43	      if (!slide.slideNumberVisible) continue;
44	      inner.appendChild(renderCanvasDrawable(bakePageNumber(d, slideNumber), hdoc, ctx));
45	      continue;
46	    }
47	    // Empty placeholders are editor chrome: Keynote's own export paints
48	    // nothing for them (their theme para styles can carry stray borders).
49	    if (roleOf(d) && !hasVisibleText(d)) continue;
50	    inner.appendChild(renderCanvasDrawable(d, hdoc, ctx));
51	  }
52	
53	  frame.appendChild(inner);
54	  return frame;
55	}
56	
57	// -- slide background -------------------------------------------------------
58	
59	/** Image extensions <img> cannot rasterize (Apple renders them natively). */
60	const VECTOR_FILL = /\.(pdf|ai|eps)$/i;
61	
62	/**
63	 * Best-effort CSS gradient from PDF-based vector art (.ai/.pdf background
64	 * fills). Modern .ai files are PDF-compatible, and their shading DICTS are
65	 * plain text even when content streams are Flate-compressed: an axial
66	 * gradient carries `/ShadingType 2` with `/C0 [...]` / `/C1 [...]` function
67	 * endpoints (PDF 32000-1 §8.7.4.5.3). The axis direction lives in the
68	 * compressed stream's transform, so we assume top-to-bottom — the common
69	 * orientation for slide backdrops [inferred]. Returns null when no shading
70	 * is found (fully-compressed or raster-only art).
71	 */
72	async function vectorArtGradientCss(url: string): Promise<string | null> {
73	  try {
74	    const buf = await (await fetch(url)).arrayBuffer();
75	    const text = new TextDecoder("latin1").decode(buf);
76	    if (!/\/ShadingType\s*2/.test(text)) return null;
77	    const comp = (name: string): number[] | null => {
78	      const m = text.match(new RegExp(`\\/${name}\\s*\\[([^\\]]*)\\]`));
79	      if (!m) return null;
80	      const nums = m[1].trim().split(/\s+/).map(Number).filter((n) => !Number.isNaN(n));
81	      return nums.length ? nums : null;
82	    };
83	    const rgb = (c: number[]): string | null => {
84	      const to255 = (v: number) => Math.round(Math.min(1, Math.max(0, v)) * 255);
85	      if (c.length === 3) return `rgb(${to255(c[0])},${to255(c[1])},${to255(c[2])})`;
86	      if (c.length === 1) return `rgb(${to255(c[0])},${to255(c[0])},${to255(c[0])})`;
87	      if (c.length === 4) {
88	        // DeviceCMYK -> naive RGB
89	        const [cy, mg, ye, k] = c;
90	        return `rgb(${to255((1 - cy) * (1 - k))},${to255((1 - mg) * (1 - k))},${to255((1 - ye) * (1 - k))})`;
91	      }
92	      return null;
93	    };
94	    const c0 = comp("C0"), c1 = comp("C1");
95	    if (!c0 || !c1) return null;
96	    const a = rgb(c0), b = rgb(c1);
97	    return a && b ? `linear-gradient(180deg, ${a}, ${b})` : null;
98	  } catch {
99	    return null;
100	  }
101	}
102	
103	/**
104	 * Second-chance backdrop sniff for vector art with no axial shading: inflate
105	 * the PDF's FlateDecode content streams (DecompressionStream "deflate" — PDF
106	 * streams are zlib-wrapped) and take the FIRST 3-component fill operator
107	 * (`r g b rg|sc|scn`) in a stream that draws paths — slide backdrops open
108	 * with a full-page fill (RIPE ea785d2e: `0.224 0.227 0.239 scn` charcoal
109	 * before the polygon texture) [inferred: single-page .ai backdrop layout].
110	 */
111	async function vectorArtFirstFillCss(url: string): Promise<string | null> {
112	  try {
113	    const bytes = new Uint8Array(await (await fetch(url)).arrayBuffer());
114	    const text = new TextDecoder("latin1").decode(bytes); // 1:1 byte mapping
115	    // Match `stream` but not `endstream` (Illustrator uses \r EOLs).
116	    const streamRe = /(^|[^d])stream\r?\n?/g;
117	    let mm: RegExpExecArray | null;
118	    let tries = 0;
119	    while ((mm = streamRe.exec(text)) && tries < 40) {
120	      const start = mm.index + mm[0].length;
121	      const end = text.indexOf("endstream", start);
122	      if (end < 0) break;
123	      tries++;
124	      let content: string | null = null;
125	      // zlib CMF: compression method nibble 8 (0x78 AND Illustrator's 0x48).
126	      if ((bytes[start] & 0x0f) === 8) {
127	        const chunks: number[] = [];
128	        try {
129	          const ds = new DecompressionStream("deflate");
130	          const reader = new Blob([bytes.slice(start, end)]).stream().pipeThrough(ds).getReader();
131	          // Tolerant read: PDF streams may carry trailing EOL junk that makes
132	          // DecompressionStream throw at the very end — keep what inflated.
133	          for (;;) {
134	            const r = await reader.read();
135	            if (r.done) break;
136	            for (const b of r.value) chunks.push(b);
137	          }
138	        } catch { /* partial output retained */ }
139	        if (chunks.length) content = new TextDecoder("latin1").decode(new Uint8Array(chunks));
140	      }
141	      if (content === null) content = text.slice(start, end); // plain-text form streams
142	      if (!/\d\s+[ml]\b/.test(content)) continue; // no path ops: not page content
143	      const m = content.match(/([\d.]+)\s+([\d.]+)\s+([\d.]+)\s+(?:rg|scn|sc)\b/);
144	      if (!m) continue;
145	      const to255 = (v: string) => Math.round(Math.min(1, Math.max(0, Number(v))) * 255);
146	      return `rgb(${to255(m[1])},${to255(m[2])},${to255(m[3])})`;
147	    }
148	    return null;
149	  } catch {
150	    return null;
151	  }
152	}
153	
154	/** A stage-renderable image fill, or null (missing bytes / vector art). */
155	function renderableImageFill(
156	  f: Fill | null,
157	  ctx: ViewerCtx,
158	): { url: string; objectFit: string } | null {
159	  if (!f || f.type !== "image") return null;
160	  const name = f.image.preferredFileName ?? f.image.fileName ?? "";
161	  const url = ctx.url(f.image.dataId);
162	  if (!url || VECTOR_FILL.test(name)) return null;
163	  const objectFit =
164	    f.technique === "scale-to-fill" ? "cover"
165	    : f.technique === "scale-to-fit" ? "contain"
166	    : "fill";
167	  return { url, objectFit };
168	}
169	
170	/**
171	 * Paint the stage background (the fill arrives master-resolved from the
172	 * converter, model-review §3b). A CSS solid/gradient fill paints directly;
173	 * a raster image fill paints via <img>; an unrenderable image fill (vector
174	 * art / theme assets whose bytes .key files do not ship) degrades to a
175	 * name-keyed approximation, upgraded async by PDF shading-dict sniffing.
176	 */
177	function applyStageBackground(
178	  inner: HTMLElement,
179	  slideFill: Fill | null,
180	  ctx: ViewerCtx,
181	): void {
182	  const img = renderableImageFill(slideFill, ctx);
183	  if (img) {
184	    const imgEl = document.createElement("img");
185	    imgEl.src = img.url;
186	    imgEl.alt = "slide background";
187	    imgEl.style.position = "absolute";
188	    imgEl.style.inset = "0";
189	    imgEl.style.width = "100%";
190	    imgEl.style.height = "100%";
191	    imgEl.style.objectFit = img.objectFit;
192	    inner.appendChild(imgEl);
193	    return;
194	  }
195	  const css = slideFill && slideFill.type !== "image" ? fillToCss(slideFill) : undefined;
196	  if (css) {
197	    inner.style.background = css;
198	    return;
199	  }
200	  if (slideFill?.type === "image") {
201	    // Built-in theme art is not shipped in .key files (DataInfo names
202	    // survive, bytes don't); approximate the known Keynote backdrop assets
203	    // by name, else leave white.
204	    const name = slideFill.image.preferredFileName ?? slideFill.image.fileName ?? "";
205	    const approx = THEME_BACKDROP_APPROX[name.toLowerCase()];
206	    if (approx) inner.style.background = approx;
207	    // Vector art (.ai/.pdf) backgrounds whose bytes DID ship: sniff an axial
208	    // shading gradient out of the PDF dictionaries and paint it (async —
209	    // upgrades the backdrop as soon as the bytes parse).
210	    const url = ctx.url(slideFill.image.dataId);
211	    if (url && VECTOR_FILL.test(name)) {
212	      void vectorArtGradientCss(url).then(async (grad) => {
213	        const css = grad ?? (await vectorArtFirstFillCss(url));
214	        if (css) inner.style.background = css;
215	      });
216	    }
217	  }
218	}
219	
220	/**
221	 * Approximations for Apple-shipped theme backdrop assets whose bytes .key
222	 * files omit (Keynote renders them from its app-bundled theme store).
223	 */
224	const THEME_BACKDROP_APPROX: Record<string, string> = {
225	  // Keynote default template spotlight backdrop (a9d4f68c "Home" deck).
226	  "spotlight_10x7.jpeg":
227	    "radial-gradient(ellipse 95% 80% at 50% 32%, #8f8f8f 0%, #6d6d6d 38%, #4b4b4b 68%, #3a3a3a 100%)",
228	  // News/photo-deck cream paper texture (5089b9c7 deck).
229	  "cotton_paper_hd.jpeg": "radial-gradient(ellipse 120% 100% at 50% 40%, #f7f3e8 0%, #efe9d8 60%, #e7dfc9 100%)",
230	  // Keynote '09 "Showroom" theme backdrop: light-gray studio-sweep gradient
231	  // (0f9df553 / b52c89c1 decks reference the jpeg, bytes not shipped).
232	  "showroom2_1024x768.jpeg":
233	    "linear-gradient(180deg, #e9eaec 0%, #dcdee1 45%, #c9cbd0 75%, #babcc2 100%)",
234	  "showroom_1024x768.jpeg":
235	    "linear-gradient(180deg, #e9eaec 0%, #dcdee1 45%, #c9cbd0 75%, #babcc2 100%)",
236	  "showroom_1024x768-1.jpeg":
237	    "linear-gradient(180deg, #e9eaec 0%, #dcdee1 45%, #c9cbd0 75%, #babcc2 100%)",
238	};
239	
240	/** Placeholder role of a drawable, when it has one. Master-underlay
241	 * filtering itself lives in the converter now (model-review §3b). */
242	function roleOf(d: Drawable): string | null {
243	  const c = "common" in d && d.common ? (d.common as DrawableCommon) : null;
244	  return c?.placeholder?.role ?? null;
245	}
246	
247	/** Replace page-number fields with the slide's real number, inheriting the
248	 * char style of a neighboring styled run (Keynote pairs the field with an
249	 * empty styled run carrying its look). */
250	function bakePageNumber(d: Drawable, n: number): Drawable {
251	  if (!("text" in d) || !d.text) return d;
252	  let changed = false;
253	  const paragraphs = d.text.paragraphs.map((p) => {
254	    const cStyle = p.items.reduce<number | undefined>(
255	      (acc, it) =>
256	        acc ?? (typeof it === "object" && it && "cStyle" in it ? (it as { cStyle?: number }).cStyle : undefined),
257	      undefined,
258	    );
259	    const items = p.items.map((it) => {
260	      const isPageField =
261	        typeof it === "object" && it !== null && "type" in it
262	        && (it as { type?: string }).type === "field"
263	        && (it as { field?: { kind?: string } }).field?.kind === "page-number";
264	      if (!isPageField) return it;
265	      changed = true;
266	      return cStyle === undefined ? { text: String(n) } : { text: String(n), cStyle };
267	    });
268	    return { ...p, items };
269	  });
270	  return changed ? ({ ...d, text: { ...d.text, paragraphs } } as Drawable) : d;
271	}
272	
273	/** True when the drawable carries at least one non-whitespace text run. */
274	function hasVisibleText(d: Drawable): boolean {
275	  if (!("text" in d) || !d.text) return false;
276	  for (const p of d.text.paragraphs) {
277	    for (const item of p.items) {
278	      if (typeof item === "string") {
279	        if (item.trim()) return true;
280	      } else if ("text" in item && typeof item.text === "string" && item.text.trim()) {
281	        return true;
282	      } else if ("type" in item && item.type === "field") {
283	        return true; // fields (page number etc.) render content
284	      }
285	    }
286	  }
287	  return false;
288	}
289	
290	function renderStage(
291	  doc: KeynoteDocument,
292	  hdoc: HydratedDoc,
293	  ctx: ViewerCtx,
294	  slide: Slide,
295	  index: number,
296	  widthPx: number,
297	): HTMLElement {
298	  const stage = document.createElement("div");
299	  stage.className = "slide-stage";
300	
301	  const frame = buildCanvas(slide, doc, hdoc, ctx, widthPx, index + 1);
302	  frame.dataset.slideIndex = String(index);
303	  stage.appendChild(frame);
304	
305	  const caption = document.createElement("div");
306	  caption.className = "slide-caption muted";
307	  const bits = [`Slide ${index + 1}${slide.name ? ` — ${slide.name}` : ""}`];
308	  if (slide.masterName) bits.push(`master: ${slide.masterName}`);
309	  if (slide.transition?.effect) bits.push(`transition: ${slide.transition.effect}`);
310	  if (slide.skipped) bits.push("skipped");
311	  caption.textContent = bits.join("  ·  ");
312	  stage.appendChild(caption);
313	
314	  const notes = document.createElement("div");
315	  notes.className = "notes-panel";
316	  notes.dataset.hasNotes = slide.notes ? "true" : "false";
317	  const h = document.createElement("h3");
318	  h.textContent = "Presenter notes";
319	  notes.appendChild(h);
320	  notes.appendChild(slide.notes
321	    ? renderStyledText(slide.notes, hdoc, ctx)
322	    : Object.assign(document.createElement("p"), { textContent: "No notes on this slide.", className: "muted" }));
323	  stage.appendChild(notes);
324	
325	  // Corner badge only when no slide-number placeholder painted the number
326	  // on the canvas itself.
327	  if (slide.slideNumberVisible && !slide.drawables.some((d) => roleOf(d) === "slide-number")) {
328	    const num = document.createElement("div");
329	    num.className = "slide-number";
330	    num.textContent = String(index + 1);
331	    stage.appendChild(num);
332	  }
333	  return stage;
334	}
335	
336	export function renderKeynote(
337	  doc: KeynoteDocument,
338	  hdoc: HydratedDoc,
339	  ctx: ViewerCtx,
340	  mount: HTMLElement,
341	): void {
342	  const view = document.createElement("div");
343	  view.id = "keynote-view";
344	
345	  const list = document.createElement("div");
346	  list.className = "slide-list";
347	
348	  const stageSlot = document.createElement("div");
349	  stageSlot.className = "slide-stage-slot";
350	
351	  let active = doc.slides.findIndex((s) => !s.skipped);
352	  if (active < 0) active = 0;
353	
354	  const activate = (index: number) => {
355	    stageSlot.replaceChildren();
356	    stageSlot.appendChild(renderStage(doc, hdoc, ctx, doc.slides[index], index, stageSlot.clientWidth || 800));
357	    // Shrink-to-fit measurement pass needs the stage attached and laid out.
358	    applyTextFit(stageSlot);
359	    for (const item of list.children) {
360	      item.classList.toggle("active", (item as HTMLElement).dataset.slideIndex === String(index));
361	    }
362	  };
363	
364	  doc.slides.forEach((slide, i) => {
365	    const item = document.createElement("div");
366	    item.className = "slide-list-item";
367	    item.dataset.slideIndex = String(i);
368	    item.appendChild(buildCanvas(slide, doc, hdoc, ctx, THUMB_WIDTH, i + 1));
369	    const label = document.createElement("span");
370	    label.className = "label";
371	    label.textContent = `${i + 1}${slide.name ? ` · ${slide.name}` : ""}${slide.skipped ? " (skipped)" : ""}`;
372	    item.appendChild(label);
373	    item.addEventListener("click", () => activate(i));
374	    list.appendChild(item);
375	  });
376	
377	  view.appendChild(list);
378	  view.appendChild(stageSlot);
379	  mount.appendChild(view);
380	  activate(active);
381	  // Thumbnails were built detached; measure their shrink boxes now that the
382	  // whole view is attached.
383	  applyTextFit(list);
384	}
Thinking
Thinking

I'll restructure the UI from one-stage-at-a-time to a lazily-rendered continuous scroll using IntersectionObserver (to keep the 645-slide deck performant), add a compact notes strip when present, keep the thumbnail rail for navigation, and fix its clipping issue. Starting by checking the thumbnail CSS to locate the truncation.

$ Bash
Keynote view CSS rules
grep -n "canvas-frame\|canvas-inner\|slide-list\|slide-stage\|notes-panel\|keynote-view\|slide-caption\|slide-number" /Users/phebert/pnk/viewer/styles.css
15:   (.canvas-frame/.canvas-inner/.sheet-* keep their explicit whites). */
29:  body .sheet-area, body .pages-flow, body .notes-panel, body .error-card { color: #1d1d1f; }
189:.canvas-frame {           /* scaled wrapper: aspect kept via padding trick below */
196:.canvas-inner { position: absolute; top: 0; left: 0; transform-origin: top left; background: #fff; }
217:#keynote-view { display: flex; gap: 18px; align-items: flex-start; }
218:.slide-stage-slot { flex: 1; min-width: 0; }
219:.slide-list {
224:.slide-list-item {
228:.slide-list-item:hover { background: var(--surface); }
229:.slide-list-item.active { border-color: var(--accent); }
230:.slide-list-item .canvas-frame { position: relative; }
231:.slide-list-item .label { display: block; margin-top: 4px; color: var(--muted); }
232:.slide-stage { flex: 1; min-width: 0; }
233:.notes-panel {
237:.notes-panel h3 { margin: 0 0 6px; font-size: 12px; text-transform: uppercase; color: var(--muted); letter-spacing: 1px; }
Read
{
  "file_path": "/Users/phebert/pnk/viewer/styles.css",
  "offset": 185,
  "limit": 60
}
185	
186	/* ---- canvases (Keynote slides / Pages layout pages) ---------------------- */
187	
188	.canvas-outer { margin: 18px 0; }
189	.canvas-frame {           /* scaled wrapper: aspect kept via padding trick below */
190	  position: relative;
191	  overflow: hidden;
192	  background: #fff;
193	  border: 1px solid var(--line);
194	  box-shadow: 0 1px 4px rgba(0,0,0,.08);
195	}
196	.canvas-inner { position: absolute; top: 0; left: 0; transform-origin: top left; background: #fff; }
197	.canvas-drawable { position: absolute; }
198	.canvas-drawable > * { width: 100%; height: 100%; }
199	.canvas-caption { font-size: 12px; color: var(--muted); margin-top: 4px; }
200	
201	.drawable-text { overflow: hidden; display: flex; }
202	.drawable-text-inner { width: 100%; }
203	
204	.media-missing {
205	  display: flex; align-items: center; justify-content: center;
206	  border: 1px dashed var(--line); color: var(--muted); font-size: 11px;
207	  text-align: center; padding: 4px; background: var(--surface);
208	}
209	.unknown-drawable {
210	  display: flex; align-items: center; justify-content: center;
211	  border: 1px dashed #e0b0ae; color: #9c5350; font-size: 11px;
212	  text-align: center; padding: 4px; background: #fdf7f6;
213	}
214	
215	/* ---- Keynote -------------------------------------------------------------- */
216	
217	#keynote-view { display: flex; gap: 18px; align-items: flex-start; }
218	.slide-stage-slot { flex: 1; min-width: 0; }
219	.slide-list {
220	  flex: 0 0 190px; max-height: 75vh; overflow-y: auto;
221	  background: var(--card); border: 1px solid var(--line); border-radius: 10px;
222	  padding: 6px;
223	}
224	.slide-list-item {
225	  border-radius: 8px; padding: 8px; cursor: pointer; border: 2px solid transparent;
226	  font-size: 13px;
227	}
228	.slide-list-item:hover { background: var(--surface); }
229	.slide-list-item.active { border-color: var(--accent); }
230	.slide-list-item .canvas-frame { position: relative; }
231	.slide-list-item .label { display: block; margin-top: 4px; color: var(--muted); }
232	.slide-stage { flex: 1; min-width: 0; }
233	.notes-panel {
234	  background: #fffbe8; border: 1px solid #efe3b0; border-radius: 10px;
235	  padding: 10px 16px; margin-top: 14px; font-size: 14px;
236	}
237	.notes-panel h3 { margin: 0 0 6px; font-size: 12px; text-transform: uppercase; color: var(--muted); letter-spacing: 1px; }
238	
239	/* ---- Numbers --------------------------------------------------------------- */
240	
241	.sheet-tabs { display: flex; gap: 6px; flex-wrap: wrap; margin: 14px 0; }
242	.sheet-tab {
243	  border-radius: 8px 8px 0 0; border-bottom: 3px solid transparent; padding: 6px 16px;
244	}
Thinking
Thinking

Found the truncation cause: .canvas-frame's 1px border with box-sizing: border-box shrinks the content box, causing the full-size inner canvas to get clipped by overflow: hidden. I'll now check what the gate tests expect before restructuring the fix.

$ Bash
Test and harness selectors in use
grep -n "slide\|notes\|keynote" /Users/phebert/pnk/viewer/tests/gate.spec.ts | head; grep -n "slide-stage\|slide-list\|canvas" /Users/phebert/pnk/scripts/visual_diff.py | head -20
20:  keynote: "85c3a6f17ca8e64ae24fb95c64af6c47e87f27237bfc19386bd09088da998007.key",
70:test("keynote fixture renders slides with positioned content + notes", async ({ page }) => {
73:  await page.setInputFiles("#file-input", path.join(CRAWL, FIXTURES.keynote));
74:  await expect(page.locator("#app-badge")).toContainText(/keynote/i);
75:  const items = page.locator(".slide-list-item");
77:  await expect(page.locator(".notes-panel")).toBeVisible();
78:  // slide switching works; walk the deck until a slide carries a raster
85:    const imgs = page.locator(".slide-stage .canvas-drawable img");
96:  await shot(page, "keynote.png");
230:  const items = page.locator(".slide-list-item");
237:      const stage = page.locator(".slide-stage").first();
371:        canvas = Image.new("RGB", (ap.width * 2 + 12, ap.height), "#d0d0d8")
372:        canvas.paste(Image.open(apple_pages[i]).convert("RGB"), (0, 0))
373:        canvas.paste(ours_slice, (ap.width + 12, 0))
375:        canvas.save(out)
388:        canvas_h = 480
392:            a_img = a_img.resize((int(a_img.width * canvas_h / a_img.height), canvas_h), Image.LANCZOS)
395:            o_img = o_img.resize((int(o_img.width * canvas_h / o_img.height), canvas_h), Image.LANCZOS)
398:        canvas = Image.new("RGB", (a_img.width + o_img.width + 12, canvas_h + 22), "#d0d0d8")
399:        canvas.paste(a_img, (0, 22))
400:        canvas.paste(o_img, (a_img.width + 12, 22))
402:        canvas.save(out)
442:    canvas = Image.new("RGB", (apple_img.width + ours_img.width + gutter, h + 22), "#404048")
443:    d = ImageDraw.Draw(canvas)
446:    canvas.paste(apple_img.convert("RGB"), (0, 22))
447:    canvas.paste(ours_img.convert("RGB"), (apple_img.width + gutter, 22))
449:    canvas.save(out)
$ Bash
Read gate keynote test + harness deck branch
sed -n 70,100p /Users/phebert/pnk/viewer/tests/gate.spec.ts; sed -n 225,250p /Users/phebert/pnk/scripts/visual_diff.py
test("keynote fixture renders slides with positioned content + notes", async ({ page }) => {
  await page.goto("/");
  trackRequests(page);
  await page.setInputFiles("#file-input", path.join(CRAWL, FIXTURES.keynote));
  await expect(page.locator("#app-badge")).toContainText(/keynote/i);
  const items = page.locator(".slide-list-item");
  await expect(items.count()).resolves.toBeGreaterThanOrEqual(2);
  await expect(page.locator(".notes-panel")).toBeVisible();
  // slide switching works; walk the deck until a slide carries a raster
  // image that decoded from local blob bytes (some master art is vector
  // PDF — those render blank in <img> and are skipped)
  let sawImage = false;
  const total = Math.min(await items.count(), 12);
  for (let i = 0; i < total && !sawImage; i++) {
    await items.nth(i).click();
    const imgs = page.locator(".slide-stage .canvas-drawable img");
    for (let k = 0; k < (await imgs.count()); k++) {
      const w = await imgs.nth(k).evaluate((node) => (node as HTMLImageElement).naturalWidth);
      if (w > 0) {
        await expect(imgs.nth(k)).toBeVisible();
        sawImage = true;
        break;
      }
    }
  }
  expect(sawImage).toBe(true);
  await shot(page, "keynote.png");
  assertNoRuntimeNetwork(page);
});

test("numbers fixture renders sheet tables with real cell values", async ({ page }) => {
  await page.waitForTimeout(1500);

  // Deck mode (.key): click through thumbnails and screenshot the STAGE only,
  // one PNG per slide, so composites align 1:1 with Apple's per-page rasters.
  let slideCount = 0;
  const items = page.locator(".slide-list-item");
  if ((await items.count()) > 0) {
    slideCount = await items.count();
    const shotDir = shotPath.replace(/\/[^/]+$/, "/");
    fs.mkdirSync(shotDir, { recursive: true });
    for (let i = 0; i < slideCount; i++) {
      await items.nth(i).click();
      const stage = page.locator(".slide-stage").first();
      await stage.waitFor({ state: "visible", timeout: 10000 });
      await page.waitForTimeout(250);
      await stage.screenshot({ path: `${shotDir}slide-${i + 1}.png` });
    }
  }

  await page.screenshot({ path: shotPath, fullPage: true });

  const bboxes = await page.evaluate(() => {
    const grab = (el) => {
      const r = el.getBoundingClientRect();
      return { x: r.x, y: r.y + window.scrollY, w: r.width, h: r.height };
    };
Thinking

Now the restructure. Rewriting renderStage/renderKeynote for a lazy continuous scroll:

✏️ Edit keynote.ts
/Users/phebert/pnk/viewer/src/keynote.ts
  const { width, height } = doc.slideSize;
  const scale = widthPx / width;
+
  const { width, height } = doc.slideSize;
  // The frame's 1px border eats into its border-box: scale to the CONTENT
  // box or the right/bottom edges of every canvas get clipped by
  // overflow:hidden (~12 doc-px on a thumbnail — visibly truncated).
  const scale = (widthPx - 2) / width;
The file /Users/phebert/pnk/viewer/src/keynote.ts has been updated successfully. (file state is current in your context — no need to Read it back)
✏️ Edit keynote.ts
/Users/phebert/pnk/viewer/src/keynote.ts
function renderStage(
  doc: KeynoteDocument,
  hdoc: HydratedDoc,
  ctx: ViewerCtx,
  slide: Slide,
  index: number,
  widthPx: number,
): HTMLElement {
  const stage = document.createElement("div");
  stage.className = "slide-stage";

  const frame = buildCanvas(slide, doc, hdoc, ctx, widthPx, index + 1);
  frame.dataset.slideIndex = String(index);
  stage.appendChild(frame);

  const caption = document.createElement("div");
  caption.className = "slide-caption muted";
  const bits = [`Slide ${index + 1}${slide.name ? ` — ${slide.name}` : ""}`];
  if (slide.masterName) bits.push(`master: ${slide.masterName}`);
  if (slide.transition?.effect) bits.push(`transition: ${slide.transition.effect}`);
  if (slide.skipped) bits.push("skipped");
  caption.textContent = bits.join("  ·  ");
  stage.appendChild(caption);

  const notes = document.createElement("div");
  notes.className = "notes-panel";
  notes.dataset.hasNotes = slide.notes ? "true" : "false";
  const h = document.createElement("h3");
  h.textContent = "Presenter notes";
  notes.appendChild(h);
  notes.appendChild(slide.notes
    ? renderStyledText(slide.notes, hdoc, ctx)
    : Object.assign(document.createElement("p"), { textContent: "No notes on this slide.", className: "muted" }));
  stage.appendChild(notes);

  // Corner badge only when no slide-number placeholder painted the number
  // on the canvas itself.
  if (slide.slideNumberVisible && !slide.drawables.some((d) => roleOf(d) === "slide-number")) {
    const num = document.createElement("div");
    num.className = "slide-number";
    num.textContent = String(index + 1);
    stage.appendChild(num);
  }
  return stage;
}

export function renderKeynote(
  doc: KeynoteDocument,
  hdoc: HydratedDoc,
  ctx: ViewerCtx,
  mount: HTMLElement,
): void {
  const view = document.createElement("div");
  view.id = "keynote-view";

  const list = document.createElement("div");
  list.className = "slide-list";

  const stageSlot = document.createElement("div");
  stageSlot.className = "slide-stage-slot";

  let active = doc.slides.findIndex((s) => !s.skipped);
  if (active < 0) active = 0;

  const activate = (index: number) => {
    stageSlot.replaceChildren();
    stageSlot.appendChild(renderStage(doc, hdoc, ctx, doc.slides[index], index, stageSlot.clientWidth || 800));
    // Shrink-to-fit measurement pass needs the stage attached and laid out.
    applyTextFit(stageSlot);
    for (const item of list.children) {
      item.classList.toggle("active", (item as HTMLElement).dataset.slideIndex === String(index));
    }
  };

  doc.slides.forEach((slide, i) => {
    const item = document.createElement("div");
    item.className = "slide-list-item";
    item.dataset.slideIndex = String(i);
    item.appendChild(buildCanvas(slide, doc, hdoc, ctx, THUMB_WIDTH, i + 1));
    const label = document.createElement("span");
    label.className = "label";
    label.textContent = `${i + 1}${slide.name ? ` · ${slide.name}` : ""}${slide.skipped ? " (skipped)" : ""}`;
    item.appendChild(label);
    item.addEventListener("click", () => activate(i));
    list.appendChild(item);
  });

  view.appendChild(list);
  view.appendChild(stageSlot);
  mount.appendChild(view);
  activate(active);
  // Thumbnails were built detached; measure their shrink boxes now that the
  // whole view is attached.
  applyTextFit(list);
}
+
/** Fill one slide's stage holder: canvas, caption, notes strip, badge. */
function fillStage(
  stage: HTMLElement,
  doc: KeynoteDocument,
  hdoc: HydratedDoc,
  ctx: ViewerCtx,
  slide: Slide,
  index: number,
  widthPx: number,
): void {
  stage.replaceChildren();

  const frame = buildCanvas(slide, doc, hdoc, ctx, widthPx, index + 1);
  stage.appendChild(frame);

  const caption = document.createElement("div");
  caption.className = "slide-caption muted";
  const bits = [`Slide ${index + 1}${slide.name ? ` — ${slide.name}` : ""}`];
  if (slide.masterName) bits.push(`master: ${slide.masterName}`);
  if (slide.transition?.effect) bits.push(`transition: ${slide.transition.effect}`);
  if (slide.skipped) bits.push("skipped");
  caption.textContent = bits.join("  ·  ");
  stage.appendChild(caption);

  // Notes: a compact strip only when the slide HAS notes — nothing to scroll
  // past otherwise. Typography is normalized to the strip (notes storages
  // carry 18pt+ editor styles that read as bloat here); bold/color survive.
  if (slide.notes) {
    const notes = document.createElement("div");
    notes.className = "notes-panel";
    const h = document.createElement("h3");
    h.textContent = "Notes";
    notes.appendChild(h);
    notes.appendChild(renderStyledText(slide.notes, hdoc, ctx));
    stage.appendChild(notes);
  }

  // Corner badge only when no slide-number placeholder painted the number
  // on the canvas itself.
  if (slide.slideNumberVisible && !slide.drawables.some((d) => roleOf(d) === "slide-number")) {
    const num = document.createElement("div");
    num.className = "slide-number";
    num.textContent = String(index + 1);
    frame.appendChild(num);
  }
}

export function renderKeynote(
  doc: KeynoteDocument,
  hdoc: HydratedDoc,
  ctx: ViewerCtx,
  mount: HTMLElement,
): void {
  const view = document.createElement("div");
  view.id = "keynote-view";

  const list = document.createElement("div");
  list.className = "slide-list";

  // One continuous scroll of every slide (like the Pages flow). Stages start
  // as aspect-ratio placeholders and render lazily as they approach the
  // viewport — the 645-slide monster deck must not pay for 645 full
  // canvases up front.
  const scroll = document.createElement("div");
  scroll.className = "slides-scroll";
  const stages: HTMLElement[] = [];
  const built = new Set<number>();

  const buildStage = (i: number): void => {
    if (built.has(i)) return;
    built.add(i);
    const stage = stages[i];
    fillStage(stage, doc, hdoc, ctx, doc.slides[i], i, stage.clientWidth || 800);
    // Shrink-to-fit measurement pass needs the stage attached and laid out.
    applyTextFit(stage);
  };

  doc.slides.forEach((slide, i) => {
    const stage = document.createElement("div");
    stage.className = "slide-stage";
    stage.dataset.slideIndex = String(i);
    const ph = document.createElement("div");
    ph.className = "canvas-frame";
    ph.style.aspectRatio = `${doc.slideSize.width} / ${doc.slideSize.height}`;
    stage.appendChild(ph);
    scroll.appendChild(stage);
    stages.push(stage);
  });

  const io = new IntersectionObserver(
    (entries) => {
      for (const e of entries) {
        if (!e.isIntersecting) continue;
        const i = Number((e.target as HTMLElement).dataset.slideIndex);
        io.unobserve(e.target);
        buildStage(i);
      }
    },
    { rootMargin: "1500px 0px" },
  );
  stages.forEach((s) => io.observe(s));

  const setActive = (index: number): void => {
    for (const item of list.children) {
      item.classList.toggle("active", (item as HTMLElement).dataset.slideIndex === String(index));
    }
  };

  doc.slides.forEach((slide, i) => {
    const item = document.createElement("div");
    item.className = "slide-list-item";
    item.dataset.slideIndex = String(i);
    item.appendChild(buildCanvas(slide, doc, hdoc, ctx, THUMB_WIDTH, i + 1));
    const label = document.createElement("span");
    label.className = "label";
    label.textContent = `${i + 1}${slide.name ? ` · ${slide.name}` : ""}${slide.skipped ? " (skipped)" : ""}`;
    item.appendChild(label);
    item.addEventListener("click", () => {
      buildStage(i);
      stages[i].scrollIntoView({ behavior: "smooth", block: "start" });
      setActive(i);
    });
    list.appendChild(item);
  });

  view.appendChild(list);
  view.appendChild(scroll);
  mount.appendChild(view);
  setActive(doc.slides.findIndex((s) => !s.skipped) < 0 ? 0 : doc.slides.findIndex((s) => !s.skipped));
  // Thumbnails were built detached; measure their shrink boxes now that the
  // whole view is attached.
  applyTextFit(list);
}
The file /Users/phebert/pnk/viewer/src/keynote.ts has been updated successfully. (file state is current in your context — no need to Read it back)
Thinking

Now the CSS updates — sticky rail, scroll column, compact notes:

$ Bash
slide-number CSS
grep -n "slide-number" /Users/phebert/pnk/viewer/styles.css
(Bash completed with no output)
✏️ Edit styles.css
/Users/phebert/pnk/viewer/styles.css
#keynote-view { display: flex; gap: 18px; align-items: flex-start; }
.slide-stage-slot { flex: 1; min-width: 0; }
.slide-list {
  flex: 0 0 190px; max-height: 75vh; overflow-y: auto;
  background: var(--card); border: 1px solid var(--line); border-radius: 10px;
  padding: 6px;
}
.slide-list-item {
  border-radius: 8px; padding: 8px; cursor: pointer; border: 2px solid transparent;
  font-size: 13px;
}
.slide-list-item:hover { background: var(--surface); }
.slide-list-item.active { border-color: var(--accent); }
.slide-list-item .canvas-frame { position: relative; }
.slide-list-item .label { display: block; margin-top: 4px; color: var(--muted); }
.slide-stage { flex: 1; min-width: 0; }
.notes-panel {
  background: #fffbe8; border: 1px solid #efe3b0; border-radius: 10px;
  padding: 10px 16px; margin-top: 14px; font-size: 14px;
}
.notes-panel h3 { margin: 0 0 6px; font-size: 12px; text-transform: uppercase; color: var(--muted); letter-spacing: 1px; }
+
#keynote-view { display: flex; gap: 18px; align-items: flex-start; }
.slide-list {
  flex: 0 0 190px; overflow-y: auto;
  position: sticky; top: 58px; max-height: calc(100vh - 74px);
  background: var(--card); border: 1px solid var(--line); border-radius: 10px;
  padding: 6px;
}
.slide-list-item {
  border-radius: 8px; padding: 8px; cursor: pointer; border: 2px solid transparent;
  font-size: 13px;
}
.slide-list-item:hover { background: var(--surface); }
.slide-list-item.active { border-color: var(--accent); }
.slide-list-item .canvas-frame { position: relative; }
.slide-list-item .label { display: block; margin-top: 4px; color: var(--muted); }
/* Continuous deck flow: every slide stacked, Pages-style. */
.slides-scroll { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 8px; }
.slide-stage { min-width: 0; margin-bottom: 14px; }
.slide-stage .canvas-frame { border-radius: 3px; }
/* Compact presenter-notes strip: rendered only when a slide HAS notes.
   Notes storages carry the editor's 18pt+ styles — normalize the strip's
   typography (size/leading) while bold/italic/color survive. */
.notes-panel {
  background: #fffbe8; border: 1px solid #efe3b0; border-radius: 8px;
  padding: 6px 12px 8px; margin-top: 8px; font-size: 13px; line-height: 1.45;
  max-height: 150px; overflow-y: auto; color: #1d1d1f;
}
.notes-panel h3 {
  margin: 0 0 4px; font-size: 10px; text-transform: uppercase;
  color: #8a7b3a; letter-spacing: 1px;
}
.notes-panel .styled-text p { margin: 2px 0; font-size: inherit !important; line-height: inherit !important; }
.notes-panel .styled-text span { font-size: inherit !important; }
The file /Users/phebert/pnk/viewer/styles.css has been updated successfully. (file state is current in your context — no need to Read it back)
Thinking
✏️ Edit styles.css
/Users/phebert/pnk/viewer/styles.css
.notes-panel .styled-text p { margin: 2px 0; font-size: inherit !important; line-height: inherit !important; }
.notes-panel .styled-text span { font-size: inherit !important; }
+
.notes-panel .styled-text p { margin: 2px 0; font-size: inherit !important; line-height: inherit !important; }
.notes-panel .styled-text span { font-size: inherit !important; }
.slide-number {
  position: absolute; right: 6px; bottom: 4px; font-size: 11px;
  color: var(--muted); background: rgba(255,255,255,.7);
  padding: 0 5px; border-radius: 4px; pointer-events: none;
}
The file /Users/phebert/pnk/viewer/styles.css has been updated successfully. (file state is current in your context — no need to Read it back)

Now update the harness's deck branch (screenshot per-slide canvas frames in the continuous view) and the gate test:

Read
{
  "file_path": "/Users/phebert/pnk/scripts/visual_diff.py",
  "offset": 215,
  "limit": 40
}
215	  const [fixture, shotPath, bboxPath, baseURL] = process.argv.slice(2);
216	  const browser = await chromium.launch();
217	  const page = await browser.newPage({
218	    viewport: { width: 1280, height: 900 },
219	    deviceScaleFactor: 2,
220	  });
221	  await page.goto(baseURL + "/");
222	  await page.setInputFiles("#file-input", fixture);
223	  await page.waitForSelector("#pages-view, #numbers-view, #keynote-view", { timeout: 30000 });
224	  // tables/images settle after first paint
225	  await page.waitForTimeout(1500);
226	
227	  // Deck mode (.key): click through thumbnails and screenshot the STAGE only,
228	  // one PNG per slide, so composites align 1:1 with Apple's per-page rasters.
229	  let slideCount = 0;
230	  const items = page.locator(".slide-list-item");
231	  if ((await items.count()) > 0) {
232	    slideCount = await items.count();
233	    const shotDir = shotPath.replace(/\/[^/]+$/, "/");
234	    fs.mkdirSync(shotDir, { recursive: true });
235	    for (let i = 0; i < slideCount; i++) {
236	      await items.nth(i).click();
237	      const stage = page.locator(".slide-stage").first();
238	      await stage.waitFor({ state: "visible", timeout: 10000 });
239	      await page.waitForTimeout(250);
240	      await stage.screenshot({ path: `${shotDir}slide-${i + 1}.png` });
241	    }
242	  }
243	
244	  await page.screenshot({ path: shotPath, fullPage: true });
245	
246	  const bboxes = await page.evaluate(() => {
247	    const grab = (el) => {
248	      const r = el.getBoundingClientRect();
249	      return { x: r.x, y: r.y + window.scrollY, w: r.width, h: r.height };
250	    };
251	    const out = { tables: [], imageParagraph: null, view: null, innerWidth: window.innerWidth };
252	    const view = document.querySelector("#pages-view, #numbers-view, #keynote-view");
253	    if (view) out.view = grab(view);
254	    document.querySelectorAll("table.sheet-table").forEach((t) => {
✏️ Edit visual_diff.py
/Users/phebert/pnk/scripts/visual_diff.py
  // Deck mode (.key): click through thumbnails and screenshot the STAGE only,
  // one PNG per slide, so composites align 1:1 with Apple's per-page rasters.
  let slideCount = 0;
  const items = page.locator(".slide-list-item");
  if ((await items.count()) > 0) {
    slideCount = await items.count();
    const shotDir = shotPath.replace(/\/[^/]+$/, "/");
    fs.mkdirSync(shotDir, { recursive: true });
    for (let i = 0; i < slideCount; i++) {
      await items.nth(i).click();
      const stage = page.locator(".slide-stage").first();
      await stage.waitFor({ state: "visible", timeout: 10000 });
      await page.waitForTimeout(250);
      await stage.screenshot({ path: `${shotDir}slide-${i + 1}.png` });
    }
  }
+
  // Deck mode (.key): the viewer shows all slides in one continuous scroll,
  // lazily rendered. Click each thumbnail (forces render + scrolls there) and
  // screenshot that slide's CANVAS FRAME only, one PNG per slide, so
  // composites align 1:1 with Apple's per-page rasters (no caption/notes).
  let slideCount = 0;
  const items = page.locator(".slide-list-item");
  if ((await items.count()) > 0) {
    slideCount = await items.count();
    const shotDir = shotPath.replace(/\/[^/]+$/, "/");
    fs.mkdirSync(shotDir, { recursive: true });
    for (let i = 0; i < slideCount; i++) {
      await items.nth(i).click();
      const frame = page.locator(`.slide-stage[data-slide-index="${i}"] .canvas-frame`).first();
      await frame.waitFor({ state: "visible", timeout: 10000 });
      await page.waitForTimeout(250);
      await frame.screenshot({ path: `${shotDir}slide-${i + 1}.png` });
    }
  }
The file /Users/phebert/pnk/scripts/visual_diff.py has been updated successfully. (file state is current in your context — no need to Read it back)
Thinking
$ Bash
Notes distribution in gate keynote fixture
./target/release/pnk2json --pretty ~/Development/pnk-fixtures/crawl/85c3a6f17ca8e64ae24fb95c64af6c47e87f27237bfc19386bd09088da998007.key 2>/dev/null | python3 -c "
import json, sys
d = json.load(sys.stdin)
doc = d.get('document', d)
for i, sl in enumerate(doc['slides'][:8], 1):
    n = sl.get('notes')
    print(i, 'notes' if n else '-', (json.dumps(n)[:60] if n else ''))
"
Exit code 1
Traceback (most recent call last):
  File "<string>", line 3, in <module>
    d = json.load(sys.stdin)
  File "/opt/homebrew/Cellar/[email protected]/3.14.7/Frameworks/Python.framework/Versions/3.14/lib/python3.14/json/__init__.py", line 298, in load
    return loads(fp.read(),
        cls=cls, object_hook=object_hook,
        parse_float=parse_float, parse_int=parse_int,
        parse_constant=parse_constant, object_pairs_hook=object_pairs_hook, **kw)
  File "/opt/homebrew/Cellar/[email protected]/3.14.7/Frameworks/Python.framework/Versions/3.14/lib/python3.14/json/__init__.py", line 352, in loads
    return _default_decoder.decode(s)
           ~~~~~~~~~~~~~~~~~~~~~~~^^^
  File "/opt/homebrew/Cellar/[email protected]/3.14.7/Frameworks/Python.framework/Versions/3.14/lib/python3.14/json/decoder.py", line 345, in decode
    obj, end = self.raw_decode(s, idx=_w(s, 0).end())
               ~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^
  File "/opt/homebrew/Cellar/[email protected]/3.14.7/Frameworks/Python.framework/Versions/3.14/lib/python3.14/json/decoder.py", line 363, in raw_decode
    raise JSONDecodeError("Expecting value", s, err.value) from None
json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)
$ Bash
Locate gate fixture
ls ~/Development/pnk-fixtures/crawl/85c3a6f1* 2>/dev/null; grep -n "CRAWL" /Users/phebert/pnk/viewer/tests/gate.spec.ts | head -3
/Users/phebert/Development/pnk-fixtures/crawl/85c3a6f17ca8e64ae24fb95c64af6c47e87f27237bfc19386bd09088da998007.key
15:const CRAWL = path.join(HERE, "../../fixtures/crawl");
73:  await page.setInputFiles("#file-input", path.join(CRAWL, FIXTURES.keynote));
103:  await page.setInputFiles("#file-input", path.join(CRAWL, FIXTURES.numbers));
$ Bash
Check notes per slide in gate fixture
./target/release/pnk2json ~/Development/pnk-fixtures/crawl/85c3a6f17ca8e64ae24fb95c64af6c47e87f27237bfc19386bd09088da998007.key > /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/85c3.json 2>/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/85c3.err; echo $?; head -c 200 /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/85c3.err; python3 -c "
import json
d = json.load(open('/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/85c3.json'))
doc = d.get('document', d)
print([('N' if sl.get('notes') else '-') for sl in doc['slides']])
"
Exit code 1
127
(eval):1: no such file or directory: ./target/release/pnk2json
Traceback (most recent call last):
  File "<string>", line 3, in <module>
    d = json.load(open('/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/85c3.json'))
  File "/opt/homebrew/Cellar/[email protected]/3.14.7/Frameworks/Python.framework/Versions/3.14/lib/python3.14/json/__init__.py", line 298, in load
    return loads(fp.read(),
        cls=cls, object_hook=object_hook,
        parse_float=parse_float, parse_int=parse_int,
        parse_constant=parse_constant, object_pairs_hook=object_pairs_hook, **kw)
  File "/opt/homebrew/Cellar/[email protected]/3.14.7/Frameworks/Python.framework/Versions/3.14/lib/python3.14/json/__init__.py", line 352, in loads
    return _default_decoder.decode(s)
           ~~~~~~~~~~~~~~~~~~~~~~~^^^
  File "/opt/homebrew/Cellar/[email protected]/3.14.7/Frameworks/Python.framework/Versions/3.14/lib/python3.14/json/decoder.py", line 345, in decode
    obj, end = self.raw_decode(s, idx=_w(s, 0).end())
               ~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^
  File "/opt/homebrew/Cellar/[email protected]/3.14.7/Frameworks/Python.framework/Versions/3.14/lib/python3.14/json/decoder.py", line 363, in raw_decode
    raise JSONDecodeError("Expecting value", s, err.value) from None
json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)
$ Bash
Notes distribution, from repo root
cd /Users/phebert/pnk && ./target/release/pnk2json ~/Development/pnk-fixtures/crawl/85c3a6f17ca8e64ae24fb95c64af6c47e87f27237bfc19386bd09088da998007.key 2>/dev/null | python3 -c "
import json, sys
d = json.load(sys.stdin)
doc = d.get('document', d)
print(''.join('N' if sl.get('notes') else '-' for sl in doc['slides']))
"
NNNNNNNNNNN
Thinking

All 11 slides have notes, so the gate assertions hold. Build and run the gate:

$ Bash
Rebuild and run viewer gate
bash scripts/build_viewer.sh 2>&1 | tail -1 && cd viewer && npm test 2>&1 | tail -8
viewer built: viewer/dist/  (serve: cd viewer && npm run serve)
      80 |   // PDF — those render blank in <img> and are skipped)
        at /Users/phebert/pnk/viewer/tests/gate.spec.ts:77:46

    Error Context: test-results/gate-keynote-fixture-rende-f559c-th-positioned-content-notes/error-context.md

  1 failed
    tests/gate.spec.ts:70:1 › keynote fixture renders slides with positioned content + notes ───────
  5 passed (1.7s)
$ Bash
See failing assertion line 77
sed -n 74,80p /Users/phebert/pnk/viewer/tests/gate.spec.ts
  await expect(page.locator("#app-badge")).toContainText(/keynote/i);
  const items = page.locator(".slide-list-item");
  await expect(items.count()).resolves.toBeGreaterThanOrEqual(2);
  await expect(page.locator(".notes-panel")).toBeVisible();
  // slide switching works; walk the deck until a slide carries a raster
  // image that decoded from local blob bytes (some master art is vector
  // PDF — those render blank in <img> and are skipped)
Thinking

Multiple notes panels now exist (one per slide) — the locator needs .first():

✏️ Edit gate.spec.ts
/Users/phebert/pnk/viewer/tests/gate.spec.ts
  await expect(page.locator(".notes-panel")).toBeVisible();
+
  // Continuous-scroll view: one .notes-panel per slide that HAS notes.
  await expect(page.locator(".notes-panel").first()).toBeVisible();
The file /Users/phebert/pnk/viewer/tests/gate.spec.ts has been updated successfully. (file state is current in your context — no need to Read it back)
$ Bash
Re-run gate
npm test 2>&1 | tail -3
  ✓  6 tests/gate.spec.ts:145:1 › encrypted fixture gets the password-protected explanation (87ms)

  6 passed (1.6s)

Gate 6/6. Full visual re-verification of the deck through the updated harness:

$ Bash
Re-run visual diff with new UI
cd /Users/phebert/pnk && uv run --with pillow --with pyobjc-framework-Quartz --with pymupdf python3 scripts/visual_diff.py --app keynote --fixture ~/Development/pnk-fixtures/crawl/0d5851c0105958b602c58646b98ffaee1b453def8478f9b872aca37319d2ed5f.key --out /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/vd-0d58c --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-0d58c
[visual_diff] summary: /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/vd-0d58c/summary.md
Read
{
  "file_path": "/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/vd-0d58c/composites/composite-page-19.png"
}
Read
{
  "file_path": "/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/vd-0d58c/composites/composite-page-10.png"
}
Thinking

Composites now align canvas-to-canvas and slide 19's rhythm matches. Let me eyeball the new continuous UI + notes strip + thumbnails directly:

$ Bash
Screenshot the new continuous UI
cd /Users/phebert/pnk/viewer && cat > probe-tmp.mjs <<'EOF'
import { chromium } from "playwright";
const fixture = "/Users/phebert/Development/pnk-fixtures/crawl/0d5851c0105958b602c58646b98ffaee1b453def8478f9b872aca37319d2ed5f.key";
const b = await chromium.launch();
const page = await b.newPage({ viewport: { width: 1280, height: 900 } });
await page.goto("http://127.0.0.1:8123/");
await page.setInputFiles("#file-input", fixture);
await page.waitForSelector(".slide-list-item", { timeout: 30000 });
await page.waitForTimeout(1200);
await page.screenshot({ path: "/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/ui-top.png" });
await page.locator(".slide-list-item").nth(9).click();
await page.waitForTimeout(1200);
await page.screenshot({ path: "/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/ui-slide10.png" });
await b.close();
EOF
node probe-tmp.mjs
(Bash completed with no output)
Read
{
  "file_path": "/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/ui-top.png"
}
Read
{
  "file_path": "/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/ui-slide10.png"
}
Thinking
Thinking

Everything's working except slide 9, which shows an empty "NOTES" strip since its notes object contains only blank paragraphs. I'll fix this by gating the strip's visibility on whether there's actual visible text.

✏️ Edit keynote.ts
/Users/phebert/pnk/viewer/src/keynote.ts
  // Notes: a compact strip only when the slide HAS notes — nothing to scroll
  // past otherwise. Typography is normalized to the strip (notes storages
  // carry 18pt+ editor styles that read as bloat here); bold/color survive.
  if (slide.notes) {
+
  // Notes: a compact strip only when the slide HAS visible notes — nothing
  // to scroll past otherwise (a notes storage of empty paragraphs counts as
  // none). Typography is normalized to the strip (notes storages carry
  // 18pt+ editor styles that read as bloat here); bold/color survive.
  const hasNotes = slide.notes?.paragraphs.some((p) =>
    p.items.some((it) => (typeof it === "string" ? it.trim() : "type" in it ? true : it.text.trim())),
  );
  if (slide.notes && hasNotes) {
The file /Users/phebert/pnk/viewer/src/keynote.ts has been updated successfully. (file state is current in your context — no need to Read it back)
Thinking

Now the full gauntlet — the converter changed, so conformance + cargo golden tests + viewer gate:

$ Bash
Build, typecheck, gate
cd /Users/phebert/pnk && bash scripts/build_viewer.sh 2>&1 | tail -1 && cd viewer && npx tsc --noEmit && echo TSC-OK && npm test 2>&1 | tail -2
viewer built: viewer/dist/  (serve: cd viewer && npm run serve)
TSC-OK
    tests/gate.spec.ts:70:1 › keynote fixture renders slides with positioned content + notes ───────
  5 passed (6.7s)
$ Bash
Re-run failing keynote gate test verbosely
npx playwright test -g "keynote" 2>&1 | tail -25
    Error: expect(locator).toBeVisible() failed

    Locator: locator('.notes-panel').first()
    Expected: visible
    Timeout: 5000ms
    Error: element(s) not found

    Call log:
      - Expect "toBeVisible" with timeout 5000ms
      - waiting for locator('.notes-panel').first()


      76 |   await expect(items.count()).resolves.toBeGreaterThanOrEqual(2);
      77 |   // Continuous-scroll view: one .notes-panel per slide that HAS notes.
    > 78 |   await expect(page.locator(".notes-panel").first()).toBeVisible();
         |                                                      ^
      79 |   // slide switching works; walk the deck until a slide carries a raster
      80 |   // image that decoded from local blob bytes (some master art is vector
      81 |   // PDF — those render blank in <img> and are skipped)
        at /Users/phebert/pnk/viewer/tests/gate.spec.ts:78:54

    Error Context: test-results/gate-keynote-fixture-rende-f559c-th-positioned-content-notes/error-context.md

  1 failed
    tests/gate.spec.ts:70:1 › keynote fixture renders slides with positioned content + notes ───────
Thinking
$ Bash
Actual notes text in gate fixture
cd /Users/phebert/pnk && ./target/release/pnk2json ~/Development/pnk-fixtures/crawl/85c3a6f17ca8e64ae24fb95c64af6c47e87f27237bfc19386bd09088da998007.key 2>/dev/null | python3 -c "
import json, sys
d = json.load(sys.stdin)
doc = d.get('document', d)
for i, sl in enumerate(doc['slides'], 1):
    n = sl.get('notes') or {}
    txt = ''.join((it if isinstance(it,str) else it.get('text','')) for p in n.get('paragraphs',[]) if isinstance(p,dict) for it in p.get('items',[]) if isinstance(it,(str,dict)))
    print(i, repr(txt[:50]))
"
1 ''
2 ''
3 ''
4 ''
5 ''
6 ''
7 ''
8 ''
9 ''
10 ''
11 ''
Thinking

The gate deck's notes are all empty — the old UI showed 11 empty yellow panels (exactly the noise being removed). Updating the test to assert the new spec:

✏️ Edit gate.spec.ts
/Users/phebert/pnk/viewer/tests/gate.spec.ts
  // Continuous-scroll view: one .notes-panel per slide that HAS notes.
  await expect(page.locator(".notes-panel").first()).toBeVisible();
+
  // Continuous-scroll view: a .notes-panel renders only for slides with
  // VISIBLE notes. This deck's notes storages are all empty paragraphs, so
  // no panel may appear (the old UI showed 11 empty yellow strips).
  await expect(page.locator(".notes-panel")).toHaveCount(0);
The file /Users/phebert/pnk/viewer/tests/gate.spec.ts has been updated successfully. (file state is current in your context — no need to Read it back)
$ Bash
Viewer gate + cargo golden tests
cd /Users/phebert/pnk/viewer && npm test 2>&1 | tail -2 && cargo test -p pnk2json --release 2>&1 | tail -5
  6 passed (1.6s)

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
$ Bash
Cargo tests from repo root
cd /Users/phebert/pnk && cargo test -p pnk2json --release 2>&1 | grep -E "test result|running|FAILED|passed" | head
running 3 tests
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
running 2 tests
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s
running 9 tests
test result: ok. 9 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.05s
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
$ Bash
Run full conformance harness
python3 scripts/conformance.py 2>&1 | tail -6
  CC-MAIN-2026-34-cdx-00174-3 keynote          0.367s in= 701234477 out=   636459
  CC-MAIN-2026-34-cdx-00238-5 numbers          0.344s in=  14679959 out= 18162943
  CC-MAIN-2026-34-cdx-00008-1 keynote          0.304s in= 521170455 out=   828736
  CC-MAIN-2026-34-cdx-00203-5 keynote          0.287s in= 601335887 out=   369871

NO DEFECTS.
Thinking

All green — conformance NO DEFECTS, goldens byte-stable, gate 6/6. Committing in three educational steps:

$ Bash
Commit converter re-anchor fix
rm -f viewer/probe-tmp.mjs && git add crates/pnk2json/src/drawables.rs && git commit -m "pnk2json: classic Keynote text shapes are CENTER-anchored — geometry flags=0

Keynote-'09-converted decks (format 1.5, KN.Classic* records) store some
TSWP.ShapeInfoArchive geometry with flags == 0 and position = the shape's
CENTER: 0d5851c0's title stores (512, 638) — the slide's exact horizontal
center — and Apple lays the 500×36 rect out at 262..762 with its centered
text on x=512; our top-left read pushed the text half a box right, off the
band and clipped at the slide edge. Modern archives always write flags 3
(|4 when rotated): verified across G2 and 0f9df553 shapes, plus this
deck's own correctly-placed flags=3 images.

Re-anchor position to top-left at conversion (Shape/Textbox from
ShapeInfo, unrotated only — no rotated flags=0 sample exists to verify),
so the model's geometry contract holds and the viewer never learns about
the flag. 0×0 anchored labels are unaffected (half-zero shift).

flags bit semantics remain undocumented in any local proto [inferred,
behavior verified against Apple's render of 0d5851c0 slides 1/27/28].
Goldens byte-stable (modern docs all flags=3); conformance NO DEFECTS.

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, 23 insertions(+)
   01021ad..676533d  main -> main
$ Bash
Commit viewer fidelity fixes
git add viewer/src/text.ts viewer/src/drawables.ts && git commit -m "viewer: four text-fidelity fixes from the 0d5851c0 deck

- Shrink pass width compensation actually applies now: the inner is a
  flex ITEM and its default flex-shrink:1 silently squashed the >100%
  compensated width back to the container's — text wrapped at the
  original width, then scaled, leaving a right gap; centered lines
  drifted left by (1-s)/2·width (slide 9 subtitle ~45pt off-center).
  flex: 0 0 auto during measurement.

- Image list markers render (markerKind 'image' was skipped entirely —
  slide 2/19's rightArrow PNG bullets showed neither glyph nor indent).
  The PNG scales like a glyph: markerScale × run size, on the baseline,
  with a • fallback when the media bytes didn't ship.

- 0×0 SHAPES join 0×0 textboxes in point-anchor layout (natural width,
  anchored per paragraph alignment): slide 29's 51pt quote is a 0×0
  shape Apple lays out on one line; our 0-width box wrapped it into a
  four-line sliver. Shared helper anchorZeroSizeText.

- Baseline-shifted runs no longer grow the line box (line-height: 0 on
  shifted spans): slide 19's raised red labels stretched 40px-exact rows
  to 41.7px — visibly loose leading across 8 rows. Apple's baseline
  shifts never change line spacing.

All verified by eye against Apple's PDF via visual_diff.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018a2hmoDhN4kBq1BwfbFdAU" && git push origin main 2>&1 | tail -1
2 files changed, 84 insertions(+), 32 deletions(-)
   676533d..f8bbc68  main -> main
$ Bash
Commit UI rework + harness update
git add viewer/src/keynote.ts viewer/styles.css viewer/tests/gate.spec.ts scripts/visual_diff.py && git commit -m "viewer: Keynote decks scroll as one continuous view; notes become a compact strip

The stage no longer swaps one slide at a time: every slide stacks in a
single scrolling column (like the Pages flow), rendered lazily via
IntersectionObserver (aspect-ratio placeholders hold scroll height; the
645-slide monster deck must not pay for 645 canvases up front). The
thumbnail rail stays as navigation — sticky, click scrolls to the slide.

Presenter notes: a compact strip under each slide ONLY when the slide has
visible notes (a storage of empty paragraphs counts as none — the gate
deck previously showed 11 empty yellow panels). Strip typography is
normalized (13px/1.45, capped at 150px with scroll) since notes storages
carry 18pt+ editor styles; bold/italic/color survive. Fixes the excessive
notes leading (UA <p> margins) as a side effect.

Thumbnail truncation fixed: .canvas-frame's 1px border eats into its
border-box, so scaling the inner canvas to the full given width clipped
~12 doc-px off every thumbnail's right/bottom edge under overflow:hidden.
Scale to the content box (width−2) instead — applies to full-size stages
too (their 2px loss was invisible but real).

visual_diff deck mode now screenshots each slide's .canvas-frame (not the
whole stage), so composites align canvas-to-canvas with Apple's rasters
without caption/notes skewing the height match.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018a2hmoDhN4kBq1BwfbFdAU" && git push origin main 2>&1 | tail -1
4 files changed, 109 insertions(+), 42 deletions(-)
   f8bbc68..bc5004d  main -> main
Thinking

One last visual sanity pass on the final build:

$ Bash
Screenshot final UI at slide 10
cd /Users/phebert/pnk/viewer && cat > /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/probe2.mjs <<'EOF'
import { chromium } from "playwright";
const b = await chromium.launch();
const page = await b.newPage({ viewport: { width: 1280, height: 900 } });
await page.goto("http://127.0.0.1:8123/");
await page.setInputFiles("#file-input", "/Users/phebert/Development/pnk-fixtures/crawl/0d5851c0105958b602c58646b98ffaee1b453def8478f9b872aca37319d2ed5f.key");
await page.waitForSelector(".slide-list-item", { timeout: 30000 });
await page.locator(".slide-list-item").nth(9).click();
await page.waitForTimeout(1500);
await page.screenshot({ path: "/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/ui-final.png" });
await b.close();
EOF
node /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/probe2.mjs 2>/dev/null || (cp /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/probe2.mjs probe2-tmp.mjs && node probe2-tmp.mjs && rm probe2-tmp.mjs)
(Bash completed with no output)
Read
{
  "file_path": "/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/ui-final.png"
}
Thinking

All seven fidelity items plus the UI rework are done, verified against Apple's PDF, and pushed as three commits (676533d, f8bbc68, bc5004d). The interesting finds:

Slide 1 title (and 27/28's quotes) — a format archaeology dig. This is a Keynote-'09-converted deck (format 1.5), and its text shapes store geometry with flags=0 where position means the shape's center — the title stores x=512, the slide's exact midpoint. Modern archives always write flags=3 (verified across G2 and other decks, plus this deck's own correctly-placed images). The converter now re-anchors these to top-left, so the viewer never learns the flag exists. The flag bits are documented in no local proto, so the commit carries the full evidence chain.

Slide 9 — a real bug hiding under "not centered." The shrink-to-fit pass compensates its scale with a >100% width, but the inner div is a flex item and default flex-shrink:1 silently squashed it back — text wrapped at the wrong width, so centered lines drifted left ~45pt. One flex: 0 0 auto fixes every shrink box, not just this slide.

Slide 2/19 arrows: the bullets are markerKind: "image" (a rightArrow PNG in the media store) — the viewer only knew string/number markers. They now render scaled like glyphs, with their indents.

Slide 19's loose leading: the raised red labels (baseline-shifted runs) were growing each 40px-exact line box to 41.7px. Shifted spans now get line-height: 0, Apple's own rule (baseline shifts never affect leading).

Slide 29's quote: a 0×0 shape — it now gets the same point-anchor natural-width layout the 0×0 textbox labels already had, so it's one 51pt line instead of a four-line sliver.

Thumbnails: the frame's 1px border eats into the border-box, so every canvas was scaled 2px too wide and clipped right/bottom by overflow:hidden — ~12 doc-px at thumbnail scale.

UI: slides now stack in one continuous scroll like Pages, lazily rendered (aspect-ratio placeholders + IntersectionObserver, so the 645-slide monster deck stays fast). The thumbnail rail is sticky and clicking scrolls to the slide. Notes are a compact 13px strip under a slide only when it has visible notes — empty note storages (the gate deck had 11 of them) render nothing. That also killed the slide-10 leading problem. The visual_diff harness now screenshots per-slide canvas frames, so composites align canvas-to-canvas.

Gates: conformance NO DEFECTS, goldens byte-stable, cargo tests green, viewer 6/6.

please massage the viewer UI to have a top nav-bar, be more compact, remove the fonts list, make warnings much more subtle, call it pnk.vu, design a subtle dev-forward/minimalist branding

Thinking

My lane — index.html, main.ts, styles.css. Let me look at the current shell first:

Read
{
  "file_path": "/Users/phebert/pnk/viewer/index.html"
}
1	<!doctype html>
2	<html lang="en">
3	<head>
4	  <meta charset="utf-8">
5	  <meta name="viewport" content="width=device-width, initial-scale=1">
6	  <title>pnk viewer — iWork, locally</title>
7	  <link rel="stylesheet" href="styles.css">
8	</head>
9	<body>
10	  <div id="app">
11	    <header id="app-header" class="hidden">
12	      <span id="doc-filename"></span>
13	      <span id="app-badge" class="badge"></span>
14	      <span id="doc-meta" class="muted"></span>
15	      <span class="spacer"></span>
16	      <button id="json-btn" type="button" title="Download the converted JSON model">JSON</button>
17	      <button id="reset-btn" type="button" title="Open another file">Open another…</button>
18	    </header>
19	
20	    <div id="panel-fonts" class="panel hidden">
21	      <details>
22	        <summary>Fonts <span class="pill" id="fonts-count">0</span></summary>
23	        <div id="fonts-list" class="chips"></div>
24	      </details>
25	    </div>
26	
27	    <div id="panel-warnings" class="panel hidden">
28	      <details>
29	        <summary>Warnings <span class="pill" id="warnings-count">0</span></summary>
30	        <div id="warnings-list"></div>
31	      </details>
32	    </div>
33	
34	    <div id="drop-zone">
35	      <div id="drop-card">
36	        <h1>pnk</h1>
37	        <p class="tagline">Open a Pages, Numbers or Keynote file — <strong>entirely in this browser</strong>.<br>
38	        No accounts, no upload, no backend: the file is parsed locally and never leaves your machine.</p>
39	        <div class="fmt-chips">
40	          <span class="fmt-chip fmt-pages">📄 Pages</span>
41	          <span class="fmt-chip fmt-numbers">📊 Numbers</span>
42	          <span class="fmt-chip fmt-keynote">🎞️ Keynote</span>
43	        </div>
44	        <div id="drop-target">Drop a <code>.pages</code>, <code>.numbers</code> or <code>.key</code> file here<br>
45	          <span class="muted">or</span><br>
46	          <button id="pick-btn" type="button">Choose a file…</button>
47	        </div>
48	        <p class="muted" id="drop-hint">Encrypted (password-protected) and legacy pre-iWork&nbsp;'13 files are politely refused — nothing about them leaves the browser either.</p>
49	      </div>
50	    </div>
51	
52	    <main id="view" class="hidden"></main>
53	  </div>
54	
55	  <div id="drag-overlay" class="hidden" aria-hidden="true">
56	    <div class="drag-overlay-card">Drop to open<br>
57	      <span class="drag-overlay-sub"><code>.pages</code> · <code>.numbers</code> · <code>.key</code> — parsed locally, never uploaded</span>
58	    </div>
59	  </div>
60	  <div id="parse-status" class="hidden" role="status"></div>
61	
62	  <input type="file" id="file-input" class="hidden"
63	         accept=".pages,.numbers,.key,.pages.zip,.numbers.zip,.key.zip,application/vnd.apple.pages,application/vnd.apple.numbers,application/vnd.apple.keynote">
64	
65	  <script type="module" src="main.js"></script>
66	</body>
67	</html>
$ Bash
Find shell wiring in main.ts
grep -n "panel-fonts\|fonts-list\|fonts-count\|panel-warnings\|app-header\|doc-filename\|doc-meta\|app-badge\|parse-status\|sticky" /Users/phebert/pnk/viewer/src/main.ts | head -25; wc -l /Users/phebert/pnk/viewer/src/main.ts /Users/phebert/pnk/viewer/styles.css
24:  $("app-header").classList.add("hidden");
25:  $("panel-fonts").classList.add("hidden");
26:  $("panel-warnings").classList.add("hidden");
33:  $("doc-filename").textContent = filename;
34:  $("app-badge").textContent = doc.meta.application ?? doc.meta.app;
41:  $("doc-meta").textContent = meta.join("  ·  ");
43:  $("app-header").classList.remove("hidden");
79:  $("app-header").classList.remove("hidden");
80:  $("doc-filename").textContent = filename;
81:  $("app-badge").textContent = "rejected";
82:  $("doc-meta").textContent = "";
92:  const status = $("parse-status");
     177 /Users/phebert/pnk/viewer/src/main.ts
     393 /Users/phebert/pnk/viewer/styles.css
     570 total
Read
{
  "file_path": "/Users/phebert/pnk/viewer/src/main.ts"
}
1	// pnk viewer entry point: init the wasm converter, wire the drop zone /
2	// file picker, and dispatch to the per-app renderers. No network calls after
3	// the static assets load — the file is parsed in-process and never uploaded.
4	
5	import init, { convert, media_bytes } from "./wasm/pnk2json_wasm.js";
6	import { ViewerCtx } from "./ctx";
7	import { hydrate } from "./hydrate";
8	import { mapError, renderErrorCard } from "./errors";
9	import { renderKeynote } from "./keynote";
10	import { renderNumbers } from "./numbers";
11	import { setTableLocale } from "./tables";
12	import { renderPages } from "./pages";
13	import { renderFonts, renderWarnings } from "./warnings";
14	import type { PnkDocument } from "../../model/src/shared";
15	
16	let ctx: ViewerCtx | null = null;
17	let lastJson: { text: string; filename: string } | null = null;
18	
19	const $ = (id: string) => document.getElementById(id)!;
20	
21	function showLanding(): void {
22	  ctx?.dispose();
23	  ctx = null;
24	  $("app-header").classList.add("hidden");
25	  $("panel-fonts").classList.add("hidden");
26	  $("panel-warnings").classList.add("hidden");
27	  $("view").classList.add("hidden");
28	  $("view").replaceChildren();
29	  $("drop-zone").classList.remove("hidden");
30	}
31	
32	function renderHeader(doc: PnkDocument, filename: string): void {
33	  $("doc-filename").textContent = filename;
34	  $("app-badge").textContent = doc.meta.application ?? doc.meta.app;
35	  const meta: string[] = [];
36	  if (doc.meta.fileFormatVersion) meta.push(`format ${doc.meta.fileFormatVersion}`);
37	  if (doc.meta.createdAt) meta.push(`created ${doc.meta.createdAt.slice(0, 10)}`);
38	  if (doc.meta.modifiedAt) meta.push(`modified ${doc.meta.modifiedAt.slice(0, 10)}`);
39	  if (doc.meta.locale) meta.push(doc.meta.locale);
40	  if (doc.meta.documentId) meta.push(`id ${doc.meta.documentId.slice(0, 8)}`);
41	  $("doc-meta").textContent = meta.join("  ·  ");
42	  $("json-btn").classList.remove("hidden");
43	  $("app-header").classList.remove("hidden");
44	}
45	
46	function renderDocument(doc: PnkDocument, filename: string): void {
47	  ctx?.dispose();
48	  const mediaCtx = new ViewerCtx();
49	  ctx = mediaCtx;
50	
51	  // media bytes: per-dataId raw fetch from the wasm side (no base64 in the
52	  // envelope); missing bytes render as a labeled placeholder instead
53	  if (typeof media_bytes === "function") {
54	    for (const asset of doc.media) {
55	      const bytes = media_bytes(asset.dataId);
56	      if (bytes) mediaCtx.addMedia(asset.dataId, bytes, asset.fileName ?? asset.preferredFileName);
57	    }
58	  }
59	
60	  renderHeader(doc, filename);
61	  setTableLocale(doc.meta.locale);
62	  renderFonts(doc.fonts);
63	  renderWarnings(doc.warnings);
64	
65	  const view = $("view");
66	  view.replaceChildren();
67	  view.classList.remove("hidden");
68	  $("drop-zone").classList.add("hidden");
69	
70	  if (doc.kind === "keynote") renderKeynote(doc, hydrate(doc), mediaCtx, view);
71	  else if (doc.kind === "numbers") renderNumbers(doc, hydrate(doc), mediaCtx, view);
72	  else renderPages(doc, hydrate(doc), mediaCtx, view);
73	}
74	
75	function showError(err: unknown, filename: string): void {
76	  lastJson = null;
77	  $("json-btn").classList.add("hidden");
78	  $("drop-zone").classList.add("hidden");
79	  $("app-header").classList.remove("hidden");
80	  $("doc-filename").textContent = filename;
81	  $("app-badge").textContent = "rejected";
82	  $("doc-meta").textContent = "";
83	  const view = $("view");
84	  view.replaceChildren();
85	  view.classList.remove("hidden");
86	  renderErrorCard(mapError(err, filename), view);
87	}
88	
89	async function handleFile(file: File): Promise<void> {
90	  // The current document (or landing card) stays on screen while we parse;
91	  // the swap happens only once the new document is ready (or errors out).
92	  const status = $("parse-status");
93	  status.textContent = `Parsing ${file.name}…`;
94	  status.classList.remove("hidden");
95	  try {
96	    const bytes = new Uint8Array(await file.arrayBuffer());
97	    const json = convert(bytes);
98	    const doc = JSON.parse(json) as PnkDocument;
99	    lastJson = { text: json, filename: file.name.replace(/\.[^.]+$/, "") + ".json" };
100	    renderDocument(doc, file.name);
101	  } catch (err) {
102	    showError(err, file.name);
103	  } finally {
104	    status.classList.add("hidden");
105	  }
106	}
107	
108	// Whole-window drag & drop: any file drag anywhere over the app raises a
109	// full-viewport overlay; dropping loads the file, whatever view is showing.
110	function wireDragAndDrop(): void {
111	  const overlay = $("drag-overlay");
112	  const target = $("drop-target");
113	  let depth = 0; // dragenter/leave fire per descendant element — count them
114	
115	  const isFileDrag = (e: DragEvent) =>
116	    Array.from(e.dataTransfer?.types ?? []).includes("Files");
117	  const hideOverlay = () => {
118	    depth = 0;
119	    overlay.classList.add("hidden");
120	    target.classList.remove("dragover");
121	  };
122	
123	  window.addEventListener("dragenter", (e) => {
124	    if (!isFileDrag(e)) return;
125	    e.preventDefault();
126	    depth++;
127	    overlay.classList.remove("hidden");
128	    target.classList.add("dragover");
129	  });
130	  window.addEventListener("dragover", (e) => {
131	    if (isFileDrag(e)) e.preventDefault();
132	  });
133	  window.addEventListener("dragleave", (e) => {
134	    if (!isFileDrag(e)) return;
135	    if (--depth <= 0) hideOverlay();
136	  });
137	  window.addEventListener("drop", (e) => {
138	    e.preventDefault();
139	    hideOverlay();
140	    const file = e.dataTransfer?.files?.[0];
141	    if (file) handleFile(file);
142	  });
143	}
144	
145	function wireEvents(): void {
146	  const input = $("file-input") as HTMLInputElement;
147	
148	  $("pick-btn").addEventListener("click", () => input.click());
149	  $("reset-btn").addEventListener("click", () => {
150	    input.value = "";
151	    showLanding();
152	  });
153	  // Download the converted JSON model (blob URL — still no network, no upload)
154	  $("json-btn").addEventListener("click", () => {
155	    if (!lastJson) return;
156	    const url = URL.createObjectURL(new Blob([lastJson.text], { type: "application/json" }));
157	    const a = document.createElement("a");
158	    a.href = url;
159	    a.download = lastJson.filename;
160	    a.click();
161	    URL.revokeObjectURL(url);
162	  });
163	  input.addEventListener("change", () => {
164	    if (input.files?.[0]) handleFile(input.files[0]);
165	  });
166	  wireDragAndDrop();
167	}
168	
169	async function boot(): Promise<void> {
170	  await init("wasm/pnk2json_wasm_bg.wasm");
171	  wireEvents();
172	  $("drop-hint").textContent =
173	    "Encrypted (password-protected) and legacy pre-iWork '13 files are politely refused — nothing about them leaves the browser either.";
174	}
175	
176	boot().catch((err) => {
177	  $("drop-hint").textContent = `Failed to load the local parser: ${err}`;
178	});
Read
{
  "file_path": "/Users/phebert/pnk/viewer/styles.css",
  "offset": 40,
  "limit": 150
}
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;
56	  padding: 40px 48px;
57	  text-align: center;
58	}
59	#drop-card h1 { margin: 0; font-size: 44px; letter-spacing: 2px; color: var(--accent); }
60	#drop-card .tagline { margin: 12px 0 16px; }
61	
62	.fmt-chips { display: flex; justify-content: center; gap: 10px; margin: 0 0 24px; }
63	.fmt-chip {
64	  border: 1px solid var(--line); border-radius: 999px;
65	  padding: 4px 14px; font-size: 13px; font-weight: 600; background: var(--surface);
66	}
67	.fmt-pages   { color: #b3730d; }
68	.fmt-numbers { color: #1e7e34; }
69	.fmt-keynote { color: #1a6ec0; }
70	
71	#drop-target {
72	  border: 2px dashed var(--line);
73	  border-radius: 12px;
74	  padding: 36px 20px;
75	  transition: border-color .15s, background .15s;
76	}
77	#drop-target.dragover { border-color: var(--accent); background: #fdf1f0; }
78	#drop-target code { background: var(--surface); padding: 1px 5px; border-radius: 4px; }
79	
80	button {
81	  font: inherit;
82	  padding: 8px 18px;
83	  border-radius: 8px;
84	  border: 1px solid var(--line);
85	  background: var(--card);
86	  cursor: pointer;
87	}
88	button:hover { border-color: var(--muted); }
89	
90	/* ---- whole-window drag overlay + parse toast ----------------------------- */
91	
92	#drag-overlay {
93	  position: fixed; inset: 0; z-index: 1000;
94	  display: flex; align-items: center; justify-content: center;
95	  background: rgba(245, 245, 247, .82);
96	  backdrop-filter: blur(3px);
97	  pointer-events: none;          /* drops land on window, not this layer */
98	}
99	.drag-overlay-card {
100	  border: 3px dashed var(--accent);
101	  border-radius: 20px;
102	  padding: 48px 72px;
103	  font-size: 28px; font-weight: 700; color: var(--accent);
104	  text-align: center; background: var(--card);
105	  box-shadow: 0 8px 40px rgba(0,0,0,.12);
106	}
107	.drag-overlay-sub { font-size: 14px; font-weight: 400; color: var(--muted); }
108	.drag-overlay-sub code { background: var(--surface); padding: 1px 5px; border-radius: 4px; }
109	
110	#parse-status {
111	  position: fixed; left: 50%; bottom: 28px; transform: translateX(-50%);
112	  z-index: 1001;
113	  background: var(--ink); color: #fff;
114	  border-radius: 999px; padding: 8px 20px; font-size: 13px;
115	  box-shadow: 0 4px 16px rgba(0,0,0,.25);
116	}
117	
118	/* ---- header / envelope -------------------------------------------------- */
119	
120	#app-header {
121	  display: flex;
122	  align-items: center;
123	  gap: 12px;
124	  padding: 10px 4px;
125	  flex-wrap: wrap;
126	  position: sticky; top: 0; z-index: 20;
127	  background: color-mix(in srgb, var(--surface) 88%, transparent);
128	  backdrop-filter: blur(6px);
129	}
130	#doc-filename { font-weight: 600; word-break: break-all; }
131	.badge {
132	  background: var(--accent);
133	  color: #fff;
134	  border-radius: 999px;
135	  padding: 2px 12px;
136	  font-size: 12px;
137	  font-weight: 700;
138	  text-transform: uppercase;
139	  letter-spacing: 1px;
140	}
141	.pill {
142	  display: inline-block;
143	  min-width: 20px;
144	  text-align: center;
145	  background: var(--surface);
146	  border-radius: 999px;
147	  padding: 0 7px;
148	  font-size: 12px;
149	}
150	
151	.panel {
152	  background: var(--card);
153	  border: 1px solid var(--line);
154	  border-radius: 10px;
155	  padding: 6px 14px;
156	  margin-bottom: 10px;
157	  font-size: 13px;
158	}
159	.panel summary { cursor: pointer; user-select: none; }
160	.chips { display: flex; flex-wrap: wrap; gap: 6px; padding: 8px 0; }
161	.chips .chip {
162	  background: var(--surface);
163	  border: 1px solid var(--line);
164	  border-radius: 999px;
165	  padding: 2px 10px;
166	  font-size: 12px;
167	}
168	#warnings-list { padding: 6px 0; }
169	.warning-row { display: flex; gap: 10px; padding: 3px 0; border-top: 1px solid var(--surface); }
170	.warning-row code { color: var(--muted); flex: 0 0 160px; }
171	.warning-row .path { color: var(--muted); font-style: italic; }
172	
173	/* ---- error card ---------------------------------------------------------- */
174	
175	.error-card {
176	  background: #fff6f5;
177	  border: 1px solid #f2c4c0;
178	  border-left: 4px solid var(--accent);
179	  border-radius: 10px;
180	  padding: 18px 22px;
181	  margin: 24px 0;
182	}
183	.error-card h2 { margin: 0 0 8px; color: var(--accent); font-size: 18px; }
184	.error-card .error-detail { margin: 8px 0 0; font-size: 13px; color: var(--muted); word-break: break-word; }
185	
186	/* ---- canvases (Keynote slides / Pages layout pages) ---------------------- */
187	
188	.canvas-outer { margin: 18px 0; }
189	.canvas-frame {           /* scaled wrapper: aspect kept via padding trick below */
Thinking
$ Bash
Test refs + warnings.ts content
grep -n "fonts\|warnings\|app-badge\|doc-filename\|drop-hint\|panel" /Users/phebert/pnk/viewer/tests/gate.spec.ts; cat /Users/phebert/pnk/viewer/src/warnings.ts
62:  await expect(page.locator("#drop-hint")).toContainText("nothing about them leaves the browser");
74:  await expect(page.locator("#app-badge")).toContainText(/keynote/i);
77:  // Continuous-scroll view: a .notes-panel renders only for slides with
79:  // no panel may appear (the old UI showed 11 empty yellow strips).
80:  await expect(page.locator(".notes-panel")).toHaveCount(0);
// Envelope UI: fonts (font-ready indicator) and warnings (collapsible).
// Warnings never block rendering — unknown-object-type and friends are shown
// as context alongside the rendered document.

import type { Warning } from "../../model/src/shared";

const CODE_LABELS: Record<string, string> = {
  "unknown-object-type": "unknown object type",
  "undecodable-object": "undecodable object",
  "unresolved-reference": "unresolved reference",
  "unsupported-feature": "unsupported feature",
  "media-missing": "media missing",
  "color-degraded": "color degraded",
  "legacy-variant": "legacy variant",
  "table-degraded": "degraded table",
  "formula-unparsed": "formula not parsed",
};

export function renderFonts(fonts: string[]): void {
  const panel = document.getElementById("panel-fonts");
  if (!panel) return;
  panel.classList.remove("hidden");
  document.getElementById("fonts-count")!.textContent = String(fonts.length);
  const list = document.getElementById("fonts-list")!;
  list.replaceChildren();
  for (const f of fonts) {
    const chip = document.createElement("span");
    chip.className = "chip";
    chip.textContent = f;
    list.appendChild(chip);
  }
  if (fonts.length === 0) list.textContent = "none";
}

export function renderWarnings(warnings: Warning[]): void {
  const panel = document.getElementById("panel-warnings");
  if (!panel) return;
  panel.classList.remove("hidden");
  const total = warnings.reduce((n, w) => n + (w.count ?? 1), 0);
  document.getElementById("warnings-count")!.textContent = String(total);
  const list = document.getElementById("warnings-list")!;
  list.replaceChildren();
  // summary: count per code, so a pile of unknown-type-ids stays one line
  const byCode = new Map<string, number>();
  for (const w of warnings) byCode.set(w.code, (byCode.get(w.code) ?? 0) + (w.count ?? 1));
  const summary = document.createElement("div");
  summary.className = "chips";
  for (const [code, count] of byCode) {
    const chip = document.createElement("span");
    chip.className = "chip";
    chip.dataset.warningCode = code;
    chip.textContent = `${CODE_LABELS[code] ?? code} × ${count}`;
    summary.appendChild(chip);
  }
  if (warnings.length > 0) list.appendChild(summary);

  for (const w of warnings) {
    const row = document.createElement("div");
    row.className = "warning-row";
    const code = document.createElement("code");
    code.textContent = CODE_LABELS[w.code] ?? w.code;
    row.appendChild(code);
    const msg = document.createElement("span");
    msg.textContent = w.count && w.count > 1 ? `${w.message} (×${w.count})` : w.message;
    row.appendChild(msg);
    const pathText = w.path ?? (w.paths ? w.paths.join(", ") + (w.count && w.count > w.paths.length ? ", …" : "") : undefined);
    if (pathText) {
      const path = document.createElement("span");
      path.className = "path";
      path.textContent = pathText;
      row.appendChild(path);
    }
    list.appendChild(row);
  }
  if (warnings.length === 0) list.textContent = "No warnings — clean decode.";
}
Thinking

Full picture in hand. Implementing the redesign — index.html first:

📝 Write index.html
/Users/phebert/pnk/viewer/index.html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>pnk.vu — iWork, locally</title>
  <link rel="stylesheet" href="styles.css">
</head>
<body>
  <nav id="topnav">
    <a id="brand" href="#" title="pnk.vu — back to start">pnk<span class="brand-tld">.vu</span></a>
    <span id="doc-filename" class="hidden"></span>
    <span id="app-badge" class="badge hidden"></span>
    <span id="doc-meta" class="muted hidden"></span>
    <span class="spacer"></span>
    <details id="warnings-dd" class="hidden">
      <summary title="Decode warnings"><span class="warn-mark">▲</span> <span id="warnings-count">0</span></summary>
      <div id="warnings-list"></div>
    </details>
    <button id="json-btn" type="button" class="hidden" title="Download the converted JSON model">json</button>
    <button id="reset-btn" type="button" title="Open a file">open…</button>
  </nav>

  <div id="app">
    <div id="drop-zone">
      <div id="drop-card">
        <h1>pnk<span class="brand-tld">.vu</span></h1>
        <p class="tagline">a client-side viewer for Apple iWork files.<br>
        <strong>everything runs in this browser</strong> — no account, no upload, no backend.</p>
        <div class="fmt-chips">
          <span class="fmt-chip fmt-pages"><i></i>.pages</span>
          <span class="fmt-chip fmt-numbers"><i></i>.numbers</span>
          <span class="fmt-chip fmt-keynote"><i></i>.key</span>
        </div>
        <div id="drop-target">drop a file anywhere<br>
          <span class="muted">or</span><br>
          <button id="pick-btn" type="button">choose a file…</button>
        </div>
        <p class="muted" id="drop-hint">Encrypted (password-protected) and legacy pre-iWork&nbsp;'13 files are politely refused — nothing about them leaves the browser either.</p>
      </div>
    </div>

    <main id="view" class="hidden"></main>
  </div>

  <div id="drag-overlay" class="hidden" aria-hidden="true">
    <div class="drag-overlay-card">drop to open<br>
      <span class="drag-overlay-sub"><code>.pages</code> · <code>.numbers</code> · <code>.key</code> — parsed locally, never uploaded</span>
    </div>
  </div>
  <div id="parse-status" class="hidden" role="status"></div>

  <input type="file" id="file-input" class="hidden"
         accept=".pages,.numbers,.key,.pages.zip,.numbers.zip,.key.zip,application/vnd.apple.pages,application/vnd.apple.numbers,application/vnd.apple.keynote">

  <script type="module" src="main.js"></script>
</body>
</html>
The file /Users/phebert/pnk/viewer/index.html has been updated successfully. (file state is current in your context — no need to Read it back)

Now main.ts — nav wiring, drop fonts, badge coloring:

✏️ Edit main.ts
/Users/phebert/pnk/viewer/src/main.ts
import { renderFonts, renderWarnings } from "./warnings";
import type { PnkDocument } from "../../model/src/shared";

let ctx: ViewerCtx | null = null;
let lastJson: { text: string; filename: string } | null = null;

const $ = (id: string) => document.getElementById(id)!;

function showLanding(): void {
  ctx?.dispose();
  ctx = null;
  $("app-header").classList.add("hidden");
  $("panel-fonts").classList.add("hidden");
  $("panel-warnings").classList.add("hidden");
  $("view").classList.add("hidden");
  $("view").replaceChildren();
  $("drop-zone").classList.remove("hidden");
}

function renderHeader(doc: PnkDocument, filename: string): void {
  $("doc-filename").textContent = filename;
  $("app-badge").textContent = doc.meta.application ?? doc.meta.app;
  const meta: string[] = [];
  if (doc.meta.fileFormatVersion) meta.push(`format ${doc.meta.fileFormatVersion}`);
  if (doc.meta.createdAt) meta.push(`created ${doc.meta.createdAt.slice(0, 10)}`);
  if (doc.meta.modifiedAt) meta.push(`modified ${doc.meta.modifiedAt.slice(0, 10)}`);
  if (doc.meta.locale) meta.push(doc.meta.locale);
  if (doc.meta.documentId) meta.push(`id ${doc.meta.documentId.slice(0, 8)}`);
  $("doc-meta").textContent = meta.join("  ·  ");
  $("json-btn").classList.remove("hidden");
  $("app-header").classList.remove("hidden");
}
+
import { renderWarnings } from "./warnings";
import type { PnkDocument } from "../../model/src/shared";

let ctx: ViewerCtx | null = null;
let lastJson: { text: string; filename: string } | null = null;

const $ = (id: string) => document.getElementById(id)!;

function showLanding(): void {
  ctx?.dispose();
  ctx = null;
  for (const id of ["doc-filename", "app-badge", "doc-meta", "warnings-dd", "json-btn"]) {
    $(id).classList.add("hidden");
  }
  $("view").classList.add("hidden");
  $("view").replaceChildren();
  $("drop-zone").classList.remove("hidden");
}

function renderHeader(doc: PnkDocument, filename: string): void {
  $("doc-filename").textContent = filename;
  const badge = $("app-badge");
  badge.textContent = doc.meta.application ?? doc.meta.app;
  badge.dataset.app = doc.kind;
  const meta: string[] = [];
  if (doc.meta.fileFormatVersion) meta.push(`v${doc.meta.fileFormatVersion}`);
  if (doc.meta.locale) meta.push(doc.meta.locale);
  $("doc-meta").textContent = meta.join(" · ");
  for (const id of ["doc-filename", "app-badge", "doc-meta", "json-btn"]) {
    $(id).classList.remove("hidden");
  }
}
The file /Users/phebert/pnk/viewer/src/main.ts has been updated successfully. (file state is current in your context — no need to Read it back)
✏️ Edit main.ts
/Users/phebert/pnk/viewer/src/main.ts
  renderHeader(doc, filename);
  setTableLocale(doc.meta.locale);
  renderFonts(doc.fonts);
  renderWarnings(doc.warnings);
+
  renderHeader(doc, filename);
  setTableLocale(doc.meta.locale);
  renderWarnings(doc.warnings);
The file /Users/phebert/pnk/viewer/src/main.ts has been updated successfully. (file state is current in your context — no need to Read it back)
✏️ Edit main.ts
/Users/phebert/pnk/viewer/src/main.ts
function showError(err: unknown, filename: string): void {
  lastJson = null;
  $("json-btn").classList.add("hidden");
  $("drop-zone").classList.add("hidden");
  $("app-header").classList.remove("hidden");
  $("doc-filename").textContent = filename;
  $("app-badge").textContent = "rejected";
  $("doc-meta").textContent = "";
+
function showError(err: unknown, filename: string): void {
  lastJson = null;
  $("json-btn").classList.add("hidden");
  $("warnings-dd").classList.add("hidden");
  $("drop-zone").classList.add("hidden");
  $("doc-filename").textContent = filename;
  $("doc-filename").classList.remove("hidden");
  const badge = $("app-badge");
  badge.textContent = "rejected";
  delete badge.dataset.app;
  badge.classList.remove("hidden");
  $("doc-meta").textContent = "";
  $("doc-meta").classList.add("hidden");
The file /Users/phebert/pnk/viewer/src/main.ts has been updated successfully. (file state is current in your context — no need to Read it back)
✏️ Edit main.ts
/Users/phebert/pnk/viewer/src/main.ts
  $("pick-btn").addEventListener("click", () => input.click());
  $("reset-btn").addEventListener("click", () => {
    input.value = "";
    showLanding();
  });
+
  $("pick-btn").addEventListener("click", () => input.click());
  // nav "open…" goes straight to the picker; the brand is the way home
  $("reset-btn").addEventListener("click", () => {
    input.value = "";
    input.click();
  });
  $("brand").addEventListener("click", (e) => {
    e.preventDefault();
    showLanding();
  });
The file /Users/phebert/pnk/viewer/src/main.ts has been updated successfully. (file state is current in your context — no need to Read it back)

Now warnings.ts — drop renderFonts, make the indicator hide at zero:

✏️ Edit warnings.ts
/Users/phebert/pnk/viewer/src/warnings.ts
// Envelope UI: fonts (font-ready indicator) and warnings (collapsible).
// Warnings never block rendering — unknown-object-type and friends are shown
// as context alongside the rendered document.
+
// Envelope UI: the nav warnings indicator + dropdown. Warnings never block
// rendering — unknown-object-type and friends are context alongside the
// rendered document, and a clean decode shows no indicator at all.
The file /Users/phebert/pnk/viewer/src/warnings.ts has been updated successfully. (file state is current in your context — no need to Read it back)
✏️ Edit warnings.ts
/Users/phebert/pnk/viewer/src/warnings.ts
export function renderFonts(fonts: string[]): void {
  const panel = document.getElementById("panel-fonts");
  if (!panel) return;
  panel.classList.remove("hidden");
  document.getElementById("fonts-count")!.textContent = String(fonts.length);
  const list = document.getElementById("fonts-list")!;
  list.replaceChildren();
  for (const f of fonts) {
    const chip = document.createElement("span");
    chip.className = "chip";
    chip.textContent = f;
    list.appendChild(chip);
  }
  if (fonts.length === 0) list.textContent = "none";
}

export function renderWarnings(warnings: Warning[]): void {
  const panel = document.getElementById("panel-warnings");
  if (!panel) return;
  panel.classList.remove("hidden");
  const total = warnings.reduce((n, w) => n + (w.count ?? 1), 0);
  document.getElementById("warnings-count")!.textContent = String(total);
+
export function renderWarnings(warnings: Warning[]): void {
  const panel = document.getElementById("warnings-dd") as HTMLDetailsElement | null;
  if (!panel) return;
  const total = warnings.reduce((n, w) => n + (w.count ?? 1), 0);
  panel.classList.toggle("hidden", total === 0);
  panel.open = false;
  document.getElementById("warnings-count")!.textContent = String(total);
The file /Users/phebert/pnk/viewer/src/warnings.ts has been updated successfully. (file state is current in your context — no need to Read it back)
Thinking
✏️ Edit warnings.ts
/Users/phebert/pnk/viewer/src/warnings.ts
    list.appendChild(row);
  }
  if (warnings.length === 0) list.textContent = "No warnings — clean decode.";
}
+
    list.appendChild(row);
  }
}
The file /Users/phebert/pnk/viewer/src/warnings.ts has been updated successfully. (file state is current in your context — no need to Read it back)

Now the CSS — nav bar, brand, compact chrome, subtle warnings dropdown, landing restyle:

✏️ Edit styles.css
/Users/phebert/pnk/viewer/styles.css
body {
  margin: 0;
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
  color: var(--ink);
  background: var(--surface);
}

#app { max-width: 1100px; margin: 0 auto; padding: 16px; }

/* ---- landing / drop zone ------------------------------------------------ */

#drop-zone { padding: 8vh 0; }
#drop-card {
  background: var(--card);
  border: 1px solid var(--line);
  border-radius: 16px;
  padding: 40px 48px;
  text-align: center;
}
#drop-card h1 { margin: 0; font-size: 44px; letter-spacing: 2px; color: var(--accent); }
#drop-card .tagline { margin: 12px 0 16px; }

.fmt-chips { display: flex; justify-content: center; gap: 10px; margin: 0 0 24px; }
.fmt-chip {
  border: 1px solid var(--line); border-radius: 999px;
  padding: 4px 14px; font-size: 13px; font-weight: 600; background: var(--surface);
}
.fmt-pages   { color: #b3730d; }
.fmt-numbers { color: #1e7e34; }
.fmt-keynote { color: #1a6ec0; }
+
body {
  margin: 0;
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
  color: var(--ink);
  background: var(--surface);
}
.mono, #brand, .fmt-chip, #doc-filename, #doc-meta, .badge {
  font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
}

#app { max-width: 1160px; margin: 0 auto; padding: 10px 16px 16px; }

/* ---- top nav ------------------------------------------------------------- */

#topnav {
  position: sticky; top: 0; z-index: 40;
  display: flex; align-items: center; gap: 10px;
  height: 42px; padding: 0 14px;
  background: color-mix(in srgb, var(--surface) 86%, transparent);
  backdrop-filter: blur(8px);
  border-bottom: 1px solid var(--line);
  font-size: 13px;
}
#brand {
  font-size: 15px; font-weight: 700; letter-spacing: .5px;
  color: var(--ink); text-decoration: none;
}
.brand-tld { color: var(--accent); font-weight: 700; }
#doc-filename {
  font-size: 12px; color: var(--ink);
  max-width: 34vw; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
#doc-meta { font-size: 11.5px; }
@media (max-width: 860px) { #doc-meta { display: none; } }
#topnav button {
  padding: 3px 12px; font-size: 12px; border-radius: 6px;
  font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
}

/* subtle warnings indicator + dropdown */
#warnings-dd { position: relative; }
#warnings-dd summary {
  list-style: none; cursor: pointer; user-select: none;
  color: #9a7b18; font-size: 12px; padding: 2px 8px; border-radius: 6px;
}
#warnings-dd summary::-webkit-details-marker { display: none; }
#warnings-dd summary:hover, #warnings-dd[open] summary { background: rgba(200, 160, 30, .12); }
#warnings-dd .warn-mark { font-size: 9px; vertical-align: 1px; }
#warnings-list {
  position: absolute; right: 0; top: calc(100% + 8px);
  width: min(560px, 86vw); max-height: 55vh; overflow-y: auto;
  background: var(--card); border: 1px solid var(--line); border-radius: 10px;
  box-shadow: 0 8px 30px rgba(0,0,0,.14);
  padding: 10px 14px; font-size: 12.5px;
}

/* ---- landing / drop zone ------------------------------------------------ */

#drop-zone { padding: 9vh 0; }
#drop-card {
  background: var(--card);
  border: 1px solid var(--line);
  border-radius: 14px;
  padding: 36px 44px;
  max-width: 560px; margin: 0 auto;
  text-align: center;
}
#drop-card h1 {
  margin: 0; font-size: 34px; letter-spacing: .5px; color: var(--ink);
  font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
}
#drop-card .tagline { margin: 10px 0 18px; font-size: 14px; color: var(--muted); }
#drop-card .tagline strong { color: var(--ink); }

.fmt-chips { display: flex; justify-content: center; gap: 8px; margin: 0 0 20px; }
.fmt-chip {
  border: 1px solid var(--line); border-radius: 6px;
  padding: 3px 10px; font-size: 12px; background: var(--surface);
  display: inline-flex; align-items: center; gap: 6px;
}
.fmt-chip i { width: 7px; height: 7px; border-radius: 50%; display: inline-block; }
.fmt-pages i   { background: #e8883a; }
.fmt-numbers i { background: #35a651; }
.fmt-keynote i { background: #2f8bde; }
The file /Users/phebert/pnk/viewer/styles.css has been updated successfully. (file state is current in your context — no need to Read it back)
✏️ Edit styles.css
/Users/phebert/pnk/viewer/styles.css
/* ---- header / envelope -------------------------------------------------- */

#app-header {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 10px 4px;
  flex-wrap: wrap;
  position: sticky; top: 0; z-index: 20;
  background: color-mix(in srgb, var(--surface) 88%, transparent);
  backdrop-filter: blur(6px);
}
#doc-filename { font-weight: 600; word-break: break-all; }
.badge {
  background: var(--accent);
  color: #fff;
  border-radius: 999px;
  padding: 2px 12px;
  font-size: 12px;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 1px;
}
.pill {
  display: inline-block;
  min-width: 20px;
  text-align: center;
  background: var(--surface);
  border-radius: 999px;
  padding: 0 7px;
  font-size: 12px;
}

.panel {
  background: var(--card);
  border: 1px solid var(--line);
  border-radius: 10px;
  padding: 6px 14px;
  margin-bottom: 10px;
  font-size: 13px;
}
.panel summary { cursor: pointer; user-select: none; }
.chips { display: flex; flex-wrap: wrap; gap: 6px; padding: 8px 0; }
+
/* ---- envelope bits ------------------------------------------------------- */

/* App badge: quiet colored text chip, not a shouty pill. */
.badge {
  border: 1px solid var(--line);
  border-radius: 5px;
  padding: 1px 8px;
  font-size: 11px;
  font-weight: 600;
  text-transform: lowercase;
  letter-spacing: .5px;
  color: var(--muted);
}
.badge[data-app="pages"]   { color: #b06a20; border-color: #e0b98c; }
.badge[data-app="numbers"] { color: #2c7d43; border-color: #a3d4b1; }
.badge[data-app="keynote"] { color: #2569ab; border-color: #a7cced; }

.chips { display: flex; flex-wrap: wrap; gap: 6px; padding: 8px 0; }
The file /Users/phebert/pnk/viewer/styles.css has been updated successfully. (file state is current in your context — no need to Read it back)
Thinking
✏️ Edit styles.css
/Users/phebert/pnk/viewer/styles.css
.slide-list {
  flex: 0 0 190px; overflow-y: auto;
  position: sticky; top: 58px; max-height: calc(100vh - 74px);
+
.slide-list {
  flex: 0 0 190px; overflow-y: auto;
  position: sticky; top: 52px; max-height: calc(100vh - 66px);
The file /Users/phebert/pnk/viewer/styles.css has been updated successfully. (file state is current in your context — no need to Read it back)
$ Bash
Find remaining references to removed elements
grep -n "panel-fonts\|panel-warnings\|app-header\|fonts-list\|fonts-count\|\.pill" /Users/phebert/pnk/viewer/src/*.ts /Users/phebert/pnk/viewer/styles.css /Users/phebert/pnk/viewer/tests/gate.spec.ts /Users/phebert/pnk/scripts/visual_diff.py
(Bash completed with no output)
$ Bash
Build, typecheck, gate
cd /Users/phebert/pnk && bash scripts/build_viewer.sh 2>&1 | tail -1 && cd viewer && npx tsc --noEmit && echo TSC-OK && npm test 2>&1 | tail -3
viewer built: viewer/dist/  (serve: cd viewer && npm run serve)
TSC-OK
  ✓  6 tests/gate.spec.ts:147:1 › encrypted fixture gets the password-protected explanation (94ms)

  6 passed (1.6s)

Gate 6/6. Visual check of the new chrome — landing, a doc with warnings, and dark mode:

$ Bash
Screenshot new chrome states
cat > probe2-tmp.mjs <<'EOF'
import { chromium } from "playwright";
const SCRATCH = "/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad";
const b = await chromium.launch();
const page = await b.newPage({ viewport: { width: 1280, height: 860 } });
await page.goto("http://127.0.0.1:8123/");
await page.waitForSelector("#drop-card");
await page.screenshot({ path: `${SCRATCH}/nav-landing.png` });
// a doc that has warnings: G2 has 1
await page.setInputFiles("#file-input", "/Users/phebert/pnk/fixtures/golden/G2-golden-pages-layout.pages");
await page.waitForSelector("#view:not(.hidden)");
await page.waitForTimeout(800);
await page.screenshot({ path: `${SCRATCH}/nav-doc.png` });
const dd = page.locator("#warnings-dd summary");
if (await dd.isVisible()) { await dd.click(); await page.waitForTimeout(200); }
await page.screenshot({ path: `${SCRATCH}/nav-warn.png` });
// dark mode landing
const p2 = await b.newPage({ viewport: { width: 1280, height: 860 }, colorScheme: "dark" });
await p2.goto("http://127.0.0.1:8123/");
await p2.waitForSelector("#drop-card");
await p2.screenshot({ path: `${SCRATCH}/nav-dark.png` });
await b.close();
EOF
node probe2-tmp.mjs && rm probe2-tmp.mjs
(Bash completed with no output)
Read
{
  "file_path": "/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/nav-landing.png"
}
Read
{
  "file_path": "/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/nav-warn.png"
}
Thinking
Read
{
  "file_path": "/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/nav-dark.png"
}
Thinking

Dark mode is clean too. Committing:

$ Bash
Commit and push the pnk.vu chrome
cd /Users/phebert/pnk && git add viewer/index.html viewer/src/main.ts viewer/src/warnings.ts viewer/styles.css && git commit -m "viewer: pnk.vu — top nav bar, minimalist dev-forward chrome

The shell becomes a product: a slim sticky nav (42px) carries the
monospace wordmark pnk.vu (accent .vu — the brand doubles as the way
home), the open filename, a quiet per-app badge (colored text chip:
pages orange / numbers green / keynote blue — no more shouty red pill),
compact meta, and right-aligned json/open actions.

- Fonts list: gone (it was inspector chrome, not viewer UI).
- Warnings: demoted from a full-width panel to a small amber ▲ n
  indicator that only exists when the decode produced warnings; the
  detail rows live in a right-anchored dropdown. Clean decodes show
  nothing at all.
- Landing card restyled to match: mono wordmark, lowercase tagline,
  dot-labeled .pages/.numbers/.key chips, tighter paddings.
- nav 'open…' goes straight to the file picker; clicking the brand
  returns to the landing card.

Verified light + dark, doc + landing + warnings-dropdown states via
Playwright screenshots; gate 6/6 (tsc + Playwright).

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018a2hmoDhN4kBq1BwfbFdAU" && git push origin main 2>&1 | tail -1
4 files changed, 136 insertions(+), 123 deletions(-)
   bc5004d..95c7ada  main -> main

Done and pushed as 95c7ada. The viewer is now pnk.vu:

  • Top nav bar (slim, sticky, blurred): monospace pnk.vu wordmark with the accent-red .vu, then the filename, a quiet app badge — colored text chips (pages orange / numbers green / keynote blue) replacing the old red pill — compact format/locale meta, and right-aligned json / open… actions. The brand is the way home; open… goes straight to the picker.
  • Fonts list removed entirely — it was inspector chrome, not viewer UI.
  • Warnings demoted to a whisper: a small amber ▲ n in the nav that exists only when the decode actually produced warnings; clicking it drops down the detail rows. Clean decodes show nothing.
  • Landing card re-branded to match: mono lowercase wordmark, terminal-flavored tagline, dot-labeled .pages / .numbers / .key chips, tighter spacing.
  • Everything sits a bit more compactly (slimmer paddings, wider 1160px content column), and the Keynote rail's sticky offset tracks the new nav height.

Verified by screenshot in light and dark mode across landing, document, and warnings-dropdown states; tsc clean and Playwright gate 6/6.

One taste note you might want to weigh in on: the wordmark renders as pnk.vu in SF Mono with the dot getting a full monospace cell — I kept it because the airiness reads intentionally terminal-ish, but it's a one-line change if you'd rather tighten the dot.

Fewer words on the page please, it's very Claude and over-explained

Thinking
$ Bash
Find hint text duplication and test assertion
grep -n "drop-hint\|politely" /Users/phebert/pnk/viewer/src/main.ts /Users/phebert/pnk/viewer/tests/gate.spec.ts
/Users/phebert/pnk/viewer/tests/gate.spec.ts:62:  await expect(page.locator("#drop-hint")).toContainText("nothing about them leaves the browser");
/Users/phebert/pnk/viewer/src/main.ts:181:  $("drop-hint").textContent =
/Users/phebert/pnk/viewer/src/main.ts:182:    "Encrypted (password-protected) and legacy pre-iWork '13 files are politely refused — nothing about them leaves the browser either.";
/Users/phebert/pnk/viewer/src/main.ts:186:  $("drop-hint").textContent = `Failed to load the local parser: ${err}`;
✏️ Edit index.html
/Users/phebert/pnk/viewer/index.html
        <p class="tagline">a client-side viewer for Apple iWork files.<br>
        <strong>everything runs in this browser</strong> — no account, no upload, no backend.</p>
        <div class="fmt-chips">
          <span class="fmt-chip fmt-pages"><i></i>.pages</span>
          <span class="fmt-chip fmt-numbers"><i></i>.numbers</span>
          <span class="fmt-chip fmt-keynote"><i></i>.key</span>
        </div>
        <div id="drop-target">drop a file anywhere<br>
          <span class="muted">or</span><br>
          <button id="pick-btn" type="button">choose a file…</button>
        </div>
        <p class="muted" id="drop-hint">Encrypted (password-protected) and legacy pre-iWork&nbsp;'13 files are politely refused — nothing about them leaves the browser either.</p>
+
        <p class="tagline">view iWork files in your browser. nothing is uploaded.</p>
        <div class="fmt-chips">
          <span class="fmt-chip fmt-pages"><i></i>.pages</span>
          <span class="fmt-chip fmt-numbers"><i></i>.numbers</span>
          <span class="fmt-chip fmt-keynote"><i></i>.key</span>
        </div>
        <div id="drop-target">drop a file anywhere<br>
          <button id="pick-btn" type="button">choose a file…</button>
        </div>
        <p class="muted" id="drop-hint">encrypted and pre-2013 files are refused.</p>
The file /Users/phebert/pnk/viewer/index.html has been updated successfully. (file state is current in your context — no need to Read it back)
✏️ Edit index.html
/Users/phebert/pnk/viewer/index.html
    <div class="drag-overlay-card">drop to open<br>
      <span class="drag-overlay-sub"><code>.pages</code> · <code>.numbers</code> · <code>.key</code> — parsed locally, never uploaded</span>
    </div>
+
    <div class="drag-overlay-card">drop to open<br>
      <span class="drag-overlay-sub"><code>.pages</code> · <code>.numbers</code> · <code>.key</code></span>
    </div>
The file /Users/phebert/pnk/viewer/index.html has been updated successfully. (file state is current in your context — no need to Read it back)
Thinking

Also folding in the nav-button and CTA changes:

✏️ Edit index.html
/Users/phebert/pnk/viewer/index.html
    <button id="json-btn" type="button" class="hidden" title="Download the converted JSON model">json</button>
    <button id="reset-btn" type="button" title="Open a file">open…</button>
+
    <button id="json-btn" type="button" class="hidden" title="Download the converted JSON model">json</button>
    <button id="reset-btn" type="button" class="hidden" title="Open a file">open…</button>
The file /Users/phebert/pnk/viewer/index.html has been updated successfully. (file state is current in your context — no need to Read it back)
✏️ Edit main.ts
/Users/phebert/pnk/viewer/src/main.ts
  for (const id of ["doc-filename", "app-badge", "doc-meta", "warnings-dd", "json-btn"]) {
    $(id).classList.add("hidden");
  }
+
  // the landing card's own CTA is the only "open" on the landing screen
  for (const id of ["doc-filename", "app-badge", "doc-meta", "warnings-dd", "json-btn", "reset-btn"]) {
    $(id).classList.add("hidden");
  }
The file /Users/phebert/pnk/viewer/src/main.ts has been updated successfully. (file state is current in your context — no need to Read it back)
✏️ Edit main.ts
/Users/phebert/pnk/viewer/src/main.ts
  for (const id of ["doc-filename", "app-badge", "doc-meta", "json-btn"]) {
    $(id).classList.remove("hidden");
  }
+
  for (const id of ["doc-filename", "app-badge", "doc-meta", "json-btn", "reset-btn"]) {
    $(id).classList.remove("hidden");
  }
The file /Users/phebert/pnk/viewer/src/main.ts has been updated successfully. (file state is current in your context — no need to Read it back)
Thinking
✏️ Edit main.ts
/Users/phebert/pnk/viewer/src/main.ts
  badge.classList.remove("hidden");
  $("doc-meta").textContent = "";
  $("doc-meta").classList.add("hidden");
+
  badge.classList.remove("hidden");
  $("reset-btn").classList.remove("hidden");
  $("doc-meta").textContent = "";
  $("doc-meta").classList.add("hidden");
The file /Users/phebert/pnk/viewer/src/main.ts has been updated successfully. (file state is current in your context — no need to Read it back)
✏️ Edit main.ts
/Users/phebert/pnk/viewer/src/main.ts
  $("drop-hint").textContent =
    "Encrypted (password-protected) and legacy pre-iWork '13 files are politely refused — nothing about them leaves the browser either.";
+
  $("drop-hint").textContent = "encrypted and pre-2013 files are refused.";
The file /Users/phebert/pnk/viewer/src/main.ts has been updated successfully. (file state is current in your context — no need to Read it back)
✏️ Edit gate.spec.ts
/Users/phebert/pnk/viewer/tests/gate.spec.ts
  await expect(page.locator("#drop-hint")).toContainText("nothing about them leaves the browser");
+
  await expect(page.locator("#drop-hint")).toContainText("encrypted and pre-2013 files are refused");
The file /Users/phebert/pnk/viewer/tests/gate.spec.ts has been updated successfully. (file state is current in your context — no need to Read it back)

Now the primary-CTA styling and tightening the drop target (the "or" line is gone):

✏️ Edit styles.css
/Users/phebert/pnk/viewer/styles.css
#drop-target {
  border: 2px dashed var(--line);
  border-radius: 12px;
  padding: 36px 20px;
  transition: border-color .15s, background .15s;
}
+
#drop-target {
  border: 2px dashed var(--line);
  border-radius: 12px;
  padding: 28px 20px;
  transition: border-color .15s, background .15s;
  color: var(--muted);
}
#pick-btn {
  margin-top: 12px;
  background: var(--accent); color: #fff; border-color: var(--accent);
  font-weight: 600;
}
#pick-btn:hover { background: #b73632; border-color: #b73632; }
The file /Users/phebert/pnk/viewer/styles.css has been updated successfully. (file state is current in your context — no need to Read it back)
$ Bash
Build, gate, screenshot trimmed landing
bash scripts/build_viewer.sh 2>&1 | tail -1 && cd viewer && npm test 2>&1 | tail -2 && cat > probe3-tmp.mjs <<'EOF'
import { chromium } from "playwright";
const SCRATCH = "/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad";
const b = await chromium.launch();
const page = await b.newPage({ viewport: { width: 1280, height: 760 } });
await page.goto("http://127.0.0.1:8123/");
await page.waitForSelector("#drop-card");
await page.screenshot({ path: `${SCRATCH}/landing2.png` });
await b.close();
EOF
node probe3-tmp.mjs && rm probe3-tmp.mjs
viewer built: viewer/dist/  (serve: cd viewer && npm run serve)

  6 passed (1.5s)
$ Bash
Screenshot trimmed landing
cat > probe3-tmp.mjs <<'EOF'
import { chromium } from "playwright";
const b = await chromium.launch();
const page = await b.newPage({ viewport: { width: 1280, height: 760 } });
await page.goto("http://127.0.0.1:8123/");
await page.waitForSelector("#drop-card");
await page.screenshot({ path: "/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/landing2.png" });
await b.close();
EOF
node probe3-tmp.mjs && rm probe3-tmp.mjs
(Bash completed with no output)
Read
{
  "file_path": "/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/landing2.png"
}
$ Bash
Commit trimmed landing + single CTA
cd /Users/phebert/pnk && git add viewer/index.html viewer/src/main.ts viewer/styles.css viewer/tests/gate.spec.ts && git commit -m "viewer: landing trimmed to a sentence; one open CTA, colored

Copy cut to the bone: tagline is 'view iWork files in your browser.
nothing is uploaded.', hint is 'encrypted and pre-2013 files are
refused.', drag overlay loses its reassurance subtitle.

The nav 'open…' button now appears only while a document (or error) is
showing — on the landing screen the card's own 'choose a file…' is the
single open affordance, and it's painted as the primary CTA (accent).

Gate 6/6 (hint assertion updated to the new copy).

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018a2hmoDhN4kBq1BwfbFdAU" && git push origin main 2>&1 | tail -1
4 files changed, 18 insertions(+), 12 deletions(-)
   95c7ada..a85b362  main -> main

Pushed as a85b362. The landing is now one sentence — "view iWork files in your browser. nothing is uploaded." — with the chips, the drop zone, and a six-word hint. The nav open… button only exists while a document or error is showing, so the accent-red "choose a file…" is the lone CTA dead-center on the landing. Gate 6/6 with the hint assertion updated.

can JSON view be a toggle that is pretty-printed and colored with a download available?

Thinking

Nice feature. Plan: json in the nav becomes a toggle to a pretty-printed, syntax-colored JSON panel (document DOM stays alive underneath), with a download button in the panel — coloring capped for multi-MB envelopes so the monster docs don't explode the DOM.

✏️ Edit index.html
/Users/phebert/pnk/viewer/index.html
    <main id="view" class="hidden"></main>
  </div>
+
    <main id="view" class="hidden"></main>

    <div id="json-view" class="hidden">
      <div id="json-toolbar">
        <span id="json-size" class="muted"></span>
        <span class="spacer"></span>
        <button id="json-dl-btn" type="button" title="Save the JSON model to a file">download</button>
      </div>
      <pre id="json-pre"></pre>
    </div>
  </div>
The file /Users/phebert/pnk/viewer/index.html has been updated successfully. (file state is current in your context — no need to Read it back)

Now main.ts — the toggle, lazy pretty-print + highlight, and download move:

✏️ Edit main.ts
/Users/phebert/pnk/viewer/src/main.ts
let ctx: ViewerCtx | null = null;
let lastJson: { text: string; filename: string } | null = null;

const $ = (id: string) => document.getElementById(id)!;

function showLanding(): void {
  ctx?.dispose();
  ctx = null;
  // the landing card's own CTA is the only "open" on the landing screen
  for (const id of ["doc-filename", "app-badge", "doc-meta", "warnings-dd", "json-btn", "reset-btn"]) {
    $(id).classList.add("hidden");
  }
  $("view").classList.add("hidden");
  $("view").replaceChildren();
  $("drop-zone").classList.remove("hidden");
}
+
let ctx: ViewerCtx | null = null;
let lastJson: { text: string; filename: string } | null = null;
let jsonRendered = false;

const $ = (id: string) => document.getElementById(id)!;

function closeJsonView(): void {
  jsonRendered = false;
  $("json-view").classList.add("hidden");
  $("json-pre").replaceChildren();
  $("json-btn").classList.remove("active");
}

function showLanding(): void {
  ctx?.dispose();
  ctx = null;
  closeJsonView();
  // the landing card's own CTA is the only "open" on the landing screen
  for (const id of ["doc-filename", "app-badge", "doc-meta", "warnings-dd", "json-btn", "reset-btn"]) {
    $(id).classList.add("hidden");
  }
  $("view").classList.add("hidden");
  $("view").replaceChildren();
  $("drop-zone").classList.remove("hidden");
}

// Above this many pretty-printed bytes, skip syntax coloring: a dense
// Numbers envelope runs to tens of MB and a span-per-token DOM would hang
// the tab. Plain <pre> text stays fast at any size.
const HIGHLIGHT_LIMIT = 3 * 1024 * 1024;

function highlightJson(pretty: string): string {
  const esc = pretty.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
  return esc.replace(
    /("(?:[^"\\]|\\.)*")(\s*:)?|\b(true|false|null)\b|-?\b\d+(?:\.\d+)?(?:[eE][+-]?\d+)?\b/g,
    (m, str: string | undefined, colon: string | undefined, kw: string | undefined) => {
      if (str !== undefined) {
        return colon !== undefined
          ? `<span class="j-key">${str}</span>${colon}`
          : `<span class="j-str">${str}</span>`;
      }
      if (kw !== undefined) return `<span class="j-kw">${kw}</span>`;
      return `<span class="j-num">${m}</span>`;
    },
  );
}

function toggleJsonView(): void {
  if (!lastJson) return;
  const panel = $("json-view");
  const showing = !panel.classList.contains("hidden");
  if (showing) {
    panel.classList.add("hidden");
    $("view").classList.remove("hidden");
    $("json-btn").classList.remove("active");
    return;
  }
  if (!jsonRendered) {
    jsonRendered = true;
    const pretty = JSON.stringify(JSON.parse(lastJson.text), null, 2);
    const kb = pretty.length / 1024;
    $("json-size").textContent = `${lastJson.filename} · ${kb >= 1024 ? (kb / 1024).toFixed(1) + " MB" : Math.ceil(kb) + " KB"} pretty-printed`;
    const pre = $("json-pre");
    if (pretty.length <= HIGHLIGHT_LIMIT) pre.innerHTML = highlightJson(pretty);
    else pre.textContent = pretty;
  }
  $("view").classList.add("hidden");
  panel.classList.remove("hidden");
  $("json-btn").classList.add("active");
}
The file /Users/phebert/pnk/viewer/src/main.ts has been updated successfully. (file state is current in your context — no need to Read it back)
✏️ Edit main.ts
/Users/phebert/pnk/viewer/src/main.ts
  renderHeader(doc, filename);
  setTableLocale(doc.meta.locale);
  renderWarnings(doc.warnings);

  const view = $("view");
+
  closeJsonView();
  renderHeader(doc, filename);
  setTableLocale(doc.meta.locale);
  renderWarnings(doc.warnings);

  const view = $("view");
The file /Users/phebert/pnk/viewer/src/main.ts has been updated successfully. (file state is current in your context — no need to Read it back)
✏️ Edit main.ts
/Users/phebert/pnk/viewer/src/main.ts
function showError(err: unknown, filename: string): void {
  lastJson = null;
  $("json-btn").classList.add("hidden");
  $("warnings-dd").classList.add("hidden");
+
function showError(err: unknown, filename: string): void {
  lastJson = null;
  closeJsonView();
  $("json-btn").classList.add("hidden");
  $("warnings-dd").classList.add("hidden");
The file /Users/phebert/pnk/viewer/src/main.ts has been updated successfully. (file state is current in your context — no need to Read it back)
✏️ Edit main.ts
/Users/phebert/pnk/viewer/src/main.ts
  // Download the converted JSON model (blob URL — still no network, no upload)
  $("json-btn").addEventListener("click", () => {
    if (!lastJson) return;
    const url = URL.createObjectURL(new Blob([lastJson.text], { type: "application/json" }));
    const a = document.createElement("a");
    a.href = url;
    a.download = lastJson.filename;
    a.click();
    URL.revokeObjectURL(url);
  });
+
  // json = toggle the pretty-printed model view; download lives in its toolbar
  $("json-btn").addEventListener("click", toggleJsonView);
  // Download the converted JSON model (blob URL — still no network, no upload)
  $("json-dl-btn").addEventListener("click", () => {
    if (!lastJson) return;
    const url = URL.createObjectURL(new Blob([lastJson.text], { type: "application/json" }));
    const a = document.createElement("a");
    a.href = url;
    a.download = lastJson.filename;
    a.click();
    URL.revokeObjectURL(url);
  });
The file /Users/phebert/pnk/viewer/src/main.ts has been updated successfully. (file state is current in your context — no need to Read it back)
Thinking

Now the CSS — token colors for light/dark, toolbar:

✏️ Edit styles.css
/Users/phebert/pnk/viewer/styles.css
/* ---- error card ---------------------------------------------------------- */
+
/* ---- JSON model view ------------------------------------------------------ */

#topnav button.active { background: var(--ink); color: var(--card); border-color: var(--ink); }
#json-toolbar {
  display: flex; align-items: center; gap: 10px;
  padding: 8px 2px; font-size: 12px;
  font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
}
#json-toolbar button {
  padding: 3px 12px; font-size: 12px; border-radius: 6px;
  font-family: inherit;
}
#json-pre {
  margin: 0; padding: 14px 16px;
  background: var(--card); border: 1px solid var(--line); border-radius: 10px;
  font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
  font-size: 12px; line-height: 1.5;
  overflow-x: auto; white-space: pre;
}
.j-key { color: #8250df; }
.j-str { color: #0a7d4d; }
.j-num { color: #1a6ec0; }
.j-kw  { color: var(--accent); }
@media (prefers-color-scheme: dark) {
  .j-key { color: #c197ff; }
  .j-str { color: #5fd39a; }
  .j-num { color: #6cb4f5; }
}

/* ---- error card ---------------------------------------------------------- */
The file /Users/phebert/pnk/viewer/styles.css has been updated successfully. (file state is current in your context — no need to Read it back)
$ Bash
Build, gate, screenshot JSON toggle
bash scripts/build_viewer.sh 2>&1 | tail -1 && cd viewer && npx tsc --noEmit && npm test 2>&1 | tail -2 && cat > probe4-tmp.mjs <<'EOF'
import { chromium } from "playwright";
const SCRATCH = "/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad";
const b = await chromium.launch();
const page = await b.newPage({ viewport: { width: 1280, height: 800 } });
await page.goto("http://127.0.0.1:8123/");
await page.setInputFiles("#file-input", "/Users/phebert/pnk/fixtures/golden/G2-golden-pages-layout.pages");
await page.waitForSelector("#view:not(.hidden)");
await page.click("#json-btn");
await page.waitForTimeout(400);
await page.screenshot({ path: `${SCRATCH}/json-view.png` });
await page.click("#json-btn");
await page.waitForTimeout(200);
const backToDoc = await page.evaluate(() => !document.getElementById("view").classList.contains("hidden"));
console.log("toggled back to doc:", backToDoc);
await b.close();
EOF
node probe4-tmp.mjs && rm probe4-tmp.mjs
viewer built: viewer/dist/  (serve: cd viewer && npm run serve)

  6 passed (1.5s)
toggled back to doc: true
Read
{
  "file_path": "/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/json-view.png"
}
Thinking
$ Bash
Commit JSON view toggle
cd /Users/phebert/pnk && git add viewer/index.html viewer/src/main.ts viewer/styles.css && git commit -m "viewer: json is now a toggled, colored model view with download inside

The nav 'json' button no longer fires a download — it toggles a
pretty-printed, syntax-colored view of the converted model (keys purple,
strings green, numbers blue, keywords accent; dark variants included).
The rendered document DOM stays alive underneath, so toggling back is
instant. The toolbar shows filename + pretty-printed size and carries
the download button (same blob URL, still no network).

Highlighting is capped at 3 MB of pretty text: a dense Numbers envelope
runs to tens of MB and a span-per-token DOM would hang the tab — big
models fall back to plain <pre>, fast at any size. Render is lazy (first
toggle) and cached per document.

Gate 6/6; toggle round-trip verified via Playwright.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018a2hmoDhN4kBq1BwfbFdAU" && git push origin main 2>&1 | tail -1
3 files changed, 97 insertions(+), 1 deletion(-)
   a85b362..f72882d  main -> main

Done, pushed as f72882d. The nav json button now toggles a pretty-printed, syntax-colored view of the model — purple keys, green strings, blue numbers, with dark-mode variants — and the button shows an active (inverted) state while it's open. The toolbar above the code shows the filename and pretty-printed size, and holds the download button (same local blob URL, no network).

Two engineering details worth knowing: the rendered document DOM stays alive underneath, so toggling back is instant even on a big deck; and syntax coloring caps at 3 MB of pretty text — a dense Numbers envelope can hit tens of MB, where a span-per-token DOM would hang the tab, so those fall back to plain (still pretty-printed) text. Render is lazy on first toggle and cached per document.