Skip to content

v0.1.0 - #43

Draft
leogdion wants to merge 32 commits into
mainfrom
v0.1.x
Draft

v0.1.0#43
leogdion wants to merge 32 commits into
mainfrom
v0.1.x

Conversation

@leogdion

Copy link
Copy Markdown
Member

No description provided.

leogdion and others added 17 commits July 28, 2026 15:26
)

Research only, no codec code. Records the depend-vs-vendor decision that #16
builds on.

Decision: vendor a pure-Swift block codec in `Snappy`; #5 stays the exit.

Two premises in the tickets turned out to be wrong, and both are corrected in
the writeup:

- #5 assumed stock libraries expose only the *framing* layer and keep the block
  codec internal. False — google/snappy's C API (`snappy-c.h`) is block-level
  only and exposes no framing at all, and `codelynx/snappy-swift` is likewise
  block-only. Block-level access is common; a *trustworthy* package is not.
- The "stock stream libraries reject .iwa" claim is now verified from the bytes
  rather than assumed.

Measured directly from research/fixtures (no vendor library involved): Apple's
chunk header is 4 bytes (0x00 + 3-byte LE length), there is no sNaPpY stream
identifier and no CRC-32C anywhere, chunk payloads are stock Snappy blocks, and
all 25 .iwa files decode with a plain block decompressor. Chunks cap at exactly
65536 bytes; the encoder emits only literal/copy1/copy2 (never copy4), max
back-reference 60980.

The leading candidate (codelynx/snappy-swift) was built on Swift 6.4 and tested
against real Keynote bytes: 30/30 chunks decompressed and round-tripped, 207/207
malformed inputs threw cleanly without trapping. So the rejection is not about
quality — it is supply chain: 2 stars, one author, a single squashed commit, no
CI, three months old, and its product name collides with our own `Snappy`.

