Claude Code transcript - page 11/11

[Image: original 13506x2137, displayed at 2000x316. Multiply coordinates by 6.75 to map to original image.]
Thinking

Our side is blank below the tabs — possible JS error with the new output. Checking the console:

$ Bash
Console errors on the blank doc
cd /Users/phebert/pnk/viewer && cat > probe7-tmp.mjs <<'EOF'
import { chromium } from "playwright";
const b = await chromium.launch();
const page = await b.newPage({ viewport: { width: 1400, height: 900 } });
page.on("console", (m) => { if (m.type() === "error") console.log("CONSOLE:", m.text().slice(0, 300)); });
page.on("pageerror", (e) => console.log("PAGEERROR:", String(e).slice(0, 500)));
await page.goto("http://127.0.0.1:8123/");
await page.setInputFiles("#file-input", "/Users/phebert/Development/pnk-fixtures/crawl/17891b89da2f381e1449442f3c118363fa621404a5602235cca476400bb88e63.numbers");
await page.waitForTimeout(5000);
const n = await page.locator("table.sheet-table").count();
console.log("tables rendered:", n);
await b.close();
EOF
node probe7-tmp.mjs && rm probe7-tmp.mjs
tables rendered: 1
Thinking
$ Bash
Count tables in January sheet JSON
cd /Users/phebert/pnk && ./target/release/pnk2json ~/Development/pnk-fixtures/crawl/17891b89da2f381e1449442f3c118363fa621404a5602235cca476400bb88e63.numbers 2>/dev/null | python3 -c "
import json,sys
d=json.load(sys.stdin); doc=d.get('document',d)
sh = doc['sheets'][0]
def count(dr, depth=0):
    n = 1 if dr.get('type')=='table' else 0
    for ch in dr.get('children',[]): n += count(ch, depth+1)
    return n
print('sheet1 name:', sh['name'], 'drawables:', len(sh['drawables']), 'tables:', sum(count(dr) for dr in sh['drawables']))
print('types:', [dr.get('type') for dr in sh['drawables']][:12])
"
sheet1 name: January drawables: 1 tables: 1
types: ['table']
Thinking

One wide table print-paginated by Apple into columns — the composite is just misleading for print layouts. Let me look at our actual table's borders directly:

$ Bash
Screenshot our table's top-left corner
cd /Users/phebert/pnk/viewer && cat > probe8-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: 1400, height: 900 } });
await page.goto("http://127.0.0.1:8123/");
await page.setInputFiles("#file-input", "/Users/phebert/Development/pnk-fixtures/crawl/17891b89da2f381e1449442f3c118363fa621404a5602235cca476400bb88e63.numbers");
await page.waitForSelector("table.sheet-table", { timeout: 30000 });
await page.waitForTimeout(500);
const t = page.locator("table.sheet-table").first();
await t.screenshot({ path: `${SCRATCH}/num4-table.png`, clip: undefined }).catch(async () => {});
await b.close();
EOF
node probe8-tmp.mjs && rm probe8-tmp.mjs && 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('num4-table.png'); print(im.size)
im.crop((0,0,min(700,im.width),min(300,im.height))).save('num4-crop.png')
"
(3095, 645)
Shell cwd was reset to /Users/phebert/pnk
Read
{
  "file_path": "/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/num4-crop.png"
}
Thinking

Borders look right — full header outlines and the outer left edge, matching Apple. Full gates and ship:

$ Bash
Build viewer, gate, conformance
bash scripts/build_viewer.sh 2>&1 | tail -1 && cd viewer && npx tsc --noEmit && npm test 2>&1 | tail -2 && cd .. && python3 scripts/conformance.py 2>&1 | tail -2
viewer built: viewer/dist/  (serve: cd viewer && npm run serve)

  6 passed (1.6s)

NO DEFECTS.
$ Bash
Screenshot final chrome states
cat > probe9-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.waitForSelector("#drop-card");
await page.screenshot({ path: `${SCRATCH}/final-landing.png` });
await page.setInputFiles("#file-input", "/Users/phebert/pnk/fixtures/golden/G2-golden-pages-layout.pages");
await page.waitForSelector("#view:not(.hidden)");
await page.click("#warnings-dd summary");
await page.waitForTimeout(200);
await page.screenshot({ path: `${SCRATCH}/final-warn.png`, clip: { x: 640, y: 0, width: 640, height: 260 } });
await b.close();
EOF
cd viewer && cp ../probe9-tmp.mjs . 2>/dev/null; mv probe9-tmp.mjs viewer/ && cd viewer && node probe9-tmp.mjs && rm probe9-tmp.mjs
Exit code 1
mv: rename probe9-tmp.mjs to viewer/: No such file or directory
$ Bash
Screenshot final chrome
cd /Users/phebert/pnk/viewer && cat > probe9-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.waitForSelector("#drop-card");
await page.screenshot({ path: `${SCRATCH}/final-landing.png` });
await page.setInputFiles("#file-input", "/Users/phebert/pnk/fixtures/golden/G2-golden-pages-layout.pages");
await page.waitForSelector("#view:not(.hidden)");
await page.click("#warnings-dd summary");
await page.waitForTimeout(200);
await page.screenshot({ path: `${SCRATCH}/final-warn.png`, clip: { x: 620, y: 0, width: 660, height: 280 } });
await b.close();
EOF
node probe9-tmp.mjs && rm probe9-tmp.mjs
(Bash completed with no output)
Read
{
  "file_path": "/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/final-landing.png"
}
Read
{
  "file_path": "/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/final-warn.png"
}
Thinking

All verified. Two commits:

$ Bash
Commit chrome tweaks + border fix, push
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: drop the refusal hint, octocat nav icon, roomier warnings dropdown

The landing loses its last footnote (the refusal copy lives in the error
cards where it matters; #drop-hint stays as the parser-load failure
slot). The nav github link becomes the octocat mark. The warnings
dropdown gets real padding and a rule between the per-code summary chips
and the detail rows.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018a2hmoDhN4kBq1BwfbFdAU" && git add crates/pnk2json/src/tables.rs && git commit -m "pnk2json: tables draw their outer frame — top/left edges were unowned

The default gridline strokes become section-style borders in half-open
form (each cell owns right+bottom) so sidecar erases stay one-owner-per-
edge — but that leaves boundary 0, the table's TOP and LEFT outer edge,
with no owner: corpus scan found dozens of docs whose row-0 cells carry
zero top borders while every interior boundary is stroked (the missing
top/left borders Peter spotted).

