Skip to content

LTT 1.0 — the Lattice Timing Track

Version: 1.0 · Status: Ratified · Date: 2026-10-08 · Owner: @saden1 · License: CC-BY-4.0 (this document and its conformance fixtures; the reference implementation @laticent/ltt is AGPL-3.0)

The owner signs off every change to this spec’s meaning: a new field, a new layer, a new version. The reasoning behind the format is in 2026-09-24-lattice-timing-track.md. This page is the contract.

An LTT file says when each word of a narration is spoken, for a deck or a tour, in one JSON shape that every Lattice surface reads the same way. A file is named *.ltt.json.

PartHome
The types, one definition of eachdocs/src/lib/ltt/types.ts (package @laticent/ltt)
The JSON Schemadocs/src/lib/ltt/ltt.schema.json, generated from the types by tools/build-ltt-schema.js. npm run build:check fails when the two differ. Do not edit it by hand.
The validatorvalidateLtt in docs/src/lib/ltt/validate.ts
The two encodingspack / unpack in docs/src/lib/ltt/encode.ts
The staleness hash’s inputsegmentHashInput in docs/src/lib/ltt/hash.ts
The cursor (time → word, and re-timing to a clip)makeCursor in docs/src/lib/ltt/cursor.ts. Cadenza re-exports it.
The timing functionspositionAt and timeline in docs/src/lib/ltt/position.ts
The conformance fixturesdocs/src/lib/ltt/conformance/*.json: sample files, each with its expected timeline and the expected position at chosen times
The deck producerdeckLtt in lib/core/ltt-deck.mjs, called by the HTML export (narrationPayload in lib/export/player-core.mjs)
The timing engine’s hashENGINE_HASH in docs/src/lib/cadenza/engine-hash.ts, written by tools/lib/timing-engine-hash.js

@laticent/ltt imports nothing outside its own folder, including no node: built-ins. A boundary gate (checkLttBoundary in tools/check-ownership.js) enforces it. Cadenza may import the package; the other libraries may import it from the step that first needs it.

Built in step 1: the types, the schema, validateLtt and both encodings. Built in step 2: makeCursor (moved in from Cadenza), positionAt, timeline, the conformance fixtures, and the first producer and reader. The Studio’s HTML export writes a deck’s LTT, and the exported player plays from it (guardrail G2).

Built in step 4: isStale, with the Vetrina tour recorder as its first caller (staleStretches in docs/src/lib/vetrina/recorder.ts), the recorder itself, which writes a seekable tour LTT, and Narrator.plan() returning the core’s CaptionTrack. Vetrina’s gate admits @laticent/ltt by that exact name, as Cadenza’s does.

Built in step 3: the video export (lib/export/video.mjs, lattice video), a capture of the narrated HTML export’s own player in render mode, and the first production caller of timeline() (§What video export guarantees). Not built: a reference tour player.

{
"format": "ltt",
"version": "1.0",
"source": { "kind": "deck", "id": "board.md" },
"inputs": { "engine": "sha256:…", "pace": "moderate", "deckPace": "natural", "lang": "en" },
"seekable": true,
"segments": [
{ "id": "d1", "kind": "slide", "at": { "slide": 1 }, "hash": "sha256:…", "basis": "estimate",
"holdMs": 0, "track": { "cues": [ /* … */ ], "durationMs": 3120 } },
{ "id": "d2", "kind": "hold", "at": { "slide": 2 }, "holdMs": 1400 }
]
}

The decision note’s §4.1 has a longer example: a tour stretch that carries every layer.

Times. Every time is a whole, non-negative number of milliseconds, no larger than 2^53 − 1. Times restart at zero in each segment. Because times are whole numbers, the packed encoding can store times relative to a cue and add them back exactly. A producer writing a measured track rounds: cursor.align re-times to fractional milliseconds, and validateLtt refuses a fraction.

source. kind is deck or tour; id is whatever its producer uses to find it again.

inputs. Every input that changes timing, for the whole file: engine (a content hash of the timing engine, never its package version), pace, and optionally lang, lexicon and acronyms. A deck may also carry deckPace. A tour may also carry viewport, motion and stagePace, the screen and motion a recorded run depended on. A deck never carries the tour inputs, and a tour never carries deckPace.