Also fixes the vendored-schema proto count in Step 1 (35 -> 33; 34 compiled
including compat/14.4/TSKArchives_sos.proto), verified by counting the tree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Generate the acceptance/differential goldens from the committed JSON specs
via the Python reference backend and commit them under research/goldens/ so
the Swift writer (#20) has a fixed target. research/samples/ is gitignored,
so the goldens needed a committed home of their own.

Ran the three staleness checks first (prepare-keynote-parser, test,
verify-pack) to confirm the specs are still valid against the current
backend.

Document regeneration in research/findings/goldens.md, including why a
one-slide spec yields a ~460 KB, 18-slide deck: deckkit.build() creates the
base via AppleScript `make new document`, which inherits Keynote's default
theme and its master slides and stock imagery. That is expected, not a
defect -- the Python backend has no blank-theme path, and the minimal
template is #21.

Closes #19

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Vendor Keynote 15.3 schema and 14.4 registry (#14)

Check in protoc-generated Swift for the 33 vendored Keynote 15.3 protos
plus the 14.4 `TSKArchives_sos.proto` the registry still references, and
port `TSPRegistryMapping` alongside them. Consumers never run `protoc`
and `Package.swift` gains no build-tool plugin.

`TSPRegistryMapping` exposes the 631-entry id -> message-name table and
resolves each name to its generated Swift metatype. The table is not
injective — 631 identifiers name 624 distinct messages — so it is modelled
as `[UInt32: String]` and never inverted.

Decoding is partial. The 15.3 protos mark 1,497 fields `required`, so a
strict parse of a real component would reject documents Keynote itself
round-trips; pairing a 15.3 schema with a 14.4 registry makes that worse.

Generated code is excluded from lint rather than the rules being relaxed:
`.swiftlint.yml` gains one `excluded:` entry and the generated directory
carries its own empty `.swift-format`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Delete Sources/KeynoteKitProtobuf/Generated/.swift-format

* Record the partial-decoding constraint in the decision log

The 15.3 protos mark 1,497 fields `required`, but Keynote does not
populate all of them, so a strict proto2 parse throws
`.missingRequiredFields` on components Keynote itself round-trips.
Partial decoding is therefore correct rather than a workaround, and the
constraint applies to any archive decode — not just registry lookups —
so #17/#18 inherit it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Executes issue #5's first concrete task: a documentation-only survey of
Swift Snappy packages exposing block-level compress/decompress, one day
after the vendored codec (#16) landed. Re-verifies the #15 candidates
and covers two repos that appeared since; corrects two minor factual
claims in the #15 record (codelynx commit count and product name).

Recommendation, recorded as PROPOSED in PLAN.md's decision log pending
Leo's review: stay vendored (option c). No package clears both the
capability and trust bars; extraction (option b) is deferred until a
second consumer exists. Exit conditions documented in the survey.

Part of #5 — the issue stays open for the eventual swap decision.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
#35)

* Add typed scripting-dictionary values for the Keynote escape hatch (#10)

Portable value layer for KeynoteKitScripting: AppleEventCode (four-char
OSType), the full 44-effect TransitionEffect catalog, the four-field
TransitionSettings record, ExportFormat, CloseBehavior, and
KeynoteScriptingError (mapping Apple event error -1743 to a typed
Automation-denial case). All identities — AppleScript enumerator terms,
four-character event codes, and Cocoa string values — are transcribed
from Keynote 15.3's Keynote.sdef and cross-checked against the Phase 1
research (research/tools/deckkit.py EFFECTS table).

Pure stdlib, no Foundation and no ScriptingBridge import, so this layer
compiles and its tests run on every CI platform. Doc comments record
what is deliberately absent because Keynote never made it scriptable:
object builds, transition direction, and the per-effect custom* options.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Add the ScriptingBridge surface driving a live Keynote (#10)

The macOS-only half of the escape hatch, gated on
#if canImport(ScriptingBridge) so the package still builds (to the value
types) on Linux, wasm, Windows, and Android:

- Hand-declared @objc protocols for the minimal Keynote sdef surface
  (application, document, slide, text item) instead of a generated
  header dump; SBApplication/SBObject conform via empty extensions and
  resolve the selectors dynamically.
- Wrappers for the proven deckkit.py flow: make new document/slide/text
  item (create + element-array add + explicit setters, sidestepping the
  known "make new … with properties" ScriptingBridge gaps), transition
  properties get/set, save in POSIX file, export (PDF/QuickTime/etc.),
  and close saving no.
- An SBApplicationDelegate error monitor that turns silent Apple event
  failures into typed KeynoteScriptingError values.

Unit tests run without Keynote (record lowering, error paths, an
uninstalled-bundle failure). The live integration test launches and
scripts a real Keynote, so it is skipped unless KEYNOTEKIT_LIVE_KEYNOTE=1
is exported; live verification of the ScriptingBridge record keying
remains open on #10.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: CI <ci@example.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…fferential gate (#24) (#34)

* Acceptance decks scripted: DSL catalog, swift run AcceptanceDecks, differential gate (#24)

The AFK half of #24. AcceptanceDeckCatalog expresses the five acceptance
decks (4 bisect cases + build_acceptance) in the public DSL — Action via
.action { MotionPath() }, direction via .direction(.moveInNonDefault) —
mirroring the committed specs. `swift run AcceptanceDecks [dir]` writes
them for the human pass and self-checks each (every record decodes, both
SIGTRAP invariants hold) before printing the PLAN Step 6 checklist.

AcceptanceDeckTests gate the catalog three ways per deck: the lowering
equals DeckSpec.load of the committed spec; authoring the lowered model
into the stripped golden reproduces the golden archive graph; and the
full template write path emits a structurally valid deck. Verified the
gate can fail by perturbing one spec value.

The Keynote 15.3 open pass and the v0.1.0 tag stay human-in-the-loop.
Roadmap current-position updated for the post-PR-#32 state.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* SlideBuilder is inferred on body, as in SwiftUI

@slidebuilder now annotates the SlideContent.body requirement itself, so
conformances get builder inference automatically instead of opting in at
each use site. The primitives' unreachable Never bodies become real
compositions (Slide and SlideGroup compose to themselves) since the
inferred builder cannot produce Never.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Roadmap: full session handoff in the current-position note

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* agent-notes: session state always lands in versioned files

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Handoff: #34 landed; frontier is Keynote open pass + v0.1.0 tag.

Refresh PARALLEL-WORKTREES current-position and the v0.1.0 progress memory
so the next session starts at the human Keynote 15.3 acceptance gate.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: CI <ci@example.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
- research/findings/acceptance_keynote_open.md: expanded-pass section (9 decks
  incl. text_runs, regression + drawable-depth render columns, #10 live-verify
  extra, tag + merge-to-main steps)
- .claude/PLAN.md, .claude/PARALLEL-WORKTREES.md, .claude/agent-notes.md:
  current position after PR #39 and PR #41 merges (drawable depth, mixed runs,
  TextBox/Text DSL rename) and issue housekeeping

Co-authored-by: CI <ci@example.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 3d381e5f-d620-430b-889b-e0b397aacdae

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@leogdion

Copy link
Copy Markdown
Member Author

Code review — PR #43 (v0.1.xmain)

Two-axis review of main...HEAD (17 commits, ~659 files). Axes are intentionally separate: Standards = does the code follow this repo's documented conventions; Spec = does it match the v0.1.0 PLAN / map #12. Generated protobuf, research fixtures/goldens, and agent-skill copies were out of scope except where they touch packaging rules.

Fixed point: main (1937f49) … HEAD: 4b756e0


Standards

Verdict: Largely clean against documented standards. No hard breaches found in the hand-written write path.

Hard standards (checked)

  • Swift 6.4-only// swift-tools-version: 6.4; no Package@swift-* (.claude/memory/keynotekit-swift-64-only.md).
  • Products / depsSnappyIWAFramingKeynoteKit + KeynoteKitProtobuf; Scripting only in KeynoteKitScripting under #if canImport(ScriptingBridge); no .linkedFramework (Package.swift, PLAN / CI conventions).
  • Template resource.copy("Resources/blank.key"), not .process.
  • No Process in Sources/Tests; zip checks use KeyBundle (agent-notes 2026-07-31).
  • DSLTextRunsBuilder / SlideItemsBuilder only accept Text / TextBox / Image; no buildExpression(_: String).
  • has* remapsRecordCloner guards optional refs before remap(&…) (agent-notes 2026-07-31).
  • Gates — golden path is ArchiveGraphComparer (structural); ByteIdentityDiagnosticTests is opt-in / non-blocking.
  • Snappy — no Apple/Foundation imports; struct Snappy + static let default / private init.
  • LintLINT_MODE=STRICT in CI; Generated excluded in .swiftlint.yml.

Soft / related (not a hard remap breach)

  • KeynoteArchiveSurgeon+DrawableItems.swift: placeholder.super.ownedStorage.identifier with no hasOwnedStorage — read path, local value discarded, but same materialization footgun as the has* rule if ever written back.

Judgement-call smells (watch)

  1. Message Chainsplaceholder.super.super.super.geometry… / .caption / .title / .parent in KeynoteArchiveSurgeon+DrawableItems.swift and RecordCloner.swift.
  2. Duplicated Code — near-identical KeyFixture / KeyFixtureCorpus in IWAFramingTests and KeynoteArchiveNavigationTests (latter notes test-target isolation).
  3. Shotgun SurgeryKeynoteArchiveSurgeon+*.swift spread (acceptable vs file_length / one_declaration_per_file; lint wins).

Spec

(a) Missing / partial

  • 12 — Acceptance — five decks in Keynote 15.3 #24 human DoD still open (not a code gap). PLAN: “Definition of done for v0.1.0: a deck authored purely in Swift opens in Keynote 15.3 with no crash and no repair warning….” Issue 12 — Acceptance — five decks in Keynote 15.3 #24 still unchecked: In/Out/Action + ordering/direction; geometry/formatting/image checks; v0.1.0 tagged. Code side is present: AcceptanceDeckCatalog (9 decks), AcceptanceDecks, AcceptanceDeckTests / GoldenDifferentialTests. research/findings/acceptance_keynote_open.md expanded checklist is blank — tag gate only.
  • MotionPath is sharp-polyline only. PLAN Step 5 / decision log: “Full editableBezierPathSource in v0.1.0 (not a simplified Move-only sugar)” / “full MotionPath bezier in v0.1”. Wire format emits editableBezierPathSource, but public/AuthoredMotionPath nodes are sharp only (coincident in/out controls in BuildRecordFactory.pathSource) — curved handles aren’t authorable. Docs on MotionPath claim “full editable bezier path source”; the public surface is polyline nodes.

(b) Scope creep

(c) Looks done, wrong / stale vs shipped surface

Present and matching PLAN (no finding): five library products; KeynoteKit → IWAFraming/Snappy + Protobuf, no ScriptingBridge; bundled blank.key + write(to:basedOn:); #17 semantic round-trip; #18 KeynoteArchiveNavigation non-product + ExtractBuildsParityTests; #20 goldens + UUIDMapVerifier; #22 supply tests; Goal sketch via TextBox/MotionPath (GoalSketchTests); no public Deck(reading…); Linux CI.


Summary

Axis Findings Worst
Standards 0 hard · 1 soft footgun · 3 judgement smells Message-chain / optional-field materialization risk in drawable surgery
Spec 2 partial · 1 intentional scope (scripting) · 2 stale docs #24 human acceptance still the only v0.1.0 tag gate; MotionPath public API is sharp-only vs “full bezier” PLAN wording

Merge posture: code/product shape matches the v0.1.0 PLAN gates that are automatable. Do not treat merge of this PR as the v0.1.0 tag — finish the expanded #24 checklist in research/findings/acceptance_keynote_open.md first. Before tag, also fix the two stale public-API doc comments and clarify whether sharp-only MotionPath satisfies the PLAN’s “full bezier” decision or needs a follow-up ticket.

@leogdion

Copy link
Copy Markdown
Member Author

Code Review: PR #43 (v0.1.xmain)

This is a two-axis review of PR #43 comparing v0.1.x against main.


1. Standards

Documented Repo Standards (Hard Violations)

  1. Un-guarded Protobuf Optional Message Access in RecordCloner.swift
    • File: Sources/KeynoteKit/RecordCloner.swift (L153–155)
    • Standard: .claude/agent-notes.md"NEVER remap a swift-protobuf optional message field without its has* guard — remap(&msg.field) on an absent field MATERIALIZES an empty reference (identifier 0)..."
    • Hunk:
      private static func rewrittenStorage(_ payload: [UInt8], map: [UInt64: UInt64]) throws -> [UInt8] {
        var storage = try TSWP_StorageArchive(serializedBytes: payload, partial: true)
        for index in storage.tableAttachment.entries.indices {
          remap(&storage.tableAttachment.entries[index].object, map: map)
        }
        return try storage.serializedBytes(partial: true)
      }
    • Violation: Accessing storage.tableAttachment.entries without checking if storage.hasTableAttachment materializes an empty optional message field (identifier 0) when tableAttachment is absent, risking silent component load failures or NSSet-nil crashes.

Baseline Code Smells (Judgement Calls)

  1. Message Chains — Deep Protobuf Inheritance Traversal (super.super.super)

    • Files:
      • Sources/KeynoteKit/RecordCloner.swift (L136–144) (placeholder.super.super.super.hasCaption, etc.)
      • Sources/KeynoteKit/KeynoteArchiveSurgeon+DrawableItems.swift (L194–200) (placeholder.super.super.super.geometry.position.x, etc.)
    • Smell: Navigating through 3 levels of protobuf message inheritance exposes internal framing.
    • Recommendation: Introduce helper extensions or properties on KN_PlaceholderArchive (e.g. drawableGeometry) to encapsulate the walk.
  2. Duplicated Code — Repeated has* & remap Pattern

    • File: Sources/KeynoteKit/RecordCloner.swift (L136–144)
    • Smell: The exact sequence of if hasX { remap(&x, map: map) } repeats 5 consecutive times across deprecatedStorage, ownedStorage, caption, title, and parent.

2. Spec

(a) Missing or Partial Requirements

  1. magicId Pairing & Lowering is a No-Op
    • Spec Quote: .claude/PLAN.md (L63): "- **magic-id is compile-time only** — Keynote persists no object correspondence; matching is a runtime heuristic. The compiler must emit matched objects as the same type with similar content/geometry."
    • Finding: In Sources/KeynoteKit/Deck+Lowering.swift (L78–85), touchMagicIdentifiers executes _ = text.magicIdentifier and _ = image.magicIdentifier, discarding the identifier without matching or aligning corresponding drawables across adjacent slides.

(b) Scope Creep (Unasked-for Behaviour)

  1. Full Implementation of KeynoteKitScripting (Expose a ScriptingBridge escape hatch for direct Keynote control #10)

    • Spec Quote: .claude/PLAN.md (L184): "KeynoteKitScripting | ScriptingBridge escape hatch (#10) | Scaffolded empty in Step 0 (name + product reserved); body is #10, past v0.1"
    • Finding: Commit 068cefe (PR Expose a ScriptingBridge escape hatch for direct Keynote control (#10) #35) fully implemented KeynoteKitScripting with application, document, slide, and text item ScriptingBridge wrappers plus live test suites rather than leaving it scaffolded empty.
  2. Drawable Depth & Geometry (Drawable geometry: width, height, z-order #3, Text formatting on authored Text items #37, Author images as slide drawables #38, Support mixed formatting (runs) within a single text box #40)

(c) Requirements Implemented Wrongly

  1. Opaque Transition Direction Enum

    • Spec Quote: .claude/PLAN.md (L402–403): "Direction via a typed enum on directional effects only (e.g. .push, .moveIn), chained as .direction(.…)"
    • Finding: Sources/KeynoteKit/TransitionDirection.swift (L38) exposes only public static let moveInNonDefault = TransitionDirection(ordinal: 11) with doc comment "its inspector label was not recorded", offering no semantic direction names (e.g. .leftToRight).
  2. Empty Deck().write(to:) Bypass

    • Spec Quote: .claude/PLAN.md (L48): "Definition of done for v0.1.0: a deck authored purely in Swift opens in Keynote 15.3 with no crash and no repair warning"
    • Finding: Sources/KeynoteKit/Deck.swift (L83–86) bypasses slide processing when slides.isEmpty by copying the template file directly to disk, skipping archive surgeon invariant verification.

Summary

  • Standards: 1 hard violation (missing hasTableAttachment guard in RecordCloner.swift:L153) and 2 judgement smells (super.super.super chains, repeated remap pattern).
  • Spec: 5 findings across missing requirements (magicId lowering is a no-op), scope creep (KeynoteKitScripting fully implemented, drawable depth added), and incorrect implementations (unlabelled transition direction ordinal, empty deck write bypass).

Co-authored-by: Cursor <cursoragent@cursor.com>

@leogdion leogdion left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review: v0.1.0 (v0.1.xmain)

Overview & scope

This PR lands the entire v0.1.0 release: a Swift package that authors Keynote .key files by byte-surgery on a bundled template, with no Python/keynote-parser/AppleScript at runtime. Of the 659 files / ~326k added lines, only ~20k lines are hand-written — the rest is generated protobuf (Sources/KeynoteKitProtobuf/Generated/), vendored protos, and research/ artifacts. This review covers the hand-written code: Snappy, IWAFraming, KeynoteKitProtobuf (hand-written parts), KeynoteArchiveNavigation, the KeynoteKit surgeon + DSL layers, KeynoteKitScripting, the acceptance-deck targets, tests, and build/CI configuration. Every finding below was verified against the actual code, and severities are calibrated to the project's own documented invariants (.claude/agent-notes.md, .claude/PLAN.md).

Overall assessment: the architecture is strong — clean layer boundaries, research-traceable constants, hard-won crash invariants encoded in code and enforced by a genuinely failable UUIDMapVerifier, and tests that assert archive structure rather than smoke. There is 1 critical bug (multi-image identifier collision), a handful of majors that cluster into three themes — 32-bit / malformed-input trap paths, bundled-template assumptions leaking into the public arbitrary-base API, and DSL result-builder gaps — plus targeted test-coverage and CI-hygiene items.


🔴 Critical

C1. Two images on one slide collide on the same TSP.DataInfo identifier

Sources/KeynoteKit/KeynoteArchiveSurgeon+ImageSupply.swift:175

mintImageDrawable allocates data: try nextDataIdentifier(), and nextDataIdentifier() (KeynoteArchiveSurgeon+DataIdentifiers.swift:45-58) computes metadata.datas.map(\.identifier).max() ?? 0 + 1 from TSP.PackageMetadata. But in expandDrawables the minted DataInfo rows accumulate in pending.infos and are only registered into metadata after the per-item loop. Two .image items on the same slide therefore both read the same pre-loop maximum and mint the same data identifier: two metadata.datas rows sharing one id, two Data/ zip members whose filenames embed the same id, and both TSD.ImageArchive.data references resolving to whichever row wins. Cross-slide allocation is safe only by accident (each slide registers before the next runs). ImageAuthoringTests only ever exercises one image per slide, so this is currently invisible to the suite. Fix: register (or at least reserve) each identifier as it is minted; add a two-images-one-slide test.


🟠 Major

Surgeon layer — template assumptions leaking into the public arbitrary-base API

KeynoteTemplate.init(contentsOf:) is public, so any Keynote-written (or previously KeynoteKit-written) document is a supported base. Several code paths are only correct for the bundled blank template:

  • M1. RecordCloner.remapSlideLists does not remap ownedDrawables (Sources/KeynoteKit/RecordCloner.swift:113-123). The rewrite inventory covers drawablesZOrder, builds, and buildChunks — but the surgeon itself writes slide.ownedDrawables for authored images (ImageSupply.swift:144). Cloning a slide that contains an inserted image leaves the clone's ownedDrawables pointing at the original slide's drawables while drawablesZOrder points at the clones — a cross-slide shared/dangling-ownership violation of exactly the class PLAN treats as crash-inducing. (thumbnails and note-storage attachment tables are also outside the inventory, though those are documented as template-measured scope.)
  • M2. expandTextItems' clone path skips objectUuidMapEntries registration (Sources/KeynoteKit/KeynoteArchiveSurgeon+Supply.swift:51-75). cloneBodyPlaceholder appends cloned subtree records to the slide member, but nothing mints uuid-map entries for them — unlike the sibling expandDrawables path (ImageSupply.swift:85-94), which diff-scans and registers every new record. This directly violates the standing invariant (agent-notes 2026-07-31: "Every record added to a slide member needs a fresh-uuid objectUuidMapEntries row") — the documented failure mode is blank component load or NSSet-nil crash. SupplyTests.oversizedTextItemCount passes only because UUIDMapVerifier checks builds, not all minted records.
  • M3. Metadata registration helpers silently no-op on a missing component (Sources/KeynoteKit/KeynoteArchiveSurgeon+Metadata.swift:80-90, 104-111, 170-175). registerUUIDEntries(_:componentStem:), registerDataObjectReference, and the per-reference lookup in registerExternalReferences return/continue when no component matches the stem. On a base whose stylesheet member isn't named DocumentStylesheet, minted style edges and thumbnail data references vanish without error — the exact "silent component load failure / crashes layout" modes the findings docs describe. These should throw, as registerUUIDEntries(_:slideIdentifier:) already does.
  • M4. Partial bundle mutation when author throws midway (Sources/KeynoteKit/KeynoteArchiveSurgeon+SlideCloning.swift:63-69, KeynoteArchiveSurgeon.swift:72-115). cloneLastSlide inserts the new Index/Slide-<id>.iwa entry with an empty body, filled only by the final serialization loop; Data/ members are upserted per slide. Any throw in between (e.g. targetOutOfRange on a later slide, or the verifier itself) leaves the caller's inout bundle with a zero-byte IWA member and orphan Data/ blobs. A caller that catches and serializes writes a corrupt .key. Document the invalidation, or stage mutations and commit at the end.
  • M5. Cached slide record indices assume one slide record per member (Sources/KeynoteKit/KeynoteArchiveSurgeon.swift:85 + +SlideSurgery.swift:159-173). author computes member/record indices once, then insertMintedRecords/cloneSlideNode do mid-member inserts that shift later records. If a base stores two slide records in one Index member (nothing forbids it), the second slide's cached index silently targets the wrong record. Re-locate by identifier per slide, or assert one-slide-per-member at catalog time.

Parsing/decoding — malformed input traps instead of throwing, and 32-bit platforms

The package builds for watchOS (arm64_32, 32-bit Int), and these layers document a throws-not-traps contract:

  • M6. TSPArchiveStream traps on a crafted varint length (Sources/KeynoteKitProtobuf/TSPArchiveStream.swift:97). Int(headerLength) uses the trapping initializer on an input-controlled UInt64; a 10-byte varint decoding to 1 << 63 crashes the process instead of throwing TSPArchiveStreamError. Bounds-check in UInt64 space before converting. Related: ProtobufVarint.read (ProtobufVarint.swift:48-56) silently wraps 10-byte overflow (value |= ... << 63 drops bits) instead of returning nil as documented — [0xFF ×9, 0x7F] "succeeds" as UInt64.max, which is precisely the value class that feeds the trap.
  • M7. Snappy decoder: attacker-controlled reserveCapacity + three 32-bit trap paths (Sources/Snappy/SnappyDecoder.swift:54; Snappy.swift:56; Varint.swift:67; SnappyDecoder.swift:82). A 6-byte input (FF FF FF FF 0F …) drives an up-front ~4 GiB reserveCapacity before any body validation — a jetsam kill / malloc trap on Apple platforms, contradicting the documented "malformed input never traps" guarantee. On arm64_32: Int(UInt32.max) in maximumBlockSize traps at first touch; Varint's Int(result) traps for lengths > Int32.max; and a width-4 extended literal length shifts into the sign bit, producing a negative length that passes the bounds guard and traps in the slice. Clamp the reserve (per-element expansion is bounded ~22×), compute lengths in UInt32/UInt64, and add a per-element output.count + length <= expectedCount fail-fast.
  • M8. IWAFraming zip writer/reader trap edges (Sources/IWAFraming/ZipArchive.swift:61-99; ZipCentralDirectoryRecord.swift:87-89; ZipEndOfCentralDirectory.swift:76). The writer guards each body and the entry count but never the cumulative offsets — multiple sub-4 GiB entries summing past 4 GiB trap in UInt32(...) appends instead of throwing the documented .zip64Unsupported. On the read side, Int(buffer.readUInt32(...)) on untrusted size/offset fields traps on arm64_32 for values ≥ 2³¹. (All majors here concern inputs/scales Keynote itself never produces; nothing threatens re-emission fidelity for genuine Keynote files.)

DSL surface — result-builder and ordering gaps (user-visible at v0.1.0)

  • M9. SlideItemsBuilder has no buildOptional/buildEither (Sources/KeynoteKit/SlideItemsBuilder.swift:32-52): Slide { if showCaption { TextBox("Caption") } } does not compile, while SlideBuilder and TextRunsBuilder both support control flow. buildArray is present, so for works but if doesn't — a surprising asymmetry users hit immediately.
  • M10. BuildEffectsBuilder has no control-flow support at all (Sources/KeynoteKit/BuildEffectsBuilder.swift:32-44): no buildOptional, buildEither, or buildArray, so build(.in) { if emphasize { Blur() } else { Dissolve() } } fails to compile. Both fixes are the same two-liners TextRunsBuilder already has — worth doing before the API surface is tagged.
  • M11. Build delivery order follows z-order, not declaration order (Sources/KeynoteKit/Deck+Lowering.swift:40-42, 88-99). authoredSlide sorts drawables by z-index and then collects builds from that ordering, but PLAN §5 (and this file's own doc comment) fix delivery order as encounter order walking the slide builder. Concretely: TextBox("A").zIndex(10).build(...) declared before TextBox("B").zIndex(0).build(...) delivers B's build first — a purely visual layering property silently reorders the animation timeline. The existing zIndexReordersDrawables test doesn't catch it because its declaration order coincides with z-order. Iterate declaration order and map targetIndex through the ordered list.

Reading/parity infrastructure (internal, but gates depend on it)

  • M12. BuildExtractor never scrapes customTwist — documented Python parity is broken for twist effects (Sources/KeynoteArchiveNavigation/BuildExtractor.swift:104-126). Python's _parse_build_options scrapes the whole BuildArchive block including nested animationAttributes; customTwist exists only on KN_AnimationAttributesArchive, which the Swift options bag never reads (customBounce/customTravelDistance also have unscraped shadow copies there). BuildRecord's own docs list customTwist in the bag. The 24-fixture corpus contains zero twist effects, so the parity gate passes vacuously.
  • M13. SlideOrderTests never asserts order (Tests/KeynoteArchiveNavigationTests/SlideOrderTests.swift:9-19). The only assertions are set-equality and count — a reversed or arbitrary-order result passes on all 24 fixtures, and no SlideOrderError case is ever exercised. Order is the one property the type exists to provide and is what the writer path consumes. Related: the slide-tree walk is one level deep (SlideOrder.swift:87-109) — grouped/indented slides in a user-supplied base would silently vanish from the result; recurse or throw on unexpected depth.
  • M14. Apple-corpus Snappy test asserts only decoded length (Tests/SnappyTests/AppleCorpusTests.swift:19). Copy offsets don't affect output length, so the entire class of offset-decoding bugs is invisible to the module's only cross-implementation evidence. Store a SHA-256 (or plaintext) per fixture alongside uncompressedCount.

🟡 Minor (selected, grouped)

Correctness edges

  • MotionPath.naturalWidth/Height computed as max coordinate, not extent (max - min) — leftward or offset paths yield 0 or inflated sizes (Sources/KeynoteKit/MotionPath.swift:67-71).
  • Action build with motionPath == nil silently emits an empty TSD.PathSourceArchive instead of throwing (Sources/KeynoteKit/BuildRecordFactory.swift:175-177).
  • Image documents "JPEG or PNG" but sizes only JPEGs; a PNG without explicit dimensions gets the silent 200×200 pixel-size fallback (Sources/KeynoteKit/Image.swift:77-88, KeynoteArchiveSurgeon+ImageSupply.swift:186-193) — pixelSize is a documented crash/misrender-sensitive field. PNG IHDR sniffing is 8 fixed-offset bytes.
  • JPEGSize SOF guard admits length 2–6, reading dimension bytes from the next segment (garbage, in-bounds); require length >= 7. Legal 0xFF fill bytes also make valid JPEGs return nil (Sources/KeynoteKit/JPEGSize.swift:46-55).
  • UUIDMapVerifier watermark check only runs when builds exist and only against build ids — an image-only deck regression in bumpLastObjectIdentifier passes the gate (Sources/KeynoteKit/UUIDMapVerifier.swift:69-74); bumpLastObjectIdentifier itself no-ops when the field is absent (+Metadata.swift:190-196).
  • ?? 0 fallbacks on identifier-map lookups that must never miss would wire the forbidden id-0 reference instead of throwing (+SlideCloning.swift:56, +Supply.swift:112).
  • Paragraph-fork record header omits the stylesheet reference from objectReferences while the payload carries it — an undeclared cross-record edge; the sibling characterStyleRecord declares it (+CharacterStyle.swift:108 vs +CharacterRuns.swift:107).
  • Second .action { } call silently replaces the first, unlike accumulating .build calls (TextBox.swift:178-182, Image.swift:141-145); .direction(_:) is chainable on non-directional transitions (even .none), contra PLAN's "typed enum on directional effects only" (SlideTransition.swift:88-92).
  • TSPArchiveRecord payloads/messageInfos count invariant unenforced — mismatches serialize mis-parsing streams or silently drop payload bytes via zip (TSPArchiveRecord.swift:55-58, 74; also RecordCloner.swift:55).
  • EOCD backward scan accepts a false PK\x05\x06 inside a trailing comment (no comment-length consistency check), and entry paths > 65,535 UTF-8 bytes are silently corrupted via the appendUInt16 mask (ZipEndOfCentralDirectory.swift:49-59, ZipLocalFileHeader.swift:77).
  • IWAChunkCodec.maximumUncompressedChunkCount is an unvalidated public var: 0 hangs encode forever; oversized values silently wrap the 24-bit length field (IWAFraming/IWAChunkCodec.swift:51, 85-99).
  • Scripting: openDocument's fallback to documents[0] can bind (and later save over) an unrelated open document (KeynoteScriptingApplication.swift:86-97); single-slot AppleEventErrorMonitor.lastError mis-attributes the root cause in multi-event commands (AppleEventErrorMonitor.swift:62-83).

Test-coverage gaps worth closing before tag

  • Multi-image slides (would catch C1); a Keynote-opened oversized-text-item deck (M2).
  • Trigger lowering has zero automated assertions: .afterPrevious→2 / .withPrevious→3 and chunk.automatic are verified only by the human pass. Similarly untested: autoAdvance, transition/build delay, custom motion-path points, run-level .italic()/.font(), and SlideBuilder's own control-flow paths.
  • No known-answer vectors: SHA-1 ("abc" → a9993e36…; the implementation was hand-verified correct against FIPS 180-4, but the only test compares it to itself), CRC-32 ("123456789"0xCBF43926), copy1 offsets > 255 in Snappy.
  • KeyBundle.upsertEntry (four ordering branches, on the production surgery path) has no direct test; malformed-zip error paths (truncatedArchive variants, zip64Unsupported, multi-disk) largely untested.
  • TSPArchiveStream.serialize is never run against real Keynote headers at its own layer (only synthetic round-trips); patch-record resolution (type == 0 / shouldMerge) has zero coverage.
  • ArchiveGraphComparer doesn't compare messageInfos.dataReferences — a dropped thumbnail/image data reference would pass the golden differential (Tests/KeynoteKitTests/ArchiveGraphComparer.swift:53-57).

CI / config hygiene

  • Release-tag pushes get the reduced matrix: the scope check matches branch refs only, so refs/tags/v0.1.0 sets FULL=false — the tag build that certifies the release skips the platform legs (.github/workflows/KeynoteKit.yml:50-61). Add elif [[ "$REF" == refs/tags/* ]]; then FULL=true.
  • Fork PRs will always fail claude-code-review (secrets unavailable on forks); guard with a same-repo if: (claude-code-review.yml:4-5, 38). No pull_request_target/script-injection issues found anywhere.
  • Three workflows lack permissions: blocks (KeynoteKit.yml, check-unsafe-flags.yml, swift-source-compat.yml); third-party actions are tag-pinned rather than SHA-pinned (jdx/mise-action, jlumbroso/free-disk-space are the real supply-chain surface).
  • .swiftlint.yml lists pattern_matching_keywords in both opt_in_rules (line 60) and disabled_rules (line 147) — disabled wins; delete one deliberately.
  • Scripts/lint.sh's ScriptingBridge-import guard doesn't scan AcceptanceDeckCatalog, AcceptanceDecks, or KeynoteArchiveNavigation (lines 83-87); unquoted $PACKAGE_DIR word-splits on paths with spaces.
  • cleanup-caches isn't paginated (leftover caches past 30); Deck.write hardcodes SystemRandomNumberGenerator, so a deck failing the human pass can't be regenerated for bisection — thread the existing generator seam through the public API.

Performance (none blocking)

  • Surgeon: SlideCatalog rebuilt inside nearly every helper, withPackageMetadata decode/encode round-trips per call (~6× per image slide), and author re-encodes every member whenever any id was minted — quadratic on large bases; a one-time identifier→location index fixes most of it.
  • Snappy: fixed 16,384-entry hash table zeroed per encode call regardless of input size (the corpus's smallest block is 18 bytes); [UInt8]-only API forces an Array(payload[start..<end]) copy per 64 KiB chunk in IWAChunkCodec; KeyArchiveIndex.record(withIdentifier:) is a linear scan called repeatedly per slide.

✅ Strengths

  • The hardest-won invariants are encoded, not just documented: has*-guards throughout RecordCloner (with the materialized-empty-reference rationale inline), UUIDMapVerifier running before every write with error messages explaining why Keynote would crash, and tests proving each invariant can actually fail.
  • The golden-differential design is excellent: strip Python's surgery from a real golden, re-author with Swift, demand graph equality with only the two genuinely random fields normalized — deterministic id allocation makes even minted identifiers comparable.
  • Research traceability is unusually good: BuildRecordFactory and the transition catalogs cite the exact Python lines, findings docs, and fixture measurements; all 44 scripting transition entries match deckkit.py byte-for-byte including the three deceptive name→archive traps.
  • Layer boundaries follow the plan: fine-grained targets, KeynoteKit never links ScriptingBridge (enforced by lint grep + a Linux CI leg), navigation stays package-scoped per the "reading is not v0.1.0 API" directive, Snappy knows nothing about framing.
  • Malformed-input handling on 64-bit is genuinely throw-based and fuzz-tested in Snappy/IWAFraming (2,000 random blocks, every truncation prefix, every single-byte corruption); the fixture corpus is pinned with an integrity test that avoids the known Bundle.module vacuous-pass pitfall.
  • Live-test gating is exemplary: the only Keynote-launching suite is env-var opt-in, .serialized, with unique temp dirs and an explanation of why the "safe" test can't trigger TCC. CI is green, two-tiered sensibly, and free of script-injection patterns.

Suggested pre-tag punch list

  1. Fix C1 (per-slide data-identifier collision) + add a two-images-one-slide test.
  2. M9/M10 (result-builder control flow) and M11 (declaration-order builds) — public API surface and documented semantics, cheapest to fix before the tag locks them in.
  3. M2/M3 (uuid-map registration + silently no-oping registration helpers) — these violate the repo's own crash invariants on supported inputs.
  4. M6/M7/M8 trap paths — or, if deferred, narrow the documented "never traps" claims and file follow-ups.
  5. Tag-ref matrix fix in CI so v0.1.0 gets the full build.
  6. The one-line known-answer tests (SHA-1, CRC-32) and the trigger-lowering assertion — tiny effort, large regression surface.

Review conducted module-by-module (six parallel deep reads) against .claude/PLAN.md, .claude/agent-notes.md, and the research findings; all findings verified against the code as of this branch's head.

CI and others added 4 commits July 31, 2026 16:42
Fixes from the PR #43 author reviews, crash-invariant class:

- C1: thread the next data identifier through expandDrawables so two
  images on one slide mint distinct TSP.DataInfo ids (was: both read the
  pre-loop metadata maximum). Test: two-images-one-slide.
- M1: RecordCloner.remapSlideLists now remaps ownedDrawables; cloning a
  slide carrying an inserted image no longer leaves the clone pointing
  at the original's drawables. Unit + basedOn integration tests
  (confirmed to fail without the fix).
- M2: expandTextItems' clone path registers objectUuidMapEntries for
  cloned subtree records via shared registerFreshRecordUUIDs.
- Verifier rule 6: every record minted into a presentation slide member
  must be registered, exempting KN.BuildChunkArchive and
  TSWP.NumberAttachmentArchive (template leaves its own unregistered);
  watermark check now runs unconditionally against all record ids.
- M3: registerUUIDEntries(componentStem:), registerDataObjectReference,
  and registerExternalReferences throw missingComponent instead of
  silently no-oping on a missing component.
- M5: author() asserts one slide record per member before caching
  member/record indices.
- M4: documented that a throw from author() invalidates the surgeon and
  bundle (transactional staging deferred to a follow-up issue).
- Standards: hasTableAttachment/hasObject guards in rewrittenStorage;
  id-0 `?? 0` fallbacks in cloneLastSlide/cloneBodyPlaceholder now
  throw; paragraph-fork record header declares its stylesheet reference
  like the sibling character-style record.
- Split KeynoteArchiveSurgeon+UUIDRegistration.swift out of +Metadata
  (file_length).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Makes the documented throws-not-traps contract true on malformed input
and on 32-bit (arm64_32 watchOS) platforms:

- M6 KeynoteKitProtobuf: TSPArchiveStream bounds-checks header and
  payload lengths in UInt64 space before Int conversion (a crafted
  10-byte varint decoding to 2^63 previously trapped the process);
  ProtobufVarint.read detects tenth-byte overflow and returns nil as
  documented instead of silently wrapping.
- M7 Snappy: reserveCapacity clamped to the input's maximum possible
  expansion (22x - a 6-byte input can no longer force a ~4 GiB
  allocation); maximumBlockSize uses Int(clamping:) so first touch on
  watchOS does not trap; Varint.decode and readInteger reject values
  above Int.max via Int(exactly:) (the width-4 literal sign-bit path
  produced a negative length that passed the bounds guard); per-element
  output.count + length <= expectedCount fail-fast.
- M8 IWAFraming: the writer guards cumulative header/directory offsets
  and throws .zip64Unsupported instead of trapping in UInt32 appends
  when sub-4 GiB entries sum past 4 GiB; the reader converts untrusted
  size/offset fields with Int(exactly:); ZipArchive.serialize split
  into validate/appendLocalEntries (cyclomatic_complexity).
- IWAChunkCodec.maximumUncompressedChunkCount is now `let`: 0 hung
  encode forever and oversized values wrapped the 24-bit length field.

New malformed-input vectors in SnappyTests and TSPArchiveStreamTests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- M12 refuted with schema evidence, not code: the vendored Keynote 15.3
  protos define custom_twist only on KN.TransitionAttributesArchive, and
  KN.AnimationAttributesArchive has no customBounce/customTravelDistance
  shadow copies - a KN.BuildArchive YAML block can never contain a
  customTwist line, so Python's defensive key is unreachable on both
  sides and Swift parity is already complete. BuildRecord's option-bag
  doc now records this instead of implying the key can occur.
- M13: SlideOrder gets order-sensitive coverage via a SyntheticDeck
  helper (tree order deliberately reversed from member order), a
  missingSlide error-path test, and a new nestedSlideNode error - the
  one-level walk now refuses grouped/indented bases instead of silently
  dropping their slides.
- M14: Apple-corpus Snappy fixtures carry SHA-256 digests of the decoded
  payloads, computed independently with python-snappy (cramjam), so
  copy-offset bugs can no longer hide behind length-only assertions.
- Known-answer vectors: SHA-1 "abc", CRC-32 "123456789", Snappy copy1
  offset > 255 (tag high-bits path).
- Lowering vectors: trigger ordinals (onClick/afterPrevious/withPrevious
  -> 1/2/3 + chunk.automatic), build delay, custom motion-path points,
  transition delay/autoAdvance, SlideBuilder if/else+for, run-level
  .italic()/.font(), empty-deck output parses.
- KeyBundle.upsertEntry: all four ordering branches pinned.
- ArchiveGraphComparer now compares messageInfos.dataReferences, so a
  dropped thumbnail/image data reference fails the golden differential.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…icId validation, curved MotionPath [skip ci]

PARTIAL - library compiles but tests have NOT been run against these
changes; resume point in .claude/PARALLEL-WORKTREES.md "Current
position". Landed here:

- M9/M10: buildOptional/buildEither (+buildArray) on SlideItemsBuilder
  and BuildEffectsBuilder.
- M11: builds collect in declaration order; targetIndex maps through
  the z-order permutation (zIndex reorders layers, never the timeline).
- .action {} accumulates like .build (actions: [MotionPath]) - per the
  standing DSL directive, never a runtime error.
- magicId implemented: Deck.write() validates Magic Move pairs (same
  drawable type + same content, per magic_move_correspondence.md) and
  throws public MagicMoveError; the touch-only no-op is gone.
- MotionPath: curved-node API (Node with controlIn/controlOut ->
  .bezier; sharp nodes unchanged so goldens stay valid), natural size
  now extent (max-min), nil action path throws instead of emitting an
  empty TSD.PathSourceArchive.
- .direction() documented as ignored on non-directional transitions;
  Deck.swift stale "content not modelled" doc rewritten.

Remaining for batch C + batch E recorded in PARALLEL-WORKTREES.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CI and others added 10 commits July 31, 2026 18:08
…w-ups #44#50

Finish the PR #43 triage slate: PNG IHDR / hardened JPEGSize, Batch C
tests (M9/M11/magicId/curves), BuildRecord.description for parity diffs,
CI hygiene (tag FULL matrix, permissions, fork-PR guard), lint guard
quoting, PLAN scripting sync, and thematic follow-up issues. Expanded

Co-authored-by: Cursor <cursoragent@cursor.com>
#24 human pass remains the v0.1.0 tag gate.
)

* Monospace probe deck + findings stub for the #64 spike

Prepares the #64 Keynote-bound spike so the human pass is a short scripted
check rather than authoring probe slides by hand.

`monospace_probe.key` is four family slides (Menlo, SF Mono, Courier New,
Monaco) plus an indentation slide. Each family slide renders `iiii` / `MMMM`
/ `1111` at 96pt: in a genuinely monospaced face those three lines are the
same width and their right edges form a clean column; under a proportional
substitute `MMMM` is dramatically wider. That signal is legible at a glance,
which matters because a silent substitution still renders — it just isn't
monospaced.

The indentation slide checks that leading-space columns hold, which is what
#66 actually depends on.

Registered as its own `monospaceProbe` group rather than folded into an
existing one, and the findings file records how to remove it: this is spike
evidence, not a permanent acceptance deck, and it should not add noise to
every future human pass once the working family is pinned.

Structurally green via `swift run AcceptanceDecks` (all records decode, both
SIGTRAP invariants hold); `swift test` and `LINT_MODE=STRICT ./Scripts/lint.sh`
pass. The render result is Leo's to record.

Refs #64

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* #63 scale spike: linear, not quadratic. #64 probe round two.

## #63 — measured, and the concern is refuted

`Sources/ScaleSpike` times `Deck.write(to:)` across a ladder of slide counts.
A single 20-slide number cannot separate "quadratic but small" from "linear",
so it sweeps 3/5/10/15/20 and reports the growth exponent.

    slides  drawables  median(s)
         3         12     0.1049
        20         80     0.6558

    growth exponent 3->20: 0.97   (1 = linear, 2 = quadratic)

A 20-slide demo-shaped deck writes in 0.66s and per-slide cost is flat
(0.035s at 3 slides, 0.033s at 20). The quadratic SHAPE in `expandSlides` is
real — `SlideCatalog` is rebuilt every loop iteration and `locate` is a full
scan — but at demo scale the constant factors dominate. **#44 is not a
v0.1.0 blocker.**

The deck is demo-shaped rather than minimal (4 drawables/slide, transition,
build) because cost scales with records per member; bare slides would
understate it. Findings: `research/findings/scale_spike.md`.

Still needs a human: open the kept 20-slide deck and confirm it renders.

## #64 — round one failed, but not the way it looked

All four families rendered proportional AND at template default size —
including Courier New, which certainly exists and is certainly monospaced.
Both symptoms together rule out font substitution: a missing family would
still honor the authored 96pt.

Probing the archive showed KeynoteKit writes the authored values correctly —
`fontName=Menlo fontSize=96.0` in a `TSWP.ParagraphStyleArchive` variation,
identical for single- and multi-line boxes. So the bytes are right and
Keynote ignored them.

The one structural difference from `TextFormattingContent`, which does render
its authored font, is paragraph count: that deck is one paragraph, every
round-one probe box was three. Hypothesis: whole-item font is only honored on
single-paragraph items.

Round two isolates that variable — slides 1-3 hold paragraph count at one and
vary family; slides 4-5 hold family and vary paragraph count and styling
route. If 1-3 render and 4 does not, it is a KeynoteKit bug that blocks #66,
since code blocks are inherently multi-line.

Refs #63, #64

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* #64 resolved: Menlo works; the failure was #81

Round two settles it by render. Monospace was never the problem.

    slide 1  Menlo, 1 paragraph            monospaced at 96pt   OK
    slide 4  Menlo, 3 paragraphs, item     template default     FAIL
    slide 5  Menlo, 3 paragraphs, spans    1st + 3rd only       PARTIAL

Slides 1 and 4 differ only in paragraph count — same family, same size, same
deck, same run. That is #81 (one `tableParaStyle` entry written regardless of
paragraph count), now confirmed visually as well as structurally.

Slide 5 is the useful surprise: styling each paragraph's own `Text` span
fixes the FIRST and THIRD paragraphs and leaves the SECOND proportional. So
per-span is a different off-by-one, not the same defect reappearing — and
#81's fix has to cover both routes. Its acceptance test needs at least three
paragraphs; a two-paragraph case would pass and hide this.

Consequences:

- `Menlo` is the demo deck's family and #66's `CodeTheme` default
- #66 is BLOCKED on #81 — neither styling route renders multi-line code
  correctly today, so shipping it now would produce unstyled code slides

Refs #64, #81, #66

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Correct the record: `MMMM` was a bad probe, not a second bug

The condensed middle line in round two looked like a second off-by-one in
the per-span route. It was not. An isolation probe:

    iiii / xxxx / 1111    interior line renders Menlo — fine
    MMMM / iiii / 1111    MMMM fails in FIRST position
    MMMM alone            still condensed, single paragraph

`MMMM` fails wherever it appears, including alone on a slide with no
multi-paragraph involvement. Any other interior line renders correctly. That
is Keynote substituting a condensed face for capital M in Menlo at 90pt —
its shrink-to-fit on a wide glyph run — not a KeynoteKit defect.

The ruler was badly chosen: I picked `MMMM` precisely BECAUSE it is the
widest glyph, which is what provokes the substitution. A width comparison
needs glyphs that do not trigger fallback.

Consequence: #81's fix is complete, and #66 is unblocked.

Refs #64, #81, #66

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Write one tableParaStyle entry per paragraph (#81)

Item-level `.font`/`.fontSize`/`.foregroundColor` reached only the FIRST
paragraph of a multi-paragraph text box. Keynote rendered the rest at the
template default, silently — the archive stayed valid, so nothing failed
until a human looked at a slide.

`collapsedEntries` run-length collapsed adjacent identical paragraph formats
into one entry at offset 0. That reads like a reasonable optimization, but
Keynote treats each `tableParaStyle` entry as a paragraph boundary marker,
so collapsing left later paragraphs unstyled.

The fixture settles the intended shape. A human-authored 5-paragraph body
storage in `build_action_B.key` writes FIVE entries:

    char=0  -> 2651127
    char=15 -> 0
    char=30 -> 0
    char=47 -> 0
    char=63 -> 0

The style rides entry 0; later boundaries carry identifier 0, meaning "same
as the preceding entry". The entry must exist even though it references no
record.

`paragraphEntries` emits one entry per paragraph, using identifier 0 for a
repeat. Fork records are still deduped — the dedupe is on records, not
entries. `applyParagraphStyles` filters identifier-0 entries out of the
header references, since 0 is not a record.

Two existing tests asserted the collapsing behavior; they encoded the bug, so
they now assert the per-paragraph shape instead. Added
`MultiParagraphFormattingTests` and a `multi_paragraph_formatting`
acceptance deck.

Everything uses THREE or more paragraphs deliberately: a two-paragraph case
exercises only the first and last and passes while interior paragraphs stay
broken. That is exactly how the per-span variant of this bug survived the
first #64 probe round.

`swift test` 70 green, `LINT_MODE=STRICT ./Scripts/lint.sh` exit 0.
Structural only — the render pass is the real gate.

Refs #81, #64, #66

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Fix the crash: a repeat entry must omit `object`, not zero it

Leo's render pass crashed Keynote on open. My fault, and a worse failure than
the bug being fixed.

The per-paragraph entries were right; writing them was not. I set
`entry.object.identifier = 0` for a repeat, which MATERIALIZES a
present-but-empty `TSP.Reference`. Keynote resolves that to nil and crashes —
the same id-0 trap already recorded twice in agent-notes, for `RecordCloner`
remaps and for plain character spans. I read the template's repeats as
"identifier 0" and generalized from that without checking how they serialize.

The wire bytes are unambiguous:

    template repeat:  [08 0f]         characterIndex only, no object field
    what I wrote:     [08 0f 12 00]   present-but-empty reference

Reading the identifier back cannot tell them apart — absent and zeroed both
report 0. Only `hasObject` does.

## Changes

- `applyParagraphStyles` assigns `object` only for real identifiers
- `UUIDMapVerifier` gains **rule 7**: no storage object-attribute entry may
  carry a present-but-empty reference, across `tableParaStyle`,
  `tableCharStyle`, and `tableListStyle`. Every deck now passes through it,
  so this class of crash cannot ship again from any code path.
- A regression test asserting `hasObject` is false and the entry serializes
  to exactly `[08 04]`. Verified it FAILS against the crashing version —
  asserting `identifier == 0` alone passes on both, which is precisely why
  the original test missed it.

All 12 acceptance decks regenerate clean under rule 7. 71 tests green,
STRICT lint exit 0.

Refs #81

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The demo `.key`, the movie, and the slide-image screenshots can only be
produced by a human on a Mac running Keynote. Without a written procedure
they rot silently as the API changes.

`docs/RELEASING.md` covers regenerating the demo deck, the Keynote eyeball
pass, movie export via ScriptingBridge (including the TCC prompt on first
run and the manual File -> Export fallback when the scripted path stalls),
slide images, the `.mov` -> H.264 `.mp4` conversion DocC's `@Video`
requires, where each artifact is committed, and which release steps force a
refresh.

States plainly that none of it is CI-verified and links #8.

Two accuracy notes: the scripting snippets use the real signatures
(`openDocument(at:)`, `export(to:format:)`) rather than plausible-looking
ones, and step 1 flags that the demo target ships with #56 — until then the
substitute is `swift run AcceptanceDecks`.

Closes #70

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
SwiftUI Color.resolve stalls the xrsimulator leg mid-suite and leaves
xcodebuild wedged. Skip the suite there with a Testing trait; other
platforms still run it.

Co-authored-by: Cursor <cursoragent@cursor.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant