<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://debrief.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://debrief.github.io/" rel="alternate" type="text/html" /><updated>2026-06-02T17:17:41+00:00</updated><id>https://debrief.github.io/feed.xml</id><title type="html">Debrief Website</title><subtitle>Debrief Maritime Analysis Tool – Powerful, Fast, Free, and Intuitive</subtitle><entry><title type="html">Shipped: Briefing renderer honours Trail display mode</title><link href="https://debrief.github.io/shipped-briefing-trail-mode" rel="alternate" type="text/html" title="Shipped: Briefing renderer honours Trail display mode" /><published>2026-06-02T00:00:00+00:00</published><updated>2026-06-02T00:00:00+00:00</updated><id>https://debrief.github.io/shipped-briefing-trail-mode</id><content type="html" xml:base="https://debrief.github.io/shipped-briefing-trail-mode"><![CDATA[<h2 id="what-we-built">What We Built</h2>

<p>When you compose a storyboard scene in Trail mode, you’re making a deliberate narrative choice: show the recent history of each platform — the snail-trail leading up to a moment — rather than its entire route. “The minute before contact” reads very differently as a growing tail than as a fully-drawn line that was there from the start. Spec #258 taught the main application to capture that Full-vs-Trail choice per scene and honour it on playback. But the <em>exported</em> briefing — the standalone, air-gapped SPA you hand to someone who was never near the analysis environment — quietly ignored it, always drawing each platform’s complete route. A scene you framed to build toward a moment played back flat, its emphasis silently discarded.</p>

<p>This change makes the briefing renderer honour the display mode that was captured with the scene. In Trail mode each track now grows from its start up to the current playback time, trailing the moving position dot. In Full mode — and in legacy briefings exported before display mode was a thing — the whole track shows exactly as it always has. The author’s intent now survives the trip from the app preview into the shareable, offline briefing.</p>

<h2 id="how-it-fits">How It Fits</h2>

<p>The briefing renderer is the end of the storyboarding pipeline (epic E13): the point where a composed, scoped storyboard becomes a self-contained file someone can play back offline, with no service and no network behind it. Everything the fix needs was already there — the per-scene display mode and the per-vertex track timing are carried into the exported briefing, and the moving position dot already depends on that same timing. This was never a data-capture or export gap; it was the renderer not reading what it had been handed. The fix stays entirely on the display side: one front-end component (<code class="language-plaintext highlighter-rouge">BriefingMap.tsx</code>) plus a new <code class="language-plaintext highlighter-rouge">trackDisplay.ts</code> helper, no schema change, no change to how scenes are captured, scoped, or exported.</p>

<h2 id="key-decisions">Key Decisions</h2>

<ul>
  <li>
    <p><strong>Reuse the main app’s exact trail-slicing helper rather than writing a renderer-specific copy.</strong> The briefing calls the same <code class="language-plaintext highlighter-rouge">sliceTrackToTime</code> from <code class="language-plaintext highlighter-rouge">@debrief/utils</code> that the in-app preview uses, so the trail in the exported file is identical in shape to what the author saw while composing — visual parity by construction, with no second implementation to drift out of step. It’s an internal workspace package the renderer already pulls in transitively, so this is reuse, not a new third-party surface.</p>
  </li>
  <li>
    <p><strong>Grow the track as a stable-keyed map polyline whose points update in place each frame</strong>, mirroring how the moving dot already updates, rather than rebuilding the map layer on every tick. An earlier oscillation bug (#264) came from tearing the layer down too eagerly each frame; updating positions in place keeps the growth smooth and steers well clear of that failure mode. Non-temporal context — region outlines, annotations, reference points — stays on the existing layer, untouched.</p>
  </li>
  <li>
    <p><strong>One predicate decides everything: a scene is Trail only if its display mode is exactly <code class="language-plaintext highlighter-rouge">trail</code>; anything else shows the full track.</strong> Full, absent (legacy), and any unrecognised value all fall through to “show the whole route” — the safe, non-destructive default. That single rule is also why every briefing exported before #258 keeps playing back exactly as it does today.</p>
  </li>
  <li>
    <p><strong>A track that lacks usable per-vertex timestamps falls back to its full line — never blank, never an error — even in a Trail scene.</strong> This reuses the same validity gate that already governs the moving dot, so a track either participates in both time-driven behaviours or neither. No half-states, no confusing empty geometry where context is expected.</p>
  </li>
</ul>

<h2 id="screenshots">Screenshots</h2>

<p>The trail grows as playback time advances. Here’s the same 8-point Alpha track at the window start, midway through playback, and at the end:</p>

<p><img src="/assets/images/future-debrief/shipped-briefing-trail-mode/trail-start.png" alt="Exported briefing at the scene start: only the moving position dot is visible over the Channel, with near-zero track behind it" /></p>

<p><img src="/assets/images/future-debrief/shipped-briefing-trail-mode/trail-growth.png" alt="Exported briefing mid-playback: a blue snail-trail has grown partway, trailing the moving position dot" /></p>

<p><img src="/assets/images/future-debrief/shipped-briefing-trail-mode/trail-end.png" alt="Exported briefing at the window end: the complete eight-vertex track is drawn across the Channel" /></p>

<p>Scrubbing the playback slider forward and back shows the trail growing and shrinking in real time:</p>

<p><img src="/assets/images/future-debrief/shipped-briefing-trail-mode/interaction.gif" alt="Animation of the exported briefing's trail growing from empty to full as the playback slider is dragged from start to end" /></p>

<h2 id="by-the-numbers">By the Numbers</h2>

<table>
  <thead>
    <tr>
      <th>Metric</th>
      <th>Value</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Feature tests passing</td>
      <td>24 (20 unit + 4 Playwright)</td>
    </tr>
    <tr>
      <td>Test failures</td>
      <td>0</td>
    </tr>
    <tr>
      <td>Trail growth measured</td>
      <td>1 → 5 → 8 vertices (8-point Alpha track)</td>
    </tr>
    <tr>
      <td>Repo gate</td>
      <td>Passing (ruff, eslint, pyright, tsc, pytest 2162, vitest)</td>
    </tr>
    <tr>
      <td>Components edited</td>
      <td>1 (<code class="language-plaintext highlighter-rouge">BriefingMap.tsx</code>)</td>
    </tr>
    <tr>
      <td>New helper modules</td>
      <td>1 (<code class="language-plaintext highlighter-rouge">trackDisplay.ts</code>)</td>
    </tr>
    <tr>
      <td>New workspace dependencies</td>
      <td>1 (<code class="language-plaintext highlighter-rouge">@debrief/utils</code>)</td>
    </tr>
    <tr>
      <td>Schema changes</td>
      <td>None</td>
    </tr>
  </tbody>
</table>

<p>The 24-test suite breaks down as 20 unit tests covering the trail-slicing contracts and the mode predicate, and 4 Playwright tests exercising the full playback flow: a growing trail in Trail mode, a constant track in Full mode, legacy scenes without a display mode falling back to full, and a mixed briefing reapplying the right mode per scene.</p>

<h2 id="lessons-learned">Lessons Learned</h2>

<p>Reusing the main app’s exact <code class="language-plaintext highlighter-rouge">sliceTrackToTime</code> helper made visual parity true by construction — there’s no second implementation to drift out of step. The real fix was a <em>reading</em> gap, not a data gap: everything needed (per-scene <code class="language-plaintext highlighter-rouge">display_mode</code>, per-vertex timestamps) was already in the exported briefing; the renderer just wasn’t paying attention to it.</p>

<p>Sharing one validity gate between the growing trail and the moving dot keeps them consistent — a track shows both time-driven behaviours or neither, with no confusing empty geometry where context is expected. And a deterministic, hidden per-track vertex-count node made the Playwright growth assertion robust against Leaflet’s polyline simplification: the exact vertex count can vary as Leaflet’s optimisation kicks in, but it strictly grows from start to mid to end, and that monotonic growth is what the test asserts.</p>

<h2 id="whats-next">What’s Next</h2>

<p>This completes the Trail-mode story for both the standalone, air-gapped briefing and the #273 live-Preview tab. Epic E13 (storyboarding end-to-end) is now whole: Trail and Full modes are captured per scene, honoured on playback in the main app, and honoured in exported briefings. A subtle fade on the trail’s tail is possible future polish — not required for correctness, but it could draw the eye even more explicitly to the direction of motion.</p>

<p>→ <a href="https://github.com/debrief/debrief-future/tree/main/specs/280-briefing-trail-mode/spec.md">See the spec</a>
→ <a href="https://github.com/debrief/debrief-future/tree/main/specs/280-briefing-trail-mode/evidence">View the evidence</a></p>]]></content><author><name>Ian</name></author><category term="storyboard" /><category term="briefing-zip" /><category term="leaflet" /><category term="display-modes" /><category term="testing" /><summary type="html"><![CDATA[Exported briefings now honour Trail mode — each track grows from its start to the current playback time, mirroring what the author saw in the app.]]></summary></entry><entry><title type="html">Shipped: Prefix-aware STAC typing</title><link href="https://debrief.github.io/shipped-prefix-aware-stac-typing" rel="alternate" type="text/html" title="Shipped: Prefix-aware STAC typing" /><published>2026-06-02T00:00:00+00:00</published><updated>2026-06-02T00:00:00+00:00</updated><id>https://debrief.github.io/shipped-prefix-aware-stac-typing</id><content type="html" xml:base="https://debrief.github.io/shipped-prefix-aware-stac-typing"><![CDATA[<table>
  <thead>
    <tr>
      <th>Before</th>
      <th>After</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">const log = (props['debrief:provenance_log'] as PropertiesProvenanceEntry[]) ?? [];</code></td>
      <td><code class="language-plaintext highlighter-rouge">const log = props['debrief:provenance_log'] ?? []; // typed</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">const props = item.properties as Record&lt;string, unknown&gt;;</code> at the write path</td>
      <td><code class="language-plaintext highlighter-rouge">const props: StacItemProperties = item.properties;</code> — modelled-key writes are checked</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">asset as StacAsset &amp; { 'debrief:toolId'?: string }</code> hand-cast</td>
      <td><code class="language-plaintext highlighter-rouge">asset['debrief:toolId']</code> typed as <code class="language-plaintext highlighter-rouge">string \| undefined</code> via <code class="language-plaintext highlighter-rouge">StacAsset</code></td>
    </tr>
    <tr>
      <td>Modelled <code class="language-plaintext highlighter-rouge">debrief:*</code> fields fell through a <code class="language-plaintext highlighter-rouge">[key: string]: unknown</code> bag — typos failed silently at runtime</td>
      <td>A typo’d or renamed key fails the typecheck at build time, on read <strong>and</strong> write</td>
    </tr>
    <tr>
      <td>Adding a new schema field surfaced only in generated declarations</td>
      <td>A new <code class="language-plaintext highlighter-rouge">debrief:*</code> slot flows to the writers’ typed surface automatically — no hand-edit</td>
    </tr>
  </tbody>
</table>

<h2 id="what-we-built">What We Built</h2>

<p>Read a <code class="language-plaintext highlighter-rouge">debrief:provenance_log</code> off a STAC Item in either of our writer hosts and you now get the type the schema promises — not <code class="language-plaintext highlighter-rouge">unknown</code>, and not a value you’ve had to cast into shape and hope you spelled the key right. That was the gap; it’s closed. The modelled <code class="language-plaintext highlighter-rouge">debrief:*</code> keys now arrive at the writers’ real call sites as named, typed slots flowing straight from the LinkML schema, and the <code class="language-plaintext highlighter-rouge">as</code> casts that papered over the gap are gone — across three surfaces: the five item-property fields (<code class="language-plaintext highlighter-rouge">platforms</code>, <code class="language-plaintext highlighter-rouge">tags</code>, <code class="language-plaintext highlighter-rouge">feature_tags</code>, <code class="language-plaintext highlighter-rouge">overrides</code>, <code class="language-plaintext highlighter-rouge">provenance_log</code>), the three collection-summary fields on <code class="language-plaintext highlighter-rouge">StacSummaries</code>, and the two asset-level keys (<code class="language-plaintext highlighter-rouge">debrief:toolId</code>, <code class="language-plaintext highlighter-rouge">debrief:snapshotTimestamp</code>) we newly modelled onto <code class="language-plaintext highlighter-rouge">StacAsset</code>. Crucially the typing now reaches the <strong>write</strong> path too: both hosts used to widen the properties bag to <code class="language-plaintext highlighter-rouge">Record&lt;string, unknown&gt;</code> at the mutation site, so a mis-typed <em>write</em> slipped through — that widening is gone.</p>

<p>The payoff is the next field, not the current ten. Add a new <code class="language-plaintext highlighter-rouge">debrief:*</code> slot to the schema, regenerate, and it appears as a typed slot wherever the writers touch it — without anyone editing a writer-owned type declaration. A misspelled or renamed key stops being a silent runtime <code class="language-plaintext highlighter-rouge">undefined</code> and becomes a build failure. The kind of quiet data loss that happens when a properties bag grows but the access sites don’t keep up simply can’t compile any more.</p>

<h2 id="how-it-fits">How It Fits</h2>

<p>This finishes a promise we deliberately deferred. Spec #240 made <code class="language-plaintext highlighter-rouge">PropertiesProvenanceEntry</code> LinkML-derived and added a schema drift gate, but it stopped short of claiming new fields flow to the <em>writer’s typed surface</em> — because that needed the prefix problem solved first. Spec #236 had already made the writer a single source of truth across both the VS Code and web-shell hosts. This feature lands on top of both: it routes the schema’s authority all the way through to where the writers actually read and write, closing the Article II.1 (LinkML as single source of truth) audit deferral recorded against #240, and reusing #240’s existing <code class="language-plaintext highlighter-rouge">src/generated</code> drift gate rather than inventing a new one.</p>

<h2 id="key-decisions">Key Decisions</h2>

<ul>
  <li>
    <p><strong>Why the naive fix does nothing.</strong> LinkML’s <code class="language-plaintext highlighter-rouge">gen-typescript</code> strips the <code class="language-plaintext highlighter-rouge">debrief:</code> prefix and emits bare slot names (<code class="language-plaintext highlighter-rouge">provenance_log</code>), but the data on disk — and every one of the ~27 real call sites — uses the prefixed key (<code class="language-plaintext highlighter-rouge">debrief:provenance_log</code>). A <code class="language-plaintext highlighter-rouge">StacItem.properties: StacExtensionProperties</code> intersection, the obvious move, keys on the bare names and matches <em>none</em> of the prefixed access sites. Zero benefit. That’s exactly why #240 punted it here.</p>
  </li>
  <li>
    <p><strong>Generator post-processor over writer refactor.</strong> We extended the existing <code class="language-plaintext highlighter-rouge">shared/schemas/scripts/generate.py</code> post-processor with one step that rewrites generated slot keys to their on-disk prefixed form. TypeScript resolves string-literal index access to the matching named slot, so the existing literal-key call sites gain types with no rewrite. The alternative — rewriting all ~27 sites to unprefixed keys behind a serialisation adapter — has a larger blast radius and introduces a new boundary where a forgotten field can silently drop, the precise ADR-033 / Article IV.5 failure class we guard against.</p>
  </li>
  <li>
    <p><strong>Schema-driven from <code class="language-plaintext highlighter-rouge">slot_uri</code>, not per-class text rules.</strong> The step reads each slot’s LinkML <code class="language-plaintext highlighter-rouge">slot_uri</code> and uses it verbatim. We deliberately did <em>not</em> hard-code a “prepend <code class="language-plaintext highlighter-rouge">debrief:</code>” rule: it can’t generalise across the three target classes — <code class="language-plaintext highlighter-rouge">StacSummaries</code> keys are underscore-named (<code class="language-plaintext highlighter-rouge">debrief_platforms</code> → <code class="language-plaintext highlighter-rouge">debrief:platforms</code>, a substitution), and <code class="language-plaintext highlighter-rouge">StacAsset</code> mixes Debrief slots with non-Debrief ones (<code class="language-plaintext highlighter-rouge">href</code>, <code class="language-plaintext highlighter-rouge">roles</code>) that must stay untouched. Reading <code class="language-plaintext highlighter-rouge">slot_uri</code> is the only thing that handles all three and keeps the add-a-field promise — a new slot flows through with no edit to the generator.</p>
  </li>
  <li>
    <p><strong>We typed the write path, not just reads.</strong> The naive generated-type fix only reaches the read sites; both writer hosts widened the properties bag to <code class="language-plaintext highlighter-rouge">Record&lt;string, unknown&gt;</code> at the mutation path, leaving write-side typos silent — exactly the failure this feature exists to kill. Removing those widenings (and re-homing <code class="language-plaintext highlighter-rouge">debrief:toolId</code> / <code class="language-plaintext highlighter-rouge">debrief:snapshotTimestamp</code> onto <code class="language-plaintext highlighter-rouge">StacAsset</code>) is what makes the guarantee real end-to-end. We also found <code class="language-plaintext highlighter-rouge">debrief:label</code> is a <em>feature</em> property, not a STAC one, and left it alone rather than mistyping it.</p>
  </li>
  <li>
    <p><strong>Reuse the drift gate; change no bytes on disk.</strong> The committed artefacts are already covered by #240’s <code class="language-plaintext highlighter-rouge">src/generated</code> CI drift gate, so freshness is enforced without a new gate. The two new <code class="language-plaintext highlighter-rouge">StacAsset</code> slots are additive and their keys already exist on disk — the on-disk JSON is byte-for-byte unchanged, verified by a round-trip golden check.</p>
  </li>
</ul>

<h2 id="by-the-numbers">By the Numbers</h2>