Emphasis goes with the text, not in inputs (settled in step 4). Cadenza takes per-line emphasis spans, and they change a track’s timing, but a span is a character range into one line, so it means nothing file-wide. segmentHashInput(text, inputs, emphasis) appends a segment’s spans, one list per line, when any line has one; with none, the string is exactly what it was, so no existing hash moves. The tour recorder hashes them, and so does the deck producer: the Studio’s bake hands each slide the spans buildTrack timed it with, narrationPayload carries them, and deckLtt hashes a slide as one line. A deck with no emphasis keeps exactly the hashes it had, and re-weighting one span moves only that slide’s hash (test/unit/export/ltt-deck-emphasis.test.js).

engine hashes the SOURCE the estimate is computed from: Cadenza’s track.ts (buildTrack) and every file it reaches through relative imports, in name order, each preceded by its path (tools/lib/timing-engine-hash.js). The import graph picks the files, so an edit to the cursor, the validator or a comment elsewhere does not mark every measured segment stale. Source rather than the bundled build, because the Studio runs Cadenza from source and never sees the bundle, and because a bundler upgrade that reprints the same code should not mark every measured segment stale. npm run engine-hash:build regenerates the constant, and a unit test fails when the committed one is stale.

seekable. True when every segment’s length is known, so the segments lay end to end on one timeline. A deck is always seekable. A tour is seekable only once a run has been recorded, and every waited stretch then records how long its wait took in waitedMs.

“Known” means known to the precision of the segment’s basis. A clip’s length is the estimate until a producer records its measuredMs, and the HTML export’s producer does not decode clips, so its file is basis: "estimate" throughout: timeline() on it is exact for a captions-only export and an estimate for a voiced one. The player itself follows the real clips (rule 3). A consumer that needs the voiced timeline exactly, such as video export, decodes the clips and fills in measuredMs first.

Each segment has a unique id, a kind and an at.

kindWhereCarriesIts length
slidedeck; at: { slide: n } (1-based)hash, basis, holdMs, track, tailMs, optional layersholdMs + track.durationMs + tailMs
holddeck; a slide with no narrationholdMs onlyholdMs
stretchtour; at: { beats: [first, last] }hash, basis, track, optional after / waitedMs, optional layersthe wait + the track
  • A deck has one segment per slide, in order, with no gaps: slide 1, 2, 3 and so on. A silent slide still waits its hold, so a missing slide would be a missing hold. The first segment’s holdMs is 0, because Play speaks the first slide at once. Every later slide waits one hold when it arrives, whether or not it is narrated. The hold is a section hold or a slide hold according to the arriving slide.
  • tailMs is the breath the player holds after a slide’s last cue, before it advances. The track ends at the end of its last cue, so this is the one part of a slide’s length the track cannot carry. The deck producer writes the larger of the last sentence’s own breath and the deck pace’s leave beat (paceBeatMs('leave'): 400, 700 or 1,100 ms), so a closing line lands before the next slide appears; a weighted closing line (a coda) keeps its emphasis hold on top of that.
  • A stretch has no tail. Whatever passes between a stretch’s last cue and the next stretch’s first belongs to that next stretch’s wait.
  • waitedMs is how long a recorded run spent between the end of the previous segment’s track and the start of this stretch’s first cue.
  • after names what a stretch waits on before it starts: awaitUser, until or act. It is never a time. Absent means the stretch follows the previous segment at once.
  • basis says how far to trust a segment’s numbers: estimate (Cadenza’s calculation from text alone) or measured (re-timed to a real clip).

A deck can say one line before slide 1 and one after the last slide: its greeting: and closing: (lib/core/resolve-bookends.mjs, engineering/decisions/2026-09-27-narration-bookends.md). Neither is a slide, so neither is a segment. They sit in an optional top-level bookends object, and segments[i] stays slide i + 1.

