diff --git a/.gitignore b/.gitignore
index ef7d4990..b8fac6a3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -165,3 +165,5 @@ vscode/.vscode-test/
# The blog playground, built by scripts/build_playground.sh
site/docs/play/
+# The web demo beside it, made from the playground's page by vscode/scripts/build-play.mjs
+site/docs/demo/
diff --git a/docs/delivery-plan.md b/docs/delivery-plan.md
index 8847a154..077ce0f1 100644
--- a/docs/delivery-plan.md
+++ b/docs/delivery-plan.md
@@ -107,8 +107,8 @@ Phase 3 delivers the capabilities every community needs, ending in a pilot-ready
| Narrative | Word and PDF import in legacy dialects; combined Zulu time axis. Narrative entry types, which the Phase 2 .dpf import lists as not imported. Part A, done 15 Oct: narrative entries as features of their own (time, source, type, text, the track they belong to; schema 0.0.19), each added, changed or deleted as one step; REP `;NARRATIVE:` and `;NARRATIVE2:` lines read as legacy Debrief read them, a REP file's narrative also read into an open plot as one step with its source; a .dpf plot's narrative entries with their types, so no longer listed as not imported; the sample's log moved from notes to narrative entries; every source on one Zulu time axis in one order (time, source, the source's own order), held by shared vectors; read in the Timeline (decision, product owner, 15 Oct 2026), now a webview in VS Code and the playground: a quiet table that follows current time without fighting the reader, chosen entries moving current time, filter by kind, source and type, search, a form to add and change entries, the keyboard and touch, virtualised for 60,000 entries (scrolling at 60 frames a second); the entry current time is at beside its track on the map; `debrief/narrative` and the four changing methods for agents. Part B, done 15 Oct: Word (`.docx`, `.doc`) and PDF narrative in legacy Debrief's four dialects (narrative log, Rider's narrative, ASW data as a track, dated narrative), ported with their tests' cases on synthetic documents and held to legacy Debrief's own expectations on its sample documents; read into the open plot as one step with the document as its source, its dialect and the analyst's choices recorded, the analyst asked first what legacy Debrief asked (types with counts, only within the plot's period, which track an unknown platform is), or imported as a plot of its own; the same in the playground, under Pyodide; `debrief/preview_narrative` and choices on `debrief/import_narrative` for agents; and the Timeline's extras, the reader's own, in one small menu: highlighted phrases, an off switch for choosing an entry moving current time, text size, wrapping and the columns shown. Follow-ups, done 15 Oct: a line starting with a number whose time would be out of order is text of the entry before, listed in the step's record; a file read into the plot before (by its content hash, under any name), or with entries already in it, is said to be, with the choice to skip those entries or read nothing (`duplicates` for agents). | CAP-05, CAP-19, CAP-20, EXT-04, EXT-14, NFR-06 |
| Collation | Coverage view, source preference, new-data marking, merging new data, upstream-revision flags. Done 16 Oct, in one pull request (product owner, 16 Oct 2026): a source is a catalogue item, an imported file or any plot; each item's tracks and the periods each has data for (first fix to last, broken at a gap of more than ten times the median time between fixes and more than five minutes; none without a median), kept in its STAC item at each change (`debrief:coverage`, with `debrief:revision`, the plot's content hash, and `debrief:drawn_from`; schema 0.0.21), so a catalogue's coverage is read from its items alone; in VS Code's Catalogues, each item's tracks with their periods, items marked *New data* (sparkle and words, the reason as tooltip and for screen readers) or *May need review*, and their menus; a **Coverage** view below it and the playground's **Catalogue** and **Coverage** panels, the same component: each of the open plot's tracks with a bar for every source of data for its platform, the periods it supplies filled, and a list choosing the item that supplies each period two items have data for; tracks of the same name (case and spaces ignored) proposed as one platform, confirmed, refused or linked by hand, each a step; the choice of source per period one step naming the items and periods (`supplies` on the track, stitched from the chosen periods); new data only through Debrief's import, marked when a file's import came in after the plot within the folder tree it draws from, sharing a platform with it and with data within its period (never a product, an item made by hand or one made from the plot; a colleague's copy only once it adds data), taken as one step naming the item (matched tracks gain fixes where they have none, periods another item supplies kept, unmatched tracks added; the analyst's moves, shears, stretches, smoothing and dragged fixes carried to new fixes by the offsets of the fixes either side; DR course and speed changes carried, positions reckoned again; marks, frequencies and annotations kept) or set aside (**Not now**, the island); any recorded change to an item a revision, the analyst told once (VS Code's notification, the playground's note) and every plot that drew from an earlier revision flagged until **Reviewed** (the island); the Timeline lists each track's data coverage by the item supplying it (CAP-05); `debrief/coverage`, `debrief/take_new_data`, `debrief/set_source_preference`, `debrief/link_tracks`, `debrief/dismiss_new_data`, `debrief/clear_review_flag` and `debrief/review_flags` for agents. Coverage of a 50-item study of 400,000 fixes in 150 to 230 ms when the plot is opened and 35 to 45 ms after, and of fifty gappy sources of a hundred spans each in 250 to 370 ms and 15 to 30 ms (budget 500 ms and 100 ms). Revised after an expert panel's review (16 Oct): revisions per period, new data as the product owner then decided, edits carried to courses and speeds, no step for data already taken. | CAP-05, CAP-08 to CAP-12 |
| Export | Images carrying an embedded GUID. Done 16 Oct, in one pull request (decisions, product owner, 16 Oct 2026): **Export image…** in both hosts draws the map as the analyst sees it (labels where the map placed them, symbols and arrows, annotations, current time's markers, the narrative box, the north arrow and the scale bar) as PNG or SVG, as on screen, a 1600 × 900 slide or A4 landscape, in the editor's colours or light ones for print; each image carries an ID naming the plot, the exact revision exported and the export (`DBF1-…` in PNG iTXt and XMP, SVG metadata), and a small footer with its short form (`DBF-PTET-KG8R-B9EM-69`, two exports of one revision told apart), on for every export, in the corner it covers least of; each export logged in the plot's island (schema 0.0.22), never a step, or in the analyst's own export log for a read-only catalogue. **Find source of image…** reads an image (or the pictures in a Word or PowerPoint file) or a typed footer ID, finds the plot in the analyst's catalogues and shows it as it was then, rebuilt by replay, read-only, at the view and time exported, with **Go to latest**. `debrief/figure_id`, `debrief/export_image` (an agent's drawn by the open map), `debrief/find_image_source` and `debrief/open_revision` for agents. Revised the same day after an expert panel's review and the product owner's answers (below). The ID's format is held by Python and TypeScript to shared vectors. | SUB-25, EXT-05, OUT-04 |
-| Onboarding | Guided walkthroughs on the Phase 1 sample data; onboarding trial against the one-hour target. | DEP-08, CAP-25, NFR-09 |
-| Web demo | Standalone web build with Python tools under Pyodide, fixed sample data, no upload. Interactive blog examples start here. | CON-09, CON-10, DEP-06, DEP-07, DEV-06 |
+| Onboarding | Guided walkthroughs on the Phase 1 sample data; onboarding trial against the one-hour target. Done 17 Oct, with the web demo in one pull request (decisions, product owner, 17 Oct 2026): four walkthroughs (*Open and explore a serial*, *Import and collate*, *Tidy a track*, *Annotate and export a figure*), each on a fresh copy of the sample's serial 1 titled after it, never the sample (`debrief_sample.walkthrough`: *Tidy a track*'s with GPS glitches in RED ONE as a step of their own, *Import and collate*'s with BLUE ONE's navigation log to import); native in each host from one source of words (`vscode/walkthroughs/tours.json`): VS Code's Get Started page (`contributes.walkthroughs`, written from it), each step with a picture drawn from the sample in theme colours, a keyboard equivalent and a button that opens the copy or brings the command to hand, ticked by a context key set when the analyst does the step (the tool service's request succeeding, or a time, selection, Tote, Timeline entry or Coverage shown by hand), never by the button; the playground's *Walkthroughs* panel, the same steps and pictures, folding like every panel, from the keyboard, at 210 to 390 px. The trial, of what exists against a provisional 40 minutes, is written as a protocol (`docs/notes/onboarding-trial.md`) for the product owner to run, and is pending (formative, its pass rule pre-registered; REP and .dpf imported, a catalogue already added); the playground's test does its tasks as a speed run, 32 actions, failing above 36. Revised after an independent panel's review (17 Oct): each step acts and ticks only on its walkthrough's copy, starting again unticks it, and the *Walkthroughs* panel is folded outside the web demo. | DEP-08, CAP-25, NFR-09 |
+| Web demo | Standalone web build with Python tools under Pyodide, fixed sample data, files the analyst opens read in the browser only (DEP-07 as amended, 17 Oct 2026). Interactive blog examples start here. Done 17 Oct: the web demo full screen at `/demo/` on the documentation site, linked from its home page: the playground's page with every panel and the walkthroughs, opening the fixed sample serials. A file the analyst chooses opens there, read in the browser only, never sent or stored (product owner, 17 Oct 2026, amending DEP-07): tested by watching every request while a file is imported (none carries it; every request only fetches) and by browser storage, IndexedDB and the Cache API holding nothing of it, before and after the tab is closed. The blog's demos are unchanged. Revised after the panel's review (17 Oct): a Content-Security-Policy on every playground page, Pyodide from jsDelivr with no integrity check, an accepted risk; the web demo keeps nothing derived from a file, its Timeline filter included, tested across every store, a Word narrative, an image found by its file, WebSockets and the tab closing. | CON-09, CON-10, DEP-06, DEP-07, DEV-06 |
**Exit:** a pilot-ready core on Windows 11, and on Windows 10 while VS Code supports it, installable as a VSIX file (NFR-12, DEP-01, DEP-02).
@@ -186,6 +186,34 @@ The schema freeze may come earlier, even before pilots, if the model settles.
**Decisions taken during Phase 3**
+- [x] 17 Oct 2026, product owner: the onboarding trial trials what exists now (import, tidy, annotate and export a traceable figure) against a **provisional 40-minute target**; the full one-hour NFR-09 trial, storyboard included, reruns in Phase 4. A human trial cannot be run by the team building it: it is written as a protocol (`docs/notes/onboarding-trial.md`: participant profile, set-up, script, tasks, what to time, success criteria, observation sheet, how results are recorded) for the product owner to run, and an automated speed run in the playground's tests does the trial's tasks and records the actions they take. **The human trial is pending.** (17 Oct)
+- [x] 17 Oct 2026, product owner: all four walkthroughs: (a) *Open and explore a serial* (time slider, Outline, Tote, Timeline); (b) *Import and collate* (REP import, coverage, a source chosen for a period, new data taken; .dpf named as imported the same way); (c) *Tidy a track* (jump removal, smoothing, a marked and labelled fix, shear as a TMA example); (d) *Annotate and export a figure* (an annotation drawn, labels at intervals, a traceable image exported, its source found). Each works on a fresh copy of serial 1, titled after it; the sample is never changed. (17 Oct)
+- [x] 17 Oct 2026, product owner: walkthroughs are native in each host. In VS Code, the Walkthroughs API (`contributes.walkthroughs`, the Get Started page), each step ticked by `completionEvents` on a context key the extension sets when the analyst actually does the step, each with a picture drawn from the sample (SVG, theme colours) and a button that opens the right copy of the sample; in the playground, a matching step-by-step panel, its step text from the same source (`vscode/walkthroughs/tours.json`) so the two cannot drift, its steps ticked by real actions, from the keyboard, folding like other panels, working at 210, 280 and 390 px in a wide font. (17 Oct)
+- [x] 17 Oct 2026, product owner: the web demo is a standalone full-screen page at `/demo/` on the documentation site with every panel and the walkthroughs and fixed sample serials, linked from the home page; the blog's embedded demos keep working. First decided with file upload removed, then, the same day, superseded: (17 Oct)
+- [x] 17 Oct 2026, product owner, superseding the "no upload" wording of DEP-07: the web demo **allows a file the analyst chooses** to be opened, read only in the browser (under Pyodide), never sent over the network and never stored: nothing of it persists after the tab closes (no IndexedDB, local storage or Cache API storage of it). No warning banner. The fixed sample serials stay the default content. DEP-07 now reads: "The public instance shall ship with fixed sample data. It may open files the analyst chooses, read only in the browser, never sent or stored." Tested: every request is watched while a file of the reader's own is imported, and none carries it (in its address or body) or sends anything at all; storage holds none of it, before or after the tab is closed. (17 Oct)
+- [x] 17 Oct 2026, product owner, answering the onboarding pull request's questions: the trial imports **both** a REP file (serial 2's `red-one.rep`) **and** a legacy .dpf plot, and the speed run does too. It starts with a catalogue already added, since otherwise task 1 starts by asking where plots go. A walkthrough's ticks are not remembered across a reload. In VS Code, **Start again** deletes the old copy. The file picker in the web demo has no label and no banner of its own, as before. (17 Oct)
+- [x] 17 Oct 2026, product owner: the *Walkthroughs* panel is at the top of the side column everywhere: open in the web demo, folded in the playground and the blog's demos, whose readers came for something else. (17 Oct; on screens 1200 px wide or more, a column on the right instead, 22 Oct below)
+- [x] 17 Oct 2026, product owner: Pyodide stays on jsDelivr, and the web demo and the playground's pages (the page, each frame, the geodesic demo) carry a strict Content-Security-Policy in a meta element: `default-src 'self'; connect-src 'self' https://cdn.jsdelivr.net; worker-src 'self' blob:; script-src 'self' 'wasm-unsafe-eval' https://cdn.jsdelivr.net; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; object-src 'none'; form-action 'none'; base-uri 'self'`. It is `base-uri 'self'`, not `'none'`, because the web demo's `` points at the playground's files. A meta element's policy does not cover the Python worker, and GitHub Pages cannot send a policy as a header. **jsDelivr's files are loaded with no integrity check: the product owner accepts this risk.** Tested: the policy is in the page and every frame, and the whole playground suite runs with nothing refused by it. (17 Oct)
+- [x] 17 Oct 2026, after an independent panel's review of the onboarding pull request:
+ - **The web demo keeps nothing that came from a file.** In the web demo, the Timeline's remembered filter keeps only the kinds of entry shown and the view's own settings (text size, wrapping, columns, choosing moving time). It never keeps sources, types or highlighted phrases, which are words from the plot (`contentFree`). Every other value the demo keeps in the browser is a panel id, a walkthrough id or an export's size and colours. Tested after a REP file, a Word narrative, an exported image found again by its file, and the Timeline filtered and highlighted: local storage, session storage after the tab closes, IndexedDB, the Cache API and the origin's private file system hold nothing of them. No request carries them, nothing is sent at all (also as the tab closes), and no WebSocket opens.
+ - **A walkthrough acts and ticks only on its own copy.** Every step after the first opens the walkthrough's copy before it acts if another plot is open (VS Code: `debrief.walkthrough.do`; the playground: the panel), and refuses, saying why, before there is a copy. A step ticks only when what was done was done to that copy, in the walkthrough shown:
+ - not the same change to another plot;
+ - not the analyst's own file imported (only the walkthrough's log);
+ - not Coverage before the log is imported;
+ - not a stretch for the shear;
+ - not one track selected, nor a selection Debrief makes by command (VS Code's `BY_HAND` no longer includes `command`).
+ Serial 1 itself gains no step: tested from its history.
+ - **Starting a walkthrough again** unticks its steps (on VS Code's Get Started page too, `welcome.markStepIncomplete`) and replaces its copy, so ticks are per copy. A second press while a copy is being made makes none.
+ - **The context key stays the documented way a step ticks.** `welcome.markStepComplete` and `welcome.markStepIncomplete` are VS Code's own commands, not its API; they are used as well, because the Get Started page ignores a context key already set when its service starts. Their failures are logged, and the integration test checks that VS Code still has them.
+ - **The trial protocol is formative**, with a pass rule to pre-register: at least 80% of participants complete the tasks without help above the first rung of a fixed help ladder, the median *silent* run takes 40 minutes or less, and no one takes over 60. It also adds a neutral observer, tasks that do not mirror the walkthroughs, a stated tolerance for the area (within 2 nmi of RED ONE at first contact), a pilot participant, fixed hardware and VS Code version, and a reason for every failure.
+ - **The speed run** is labelled playground-only, imports the .dpf, smooths over 5 fixes, and fails above 36 actions.
+ - **Smaller fixes:**
+ - VS Code's Outline adds to a selection with `Ctrl+Shift+Enter`, not `Ctrl+Space`, in the walkthrough and the keyboard guide.
+ - The time controller's window wraps in a 210 px column, and Import's file boxes stay inside it.
+ - `package.json` no longer lists the walkthrough commands inside its menus. A unit test now holds every menu entry to a contributed command, by id and condition.
+ (17 Oct)
+- [x] 22 Oct 2026, product owner, amending the 17 Oct placement of the *Walkthroughs* panel: in the browser (playground and web demo), on a screen **1200 px wide or more**, the walkthroughs are a **column of their own on the right of the map**, the side panels staying on the left, so a step's words stay in sight while the analyst does it in the side panels and on the map; at the top of the side column, a step meant scrolling between its words and the panel used. The column scrolls on its own; it folds as before (folded, a narrow tab with its heading on its side, about 25 px, and the map widens), by the pointer or the keyboard, and its status line still speaks to screen readers. Open in the web demo, folded in the playground and the blog's demos, as before. Below 1200 px (phones, the blog's embedded demos, the 601 to 900 px layouts) it stays the side column's first panel, unchanged. 1200 px because from 901 px a tool's form opens beside the map: at 1200 px the map keeps about 670 px beside both columns and about 290 px beside the form too, while at 1024 px it would keep about 150 px with the form open. The column is 20% of the window, 240 to 300 px (256, 273 and 288 px at 1280, 1366 and 1440). With the next step's button always in sight, it can be pressed while the step before is still being saved: removing jumps and applying smoothing now close only their own panel beside the map, never the fix list the next step opened. (22 Oct)
+
- [x] 16 Oct 2026, product owner: collation (CAP-08 to CAP-12) in one pull request, with data coverage in the Timeline (CAP-05). (16 Oct)
- [x] 16 Oct 2026, product owner: a source is a catalogue item: an imported file, which Debrief keeps as an item of its own, or any other plot in a catalogue. Coverage shows, for each item and each of its tracks, the periods it has data for: in the catalogue browser (VS Code's Catalogues tree, a Coverage view below it, and the playground's Catalogue and Coverage panels) and in the Timeline (CAP-05), where each track's data coverage, by the item supplying each period, joins the narrative, notes, periods and fix comments. (16 Oct)
- [x] 16 Oct 2026, product owner: source preference is per period. Where two items have data for one platform over overlapping periods, the analyst chooses which item supplies each overlapping period, with coverage visible (CAP-09); the plotted track is stitched from the chosen periods. Each choice is one undoable step in the plot's history, its provenance naming the items, the periods and the revisions taken (`TrackProperties.supplies`, `ProvRecord.drawn_from`; schema 0.0.21). (16 Oct)
diff --git a/docs/notes/onboarding-trial.md b/docs/notes/onboarding-trial.md
new file mode 100644
index 00000000..249155f0
--- /dev/null
+++ b/docs/notes/onboarding-trial.md
@@ -0,0 +1,121 @@
+# Onboarding trial: protocol
+
+Oct 17, 2026 · for the product owner to run · **formative** · status: **pending** (no participant has taken it yet)
+
+## Why, and what kind of trial
+
+NFR-09 asks that an analyst with domain knowledge but no Debrief training import a sample serial, compose a storyboard and export it within an hour (OUT-02, OUT-05). The storyboard is Phase 4, so the product owner decided on 17 Oct 2026 to trial now what exists: **import, tidy, annotate and export a traceable figure**, against a **provisional target of 40 minutes**. The full one-hour trial, storyboard included, runs again in Phase 4 with this protocol extended.
+
+This trial is **formative**. With five to eight participants, it can find the problems newcomers meet and give a rough idea of the time the work takes. It cannot show statistically that a population of analysts meets the target. Its results guide what is fixed next. NFR-09 itself is confirmed at pilot (SRD section 8).
+
+## Pass rule (pre-registered)
+
+Write this down, dated, before the first participant (pilot excepted) takes the trial, and do not change it afterwards:
+
+- **Completion.** At least 80% of participants complete all four tasks unaided, that is, without any help above rung 1 of the help ladder (below). With 5 participants that is at least 4; with 6, at least 5; with 7, at least 6; with 8, at least 7.
+- **Time.** The **median** time of the *silent* runs (below) is at most **40 minutes**, and **no participant** takes over **60 minutes** for the four tasks.
+- **Recurring problems.** Any stuck point seen in two or more participants is a finding to fix, whatever the times.
+
+The trial passes when the completion and time rules both hold. Otherwise it fails, and the findings say why.
+
+## Participants
+
+- **Who:** analysts with domain knowledge of the community's work (O, N or D) who have **never used Debrief v4** and have had no training on it. Some may have used legacy Debrief; record which.
+- **How many:** one **pilot** participant first, then five to eight, one at a time. The pilot's results are not counted. It checks the set-up, the task card and the timings, and anything it shows wrong is fixed before the next participant.
+- **Not:** anyone on the development team, or anyone who has read the blog posts or guides closely.
+- **Consent:** participants agree to be observed and timed, and to their words being noted without their name. Results are reported by participant number only.
+
+## Set-up (fixed for every participant)
+
+- **Hardware:** one laptop for every session, of the kind analysts use. Record its make, model, memory and screen size here before the pilot: ______. Use a mouse; a touchpad also works, but use the same for all participants.
+- **Software:** Windows 11. VS Code at version ______, recorded before the pilot and unchanged throughout. The Debrief extension from one VSIX, its version ______ recorded. Python with Debrief's packages installed, **including `debrief-sample` and its `pyarrow`**, which the walkthroughs use to make their copies. The tool service running.
+- **A catalogue already added** (product owner, 17 Oct 2026): an empty catalogue named *Trial*, added with **Add catalogue folder…** before the participant arrives, so the first task does not begin by asking where plots go. No other catalogue.
+- **On the desktop, two files only:**
+ - `red-one.rep`: serial 2's RED ONE from the sample exercise (`python -m debrief_sample build`, under `serial-2/sources/`);
+ - a legacy Debrief plot, `legacy.dpf`: an unclassified .dpf plot the product owner provides. The playground's speed run uses `shapes.dpf` from debrief-io's tests in its place.
+- The Debrief sidebar open, VS Code's Get Started page closed, notifications cleared. Screen recording on, if the participant agrees.
+- A clock the observer can read; the observation sheet below, printed or open on another machine.
+- Reset between participants: remove the *Trial* catalogue's contents, the walkthroughs' copies (**Start again** in each, or VS Code's extension storage), and VS Code's walkthrough progress; then check the set-up again.
+
+## The observer
+
+- **Neutral:** someone who did not build the part being tested, who reads the script as written and does not react to what the participant does. They do not demonstrate, hint, nod or say whether a step is right.
+- **The help ladder.** When the participant asks for help, or makes no progress for 3 minutes, the observer climbs one rung at a time, and notes the rung and the time:
+ 1. "What are you trying to do?" (no help with Debrief)
+ 2. "Where have you looked? Is there anywhere else in Debrief that might help?"
+ 3. Points to where to look: "Try the walkthroughs" or "Look in the Outline's menu".
+ 4. Shows the step. **The task then fails**, and the participant carries on with the next task.
+
+## Two runs: silent, then think-aloud
+
+Thinking aloud slows people down. So each participant does the tasks **silently, timed**, and then, in a short second pass, **thinks aloud** about where they hesitated. Only silent runs are judged against the 40 minutes. The think-aloud pass is never timed against the target; it is for findings.
+
+## Script
+
+Read this aloud, as written:
+
+> "Thank you for helping. We are testing Debrief, not you: if something is hard, that is what we need to know. There are four tasks on this card. Please work quietly; I will time you. I can't help you with Debrief, but Debrief has walkthroughs for newcomers. Use them as you wish. Tell me when you think each task is done. You can stop at any time. Afterwards I'll ask you to go back over the hard parts and tell me what you were thinking."
+
+Then hand over the task card. Start the clock when the participant first touches the mouse or keyboard.
+
+## Tasks (the task card)
+
+The card says what to achieve, not how, and does not follow the walkthroughs step for step.
+
+1. **Import.** Two files on the desktop came from an exercise: `red-one.rep` and `legacy.dpf`. Bring both into the *Trial* catalogue in Debrief. Plot the REP file's track as logged.
+2. **Tidy.** Debrief's walkthroughs include a copy of serial 1 for practising tidying a track. Open that copy. Its RED ONE looks wrong in places: make it trustworthy, and then make it less noisy.
+3. **Annotate.** On that copy, mark the area where BLUE ONE first holds contact on RED ONE, and give it a name. Make the times of BLUE ONE and RED ONE readable along their tracks, at intervals.
+4. **Export a traceable figure.** Produce an image of the map for a slide that someone could later trace back to the plot. Then use the image to find the plot again in Debrief.
+
+## What to time and note
+
+- **Time to each task's end**, from the clock's start: when the participant says it is done, and whether it is done (see the success criteria).
+- **Total time** to the end of task 4 (silent run).
+- **Each stuck point** (no progress for 60 seconds, or a request for help): what they were trying to do, where they looked, what got them going again (a walkthrough, a tooltip, chance), and the help-ladder rung if any.
+- **Walkthroughs used:** which, which steps, and whether a step ticked itself off when they did it.
+- **Errors:** anything done wrongly and undone, or not noticed.
+- **Words:** any term the participant did not understand or used differently (for the glossary and the language guide).
+- **For every task that fails, the reason**, in one line: help at rung 4, time out (the 60-minute limit reached), wrong result, gave up, or a fault in Debrief (note what it was).
+
+## Success criteria
+
+| Task | Done when |
+| --- | --- |
+| Import | Both files are plots in *Trial*: RED ONE from `red-one.rep`, plotted over the ground (its import note says so), and the legacy plot from `legacy.dpf` (its import listing what was not imported, if anything) |
+| Tidy | In *Serial 1: Tidy a track*, History shows *RED ONE: 3 jumps removed* and a smoothing of RED ONE (any number of fixes); no fix of RED ONE is more than 1 nmi from its neighbours' line |
+| Annotate | An area (or circle or ellipse) named by the participant whose edge is within **2 nmi** of RED ONE's position at BLUE ONE's first sonar contact (the Timeline's first *Sonar* entry, 08:29Z), or contains that position; and BLUE ONE and RED ONE carry time labels at one interval between 5 and 30 minutes, set as one step or two |
+| Export | A PNG of 1600 × 900 with a footer ID exists, and **Find source of image…** has opened the plot read-only from that image or its ID |
+
+## Observation sheet
+
+Copy one per participant.
+
+| Participant | P__ (pilot: P0) | Community (O, N, D) | | Used legacy Debrief? | yes / no |
+| --- | --- | --- | --- | --- | --- |
+
+| Task | Start (mm:ss) | Said done (mm:ss) | Done? (Y/N) | If not, why | Stuck points (time: what, where, rung, how resolved) | Walkthrough steps used |
+| --- | --- | --- | --- | --- | --- | --- |
+| 1 Import | | | | | | |
+| 2 Tidy | | | | | | |
+| 3 Annotate | | | | | | |
+| 4 Export and trace | | | | | | |
+
+Silent-run total time: ______ Highest help rung reached: ______
+
+Words not understood: ______
+
+From the think-aloud pass ("What was hardest? What would have helped?"): ______
+
+## Recording results
+
+- Keep each sheet, and any recording, where the product owner keeps pilot records; not in this repository.
+- Summarise in a new dated section of this note, **Results**: the pre-registered rule as written; then, by participant number, each task's time, success and reason for any failure, the silent total and the highest help rung; then the stuck points that recurred, and the words. State whether the pass rule held. Record the outcome as a dated decision in `docs/delivery-plan.md` (pass, or the findings to fix) and update NFR-09's note in the SRD.
+- Findings become issues against the walkthroughs (`vscode/walkthroughs/tours.json`), the language guide or the feature concerned.
+
+## The automated speed run (playground only)
+
+`vscode/test/play/onboarding.mjs` (`speedRun`) does the four tasks in the playground, not in VS Code. It imports serial 2's `red-one.rep` and debrief-io's `shapes.dpf`, and smooths over 5 fixes. It uses the walkthroughs' own buttons where a step has one, so it is quicker than a newcomer could be. It counts each click, key press and field filled in, prints the count and the time, and **fails if the tasks come to take more than 36 actions**, so a change that makes them ask more is caught. It runs with the playground's tests in CI. On 17 Oct 2026: **32 actions in about 12 seconds** (import 5, tidy 10, annotate 12, export and trace 5). It proves the tasks are achievable and shows how much they ask; it says nothing about how long a person new to Debrief takes, which is what this trial is for.
+
+## Results
+
+*None yet: the trial is pending (17 Oct 2026). Pre-register the pass rule above before the first counted participant.*
diff --git a/docs/srd.md b/docs/srd.md
index 25a5d6e6..ac8a7ee6 100644
--- a/docs/srd.md
+++ b/docs/srd.md
@@ -289,7 +289,7 @@ Capabilities are replaceable and are where feature requests land. Each shall be
- **CAP-23** Analysts should be able to ask for an operation in plain English. Debrief shall show the proposed tool and its parameters, and require confirmation before applying them. The language model shall run locally, so the feature works offline (PRN-06). Until a local model is available, OUT-05 shall be met by CAP-24 and CAP-25 alone. Analyst machines are laptops without GPUs, so a local model is unlikely soon (proxy; section 12.3).
- **CAP-24** Each tool's declaration (TL-06) shall carry a short user-facing explanation of what it does and any judgement it involves, shown on its parameter form. This applies to core tools and domain packs; scientist libraries should follow it.
-- **CAP-25** Debrief shall provide guided first-use walkthroughs for common tasks, using the demonstration sample data. They depend on convincing sample data replacing the 1995 serial (DEP-07).
+- **CAP-25** Debrief shall provide guided first-use walkthroughs for common tasks, using the demonstration sample data. They depend on convincing sample data replacing the 1995 serial (DEP-07). *Note (product owner, 17 Oct 2026):* four walkthroughs (open and explore a serial; import and collate; tidy a track; annotate and export a figure), each on a fresh copy of the sample, never the sample itself; native in each host (VS Code's walkthroughs on its Get Started page, a matching panel in the web build) from one source of words, each step ticked when the analyst does it.
### 7.10 Cross-plot analysis
@@ -370,7 +370,7 @@ Analysts accept a visible lag on busy plots, but not a stall. Requirements are s
### 10.5 Onboarding
-- **NFR-09** An analyst with domain knowledge but no Debrief training should import a sample serial, compose a storyboard and export it within an hour (OUT-02, OUT-05).
+- **NFR-09** An analyst with domain knowledge but no Debrief training should import a sample serial, compose a storyboard and export it within an hour (OUT-02, OUT-05). *Note (product owner, 17 Oct 2026):* until the storyboard exists (Phase 4), a trial of what exists (import, tidy, annotate and export a traceable figure) against a provisional 40 minutes; the full one-hour trial reruns in Phase 4. The trial is formative, its pass rule pre-registered (at least 80% complete unaided, a median silent run within 40 minutes, none over 60); it imports a REP file and a legacy .dpf plot; it is pending (docs/notes/onboarding-trial.md).
### 10.6 Data integrity and recovery
@@ -412,8 +412,8 @@ Legacy Debrief is already accredited on the target network, so the path is a kno
### 11.4 Public demonstration instance
-- **DEP-06** An unclassified web-hosted instance shall be available to anyone during development, for review and demonstration.
-- **DEP-07** The public instance shall never hold real data. It shall ship with fixed sample data and have no upload capability.
+- **DEP-06** An unclassified web-hosted instance shall be available to anyone during development, for review and demonstration. *Note (17 Oct 2026):* the web demo, full screen at /demo/ on the documentation site, with every panel and the walkthroughs.
+- **DEP-07** The public instance shall ship with fixed sample data. It may open files the analyst chooses, read only in the browser, never sent or stored. *(Amended by the product owner, 17 Oct 2026; it said the instance shall never hold real data and have no upload capability. Nothing the analyst opens leaves their browser or outlives the tab, which is tested. Its pages carry a strict Content-Security-Policy; Pyodide is loaded from jsDelivr with no integrity check, a risk the product owner accepts, 17 Oct 2026.)*
- **DEP-08** Sample data shall be a hybrid: open AIS data for the background picture, with a synthesised blue, red and sensor layer and a scripted narrative on top, covering several serials. It replaces the 1995 single-serial sample. It shall be built before the first graphical milestone, since the demonstration instance (DEP-06), the walkthroughs (CAP-25) and blog examples (DEV-06) depend on it, and shall carry its own provenance as a worked example of SUB-18.
### 11.5 Development practice
diff --git a/python/debrief-sample/src/debrief_sample/__init__.py b/python/debrief-sample/src/debrief_sample/__init__.py
index 87dcde58..2c5074a7 100644
--- a/python/debrief-sample/src/debrief_sample/__init__.py
+++ b/python/debrief-sample/src/debrief_sample/__init__.py
@@ -1,5 +1,22 @@
-"""Debrief sample data: open AIS with a synthesised exercise on top (DEP-08)."""
+"""Debrief sample data: open AIS with a synthesised exercise on top (DEP-08).
-from debrief_sample.builder import DEFAULT_SEED, RECORDED_AT, BuildReport, build
+The builder is imported only when asked for, so debrief_sample.walkthrough runs
+in the browser (CON-09), where the builder's pyarrow is not installed.
+"""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Any
+
+if TYPE_CHECKING:
+ from debrief_sample.builder import DEFAULT_SEED, RECORDED_AT, BuildReport, build
__all__ = ["DEFAULT_SEED", "RECORDED_AT", "BuildReport", "build"]
+
+
+def __getattr__(name: str) -> Any:
+ if name in __all__:
+ from debrief_sample import builder
+
+ return getattr(builder, name)
+ raise AttributeError(f"module 'debrief_sample' has no attribute {name!r}")
diff --git a/python/debrief-sample/src/debrief_sample/__main__.py b/python/debrief-sample/src/debrief_sample/__main__.py
index 7d1df24e..74e1915e 100644
--- a/python/debrief-sample/src/debrief_sample/__main__.py
+++ b/python/debrief-sample/src/debrief_sample/__main__.py
@@ -2,16 +2,21 @@
python -m debrief_sample build OUT_DIR [--seed N] [--recorded-at TIME]
python -m debrief_sample reduce-ais AIS_2023_06_15.zip OUT.csv
+python -m debrief_sample walkthrough OUT_DIR TOUR
+python -m debrief_sample media OUT_DIR [--check]
"""
from __future__ import annotations
import argparse
+import json
import sys
from pathlib import Path
from debrief_sample.ais import reduce_marinecadastre
from debrief_sample.builder import DEFAULT_SEED, RECORDED_AT, build
+from debrief_sample.media import write_media
+from debrief_sample.walkthrough import TOURS, prepare
def main(argv: list[str] | None = None) -> int:
@@ -30,8 +35,28 @@ def main(argv: list[str] | None = None) -> int:
)
reduce_cmd.add_argument("archive", type=Path)
reduce_cmd.add_argument("output", type=Path)
+ tour_cmd = commands.add_parser(
+ "walkthrough", help="a fresh copy of the sample for a walkthrough, in a folder of its own"
+ )
+ tour_cmd.add_argument("out_dir", type=Path, help="an empty folder for the walkthrough")
+ tour_cmd.add_argument("tour", choices=list(TOURS), help="which walkthrough")
+ media_cmd = commands.add_parser(
+ "media", help="the walkthroughs' pictures, drawn from the sample"
+ )
+ media_cmd.add_argument("out_dir", type=Path, help="folder for the SVG files")
+ media_cmd.add_argument("--check", action="store_true", help="fail if the files differ")
args = parser.parse_args(argv)
+ if args.command == "walkthrough":
+ print(json.dumps(prepare(args.out_dir, args.tour)))
+ return 0
+ if args.command == "media":
+ stale = write_media(args.out_dir, check=args.check)
+ if stale:
+ print("Out of date: " + ", ".join(stale) + ". Run python -m debrief_sample media.")
+ return 1
+ return 0
+
if args.command == "reduce-ais":
count = reduce_marinecadastre(args.archive, args.output)
print(f"Wrote {args.output}: {count} vessels.")
diff --git a/python/debrief-sample/src/debrief_sample/builder.py b/python/debrief-sample/src/debrief_sample/builder.py
index 56dd3587..31b16d61 100644
--- a/python/debrief-sample/src/debrief_sample/builder.py
+++ b/python/debrief-sample/src/debrief_sample/builder.py
@@ -457,10 +457,18 @@ def _build_serial(
}
-def build(root: Path, *, seed: int = DEFAULT_SEED, recorded_at: str = RECORDED_AT) -> BuildReport:
- """Build the sample catalogue at root, which must not already hold one."""
+def build(
+ root: Path,
+ *,
+ seed: int = DEFAULT_SEED,
+ recorded_at: str = RECORDED_AT,
+ catalogue_id: str = CATALOGUE_ID,
+ title: str = CATALOGUE_TITLE,
+) -> BuildReport:
+ """Build the sample catalogue at root, which must not already hold one. A walkthrough's
+ copy has an id and title of its own, so it is never taken for the sample."""
root = Path(root)
- catalogue = Catalogue.create(root, CATALOGUE_ID, CATALOGUE_TITLE, CATALOGUE_DESCRIPTION)
+ catalogue = Catalogue.create(root, catalogue_id, title, CATALOGUE_DESCRIPTION)
clock = FixedClock(recorded_at)
vessels = ais.read_extract(extract_path())
report = BuildReport(root, seed)
diff --git a/python/debrief-sample/src/debrief_sample/media.py b/python/debrief-sample/src/debrief_sample/media.py
new file mode 100644
index 00000000..ec726b94
--- /dev/null
+++ b/python/debrief-sample/src/debrief_sample/media.py
@@ -0,0 +1,519 @@
+"""The walkthroughs' pictures (CAP-25), drawn from the sample's serial 1.
+
+One SVG a step, shown beside it on VS Code's Get Started page and in the playground's
+Walkthroughs panel. Each is the serial's tracks, where the sample has them, with what the
+step is about drawn over them. Interface colours are VS Code theme variables, so a picture
+follows the editor's theme (light, dark or high contrast); tracks keep their data colours
+(DEV-07). The pictures are schematic: they show what to look for, not the screen exactly.
+
+python -m debrief_sample media vscode/media/walkthroughs [--check]
+"""
+
+from __future__ import annotations
+
+import math
+import tempfile
+from collections.abc import Callable
+from html import escape
+from pathlib import Path
+
+from debrief_kernel.formatting import format_bearing, format_range
+from debrief_kernel.geodesy import Position, TrackPath, range_bearing
+from debrief_kernel.time import format_utc, parse_utc
+from debrief_sample.walkthrough import (
+ LOG_TAIL_MS,
+ PLOT,
+ glitch_indices,
+ glitched_positions,
+ log_period,
+)
+from debrief_stac import Catalogue
+
+W, H = 480, 270
+FG = "var(--vscode-foreground)"
+MUTED = "var(--vscode-descriptionForeground)"
+BG = "var(--vscode-editor-background)"
+PANEL = "var(--vscode-editorWidget-background)"
+BORDER = "var(--vscode-panel-border)"
+FOCUS = "var(--vscode-focusBorder)"
+WARN = "var(--vscode-editorWarning-foreground)"
+FONT = "var(--vscode-font-family)"
+MONO = "var(--vscode-editor-font-family)"
+ANNOTATION = "#c7820a" # the annotation amber: a data colour (theme guide)
+MARK = "#00800b" # a fix's own colour: legacy green, a data colour
+
+
+class Map:
+ """Serial 1 projected onto the picture: the exercise (BLUE ONE and RED ONE) fills it."""
+
+ def __init__(self, tracks: dict[str, dict]):
+ self.tracks = tracks
+ focus = [p for n in ("BLUE ONE", "RED ONE") for p in tracks[n]["coordinates"]]
+ lons, lats = [p[0] for p in focus], [p[1] for p in focus]
+ self.lat0 = math.radians((min(lats) + max(lats)) / 2)
+ x0, x1 = min(lons), max(lons)
+ y0, y1 = min(lats), max(lats)
+ span_x, span_y = (x1 - x0) * math.cos(self.lat0), y1 - y0
+ self.scale = min((W - 80) / span_x, (H - 70) / span_y)
+ self.cx, self.cy = (x0 + x1) / 2, (y0 + y1) / 2
+
+ def xy(self, lon: float, lat: float) -> tuple[float, float]:
+ x = W / 2 + (lon - self.cx) * math.cos(self.lat0) * self.scale
+ y = H / 2 - (lat - self.cy) * self.scale
+ return round(x, 1), round(y, 1)
+
+ def path(self, coordinates: list, **attributes: str) -> str:
+ points = " ".join(f"{x},{y}" for x, y in (self.xy(p[0], p[1]) for p in coordinates))
+ return _el("polyline", points=points, fill="none", **attributes)
+
+
+def _el(name: str, text: str | None = None, **attributes: object) -> str:
+ attrs = " ".join(
+ f'{k.rstrip("_").replace("_", "-")}="{escape(str(v))}"' for k, v in attributes.items()
+ )
+ return f"<{name} {attrs}/>" if text is None else f"<{name} {attrs}>{escape(text)}{name}>"
+
+
+def _text(x: float, y: float, words: str, size: int = 11, fill: str = FG, **more: object) -> str:
+ return _el(
+ "text",
+ words,
+ **{"x": x, "y": y, "font_size": size, "fill": fill, "font_family": FONT, **more},
+ )
+
+
+def _svg(parts: list[str]) -> str:
+ body = "\n".join(parts)
+ return (
+ f'\n'
+ )
+
+
+def _background(m: Map, *, dim: bool = False, skip: tuple[str, ...] = ()) -> list[str]:
+ parts = []
+ for name, t in m.tracks.items():
+ if name in skip:
+ continue
+ exercise = name in ("BLUE ONE", "RED ONE")
+ opacity = 0.25 if dim and exercise is False else (0.35 if dim else (1 if exercise else 0.6))
+ parts.append(
+ m.path(
+ t["coordinates"],
+ stroke=t["colour"],
+ stroke_width=2 if exercise else 1,
+ opacity=opacity,
+ )
+ )
+ if exercise and not dim:
+ x, y = m.xy(*t["coordinates"][-1][:2])
+ parts.append(_text(x + 4, y + 4, name, 10, t["colour"], font_weight="bold"))
+ return parts
+
+
+def _at(m: Map, name: str, ms: int) -> tuple[float, float]:
+ p = m.tracks[name]["path"].position_at(ms)
+ return m.xy(p.longitude, p.latitude)
+
+
+def _square(x: float, y: float, colour: str) -> str:
+ return _el(
+ "rect", x=x - 4, y=y - 4, width=8, height=8, fill=colour, stroke=FG, stroke_width=0.5
+ )
+
+
+def _panel(x: float, y: float, w: float, h: float) -> str:
+ return _el("rect", x=x, y=y, width=w, height=h, rx=3, fill=PANEL, stroke=BORDER)
+
+
+def draw(tracks: dict[str, dict], narrative: list[dict]) -> dict[str, str]:
+ """Every walkthrough picture, by file name."""
+ m = Map(tracks)
+ blue, red = tracks["BLUE ONE"], tracks["RED ONE"]
+ start, end = blue["path"].start_ms, blue["path"].end_ms
+ now = start + (end - start) * 5 // 8 // 60_000 * 60_000
+ pictures: dict[str, Callable[[], list[str]]] = {}
+
+ def at_now() -> list[str]:
+ return [_square(*_at(m, n, now), m.tracks[n]["colour"]) for n in ("BLUE ONE", "RED ONE")]
+
+ pictures["explore-open.svg"] = lambda: _background(m)
+
+ def time() -> list[str]:
+ y = H - 22
+ x = 20 + (W - 40) * (now - start) / (end - start)
+ return [
+ *_background(m),
+ *at_now(),
+ _panel(10, y - 14, W - 20, 30),
+ _el("line", x1=20, y1=y + 4, x2=W - 20, y2=y + 4, stroke=MUTED, stroke_width=2),
+ _el("circle", cx=x, cy=y + 4, r=5, fill=FOCUS),
+ _text(20, y - 2, "Current time", 10, MUTED),
+ _text(W - 20, y - 2, format_utc(now)[11:19] + "Z", 10, FG, text_anchor="end"),
+ ]
+
+ pictures["explore-time.svg"] = time
+
+ def select() -> list[str]:
+ parts = _background(m, dim=True)
+ for t in (blue, red):
+ parts.append(m.path(t["coordinates"], stroke=FOCUS, stroke_width=6, opacity=0.5))
+ parts.append(m.path(t["coordinates"], stroke=t["colour"], stroke_width=2))
+ return parts
+
+ pictures["explore-select.svg"] = select
+
+ def tote() -> list[str]:
+ (bx, by), (rx, ry) = _at(m, "BLUE ONE", now), _at(m, "RED ONE", now)
+ a, b = blue["path"].position_at(now), red["path"].position_at(now)
+ distance, bearing = range_bearing(a, b)
+ rows = [("Range nmi", format_range(distance)), ("Brg °T", format_bearing(bearing))]
+ parts = [
+ *_background(m),
+ *at_now(),
+ _el("line", x1=bx, y1=by, x2=rx, y2=ry, stroke=MUTED, stroke_dasharray="4 3"),
+ ]
+ parts += [
+ _panel(W - 170, 10, 160, 66),
+ _text(W - 162, 26, "Tote", 11, FG, font_weight="bold"),
+ ]
+ parts.append(_text(W - 70, 26, "RED ONE", 10, red["colour"], text_anchor="end"))
+ for i, (label, value) in enumerate(rows):
+ parts.append(_text(W - 162, 44 + i * 16, label, 10, MUTED))
+ parts.append(
+ _text(W - 18, 44 + i * 16, value, 10, FG, text_anchor="end", font_family=MONO)
+ )
+ return parts
+
+ pictures["explore-tote.svg"] = tote
+
+ def timeline() -> list[str]:
+ shown = [e for e in narrative if parse_utc(e["time"]) <= now][-3:]
+ parts = [*_background(m), *at_now(), _panel(W - 210, 10, 200, 70)]
+ for i, e in enumerate(shown):
+ current = i == len(shown) - 1
+ y = 28 + i * 20
+ if current:
+ parts.append(
+ _el("rect", x=W - 206, y=y - 12, width=192, height=17, fill=FOCUS, opacity=0.25)
+ )
+ parts.append(_text(W - 202, y, e["time"][11:16] + "Z", 9, MUTED, font_family=MONO))
+ parts.append(_text(W - 168, y, (e["source"] + ": " + e["text"])[:30], 9, FG))
+ return parts
+
+ pictures["explore-timeline.svg"] = timeline
+
+ first, last = log_period(start, end)
+
+ def emphasise_blue() -> list[str]:
+ return [
+ *_background(m, dim=True),
+ m.path(blue["coordinates"], stroke=blue["colour"], stroke_width=3),
+ ]
+
+ pictures["collate-open.svg"] = emphasise_blue
+
+ def imported() -> list[str]:
+ lines = [
+ ";; A navigation log",
+ '230615 104000.000 "Blue One" @A …',
+ '230615 104010.000 "Blue One" @A …',
+ '230615 104020.000 "Blue One" @A …',
+ ]
+ parts = [*_background(m, dim=True), _panel(20, 20, 250, 82)]
+ for i, line in enumerate(lines):
+ parts.append(_text(30, 40 + i * 16, line, 9, MUTED if i == 0 else FG, font_family=MONO))
+ parts += [
+ _el("line", x1=270, y1=60, x2=320, y2=60, stroke=FOCUS, stroke_width=2),
+ _panel(320, 44, 140, 34),
+ ]
+ parts.append(_text(330, 65, "✦ New data", 11, WARN, font_weight="bold"))
+ return parts
+
+ pictures["collate-import.svg"] = imported
+
+ def bars(chosen: bool) -> list[str]:
+ x0, x1 = 110, W - 20
+ tail = end + LOG_TAIL_MS
+
+ def x(ms: int) -> float:
+ return round(x0 + (x1 - x0) * (ms - start) / (tail - start), 1)
+
+ parts = [
+ _panel(10, 10, W - 20, H - 20),
+ _text(20, 34, "Coverage", 12, FG, font_weight="bold"),
+ ]
+ rows = [
+ ("BLUE ONE", "Serial 1", start, end, blue["colour"]),
+ ("", "Navigation log", first, tail, blue["colour"]),
+ ("RED ONE", "Serial 1", start, end, red["colour"]),
+ ]
+ for i, (track, source, a, b, colour) in enumerate(rows):
+ y = 70 + i * 44
+ if track:
+ parts.append(_text(20, y - 14, track, 11, colour, font_weight="bold"))
+ parts.append(_text(20, y + 6, source, 10, MUTED))
+ parts.append(
+ _el("rect", x=x0, y=y - 4, width=x1 - x0, height=14, fill="none", stroke=BORDER)
+ )
+ parts.append(
+ _el("rect", x=x(a), y=y - 4, width=x(b) - x(a), height=14, fill=colour, opacity=0.6)
+ )
+ if chosen:
+ parts.append(
+ _el(
+ "rect",
+ x=x(first),
+ y=66 + 44 - 4,
+ width=x(last) - x(first),
+ height=22,
+ fill="none",
+ stroke=FOCUS,
+ stroke_width=2,
+ )
+ )
+ parts.append(
+ _text(
+ x(first),
+ 140,
+ f"{format_utc(first)[11:16]}Z to {format_utc(last)[11:16]}Z: Navigation log",
+ 10,
+ FG,
+ )
+ )
+ for ms in range(start, tail + 1, 3_600_000):
+ parts.append(
+ _text(x(ms), H - 26, format_utc(ms)[11:16] + "Z", 9, MUTED, text_anchor="middle")
+ )
+ return parts
+
+ pictures["collate-coverage.svg"] = lambda: bars(False)
+ pictures["collate-prefer.svg"] = lambda: bars(True)
+
+ def take() -> list[str]:
+ p = blue["path"]
+ a, b = p.positions[-2], p.positions[-1]
+ rng, brg = range_bearing(a, b)
+ speed = rng.value / ((p.times_ms[-1] - p.times_ms[-2]) / 1000)
+ from debrief_kernel.geodesy import destination
+ from debrief_kernel.units import Quantity
+
+ on = [b]
+ for i in range(1, 21):
+ on.append(destination(b, brg, Quantity(speed * 60 * i, "m")))
+ extended = [[q.longitude, q.latitude] for q in on]
+ return [
+ *_background(m),
+ m.path(extended, stroke=blue["colour"], stroke_width=2, stroke_dasharray="2 3"),
+ ]
+
+ pictures["collate-take.svg"] = take
+
+ glitched = glitched_positions(red["coordinates"])
+ spikes = glitch_indices(len(glitched))
+
+ def tidy(rings: bool = False, smooth: bool = False) -> list[str]:
+ parts = [
+ *_background(m, skip=("RED ONE",)),
+ m.path(glitched, stroke=red["colour"], stroke_width=2),
+ ]
+ if rings:
+ for i in spikes:
+ x, y = m.xy(*glitched[i][:2])
+ parts.append(
+ _el("circle", cx=x, cy=y, r=7, fill="none", stroke=WARN, stroke_width=2)
+ )
+ if smooth:
+ parts = [
+ *_background(m, skip=("RED ONE",)),
+ m.path(red["coordinates"], stroke=red["colour"], stroke_width=2),
+ ]
+ moved = [[lon + 0.004, lat - 0.003] for lon, lat in (p[:2] for p in red["coordinates"])]
+ parts.append(
+ m.path(moved, stroke=red["colour"], stroke_width=1.5, stroke_dasharray="5 3")
+ )
+ return parts
+
+ pictures["tidy-open.svg"] = tidy
+ pictures["tidy-jumps.svg"] = lambda: tidy(rings=True)
+ pictures["tidy-smooth.svg"] = lambda: tidy(smooth=True)
+
+ def mark() -> list[str]:
+ coords = red["coordinates"]
+ x, y = m.xy(*coords[len(coords) // 2][:2])
+ return [
+ *_background(m),
+ _el("circle", cx=x, cy=y, r=5, fill=MARK, stroke=FG, stroke_width=0.5),
+ _text(x + 8, y - 6, "Turned away", 11, FG, font_weight="bold"),
+ ]
+
+ pictures["tidy-mark.svg"] = mark
+
+ def shear() -> list[str]:
+ coords = blue["coordinates"]
+ held = m.xy(*coords[0][:2])
+ x, y = m.xy(*coords[-1][:2])
+ moved = (x + 30, y - 24)
+ n = len(coords)
+ sheared = []
+ for i, p in enumerate(coords):
+ px, py = m.xy(*p[:2])
+ f = i / (n - 1)
+ sheared.append(f"{round(px + 30 * f, 1)},{round(py - 24 * f, 1)}")
+ diamond = " ".join(
+ f"{moved[0] + dx},{moved[1] + dy}" for dx, dy in ((0, -6), (6, 0), (0, 6), (-6, 0))
+ )
+ return [
+ *_background(m),
+ _el(
+ "polyline",
+ points=" ".join(sheared),
+ fill="none",
+ stroke=blue["colour"],
+ stroke_width=2,
+ stroke_dasharray="5 3",
+ ),
+ _el("polygon", points=diamond, fill=FOCUS, stroke=FG, stroke_width=0.5),
+ _el("circle", cx=held[0], cy=held[1], r=4, fill="none", stroke=FG),
+ _text(moved[0] + 10, moved[1] - 8, "New course and speed", 10, FG),
+ ]
+
+ pictures["tidy-shear.svg"] = shear
+ pictures["annotate-open.svg"] = lambda: _background(m)
+
+ def area() -> list[str]:
+ cx, cy = _at(m, "RED ONE", start + (end - start) // 6)
+ corners = [(cx - 40, cy - 25), (cx + 45, cy - 30), (cx + 50, cy + 28), (cx - 35, cy + 30)]
+ points = " ".join(f"{a},{b}" for a, b in corners)
+ return [
+ *_background(m),
+ _el(
+ "polygon",
+ points=points,
+ fill=ANNOTATION,
+ fill_opacity=0.05,
+ stroke=ANNOTATION,
+ stroke_width=1.5,
+ stroke_dasharray="6 3",
+ ),
+ _text(cx - 36, cy - 30, "Search area", 11, ANNOTATION, font_weight="bold"),
+ ]
+
+ pictures["annotate-area.svg"] = area
+
+ def frequencies() -> list[str]:
+ parts = _background(m)
+ for t in (blue, red):
+ for ms in range(start, end + 1, 1_800_000):
+ x, y = _at(m, t["name"], ms)
+ parts.append(_el("circle", cx=x, cy=y, r=2, fill=t["colour"]))
+ parts.append(
+ _text(x + 4, y - 3, format_utc(ms)[11:13] + format_utc(ms)[14:16], 8, FG)
+ )
+ return parts
+
+ pictures["annotate-frequencies.svg"] = frequencies
+
+ def export() -> list[str]:
+ return [
+ *_background(m),
+ _el(
+ "rect",
+ x=4,
+ y=4,
+ width=W - 8,
+ height=H - 8,
+ fill="none",
+ stroke=FOCUS,
+ stroke_width=2,
+ stroke_dasharray="8 4",
+ ),
+ _text(14, H - 14, "DBF-PTET-KG8R-B9EM-69", 9, MUTED, font_family=MONO),
+ ]
+
+ pictures["annotate-export.svg"] = export
+
+ def trace() -> list[str]:
+ steps = [
+ "Step 7 Labels every 10 min",
+ "Step 6 Search area added",
+ "Step 5 Shown read-only",
+ ]
+ parts = [
+ *_background(m, dim=True),
+ _panel(20, 20, 200, 28),
+ _text(30, 38, "DBF-PTET-KG8R-B9EM-69", 11, FG, font_family=MONO),
+ ]
+ parts += [
+ _el("line", x1=220, y1=34, x2=260, y2=34, stroke=FOCUS, stroke_width=2),
+ _panel(260, 14, 200, 72),
+ ]
+ for i, line in enumerate(steps):
+ y = 32 + i * 20
+ if i == 2:
+ parts.append(
+ _el(
+ "rect",
+ x=264,
+ y=y - 12,
+ width=192,
+ height=17,
+ fill="none",
+ stroke=WARN,
+ stroke_dasharray="3 2",
+ )
+ )
+ parts.append(_text(270, y, line, 10, FG if i == 2 else MUTED))
+ return parts
+
+ pictures["annotate-trace.svg"] = trace
+ return {name: _svg(make()) for name, make in sorted(pictures.items())}
+
+
+def sample_tracks() -> tuple[dict[str, dict], list[dict]]:
+ """Serial 1's tracks and narrative, from a fresh build of the sample."""
+ from debrief_sample.builder import build
+
+ with tempfile.TemporaryDirectory() as work:
+ root = Path(work) / "sample"
+ build(root)
+ plot = Catalogue.open(root).read_plot(PLOT)
+ tracks = {}
+ for f in plot.features:
+ if f.properties.kind == "track":
+ colour = f.properties.style.colour if f.properties.style else "#808080"
+ tracks[f.properties.name] = {
+ "name": f.properties.name,
+ "coordinates": [list(p[:2]) for p in f.geometry.coordinates],
+ "colour": colour,
+ "path": TrackPath.from_feature(f),
+ }
+ narrative = sorted(
+ (
+ {"time": f.properties.time, "source": f.properties.source, "text": f.properties.text}
+ for f in plot.features
+ if f.properties.kind == "narrative"
+ ),
+ key=lambda e: e["time"],
+ )
+ return tracks, narrative
+
+
+def write_media(out: Path, *, check: bool = False) -> list[str]:
+ """Write every picture into out; with check, write nothing and list those that differ."""
+ out = Path(out)
+ pictures = draw(*sample_tracks())
+ stale = [
+ n
+ for n, svg in pictures.items()
+ if not (out / n).exists() or (out / n).read_text(encoding="utf-8") != svg
+ ]
+ if not check:
+ out.mkdir(parents=True, exist_ok=True)
+ for name in stale:
+ (out / name).write_text(pictures[name], encoding="utf-8")
+ return []
+ return stale
+
+
+__all__ = ["Position", "draw", "write_media"]
diff --git a/python/debrief-sample/src/debrief_sample/walkthrough.py b/python/debrief-sample/src/debrief_sample/walkthrough.py
new file mode 100644
index 00000000..ead9ea9b
--- /dev/null
+++ b/python/debrief-sample/src/debrief_sample/walkthrough.py
@@ -0,0 +1,295 @@
+"""Walkthroughs on the sample data (CAP-25): a fresh copy of serial 1 for each one.
+
+Every walkthrough works on a copy of the sample's serial 1, never on the sample
+itself (decision, product owner, 17 Oct 2026): the copy is a plot of its own in
+the catalogue, titled after the walkthrough, whose first step holds the sample's
+whole history and one more record saying it is a copy. Its sources, payloads and
+supporting files are copied with it, so it is complete on its own.
+
+What a walkthrough needs beyond the copy is made here too, so VS Code and the
+playground get the same: for *Tidy a track*, three GPS glitches in RED ONE as a
+step of their own; for *Import and collate*, BLUE ONE's navigation log as a REP
+file to import. The playground runs this module under Pyodide, so it imports
+nothing the browser lacks (no pyarrow: the package's builder is not imported).
+"""
+
+from __future__ import annotations
+
+import time
+from pathlib import Path
+
+from debrief_kernel.geodesy import Position, TrackPath, destination, range_bearing
+from debrief_kernel.time import format_utc
+from debrief_kernel.units import Quantity
+from debrief_schemas import models
+from debrief_schemas.island import plot_feature
+from debrief_stac import Catalogue, Changes
+
+#: The walkthroughs, in the order they are offered, and their titles (the same as the guide's).
+TOURS: dict[str, str] = {
+ "explore": "Open and explore a serial",
+ "collate": "Import and collate",
+ "tidy": "Tidy a track",
+ "annotate": "Annotate and export a figure",
+}
+#: The serial every walkthrough copies.
+PLOT = "serial-1"
+#: The navigation log Import and collate brings in, beside the catalogue.
+LOG_FILE = "blue-one-nav-log.rep"
+#: Who makes the copies and the glitches, as provenance names it.
+WALKTHROUGH = models.AgentRef(kind="software_agent", name="Debrief walkthrough")
+
+
+def _now() -> str:
+ return format_utc(int(time.time() * 1000))
+
+
+def _unique(catalogue: Catalogue, stem: str) -> str:
+ taken, item_id, n = set(catalogue.item_ids()), stem, 2
+ while item_id in taken:
+ item_id, n = f"{stem}-{n}", n + 1
+ return item_id
+
+
+def catalogue_title(tour: str) -> str:
+ """A walkthrough's own catalogue, in VS Code: named so the analyst knows it for what it is."""
+ return f"Walkthrough: {TOURS[tour]}"
+
+
+def copy_title(source_title: str, tour: str) -> str:
+ """The copy's title: the serial's, then the walkthrough's, so the two are never confused."""
+ return f"{source_title}: {TOURS[tour]}"
+
+
+def copy_for_tour(
+ target: Catalogue,
+ source_root: Path,
+ tour: str,
+ *,
+ agent: models.AgentRef = WALKTHROUGH,
+) -> str:
+ """Copy serial 1 from the catalogue at source_root into target for a walkthrough,
+ as a new plot titled after it; for *Tidy a track*, put GPS glitches into RED ONE.
+ The source is only read. Returns the copy's item id."""
+ if tour not in TOURS:
+ raise ValueError(f"There is no walkthrough {tour!r}: choose one of {', '.join(TOURS)}.")
+ source = Catalogue.open(source_root)
+ plot = source.read_plot(PLOT)
+ details = plot_feature(plot)
+ serial_title = (details.properties.title if details is not None else None) or "Serial 1"
+ title = copy_title(serial_title, tour)
+ now = _now()
+ record = models.ProvRecord(
+ id=f"walkthrough-copy-{tour}-{int(time.time() * 1000)}",
+ kind="edit",
+ started_at=now,
+ ended_at=now,
+ agent=agent,
+ notes=[
+ f"Copied from {serial_title} of the sample for the walkthrough {TOURS[tour]}",
+ "The sample itself is unchanged",
+ ],
+ )
+ features = [
+ f.model_copy(update={"properties": f.properties.model_copy(update={"title": title})})
+ if f is details
+ else f
+ for f in plot.features
+ ]
+ content = plot.model_copy(
+ update={"features": features, "provenance": [*(plot.provenance or []), record]}
+ )
+ item_id = _unique(target, f"{PLOT}-{tour}")
+ target.create_plot(item_id, title, plot=content)
+ try:
+ # Sources, payloads and supporting files come too: the copy is complete on its own.
+ for key, asset in source.assets(PLOT).items():
+ path = source_root / PLOT / asset.href
+ kind = asset.debrief_asset_kind
+ media_type = asset.type or "application/octet-stream"
+ if kind == "source":
+ target.add_source(item_id, path, key=key, media_type=media_type)
+ elif kind == "payload":
+ target.add_payload(item_id, path, key=key, media_type=media_type)
+ elif kind == "supporting_file":
+ target.add_supporting_file(item_id, path, key=key, media_type=media_type)
+ if tour == "tidy":
+ red = next(
+ f
+ for f in target.read_plot(item_id).features
+ if f.properties.kind == "track" and f.properties.name == "RED ONE"
+ )
+ put_glitches(target, item_id, red.id, agent, reason="for the walkthrough")
+ except BaseException:
+ target.discard_plot(item_id)
+ raise
+ return item_id
+
+
+def glitch_indices(n: int) -> list[int]:
+ """Which of a track's n fixes the glitches move."""
+ return [n // 3, n // 3 + 1, 2 * n // 3]
+
+
+def glitched_positions(coordinates: list) -> list[list[float]]:
+ """A track's positions with three GPS glitches: two fixes in a row three miles off to
+ the north, and one four miles off to the south."""
+ positions = [list(p) for p in coordinates]
+ moves = ((0, 3 * 1852), (0, 3 * 1852), (180, 4 * 1852))
+ for index, (bearing, metres) in zip(glitch_indices(len(positions)), moves, strict=True):
+ lon, lat = positions[index][:2]
+ to = destination(Position(lon, lat), Quantity(bearing, "deg"), Quantity(metres, "m"))
+ positions[index] = [to.longitude, to.latitude]
+ return positions
+
+
+def put_glitches(
+ catalogue: Catalogue,
+ item: str,
+ feature: str,
+ agent: models.AgentRef,
+ *,
+ reason: str = "to try jump removal on",
+) -> None:
+ """Put three GPS glitches into a track: two fixes in a row three miles off to the
+ north, and one four miles off to the south. Recorded as a step of its own, by the
+ agent given, so it is never mistaken for real data and Undo takes it out."""
+ plot = catalogue.read_plot(item)
+ track = next((f for f in plot.features if f.id == feature), None)
+ if track is None or track.properties.kind != "track" or len(track.properties.times) < 12:
+ raise ValueError("Select a track of at least 12 fixes to put glitches in.")
+ positions = glitched_positions(track.geometry.coordinates)
+ glitched = track.model_copy(
+ update={"geometry": track.geometry.model_copy(update={"coordinates": positions})}
+ )
+ at = _now()
+ record = models.ProvRecord(
+ id=f"edit-glitches-{at}",
+ kind="edit",
+ started_at=at,
+ agent=agent,
+ notes=[
+ f"GPS glitches put into {track.properties.name}",
+ f"Three fixes moved off the track, {reason}",
+ ],
+ )
+ catalogue.commit(item, [record], Changes(modified=[glitched]))
+
+
+# --- A navigation log, as new data arrives (CAP-10) ---------------------------------------
+
+
+def _dms(value: float, positive: str, negative: str, width: int) -> str:
+ hemisphere = positive if value >= 0 else negative
+ hundredths = round(abs(value) * 360_000)
+ degrees, rest = divmod(hundredths, 360_000)
+ minutes, rest = divmod(rest, 6_000)
+ return f"{degrees:0{width}d} {minutes:02d} {rest / 100:05.2f} {hemisphere}"
+
+
+def _rep(name: str, ms: int, position: Position, course: float, knots: float) -> str:
+ when = format_utc(ms)
+ stamp = f"{when[2:4]}{when[5:7]}{when[8:10]} {when[11:13]}{when[14:16]}{when[17:19]}.000"
+ quoted = f'"{name}"' if " " in name else name
+ lat = _dms(position.latitude, "N", "S", 2)
+ lon = _dms(position.longitude, "E", "W", 3)
+ return f"{stamp} {quoted} @A {lat} {lon} {course:05.1f} {knots:.1f} 0"
+
+
+def _leg(track_path: TrackPath, ms: int) -> tuple[Position, Position, float]:
+ leg = track_path.leg_at(ms)
+ i = min(leg[0] if leg else 0, len(track_path.positions) - 2)
+ seconds = max((track_path.times_ms[i + 1] - track_path.times_ms[i]) / 1000, 1)
+ return track_path.positions[i], track_path.positions[i + 1], seconds
+
+
+def _course(track_path: TrackPath, ms: int) -> float:
+ a, b, _ = _leg(track_path, ms)
+ return range_bearing(a, b)[1].value
+
+
+def _knots(track_path: TrackPath, ms: int) -> float:
+ a, b, seconds = _leg(track_path, ms)
+ return range_bearing(a, b)[0].value / seconds * 3600 / 1852
+
+
+#: How long the log runs on after the track ends, and how often it has a fix.
+LOG_TAIL_MS = 20 * 60_000
+LOG_STEP_MS = 10_000
+
+
+def log_period(start: int, end: int) -> tuple[int, int]:
+ """The part of a track's period its navigation log overlaps: from a third of the way in,
+ for a quarter of it, on whole minutes."""
+ first = start + (end - start) // 3 // 60_000 * 60_000
+ return first, first + (end - start) // 4 // 60_000 * 60_000
+
+
+def navigation_log(track: models.TrackFeature) -> str:
+ """A track's own navigation log as a REP file, as new data would arrive: a fix every
+ 10 s over part of its period (from a third of the way in, for a quarter of it) and on
+ for 20 minutes after it ends, 120 m from where the plot has it, under its name in
+ other letters (*Blue One*); and a contact the plot has never seen, WATCHER. Fiction,
+ like the sample; the same track always gives the same file."""
+ path_of = TrackPath.from_feature(track)
+ start, end = path_of.start_ms, path_of.end_ms
+ name = track.properties.name.title()
+ lines = [
+ ";; A navigation log, made up for Debrief's sample: not a record of any real platform."
+ ]
+ first, last = log_period(start, end)
+ north = Quantity(120, "m")
+ for ms in range(first, last + 1, LOG_STEP_MS):
+ at = destination(path_of.position_at(ms), Quantity(0, "deg"), north)
+ lines.append(_rep(name, ms, at, _course(path_of, ms), _knots(path_of, ms)))
+ course, knots = _course(path_of, end), _knots(path_of, end)
+ tail = path_of.position_at(end)
+ for i in range(1, LOG_TAIL_MS // LOG_STEP_MS + 1):
+ metres = knots * 1852 / 3600 * i * LOG_STEP_MS / 1000
+ at = destination(tail, Quantity(course, "deg"), Quantity(metres, "m"))
+ lines.append(_rep(name, end + i * LOG_STEP_MS, at, course, knots))
+ origin = destination(path_of.position_at(first), Quantity(225, "deg"), Quantity(6000, "m"))
+ for i in range(31):
+ at = destination(origin, Quantity(80, "deg"), Quantity(i * 300, "m"))
+ lines.append(_rep("WATCHER", first + i * 60_000, at, 80.0, 9.7))
+ return "\n".join(lines) + "\n"
+
+
+def log_track(catalogue: Catalogue, item: str) -> models.TrackFeature:
+ """The track a navigation log is made for: the plot's first track of more than two fixes."""
+ plot = catalogue.read_plot(item)
+ tracks = [
+ f for f in plot.features if f.properties.kind == "track" and len(f.properties.times) > 2
+ ]
+ if not tracks:
+ raise ValueError("The open plot has no track to bring new data for.")
+ return tracks[0]
+
+
+# --- VS Code: a catalogue of its own for each walkthrough ---------------------------------
+
+
+def prepare(folder: Path, tour: str) -> dict[str, str]:
+ """Build the sample catalogue into folder/catalogue and copy serial 1 for the
+ walkthrough there; for *Import and collate*, write the navigation log beside the
+ catalogue (folder/blue-one-nav-log.rep), outside it, as a file waiting to be
+ imported. Returns where things are, for the host to open."""
+ from debrief_sample.builder import build # needs pyarrow: never imported in the browser
+
+ if tour not in TOURS:
+ raise ValueError(f"There is no walkthrough {tour!r}: choose one of {', '.join(TOURS)}.")
+ root = Path(folder) / "catalogue"
+ build(root, catalogue_id=f"debrief-walkthrough-{tour}", title=catalogue_title(tour))
+ catalogue = Catalogue.open(root)
+ item = copy_for_tour(catalogue, root, tour)
+ out = {
+ "catalogue": str(root),
+ "catalogue_title": catalogue_title(tour),
+ "item": item,
+ "title": catalogue.read_item(item).properties.title or item,
+ }
+ if tour == "collate":
+ log = Path(folder) / LOG_FILE
+ log.write_text(navigation_log(log_track(catalogue, item)), encoding="utf-8")
+ out["log"] = str(log)
+ return out
diff --git a/python/debrief-sample/tests/test_walkthrough.py b/python/debrief-sample/tests/test_walkthrough.py
new file mode 100644
index 00000000..6ef3cfef
--- /dev/null
+++ b/python/debrief-sample/tests/test_walkthrough.py
@@ -0,0 +1,132 @@
+"""Walkthrough copies (CAP-25): fresh, complete, titled, and never touching the sample."""
+
+from __future__ import annotations
+
+import json
+import subprocess
+import sys
+from pathlib import Path
+
+import pytest
+
+from debrief_io import import_rep
+from debrief_sample import build
+from debrief_sample.walkthrough import (
+ LOG_FILE,
+ PLOT,
+ TOURS,
+ copy_for_tour,
+ log_track,
+ navigation_log,
+ prepare,
+)
+from debrief_stac import Catalogue
+
+
+def _files(root: Path) -> dict[str, bytes]:
+ return {p.relative_to(root).as_posix(): p.read_bytes() for p in root.rglob("*") if p.is_file()}
+
+
+@pytest.fixture(scope="module")
+def sample(tmp_path_factory: pytest.TempPathFactory) -> Path:
+ root = tmp_path_factory.mktemp("sample") / "catalogue"
+ build(root)
+ return root
+
+
+def test_a_copy_leaves_the_sample_alone(sample: Path, tmp_path: Path) -> None:
+ before = _files(sample)
+ target = Catalogue.create(tmp_path / "work", "work", "Work", "A catalogue to work in")
+ for tour in TOURS:
+ copy_for_tour(target, sample, tour)
+ assert _files(sample) == before
+
+
+def test_each_copy_is_serial_1_titled_after_its_walkthrough(sample: Path, tmp_path: Path) -> None:
+ target = Catalogue.create(tmp_path / "work", "work", "Work", "A catalogue to work in")
+ original = Catalogue.open(sample).read_plot(PLOT)
+ for tour, title in TOURS.items():
+ item = copy_for_tour(target, sample, tour)
+ assert item == f"{PLOT}-{tour}"
+ assert target.read_item(item).properties.title == f"Serial 1: {title}"
+ copy = target.read_plot(item)
+ tracks = {f.properties.name for f in copy.features if f.properties.kind == "track"}
+ assert tracks == {
+ f.properties.name for f in original.features if f.properties.kind == "track"
+ }
+ # Its sources, payloads and supporting files came with it.
+ kinds = sorted(a.debrief_asset_kind for a in target.assets(item).values())
+ assert kinds.count("source") == 3 and "payload" in kinds and "supporting_file" in kinds
+ notes = [n for r in copy.provenance for n in (r.notes or [])]
+ assert f"Copied from Serial 1 of the sample for the walkthrough {title}" in notes
+ # Another copy of the same walkthrough is another plot, never over the first.
+ assert copy_for_tour(target, sample, "explore") == f"{PLOT}-explore-2"
+
+
+def test_tidy_a_track_has_three_glitches_to_remove(sample: Path, tmp_path: Path) -> None:
+ target = Catalogue.create(tmp_path / "work", "work", "Work", "A catalogue to work in")
+ item = copy_for_tour(target, sample, "tidy")
+ red = next(f for f in target.read_plot(item).features if f.properties.name == "RED ONE")
+ _, _, jumps = target.find_jumps(item, red.id)
+ assert len(jumps) == 3
+ last = target.read_plot(item).provenance[-1]
+ assert last.notes == [
+ "GPS glitches put into RED ONE",
+ "Three fixes moved off the track, for the walkthrough",
+ ]
+ # The sample's own RED ONE has none.
+ assert Catalogue.open(sample).find_jumps(PLOT, red.id)[2] == []
+
+
+def test_the_navigation_log_is_new_data_for_the_copy(tmp_path: Path) -> None:
+ made = prepare(tmp_path / "collate", "collate")
+ assert made["item"] == f"{PLOT}-collate" and Path(made["log"]).name == LOG_FILE
+ # Outside the catalogue: a file waiting to be imported, not an item.
+ assert Path(made["log"]).parent == Path(made["catalogue"]).parent
+ catalogue = Catalogue.open(Path(made["catalogue"]))
+ text = Path(made["log"]).read_text(encoding="utf-8")
+ assert text == navigation_log(log_track(catalogue, made["item"]))
+ assert "Blue One" in text and "WATCHER" in text
+ import_rep(catalogue, Path(made["log"]), item_id="blue-one-nav-log", modes="otg")
+ report = catalogue.coverage(made["item"])
+ marked = [i for i in report.items if i.new_data]
+ assert [i.item for i in marked] == ["blue-one-nav-log"]
+
+
+def test_the_command_line_prepares_a_walkthrough(tmp_path: Path) -> None:
+ out = subprocess.run(
+ [sys.executable, "-m", "debrief_sample", "walkthrough", str(tmp_path / "w"), "explore"],
+ check=True,
+ capture_output=True,
+ text=True,
+ )
+ made = json.loads(out.stdout)
+ assert (
+ made["item"] == "serial-1-explore"
+ and made["title"] == "Serial 1: Open and explore a serial"
+ )
+ assert "log" not in made
+
+
+def test_the_walkthrough_module_runs_without_pyarrow() -> None:
+ # The playground imports it under Pyodide, where pyarrow is not installed.
+ code = (
+ "import sys; sys.modules['pyarrow'] = None; import debrief_sample.walkthrough; print('ok')"
+ )
+ out = subprocess.run([sys.executable, "-c", code], check=True, capture_output=True, text=True)
+ assert out.stdout.strip() == "ok"
+
+
+def test_the_walkthrough_pictures_are_drawn_from_the_sample() -> None:
+ # VS Code's Get Started page and the playground show these: made by
+ # python -m debrief_sample media vscode/media/walkthroughs, and kept up to date.
+ from debrief_sample.media import write_media
+
+ media = Path(__file__).resolve().parents[3] / "vscode" / "media" / "walkthroughs"
+ assert write_media(media, check=True) == []
+ tours = json.loads((media.parents[1] / "walkthroughs" / "tours.json").read_text("utf-8"))
+ named = {s["media"] for t in tours["tours"] for s in t["steps"]}
+ assert named == {p.name for p in media.glob("*.svg")}
+ for picture in media.glob("*.svg"):
+ text = picture.read_text("utf-8")
+ assert "