<p>Typing-only and behaviour-preserving — every figure below comes from the committed test run and the round-trip golden check.</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th> </th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Total tests passing</td>
      <td>2204</td>
    </tr>
    <tr>
      <td>Generator transform tests</td>
      <td>7</td>
    </tr>
    <tr>
      <td>Type-level TypeScript tests</td>
      <td>5</td>
    </tr>
    <tr>
      <td>Writer unit tests (unchanged, regression guard)</td>
      <td>165</td>
    </tr>
    <tr>
      <td>Tests failed</td>
      <td>0</td>
    </tr>
    <tr>
      <td>Classes transformed</td>
      <td>3 (<code class="language-plaintext highlighter-rouge">StacExtensionProperties</code>, <code class="language-plaintext highlighter-rouge">StacSummaries</code>, <code class="language-plaintext highlighter-rouge">StacAsset</code>)</td>
    </tr>
    <tr>
      <td>Slots now typed under prefixed keys</td>
      <td>10 (5 item + 3 summary + 2 asset)</td>
    </tr>
    <tr>
      <td>Hand-casts removed</td>
      <td>3</td>
    </tr>
    <tr>
      <td>On-disk JSON bytes changed</td>
      <td>0</td>
    </tr>
  </tbody>
</table>

<h2 id="lessons-learned">Lessons Learned</h2>

<p>The scope grew during implementation, and it grew in the right direction. The original plan targeted <code class="language-plaintext highlighter-rouge">StacExtensionProperties</code> alone — that looked like five slot renames and done. Working through the generator step revealed that <code class="language-plaintext highlighter-rouge">StacSummaries</code> had the same problem (underscore-mangled names, no match on disk) and <code class="language-plaintext highlighter-rouge">StacAsset</code> had no typed home for <code class="language-plaintext highlighter-rouge">debrief:toolId</code> and <code class="language-plaintext highlighter-rouge">debrief:snapshotTimestamp</code> at all. Folding both in was the right call: a “prepend <code class="language-plaintext highlighter-rouge">debrief:</code>” text rule would have sufficed for <code class="language-plaintext highlighter-rouge">StacExtensionProperties</code> but would have silently mishandled <code class="language-plaintext highlighter-rouge">StacSummaries</code> and trashed <code class="language-plaintext highlighter-rouge">StacAsset</code>’s non-Debrief slots. Reading <code class="language-plaintext highlighter-rouge">slot_uri</code> from the schema handled all three cleanly and made scope expansion cheap rather than risky.</p>

<p>The more interesting moment was what typing <code class="language-plaintext highlighter-rouge">debrief:provenance_log</code> properly uncovered. Spec #240 had generated a wide <code class="language-plaintext highlighter-rouge">PropertiesProvenanceEntry</code> (all fields from LinkML) but the consuming component had narrowed it to a local hybrid — an <code class="language-plaintext highlighter-rouge">as</code> cast was bridging the mismatch invisibly. Once the <code class="language-plaintext highlighter-rouge">props</code> bag gained a proper type, the cast was gone and pyright surfaced the divergence. The fix — an explicit typed narrowing bridge from the persistence shape to the domain shape — is the kind of thing that should have been written at #240. The types found a real latent bug, which is the point of this entire class of work.</p>

<p>The half-naivety trap bears repeating: if you only fix the read path, you get a false sense of safety. Both writer hosts had widened <code class="language-plaintext highlighter-rouge">item.properties</code> to <code class="language-plaintext highlighter-rouge">Record&lt;string, unknown&gt;</code> at the mutation site. That’s where a field gets added to a schema, a developer writes <code class="language-plaintext highlighter-rouge">props['debrief:newField'] = value</code> with a typo, and nothing complains until the data shows up missing downstream. Re-typing the write path from <code class="language-plaintext highlighter-rouge">Record&lt;string, unknown&gt;</code> to <code class="language-plaintext highlighter-rouge">StacItemProperties</code> is the move that actually closes the loop.</p>

<h2 id="whats-next">What’s Next</h2>

<p>The same schema-driven technique — read <code class="language-plaintext highlighter-rouge">slot_uri</code>, emit the on-disk key verbatim — is now the established pattern for any future <code class="language-plaintext highlighter-rouge">debrief:*</code> slot. The generator step is a pure function; a new slot in the schema flows through to every writer host’s typed surface automatically. No hand-edits, no drift.</p>

<p>One thing we deliberately left out: <code class="language-plaintext highlighter-rouge">debrief:label</code>. It’s a GeoJSON feature property and an MCP annotation, not a STAC item property, and modelling it onto <code class="language-plaintext highlighter-rouge">StacExtensionProperties</code> would recreate the very mismatch this feature removes. Giving it a typed home on <code class="language-plaintext highlighter-rouge">BaseFeatureProperties</code> is the natural next step when that surface gets attention.</p>

<p>→ <a href="https://github.com/debrief/debrief-future/tree/main/specs/256-prefix-aware-stac-typing/spec.md">See the spec</a>
→ <a href="https://github.com/debrief/debrief-future/tree/main/specs/256-prefix-aware-stac-typing/evidence">View the evidence</a></p>]]></content><author><name>Ian</name></author><category term="tracer-bullet" /><category term="schemas" /><category term="stac" /><category term="linkml" /><category term="typescript" /><category term="boundary-types" /><summary type="html"><![CDATA[Schema-driven typing closes the gap between LinkML definitions and the debrief: keys our writers actually use, so a typo now fails at build time.]]></summary></entry><entry><title type="html">Shipped: All-or-nothing plot saves</title><link href="https://debrief.github.io/shipped-atomic-plot-save" rel="alternate" type="text/html" title="Shipped: All-or-nothing plot saves" /><published>2026-06-01T00:00:00+00:00</published><updated>2026-06-01T00:00:00+00:00</updated><id>https://debrief.github.io/shipped-atomic-plot-save</id><content type="html" xml:base="https://debrief.github.io/shipped-atomic-plot-save"><![CDATA[<h2 id="what-we-built">What We Built</h2>

<p>Saving a plot writes three things: the feature collection (<code class="language-plaintext highlighter-rouge">features.geojson</code>), the STAC metadata (<code class="language-plaintext highlighter-rouge">item.json</code>), and the thumbnail PNGs. Until now those were three independent writes done in sequence, and the “Plot saved” confirmation fired before the last of them had necessarily landed. Most of the time that is invisible. But if anything goes wrong partway through — the disk fills up, a permission is denied, the browser hits its storage quota, or the machine simply dies mid-write — you could be left with a plot that is quietly broken: new geometry against stale metadata, or a half-written file that won’t open.</p>

<p>This spec closes that gap. A save is now atomic from the analyst’s perspective (FR-001): after any save attempt, the persisted plot is observable as either the complete new state or the complete previous state, never a partial mixture. The success marker only appears once every write that makes up the save has committed (FR-005). And if a save is cut off by something we can’t catch — a crash, a power loss — reopening the plot yields a single coherent version rather than a corrupt one (FR-007). We deliberately aimed for coherence, not power-loss durability of the newest in-flight save: the guarantee is that you never lose <em>or corrupt</em> what was already there, not that the very last keystroke survives the plug being pulled.</p>

<h2 id="how-it-works">How It Works</h2>

<p>Atomicity is concentrated behind the persistence boundary rather than scattered across frontend code. We added two host-agnostic operations to the <code class="language-plaintext highlighter-rouge">StacWriter</code> interface (<code class="language-plaintext highlighter-rouge">shared/stac-writer/src/interface.ts</code>): <code class="language-plaintext highlighter-rouge">commitPlotSave</code>, which commits the whole save unit as one, and <code class="language-plaintext highlighter-rouge">reconcilePlotSave</code>, which runs on open before the first read to heal any interrupted save. Each host implements the pair against its native backend.</p>

<p>On the <strong>VS Code filesystem</strong> adaptor (<code class="language-plaintext highlighter-rouge">apps/vscode/src/services/stacWriterFs.ts</code>), the commit point is a write-ahead intent journal:</p>

<pre><code class="language-mermaid">flowchart LR
  A[Stage all new files&lt;br/&gt;as temporaries] --&gt; B[Atomically write journal&lt;br/&gt;listing pending renames]
  B --&gt;|COMMIT POINT| C[Apply renames]
  C --&gt; D[Delete journal]
</code></pre>

<p>If we die before the journal exists, nothing has moved — <code class="language-plaintext highlighter-rouge">reconcilePlotSave</code> discards the temporaries and the previous plot is byte-identical. If we die after it exists, the journal tells reconciliation exactly which renames to roll <em>forward</em> to finish the save. The journal’s presence or absence is the single bit that decides roll-forward versus roll-back, and the temp-write plus rename reuse the adaptor’s existing <code class="language-plaintext highlighter-rouge">atomicWriteSync</code> helper.</p>

<p>On the <strong>web-shell</strong> adaptor (<code class="language-plaintext highlighter-rouge">apps/web-shell/src/services/stacWriterIdb.ts</code>), there is no journal to invent: the three writes collapse into a single multi-store IndexedDB transaction and lean on IndexedDB’s native atomicity. The call site in <code class="language-plaintext highlighter-rouge">apps/web-shell/src/mocks/stacService.ts</code> swapped its separate <code class="language-plaintext highlighter-rouge">writeItem</code> + <code class="language-plaintext highlighter-rouge">writeAsset</code> calls for one <code class="language-plaintext highlighter-rouge">commitPlotSave</code>.</p>

<p>The honest-reporting half lives at the call sites. <code class="language-plaintext highlighter-rouge">apps/vscode/src/commands/saveSession.ts</code> moves its <code class="language-plaintext highlighter-rouge">markClean</code> / “Plot saved” marker to <em>after</em> the commit returns, and a failure now surfaces through <code class="language-plaintext highlighter-rouge">window.showWarningMessage</code> while preserving the dirty editor state for retry. <code class="language-plaintext highlighter-rouge">apps/vscode/src/commands/openPlot.ts</code> calls <code class="language-plaintext highlighter-rouge">reconcilePlotSave</code> before <code class="language-plaintext highlighter-rouge">loadPlotData</code>, so every open heals first and reads second.</p>

<p>No new runtime dependencies — just <code class="language-plaintext highlighter-rouge">node:fs</code> / <code class="language-plaintext highlighter-rouge">node:crypto</code> on the desktop and the existing <code class="language-plaintext highlighter-rouge">idb</code> library in the browser.</p>

<h2 id="key-decisions">Key Decisions</h2>

<ul>
  <li><strong>The boundary owns atomicity, not the frontend (Article IV).</strong> The feature-collection write moved off a raw <code class="language-plaintext highlighter-rouge">fs.writeFileSync</code> onto the <code class="language-plaintext highlighter-rouge">@debrief/stac-writer</code> boundary, so neither host can regress it and the logic lives in exactly two adaptors.</li>
  <li><strong>Coherence over durability, on purpose.</strong> Guaranteeing the newest in-flight save survives power loss would have meant <code class="language-plaintext highlighter-rouge">fsync</code> barriers and a heavier design. The narrower “never corrupt, never half-update” guarantee removed the silent-corruption risk entirely at a fraction of the cost.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">Pick</code>-derived DTOs.</strong> The <code class="language-plaintext highlighter-rouge">CommitPlotSaveInput</code> type is derived rather than hand-listed, so a new save artifact can’t be silently omitted from a commit — the omission becomes a compile error.</li>
</ul>

<h2 id="by-the-numbers">By the Numbers</h2>

<table>
  <thead>
    <tr>
      <th>Metric</th>
      <th>Value</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>New tests passing</td>
      <td>32 (0 failures)</td>
    </tr>
    <tr>
      <td>FS adaptor — commit / reconcile</td>
      <td>5 / 6</td>
    </tr>
    <tr>
      <td>IndexedDB adaptor</td>
      <td>7</td>
    </tr>
    <tr>
      <td>VS Code host call sites</td>
      <td>9</td>
    </tr>
    <tr>
      <td>Playwright E2E smoke</td>
      <td>1</td>
    </tr>
    <tr>
      <td>Compile-time type guard (<code class="language-plaintext highlighter-rouge">tsc --noEmit</code>)</td>
      <td>1</td>
    </tr>
    <tr>
      <td>Full suite on the same commit</td>
      <td>998 passing, zero new failures</td>
    </tr>
  </tbody>
</table>

<p>Three properties are enforced mechanically: exactly one IndexedDB transaction per save, zero success messages for an uncommitted save, and compile-time guards against omitted fields. A fault-injection matrix exercises interruption points across both backends — every one resolves to a single coherent version.</p>

<h2 id="whats-next">What’s Next</h2>

<p>This makes the persistence boundary the right place to add any future durability knob, should analyst feedback ask for one — the commit point is now a single, named operation per host rather than a sequence of bare writes. For now, the half-updated plot is gone: a save either lands or it doesn’t, and you always reopen something whole.</p>

<p>→ <a href="https://github.com/debrief/debrief-future/tree/main/specs/268-save-atomicity/spec.md">See the spec</a>
→ <a href="https://github.com/debrief/debrief-future/tree/main/specs/268-save-atomicity/evidence">View the evidence</a></p>]]></content><author><name>Ian</name></author><category term="stac" /><category term="vscode-extension" /><category term="testing" /><category term="tracer-bullet" /><category term="reliability" /><summary type="html"><![CDATA[A plot save now lands all-or-nothing — after any interruption you reopen the complete new plot or the complete previous one, never a torn half.]]></summary></entry><entry><title type="html">Shipped: Retiring the session sidecar</title><link href="https://debrief.github.io/shipped-retire-session-sidecar" rel="alternate" type="text/html" title="Shipped: Retiring the session sidecar" /><published>2026-06-01T00:00:00+00:00</published><updated>2026-06-01T00:00:00+00:00</updated><id>https://debrief.github.io/shipped-retire-session-sidecar</id><content type="html" xml:base="https://debrief.github.io/shipped-retire-session-sidecar"><![CDATA[<p><strong>Today</strong> — a plot is three files on disk, and the one that holds your interactive state is the one that doesn’t travel:</p>

<pre><code class="language-mermaid">flowchart LR
  subgraph PlotDir["a plot on disk (three files)"]
    Item["item.json — catalog metadata"]
    Features["features.geojson — tracks, points, annotations"]
    Sidecar["item.debrief-session — viewport, time window, playhead, selection, hidden features"]
  end
  Features -. emailed / committed / copied .-&gt; Colleague["Colleague's machine"]
  Sidecar -. left behind .-x Colleague
</code></pre>

<p><strong>After</strong> — the sidecar is gone. A plot is two files, and the entire interactive view rebuilds from <code class="language-plaintext highlighter-rouge">features.geojson</code> alone:</p>

<pre><code class="language-mermaid">flowchart LR
  subgraph PlotDir["a plot on disk (two files)"]
    Item["item.json — catalog metadata"]
    subgraph Features["features.geojson"]
      Geo["tracks, points, annotations (with visible flags)"]
      SS_SP["SystemState: state.spatial"]
      SS_TM["SystemState: state.temporal"]
      SS_SE["SystemState: state.selection"]
      SS_AS["SystemState: state.activestoryboard"]
    end
  end
  Features -. emailed / committed / copied .-&gt; Colleague["Colleague's machine — same view, time, selection"]
</code></pre>

<h2 id="what-we-built">What We Built</h2>

<p>When you save a plot today, the part of it you were actually looking at gets left behind. The map viewport, the analytical time window, the playhead position, the feature selection, which features you’d hidden — all of it lives in a sibling file, the <code class="language-plaintext highlighter-rouge">item.debrief-session</code> sidecar, that gets stripped the moment the plot leaves your machine. Email a colleague the GeoJSON, pull it from a STAC catalogue, check it out of git, copy it to a USB stick, and they open it on the default global view, a default time window, and an empty selection. The portable artefact — <code class="language-plaintext highlighter-rouge">features.geojson</code> — carries none of the state that makes the plot <em>yours</em>.</p>

<p>This work deletes the sidecar entirely. Every field it held is given a proper home: plot state (viewport, time window, playhead, selection, active storyboard) becomes a handful of <code class="language-plaintext highlighter-rouge">SystemState</code> Features written directly into the FeatureCollection, addressed by deterministic ids like <code class="language-plaintext highlighter-rouge">state.spatial</code> and <code class="language-plaintext highlighter-rouge">state.temporal</code>; per-feature state — visibility — becomes a <code class="language-plaintext highlighter-rouge">visible</code> flag on the individual feature it describes, so hiding a track travels with that track; and genuinely ephemeral runtime — whether you’re currently playing, transient drawing mode, a viewport lock — simply isn’t persisted, and defaults cleanly on load. After this, a plot is exactly two files, <code class="language-plaintext highlighter-rouge">item.json</code> and <code class="language-plaintext highlighter-rouge">features.geojson</code>, and the whole interactive state is reconstructable from the GeoJSON alone. Hand a colleague a single file and they open it exactly where you left it.</p>

<h2 id="how-it-fits">How It Fits</h2>

<p>This is the payoff of two principles the project has held from the start: schema-first single source of truth, and the plot file as the one portable, canonical artefact. Until now those principles were quietly contradicted by the sidecar — a second persistence path that split plot state across two files, only one of which travelled. The fix generalises a pattern that already shipped for a single case: #237 introduced the <code class="language-plaintext highlighter-rouge">SystemState</code> Feature for the active-storyboard pin, and this work makes that the general home for <em>all</em> non-spatial plot state. The same shape on disk, the same deterministic addressing, now covering spatial, temporal, and selection too. It also substantially narrows a separate planned piece of work — web-shell session persistence — because the VS Code extension and the browser web-shell now read and write the same FeatureCollection through one shared helper, rather than each carrying its own persistence path.</p>

<h2 id="key-decisions">Key Decisions</h2>