Apple draws the frame with dedicated strokes: table_body_horizontal/
vertical_border_stroke (f58/f59, gated table_border_visible f38) and the
header region's outer edge with header_row/column_border_stroke (f47/
f50, gated f39) [proto TSTStylePropertyArchiving.proto:70-101]. Bake
them onto boundary cells as the weakest per-cell overlay — the stroke
sidecar's explicit edges and erases still win — including bottom/right,
which previously borrowed the interior gridline stroke.

Corpus rescan: zero tables left with unowned outer edges. Verified by
eye vs Apple (05377030 template, 17891b89 budget). Goldens byte-stable,
conformance NO DEFECTS, 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
4 files changed, 12 insertions(+), 10 deletions(-)
1 file changed, 62 insertions(+)
   38c264c..09cb423  main -> main

All four done, pushed as a95c79d + 09cb423:

  • Hint removed — the landing is down to the wordmark, one sentence, chips, and the drop zone. (#drop-hint survives invisibly as the parser-load-failure slot.)
  • Octocat — the nav github link is now the mark itself, muted until hover.
  • Warnings dropdown — proper padding, and a divider between the per-code summary chips and the detail rows.
  • Numbers borders — real converter bug, confirmed and fixed. Your "sometimes" was exactly right: the default gridlines are emitted half-open (each cell owns its right+bottom edge, so sidecar border-erases keep a single owner per edge) — which left the table's top and left outer boundary owned by nobody. A corpus scan found dozens of docs with zero top borders on row 0 while every interior boundary was stroked. Apple draws the frame with dedicated strokes I hadn't been reading — table_body_*_border_stroke (f58/f59, gated by table_border_visible) plus header-region border strokes (f47/f50). Those now bake onto boundary cells as the weakest overlay, so explicit sidecar strokes and erases still win. Bottom/right outer edges also now use the true frame stroke instead of borrowing the interior gridline.

Verified: corpus rescan shows zero tables left with unowned outer edges, spot-checked by eye against Apple's PDF on two flagged docs, goldens byte-stable, conformance NO DEFECTS, gate 6/6.

One thing I noticed en route, for the backlog: Apple's PDF export print-paginates wide Numbers sheets into page columns, so visual_diff composites for such docs pair one long viewer screenshot against many narrow pages — misleading but harmless. A future harness tweak could tile our screenshot to Apple's print geometry.

on mobile maybe remove the keynote left-side slide selector and make sure the numbers tabs render OK, pages docs default to full-width?

Thinking

Let me check how Pages sizes its pages and what the Numbers tabs do, then write the mobile block:

$ Bash
Pages sizing + relevant CSS
grep -n "pages-wp-page\|pageSize\|width" /Users/phebert/pnk/viewer/src/pages.ts | head -20; grep -n "pages-flow\|pages-wp-page\|pages-print\|sheet-tabs\|sheet-area" /Users/phebert/pnk/viewer/styles.css
87:  if (doc.pageSize) {
88:    frame.style.aspectRatio = `${doc.pageSize.width} / ${doc.pageSize.height}`;
91:    // scale: pages render at a fixed 720px logical width, scaled responsively
92:    const scale = 720 / doc.pageSize.width;
93:    inner.style.width = `${doc.pageSize.width}px`;
94:    inner.style.height = `${doc.pageSize.height}px`;
99:    inner.style.marginLeft = `${-doc.pageSize.width * scale / 2}px`;
104:    frame.style.height = `${doc.pageSize.height * scale}px`;
110:    inner.style.width = "720px";
146: *  (TP.DocumentArchive page_width/height 30/31, margins 32-35 [proto]). */
157:  const ps = doc.pageSize;
158:  if (!ps || !(ps.width > 0) || !(ps.height > 0)) return null;
164:  const contentW = ps.width - left - right;
167:  return { w: ps.width, h: ps.height, left, top, contentW, contentH };
186: * width, then greedily packed into page frames of the printable height.
192: * column-by-column (column-fill: auto), measured at the column width and
210:  const pageSizedDrawables = (p: (typeof body.paragraphs)[number]): Drawable[] | null => {
211:    let pageSized = false;
219:      if (sz.width >= g.w * 0.85 && sz.height >= g.h * 0.85) pageSized = true;
221:    return pageSized ? positioned : null;
29:  body .sheet-area, body .pages-flow, body .notes-panel, body .error-card { color: #1d1d1f; }
30:  body .sheet-area, body .pages-flow { background: #ffffff; border-color: #d2d2d7; }
348:.sheet-tabs { display: flex; gap: 6px; flex-wrap: wrap; margin: 14px 0; }
354:.sheet-area { background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 18px; overflow: auto; }
370:.pages-flow {
374:.pages-flow h1, .pages-flow h2, .pages-flow h3,
375:.pages-flow h4, .pages-flow h5, .pages-flow h6 { margin: 0.6em 0 0.3em; }
376:.pages-flow p { margin: 0.4em 0; }
392:/* Word-processing pagination: .pages-print is the printable area inside a
397:.pages-print { display: flow-root; color: #1d1d1f; line-height: 1.2; font-size: 11px; }
398:.pages-print p { margin: 0; }
399:.pages-print h1, .pages-print h2, .pages-print h3,
400:.pages-print h4, .pages-print h5, .pages-print h6 { margin: 0; }
401:.pages-wp-page { margin: 18px auto; max-width: 720px; }
411:.pages-print p, .pages-print h1, .pages-print h2, .pages-print h3,
412:.pages-print h4, .pages-print h5, .pages-print h6,
413:.pages-print .list-item { white-space: pre-wrap; tab-size: 36px; }
430:.pages-print .field[data-field-kind="footnote-mark"] { vertical-align: super; font-size: 0.7em; }
Read
{
  "file_path": "/Users/phebert/pnk/viewer/src/pages.ts",
  "offset": 80,
  "limit": 40
}
80	  ctx: ViewerCtx,
81	  drawables: Drawable[],
82	  pageIndex: number | undefined,
83	  templateDrawables?: Drawable[],
84	): HTMLElement {
85	  const frame = document.createElement("div");
86	  frame.className = "canvas-frame pages-page";
87	  if (doc.pageSize) {
88	    frame.style.aspectRatio = `${doc.pageSize.width} / ${doc.pageSize.height}`;
89	    const inner = document.createElement("div");
90	    inner.className = "canvas-inner";
91	    // scale: pages render at a fixed 720px logical width, scaled responsively
92	    const scale = 720 / doc.pageSize.width;
93	    inner.style.width = `${doc.pageSize.width}px`;
94	    inner.style.height = `${doc.pageSize.height}px`;
95	    inner.style.transform = `scale(${scale})`;
96	    inner.style.position = "absolute";
97	    inner.style.top = "0";
98	    inner.style.left = "50%";
99	    inner.style.marginLeft = `${-doc.pageSize.width * scale / 2}px`;
100	    // template underlay paints first, beneath the page's own drawables
101	    for (const d of templateDrawables ?? []) inner.appendChild(renderCanvasDrawable(d, hdoc, ctx));
102	    for (const d of drawables) inner.appendChild(renderCanvasDrawable(d, hdoc, ctx));
103	    frame.appendChild(inner);
104	    frame.style.height = `${doc.pageSize.height * scale}px`;
105	    frame.dataset.pageIndex = pageIndex === undefined ? "" : String(pageIndex);
106	  } else {
107	    const inner = document.createElement("div");
108	    inner.className = "canvas-inner";
109	    inner.style.position = "relative";
110	    inner.style.width = "720px";
111	    inner.style.minHeight = "400px";
112	    for (const d of templateDrawables ?? []) inner.appendChild(renderCanvasDrawable(d, hdoc, ctx));
113	    for (const d of drawables) inner.appendChild(renderCanvasDrawable(d, hdoc, ctx));
114	    frame.appendChild(inner);
115	  }
116	  return frame;
117	}
118	
119	function floatingSection(
Thinking
$ Bash
WP page sizing code
grep -n "pages-wp-page\|pages-print\|style.width\|transform" /Users/phebert/pnk/viewer/src/pages.ts | head -20
93:    inner.style.width = `${doc.pageSize.width}px`;
95:    inner.style.transform = `scale(${scale})`;
110:    inner.style.width = "720px";
263:  // transform scaling does not affect layout metrics, so these heights match
282:      wrap.className = "pages-print";
392:    frame.className = "canvas-frame pages-page pages-wp-page";
401:    inner.style.width = `${g.w}px`;
403:    inner.style.transform = `scale(${scale})`;
419:    content.className = "pages-print";
423:    content.style.width = `${g.contentW}px`;
444:      area.className = "pages-footnote-area pages-print";
446:      area.style.width = `${g.contentW}px`;
480:        h.style.width = `${g.contentW}px`;
487:        f.style.width = `${g.contentW}px`;
Read
{
  "file_path": "/Users/phebert/pnk/viewer/src/pages.ts",
  "offset": 385,
  "limit": 30
}
385	      footnotesByPage.set(pg, list);
386	    });
387	  }
388	
389	  const scale = 720 / g.w;
390	  for (let i = 0; i < pageCount; i++) {
391	    const frame = document.createElement("div");
392	    frame.className = "canvas-frame pages-page pages-wp-page";
393	    frame.style.aspectRatio = `${g.w} / ${g.h}`;
394	    frame.style.height = `${g.h * scale}px`;
395	    // clip at the paper edge like Pages (full-bleed covers store images
396	    // taller than the page: 00C's is 1155pt on a 1024pt page)
397	    frame.style.overflow = "hidden";
398	    frame.dataset.pageIndex = String(i);
399	    const inner = document.createElement("div");
400	    inner.className = "canvas-inner";
401	    inner.style.width = `${g.w}px`;
402	    inner.style.height = `${g.h}px`;
403	    inner.style.transform = `scale(${scale})`;
404	    inner.style.position = "absolute";
405	    inner.style.top = "0";
406	    inner.style.left = "50%";
407	    inner.style.marginLeft = `${-g.w * scale / 2}px`;
408	
409	    // floating drawables first: behind the body text, like Apple's default
410	    for (const d of floatingByPage.get(i) ?? []) {
411	      inner.appendChild(renderCanvasDrawable(d, hdoc, ctx));
412	    }
413	    // full-bleed cover drawables paint at page coordinates (not inset)
414	    for (const d of fullBleedByPage.get(i) ?? []) {
Thinking
Thinking

I found both flavors hardcode a 720px display width, so I'll make it responsive using min(720, container) since pagination is measured in page points independent of display scale. Checking the call structure now.

$ Bash
renderPages structure + 720 sites
grep -n "export function renderPages\|function floatingSection\|function wordProcessing\|floatingSection(\|720" /Users/phebert/pnk/viewer/src/pages.ts | head
91:    // scale: pages render at a fixed 720px logical width, scaled responsively
92:    const scale = 720 / doc.pageSize.width;
110:    inner.style.width = "720px";
119:function floatingSection(
389:  const scale = 720 / g.w;
508:export function renderPages(doc: PagesDocument, hdoc: HydratedDoc, ctx: ViewerCtx, mount: HTMLElement): void {
531:    floatingSection(doc, hdoc, ctx, view, leading, wordProcessing ? "Cover page" : "Pages");
544:    floatingSection(doc, hdoc, ctx, view, trailing, "Floating objects");
Read
{
  "file_path": "/Users/phebert/pnk/viewer/src/pages.ts",
  "offset": 508,
  "limit": 45
}
508	export function renderPages(doc: PagesDocument, hdoc: HydratedDoc, ctx: ViewerCtx, mount: HTMLElement): void {
509	  const view = document.createElement("div");
510	  view.id = "pages-view";
511	
512	  const wordProcessing = doc.flavor === "word-processing";
513	  const geom = pageGeom(doc);
514	
515	  if (wordProcessing && doc.body && geom) {
516	    // paginated word-processing render: page frames + margins. Footnotes
517	    // render at their anchor pages' bottoms unless the document asks for
518	    // endnotes (footnotePlacement: section/document endnotes).
519	    paginatedBody(doc, hdoc, ctx, view);
520	    if (doc.footnotePlacement) appendFootnotes(doc, hdoc, ctx, view);
521	    mount.appendChild(view);
522	    applyTextFit(view);
523	    return;
524	  }
525	
526	  // document order: floating groups anchored to page 1 (a cover) belong
527	  // above the flowing body; later pages trail it
528	  const leading = doc.floating.filter((g) => (g.pageIndex ?? 0) === 0);
529	  const trailing = doc.floating.filter((g) => (g.pageIndex ?? 0) !== 0);
530	  if (!wordProcessing || doc.body) {
531	    floatingSection(doc, hdoc, ctx, view, leading, wordProcessing ? "Cover page" : "Pages");
532	  }
533	
534	  if (wordProcessing && doc.body) {
535	    const flow = document.createElement("article");
536	    flow.className = "pages-flow";
537	    const listState = newListNumberingState();
538	    for (const p of doc.body.paragraphs) flow.appendChild(renderParagraph(p, hdoc, ctx, listState));
539	    view.appendChild(flow);
540	    appendFootnotes(doc, hdoc, ctx, view);
541	  }
542	
543	  if (trailing.length > 0) {
544	    floatingSection(doc, hdoc, ctx, view, trailing, "Floating objects");
545	  }
546	  mount.appendChild(view);
547	  // measurement pass (attached): bounded shrink absorbs font-metric drift
548	  applyTextFit(view);
549	}
550	
551	function appendFootnotes(doc: PagesDocument, hdoc: HydratedDoc, ctx: ViewerCtx, view: HTMLElement): void {
552	  if (!doc.footnotes?.length) return;
Thinking
✏️ Edit pages.ts
/Users/phebert/pnk/viewer/src/pages.ts
  if (doc.pageSize) {
    frame.style.aspectRatio = `${doc.pageSize.width} / ${doc.pageSize.height}`;
    const inner = document.createElement("div");
    inner.className = "canvas-inner";
    // scale: pages render at a fixed 720px logical width, scaled responsively
    const scale = 720 / doc.pageSize.width;
+
  if (doc.pageSize) {
    frame.style.aspectRatio = `${doc.pageSize.width} / ${doc.pageSize.height}`;
    const inner = document.createElement("div");
    inner.className = "canvas-inner";
    // scale: pages render at a 720px logical width, or the container's on
    // narrow (mobile) screens — display-only, layout metrics are in pt
    const scale = pageDisplayWidth / doc.pageSize.width;
The file /Users/phebert/pnk/viewer/src/pages.ts has been updated successfully. (file state is current in your context — no need to Read it back)
✏️ Edit pages.ts
/Users/phebert/pnk/viewer/src/pages.ts
    inner.style.position = "relative";
    inner.style.width = "720px";
+
    inner.style.position = "relative";
    inner.style.width = `${pageDisplayWidth}px`;
The file /Users/phebert/pnk/viewer/src/pages.ts has been updated successfully. (file state is current in your context — no need to Read it back)
✏️ Edit pages.ts
/Users/phebert/pnk/viewer/src/pages.ts
  const scale = 720 / g.w;
  for (let i = 0; i < pageCount; i++) {
+
  const scale = pageDisplayWidth / g.w;
  for (let i = 0; i < pageCount; i++) {
The file /Users/phebert/pnk/viewer/src/pages.ts has been updated successfully. (file state is current in your context — no need to Read it back)
✏️ Edit pages.ts
/Users/phebert/pnk/viewer/src/pages.ts
export function renderPages(doc: PagesDocument, hdoc: HydratedDoc, ctx: ViewerCtx, mount: HTMLElement): void {
  const view = document.createElement("div");
  view.id = "pages-view";
+
/** Display width for a page frame: 720px logical, or the container width on
 *  narrow screens so pages default to full width on mobile. Set per render
 *  from the mount; pagination itself measures in page POINTS regardless. */
let pageDisplayWidth = 720;

export function renderPages(doc: PagesDocument, hdoc: HydratedDoc, ctx: ViewerCtx, mount: HTMLElement): void {
  pageDisplayWidth = Math.max(280, Math.min(720, mount.clientWidth || 720));
  const view = document.createElement("div");
  view.id = "pages-view";
The file /Users/phebert/pnk/viewer/src/pages.ts has been updated successfully. (file state is current in your context — no need to Read it back)

Now the mobile CSS block:

$ Bash
End of stylesheet + sheet-tab rule
tail -8 /Users/phebert/pnk/viewer/styles.css; grep -n "\.sheet-tab {" -A 3 /Users/phebert/pnk/viewer/styles.css
.drawable-text h4, .drawable-text h5, .drawable-text h6,
.drawable-text .list-item { white-space: pre-wrap; tab-size: 36px; }
/* Apple has no implicit paragraph margins in shape/textbox text either —
   the UA's 1em <p> margin read as a phantom blank line between consecutive
   paragraphs (G2's caption box). Spacing comes only from spaceBefore/After
   on the paragraph style. */
.drawable-text p, .drawable-text h1, .drawable-text h2, .drawable-text h3,
.drawable-text h4, .drawable-text h5, .drawable-text h6 { margin: 0; }
349:.sheet-tab {
350-  border-radius: 8px 8px 0 0; border-bottom: 3px solid transparent; padding: 6px 16px;
351-}
352-.sheet-tab.active { border-bottom-color: var(--accent); font-weight: 600; background: var(--card); }
$ Bash
Append mobile CSS, build, gate
cat >> /Users/phebert/pnk/viewer/styles.css <<'EOF'

/* ---- mobile --------------------------------------------------------------- */

@media (max-width: 700px) {
  #app { padding: 8px; }
  /* Keynote: the thumbnail rail eats half the screen — the continuous
     scroll IS the navigation on a phone. */
  #keynote-view .slide-list { display: none; }
  /* Numbers: sheet tabs stay one row, scrolled horizontally. */
  .sheet-tabs { flex-wrap: nowrap; overflow-x: auto; }
  .sheet-tab { flex: 0 0 auto; white-space: nowrap; padding: 6px 12px; }
  .sheet-area { padding: 10px; }
  /* Pages: page frames already size to the container (pages.ts
     pageDisplayWidth); trim the chrome around them. */
  .pages-flow { padding: 20px 16px; }
  .canvas-outer { margin: 10px 0; }
  #drop-card { padding: 24px 18px; }
}
EOF
bash scripts/build_viewer.sh 2>&1 | tail -1 && cd viewer && npx tsc --noEmit && npm test 2>&1 | tail -2
viewer built: viewer/dist/  (serve: cd viewer && npm run serve)

  6 passed (1.6s)
Thinking

The old #warnings-list { padding: 6px 0 } rule at line 212 (from the panel era) overrides my new dropdown padding since it comes later — removing the stale rule:

✏️ Edit styles.css
/Users/phebert/pnk/viewer/styles.css
#warnings-list { padding: 6px 0; }
.warning-row { display: flex; gap: 10px; padding: 3px 0; border-top: 1px solid var(--surface); }
+
.warning-row { display: flex; gap: 10px; padding: 3px 0; border-top: 1px solid var(--surface); }
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
Mobile screenshots for all three apps
bash scripts/build_viewer.sh 2>&1 | tail -1 && cd viewer && cat > probem-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: 390, height: 844 }, isMobile: true, hasTouch: true, deviceScaleFactor: 2 });
await page.goto("http://127.0.0.1:8123/");
await page.waitForSelector("#drop-card");
// keynote
await page.setInputFiles("#file-input", "/Users/phebert/Development/pnk-fixtures/crawl/0d5851c0105958b602c58646b98ffaee1b453def8478f9b872aca37319d2ed5f.key");
await page.waitForSelector("#keynote-view", { timeout: 30000 });
await page.waitForTimeout(1500);
await page.screenshot({ path: `${SCRATCH}/mob-key.png` });
// numbers (many sheets)
await page.setInputFiles("#file-input", "/Users/phebert/Development/pnk-fixtures/crawl/17891b89da2f381e1449442f3c118363fa621404a5602235cca476400bb88e63.numbers");
await page.waitForSelector("#numbers-view", { timeout: 30000 });
await page.waitForTimeout(1500);
await page.screenshot({ path: `${SCRATCH}/mob-num.png` });
// pages WP
await page.setInputFiles("#file-input", "/Users/phebert/Development/pnk-fixtures/crawl/1eb960ba4ad8f155d70037395ca50b28b3993cf82d03a3d787daced092ddbd54.pages");
await page.waitForSelector("#pages-view", { timeout: 30000 });
await page.waitForTimeout(1200);
await page.screenshot({ path: `${SCRATCH}/mob-pages.png` });
await b.close();
EOF
node probem-tmp.mjs && rm probem-tmp.mjs
Exit code 1
bash: scripts/build_viewer.sh: No such file or directory
(eval):cd:1: no such file or directory: viewer
node:internal/modules/cjs/loader:1433
  throw err;
  ^

Error: Cannot find module '/Users/phebert/pnk/viewer/probem-tmp.mjs'
    at Function._resolveFilename (node:internal/modules/cjs/loader:1430:15)
    at defaultResolveImpl (node:internal/modules/cjs/loader:1040:19)
    at resolveForCJSWithHooks (node:internal/modules/cjs/loader:1045:22)
    at Function._load (node:internal/modules/cjs/loader:1216:25)
    at wrapModuleLoad (node:internal/modules/cjs/loader:254:19)
    at Function.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:171:5)
    at node:internal/main/run_main_module:36:49 {
  code: 'MODULE_NOT_FOUND',
  requireStack: []
}

Node.js v22.23.2
$ Bash
Build then mobile screenshots
cd /Users/phebert/pnk && bash scripts/build_viewer.sh 2>&1 | tail -1 && cd viewer && cat > probem-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: 390, height: 844 }, isMobile: true, hasTouch: true, deviceScaleFactor: 2 });
await page.goto("http://127.0.0.1:8123/");
await page.waitForSelector("#drop-card");
await page.setInputFiles("#file-input", "/Users/phebert/Development/pnk-fixtures/crawl/0d5851c0105958b602c58646b98ffaee1b453def8478f9b872aca37319d2ed5f.key");
await page.waitForSelector("#keynote-view", { timeout: 30000 });
await page.waitForTimeout(1500);
await page.screenshot({ path: `${SCRATCH}/mob-key.png` });
await page.setInputFiles("#file-input", "/Users/phebert/Development/pnk-fixtures/crawl/17891b89da2f381e1449442f3c118363fa621404a5602235cca476400bb88e63.numbers");
await page.waitForSelector("#numbers-view", { timeout: 30000 });
await page.waitForTimeout(1500);
await page.screenshot({ path: `${SCRATCH}/mob-num.png` });
await page.setInputFiles("#file-input", "/Users/phebert/Development/pnk-fixtures/crawl/1eb960ba4ad8f155d70037395ca50b28b3993cf82d03a3d787daced092ddbd54.pages");
await page.waitForSelector("#pages-view", { timeout: 30000 });
await page.waitForTimeout(1200);
await page.screenshot({ path: `${SCRATCH}/mob-pages.png` });
await b.close();
EOF
node probem-tmp.mjs && rm probem-tmp.mjs
viewer built: viewer/dist/  (serve: cd viewer && npm run serve)
$ Bash
Check if screenshots ran, retry if needed
ls -la /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/mob-*.png 2>/dev/null; ls probem-tmp.mjs 2>/dev/null && node probem-tmp.mjs && rm probem-tmp.mjs
Exit code 1
-rw-r--r--@ 1 phebert  wheel  381383 Aug 30 12:58 /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/mob-key.png
-rw-r--r--@ 1 phebert  wheel   71085 Aug 30 12:58 /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/mob-num.png
-rw-r--r--@ 1 phebert  wheel  252980 Aug 30 12:58 /private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/mob-pages.png
Read
{
  "file_path": "/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/mob-key.png"
}
Read
{
  "file_path": "/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/mob-num.png"
}
Read
{
  "file_path": "/private/tmp/claude-501/-Users-phebert-pnk/454dda24-911e-4ddc-a681-9413ce6505b1/scratchpad/mob-pages.png"
}
Thinking