"bookends": {
"greeting": { "morning": {…}, "afternoon": {…}, "evening": {…}, "neutral": {…} },
"closing": {…}
}
  • Each bookend is laid out exactly as a narrated slide: id, kind: "bookend", hash, basis, holdMs, track, tailMs and an optional audio layer. positionAt reads it like a slide. Its id shares the file’s id space.
  • The greeting comes in four variants. A player picks one from the viewer’s local hour (greetingPeriod): morning from 04:00, afternoon from 12:00, evening from 17:00. neutral (“Hello”) is for a surface with no viewer clock: a video render, a .vtt. A file ships a greeting only with all four variants.
  • The greeting’s holdMs is 0 (Play speaks it at once), and its tailMs is the gap before slide 1. The closing’s holdMs is the gap after the last slide, and its tailMs is 0.
  • Audio blocks for a bookend are keyed by its id (greeting-morning, …, closing) rather than a slide index. A clip src is #lp-audio/<id>/<n>.
  • timeline(ltt, { greeting }) puts the chosen greeting (default neutral) at 0, starts every slide that much later, and appends the closing. It returns them as greeting and closing entries beside segments, so segments[i] is still slide i + 1. { bookends: false } lays out the slides alone.
  • Where it sits under G4 (§Layers). It is not a segment layer, since positionAt of every segment is unchanged. It changes the deck’s timeline, so it has its own record: the decision note above, approved by the owner on 2026-09-27. version stays "1.0". Lattice is pre-GA, so no older reader had to be kept working.

A slide or stretch segment’s track is exactly a Cadenza CaptionTrack: cues[] and durationMs. Each cue has display, words[], startMs, endMs, charOffset, and optionally endsParagraph and weight. Each word has display, spoken, startMs, endMs, charOffset, and optionally weight. A charOffset counts UTF-16 code units into the source text, as a JavaScript string index does, so a character outside the Basic Multilingual Plane counts two. durationMs is the end of the last cue. A narrated segment has at least one cue, and every cue has at least one word.

The core is closed: a key it does not define is an error. Every reader must honor it, and a reader that cannot read the core cannot play the file.

A layer is an optional block on a segment, owned by one library.

LayerOwnerA reader that does not know it…
audio — voice, and clips[]: one per cue at most, each { cue, src, clip, measuredMs?, leadMs? }Suonoplays silently on the estimate
actions — { cue, word, match, verb, target?, arrive? }Vetrinaskips it; captions and audio still play

Everything outside the core is open. A reader skips a key it does not know, and validateLtt ignores one, so a file written with a later layer still plays in an older reader.

The audio layer holds one clip per cue (owner ruling, 2026-09-24; revised inside 1.0, because no file had carried the layer). Every producer records a sentence at a time, and the transport advances on each clip’s end (rule 3), so the file describes the audio as it exists. A cue with no entry in clips has no audio. clips run in cue order. cue is the index of the cue a clip speaks, clip is a hash of its bytes, measuredMs is its decoded length once a producer has decoded it, and leadMs is the encoder silence at its head (rule 7). The rejected alternative, one clip per segment, meant joining the clips with the breaths recorded as silence: on a 13-slide, 87-sentence deck, 14.2 s of encoded silence (~113 KB at the bake’s 64 kbps), 4 s of encoder lead at the joins, and a transport that could no longer advance or re-time per sentence.

An action is anchored to a word, never to a time. match is the word the author named, normalized: case-folded, with edge punctuation stripped (normalizeMatch, the same rule as Vetrina’s findCueWord). validateLtt fails when the word at {cue, word} no longer normalizes to match. That is how a narration edit that moved the words under an action is caught.

What may become a layer (G4). A new layer must change what positionAt returns for some time in some segment. It needs its own decision record and the owner’s sign-off. Anything else is metadata and belongs to its producer, not to this format.

The JSON Schema says what a file may contain. It cannot say how the parts relate, so a file can pass the schema and still fail validateLtt. Validate with validateLtt when correctness matters. The rules only it enforces:

  • which fields belong to which segment kind (tailMs on a slide only, after and waitedMs on a stretch only, no narration on a hold), and which inputs belong to a deck or a tour;
  • deck segments run one per slide from slide 1 with no gaps, and the first one’s hold is 0;
  • tour stretches run forward through the storyboard’s beats;
  • a seekable tour records waitedMs on every waited stretch;
  • each audio clip names a cue the track has, clips run in cue order, and no cue has two;
  • the timeline: cue starts in order, each word inside its cue, words running forward, cues not overlapping, and durationMs equal to the last cue’s end;
  • each action’s match still names the word at {cue, word};
  • segment ids are unique.

Two schema-level facts also run the other way: an unknown key outside the core passes both checks, and a key inside the core fails both.

version is "1.<minor>", and this spec is "1.0". A new optional field outside the core is a minor revision. A new required field, or any change to the core, is a new major version, because the core is closed and every reader depends on it.