<ul>
  <li>
    <p><strong>Everything that looked like “session state” is actually plot state.</strong> The earlier assumption that playback speed, step size, the time filter, and display mode were <em>per-user</em> preferences turned out to be wrong: they describe the data being replayed, not the person replaying it, so they belong to the plot. Once that lands, the design collapses — there is no residual per-user bucket left for the sidecar to hold, which is precisely why the sidecar can be deleted outright rather than merely shrunk. No replacement store.</p>
  </li>
  <li>
    <p><strong>One shared read/write helper, not one per host.</strong> Both frontends — the VS Code extension and the web-shell — go through a single helper that owns reading and writing every <code class="language-plaintext highlighter-rouge">SystemState</code> variant. #237’s host-private writer is folded into it. Plot-load and plot-save become the only two places this state is touched, so the two hosts can never drift into divergent persistence behaviour.</p>
  </li>
  <li>
    <p><strong>Exploration never marks the plot dirty.</strong> Panning, zooming, scrubbing the time cursor, changing the selection — none of these flag unsaved changes. Merely <em>looking</em> at a plot should never nag you to save. An explicit Save still commits the current view; only substantive content edits drive the unsaved-changes prompt. The state is captured in memory as you explore and persisted only when you choose to save.</p>
  </li>
  <li>
    <p><strong>Visibility lives on the feature, not in a separate list.</strong> Hiding a track sets <code class="language-plaintext highlighter-rouge">visible: false</code> on that track and records it in the track’s own provenance log. We accept that the provenance grows a little as the price of visibility travelling <em>with</em> the feature it describes — a hidden track stays hidden when the plot moves, with no separate hidden-list to keep in sync.</p>
  </li>
  <li>
    <p><strong>Strict on import.</strong> A malformed or self-contradictory saved state — a playhead sitting outside its own time window, say — fails loudly with a clear error that names the offending feature. Never a silent default, never a quiet clamp. If the plot file claims something impossible, you find out immediately and you find out where.</p>
  </li>
</ul>

<h2 id="screenshots">Screenshots</h2>

<p>The whole round-trip in one loop — host A sets a view, only <code class="language-plaintext highlighter-rouge">features.geojson</code> is carried across, and host B rebuilds the same view (then the visibility round-trip):</p>

<p><img src="/assets/images/future-debrief/shipped-retire-session-sidecar/interaction.gif" alt="Animated round-trip: host A's viewport and selection, the same view restored on host B from features.geojson alone, then a hidden feature surviving the transfer." /></p>

<p>The headline test is a round-trip across two hosts. Host A gets a recognisable viewport, a scoped time window, a scrubbed playhead, and a feature selection — the kind of state the sidecar used to strand on the machine that created it.</p>

<p><img src="/assets/images/future-debrief/shipped-retire-session-sidecar/roundtrip-host-a.png" alt="Host A before transfer: a recognisable map viewport with a scoped analytical time window and a feature selection active." /></p>

<p><em>Host A: the view we want to travel — viewport, scoped time window, playhead, and selection.</em></p>

<p>Then we copy <strong>only</strong> <code class="language-plaintext highlighter-rouge">features.geojson</code> to a different machine — no STAC catalogue, no sidecar, just the one file — and open it. The whole view comes back. The title bar reads <code class="language-plaintext highlighter-rouge">transferred/plot.geojson</code>, the playhead has restored to <code class="language-plaintext highlighter-rouge">09:30:00</code>, and <code class="language-plaintext highlighter-rouge">track-hms-defender</code> is selected, all rebuilt from the GeoJSON alone.</p>

<p><img src="/assets/images/future-debrief/shipped-retire-session-sidecar/roundtrip-host-b.png" alt="Host B after a features-only transfer: the same viewport, the playhead restored to 09:30:00, and track-hms-defender selected, with the title bar reading transferred/plot.geojson." /></p>

<p><em>Host B: the same view, restored from <code class="language-plaintext highlighter-rouge">features.geojson</code> alone. No sidecar was carried across.</em></p>

<p>Visibility travels the same way. A feature hidden on host A carries <code class="language-plaintext highlighter-rouge">properties.visible: false</code> on the feature itself, so a features-only reopen keeps it hidden — there is no separate hidden-list to lose.</p>

<p><img src="/assets/images/future-debrief/shipped-retire-session-sidecar/visibility-host-a.png" alt="Host A: a feature has been hidden from the layers panel." /></p>

<p><em>Host A: a feature hidden.</em></p>

<p><img src="/assets/images/future-debrief/shipped-retire-session-sidecar/visibility-host-b.png" alt="Host B: after a features-only reopen, the same feature is still hidden — its visible flag rode along inside the FeatureCollection." /></p>

<p><em>Host B: still hidden after a features-only reopen — the <code class="language-plaintext highlighter-rouge">visible</code> flag rode along on the feature.</em></p>

<p>And when a saved state contradicts itself — a <code class="language-plaintext highlighter-rouge">current_time</code> outside its own <code class="language-plaintext highlighter-rouge">[start_time, end_time]</code>, or a duplicate <code class="language-plaintext highlighter-rouge">state_type</code> — load fails loudly. The error banner names the offending feature id rather than silently clamping or falling back to a default.</p>

<p><img src="/assets/images/future-debrief/shipped-retire-session-sidecar/strict-import-error.png" alt="Strict-on-import error banner naming the offending SystemState feature id after a self-contradictory state was loaded." /></p>

<p><em>Strict-on-import: a self-contradictory saved state fails loudly and names the feature at fault.</em></p>

<h2 id="by-the-numbers">By the Numbers</h2>

<table>
  <thead>
    <tr>
      <th> </th>
      <th> </th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Tests passing</td>
      <td>2620</td>
    </tr>
    <tr>
      <td>Suites touched</td>
      <td>4</td>
    </tr>
    <tr>
      <td>Files per plot</td>
      <td>3 → 2</td>
    </tr>
    <tr>
      <td>SystemState write code paths</td>
      <td>many → 1 shared helper</td>
    </tr>
    <tr>
      <td>Shared-helper unit tests</td>
      <td>42</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">gen-json-schema</code> ViewportPolygon risk</td>
      <td>non-event</td>
    </tr>
  </tbody>
</table>

<p>The four suites are schema adherence (Python, 1071), session-state (Vitest, 696), the VS Code extension (Vitest, 845), and the web-shell Playwright round-trip. A plot dropped from three files to two. The split persistence story — a web-shell-private <code class="language-plaintext highlighter-rouge">active_storyboard</code> writer plus a VS Code extension that wrote no SystemState at all — collapsed into one shared helper that is now the sole producer and consumer of every variant, covered by 42 focused unit tests.</p>

<p>The one risk we flagged in planning (FR-006a) was that <code class="language-plaintext highlighter-rouge">gen-json-schema</code> had a known bug with <code class="language-plaintext highlighter-rouge">Coordinate</code> as a multivalued class range — exactly the shape of <code class="language-plaintext highlighter-rouge">ViewportPolygon.coordinates</code> — and moving <code class="language-plaintext highlighter-rouge">viewport</code> onto <code class="language-plaintext highlighter-rouge">SystemStateProperties</code> (which <em>is</em> in the JSON Schema build) might resurface it. It didn’t. The existing <code class="language-plaintext highlighter-rouge">generate.py</code> post-processor pattern already handled the case; it was a non-event.</p>

<h2 id="lessons-learned">Lessons Learned</h2>

<p><strong>A type name collision had to be paid off before anything else could move.</strong> The schema cluster held two different things both called <code class="language-plaintext highlighter-rouge">Coordinate</code> — a scalar <code class="language-plaintext highlighter-rouge">type</code> in one file and a lng/lat <code class="language-plaintext highlighter-rouge">class</code> in another. As long as the duplicates lived in separate schemas this stayed dormant, but <code class="language-plaintext highlighter-rouge">SystemStateProperties</code> lives in <code class="language-plaintext highlighter-rouge">geojson.yaml</code>, which imports <code class="language-plaintext highlighter-rouge">common.yaml</code>, and pointing <code class="language-plaintext highlighter-rouge">viewport</code> at the canonical <code class="language-plaintext highlighter-rouge">ViewportPolygon</code> meant consolidating all the shared value types into <code class="language-plaintext highlighter-rouge">common.yaml</code> first. The moment they shared a namespace, the two <code class="language-plaintext highlighter-rouge">Coordinate</code>s collided and the consolidation had to resolve which one survived. The prerequisite refactor was larger than the feature it unblocked.</p>

<p><strong>A circular dependency decided where one variant’s logic lives.</strong> We wanted the shared helper to own all four <code class="language-plaintext highlighter-rouge">SystemState</code> variants. But <code class="language-plaintext highlighter-rouge">active_storyboard</code> originally read through <code class="language-plaintext highlighter-rouge">@debrief/components</code>, and routing the helper back through that package would have closed a dependency loop. So the helper implements the active-storyboard wire shape natively — verbatim to #237’s format (NG-002), just no longer borrowed. The web-shell’s <em>interactive</em> read of active-storyboard deliberately stays on the tolerant <code class="language-plaintext highlighter-rouge">@debrief/components</code> helper (R-011), because that read runs on every edit and the strict load reader throws on a duplicate or malformed feature. The shared helper owns the unified <em>load-time</em> read of all four variants and is the single writer for the three migrated ones; no host re-implements the wire shape.</p>

<p><strong>Reclassifying view-state as exploration was the real unlock.</strong> The rule that panning, zooming, scrubbing, and selecting never mark the plot dirty (FR-019) reads like a UX nicety, but it forced a second decision that made the whole thing usable: an explicit Save now persists the current view <em>regardless</em> of the dirty flag (FR-020). VS Code’s save command used to early-return “no unsaved changes” when the plot wasn’t dirty — which, once view-state stopped setting the flag, would have made a looked-at-only view impossible to save at all. Relaxing that guard is what lets you open a plot, arrange the view you want, and commit it without having edited a single feature.</p>

<h2 id="whats-next">What’s Next</h2>

<p>A few follow-ups are scheduled rather than done:</p>

<ul>
  <li><strong>#250 — web-shell durable persistence.</strong> This work landed the in-memory mirror (FR-009a): view-state is written into the FeatureCollection, which the web-shell already auto-persists to IndexedDB. The residual is the auto-commit-trigger UX — when to debounce a viewport nudge versus require an explicit gesture — which is a UX decision, not a persistence-mechanism gap.</li>
  <li><strong>#266 — purge stale references.</strong> The legacy <code class="language-plaintext highlighter-rouge">bbox</code> / <code class="language-plaintext highlighter-rouge">center</code> fields are removed from the schema; lingering mentions in docs and ADRs still need a sweep.</li>
  <li><strong>#267 — out-of-window <code class="language-plaintext highlighter-rouge">current_time</code> policy.</strong> Strict-on-import is the chosen default; whether a tolerant import path is ever warranted is parked here, to be revisited only if strict proves user-hostile.</li>
  <li><strong>#268 — broader multi-asset save atomicity.</strong> With the sidecar gone, the dual-write failure class is gone too. The wider question — <code class="language-plaintext highlighter-rouge">features.geojson</code> versus thumbnails versus <code class="language-plaintext highlighter-rouge">item.json</code> written transactionally — is its own item.</li>
</ul>

<p>→ <a href="https://github.com/debrief/debrief-future/tree/main/specs/261-session-state-systemstate/spec.md">See the spec</a>
→ <a href="https://github.com/debrief/debrief-future/tree/main/specs/261-session-state-systemstate/evidence">View the evidence</a></p>]]></content><author><name>Ian</name></author><category term="tracer-bullet" /><category term="schemas" /><category term="session-state" /><category term="geojson" /><category term="vscode-extension" /><summary type="html"><![CDATA[The sidecar is gone. A plot is now two files, and the whole interactive view rebuilds from features.geojson alone.]]></summary></entry><entry><title type="html">Shipped: Overlap warnings for time-range storyboard scenes</title><link href="https://debrief.github.io/shipped-scene-overlap-warning" rel="alternate" type="text/html" title="Shipped: Overlap warnings for time-range storyboard scenes" /><published>2026-06-01T00:00:00+00:00</published><updated>2026-06-01T00:00:00+00:00</updated><id>https://debrief.github.io/shipped-scene-overlap-warning</id><content type="html" xml:base="https://debrief.github.io/shipped-scene-overlap-warning"><![CDATA[<h2 id="what-we-built">What We Built</h2>

<p>When you assemble a Storyboard, each time-range Scene claims a window of the exercise — a stretch of <code class="language-plaintext highlighter-rouge">[start, end]</code> you want replayed. Most of the time those windows sit end to end: Scene A hands off to Scene B, B to C, and the story walks forward through time. But sometimes two Scenes quietly cover the <em>same</em> stretch — you nudged a window while editing, or duplicated a Scene and forgot to move it, and now the same minutes get replayed twice without you meaning them to. Nothing breaks, so nothing tells you.</p>

<p>This adds a quiet tell. When two time-range Scenes overlap, each offending row in the Storyboard panel grows a small warning that names its partner — “Overlaps with <em>Egress leg</em>” — so the accidental double-cover is visible at a glance instead of waiting to be noticed on playback. It is deliberately a nudge, not a rule. The platform never reorders, merges, rejects, or blocks a thing. If the overlap is intentional — a deliberate re-play to land an emphasis — you dismiss the warning and it stays gone for the session. The aim is to catch authoring drift without putting the platform in the business of policing a legitimate creative choice.</p>

<h2 id="how-it-fits">How It Fits</h2>

<p>This is a follow-up to #263, which shipped time-range Scenes but left overlap detection to authoring discipline. It is the lightweight safety net that closes that gap — TypeScript-only, frontend-only, no schema change and no service call. Detection lives in one pure, synchronous helper, <code class="language-plaintext highlighter-rouge">detectSceneOverlaps()</code> in <code class="language-plaintext highlighter-rouge">shared/components/src/storyboard/overlap.ts</code>, consumed verbatim by both the VS Code extension and the web-shell so the two surfaces can’t drift in what they consider an overlap. The warning itself is a new presentational <code class="language-plaintext highlighter-rouge">OverlapBadge</code> that reuses the per-row slot pattern already established for the stale indicator, and the whole thing is a read-only derivation over data already in the plot — offline by default, like everything else.</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">import</span> <span class="p">{</span> <span class="nx">detectSceneOverlaps</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">@debrief/components</span><span class="dl">'</span><span class="p">;</span>

<span class="c1">// plot = the FeatureCollection; storyboardId = the active Storyboard;</span>
<span class="c1">// dismissedPairs = optional Set of dismissed pair keys.</span>
<span class="kd">const</span> <span class="nx">overlaps</span> <span class="o">=</span> <span class="nx">detectSceneOverlaps</span><span class="p">(</span><span class="nx">plot</span><span class="p">,</span> <span class="nx">storyboardId</span><span class="p">,</span> <span class="nx">dismissedPairs</span><span class="p">);</span>
<span class="c1">// -&gt; ReadonlyMap&lt;sceneId, { sceneId; title }[]&gt;</span>
<span class="c1">//    non-overlapping and instant Scenes are simply absent</span>
</code></pre></div></div>

<p>Each host computes overlaps from the active-Storyboard Scene set it already holds, merges the result into the per-row view-model, and owns its own dismissed-pairs set — <code class="language-plaintext highlighter-rouge">apps/vscode/src/views/storyboardPanelView.ts</code> on one side, <code class="language-plaintext highlighter-rouge">apps/web-shell/src/StoryboardPanelMount.tsx</code> on the other. The host code only wires data in and renders the result.</p>

<h2 id="key-decisions">Key Decisions</h2>

<ul>
  <li><strong>Strict interior overlap, not touching endpoints.</strong> The rule is <code class="language-plaintext highlighter-rouge">aStart &lt; bEnd &amp;&amp; bStart &lt; aEnd</code> on epoch milliseconds. Scenes that merely meet at an edge — A ends exactly where B starts — are a normal contiguous handoff and produce no warning. Well-formed sequential Storyboards stay completely clean, which is what keeps the signal worth trusting.</li>
  <li><strong>Time-range Scenes only.</strong> Instant, single-timestamp Scenes are excluded; their timestamp collisions are a separate existing flow, and folding them in here would muddy the meaning of the badge.</li>
  <li><strong>One shared helper, two hosts.</strong> <code class="language-plaintext highlighter-rouge">detectSceneOverlaps()</code> is a single pure function in <code class="language-plaintext highlighter-rouge">shared/components</code>. Putting the rule in one place — rather than implementing it twice — means the VS Code panel and the web-shell can never disagree about what overlaps.</li>
  <li><strong>Dismissal is session-scoped, keyed by the unordered Scene pair, and not persisted.</strong> Dismissing clears the warning on both rows; pull the windows apart and re-overlap them and it warns afresh. We deliberately kept this out of plot state — no new persisted field — to hold the feature at the one-to-two-dev-day aid it was scoped to be. A deliberate overlap you re-open in a new session warns again, and that felt like the right default: better to re-confirm intent than to silently carry a stale “I meant this” forever.</li>
  <li><strong>Passive and non-blocking, by design.</strong> No reorder, no merge, no rejection. Accidental overlaps are a mistake worth surfacing; intentional ones are a creative decision the platform has no business overruling. The badge respects that line.</li>
</ul>

<h2 id="screenshots">Screenshots</h2>

<p>The warning is a per-row badge naming the conflicting Scene. Two overlapping Scenes each warn about the other; the non-overlapping range Scene and the instant Scene below stay clean.</p>

<p><img src="/assets/images/future-debrief/shipped-scene-overlap-warning/overlap-light.png" alt="Storyboard panel in light theme showing four scene rows; the Approach run and Egress leg rows each carry an amber warning bar naming the other Scene with a Dismiss link, while the Final approach and Contact datum rows carry none" />
<em>Light theme. “Approach run” (10:00–10:30) and “Egress leg” (10:15–10:45) overlap; each warning names the other. “Final approach” and the instant “Contact datum” stay clean.</em></p>