All three verified on a 390px viewport. Gate passed pre-screenshot; committing:

$ Bash
Commit mobile pass
cd /Users/phebert/pnk && git add viewer/src/pages.ts viewer/styles.css && git commit -m "viewer: mobile pass — no keynote rail, scrollable sheet tabs, full-width pages

Under 700px: the Keynote thumbnail rail disappears (the continuous
scroll IS the navigation on a phone), Numbers sheet tabs keep one row
and scroll horizontally, and chrome padding tightens.

Pages frames stop hardcoding a 720px display width: pageDisplayWidth =
min(720, container) measured per render, so documents fill a phone
screen edge-to-edge. Display-only — pagination still measures in page
points, so page breaks are identical at every size. Also removed a stale
warnings-panel padding rule that was overriding the new dropdown's.

Verified at 390×844 (iPhone-ish) on the spamhaus deck, the budget
spreadsheet, and the ATM-DOMINA word-processing doc; 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
2 files changed, 29 insertions(+), 5 deletions(-)
   09cb423..6985d83  main -> main

Pushed as 6985d83. On screens under 700px:

  • Keynote: the thumbnail rail is gone — the continuous scroll is the navigation, and slides run full-width.
  • Numbers: sheet tabs stay on a single row and scroll horizontally instead of wrapping into a stack; sheet-area padding tightens.
  • Pages: page frames no longer hardcode a 720px display width — they now size to min(720, container), so documents fill a phone edge-to-edge. This is display-only scaling: pagination still measures in page points, so page breaks are identical at every screen size. Desktop is unchanged (container > 720).