So a reader of any 1.x MUST read every 1.y file, ignoring the fields it does not know outside the core, and MUST refuse a file whose major version it does not know. A writer writes the version it implements (LTT_VERSION).

One data model, two encodings. Conversion is lossless both ways.

  • Canonical — named keys, every field. This is what tools read and write, and what *.ltt.json holds. A producer drops Cadenza’s track in as-is.
  • Packed — what the HTML export embeds. The file gains "encoding": "packed", and each segment’s track becomes tuples, as does each bookend’s. Every other key rides through unchanged.
PackedTrack = [durationMs, PackedCue[]]
PackedCue = [display, startMs, endMs − startMs, charOffset, PackedWord[], {e?, w?}?]
PackedWord = [display, startMs − cue.startMs, endMs − cue.startMs,
charOffset − cue.charOffset, {s?, w?}?]

e is endsParagraph and w is weight, each written only when the source has the key. s is spoken, written only when it differs from display. The trailing object is left out when it is empty. On the 141-word sample in the decision note’s §7, the packed form is 4,432 bytes (833 gzipped) against the canonical form’s 13,718 (2,391 gzipped). The note measured 4,400 (856) for an earlier tuple layout; the test pins only the ratio, at least 2.5x gzipped.

In an HTML export the player resolves each clip by its src: the fragment below, or a data: URI carried in place. Any other src plays nothing, because the export is self-contained and offline.

The packed LTT rides in one inert block, <script type="application/lattice+ltt" data-lp-ltt>, with every < written as \u003c. The clip bytes do not ride in it: each slide with audio gets its own <script type="application/lattice+audio" data-lp-audio="<n>">, a JSON array of data: URIs, where n is the slide’s 0-based index. A clip’s src in the LTT names its place there as the fragment #lp-audio/<n>/<k>: entry k of block n. The split exists because a viewer pays for what is parsed. The LTT is a few kilobytes and the transport needs all of it at Play; the clips are megabytes and the player needs one slide’s at a time (narrationPayload in lib/export/player-core.mjs).

The round-trip test in encode.test.ts is generated from the schema. It builds a file with every field the schema defines, so a field added to the types but not to packTrack fails the test (G1).

  • A segment’s hash is SHA-256 over the UTF-8 bytes of segmentHashInput(text, inputs, emphasis?) (docs/src/lib/ltt/hash.ts): canonical JSON of [text, inputs], or of [text, inputs, emphasis] when the segment’s narration carries emphasis (one list of spans per line, in line order; an absent or empty emphasis leaves the two-element form). Object keys are sorted at every depth, and the rest follows JSON.stringify. text is the exact string the segment’s track was built from, the one handed to buildTrack. A tour stretch also covers the storyboard steps it spans. inputs is the file’s inputs object.
  • The package defines the input string and no digest, because it imports nothing. Each runtime digests with its own SHA-256 (node:crypto in Node, crypto.subtle in a browser). A golden vector in test/unit/tools/ltt-schema.test.js pins both the string and the digest.
  • On a mismatch, a reader marks the segment stale. An estimate segment may be rebuilt freely. A measured segment or a recorded wait is flagged and kept, because it cannot be rebuilt from text. isStale(ltt, current) takes each segment id mapped to its source’s hash now, and returns the stale ones: changed or gone, each with keep true when the segment holds measured data (a recorded wait, a measured clip, a measured basis). It never edits the file. Its first caller is the tour recorder’s staleStretches.

positionAt(segment, localMs, cursor?) says where a player is, localMs after a segment starts: the phase (wait, hold, cue, gap or end), the cue, the word to show as spoken, the time on the track the crawl reads, each cue’s onset, the segment’s length, and the actions whose word has been reached. It lays the segment out as the transport plays it:

  • a slide first holds for holdMs, and a stretch for its waitedMs (0 if none);
  • a cue with a clip plays for its measured speech, measuredMs − leadMs, or its estimate when no measurement is recorded. Its words are re-timed to that length (cursor.align), which moves every later cue;
  • a cue with no clip plays for its estimate, but never less than 300 ms, and 900 ms when the estimate is 0 (rule 4). Its words still run on the estimate, and the last one is held through the rest of the floor;
  • then the breath: the track’s gap to the next cue, or after a slide’s last cue, tailMs. A stretch has no tail.