<p>The badge inherits the theming of the existing stale-indicator slot, so it lands correctly across all three theme variants without any new colour work.</p>

<p><img src="/assets/images/future-debrief/shipped-scene-overlap-warning/overlap-dark.png" alt="The same Storyboard panel rendered in dark theme, with the two overlap warning bars on the Approach run and Egress leg rows" />
<em>Dark theme.</em></p>

<p><img src="/assets/images/future-debrief/shipped-scene-overlap-warning/overlap-vscode.png" alt="The same Storyboard panel rendered in the VS Code theme, with the two overlap warning bars on the Approach run and Egress leg rows" />
<em>VS Code theme.</em></p>

<p>Clicking <strong>Dismiss</strong> on either badge clears the warning on both rows at once. Nothing in the plot changes — the Scene windows are untouched.</p>

<p><img src="/assets/images/future-debrief/shipped-scene-overlap-warning/overlap-after-dismiss.png" alt="The Storyboard panel after clicking Dismiss; all four scene rows are clean with no overlap warning bars" />
<em>After dismiss — pair it with the light-theme shot above as a before/after. (A static pair stands in for an animated clip.)</em></p>

<h2 id="by-the-numbers">By the Numbers</h2>

<table>
  <thead>
    <tr>
      <th>Metric</th>
      <th>Value</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Feature tests passing</td>
      <td>28</td>
    </tr>
    <tr>
      <td>Detection-helper unit tests</td>
      <td>16</td>
    </tr>
    <tr>
      <td>Badge component tests</td>
      <td>4</td>
    </tr>
    <tr>
      <td>VS Code host tests</td>
      <td>4</td>
    </tr>
    <tr>
      <td>Storybook E2E tests</td>
      <td>4</td>
    </tr>
    <tr>
      <td>Theme variants</td>
      <td>3/3</td>
    </tr>
    <tr>
      <td>Full component suite</td>
      <td>2318 green, no regressions</td>
    </tr>
    <tr>
      <td>Failures</td>
      <td>0</td>
    </tr>
  </tbody>
</table>

<h2 id="lessons-learned">Lessons Learned</h2>

<p>The crux of this was the overlap rule itself: strict interior overlap versus inclusive of touching endpoints. Getting it wrong — treating <code class="language-plaintext highlighter-rouge">A.end === B.start</code> as an overlap — would have warned on every normal sequential handoff, which is the most common arrangement in any well-formed Storyboard. The badge would have lit up everywhere and the signal would have drowned in its own noise. The single-character difference between <code class="language-plaintext highlighter-rouge">&lt;</code> and <code class="language-plaintext highlighter-rouge">&lt;=</code> was the whole feature working or being useless.</p>

<p>Two structural choices paid off quietly. Reusing the existing per-row stale-badge slot meant the warning dropped in with no new layout risk and inherited the accessible, contrast-safe theming that slot already had — which is why the three theme variants passed without separate colour work. And keeping detection as one shared pure function, rather than implementing the rule once in the VS Code panel and again in the web-shell, is what guarantees the two hosts can’t disagree about what counts as an overlap.</p>

<h2 id="whats-next">What’s Next</h2>

<p>Dismissal is session-scoped today — re-open the plot in a new session and an intentional overlap warns again, which we chose deliberately. If analyst feedback shows people would rather their dismissals survived reloads, persisting them is the natural follow-up. Otherwise this closes the FR-SCO-003 gap deferred from #263, and there is nothing further owed here.</p>

<p>→ <a href="https://github.com/debrief/debrief-future/tree/main/specs/271-scene-overlap-warning/spec.md">See the spec</a>
→ <a href="https://github.com/debrief/debrief-future/tree/main/specs/271-scene-overlap-warning/evidence">View the evidence</a></p>]]></content><author><name>Ian</name></author><category term="storyboard" /><category term="shared-components" /><category term="vscode-extension" /><category term="accessibility" /><category term="tracer-bullet" /><summary type="html"><![CDATA[A passive, dismissible warning when two time-range Storyboard Scenes accidentally cover the same stretch of time.]]></summary></entry><entry><title type="html">Shipped: Tolerant loading for an orphaned playhead</title><link href="https://debrief.github.io/shipped-tolerant-playhead-import" rel="alternate" type="text/html" title="Shipped: Tolerant loading for an orphaned playhead" /><published>2026-05-29T00:00:00+00:00</published><updated>2026-05-29T00:00:00+00:00</updated><id>https://debrief.github.io/shipped-tolerant-playhead-import</id><content type="html" xml:base="https://debrief.github.io/shipped-tolerant-playhead-import"><![CDATA[<h2 id="hook">Hook</h2>

<table>
  <thead>
    <tr>
      <th>Before</th>
      <th>After</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Saved playhead outside the time window? The whole plot refuses to open — a hard <code class="language-plaintext highlighter-rouge">SystemStateLoadError</code>.</td>
      <td>The plot opens. The playhead clamps to the nearest window edge, and a non-blocking notification tells you what was adjusted.</td>
    </tr>
    <tr>
      <td>A trimmed analytical window orphans a perfectly valid playhead — and locks you out of your own analysis.</td>
      <td>The window is honoured; the orphaned playhead heals on next save. Re-opening before that simply re-clamps.</td>
    </tr>
    <tr>
      <td>One recoverable mismatch is treated the same as a genuinely broken file.</td>
      <td>An incoherent window (<code class="language-plaintext highlighter-rouge">start_time &gt; end_time</code>) still fails fast — tolerance is granted only where recovery is real.</td>
    </tr>
  </tbody>
</table>

<h2 id="what-we-built">What We Built</h2>

<p>When you open a plot whose saved playhead position falls outside its saved analytical time window, the plot now opens. The playhead clamps to the nearest window edge — start if it undershot, end if it overshot — and a notification explains the adjustment. You land in your colleague’s analysis at a sensible moment instead of staring at a load failure.</p>

<p>This closes a sharp edge introduced when we moved the playhead position into the plot file. The realistic case: you scrub the playhead to a moment, later trim the analytical window to a tighter span, and save. The playhead is now orphaned outside the new window — the window itself is fine, only the playhead points past it. Under the old strict rule, the plot wouldn’t open at all: a heavy penalty for a trivially recoverable mismatch on a non-critical, re-derivable field. The genuinely broken case — a window where <code class="language-plaintext highlighter-rouge">start_time &gt; end_time</code> — keeps its hard failure. Tolerance is granted only where recovery is real.</p>

<h2 id="how-it-fits">How It Fits</h2>

<p>This is the deliberate revisit our own Constitution asked for. Spec-261 put the playhead in the plot file and shipped strict-on-import validation under Article XIV.4 (“strict on import, fail fast”) to keep the data contract clean during pre-release — but Article XIV’s trigger note explicitly flagged that those clauses “should be revisited to introduce appropriate tolerance for real-world data ingestion.”</p>

<p>This is that revisit, kept honest by being maximally narrow: one field (<code class="language-plaintext highlighter-rouge">current_time</code>), one variant (<code class="language-plaintext highlighter-rouge">temporal</code>), one precondition (a coherent surrounding window). The clamp logic lives once in the shared <code class="language-plaintext highlighter-rouge">@debrief/session-state</code> load layer that both the VS Code extension and the browser-based web-shell consume — concretely in <code class="language-plaintext highlighter-rouge">read.ts</code> (the clamp + diagnostic), <code class="language-plaintext highlighter-rouge">validate.ts</code> (the recoverable-vs-fatal severity split), and <code class="language-plaintext highlighter-rouge">store-bridge.ts</code> (<code class="language-plaintext highlighter-rouge">hydrateStoreFromFeatures</code>, the shared load entry). Each host only decides how to render the resulting diagnostic. There is no schema change — the field already exists. This is a behavioural amendment to the load path, not a new contract.</p>

<h2 id="how-it-works">How It Works</h2>

<ul>
  <li><strong>A sanctioned relaxation, not a free-for-all.</strong> Tolerance applies to exactly one field, one variant, and only when the window is coherent. The unrecoverable <code class="language-plaintext highlighter-rouge">start &gt; end</code> case keeps its hard, structured load error as the guard rail. Tolerance never leaks into structurally broken data.</li>
  <li><strong>Clamp to the nearest edge, don’t discard.</strong> Moving the playhead to the closer boundary preserves the analyst’s intent — start if they undershot, end if they overshot — rather than dumping it back to the window start regardless of direction.</li>
  <li><strong>Never silent.</strong> Article I.3 says users must always know the state of their data, so every clamp surfaces a non-blocking notification on every load until you save the corrected position. The relaxation makes the data issue loud while still letting you work.</li>
  <li><strong>Single-sourced rule, host-rendered UI.</strong> The clamp decision is made once in the shared load layer; the VS Code host renders a warning notification and the web-shell renders a toast. Services emit data, frontends own presentation.</li>
  <li><strong>No dirty-on-open.</strong> The clamp is in-memory only — it doesn’t mark the plot modified or auto-save, preserving the predecessor’s “scrubbing doesn’t dirty the file” contract. The orphaned value heals in the file only when you next save; re-opening before that re-clamps idempotently.</li>
</ul>

<h2 id="screenshots">Screenshots</h2>

<p>The tolerant recovery (User Story 1): a plot with <code class="language-plaintext highlighter-rouge">current_time</code> past the window end opens successfully. An amber notification bar reports that the saved time-cursor was outside the time range and was moved to the window end. No modal, no blocking — the plot is already open and the playhead is already positioned on the nearest edge.</p>

<p><img src="/assets/images/future-debrief/shipped-tolerant-playhead-import/playhead-clamp-toast.png" alt="Plot named orphaned/plot.geojson open in the workbench with a notification bar across the top reading: The saved time-cursor was outside this plot's time range and was moved to the window end." /></p>

<p>The preserved guard rail (User Story 2): an incoherent window (<code class="language-plaintext highlighter-rouge">start_time &gt; end_time</code>) still surfaces the red <code class="language-plaintext highlighter-rouge">cross-field-invariant</code> error banner naming the offending feature and field values. The plot does not open. The tolerant path was never reached.</p>

<p><img src="/assets/images/future-debrief/shipped-tolerant-playhead-import/incoherent-window-blocked.png" alt="Red error banner reading: Plot could not be loaded (cross-field-invariant), the SystemState feature state.temporal violates a temporal invariant because start_time must be less than or equal to end_time. The plot did not open and the catalog is still shown behind it." /></p>

<h2 id="by-the-numbers">By the Numbers</h2>

<table>
  <thead>
    <tr>
      <th>Metric</th>
      <th>Value</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">@debrief/session-state</code> (vitest)</td>
      <td>714 passed</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">debrief-vscode</code> (vitest)</td>
      <td>850 passed</td>
    </tr>
    <tr>
      <td>Web-shell E2E — tolerant load + guard rail (Playwright)</td>
      <td>2 passed</td>
    </tr>
    <tr>
      <td>Schema adherence <code class="language-plaintext highlighter-rouge">shared/schemas</code> (pytest)</td>
      <td>1071 passed</td>
    </tr>
    <tr>
      <td>Schema changes</td>
      <td>0</td>
    </tr>
    <tr>
      <td>New runtime dependencies</td>
      <td>0</td>
    </tr>
    <tr>
      <td>Lint (ruff + ESLint)</td>
      <td>clean</td>
    </tr>
    <tr>
      <td>Typecheck (pyright + tsc)</td>
      <td>clean</td>
    </tr>
  </tbody>
</table>

<h2 id="lessons-learned">Lessons Learned</h2>

<p>The original integration plan used the web-shell’s existing <code class="language-plaintext highlighter-rouge">LogPanel</code> transient — the <code class="language-plaintext highlighter-rouge">actionResultMessage</code> slot — as the notification surface for the clamp. The problem: <code class="language-plaintext highlighter-rouge">actionResultMessage</code> is rendered only while the Log tab is mounted. Setting it during load, before the analyst has navigated to that tab, means the notice fires into an unmounted component and silently disappears. You open a plot, a message is set, the Log panel is somewhere else in the layout, and nothing is ever shown.</p>

<p>The fix was a dedicated App-level toast, distinct from the error banner, rendered unconditionally at the application root, so it is always visible in the context where the load event fires. The general lesson: a “non-blocking notification” surface must be visible where the event happens, not buried in a panel the user may not be looking at. A notification that requires the user to already be on the right tab is effectively silent.</p>

<h2 id="whats-next">What’s Next</h2>

<p>Two items were deliberately deferred during review as YAGNI and captured in the backlog: coalescing multiple clamp notifications into one summary if a batch or session-restore load path is ever added (both hosts load plots one at a time today), and generalising the playhead clamp diagnostic into a reusable recoverable-load diagnostics channel once a second tolerant-import case is commissioned.</p>

<p>→ <a href="https://github.com/debrief/debrief-future/tree/main/specs/267-tolerant-playhead-import/spec.md">See the spec</a>
→ <a href="https://github.com/debrief/debrief-future/tree/main/specs/267-tolerant-playhead-import/evidence">View the evidence</a></p>]]></content><author><name>Ian</name></author><category term="session-state" /><category term="validation" /><category term="load-path" /><category term="playhead" /><category term="tolerant-import" /><summary type="html"><![CDATA[Plots with an out-of-window saved playhead now open, clamping the playhead to the nearest window edge with a non-blocking notification.]]></summary></entry><entry><title type="html">Shipped: Air-gapped briefing zip renderer</title><link href="https://debrief.github.io/shipped-briefing-zip-renderer" rel="alternate" type="text/html" title="Shipped: Air-gapped briefing zip renderer" /><published>2026-05-22T00:00:00+00:00</published><updated>2026-05-22T00:00:00+00:00</updated><id>https://debrief.github.io/shipped-briefing-zip-renderer</id><content type="html" xml:base="https://debrief.github.io/shipped-briefing-zip-renderer"><![CDATA[<h2 id="what-we-built">What We Built</h2>

<p>A briefing leaves Debrief as a single <code class="language-plaintext highlighter-rouge">.zip</code> file. The recipient unzips it on any machine with a modern browser — a classified workstation, a stakeholder’s laptop, a training-room PC with the network cable out — double-clicks <code class="language-plaintext highlighter-rouge">index.html</code>, and the Storyboard plays. Same Scene order, same viewport tweens, same time-slider scrub through every time-range Scene from #263, same per-frame track motion. No install, no extension host, no server, no network call. The zip carries its own basemap tiles, its own Scene thumbnails, its own GeoJSON, its own SPA.</p>

<p>Two viewing modes live behind a hover-revealed toggle. Minimal shows a transport bar (play, pause, next/previous Scene) and a scrubber, for an interactive walkthrough where the audience wants to stop on a moment. Present hides every control and lets the map fill the screen, for the room where the briefer is talking and the screen should just be the picture. Mode survives the toggle; playback position survives the toggle; nothing about the rendering changes between them — only what chrome is on top.</p>

<h2 id="how-it-fits">How It Fits</h2>

<p>The briefing renderer is the second consumer of the Storyboard playback engine that #217 and #258 built and #263 extended for time-range Scenes. That engine — <code class="language-plaintext highlighter-rouge">StoryboardPlaybackService</code> — moved during this feature out of <code class="language-plaintext highlighter-rouge">apps/vscode/</code> into <code class="language-plaintext highlighter-rouge">shared/components/src/storyboardPlayback/service.ts</code>. The hoist is net-zero behaviour for the VS Code app (every pre-existing test passes against the relocated service via a thin re-export shim) but it removes the structural barrier to a second consumer: the briefing renderer can now compose the same service directly when its playback surface grows beyond the current read-only minimum.</p>

<p>The new SPA at <code class="language-plaintext highlighter-rouge">apps/briefing-renderer/</code> (sibling to <code class="language-plaintext highlighter-rouge">apps/backlog-navigator/</code> and <code class="language-plaintext highlighter-rouge">apps/spec-navigator/</code>) currently composes a smaller SPA-local driver around the host-agnostic <code class="language-plaintext highlighter-rouge">runTimeRangeTween</code> primitive that #263 placed in <code class="language-plaintext highlighter-rouge">shared/components/</code> — sufficient for a recipient-side playback view, and a clean swap target for the full service when the briefing grows interactive editing. Four browser-side port adapters (Map, SessionStore, PanelView, TimeRangeView) sit between the driver and Leaflet, the local Zustand store, and the chrome surface.</p>

<p>The export command lives in the VS Code extension as <code class="language-plaintext highlighter-rouge">debrief.storyboard.exportAsBriefingZip</code>, and the pre-built SPA bundle ships as a static resource inside the extension so every export is reproducible from the version of the tool that produced it.</p>

<h2 id="key-decisions">Key Decisions</h2>