I verified all three by screenshot at 390×844 — the Spamhaus deck scrolls cleanly slide-by-slide, the budget spreadsheet shows its tab strip and bordered table properly, and the ATM word-processing doc reads full-width. Also caught and removed a stale CSS rule from the old warnings-panel era that was silently overriding the new dropdown's padding.

One caveat worth knowing: the mobile width is measured at render time, so rotating a phone (or resizing a window across the breakpoint) after a document is open keeps the old page scale until the file is reopened. Fine for a hackathon demo; a resize listener is a small follow-up if you want it.

we're going to have to freeze fixes for submission, can you button up README and other documentation? Be concise, avoid narrative, do NOT delve into overwrought LLM load-bearing Claudlish please.

Thinking
$ Bash
Survey existing docs
ls *.md docs/*.md 2>/dev/null && wc -l README.md 2>/dev/null; cat README.md 2>/dev/null | head -60
AGENTS.md
CLAUDE.md
docs/CONFORMANCE.md
docs/model-design.md
docs/model-review.md
README.md
      83 README.md
# pnk

**Client-side Apple iWork document viewer** — open `.pages`, `.numbers`, and
`.key` files entirely in your browser. Drop a file, it's parsed in-page from the
raw file bytes by Rust compiled to WebAssembly, rendered as JSON, and displayed.
No account, no login, no email, no upload — nothing ever leaves your machine.

Built for [Hackyard Yard #1](https://hackyard.tech/yards/yard-1) ("no accounts",
48-hour solo build).

## What it does

- **Drag & drop** a Keynote / Numbers / Pages document (iWork '13 and newer,
  flat file or package directory) onto the page.
- It is parsed **in your browser** by a Rust pipeline compiled to WebAssembly:
  ZIP container → Snappy-compressed IWA streams → protobuf (TSP) object
  database → a typed JSON document model.
- The viewer renders that JSON: Keynote slides with positioned shapes, images
  and presenter notes; Numbers sheets with styled tables; Pages word-processing
  flow with headings, paragraphs, and floating covers.
- Friendly error cards for the things it deliberately refuses: password-protected
  files (`.iwph` / `.iwpv2` — we never ask for a password) and legacy pre-2013
  iWork formats.
- **Zero network calls after load** — asserted in the Playwright gate. No
  accounts, no analytics, no server.

## Quick start

```bash
# viewer (TS + esbuild, no framework)
cd viewer && npm install && npm run build && npm run serve

# CLI converters
cargo build --release
target/release/pnk2json document.pages > out.json      # compact JSON
target/release/pnk2json document.numbers --markdown    # readable fallback
```

Playwright gate: `cd viewer && npm install && npm run build && npm test`.

## Repo layout

| path | what |
| --- | --- |
| `crates/iwadump` | Rust CLI structure inspector for iWork '13+ documents |
| `crates/pnk2json` | converter lib + wasm binding + text/markdown dumpers |
| `viewer/` | vanilla-TS web app consuming `pnk2json.wasm` |
| `model/src/` | the TypeScript contract the JSON output obeys (strict tsc) |
| `docs/format/` | provenance-tagged iWork format reference — start at `INDEX.md` |
| `docs/CONFORMANCE.md` | how correctness is proven; corpus + cross-validation results |
| `scripts/` | research tooling, conformance + cross-validation harnesses |
| `fixtures/` | local corpus (gitignored; `provenance.json` committed) |

## How the format works (short version)

An iWork '13+ file is a ZIP whose `Index.zip` holds `.iwa` members: sequences
of Snappy-compressed blocks wrapping protobuf messages — a small object database
(TSWP text, TST tables, TSD drawables, TSCH charts, TSCE formulas, KN Keynote,
TSP shared storage). We decode it from scratch in Rust; every format claim in
`docs/format/` is provenance-tagged (`proto` / `parser` / `fixture-verified` /
$ Bash
Rest of README + doc heads
sed -n 60,83p README.md; head -30 docs/CONFORMANCE.md; head -25 docs/model-design.md; ls docs/format/ | head
`docs/format/` is provenance-tagged (`proto` / `parser` / `fixture-verified` /
`inferred`). Start at [`docs/format/INDEX.md`](docs/format/INDEX.md).

## Verification

- `scripts/conformance.py` — the whole corpus × JSON + markdown: 2,488 ok,
  8 controlled encrypted rejects, 0 defects; timing stays linear.
- `scripts/crossval.py` — every file's embedded QuickLook preview (rendered by
  Apple's own importer) is compared against our output: table censuses, text
  tokens, empty-grid detection. 960/968 clean; every flag investigated.
- Viewer gate: one real fixture per app, encrypted + legacy error cards, and an
  assertion that **zero non-blob network requests** occur after load — the
  no-upload theme, enforced by test.

## Status

Built for Hackyard Yard #1 (2026-08-28/30). See `docs/CONFORMANCE.md` for the
reliability work and `docs/model-design.md` for the JSON model rationale.

## License

Dual-licensed under [MIT](LICENSE-MIT) or [Apache-2.0](LICENSE-APACHE), at your
option. Format research is documented with per-claim provenance and source
commit hashes in `docs/format/`.
# Conformance & Reliability — pnk2json

How we prove the converter is correct and fast, and the specs for hand-built
golden fixtures (built by a human in the real apps, asserted in tests).

## 1. Corpus harness

```bash
cargo build --release
python3 scripts/conformance.py --mode both   # json + markdown over all of success.tsv
```

Verdicts per (fixture, mode): `ok`, `ok:encrypted-reject` (exit 1 with
password/encryption wording), or defects (`DEFECT:*`, `TIMEOUT`, `PANIC`,
`MISSING_FILE`). JSON report lands in `fixtures/conformance-report.json`
(regenerable, not committed).

### Corpus health (2026-08-28, CC-MAIN-2026-34, 1,248 files × 2 modes)

| metric | value |
| --- | --- |
| verdicts | 2,488 ok + 8 ok:encrypted-reject (4 files × 2 modes) |
| defects | **0** (no panics, no timeouts, no unexpected rejects) |
| wall (json mode) | 24.3 s total; median 8.3 ms; p95 39.5 ms; max 2.59 s |
| throughput | median 241 MB/s of input |
| time↔size Pearson r | 0.30 — **no super-linear timing** |

### Findings

1. **JSON amplification on table-heavy Numbers files** — the one reliability
# pnk JSON Document Model — Design Notes

The JSON the pnk pipeline emits (Rust `pnk2json` → TS viewer). The model is for a
**reader/viewer, not an editor**: everything is resolved, flattened, and
self-contained. Source of truth for the format is `docs/format/` (start at
`INDEX.md`); this doc says how that format maps onto the model in
`model/src/*.ts`.

Files:

| file | contents |
|---|---|
| `model/src/primitives.ts` | colors, geometry, fills/shadows/strokes, curve primitives, resolved text styles, units + conventions |
| `model/src/shared.ts` | root envelope, TSWP text model, TSD drawable union, TST table model, TSCH chart model, TSCE placeholder |
| `model/src/pages.ts` | `PagesDocument` — both flavors (word-processing / page-layout) |
| `model/src/numbers.ts` | `NumbersDocument` — sheets as canvases |
| `model/src/keynote.ts` | `KeynoteDocument` — show → slides, masters resolved |

---

## 1. Conventions

### 1.1 Units (everywhere, no exceptions)

| quantity | unit | JSON shape | source |
ATTRIBUTION.md
calcengine.md
charts.md
container.md
drawables.md
gotchas.md
incremental.md
INDEX.md
iwa.md
keynote.md
Thinking
$ Bash
Check build scripts, licenses, commit count
cat viewer/package.json | head -20; head -20 scripts/build_viewer.sh; ls LICENSE* 2>/dev/null; git log --oneline | wc -l
{
  "name": "pnk-viewer",
  "version": "0.1.0",
  "private": true,
  "description": "Zero-backend in-browser viewer for iWork documents (.pages/.numbers/.key) via pnk2json.wasm",
  "type": "module",
  "scripts": {
    "build": "bash ../scripts/build_viewer.sh",
    "serve": "node_modules/.bin/esbuild --servedir=dist --serve=127.0.0.1:8123 --log-level=warning",
    "typecheck": "tsc --noEmit -p tsconfig.json",
    "test": "npm run typecheck && playwright test"
  },
  "devDependencies": {
    "@playwright/test": "1.62.1",
    "esbuild": "^0.25.0"
  }
}#!/usr/bin/env bash
# Build the pnk viewer (viewer/ -> viewer/dist/), fully static:
#   1. cargo builds pnk2json-wasm for wasm32-unknown-unknown
#   2. wasm-bindgen --target web emits JS glue + .wasm into viewer/dist/wasm
#   3. the generated glue is vendored into viewer/src/wasm/ (committed; the
#      tiny JS file is deterministic for a given wasm-bindgen version) so
#      esbuild can bundle `import init from "./wasm/pnk2json_wasm.js"`
#   4. esbuild bundles viewer/src/main.ts -> viewer/dist/main.js
#   5. the static shell (index.html, styles.css) is copied to viewer/dist/
#
# Prerequisites: cargo, wasm-bindgen 0.2.127 on PATH; `npm install` run once
# inside viewer/ (esbuild + playwright devDependencies).
#
# Output layout (viewer/dist/): index.html  styles.css  main.js  wasm/*.wasm
set -euo pipefail

ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"

ESBUILD="$ROOT/viewer/node_modules/.bin/esbuild"
LICENSE-APACHE
LICENSE-MIT
     204
Thinking

npm run build already wraps the full wasm build, so the quick start stands. Rewriting the README tight and current:

Read
{
  "file_path": "/Users/phebert/pnk/README.md",
  "offset": 1,
  "limit": 12
}
1	# pnk
2	
3	**Client-side Apple iWork document viewer** — open `.pages`, `.numbers`, and
4	`.key` files entirely in your browser. Drop a file, it's parsed in-page from the
5	raw file bytes by Rust compiled to WebAssembly, rendered as JSON, and displayed.
6	No account, no login, no email, no upload — nothing ever leaves your machine.
7	
8	Built for [Hackyard Yard #1](https://hackyard.tech/yards/yard-1) ("no accounts",
9	48-hour solo build).
10	
11	## What it does
12	
Thinking
📝 Write README.md
/Users/phebert/pnk/README.md
# pnk

**Client-side Apple iWork viewer** — open `.pages`, `.numbers`, and `.key`
files entirely in your browser. The file is parsed from raw bytes by Rust
compiled to WebAssembly, emitted as a typed JSON model, and rendered by a
vanilla-TS viewer. No account, no upload, no backend.

Live at **[pnk.live](https://pnk.live)**. Built solo for
[Hackyard Yard #1](https://hackyard.tech/yards/yard-1) ("no accounts",
48 hours, 2026-08-28 → 30).

## Features

- Drag & drop anywhere; iWork '13+ flat files and package directories.
- **Keynote**: continuous slide scroll, master/theme underlays, shapes with
  arrowheads and reflections, image bullets, shrink-to-fit text, presenter
  notes.
- **Numbers**: per-sheet tabs, styled tables — merges, exact column widths,
  cell borders and outer frames, number/currency/fraction/base-n/duration
  formats.
- **Pages**: paginated word-processing with margins, headers/footers,
  footnotes, drop caps, lists; page-layout canvases with template underlays.
- Syntax-colored JSON model view (`json` in the nav) with download.
- Encrypted and pre-2013 legacy files are refused with a clear error card;
  no password prompt, nothing inspected server-side.
- Zero network requests after load — asserted in the test gate.
- Dark mode, mobile layout.

## Quick start

```bash
# viewer: wasm + bundle + static shell -> viewer/dist/
cd viewer && npm install && npm run build && npm run serve
# gate: strict tsc + Playwright (includes a zero-network assertion)
npm test

# CLI
cargo build --release
target/release/pnk2json document.pages > out.json      # compact JSON
target/release/pnk2json document.numbers --markdown    # readable fallback
target/release/iwadump document.key                    # raw IWA inspector
```

Prerequisites: rust + `wasm32-unknown-unknown` target, `wasm-bindgen` 0.2.127,
node 22+.

## Repo layout

| path | what |
| --- | --- |
| `crates/iwadump` | Rust CLI structure inspector for iWork '13+ documents |
| `crates/pnk2json` | converter lib + wasm binding + text/markdown dumpers |
| `viewer/` | vanilla-TS web app consuming `pnk2json.wasm` |
| `model/src/` | the TypeScript contract the JSON output obeys (strict tsc) |
| `docs/format/` | provenance-tagged iWork format reference — start at `INDEX.md` |
| `docs/model-design.md` | JSON model rationale and conventions |
| `docs/CONFORMANCE.md` | corpus + cross-validation results |
| `scripts/` | conformance / visual-diff / research harnesses |
| `fixtures/` | corpus (gitignored; `provenance.json` and golden checklists committed) |

## Format, short version

An iWork '13+ file is a ZIP whose `Index/` members are `.iwa` streams:
Snappy-compressed blocks wrapping a protobuf object database (TSWP text, TST
tables, TSD drawables, TSCH charts, TSCE formulas, KN Keynote, TSP storage).
Decoded from scratch in Rust. Every format claim in `docs/format/` carries a
provenance tag (`proto` / `parser` / `fixture-verified` / `inferred`); start
at [`docs/format/INDEX.md`](docs/format/INDEX.md).

## Verification

- `scripts/conformance.py` — 1,248-file Common Crawl corpus × JSON + markdown:
  2,488 ok, 8 controlled encrypted rejects, 0 defects, linear timing.
- `scripts/visual_diff.py` — side-by-side composites of our render vs PDF
  exported from the real apps (driven by AppleScript), judged per page.
- Golden fixtures (`fixtures/golden/`) hand-built in the real apps from
  one-feature-per-item checklists; converter output is byte-pinned in
  `cargo test`.
- Viewer gate: one real fixture per app, error cards, and zero non-blob
  network requests after load.

## License

MIT ([LICENSE-MIT](LICENSE-MIT)) or Apache-2.0
([LICENSE-APACHE](LICENSE-APACHE)), at your option.

Pages, Numbers, Keynote, and iWork are trademarks of Apple Inc. This project
is not affiliated with or endorsed by Apple.
The file /Users/phebert/pnk/README.md has been updated successfully. (file state is current in your context — no need to Read it back)
Thinking
$ Bash
Verify referenced scripts exist
ls scripts/ | head -20; grep -c "crossval" docs/CONFORMANCE.md 2>/dev/null; head -5 docs/format/INDEX.md
__pycache__
app_export_pdf.sh
build_viewer.sh
conformance.py
crossval.py
docs_fetch_sources.py
fixtures_downloadall.py
fixtures_ec2_runbook.md
fixtures_queryindex.py
visual_diff.py
1
# iWork File Format Reference — INDEX

Reference documentation for the Apple iWork '13+ file format (`.pages`,
`.numbers`, `.key`), as used by the **pnk** viewer. Built during phase 1 from
primary sources: protobuf definitions extracted from the locally installed
Thinking

Everything referenced exists. One small close-out stamp on the steward record, then commit:

✏️ Edit model-review.md
/Users/phebert/pnk/docs/model-review.md
- §2 hygiene nits (Sheet.style typing, legendFrame→Rect, adjustments index
  signature): still open, owners fix opportunistically with a steward ping.
+
- §2 hygiene nits (Sheet.style typing, legendFrame→Rect, adjustments index
  signature): still open, owners fix opportunistically with a steward ping.
- 2026-08-30: submission freeze. All rulings above are landed; §2 nits carry
  over as post-hackathon work.
The file /Users/phebert/pnk/docs/model-review.md has been updated successfully. (file state is current in your context — no need to Read it back)
$ Bash
Commit and push doc freeze
git add README.md docs/model-review.md && git commit -m "docs: README buttoned up for submission

Feature list matches the shipped viewer (continuous keynote scroll, JSON
view, borders/formats, mobile), quick start verified against the actual
npm scripts, verification section lists the four proof mechanisms
(conformance corpus, visual_diff vs real apps, golden fixtures, viewer
gate), live link to pnk.live, Apple trademark note. model-review.md gets
a freeze stamp.

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, 59 insertions(+), 52 deletions(-)
   6985d83..bf44ba7  main -> main

Done, pushed as bf44ba7. The README is now a tight one-screen read: what it is, feature list matching what actually shipped, verified quick-start commands (checked against the real npm scripts and build script), repo layout table, a four-line format primer, the four verification mechanisms, and license + Apple trademark note. It links to pnk.live as the live site. docs/model-review.md got a one-line freeze stamp marking the §2 hygiene nits as post-hackathon carry-over; everything else in docs/ was already factual and dated, so I left it alone.

Two things to know for the deploy from the other machine:

  • The final build artifact is viewer/dist/ — regenerate it there with cd viewer && npm install && npm run build (needs rust + wasm32-unknown-unknown + wasm-bindgen 0.2.127), or just rsync the dist/ from this machine since it's fully static.
  • The in-app branding still says pnk.vu (wordmark, tab title) while the site will live at pnk.live. If that's intentional, ignore this; if you'd rather the wordmark match the domain, it's a two-file find/replace — say the word before you build for deploy.

sorry I misspoke it is going to pnk.vu! $50 cheaper lol. making the video now need to close this session, thanks again you were amaze amaze amaze!

Thinking

One fix needed — the README links pnk.live:

✏️ Edit README.md
/Users/phebert/pnk/README.md
Live at **[pnk.live](https://pnk.live)**.
+
Live at **[pnk.vu](https://pnk.vu)**.
The file /Users/phebert/pnk/README.md has been updated successfully. (file state is current in your context — no need to Read it back)
$ Bash
Fix domain, commit, push, verify clean
git add README.md && git commit -m "docs: the live site is pnk.vu after all

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018a2hmoDhN4kBq1BwfbFdAU" && git push origin main 2>&1 | tail -1 && git status --short | head -3
1 file changed, 1 insertion(+), 1 deletion(-)
   bf44ba7..dd5d81b  main -> main
?? crates/pnk2json/examples/
?? fixtures/templates/keynote/
?? fixtures/templates/numbers/

Fixed and pushed as dd5d81b — README now points to pnk.vu, which also means the in-app branding matches the domain perfectly. Nothing to change before you build for deploy.

The tree is fully synced: every commit is on origin/main, all gates green as of the freeze. The only untracked leftovers are the Apple template binaries (deliberately untracked) and that stray debug probe in crates/pnk2json/examples/ — neither ships.

It was a genuinely fun build — from Snappy blocks to arrowhead geometry to a product with a wordmark, in 48 hours, with a public paper trail of 200+ commits to show for it. Good luck with the video and the voting — go get 'em! 🎉