The word lookup is the cursor’s own (makeCursor), so there is one answer to “which word now”. A caller holding a cursor it has already re-timed passes it, and positionAt uses it as it stands. The exported player always does.

timeline(ltt) lays a seekable file’s segments end to end and returns each one’s start and length. It throws on a file that is not seekable, because a wait of unknown length has no place on a timeline.

positionAt also returns the layout it computed — waitMs, and each cue’s onsets, played and gaps — and the exported player arms every timer from it: the arrival hold, a silent cue’s length and every breath. The transport restates no timing rule of its own. The one length it does not take from positionAt is a clip’s, which ends when the audio does (rule 3).

The conformance fixtures (docs/src/lib/ltt/conformance/*.json) pin the arithmetic: no hold on slide 1, an arrival hold on every later slide, hold segments, the breath between cues and after the last one, the silent-cue floor, and lead trim. Four checks hold positionAt and the player to them:

CheckWhat it runs
docs/src/lib/ltt/position.test.tsthe fixtures, against the source
test/unit/export/ltt-conformance.test.jsthe fixtures, against the inlined copy the player ships, plain and after an esbuild minify
docs/src/lib/ltt/inlined.test.tsthe fixtures, after rolldown (Vite 8’s bundler) minifies the package on its own. Not the whole app bundle, which only the Studio e2e reaches
test/unit/export/ltt-player-transport.test.jsthe real exported player, in jsdom on a fake clock: every slide must arrive when timeline() says, to the millisecond, and narration must stop when the last segment ends

A real-browser run (tools/verify-narrated-player.mjs, on demand) plays exports with clips, including clips 1.3× and 0.7× their estimate, and checks arrivals against timeline() with the real lengths filled in.

Inlining. The exported player’s script is CSP-hashed and cannot import, so it carries unpackTrack, makeCursor and positionAt as source text. Each is self-contained. positionAt’s one module-scope reference is in the branch that builds its own cursor, which is why the player must always pass one.

The timing functions tell a player where in a segment it is. The transport decides when to move between segments. These rules are normative; the HTML player (lib/export/player-core.mjs) implements them today, and any second player, including the video export’s capture of that player, must follow them.

  1. Play speaks the current slide at once, with no hold. The first Play of a page load on slide 1 speaks the greeting first, when the file has one, then holds its tailMs and speaks slide 1. The first Play uses the greeting up wherever it starts, so it never plays later in that page load.
  2. When a slide’s narration ends, the player advances first, then holds on the slide that arrived, then speaks it. The hold is the section or slide hold of the arriving slide. A slide with no narration that the player advanced to is on screen for exactly its hold.
  3. Advance on clip end. With audio, the next cue starts when the clip ends (onended), never at a computed time. The breath after a cue is held after that. Measured cost: headless Chromium 131 fires ended 90–110 ms after the audio content stops, so a voiced HTML export runs late of any computed timeline by about that much per clip: roughly 8 s over an 87-sentence deck. WebKitGTK 2.52 measured 51–76 ms per clip on the same real-speech deck. A capture in render mode (video export) has no such latency, so the two disagree on voiced segment lengths by that amount until one of them changes. Firefox’s figure is not measured: this sandbox’s only audio device is a PulseAudio null sink, which plays about 1.6× slow.
  4. A cue with no clip shows its caption and holds for its estimated length, but never less than 300 ms (and 900 ms when the estimate is missing). The caption crawl still runs on the estimate itself. So in a captions-only export, a cue shorter than 300 ms plays longer than its track says, and a timeline for such a file applies the same floor (positionAt does). A clip that fails to decode is treated the same way. The HTML player reads both signals a decode failure sends, the element’s error event and a play() rejection that is not NotAllowedError, and falls back once. Only NotAllowedError, an autoplay refusal, stops narration.
  5. Pause restarts the slide rather than resuming mid-word. Pausing during a bookend ends it: Play then speaks the slide, and the bookend does not repeat.
  6. Manual navigation re-anchors on the chosen slide and speaks it with no hold. Onto a slide with no narration, the player stays (owner ruling, 2026-09-25): narration remains armed, nothing is said, and the next manual move onto a narrated slide speaks it. A viewer who chose a silent slide wants to look at it; speaking it would reach the end of its narration at once and advance, so the slide left as soon as it arrived. A silent last slide ends narration instead, as reaching the end does. Play on a silent slide is rule 1, not rule 6: it advances at once and holds on the slide that arrives.
  7. Within a segment, the player may re-time a cue to the decoded clip’s length (cursor.align). That is the only way a measured length enters playback. Lead trim: a clip with leading silence (leadMs) starts playing leadMs in, and the cue is aligned to the clip’s length minus leadMs, the speech alone. The Studio’s bake (compressClip in lib/core/narration-encode.mjs, which the CLI’s --narrate shares) records leadMs as the MP3 encoder’s delay (46 ms at 24 kHz) plus the voice’s own silence before its first word: the first sample above 2% of full scale, less a 10 ms pre-roll (speechOnsetMs, lib/core/speech-pcm.mjs). Kokoro leaves 290–390 ms of it before every sentence, and a lead that counted only the encoder lit each caption about 0.3 s early. A player that skips the trim plays that silence before the first word while its crawl clock already runs, so its caption leads the voice by the whole leadMs. Present plays the store’s uncompressed clips, which carry no leadMs, so it measures the same onset on each decoded clip as it starts (read-aloud.ts, onItemStart) and aligns the cue from there, for the speech that is left. It also moves the next cue’s estimate on by that lead, or the next caption would light as the next clip starts and snap back when its own onset arrives. The crawl’s clock inside the cue is the clip’s currentTime minus leadMs, so it starts at the first word, not leadMs into it. The seek and the re-timing wait for a known duration longer than leadMs: WebKit can report loadedmetadata before it knows an MP3’s length, and a seek then makes it end the clip at once, skipping the sentence in silence (measured on WebKitGTK 2.52). A player anchors on durationchange when the length arrives late.
  8. The closing plays when narration reaches the end of the deck by itself: the last slide ran out of cues, or the player advanced onto a silent last slide (rule 2). It waits its holdMs, speaks, holds its tailMs, and narration ends. It plays at most once per page load. A silent last slide reached by manual navigation ends narration under rule 6 and never gets a closing.

Video export (step 3) is built: lib/export/video.mjs, run by lattice video (engineering/pipeline.md §6). It is a capture of the narrated HTML export’s own player, so it guarantees what that player shows: narration, slide and hold timing, and Anima motion, because the capture includes whatever the player draws. The captions ride as a tx3g track in the MP4 (3GPP timed text, the subtitle format QuickTime reads; lib/export/tx3g.mjs) and a .vtt sidecar, both laid out by timeline() over the measured clip lengths; the caption band is not in the frame. The Guide’s focus and gestures are in the frame, because a narrated export carries the Guide unless it was made with the Guide switched off (--no-guide, or the Studio’s switch) (decisions/2026-09-27-guide-in-the-exported-player.md). It does not guarantee tour actions (no recorder writes a seekable run yet).

Render mode is the player’s one hook for the capture (window.__lpRender, set only by the capture). Media does not play on the capture’s clock, so rule 3 cannot apply there: a voiced cue lasts its clip’s measuredMs − leadMs, which the capture writes into the LTT after decoding every clip, and the cue is re-timed to that length exactly as the anchor in rule 7 would. It takes the same timer path a cue with no clip takes (rule 4), and a clip with no measured length falls back as a clip that failed to decode. So the video follows timeline() exactly, and runs about 100 ms per clip ahead of a live Chromium viewing (rule 3’s measured cost). test/unit/export/ltt-player-transport.test.js pins render mode to timeline(), and the capture refuses to write a file when the cue starts the player logged disagree with it by more than half a frame.

The encoder, muxer and frame rate are recorded in 2026-09-25-video-export.md.

Out of scope: Lattice is not generally available, so no deck exported before the LTT needs converting. The design note’s legacy converter was dropped for that reason (owner ruling, 2026-09-24).

VersionDateChange
1.02026-10-08Moved from engineering/ltt.md to spec/LTT-1.0.md and published at /spec/ltt/ (spec audit §6 step 2). Three clarifications from the library trio, each matching what the reference implementation already did or what §Versions already implied: a 1.x reader reads every 1.y file and refuses a new major; the staleness hash input is [text, inputs] or [text, inputs, emphasis]; charOffset counts UTF-16 code units. Record: engineering/decisions/2026-10-08-library-trio-before-publish.md.
1.02026-09-24First version, with bookends added before any reader shipped. Record: engineering/decisions/2026-09-24-lattice-timing-track.md.