<ul>
  <li><strong>Inline the data, don’t <code class="language-plaintext highlighter-rouge">fetch()</code> it.</strong> Browsers restrict <code class="language-plaintext highlighter-rouge">fetch()</code> from <code class="language-plaintext highlighter-rouge">file://</code> origins by design. The export injects <code class="language-plaintext highlighter-rouge">features.geojson</code> and <code class="language-plaintext highlighter-rouge">item.json</code> into <code class="language-plaintext highlighter-rouge">index.html</code> as <code class="language-plaintext highlighter-rouge">&lt;script type="application/json"&gt;</code> blocks, and binary assets (Scene thumbnails, basemap tiles) load through ordinary relative <code class="language-plaintext highlighter-rouge">&lt;img&gt;</code> and Leaflet <code class="language-plaintext highlighter-rouge">TileLayer</code> paths — which <code class="language-plaintext highlighter-rouge">file://</code> allows. This is the pattern that lets the zip work on a totally cold machine.</li>
  <li><strong>Strip Vite’s <code class="language-plaintext highlighter-rouge">crossorigin</code> attribute from the built <code class="language-plaintext highlighter-rouge">&lt;script&gt;</code> and <code class="language-plaintext highlighter-rouge">&lt;link&gt;</code> tags.</strong> Chrome and Edge apply CORS to module scripts that carry that attribute, and the <code class="language-plaintext highlighter-rouge">file://</code> origin can never pass a CORS check — the attribute would cause the renderer to fail at boot. A 30-line post-build script (<code class="language-plaintext highlighter-rouge">scripts/strip-crossorigin.mjs</code>) removes it. The Playwright suite catches any regression: the file-protocol spec opens the built <code class="language-plaintext highlighter-rouge">index.html</code> from a real <code class="language-plaintext highlighter-rouge">file://</code> URL and asserts the SPA mounts.</li>
  <li><strong>Pre-fetch tiles per Scene at export time, including the interpolation path.</strong> Each Scene’s captured viewport and zoom give a tile set; for time-range Scenes we sample the viewport tween between <code class="language-plaintext highlighter-rouge">viewport_start</code> and <code class="language-plaintext highlighter-rouge">viewport_end</code> and union the coverage so mid-scrub pans never hit a missing tile. The bytes go in <code class="language-plaintext highlighter-rouge">tiles/{z}/{x}/{y}.png</code>. The zip is the basemap server.</li>
  <li><strong>Boundary types derived, not re-listed.</strong> <code class="language-plaintext highlighter-rouge">BriefingFeatureCollection = StoryboardPlot</code>; <code class="language-plaintext highlighter-rouge">BriefingItemJson</code> is a strict subset of the source plot’s STAC item.json. Constitution Article IV.5 applies; future fields on the source flow through automatically rather than disappearing silently into a recipient’s briefing.</li>
  <li><strong>One new dependency: <code class="language-plaintext highlighter-rouge">jszip</code>.</strong> Pure JS, MIT-licensed, no native binaries, used only at export time inside the VS Code extension. Considered shelling out to <code class="language-plaintext highlighter-rouge">zip(1)</code> and rejected on cross-platform grounds (Windows hosts).</li>
  <li><strong>Export per Storyboard, not per plot.</strong> The command lives on the Storyboard’s own overflow menu — there is no ambiguity about which one you exported, even when a plot accumulates several over an exercise’s iteration. The scoping pass walks the chosen Storyboard’s <code class="language-plaintext highlighter-rouge">SceneFeature</code> references and includes only the features they actually touch.</li>
  <li><strong>Browser scope narrowed to current Chrome and Edge on desktop.</strong> The original four-browser matrix (add Firefox + Safari) doubled the loader work for a marginal audience gain. The supported pair shares the same <code class="language-plaintext highlighter-rouge">file://</code>-origin loading rules; the SPA’s boot-time browser probe surfaces a banner naming the supported browsers when opened in Firefox / Safari / mobile — Article I.3, no silent failure. Captured as ADR-NEW (2026-05-20).</li>
  <li><strong>Hoist the playback service, but ship a smaller driver for now.</strong> The 983-line <code class="language-plaintext highlighter-rouge">StoryboardPlaybackService</code> was tightly bound to <code class="language-plaintext highlighter-rouge">vscode.Event</code> and <code class="language-plaintext highlighter-rouge">vscode.workspace.fs</code>. The hoist replaces those with a host-agnostic <code class="language-plaintext highlighter-rouge">HostEvent&lt;T&gt;</code> / <code class="language-plaintext highlighter-rouge">HostEventEmitter&lt;T&gt;</code> pair (structurally compatible with <code class="language-plaintext highlighter-rouge">vscode.EventEmitter</code> — the VS Code app’s <code class="language-plaintext highlighter-rouge">vscode.EventEmitter</code> instances pass through unchanged) and lifts the three vscode-backed defaults (<code class="language-plaintext highlighter-rouge">showErrorMessage</code>, <code class="language-plaintext highlighter-rouge">setContext</code>, <code class="language-plaintext highlighter-rouge">showInformationMessage</code>) up to the instantiation site. The shared service is now the single source of truth; the VS Code app composes it via a thin re-export shim, and 840 pre-existing vscode tests pass against the new module unchanged.</li>
</ul>

<h2 id="screenshots">Screenshots</h2>

<h3 id="minimal-mode-default-vs-present-mode">Minimal mode (default) vs Present mode</h3>

<p>The recipient lands in Minimal mode — title bar, transport, time slider all visible. Pressing <code class="language-plaintext highlighter-rouge">P</code> (or clicking <strong>Enter Present (P)</strong>) hides every control and the map fills the viewport.</p>

<table>
  <thead>
    <tr>
      <th style="text-align: center">Minimal mode (default)</th>
      <th style="text-align: center">Present mode</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: center"><img src="/assets/images/future-debrief/shipped-briefing-zip-renderer/briefing-minimal-dark.png" alt="Briefing renderer in Minimal mode showing title bar, transport controls and time slider over a dark map" /></td>
      <td style="text-align: center"><img src="/assets/images/future-debrief/shipped-briefing-zip-renderer/briefing-present.png" alt="Briefing renderer in Present mode with all chrome hidden, map filling the viewport" /></td>
    </tr>
  </tbody>
</table>

<h3 id="the-two-components-in-isolation">The two components in isolation</h3>

<p>The TransportBar exposes play/pause, prev/next Scene, and a Replay button that appears in place of Next at the final Scene. The ModeToggle is always visible in Minimal mode and hover-revealed in Present mode (the <code class="language-plaintext highlighter-rouge">P</code> key works in both).</p>

<table>
  <thead>
    <tr>
      <th style="text-align: center">TransportBar — Idle</th>
      <th style="text-align: center">TransportBar — End of Storyboard</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: center"><img src="/assets/images/future-debrief/shipped-briefing-zip-renderer/transport-bar-idle.png" alt="TransportBar at rest with play, previous and next Scene controls" /></td>
      <td style="text-align: center"><img src="/assets/images/future-debrief/shipped-briefing-zip-renderer/transport-bar-end-of-storyboard.png" alt="TransportBar at the final Scene with Replay button replacing Next" /></td>
    </tr>
  </tbody>
</table>

<table>
  <thead>
    <tr>
      <th style="text-align: center">ModeToggle — Minimal</th>
      <th style="text-align: center">ModeToggle — Present (hover-revealed)</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: center"><img src="/assets/images/future-debrief/shipped-briefing-zip-renderer/mode-toggle-minimal.png" alt="ModeToggle button visible in Minimal mode chrome" /></td>
      <td style="text-align: center"><img src="/assets/images/future-debrief/shipped-briefing-zip-renderer/mode-toggle-present.png" alt="ModeToggle revealed on hover while in Present mode" /></td>
    </tr>
  </tbody>
</table>

<h3 id="mapview-with-the-briefing-friendly-tile-layer-props-t-mapview-ext">MapView with the briefing-friendly tile-layer props (T-MAPVIEW-EXT)</h3>

<p>The four new <code class="language-plaintext highlighter-rouge">MapView</code> props (<code class="language-plaintext highlighter-rouge">errorTileUrl</code>, <code class="language-plaintext highlighter-rouge">maxZoom</code>, <code class="language-plaintext highlighter-rouge">noWrap</code>, <code class="language-plaintext highlighter-rouge">tileLayerCrossOrigin</code>) — captured in three themes from the shared-components Storybook. Defaults preserve today’s behaviour for every existing MapView consumer.</p>

<table>
  <thead>
    <tr>
      <th style="text-align: center">light</th>
      <th style="text-align: center">dark</th>
      <th style="text-align: center">vscode</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: center"><img src="/assets/images/future-debrief/shipped-briefing-zip-renderer/mapview-briefing-props-light.png" alt="MapView Storybook story with briefing tile-layer props in light theme" /></td>
      <td style="text-align: center"><img src="/assets/images/future-debrief/shipped-briefing-zip-renderer/mapview-briefing-props-dark.png" alt="MapView Storybook story with briefing tile-layer props in dark theme" /></td>
      <td style="text-align: center"><img src="/assets/images/future-debrief/shipped-briefing-zip-renderer/mapview-briefing-props-vscode.png" alt="MapView Storybook story with briefing tile-layer props in VS Code theme" /></td>
    </tr>
  </tbody>
</table>

<h3 id="failure-mode-surfaces-article-i3">Failure-mode surfaces (Article I.3)</h3>

<p>The empty state when a Storyboard has no Scenes; the error state when the inline JSON is unreadable; and the “playback halted” state any adapter throw or tween rejection transitions into. None of the three is silent — every recipient sees an explicit message.</p>

<table>
  <thead>
    <tr>
      <th style="text-align: center">Empty state</th>
      <th style="text-align: center">Error state</th>
      <th style="text-align: center">Playback halted</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: center"><img src="/assets/images/future-debrief/shipped-briefing-zip-renderer/briefing-empty.png" alt="Empty-state message shown when the Storyboard contains no Scenes" /></td>
      <td style="text-align: center"><img src="/assets/images/future-debrief/shipped-briefing-zip-renderer/briefing-error.png" alt="Error-state message shown when inline JSON cannot be parsed" /></td>
      <td style="text-align: center"><img src="/assets/images/future-debrief/shipped-briefing-zip-renderer/briefing-halted.png" alt="Playback-halted message shown after an adapter throw or tween rejection" /></td>
    </tr>
  </tbody>
</table>

<h3 id="interaction-gif">Interaction GIF</h3>

<p>The mode-toggle + playback flow, captured via Playwright <code class="language-plaintext highlighter-rouge">recordVideo</code> and post-processed with ffmpeg into a small looping GIF (~100 KB, well under the 2 MB target).</p>

<p><img src="/assets/images/future-debrief/shipped-briefing-zip-renderer/interaction.gif" alt="Looping GIF of the briefing renderer toggling between Minimal and Present modes during playback" /></p>

<h2 id="by-the-numbers">By the Numbers</h2>

<ul>
  <li><strong>One new SPA workspace</strong> at <code class="language-plaintext highlighter-rouge">apps/briefing-renderer/</code> — Vite + React 18 + Zustand + react-leaflet 4.2 — ~1 500 LOC TS including tests.</li>
  <li><strong>One major hoist</strong>: <code class="language-plaintext highlighter-rouge">StoryboardPlaybackService</code> (983 lines) moved from <code class="language-plaintext highlighter-rouge">apps/vscode/src/services/</code> to <code class="language-plaintext highlighter-rouge">shared/components/src/storyboardPlayback/</code>. The four port interfaces split out into <code class="language-plaintext highlighter-rouge">ports.ts</code>; the <code class="language-plaintext highlighter-rouge">vscode.EventEmitter</code> dependency replaced by a host-agnostic <code class="language-plaintext highlighter-rouge">HostEventEmitter&lt;T&gt;</code>; the three <code class="language-plaintext highlighter-rouge">vscode.window.*</code> defaults lifted to the instantiation site. <strong>Zero behaviour change</strong> — all 49 pre-existing storyboardPlayback tests pass against the relocated service.</li>
  <li><strong>New VS Code command surface</strong>: <code class="language-plaintext highlighter-rouge">debrief.storyboard.exportAsBriefingZip</code> + a 6-step orchestrator (<code class="language-plaintext highlighter-rouge">scopeStoryboard</code>, <code class="language-plaintext highlighter-rouge">buildItemJson</code>, <code class="language-plaintext highlighter-rouge">computeTileCoverage</code>, <code class="language-plaintext highlighter-rouge">fetchTiles</code>, <code class="language-plaintext highlighter-rouge">injectInlineData</code>, <code class="language-plaintext highlighter-rouge">assembleZip</code>).</li>
  <li><strong>935 passing tests across the feature surface</strong> (no failures): 840 vscode vitest cases (including 60 new briefing-zip-export cases + 49 pre-existing storyboardPlayback tests against the hoisted service), 58 briefing-renderer vitest cases (loader, probes, adapters, playback driver, halted-state, TransportBar, ModeToggle, TimeSlider, boot), 31 MapView vitest cases (9 new for briefing props), and 19 Playwright E2E specs.</li>
  <li><strong>19 Playwright specs, all passing</strong>: 16 in the briefing-renderer suite covering the <code class="language-plaintext highlighter-rouge">file://</code> boot, network isolation (SC-002), 10× mode toggle (SC-005), failure-mode surfaces, screenshot producers, story-mode component captures, the end-to-end real-export → real-unzip → real-play test, and the interaction GIF; plus 3 in shared/components covering the MapView briefing-props story in all three theme variants.</li>
  <li><strong>One new runtime dependency</strong> (<code class="language-plaintext highlighter-rouge">jszip ^3.10.1</code>) in the VS Code extension; no new dependencies in the SPA beyond React, Leaflet, react-leaflet, and Zustand.</li>
</ul>

<h2 id="whats-next">What’s Next</h2>

<ul>
  <li><strong>Swap the SPA-local driver for the hoisted <code class="language-plaintext highlighter-rouge">StoryboardPlaybackService</code></strong> once the briefing renderer needs the additional surface area the full service provides (Scene-rectangle click handling, snapshot stream, missing-data detection). With T-HOIST already complete this is now a small follow-up rather than the architectural blocker it once was.</li>
  <li><strong>PMTiles basemap</strong> (#272) when zip size becomes a real transport problem — the integer-zoom-only policy in #264’s research caps typical zips around 50 MB but very large Storyboards may exceed that.</li>
  <li><strong>MP4 / GIF export</strong> (#265 — research spike) for the audience that wants a recorded playback rather than an interactive one.</li>
  <li><strong>Bidirectional time-range scrubbing</strong> in the briefing SPA’s time slider — the slider currently reads the engine’s frame-by-frame writes; letting the user <em>drag</em> it backwards through a tween is the next polish step.</li>
  <li><strong>Multi-theme support in the briefing renderer</strong> so the chrome ships in light / dark / vscode variants alongside the MapView prop matrix already covered.</li>
</ul>

<p>→ <a href="https://github.com/debrief/debrief-future/tree/main/specs/264-briefing-zip-renderer/spec.md">See the spec</a>
→ <a href="https://github.com/debrief/debrief-future/tree/main/specs/264-briefing-zip-renderer/evidence">View the evidence</a></p>]]></content><author><name>Ian</name></author><category term="tracer-bullet" /><category term="storyboard" /><category term="briefing-zip" /><category term="vscode-extension" /><category term="spa" /><category term="air-gapped" /><summary type="html"><![CDATA[A single zip carries a Storyboard, basemap tiles, thumbnails and SPA — unzip, double-click index.html, watch it play offline.]]></summary></entry><entry><title type="html">Shipped: Air-gapped briefing zip — Storyboard renderer SPA</title><link href="https://debrief.github.io/shipped-air-gapped-briefing-zip" rel="alternate" type="text/html" title="Shipped: Air-gapped briefing zip — Storyboard renderer SPA" /><published>2026-05-21T00:00:00+00:00</published><updated>2026-05-21T00:00:00+00:00</updated><id>https://debrief.github.io/shipped-air-gapped-briefing-zip</id><content type="html" xml:base="https://debrief.github.io/shipped-air-gapped-briefing-zip"><![CDATA[<h2 id="what-we-built">What We Built</h2>

<p>A briefing leaves Debrief as a single <code class="language-plaintext highlighter-rouge">.zip</code> file. The recipient unzips it on any machine with a modern browser — a classified workstation, a stakeholder’s laptop, a training-room PC with the network cable out — double-clicks <code class="language-plaintext highlighter-rouge">index.html</code>, and the Storyboard plays. Same Scene order, same viewport tweens, same time-slider scrub through every time-range Scene from #263, same per-frame track motion. No install, no extension host, no server, no network call. The zip carries its own basemap tiles, its own Scene thumbnails, its own GeoJSON, its own SPA.</p>

<p>Two viewing modes live behind a hover-revealed toggle. Minimal shows a transport bar (play, pause, next/previous Scene) and a scrubber, for an interactive walkthrough where the audience wants to stop on a moment. Present hides every control and lets the map fill the screen, for the room where the briefer is talking and the screen should just be the picture. Mode survives the toggle; playback position survives the toggle; nothing about the rendering changes between them — only what chrome is on top.</p>

<h2 id="how-it-fits">How It Fits</h2>

<p>The briefing renderer is the second consumer of the Storyboard playback engine that #217 and #258 built and #263 extended for time-range Scenes. That engine — <code class="language-plaintext highlighter-rouge">StoryboardPlaybackService</code> — moved during this feature out of <code class="language-plaintext highlighter-rouge">apps/vscode/</code> into <code class="language-plaintext highlighter-rouge">shared/components/src/storyboardPlayback/service.ts</code>. The hoist is net-zero behaviour for the VS Code app (every pre-existing test passes against the relocated service via a thin re-export shim) but it removes the structural barrier to a second consumer: the briefing renderer can now compose the same service directly when its playback surface grows beyond the current read-only minimum.</p>

<p>The new SPA at <code class="language-plaintext highlighter-rouge">apps/briefing-renderer/</code> (sibling to <code class="language-plaintext highlighter-rouge">apps/backlog-navigator/</code> and <code class="language-plaintext highlighter-rouge">apps/spec-navigator/</code>) currently composes a smaller SPA-local driver around the host-agnostic <code class="language-plaintext highlighter-rouge">runTimeRangeTween</code> primitive that #263 placed in <code class="language-plaintext highlighter-rouge">shared/components/</code> — sufficient for a recipient-side playback view, and a clean swap target for the full service when the briefing grows interactive editing. Four browser-side port adapters (Map, SessionStore, PanelView, TimeRangeView) sit between the driver and Leaflet, the local Zustand store, and the chrome surface.</p>

<p>The export command lives in the VS Code extension as <code class="language-plaintext highlighter-rouge">debrief.storyboard.exportAsBriefingZip</code>, and the pre-built SPA bundle ships as a static resource inside the extension so every export is reproducible from the version of the tool that produced it.</p>

<h2 id="key-decisions">Key Decisions</h2>

<ul>
  <li><strong>Inline the data, don’t <code class="language-plaintext highlighter-rouge">fetch()</code> it.</strong> Browsers restrict <code class="language-plaintext highlighter-rouge">fetch()</code> from <code class="language-plaintext highlighter-rouge">file://</code> origins by design. The export injects <code class="language-plaintext highlighter-rouge">features.geojson</code> and <code class="language-plaintext highlighter-rouge">item.json</code> into <code class="language-plaintext highlighter-rouge">index.html</code> as <code class="language-plaintext highlighter-rouge">&lt;script type="application/json"&gt;</code> blocks, and binary assets (Scene thumbnails, basemap tiles) load through ordinary relative <code class="language-plaintext highlighter-rouge">&lt;img&gt;</code> and Leaflet <code class="language-plaintext highlighter-rouge">TileLayer</code> paths — which <code class="language-plaintext highlighter-rouge">file://</code> allows. This is the pattern that lets the zip work on a totally cold machine.</li>
  <li><strong>Strip Vite’s <code class="language-plaintext highlighter-rouge">crossorigin</code> attribute from the built <code class="language-plaintext highlighter-rouge">&lt;script&gt;</code> and <code class="language-plaintext highlighter-rouge">&lt;link&gt;</code> tags.</strong> Chrome and Edge apply CORS to module scripts that carry that attribute, and the <code class="language-plaintext highlighter-rouge">file://</code> origin can never pass a CORS check — the attribute would cause the renderer to fail at boot. A 30-line post-build script (<code class="language-plaintext highlighter-rouge">scripts/strip-crossorigin.mjs</code>) removes it. The Playwright suite catches any regression: the file-protocol spec opens the built <code class="language-plaintext highlighter-rouge">index.html</code> from a real <code class="language-plaintext highlighter-rouge">file://</code> URL and asserts the SPA mounts.</li>
  <li><strong>Pre-fetch tiles per Scene at export time, including the interpolation path.</strong> Each Scene’s captured viewport and zoom give a tile set; for time-range Scenes we sample the viewport tween between <code class="language-plaintext highlighter-rouge">viewport_start</code> and <code class="language-plaintext highlighter-rouge">viewport_end</code> and union the coverage so mid-scrub pans never hit a missing tile. The bytes go in <code class="language-plaintext highlighter-rouge">tiles/{z}/{x}/{y}.png</code>. The zip is the basemap server.</li>
  <li><strong>Boundary types derived, not re-listed.</strong> <code class="language-plaintext highlighter-rouge">BriefingFeatureCollection = StoryboardPlot</code>; <code class="language-plaintext highlighter-rouge">BriefingItemJson</code> is a strict subset of the source plot’s STAC item.json. Constitution Article IV.5 applies; future fields on the source flow through automatically rather than disappearing silently into a recipient’s briefing.</li>
  <li><strong>One new dependency: <code class="language-plaintext highlighter-rouge">jszip</code>.</strong> Pure JS, MIT-licensed, no native binaries, used only at export time inside the VS Code extension. Considered shelling out to <code class="language-plaintext highlighter-rouge">zip(1)</code> and rejected on cross-platform grounds (Windows hosts).</li>
  <li><strong>Export per Storyboard, not per plot.</strong> The command lives on the Storyboard’s own overflow menu — there is no ambiguity about which one you exported, even when a plot accumulates several over an exercise’s iteration. The scoping pass walks the chosen Storyboard’s <code class="language-plaintext highlighter-rouge">SceneFeature</code> references and includes only the features they actually touch.</li>
  <li><strong>Browser scope narrowed to current Chrome and Edge on desktop.</strong> The original four-browser matrix (add Firefox + Safari) doubled the loader work for a marginal audience gain. The supported pair shares the same <code class="language-plaintext highlighter-rouge">file://</code>-origin loading rules; the SPA’s boot-time browser probe surfaces a banner naming the supported browsers when opened in Firefox / Safari / mobile — Article I.3, no silent failure. Captured as ADR-NEW (2026-05-20).</li>
  <li><strong>Hoist the playback service, but ship a smaller driver for now.</strong> The 983-line <code class="language-plaintext highlighter-rouge">StoryboardPlaybackService</code> was tightly bound to <code class="language-plaintext highlighter-rouge">vscode.Event</code> and <code class="language-plaintext highlighter-rouge">vscode.workspace.fs</code>. The hoist replaces those with a host-agnostic <code class="language-plaintext highlighter-rouge">HostEvent&lt;T&gt;</code> / <code class="language-plaintext highlighter-rouge">HostEventEmitter&lt;T&gt;</code> pair (structurally compatible with <code class="language-plaintext highlighter-rouge">vscode.EventEmitter</code> — the VS Code app’s <code class="language-plaintext highlighter-rouge">vscode.EventEmitter</code> instances pass through unchanged) and lifts the three vscode-backed defaults (<code class="language-plaintext highlighter-rouge">showErrorMessage</code>, <code class="language-plaintext highlighter-rouge">setContext</code>, <code class="language-plaintext highlighter-rouge">showInformationMessage</code>) up to the instantiation site. The shared service is now the single source of truth; the VS Code app composes it via a thin re-export shim, and 840 pre-existing vscode tests pass against the new module unchanged.</li>
</ul>

<h2 id="screenshots">Screenshots</h2>

<h3 id="minimal-mode-default-vs-present-mode">Minimal mode (default) vs Present mode</h3>

<p>The recipient lands in Minimal mode — title bar, transport, time slider all visible. Pressing <code class="language-plaintext highlighter-rouge">P</code> (or clicking <strong>Enter Present (P)</strong>) hides every control and the map fills the viewport.</p>

<table>
  <thead>
    <tr>
      <th style="text-align: center">Minimal mode (default)</th>
      <th style="text-align: center">Present mode</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: center"><img src="/assets/images/future-debrief/shipped-air-gapped-briefing-zip/briefing-minimal-dark.png" alt="Briefing renderer in Minimal mode showing a dark-themed map filling most of the viewport with a transport bar and time slider underneath" /></td>
      <td style="text-align: center"><img src="/assets/images/future-debrief/shipped-air-gapped-briefing-zip/briefing-present.png" alt="Briefing renderer in Present mode showing the map filling the full viewport with no visible controls" /></td>
    </tr>
  </tbody>
</table>

<h3 id="the-two-components-in-isolation">The two components in isolation</h3>

<p>The TransportBar exposes play/pause, prev/next Scene, and a Replay button that appears in place of Next at the final Scene. The ModeToggle is always visible in Minimal mode and hover-revealed in Present mode (the <code class="language-plaintext highlighter-rouge">P</code> key works in both).</p>

<table>
  <thead>
    <tr>
      <th style="text-align: center">TransportBar — Idle</th>
      <th style="text-align: center">TransportBar — End of Storyboard</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: center"><img src="/assets/images/future-debrief/shipped-air-gapped-briefing-zip/transport-bar-idle.png" alt="Transport bar showing prev, play, next buttons and a scene counter reading 1 of 4" /></td>
      <td style="text-align: center"><img src="/assets/images/future-debrief/shipped-air-gapped-briefing-zip/transport-bar-end-of-storyboard.png" alt="Transport bar at the final scene showing a Replay button in place of Next, with the counter reading 4 of 4" /></td>
    </tr>
  </tbody>
</table>

<table>
  <thead>
    <tr>
      <th style="text-align: center">ModeToggle — Minimal</th>
      <th style="text-align: center">ModeToggle — Present (hover-revealed)</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: center"><img src="/assets/images/future-debrief/shipped-air-gapped-briefing-zip/mode-toggle-minimal.png" alt="Mode toggle button labelled Enter Present with a P keyboard hint, sitting in the Minimal-mode chrome" /></td>
      <td style="text-align: center"><img src="/assets/images/future-debrief/shipped-air-gapped-briefing-zip/mode-toggle-present.png" alt="Mode toggle in Present mode, revealed on hover, labelled Exit Present with a P keyboard hint" /></td>
    </tr>
  </tbody>
</table>

<h3 id="mapview-with-the-briefing-friendly-tile-layer-props">MapView with the briefing-friendly tile-layer props</h3>

<p>The four new <code class="language-plaintext highlighter-rouge">MapView</code> props (<code class="language-plaintext highlighter-rouge">errorTileUrl</code>, <code class="language-plaintext highlighter-rouge">maxZoom</code>, <code class="language-plaintext highlighter-rouge">noWrap</code>, <code class="language-plaintext highlighter-rouge">tileLayerCrossOrigin</code>) — captured in three themes from the shared-components Storybook. Defaults preserve today’s behaviour for every existing MapView consumer.</p>

<table>
  <thead>
    <tr>
      <th style="text-align: center">light</th>
      <th style="text-align: center">dark</th>
      <th style="text-align: center">vscode</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: center"><img src="/assets/images/future-debrief/shipped-air-gapped-briefing-zip/mapview-briefing-props-light.png" alt="MapView Storybook story rendering the briefing tile-layer props in the light theme" /></td>
      <td style="text-align: center"><img src="/assets/images/future-debrief/shipped-air-gapped-briefing-zip/mapview-briefing-props-dark.png" alt="MapView Storybook story rendering the briefing tile-layer props in the dark theme" /></td>
      <td style="text-align: center"><img src="/assets/images/future-debrief/shipped-air-gapped-briefing-zip/mapview-briefing-props-vscode.png" alt="MapView Storybook story rendering the briefing tile-layer props in the vscode theme" /></td>
    </tr>
  </tbody>
</table>

<h3 id="failure-mode-surfaces">Failure-mode surfaces</h3>

<p>The empty state when a Storyboard has no Scenes; the error state when the inline JSON is unreadable; and the “playback halted” state any adapter throw or tween rejection transitions into. None of the three is silent — every recipient sees an explicit message.</p>

<table>
  <thead>
    <tr>
      <th style="text-align: center">Empty state</th>
      <th style="text-align: center">Error state</th>
      <th style="text-align: center">Playback halted</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: center"><img src="/assets/images/future-debrief/shipped-air-gapped-briefing-zip/briefing-empty.png" alt="Empty-state screen reading This Storyboard has no Scenes to play" /></td>
      <td style="text-align: center"><img src="/assets/images/future-debrief/shipped-air-gapped-briefing-zip/briefing-error.png" alt="Error-state screen explaining that the inline briefing data could not be parsed" /></td>
      <td style="text-align: center"><img src="/assets/images/future-debrief/shipped-air-gapped-briefing-zip/briefing-halted.png" alt="Playback-halted screen surfacing that an adapter threw and the engine stopped" /></td>
    </tr>
  </tbody>
</table>

<h3 id="interaction">Interaction</h3>

<p>The mode-toggle and playback flow, captured via Playwright <code class="language-plaintext highlighter-rouge">recordVideo</code> and post-processed with ffmpeg into a small looping GIF (~100 KB, well under the 2 MB target).</p>

<p><img src="/assets/images/future-debrief/shipped-air-gapped-briefing-zip/interaction.gif" alt="Animated capture of a recipient toggling between Minimal and Present modes and advancing through Scenes" /></p>

<h2 id="by-the-numbers">By the Numbers</h2>

<ul>
  <li><strong>One new SPA workspace</strong> at <code class="language-plaintext highlighter-rouge">apps/briefing-renderer/</code> — Vite + React 18 + Zustand + react-leaflet 4.2 — ~1,500 LOC TS including tests.</li>
  <li><strong>One major hoist</strong>: <code class="language-plaintext highlighter-rouge">StoryboardPlaybackService</code> (983 lines) moved from <code class="language-plaintext highlighter-rouge">apps/vscode/src/services/</code> to <code class="language-plaintext highlighter-rouge">shared/components/src/storyboardPlayback/</code>. The four port interfaces split out into <code class="language-plaintext highlighter-rouge">ports.ts</code>; the <code class="language-plaintext highlighter-rouge">vscode.EventEmitter</code> dependency replaced by a host-agnostic <code class="language-plaintext highlighter-rouge">HostEventEmitter&lt;T&gt;</code>; the three <code class="language-plaintext highlighter-rouge">vscode.window.*</code> defaults lifted to the instantiation site. <strong>Zero behaviour change</strong> — all 49 pre-existing storyboardPlayback tests pass against the relocated service.</li>
  <li><strong>New VS Code command surface</strong>: <code class="language-plaintext highlighter-rouge">debrief.storyboard.exportAsBriefingZip</code> + a 6-step orchestrator (<code class="language-plaintext highlighter-rouge">scopeStoryboard</code>, <code class="language-plaintext highlighter-rouge">buildItemJson</code>, <code class="language-plaintext highlighter-rouge">computeTileCoverage</code>, <code class="language-plaintext highlighter-rouge">fetchTiles</code>, <code class="language-plaintext highlighter-rouge">injectInlineData</code>, <code class="language-plaintext highlighter-rouge">assembleZip</code>).</li>
  <li><strong>935 passing tests across the feature surface</strong> (no failures): 840 vscode vitest cases (including 60 new briefing-zip-export cases + 49 pre-existing storyboardPlayback tests against the hoisted service), 58 briefing-renderer vitest cases (loader, probes, adapters, playback driver, halted-state, TransportBar, ModeToggle, TimeSlider, boot), 31 MapView vitest cases (9 new for briefing props), and 19 Playwright E2E specs.</li>
  <li><strong>19 Playwright specs, all passing</strong>: 16 in the briefing-renderer suite covering the <code class="language-plaintext highlighter-rouge">file://</code> boot, network isolation, 10× mode toggle, failure-mode surfaces, screenshot producers, story-mode component captures, the end-to-end real-export → real-unzip → real-play test, and the interaction GIF; plus 3 in shared/components covering the MapView briefing-props story in all three theme variants.</li>
  <li><strong>One new runtime dependency</strong> (<code class="language-plaintext highlighter-rouge">jszip ^3.10.1</code>) in the VS Code extension; no new dependencies in the SPA beyond React, Leaflet, react-leaflet, and Zustand.</li>
</ul>

<h2 id="whats-next">What’s Next</h2>

<ul>
  <li><strong>Swap the SPA-local driver for the hoisted <code class="language-plaintext highlighter-rouge">StoryboardPlaybackService</code></strong> once the briefing renderer needs the additional surface area the full service provides (Scene-rectangle click handling, snapshot stream, missing-data detection). With the hoist already complete this is now a small follow-up rather than the architectural blocker it once was.</li>
  <li><strong>PMTiles basemap</strong> (#272) when zip size becomes a real transport problem — the integer-zoom-only policy in #264’s research caps typical zips around 50 MB but very large Storyboards may exceed that.</li>
  <li><strong>MP4 / GIF export</strong> (#265 — research spike) for the audience that wants a recorded playback rather than an interactive one.</li>
  <li><strong>Bidirectional time-range scrubbing</strong> in the briefing SPA’s time slider — the slider currently reads the engine’s frame-by-frame writes; letting the user <em>drag</em> it backwards through a tween is the next polish step.</li>
  <li><strong>Multi-theme support in the briefing renderer</strong> so the chrome ships in light / dark / vscode variants alongside the MapView prop matrix already covered.</li>
</ul>

<p>→ <a href="https://github.com/debrief/debrief-future/tree/main/specs/264-briefing-zip-renderer/spec.md">See the spec</a>
→ <a href="https://github.com/debrief/debrief-future/tree/main/specs/264-briefing-zip-renderer/evidence">View the evidence</a></p>]]></content><author><name>Ian</name></author><category term="storyboarding" /><category term="briefing" /><category term="offline" /><category term="file-protocol" /><category term="react" /><category term="leaflet" /><summary type="html"><![CDATA[A Debrief Storyboard now leaves the tool as a single zip. Double-click index.html, the briefing plays — no install, no server, no network.]]></summary></entry><entry><title type="html">Shipped: Properties Panel — feature &amp;amp; sub-feature editing</title><link href="https://debrief.github.io/shipped-properties-panel-feature-edit" rel="alternate" type="text/html" title="Shipped: Properties Panel — feature &amp;amp; sub-feature editing" /><published>2026-05-20T00:00:00+00:00</published><updated>2026-05-20T00:00:00+00:00</updated><id>https://debrief.github.io/shipped-properties-panel-feature-edit</id><content type="html" xml:base="https://debrief.github.io/shipped-properties-panel-feature-edit"><![CDATA[<table>
  <thead>
    <tr>
      <th> </th>
      <th>Before #192</th>
      <th>After #192</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Editable surface</td>
      <td>Catalog metadata and plot-level fields only</td>
      <td>Catalog, plot-level, per-feature, and per-vertex across every geometry in the plot</td>
    </tr>
    <tr>
      <td>Selection emitters</td>
      <td>Single-select only on the map; the Layers panel and modifier keys did nothing</td>
      <td>Single-select plus Ctrl/Cmd multi-select on both the map and the Layers panel</td>
    </tr>
    <tr>
      <td>Read-only plots</td>
      <td>Edits accepted in the form, then the save silently failed</td>
      <td>A pre-flight banner across every panel mode, plus a post-write notice if the filesystem rejects the write anyway</td>
    </tr>
    <tr>
      <td>Override revert</td>
      <td>Re-type the auto-derived value by hand and hope it matched</td>
      <td>One-click revert next to any overridden field, with the auto-derived value shown alongside</td>
    </tr>
    <tr>
      <td>Annotation vertices</td>
      <td>No metadata anywhere — polygon, line, and point vertices were geometry-only</td>
      <td><code class="language-plaintext highlighter-rouge">label</code>, <code class="language-plaintext highlighter-rouge">tags</code>, and <code class="language-plaintext highlighter-rouge">note</code> on any vertex via the same editor used for track points</td>
    </tr>
  </tbody>
</table>

<h2 id="what-we-built">What We Built</h2>

<p>Until now, the only way to annotate a single point on a track — or correct the nationality on one contact, or label a single vertex of an exclusion zone — was to open the underlying JSON. The Properties Panel handled the plot as a whole, and stopped there. With #192 the same panel becomes the editing surface for whatever the analyst has selected: a feature, a single vertex on that feature, several features compared side by side, or the whole plot. Clicking a track point lets you mark it as the moment of intercept and attach a note; clicking a polygon vertex lets you tag it the same way; Ctrl-clicking two contacts on the map shows a read-only summary of where they agree and where they differ.</p>

<p>The change is deliberately larger than it looked at first. After a Spec Navigator review I pulled four things out of the “out of scope” pile that the original plan had quietly assumed away. Multi-select emission, because the multi-select summary mode was unreachable without it — a documented capability that nothing in the running app could actually trigger. Read-only plot detection, because a save against a locked catalog item was failing silently and writing a provenance entry for a save that never happened, which is the exact failure mode the constitution forbids. Per-field revert, because shipping per-platform overrides without a way to undo them was half a feature. And sub-feature editing for non-track annotations, because designing a vertex-metadata schema slot now and then designing another one for polygons later would have been an obvious mistake.</p>

<h2 id="how-it-fits">How It Fits</h2>

<p>This sits on top of #447’s Properties Panel shell and consumes the per-platform override fields from #181, the structured vertex-path convention from #053, the annotation geometries from #093, and the auto-derived values from #135. Nothing new joins the dependency graph — no new packages, no new runtime libraries, no new Zustand stores. The staging buffer lives in React state inside the panel’s host controller, the read-only signal lives on the existing session-state plot slice as <code class="language-plaintext highlighter-rouge">isReadOnly</code> plus a <code class="language-plaintext highlighter-rouge">readOnlyReason</code>, and the selection model stays the existing <code class="language-plaintext highlighter-rouge">features</code> slice — the map and Layers panel click handlers grow modifier flags but the shape of <code class="language-plaintext highlighter-rouge">selection</code> is unchanged. The headline schema change is one LinkML class (<code class="language-plaintext highlighter-rouge">VertexMetadata</code>) and one slot (<code class="language-plaintext highlighter-rouge">vertex_metadata</code>) added to <code class="language-plaintext highlighter-rouge">BaseFeatureProperties</code>, which means every feature class that inherits from it — <code class="language-plaintext highlighter-rouge">TrackProperties</code> plus the seven annotation classes — picks up the slot for free. The address inside each entry is a structured string <code class="language-plaintext highlighter-rouge">path</code> following #053’s selection-path convention: <code class="language-plaintext highlighter-rouge">positions/N</code> for tracks, <code class="language-plaintext highlighter-rouge">rings/R/vertices/V</code> for polygons, <code class="language-plaintext highlighter-rouge">vertices/N</code> for lines and multipoints, <code class="language-plaintext highlighter-rouge">vertex/0</code> for points.</p>

<h2 id="key-decisions">Key Decisions</h2>

<ul>
  <li><strong>Staging buffer is panel-local React state, not a new Zustand slice.</strong> The first plan had me adding a cross-package store; the Spec Navigator review pointed out that nothing outside the panel reads it, and that introducing shared state for a panel-private concern would be the wrong call. It now lives where the panel can see it and nowhere else.</li>
  <li><strong>The staging buffer, the save→flush wiring, and the per-save provenance call site are net-new in #192, not extensions of #447.</strong> The original plan claimed they were inherited from the plot-editor; an Article I.3 audit showed they weren’t. Misclassifying them risked shipping the silent-save bug into the per-feature path too, so I added an integrated save-path Vitest specifically to close that hole.</li>
  <li><strong>Read-only detection combines a writer capability report with post-write escalation.</strong> The writer’s <code class="language-plaintext highlighter-rouge">CapabilityReport.persistent</code> flag drives the pre-flight banner. If a save still fails with <code class="language-plaintext highlighter-rouge">ReadOnlyFilesystemError</code>, <code class="language-plaintext highlighter-rouge">EACCES</code>, or <code class="language-plaintext highlighter-rouge">EPERM</code>, the panel escalates to read-only after the fact and surfaces a notice. Where the catalog lock and the filesystem permission disagree, the most restrictive wins.</li>
  <li><strong>One shared <code class="language-plaintext highlighter-rouge">VertexMetadata</code> class, addressed by a structured string <code class="language-plaintext highlighter-rouge">path</code>.</strong> The alternative was a per-geometry class hierarchy, which would have meant generating four near-identical types and four mode-resolver branches. A single class with a path slot kept the schema sparse (vertices with no metadata add nothing to the saved file), kept the editor branch-free (the same <code class="language-plaintext highlighter-rouge">label</code>/<code class="language-plaintext highlighter-rouge">tags</code>/<code class="language-plaintext highlighter-rouge">note</code> form regardless of geometry kind), and meant the mode resolver only needs to recognise a vertex path — not decode it.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">PropertiesForm</code> gets a <code class="language-plaintext highlighter-rouge">mode</code> prop and three new sibling components — <code class="language-plaintext highlighter-rouge">FeatureEditorMode</code>, <code class="language-plaintext highlighter-rouge">SubFeatureEditorMode</code>, <code class="language-plaintext highlighter-rouge">MultiSelectSummaryMode</code> — rather than a single super-component that branches internally.</strong> Each mode is independently storyable, independently testable, and independently rerenderable when the selection changes.</li>
  <li><strong>Multi-select emission is upstream of the panel.</strong> The map’s <code class="language-plaintext highlighter-rouge">onSelect</code> and the Layers panel’s row click handler grow modifier flags; the panel itself learns nothing new about how the selection was built. This keeps the panel’s mode-resolution logic pure: it reads the selection and renders, and it never asks where the selection came from.</li>
  <li><strong>Vertex re-mapping under geometry mutation is explicitly deferred.</strong> If a later feature edits a polygon’s vertices, the rules for how <code class="language-plaintext highlighter-rouge">vertex_metadata</code> entries follow insertions and deletions will be defined alongside that feature, not pre-emptively here. Annotating today is in scope; reshaping is not.</li>
</ul>

<h2 id="screenshots">Screenshots</h2>

<p>The Properties Panel renders one of four modes based on what’s selected. Below: feature mode (with the per-platform override chip and the revert affordance), sub-feature mode on a track point and on a polygon vertex (cross-geometry hero — same form, different address), the multi-select read-only summary, and the read-only banner that fires either pre-flight or after a permission-denied save.</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th> </th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><img src="/assets/images/future-debrief/shipped-properties-panel-feature-edit/properties-feature-vscode.png" alt="Properties Panel in feature edit mode showing per-platform override chip and revert affordance, VS Code theme" /></td>
      <td><img src="/assets/images/future-debrief/shipped-properties-panel-feature-edit/properties-subfeature-track-vscode.png" alt="Properties Panel in sub-feature mode editing a track point with label, tags, and note fields" /></td>
    </tr>
    <tr>
      <td>Feature mode</td>
      <td>Sub-feature mode (track point)</td>
    </tr>
    <tr>
      <td><img src="/assets/images/future-debrief/shipped-properties-panel-feature-edit/properties-subfeature-polygon-vscode.png" alt="Properties Panel in sub-feature mode editing a polygon ring vertex with the same form as the track point" /></td>
      <td><img src="/assets/images/future-debrief/shipped-properties-panel-feature-edit/properties-multiselect-vscode.png" alt="Properties Panel multi-select summary mode showing read-only comparison of two contacts" /></td>
    </tr>
    <tr>
      <td>Sub-feature mode (polygon ring vertex) — same form, different address</td>
      <td>Multi-select summary</td>
    </tr>
    <tr>
      <td><img src="/assets/images/future-debrief/shipped-properties-panel-feature-edit/properties-readonly-vscode.png" alt="Read-only banner displayed across the top of the Properties Panel" /></td>
      <td><img src="/assets/images/future-debrief/shipped-properties-panel-feature-edit/workflow-readonly.png" alt="Read-only state in the live web-shell with banner and disabled inputs" /></td>
    </tr>
    <tr>
      <td>Read-only banner alone</td>
      <td>Read-only in the live web-shell (banner + disabled inputs)</td>
    </tr>
  </tbody>
</table>

<p>The mode-swap frame strip captures the four key states the dispatcher cycles through. (The cloud environment has no <code class="language-plaintext highlighter-rouge">ffmpeg</code>, so the spec’s planned GIF is rendered as a four-frame sequence the PR description displays side-by-side.)</p>

<p><img src="/assets/images/future-debrief/shipped-properties-panel-feature-edit/workflow-mode-swap-1-plot.png" alt="Plot mode — Properties Panel showing whole-plot metadata" />
<img src="/assets/images/future-debrief/shipped-properties-panel-feature-edit/workflow-mode-swap-2-feature.png" alt="Feature mode — Properties Panel showing a single selected feature's editable fields" />
<img src="/assets/images/future-debrief/shipped-properties-panel-feature-edit/workflow-mode-swap-3-subfeature.png" alt="Sub-feature mode — Properties Panel showing per-vertex label, tags, and note" />
<img src="/assets/images/future-debrief/shipped-properties-panel-feature-edit/workflow-mode-swap-4-multi.png" alt="Multi-select mode — Properties Panel showing a read-only summary of multiple selected features" /></p>

<h2 id="by-the-numbers">By the Numbers</h2>

<table>
  <thead>
    <tr>
      <th> </th>
      <th> </th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>User stories</td>
      <td>7 (4× P1, 3× P2)</td>
    </tr>
    <tr>
      <td>LinkML classes added</td>
      <td>1 (<code class="language-plaintext highlighter-rouge">VertexMetadata</code>)</td>
    </tr>
    <tr>
      <td>LinkML slots added</td>
      <td>1 (<code class="language-plaintext highlighter-rouge">vertex_metadata: VertexMetadata[]</code> on <code class="language-plaintext highlighter-rouge">BaseFeatureProperties</code>)</td>
    </tr>
    <tr>
      <td>Concrete subclasses that inherit the new slot</td>
      <td>13</td>
    </tr>
    <tr>
      <td>New runtime dependencies</td>
      <td>0</td>
    </tr>
    <tr>
      <td>Schema pytest cases (full suite / new in #192)</td>
      <td>916 / 53</td>
    </tr>
    <tr>
      <td>Component-library Vitest cases (full suite / new in #192)</td>
      <td>2 250 / ~300</td>
    </tr>
    <tr>
      <td>Session-state Vitest cases (full suite / new in #192)</td>
      <td>675 / 14</td>
    </tr>
    <tr>
      <td>VS Code extension Vitest cases</td>
      <td>780 (no regressions)</td>
    </tr>
    <tr>
      <td>Web-shell Playwright cases (all #192 specs)</td>
      <td>42</td>
    </tr>
    <tr>
      <td>Storybook screenshot captures</td>
      <td>7 (3 themes for the feature mode + 4 mode-specific)</td>
    </tr>
    <tr>
      <td>Total tests passed at HEAD</td>
      <td><strong>4 670</strong></td>
    </tr>
    <tr>
      <td>Total tests failed</td>
      <td><strong>0</strong></td>
    </tr>
    <tr>
      <td>Breaking API changes</td>
      <td>1 (<code class="language-plaintext highlighter-rouge">MapView.onSelect</code> signature — same prop name, new payload shape)</td>
    </tr>
    <tr>
      <td>Plot-editor #447 regression count</td>
      <td>0 (49/49 tests pass unchanged)</td>
    </tr>
  </tbody>
</table>

<h2 id="lessons-learned">Lessons Learned</h2>

<ul>
  <li><strong>Audit “we inherit that from #447” claims before designing on top of them.</strong> The original plan said the save path, the provenance call site, and the staging buffer were #447 plumbing reused as-is. The Spec Navigator review showed that wasn’t true — the save path landed but the staging buffer and the provenance call site were never wired. I’d been planning to lean on infrastructure that didn’t exist. The integrated save-path Vitest (<code class="language-plaintext highlighter-rouge">saveSession-integration.test.ts</code>) is the test that closes that gap; it would not have existed without the second review pass.</li>
  <li><strong>A breaking signature change to a high-traffic prop is cheap if the package boundary is right.</strong> <code class="language-plaintext highlighter-rouge">MapView.onSelect</code> going from <code class="language-plaintext highlighter-rouge">(featureId, event)</code> to <code class="language-plaintext highlighter-rouge">({ target, modifier, shift })</code> rippled through one VS Code call-site and two web-shell call-sites, the FeatureList convergence, two Storybook stories, and one selection-sync test. It took the Phase 5 agent under 20 minutes from first edit to all-green. The same change spread across packages owned by different teams would have cost a week.</li>
  <li><strong>Browser-safe subpath exports are worth setting up before they bite.</strong> Adding <code class="language-plaintext highlighter-rouge">@debrief/session-state/browser</code> for the components package was a 30-line change that took an hour to discover (the regression was invisible until the VS Code webview pretest fell over with <code class="language-plaintext highlighter-rouge">Could not resolve "fs/promises"</code>). The web-shell was already aliasing the package to a local shim for the same reason — that pattern should be a project-wide convention, not a per-app workaround.</li>
  <li><strong>Inlining a JSON registry into a component is fragile, even when it’s expedient.</strong> The platform registry lookup for the revert affordance reads <code class="language-plaintext highlighter-rouge">shared/data/platform-registry.json</code>. The <code class="language-plaintext highlighter-rouge">@debrief/data</code> package uses <code class="language-plaintext highlighter-rouge">node:fs</code>, which doesn’t work in the webview. The Phase 8 work mirrored the JSON inline; this is a known follow-up. The right fix is a <code class="language-plaintext highlighter-rouge">@debrief/data/browser</code> subpath that ships the JSON bundled at build time.</li>
</ul>

<h2 id="whats-next">What’s Next</h2>

<ul>
  <li>Wire a Save action in the host UI. The staging buffer’s <code class="language-plaintext highlighter-rouge">applyEditsToFeatures</code> → <code class="language-plaintext highlighter-rouge">saveSession</code> → <code class="language-plaintext highlighter-rouge">appendProvenance</code> glue is shipped; the panel currently never invokes it from a visible control. The follow-up backlog item will surface a Save button in the panel header.</li>
  <li>Add <code class="language-plaintext highlighter-rouge">@debrief/data/browser</code> to remove the inline platform-registry mirror in <code class="language-plaintext highlighter-rouge">FeatureEditorMode.tsx</code>.</li>
  <li>Vertex re-mapping under geometry mutation. The current sparse-path keying means that if a polygon shrinks under the analyst, vertex_metadata entries whose path no longer resolves are skipped. The next feature to edit polygon vertices should define the re-mapping rules at the same time.</li>
  <li>Convert the workflow PNG sequences into GIFs once <code class="language-plaintext highlighter-rouge">ffmpeg</code> is on the CI image. The capture spec is already in place — only the post-processing step is missing.</li>
</ul>

<p>→ <a href="https://github.com/debrief/debrief-future/tree/main/specs/192-properties-panel-feature-edit/spec.md">See the spec</a>
→ <a href="https://github.com/debrief/debrief-future/tree/main/specs/192-properties-panel-feature-edit/evidence">View the evidence</a></p>]]></content><author><name>Ian</name></author><category term="properties-panel" /><category term="vscode-extension" /><category term="linkml" /><category term="schemas" /><category term="selection" /><category term="read-only" /><summary type="html"><![CDATA[The Properties Panel now edits features, single vertices, and multi-select summaries — with read-only detection and one-click revert.]]></summary></entry><entry><title type="html">Shipped: Schema-rooted STAC envelopes</title><link href="https://debrief.github.io/shipped-schema-rooted-stac-envelopes" rel="alternate" type="text/html" title="Shipped: Schema-rooted STAC envelopes" /><published>2026-05-20T00:00:00+00:00</published><updated>2026-05-20T00:00:00+00:00</updated><id>https://debrief.github.io/shipped-schema-rooted-stac-envelopes</id><content type="html" xml:base="https://debrief.github.io/shipped-schema-rooted-stac-envelopes"><![CDATA[<pre><code class="language-mermaid">flowchart LR
  subgraph Before["Before — 13 hand-types, 4 files, silent drift on disk"]
    PY1[Python writer&lt;br/&gt;dict StacItem]
    TS1[VS Code ext&lt;br/&gt;typed by hand]
    TS2[web-shell mock&lt;br/&gt;typed by hand]
    TS3[sceneThumbnail&lt;br/&gt;private alias]
  end

  subgraph After["After — one source, generated fan-out"]
    YAML[stac.yaml&lt;br/&gt;LinkML]
    YAML --&gt; PYD[Pydantic models]
    YAML --&gt; TST[TypeScript types]
    PYD --&gt; PYW[Python writer]
    TST --&gt; CONS[All TS consumers]
  end
</code></pre>

<h2 id="what-we-built">What We Built</h2>

<p>STAC catalog payloads — the <code class="language-plaintext highlighter-rouge">item.json</code>, <code class="language-plaintext highlighter-rouge">catalog.json</code> and <code class="language-plaintext highlighter-rouge">collection.json</code> files that record every plot in the local store — had been hand-typed on both sides of the Python ↔ TypeScript boundary since the catalog landed. Twelve interface declarations across four files, three separate copies of <code class="language-plaintext highlighter-rouge">StacItem</code>, and nothing connecting them. These files persist to disk between sessions: Python writes, TypeScript reads. A field added to one side and missed on the other didn’t crash — it silently dropped on the next save.</p>

<p>This work promoted the cluster onto a single LinkML source. <code class="language-plaintext highlighter-rouge">StacItem</code>, <code class="language-plaintext highlighter-rouge">StacCatalog</code>, <code class="language-plaintext highlighter-rouge">StacCollection</code>, <code class="language-plaintext highlighter-rouge">StacLink</code>, <code class="language-plaintext highlighter-rouge">StacAsset</code>, <code class="language-plaintext highlighter-rouge">StacExtent</code>, <code class="language-plaintext highlighter-rouge">StacSummaries</code> and <code class="language-plaintext highlighter-rouge">StacProvider</code> now live in one file, and Pydantic models plus TypeScript types are generated from it. Every committed <code class="language-plaintext highlighter-rouge">item.json</code> under <code class="language-plaintext highlighter-rouge">preview/workspace/samples/local-store/</code> — 73 real files — loads cleanly through the generated validators on both sides. That fixture-corpus test is the strongest evidence the schema captures the wire shape that actually ships, not a sanitised cartoon of it.</p>

<h2 id="how-it-fits">How It Fits</h2>

<p>This is the third slice of Epic E11 — Schema-First Boundary Typing — the same programme that #222 (MCP envelopes) closed last month. Same pattern, different cluster: the audit’s drift table flagged five sites in §3.1, seven more siblings were masked by the file-level R4 rule but still hand-written, and one inline alias rounded out the thirteen. All of them collapsed onto one generated class per name. The audit’s <code class="language-plaintext highlighter-rouge">cross-domain-hand-typed</code> count attributed to #223 drops from 5 to 0; the <code class="language-plaintext highlighter-rouge">StacItem</code> and <code class="language-plaintext highlighter-rouge">StacCatalog</code> drift clusters in §3.2 disappear entirely.</p>

<h2 id="key-decisions">Key Decisions</h2>

<ul>
  <li><strong>STAC 1.0 and 1.1 both accepted via additive optional fields.</strong> The local stores currently ship 1.0; spec #241 (in flight) upgrades them to 1.1. Modelling <code class="language-plaintext highlighter-rouge">stac_version</code> as a string and making the new 1.1-only fields optional means #223 and #241 can land in either order — neither blocks the other.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">StacCatalog</code> and <code class="language-plaintext highlighter-rouge">StacCollection</code> are siblings, not parent and child.</strong> Each declares its <code class="language-plaintext highlighter-rouge">type</code> slot with <code class="language-plaintext highlighter-rouge">equals_string</code>, which generates a TypeScript literal that makes <code class="language-plaintext highlighter-rouge">if (x.type === 'Collection')</code> narrow at the call site. Inheritance was tempting — Collection is structurally Catalog-plus-extras — but it captures the relationship wrong: a Collection’s <code class="language-plaintext highlighter-rouge">type</code> is <code class="language-plaintext highlighter-rouge">"Collection"</code>, not <code class="language-plaintext highlighter-rouge">"Catalog"</code>.</li>
  <li><strong>Open-record extension slots, with eyes open.</strong> <code class="language-plaintext highlighter-rouge">StacItem.properties</code>, <code class="language-plaintext highlighter-rouge">StacAsset</code> and <code class="language-plaintext highlighter-rouge">StacSummaries</code> all carry <code class="language-plaintext highlighter-rouge">additional_properties: true</code> so the <code class="language-plaintext highlighter-rouge">&lt;namespace&gt;:&lt;key&gt;</code> convention (<code class="language-plaintext highlighter-rouge">debrief:platforms</code>, <code class="language-plaintext highlighter-rouge">file:checksum</code>, <code class="language-plaintext highlighter-rouge">processing:datetime</code>, <code class="language-plaintext highlighter-rouge">proj:shape</code>) survives the boundary. Same Article XV.2 exception #222 used for <code class="language-plaintext highlighter-rouge">MCPContentItem.structuredContent</code>, applied where STAC’s own spec is genuinely open.</li>
  <li><strong>Composition over re-declaration.</strong> <code class="language-plaintext highlighter-rouge">StacItemProperties</code> mixes in the existing <code class="language-plaintext highlighter-rouge">StacExtensionProperties</code> from <code class="language-plaintext highlighter-rouge">stac-extension.yaml</code>; <code class="language-plaintext highlighter-rouge">StacItem.geometry</code> references the seven existing geometry classes from <code class="language-plaintext highlighter-rouge">geojson.yaml</code>. No re-declared shapes anywhere — the same rule that made #222 stick.</li>
  <li><strong>Python writes through Pydantic too.</strong> <code class="language-plaintext highlighter-rouge">scripts/enrich-legacy-catalog.py</code> switches from <code class="language-plaintext highlighter-rouge">dict[str, Any]</code> constructions to Pydantic class constructions. Field-name typos now fail at write time, not three releases later when somebody finally notices the missing key in the tree view.</li>
  <li><strong>Out of scope, and named.</strong> The camelCase <code class="language-plaintext highlighter-rouge">StacItemSummary</code> adapter (#214 follow-up) and the STAC 1.1 wire-format work (#241) both touch adjacent files, but neither is in this feature. Calling them out keeps the diff honest and the reviewer’s job small.</li>
</ul>

<h2 id="by-the-numbers">By the Numbers</h2>

<p>The audit re-run is the clearest single signal — the rows that started this feature are gone.</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th style="text-align: right">Before</th>
      <th style="text-align: right">After</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>§3.1 rows attributed to #223</td>
      <td style="text-align: right">8</td>
      <td style="text-align: right">0</td>
    </tr>
    <tr>
      <td>§3.2 <code class="language-plaintext highlighter-rouge">StacItem</code> drift cluster</td>
      <td style="text-align: right">3 members</td>
      <td style="text-align: right">0</td>
    </tr>
    <tr>
      <td>§3.2 <code class="language-plaintext highlighter-rouge">StacCatalog</code> drift cluster</td>
      <td style="text-align: right">2 members</td>
      <td style="text-align: right">0</td>
    </tr>
    <tr>
      <td>§3.2 <code class="language-plaintext highlighter-rouge">StacAsset</code> drift cluster</td>
      <td style="text-align: right">2 members</td>
      <td style="text-align: right">0</td>
    </tr>
    <tr>
      <td>Hand-typed declarations across the in-scope tree</td>
      <td style="text-align: right">13</td>
      <td style="text-align: right">0</td>
    </tr>
  </tbody>
</table>

<p>The shape of what landed:</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th> </th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>LinkML classes added to <code class="language-plaintext highlighter-rouge">stac.yaml</code></td>
      <td>11</td>
    </tr>
    <tr>
      <td>LinkML enum added</td>
      <td>1</td>
    </tr>
    <tr>
      <td>TypeScript-only union alias</td>
      <td>1 (<code class="language-plaintext highlighter-rouge">StacCatalogOrCollection</code>)</td>
    </tr>
    <tr>
      <td>Hand-typed declarations deleted</td>
      <td>13, across 4 files</td>
    </tr>
    <tr>
      <td>Files touched on the consumer side</td>
      <td><code class="language-plaintext highlighter-rouge">apps/vscode/src/types/stac.ts</code>, <code class="language-plaintext highlighter-rouge">apps/vscode/src/services/sceneThumbnailService.ts</code>, <code class="language-plaintext highlighter-rouge">apps/web-shell/src/mocks/stacService.ts</code>, <code class="language-plaintext highlighter-rouge">shared/stac-writer/src/interface.ts</code></td>
    </tr>
    <tr>
      <td>New test cases</td>
      <td>128 (round-trip 36, schema comparison 16, fixture corpus 77)</td>
    </tr>
    <tr>
      <td>Fixture corpus loads (no coercion)</td>
      <td>75 items + 2 catalogs</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">JSON.parse(JSON.stringify(...))</code> projection casts removed</td>
      <td>1 (A-009)</td>
    </tr>
  </tbody>
</table>

<p>The fixture corpus is the load-bearing test. Every committed <code class="language-plaintext highlighter-rouge">item.json</code> under <code class="language-plaintext highlighter-rouge">preview/workspace/samples/local-store/</code> (73 STAC 1.1 items + 1 Collection root) and <code class="language-plaintext highlighter-rouge">apps/vscode/test-data/local-store/</code> (2 STAC 1.0 items + 1 Catalog root) loads through the generated Pydantic validators with zero coercion. Three golden fixtures — <code class="language-plaintext highlighter-rouge">boat1</code>, <code class="language-plaintext highlighter-rouge">analysis2-track1</code>, and the 81 KB preview Collection — go through Py → JSON → Py and emerge byte-equivalent.</p>

<p>The write side now flows through Pydantic too:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">from</span> <span class="nn">debrief_schemas</span> <span class="kn">import</span> <span class="n">StacItem</span>

<span class="n">item</span> <span class="o">=</span> <span class="p">{</span>
    <span class="s">"type"</span><span class="p">:</span> <span class="s">"Feature"</span><span class="p">,</span>
    <span class="s">"stac_version"</span><span class="p">:</span> <span class="s">"1.1.0"</span><span class="p">,</span>
    <span class="s">"id"</span><span class="p">:</span> <span class="s">"core--boat1"</span><span class="p">,</span>
    <span class="s">"geometry"</span><span class="p">:</span> <span class="p">{</span><span class="s">"type"</span><span class="p">:</span> <span class="s">"Polygon"</span><span class="p">,</span> <span class="s">"coordinates"</span><span class="p">:</span> <span class="p">[...]},</span>
    <span class="s">"bbox"</span><span class="p">:</span> <span class="p">[</span><span class="o">-</span><span class="mf">21.866</span><span class="p">,</span> <span class="mf">21.947</span><span class="p">,</span> <span class="o">-</span><span class="mf">21.580</span><span class="p">,</span> <span class="mf">22.186</span><span class="p">],</span>
    <span class="s">"properties"</span><span class="p">:</span> <span class="p">{</span>
        <span class="s">"datetime"</span><span class="p">:</span> <span class="s">"1995-12-12T05:00:00+00:00"</span><span class="p">,</span>
        <span class="s">"title"</span><span class="p">:</span> <span class="s">"Saxon Warrior: Boat1"</span><span class="p">,</span>
        <span class="s">"debrief:platforms"</span><span class="p">:</span> <span class="p">[{</span><span class="s">"id"</span><span class="p">:</span> <span class="s">"NELSON"</span><span class="p">,</span> <span class="s">"name"</span><span class="p">:</span> <span class="s">"HMS Nelson"</span><span class="p">}],</span>
        <span class="c1"># extension keys: file:size, proj:shape, processing:* —
</span>        <span class="c1"># all accepted via the Article XV.2 open-record exception.
</span>    <span class="p">},</span>
    <span class="s">"links"</span><span class="p">:</span> <span class="p">[...],</span>
    <span class="s">"assets"</span><span class="p">:</span> <span class="p">{...},</span>
<span class="p">}</span>

<span class="n">StacItem</span><span class="p">.</span><span class="n">model_validate</span><span class="p">(</span><span class="n">item</span><span class="p">)</span>  <span class="c1"># raises on field-name typos, missing slots
</span></code></pre></div></div>

<p>And the read side, on every TypeScript consumer:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">import</span> <span class="kd">type</span> <span class="p">{</span> <span class="nx">StacItem</span><span class="p">,</span> <span class="nx">StacCatalogOrCollection</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">@debrief/schemas</span><span class="dl">'</span><span class="p">;</span>

<span class="kd">const</span> <span class="nx">item</span><span class="p">:</span> <span class="nx">StacItem</span> <span class="o">=</span> <span class="nx">JSON</span><span class="p">.</span><span class="nx">parse</span><span class="p">(</span><span class="nx">content</span><span class="p">);</span>

<span class="k">if</span> <span class="p">(</span><span class="nx">root</span><span class="p">.</span><span class="kd">type</span> <span class="o">===</span> <span class="dl">'</span><span class="s1">Collection</span><span class="dl">'</span><span class="p">)</span> <span class="p">{</span>
  <span class="c1">// TypeScript narrows to StacCollection — no predicate, no cast.</span>
  <span class="c1">// root.extent.spatial.bbox, root.license, root.summaries are typed.</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The writer package re-exports <code class="language-plaintext highlighter-rouge">StacItem</code> and <code class="language-plaintext highlighter-rouge">StacAsset</code> from <code class="language-plaintext highlighter-rouge">@debrief/schemas</code> rather than declaring them locally. That closes A-009: both ends of the writer ↔ mock boundary now reference the same generated class, so the JSON projection cast that previously laundered between two structurally-equivalent-but-nominally-distinct <code class="language-plaintext highlighter-rouge">StacItem</code> types at <code class="language-plaintext highlighter-rouge">apps/web-shell/src/mocks/stacService.ts:464-474</code> is gone.</p>

<h2 id="lessons-learned">Lessons Learned</h2>

<p><strong>The STAC extension convention is genuinely open.</strong> STAC’s <code class="language-plaintext highlighter-rouge">&lt;namespace&gt;:&lt;key&gt;</code> pattern (<code class="language-plaintext highlighter-rouge">debrief:platforms</code>, <code class="language-plaintext highlighter-rouge">file:checksum</code>, <code class="language-plaintext highlighter-rouge">proj:shape</code>, <code class="language-plaintext highlighter-rouge">processing:datetime</code>) is not a closed set we could enumerate. Pydantic’s <code class="language-plaintext highlighter-rouge">extra='allow'</code> and TypeScript’s <code class="language-plaintext highlighter-rouge">[key: string]: unknown</code> — the Article XV.2 exception #222 introduced — are the right shape for this, not a tighter union. The plan’s Complexity Tracking section spells out the trade-off: we lose compile-time knowledge of extension keys in exchange for a schema that survives contact with the on-disk corpus without coercion. Three open-record classes (<code class="language-plaintext highlighter-rouge">StacItemProperties</code>, <code class="language-plaintext highlighter-rouge">StacAsset</code>, <code class="language-plaintext highlighter-rouge">StacSummaries</code>, plus <code class="language-plaintext highlighter-rouge">StacItemAssetDefinition</code>) carry the exception. Everything else is closed.</p>

<p><strong>Nested arrays needed no new machinery.</strong> STAC’s <code class="language-plaintext highlighter-rouge">bbox: number[]</code> and <code class="language-plaintext highlighter-rouge">interval: (string | null)[]</code> slots have the same generator gotcha as GeoJSON’s nested coordinate arrays — LinkML’s <code class="language-plaintext highlighter-rouge">gen-pydantic</code> and <code class="language-plaintext highlighter-rouge">gen-typescript</code> emit the inner array shape incorrectly without a post-processing pass. The pass that already exists for GeoJSON handled this. No new mechanism, just a precedent applied.</p>

<p><strong>Item assets and Collection item_assets are not the same class.</strong> STAC 1.1 makes the distinction explicit: an <code class="language-plaintext highlighter-rouge">Item.assets[k]</code> is a concrete asset with a required <code class="language-plaintext highlighter-rouge">href</code>, but a <code class="language-plaintext highlighter-rouge">Collection.item_assets[k]</code> is an <em>asset definition</em> — a template that downstream items will fill in, with no <code class="language-plaintext highlighter-rouge">href</code> of its own. The temptation was to model these as one class with <code class="language-plaintext highlighter-rouge">href: Optional[str]</code>. That would have weakened typing on the read side: consumers iterating <code class="language-plaintext highlighter-rouge">item.assets</code> would have to null-check <code class="language-plaintext highlighter-rouge">href</code> on every access despite the STAC spec guaranteeing it. Two classes — <code class="language-plaintext highlighter-rouge">StacAsset</code> (href required) and <code class="language-plaintext highlighter-rouge">StacItemAssetDefinition</code> (no href) — preserves the guarantee.</p>

<p><strong>The writer-package edit closed a fragile cast nobody had complained about.</strong> Decision 1B brought <code class="language-plaintext highlighter-rouge">@debrief/stac-writer</code> into the migration, which initially looked like scope creep — the writer’s <code class="language-plaintext highlighter-rouge">StacItem</code> was structurally compatible with the schema’s. But the structural compatibility was the problem: the web-shell mock had a <code class="language-plaintext highlighter-rouge">JSON.parse(JSON.stringify(...))</code> projection at the writer-to-mock boundary specifically to launder between two nominally-distinct-but-equivalent shapes. Collapsing them onto one generated class let that cast go. Worth doing, even though no test was failing because of it.</p>

<h2 id="whats-next">What’s Next</h2>

<p>E11 has three more phases queued. #224 promotes the session-state cluster — the next set of cross-boundary types where the audit still flags drift. #225 takes on the loader ↔ main IPC envelopes (smaller surface, same pattern). #226 is the residual roll-up — whatever single-domain shapes the audit still flags after #224 and #225 are merged.</p>

<p>Parallel to all of that, #241 (STAC 1.1 best-practices upgrade) is in flight. The local stores currently ship 1.0 in the VS Code test data and 1.1 in the preview store; #241 normalises everything to 1.1 with <code class="language-plaintext highlighter-rouge">file:checksum</code> and the rest of the 1.1 extension set. Now that the schema is rooted, #241 lands additively against generated types instead of having to coordinate with thirteen hand-typed sites.</p>

<p>→ <a href="https://github.com/debrief/debrief-future/tree/main/specs/223-linkml-stac-catalog/spec.md">See the spec</a>
→ <a href="https://github.com/debrief/debrief-future/tree/main/specs/223-linkml-stac-catalog/evidence">View the evidence</a></p>]]></content><author><name>Ian</name></author><category term="tracer-bullet" /><category term="schemas" /><category term="stac" /><category term="linkml" /><category term="boundary-types" /><summary type="html"><![CDATA[One LinkML source, generated fan-out — the STAC catalog cluster joins the schema-first regime.]]></summary></entry></feed>