footprint-storydeck 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/README.md +124 -10
  3. package/deck.css +87 -0
  4. package/deck.d.ts +175 -0
  5. package/dist/BlogView.js +48 -0
  6. package/dist/PostView.js +170 -0
  7. package/dist/ScrollyView.js +93 -0
  8. package/dist/SlideDeck.js +49 -0
  9. package/dist/SlideFigure.js +24 -0
  10. package/dist/ThemeToggle.js +13 -0
  11. package/dist/content/loadPost.js +25 -0
  12. package/dist/content/markdown.js +23 -0
  13. package/dist/context.js +14 -0
  14. package/dist/deck/assets.js +38 -0
  15. package/dist/deck/bin.js +5 -0
  16. package/dist/deck/build.js +34 -0
  17. package/dist/deck/check.js +66 -0
  18. package/dist/deck/cli.js +152 -0
  19. package/dist/deck/compile.js +121 -0
  20. package/dist/deck/config.js +68 -0
  21. package/dist/deck/dev.js +318 -0
  22. package/dist/deck/devClient.js +192 -0
  23. package/dist/deck/edit.js +130 -0
  24. package/dist/deck/html.js +65 -0
  25. package/dist/deck/index.js +36 -0
  26. package/dist/deck/init.js +172 -0
  27. package/dist/deck/layout.js +60 -0
  28. package/dist/deck/mcp.js +118 -0
  29. package/dist/deck/noteKey.js +82 -0
  30. package/dist/deck/page.js +68 -0
  31. package/dist/deck/parse.js +143 -0
  32. package/dist/deck/paths.js +92 -0
  33. package/dist/deck/review.js +93 -0
  34. package/dist/deck/source.js +77 -0
  35. package/dist/deck/tools.js +101 -0
  36. package/dist/deck/transforms.js +71 -0
  37. package/dist/figure.js +6 -0
  38. package/dist/focus.js +358 -0
  39. package/dist/focusHtml.js +236 -0
  40. package/dist/index.js +59 -0
  41. package/dist/narration.js +11 -0
  42. package/dist/narrationHtml.js +243 -0
  43. package/dist/pieceRules.js +62 -0
  44. package/dist/pptx.js +280 -0
  45. package/dist/pptxCheck.js +156 -0
  46. package/dist/pptxParts.js +198 -0
  47. package/dist/scopeDeckCss.js +6 -0
  48. package/dist/sections.js +52 -0
  49. package/dist/slug.js +6 -0
  50. package/dist/tour.js +225 -0
  51. package/dist/useTheme.js +35 -0
  52. package/dist/video.js +322 -0
  53. package/dist/voice.js +35 -0
  54. package/dist/xml.js +106 -0
  55. package/dist/zip.js +133 -0
  56. package/focus.css +3 -2
  57. package/focus.d.ts +14 -2
  58. package/index.d.ts +51 -6
  59. package/package.json +30 -20
  60. package/plugin/skills/storydeck/SKILL.md +39 -4
  61. package/storydeck.css +1 -0
  62. package/video.d.ts +13 -1
  63. package/BlogView.jsx +0 -52
  64. package/PostView.jsx +0 -229
  65. package/ScrollyView.jsx +0 -112
  66. package/SlideDeck.jsx +0 -64
  67. package/SlideFigure.jsx +0 -30
  68. package/ThemeToggle.jsx +0 -24
  69. package/content/loadPost.js +0 -36
  70. package/content/markdown.js +0 -27
  71. package/context.jsx +0 -15
  72. package/focus.js +0 -387
  73. package/focusHtml.js +0 -281
  74. package/index.js +0 -22
  75. package/narration.js +0 -25
  76. package/narrationHtml.js +0 -156
  77. package/pptx.js +0 -313
  78. package/pptxCheck.js +0 -167
  79. package/pptxParts.js +0 -262
  80. package/scopeDeckCss.js +0 -8
  81. package/sections.js +0 -72
  82. package/slug.js +0 -9
  83. package/tour.js +0 -206
  84. package/useTheme.js +0 -37
  85. package/video.js +0 -236
  86. package/voice.js +0 -41
  87. package/xml.js +0 -123
  88. package/zip.js +0 -155
package/CHANGELOG.md CHANGED
@@ -1,5 +1,91 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.7.0 — a deck from a folder, an editing loop, co-editing and wire motion
4
+
5
+ - **A packet rides its wire's curve, and a wire shows itself in.** On a `path` click, a `pkt` packet now follows the
6
+ wire's quadratic curve (it crossed a bent wire in a straight line, up to 78 px off it); in Chromium, Firefox and
7
+ WebKit it stays within 0.15 px of the drawn line. CSS only: one registered number, `--sd-t`, runs 0 → 1 and the
8
+ packet stands at B(t) from the control point and end each wire gives it (`--cdx`/`--cdy`, beside `--pdx`/`--pdy`).
9
+ A wire that comes in shows itself from start to tip behind a sweeping soft edge — a mask, so a theme's dashes and
10
+ colours stay as they are — at its own moment (its turn on a path, after a new blur); a highlighted wire just fades
11
+ in, so its ring (drawn outside its box) shows. focus.css gains one more animation slot a stylesheet may give a piece,
12
+ `--sd-a-more`, and `--sd-twin-lag`, so a wire stepping aside for its twin leaves as the twin's sweep nears the tip.
13
+ Reduced motion, a jump and print show the plain wire.
14
+
15
+ - **`storydeck`: a deck is a folder, and the library builds it.** deck.json, clicks.html (a `<section>` per part; on a
16
+ map, a `<step>` per click), the maps, theme.css and icons/ become one self-contained page (`storydeck build`): every
17
+ stylesheet and runtime inlined, notes, presenter and listen mode, a light/dark toggle (T), no network request. No
18
+ build script per deck: a 37-click talk's own script is replaced, and its slides come out byte for byte. Built-in
19
+ helpers: `data-arrow` wires (with a packet, class `pkt`, that rides the wire at its turn on a `path` click) and
20
+ `data-icon` icons. New `footprint-storydeck/deck.css`: the page's chrome, wires, packets, icons and toggle, every rule
21
+ `:where()` so a deck's theme wins.
22
+ - **`storydeck dev`: one page in development and in the room.** It serves the page `build` writes, plus a dev client: a
23
+ save rebuilds (milliseconds), reloads on the same click and plays it again; a save that breaks the build shows why over
24
+ the last good page. Loopback only, localhost requests only, read-only, nothing hidden or outside the deck folder.
25
+ - **Co-editing: the person points, the agent edits.** In `storydeck dev`, **note** leaves a note on the click you are on
26
+ (appended to `feedback.md`, a checklist with the click, its label and the time), and **Alt-drag** moves a piece of the
27
+ map: its inline left/top, or a wire's `data-arrow`, is written into the map file — every other byte kept — and the
28
+ page reloads on the same click. `storydeck feedback` (and the MCP `feedback` tool) lists the open notes; `check`
29
+ counts them. The server takes these two edits only from its own page: its origin, a per-session token only that page
30
+ carries, JSON, 16 KB at most; written never through a link; no other origin may frame it. A note is signed with a key
31
+ kept with the person (`~/.storydeck/key`, or STORYDECK_HOME), and only signed notes are listed — a feedback.md that
32
+ came with someone else's deck is counted, never read as your requests. A move names the piece by the number the build
33
+ gave it (in the dev page only) and is refused while the build fails, when the map changed since the build, or when a
34
+ browser would read the piece's tag differently; a bare `0` moved is written in px. Everything the tools print is
35
+ text, never what a reader cannot see (controls, bidi marks, Unicode tag characters, runs of selectors); a note's
36
+ signature covers the person's words and the deck's folder, never the click's label (the deck's text), which is never
37
+ listed. In development only StoryDeck's own scripts run (a nonce policy on every answer): a deck's own scripts run in
38
+ the built page, and none can reach the dev page's edits. The build refuses a `data-sd-…` attribute a deck writes
39
+ itself. The note key is never read through a link or a second name, nor kept inside the deck folder. Every rule that
40
+ compares paths does so as the disk spells them (`realpathSync.native`), so on a disk that ignores case one folder is
41
+ never taken for two. `loadDeck`/`compileDeck` take `{dev}`; `deckHtml` also returns the source.
42
+ - **Every scan of a deck's text is one pass.** Stripping comments, reading a map's pieces and drawing a slide's focus
43
+ (`readPieces`, `drawFocus`, also on `footprint-storydeck/focus-html`), reading an icon file, and drawing wires and
44
+ icons were quadratic on unfinished tags, unclosed comments or one tag repeating an attribute: a hostile 4 MB map took
45
+ minutes, and `storydeck dev` never came up. Each now reads in one pass (a 4 MB hostile map builds in about 30 ms),
46
+ proved equal to the regex it replaced on thousands of generated inputs, with one deliberate difference: a tag ends at
47
+ its first `>`, so a `data-k` name holding `>` is not read. An `<i data-icon>` naming a property every object has
48
+ (`constructor`) is now refused as an unknown icon.
49
+ - **Read first, look last.** `storydeck outline` (a line per click), `review --click n` (headline and words, focus, route,
50
+ lit, greyed, what enters, leaves and moves, notes) and `check` (headline words, pieces lit by coming in, clicks that
51
+ change nothing, pieces never shown, missing notes — each with its click and its fix). `check --layout` measures each
52
+ click in a browser for text that spills out of its box (through the video driver's new `measure(n)`; needs
53
+ `playwright-core`). `storydeck mcp` serves the same tools to an AI assistant over stdio. `storydeck init` writes a
54
+ starter that builds and checks clean.
55
+ - **A deck is files, never a program — on your machine.** No deck code runs in Node; Playwright, for the layout check,
56
+ is loaded only from beside StoryDeck (never from the deck folder or the folder you run in), and the page is measured
57
+ offline (`deckStageDriver({offline: true})`: no name resolves, the context is offline and routed to the page's own
58
+ file, WebSockets and popups closed; Playwright 1.48 or newer). A deck reads only its own files: a link out of the
59
+ folder, or to a hidden file, is refused. A build renames a new file into place (never writing through a link) and a
60
+ file deck.json names never lands outside the deck; `--out` writes where you say. The dev server is loopback-only and
61
+ survives any request; the MCP server reaches only decks inside the folder it was started with. A deck's own HTML still
62
+ runs in the browser, like any page: open decks you trust.
63
+ - **`zoom` is a camera, so it goes with a mover.** A click can close in while its parts fly apart (`zoom apart`) or move
64
+ (`zoom place`): the camera frames the subject where the mover leaves it. A new strategy kind, `camera` (one a click);
65
+ a camera sees the click as the mover leaves it (subject, context and named groups moved). `zoom` alone draws exactly
66
+ as before, its `subject`/`aside` options included. Refused, naming the fix: a camera with `left`/`right`/`up`/`down`
67
+ (they keep the context in view beside the subject, which closing in would push off the slide), and a camera with a
68
+ deck's own mover that moves the view itself.
69
+ - **A blur frames the subject where it ends up.** On an `apart` or `place` click, the subject's box in the plan
70
+ (`look.subject`) and the blur's frame now follow the subject to where its group moved it (before, they kept where it
71
+ stood). Other clicks are unchanged.
72
+ - `footprint-storydeck/deck` exports the same parts for a script; `deckStageDriver` gains `measure(n)`, and
73
+ `measureSlide` is exported from `footprint-storydeck/video`.
74
+
75
+ ## 0.6.0 — a figure in any framework, and compiled JavaScript
76
+
77
+ - **`StoryFigure`: the Scroll lens drives a figure it does not know how to draw.** A section's `figure` may
78
+ now be `{ mount(el), seek(beat), dispose() }` beside the React render function it already took. The lens
79
+ mounts it once into a plain element in the pinned stage, asks it to `seek` with the beat's plain coordinates
80
+ (`{ key, index, sectionKey, step }`) on every beat change, and prints the sentence a seek resolves to under
81
+ the figure (a refusal, verbatim) until a later seek answers; only the latest seek's answer is shown.
82
+ `isStoryFigure` is exported. Additive: render-function figures and HTML steps are unchanged.
83
+ - **The package ships compiled JavaScript.** `dist/` holds each source file compiled on its own (JSX to
84
+ `react/jsx-runtime`, relative imports with `.js`, `'use client'` kept), so plain Node and any bundler load
85
+ it with no transform: the `.jsx` workarounds consumers needed (`transpilePackages`, a test runner's
86
+ inline list) are no longer required. Types stay the hand-kept `*.d.ts`. `npm run check:dist` loads every
87
+ door in plain Node and refuses a package that ships source JSX; CI runs it.
88
+
3
89
  ## 0.5.0 — PowerPoint, the exploded view, and a tour for decks nobody presents
4
90
 
5
91
  - **`footprint-storydeck/pptx`: the deck as a PowerPoint file.** `exportPptx` makes a slide per click: its settled
package/README.md CHANGED
@@ -7,7 +7,7 @@
7
7
  - **Watch** — an animated, click-through slide deck.
8
8
 
9
9
  Same content, your lens. It's the footprintjs idea — *one canonical record, you choose the view* —
10
- applied to content. A React library; theme-agnostic; ships as source.
10
+ applied to content. A React library; theme-agnostic; ships compiled ESM that Node and any bundler load as-is.
11
11
 
12
12
  > Demo (dogfooded — storydeck explaining storydeck): **[footprintjs.github.io/storydeck](https://footprintjs.github.io/storydeck/)**
13
13
  > First consumer: the **[footprintjs blog](https://footprintjs.github.io/blog/)**.
@@ -58,6 +58,32 @@ A function cannot come from JSON, so `figure` is not part of the authoring model
58
58
  builds sections from post.json + Markdown, and the consumer attaches `figure` to them. The beat a
59
59
  figure is handed carries `sectionKey`, `step` and `index` — which beat it is being asked to draw.
60
60
 
61
+ ### A figure in any framework: `StoryFigure`
62
+
63
+ A section's `figure` may instead be a **`StoryFigure`** — three plain methods, no React:
64
+
65
+ ```ts
66
+ interface StoryFigure {
67
+ mount(el: HTMLElement): void; // draw into this element
68
+ seek(beat: StoryFigureBeat): Promise<string | void>; // the reader is on this beat: move there
69
+ dispose(): void; // take down what mount drew
70
+ }
71
+ // StoryFigureBeat = { key, index, sectionKey, step } — plain data
72
+ ```
73
+
74
+ ```js
75
+ const figure = { mount(el) { chart.attach(el); }, seek: (beat) => chart.goTo(beat.sectionKey), dispose() { chart.detach(); } };
76
+ <ScrollyView sections={sections.map((s) => ({ ...s, figure }))} />
77
+ ```
78
+
79
+ It is the seam for a figure the story should not have to know: a chart library, a canvas, another
80
+ framework's component. `ScrollyView` adapts it in a thin host — mounted once while it is on stage (a
81
+ different section's figure is disposed and the new one mounted), and asked to `seek` on every beat change.
82
+ **A seek that cannot move says why:** it resolves to a sentence, and the lens prints that sentence under the
83
+ figure, verbatim, until a later seek answers; it resolves nothing when it moved. Only the latest seek's
84
+ answer is shown, and a seek that rejects is shown as the lens's own sentence. The lens judges nothing — the
85
+ figure knows whether it can stand on a beat. `isStoryFigure(value)` is the check the lens uses.
86
+
61
87
  ## Focus: what a click is about, and what happens to the rest
62
88
 
63
89
  A talk often tells its story on **one picture** — a diagram, a page, a timeline — click by click. Each
@@ -70,7 +96,7 @@ decision every click makes again, so it is data on the click, and the library do
70
96
  | Kind | Strategies | What happens |
71
97
  |---|---|---|
72
98
  | look | `grey` (default) · `hide` · `keep` | how the context looks: greyed in place, hidden, or left as it is |
73
- | mover | `zoom` | the view closes in on the subject (`share`, `most`) |
99
+ | camera | `zoom` | the view closes in on the subject — where the click's mover leaves it (`share`, `most`): so a click can close in while its parts fly apart (`zoom apart`) or move (`zoom place`). With `left`/`right`/`up`/`down`, which keep the context in view beside the subject, it is refused |
74
100
  | mover | `left` · `right` · `up` · `down` | the context steps aside that way, smaller (`scale`), and the subject grows into the room it leaves (`grow`, `fill`, `gap`, `align`; `move: 'aside'` keeps the subject where it is) |
75
101
  | mover | `place` | named groups moved by the amounts you give (`groups: 'page floor: -90 -200 0.5 \| tl: 0 -230 1.1 @ 960 968'`): a layout you art-direct, still animated from wherever the last click left it |
76
102
  | mover | `apart` | the exploded view: named groups fly apart along the vectors you give (`apart: 'lid: 0 -120 \| core: 160 0 \| base: 0 120 1.1'`), one spring each, `stagger` ms apart (35), and come back together on a later click without it — the last one out is the first one back |
@@ -113,8 +139,9 @@ The order is what the library keeps; where the parts go is yours — vectors tha
113
139
  each part its own way out. The click after a close keeps the closing groups on their springs one click more, so a
114
140
  quick click lets them settle from wherever they are. A line or connector that carries a group's name
115
141
  (`data-k="wire core"`) moves with that group, keeping its place against it. `apart` is a mover, so it goes with a look (the rest
116
- greyed), an overlay and an order, and a click that also asks for `place` is refused, naming the fix; across clicks
117
- the two compose: the parts fly out from the layout `place` left, and back into it.
142
+ greyed), a camera (`zoom apart`: the view closes in on the subject where it flies to), an overlay and an order, and a
143
+ click that also asks for `place` is refused, naming the fix; across clicks the two compose: the parts fly out from the
144
+ layout `place` left, and back into it.
118
145
 
119
146
  The springs run in `focusRuntime` (the Watch lens, `SlideDeck`, installs it; a built page inlines it), each deck on
120
147
  the page its own, played as Web Animations of the same `translate`/`scale` every mover uses (so a piece's own
@@ -152,8 +179,8 @@ const slides = looks.map(look => `<section class="${slideClass(look)}">`
152
179
 
153
180
  Pieces don't nest, so each one greys and moves on its own; a mover keeps every group's shape (the
154
181
  pieces of the subject move together, around the subject's centre) and composes with a piece's own
155
- transform (it uses CSS `translate`/`scale`, not `transform`). A mover's `aside` and `subject` options
156
- name its two groups when they are not simply the greyed and the lit pieces, and `data-twin="name"` makes
182
+ transform (it uses CSS `translate`/`scale`, not `transform`). A mover's `aside` and `subject` options (or, on a
183
+ click with no mover, the camera's) name its two groups when they are not simply the greyed and the lit pieces, and `data-twin="name"` makes
157
184
  a piece (a dashed wire under a solid one) step aside while its twin is lit. A piece that leaves fades out
158
185
  from how it looked and where it stood; a blur that moves to another subject stays, and only its frame is
159
186
  new; overlapping holes stay clear, and nothing outside the `area` is blurred.
@@ -339,6 +366,81 @@ is validated against them. Limits: a clip is embedded as it is (a .wav as voiced
339
366
  mono — convert to .m4a to shrink a long talk); no ZIP64, so a file stays under 4 GB; the slides advance by
340
367
  themselves when the show's "use timings" is on (PowerPoint's default).
341
368
 
369
+ ## A deck from a folder: `storydeck`
370
+
371
+ Building a talk by hand meant writing a build script for it: reading the maps, planning each click's focus,
372
+ wrapping the slides, inlining every runtime. That script was the same for every deck, and it was where an editing
373
+ session spent its time. So a deck is now a **folder**, and the library builds it:
374
+
375
+ ```
376
+ my-talk/
377
+ deck.json { "title": "A request, end to end" } — and footer, area, checks when you need them
378
+ clicks.html a <section> per part; on a map, a <step> per click (data-in, data-on, data-focus, <h2>, notes)
379
+ map.html the picture: every data-k element with an inline left/top/width/height is a piece
380
+ theme.css tokens (light, :root.dark) and the look of your pieces
381
+ icons/*.svg drawn identities, used as <i class="ic" data-icon="server"></i>
382
+ ```
383
+
384
+ ```bash
385
+ npx footprint-storydeck init my-talk # a starter deck that builds and checks clean
386
+ npx footprint-storydeck dev my-talk # live preview: a save rebuilds, reloads on the same click, plays it again
387
+ npx footprint-storydeck feedback my-talk # the notes left in the dev page, for whoever edits next
388
+ npx footprint-storydeck build my-talk # my-talk/index.html — one page, every runtime inside, no network request
389
+ ```
390
+
391
+ - **One page, in development and in the room.** `dev` serves the very page `build` writes, with a small dev client
392
+ added: a save reloads on the click you were on and plays that click again (*replay on save*), a save that breaks the
393
+ build shows why over the last good page, and a bar names the click you are on. Nothing of it ships. The page keeps the
394
+ keys a talk needs: ← → Space step, **T** light/dark, **N** notes, **P** presenter window, **L** read aloud.
395
+ - **Co-editing: the person points, the agent edits.** In the dev page, **note** leaves a note on the click you are on
396
+ (appended to `feedback.md`, a checklist), and **Alt-drag** moves a piece: its new left/top — or a wire's
397
+ coordinates — is written into the map file, every other byte kept, and the page reloads on the same click. An agent
398
+ reads the open notes (`storydeck feedback`, or the MCP `feedback` tool; `check` counts them), makes the change, and
399
+ ticks the line (`- [x]`). Pointing costs the person a second and the agent no screenshot. Each note is signed with a
400
+ key kept with you (`~/.storydeck/key`) for that deck's folder, so a feedback.md that came with someone else's deck is
401
+ never read as your requests: its lines are counted, never listed.
402
+ - **Read first, look last.** A reviewer — a person, or a model that pays for every screenshot — reads the deck as text:
403
+ `outline` (a line per click), `review --click 14` (its headline and words, what it lights, greys, brings in and moves,
404
+ its route, its notes), `check` (what to fix, each with its click and its fix: a headline over 12 words, pieces that
405
+ light just by coming in, a click that changes nothing, a piece never shown, clicks without notes). `check --layout`
406
+ opens the page in a browser, offline, and finds text that spills out of its box (it needs Playwright installed beside
407
+ StoryDeck: `npm i -D footprint-storydeck playwright-core`; without it, it says so and the other checks run). `mcp` serves the same tools to an AI assistant:
408
+ `claude mcp add storydeck -- npx footprint-storydeck mcp my-talk`.
409
+ - **The helpers a diagram needs.** `<i data-k="w1" class="wire pkt" data-arrow="252 352 318 352"></i>` is a wire (an
410
+ SVG arrow, with an optional bend; class `pkt` adds a packet that rides it on a `path` click, at its turn — along the
411
+ curve itself, not a straight line across the bend). A wire that comes in shows itself from its start to its tip,
412
+ its dashes and colours as your theme draws them.
413
+ `<i class="ic" data-icon="server"></i>` is `icons/server.svg`. `deck.css` styles the chrome (kicker, headline, footer
414
+ and count), wires, packets, icons and the theme toggle from the token contract, every rule `:where()`, so your
415
+ theme wins.
416
+ - **A deck is files, never a program — on your machine.** No deck code runs in Node: no build script, no deck module.
417
+ Playwright (1.48 or newer), for `check --layout`, is loaded only from beside StoryDeck — never from the deck folder or
418
+ the folder you run in — and the page is measured offline: no name resolves, the browser context is offline and routed
419
+ to the page's own file, and WebSockets and popups are closed. A hostile test deck's fetch, WebSocket, popup, beacon,
420
+ worker, service worker, WebRTC, form and navigation all failed to reach a local server.
421
+ A deck reads only its own files: a link that leaves the folder, or points at a hidden file (`.env`), is refused, so
422
+ a deck someone sent you cannot pull a file from elsewhere into its page, its review or its server. A build renames a
423
+ new file into place, so it never writes through a link, and a file deck.json names never lands outside the deck.
424
+ `dev` listens on the loopback address only, answers only requests addressed to localhost, and survives any request.
425
+ It writes only the two edits its own page sends — a note and a piece moved — each checked before it is read (the
426
+ page's origin, a per-session token only that page carries, JSON, 16 KB at most) and written by the same rules as a
427
+ build; a move edits only the piece the build drew, and only bytes a browser reads the same way. No other origin may
428
+ frame it, and only StoryDeck's own scripts run in it (a nonce policy): a deck's own scripts run in the built page,
429
+ never in development, so nothing a deck carries can send those edits. Every scan of a deck's text is one pass, so a hostile 4 MB file costs no more than its length, and what the
430
+ tools print is text, never a deck's control characters. A deck's own HTML — its scripts included — runs in the browser like any page you open, in the dev page
431
+ and in the layout check: open decks you trust. Run the commands from your own project, naming the deck folder.
432
+
433
+ The same parts are a Node door, for a script or a test of your own:
434
+
435
+ ```js
436
+ import { loadDeck, buildDeck, reviewClick, checkDeck, serveDeck } from 'footprint-storydeck/deck';
437
+
438
+ const { compiled } = loadDeck('my-talk');
439
+ console.log(reviewClick(compiled, 3)); // click 3 of 6 · "1 · The route · 2" (map "map", step 2 of 3) …
440
+ console.log(checkDeck(compiled)); // [{ level, rule, click, label, message }]
441
+ buildDeck('my-talk'); // { file, bytes, slides, clicks, ms }
442
+ ```
443
+
342
444
  ## Faster edits — what making one talk taught us
343
445
 
344
446
  - **Cache by content, not by position.** A clip is keyed by its words and settings: inserting or removing a
@@ -349,6 +451,12 @@ themselves when the show's "use timings" is on (PowerPoint's default).
349
451
  (0.48–0.70 s spaced out, 0.24–0.28 s as written) — fix, re-voice those clips, measure again.
350
452
  - **One source, several builds.** The room's build carries the presenter's voice and a live demo; the public
351
453
  one (slides page, video) leaves both out — a build switch over the same parts, never a second copy.
454
+ - **Read a click before you look at it.** On a 37-click talk ported to a deck folder (its 12 KB build script no longer
455
+ needed; the library rebuilds its 37 slides byte for byte), a rebuild after a save takes about 6.5 ms, and reviewing a click as
456
+ text costs about 154 tokens against about 1,844 for a screenshot of it — the whole deck about 5,800 tokens as text, about
457
+ 68,000 as screenshots. Measured with `node bench/devloop.mjs <deck>` (an Apple M5 Pro, Node 24); text tokens are
458
+ estimated as characters ÷ 4 and a screenshot's by Anthropic's image formula (width × height ÷ 750, at 1568 × 882), not a
459
+ model run. The layout check found a word spilling 17 px out of its box on 24 of those clicks, which the earlier screenshot checks had missed.
352
460
 
353
461
  ## Quick start
354
462
 
@@ -370,7 +478,8 @@ export default function App({ post }) {
370
478
  ```
371
479
 
372
480
  `PostView` renders the Read · Scroll · Watch toggle and each lens. Bring your own `post` (see
373
- **Authoring**). Requires `react`/`react-dom` (peer) and a slide runtime for Watch (a
481
+ **Authoring**). For a talk on its own page, start from a folder instead: `npx footprint-storydeck init my-talk`, then
482
+ `npx footprint-storydeck dev my-talk` (see **A deck from a folder**). Requires `react`/`react-dom` (peer) and a slide runtime for Watch (a
374
483
  `deck-stage.js` web component served at `${basePath}/deck-stage.js` — it ships as `footprint-storydeck/deck-stage.js`).
375
484
 
376
485
  **For agents:** a skill ships in the package — `plugin/skills/storydeck/SKILL.md` (which door for which job,
@@ -433,9 +542,14 @@ const { theme, toggle, setTheme } = useTheme(); // flips an html class + persi
433
542
  | `embedClips` | `footprint-storydeck/voice` (Node only): a deck's clips inside one page; the clip cache, the voice port and its local-kit adapter are `footprint-narration/voice`'s |
434
543
  | `renderVideo` · `planVideo` · `deckStageDriver` · `ffmpegMajor` | `footprint-storydeck/video` (Node only, ffmpeg 5.0+): the deck as a narrated video, with captions and chapters |
435
544
  | `exportPptx` · `pptxParts` · `zip` · `unzip` · `checkPptx` | `footprint-storydeck/pptx` (Node only): the deck as a PowerPoint file — a slide per click, its picture, notes and clip — through a footprintjs flowchart that records why |
545
+ | `loadDeck` · `buildDeck` · `serveDeck` · `outline` · `reviewClick` · `checkDeck` · `layoutCheck` · `initDeck` · `serveMcp` · `runCli` | `footprint-storydeck/deck` (Node only): a deck folder made into one page, served for editing, read back as text — the `storydeck` command (also `npx footprint-storydeck`); its page's base styles are `footprint-storydeck/deck.css` |
436
546
 
437
- Types ship beside the source in `index.d.ts` — hand-kept (there is no build to generate them from),
438
- and here rather than in a consumer's shim, because a package's shape belongs in the package.
547
+ The package ships compiled JavaScript (`dist/`, built by `npm run build`: each source file compiled on its
548
+ own, JSX turned into `react/jsx-runtime` calls, every relative import given its `.js`, `'use client'` kept), so
549
+ plain Node and every bundler load it with no transform of their own; `npm run check:dist` loads every door in
550
+ plain Node and refuses a package that ships source JSX. Types ship in `index.d.ts` — hand-kept (nothing
551
+ type-checks the JSX to generate them from), and here rather than in a consumer's shim, because a package's
552
+ shape belongs in the package.
439
553
 
440
554
  ## Tests
441
555
 
@@ -444,7 +558,7 @@ npm test # vitest
444
558
  npm run test:cov
445
559
  ```
446
560
 
447
- 317 tests (and 2 that run where LibreOffice or the ECMA-376 schemas are found) · ~99% lines · coverage thresholds enforced (90% statements, 85% branches, 90% functions, 95% lines).
561
+ 438 tests (and 2 that run where LibreOffice or the ECMA-376 schemas are found) · ~99% lines · coverage thresholds enforced (90% statements, 85% branches, 90% functions, 95% lines).
448
562
 
449
563
  ## License
450
564
 
package/deck.css ADDED
@@ -0,0 +1,87 @@
1
+ /* footprint-storydeck/deck.css — the base of a page `storydeck build` makes: the slide, its chrome (kicker,
2
+ headline, footer), the map, wires and their packets, icons and the theme toggle. Every rule is :where(), so a
3
+ deck's theme.css (inlined after it) wins over any of them, whatever its selectors. It reads StoryDeck's token
4
+ contract with a fallback for each: --bg --bg-elev --fg --fg-muted --line --accent-ink --sans --mono. */
5
+
6
+ :where(html, body) { margin: 0; padding: 0; background: var(--bg, #ffffff); color: var(--fg, #14181f); }
7
+ :where(deck-stage) { background: var(--bg, #ffffff); }
8
+ :where(deck-stage:not(:defined)) { visibility: hidden; }
9
+
10
+ /* a slide: the design size, its own ground */
11
+ :where(deck-stage > section) {
12
+ background: var(--bg, #ffffff); color: var(--fg, #14181f);
13
+ font-family: var(--sans, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif);
14
+ box-sizing: border-box; overflow: hidden;
15
+ }
16
+
17
+ /* the chrome: kicker and headline on top, footer and count at the bottom (each carries data-chrome) */
18
+ :where(.head) { position: absolute; left: 50px; top: 56px; right: 50px; z-index: 3; }
19
+ :where(.kicker) {
20
+ font: 600 26px/1 var(--mono, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace);
21
+ letter-spacing: .08em; text-transform: uppercase; color: var(--accent-ink, #8a5a00);
22
+ }
23
+ :where(.head h2) { margin: 18px 0 0; font-size: 64px; line-height: 1.15; font-weight: 700; letter-spacing: -.02em; }
24
+ :where(.foot) {
25
+ position: absolute; left: 50px; right: 50px; bottom: 26px; height: 34px;
26
+ display: flex; justify-content: space-between; align-items: center;
27
+ font: 500 20px/1 var(--mono, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace); color: var(--fg-muted, #5d6673);
28
+ }
29
+ :where(.foot .count) { color: var(--fg, #14181f); font-weight: 700; }
30
+
31
+ /* the map: every piece is placed by its own inline left/top/width/height */
32
+ :where(.map) { position: absolute; inset: 0; }
33
+ :where(.map [data-k]) { position: absolute; box-sizing: border-box; }
34
+
35
+ /* wires (data-arrow): the curve and its head */
36
+ :where(svg.wire) { overflow: visible; color: var(--fg-muted, #5d6673); }
37
+ :where(svg.wire path) { fill: none; stroke: currentColor; stroke-width: 4; stroke-linecap: round; }
38
+ :where(svg.wire path.head) { fill: currentColor; stroke: none; }
39
+
40
+ /* a wire's packet (class "pkt"): hidden at rest (print, a jump, reduced motion show the plain wire). On a forward path
41
+ click it rides the curve once, at the wire's turn: --sd-t runs 0 → 1 and it stands at B(t) = 2(1-t)t·C + t²·E from
42
+ its start (C: --cdx/--cdy, E: --pdx/--pdy, from transforms.js). */
43
+ @property --sd-t { syntax: '<number>'; inherits: false; initial-value: 0; }
44
+ :where(svg.wire .pkt) {
45
+ opacity: 0; fill: var(--sd-glow, currentColor); stroke: none;
46
+ transform: translate(calc(var(--cdx) * 2 * (1 - var(--sd-t)) * var(--sd-t) + var(--pdx) * var(--sd-t) * var(--sd-t)),
47
+ calc(var(--cdy) * 2 * (1 - var(--sd-t)) * var(--sd-t) + var(--pdy) * var(--sd-t) * var(--sd-t)));
48
+ }
49
+ @keyframes sdPkt { from { --sd-t: 0; } to { --sd-t: 1; } }
50
+ @keyframes sdPktShow { 0%, 100% { opacity: 0; } 15%, 80% { opacity: 1; } }
51
+ /* a wire that comes in shows itself start to tip behind a sweeping soft edge (a mask: a theme's dashes stay), square to
52
+ its run (--sd-angle) — its curve's control point sits halfway along the run, so the curve shows in drawing order. It
53
+ is the piece's own animation (focus.css · --sd-a-more): at its turn on a path, after a new blur. */
54
+ @property --sd-reveal { syntax: '<percentage>'; inherits: false; initial-value: 100%; }
55
+ @keyframes sdReveal { from { --sd-reveal: -12%; } }
56
+ @media screen and (prefers-reduced-motion: no-preference) {
57
+ :where([data-deck-active]:not([data-sd-instant]):not([data-deck-static])) :where(.sd-path) :where(.pkt) {
58
+ animation: sdPkt .6s ease-in-out var(--sd-path-delay, 0s) both, sdPktShow .6s linear var(--sd-path-delay, 0s) both;
59
+ }
60
+ :where([data-deck-active].sd-focus-new:not([data-sd-instant]):not([data-deck-static])) :where(.sd-path) :where(.pkt) {
61
+ animation-delay: calc(var(--sd-path-delay, 0s) + .4s);
62
+ }
63
+ /* a highlighted wire keeps its ring, drawn outside its box where no mask paints: it just fades in */
64
+ :where(svg.wire.sd-enter:not(.sd-hot, .sd-hot-in)) { --sd-a-more: sdReveal .6s cubic-bezier(.4, 0, .2, 1) var(--sd-delay, 0s) both; }
65
+ :where(svg.wire.sd-path.sd-enter:not(.sd-hot, .sd-hot-in)) { --sd-a-more: sdReveal .6s cubic-bezier(.4, 0, .2, 1) var(--sd-turn, 0s) both; }
66
+ :where(svg.wire.sd-twin-turn) { --sd-twin-lag: .3s; } /* the wire it hands over to sweeps in: it leaves as the sweep nears the tip */
67
+ :where([data-deck-active]:not([data-sd-instant]):not([data-deck-static])) :where(svg.wire.sd-enter:not(.sd-hot, .sd-hot-in)) {
68
+ -webkit-mask-image: linear-gradient(var(--sd-angle, 90deg), #000 var(--sd-reveal), transparent calc(var(--sd-reveal) + 12%));
69
+ mask-image: linear-gradient(var(--sd-angle, 90deg), #000 var(--sd-reveal), transparent calc(var(--sd-reveal) + 12%));
70
+ }
71
+ }
72
+
73
+ /* icons (data-icon): drawn in currentColor */
74
+ :where(svg.ic) { width: 60px; height: 60px; flex: none; fill: none; stroke: currentColor; stroke-width: 3; stroke-linecap: round; stroke-linejoin: round; }
75
+
76
+ /* the theme toggle (T): light or dark, the choice kept */
77
+ :where(.theme-toggle) {
78
+ position: fixed; top: 12px; right: 12px; z-index: 10; width: 40px; height: 40px; border-radius: 50%;
79
+ border: 1.5px solid var(--line, #c9d0d9); background: var(--bg-elev, #f6f8fa); color: var(--fg, #14181f);
80
+ display: grid; place-items: center; cursor: pointer; opacity: .55; padding: 0;
81
+ }
82
+ :where(.theme-toggle:hover, .theme-toggle:focus-visible) { opacity: 1; }
83
+ :where(.theme-toggle svg) { width: 18px; height: 18px; }
84
+ :where(.theme-toggle .sun) { display: none; }
85
+ :where(:root.dark .theme-toggle .sun) { display: block; }
86
+ :where(:root.dark .theme-toggle .moon) { display: none; }
87
+ @media print { :where(.theme-toggle) { display: none; } }
package/deck.d.ts ADDED
@@ -0,0 +1,175 @@
1
+ /**
2
+ * storydeck · footprint-storydeck/deck (Node) — a deck folder made into one self-contained page, served for editing
3
+ * with a reload on the same click, and read back as text for a reviewer (a person, or a model). The `storydeck`
4
+ * command (bin) runs the same parts.
5
+ *
6
+ * my-deck/deck.json · clicks.html · map.html · theme.css · icons/*.svg
7
+ */
8
+ import type { FocusClick, FocusLook, FocusPiece } from './focus';
9
+
10
+ /** deck.json, read: every default filled in. */
11
+ export interface DeckConfig {
12
+ readonly title: string;
13
+ readonly description: string;
14
+ readonly lang: string;
15
+ /** The text in every slide's footer (default: the title). */
16
+ readonly footer: string;
17
+ /** The slide's design size, [width, height] (1920 × 1080). */
18
+ readonly canvas: readonly [number, number];
19
+ /** Where focus may move and blur, [x0, y0, x1, y1] in slide px (below the headline, above the footer). */
20
+ readonly area: readonly [number, number, number, number];
21
+ readonly clicks: string;
22
+ readonly theme: string;
23
+ readonly icons: string;
24
+ /** A map's file, by name (default for a map not listed: <name>.html). */
25
+ readonly maps: Readonly<Record<string, string>>;
26
+ /** The built page, inside the deck folder (index.html). */
27
+ readonly out: string;
28
+ readonly post: string | null;
29
+ readonly deckData: string | null;
30
+ /** What `storydeck check` asks: words in a map click's headline (12), on a plain slide (40). */
31
+ readonly checks: { readonly maxWords: number; readonly maxSlideWords: number };
32
+ /** The keys deck.json wrote (a file it names must be there; a default may be absent). */
33
+ readonly given: readonly string[];
34
+ }
35
+ /** deck.json (text or parsed) with its defaults; throws, naming the fix, on an unknown key or a value it cannot use. */
36
+ export function readConfig(json: string | object): DeckConfig;
37
+
38
+ /** One click on a map, as written: its attributes (focus-html · readStep reads them), headline (HTML) and notes. */
39
+ export interface DeckStepSource { readonly attrs: string; readonly headline: string; readonly notes: readonly string[] }
40
+ /** A part of the deck: a plain slide (map null) or clicks told on a map. */
41
+ export type DeckPart =
42
+ | { readonly label: string; readonly kicker: string; readonly map: null; readonly body: string; readonly notes: readonly string[] }
43
+ | { readonly label: string; readonly kicker: string; readonly map: string; readonly steps: readonly DeckStepSource[] };
44
+ /**
45
+ * The clicks file read into parts, scanned once. A step holds only its <h2> and notes (anything else is refused, naming
46
+ * where it goes); a <section> or <step> left open, or opened inside another, is refused.
47
+ */
48
+ export function parseClicks(html: string): DeckPart[];
49
+
50
+ export interface DeckIcon { readonly viewBox: string; readonly body: string }
51
+ /**
52
+ * The built-in drawing helpers, icons then wires: <i data-icon="name"></i> becomes that icon's SVG, and
53
+ * <i data-arrow="x1 y1 x2 y2 [bend]"></i> an SVG wire (class "pkt": a packet that rides it on a path click).
54
+ */
55
+ export function applyTransforms(html: string, options?: { icons?: Readonly<Record<string, DeckIcon>> }): string;
56
+
57
+ /** A deck folder as plain data. Every file is read by its real path, inside the folder, never a hidden one. */
58
+ export interface DeckSource {
59
+ readonly dir: string;
60
+ readonly config: DeckConfig;
61
+ readonly clicksHtml: string;
62
+ readonly parts: readonly DeckPart[];
63
+ readonly maps: Readonly<Record<string, string>>;
64
+ readonly themeCss: string;
65
+ readonly icons: Readonly<Record<string, DeckIcon>>;
66
+ readonly post: { sections?: readonly { key?: string; slides?: readonly string[] }[] } | null;
67
+ }
68
+ export function readDeck(dir: string): DeckSource;
69
+
70
+ /** A click of the compiled deck, as review and check read it. */
71
+ export type DeckClick = {
72
+ readonly n: number;
73
+ readonly label: string;
74
+ readonly part: string;
75
+ readonly kicker: string;
76
+ /** The headline as plain text ('' on a plain slide). */
77
+ readonly headline: string;
78
+ readonly notes: readonly string[];
79
+ } & (
80
+ | { readonly kind: 'plain'; readonly map: null; readonly text: string }
81
+ | { readonly kind: 'map'; readonly map: string; readonly step: FocusClick; readonly look: FocusLook; readonly stepIndex: number; readonly steps: number }
82
+ );
83
+ export interface DeckSlide {
84
+ readonly label: string;
85
+ readonly html: string;
86
+ /** What notesData carries for the slide. */
87
+ readonly notes: { readonly slide: string; readonly step: number; readonly steps: number; readonly now: readonly string[]; readonly earlier: readonly string[] };
88
+ }
89
+ export interface CompiledDeck {
90
+ readonly config: DeckConfig;
91
+ readonly title: string;
92
+ readonly themeCss: string;
93
+ readonly slides: readonly DeckSlide[];
94
+ readonly clicks: readonly DeckClick[];
95
+ readonly maps: Readonly<Record<string, { readonly pieces: readonly FocusPiece[] }>>;
96
+ }
97
+ /** The deck's clicks planned (each map's state carried click to click) and drawn as <deck-stage> slides. */
98
+ export function compileDeck(source: Pick<DeckSource, 'config' | 'parts' | 'maps'> & Partial<DeckSource>, options?: { dev?: boolean }): CompiledDeck;
99
+
100
+ /** What the library ships that a page carries: the <deck-stage> runtime, focus.css, narration.css, deck.css, the version. */
101
+ export interface PageAssets { readonly stageJs: string; readonly css: readonly string[]; readonly version: string }
102
+ export function pageAssets(): PageAssets;
103
+ /** One self-contained page: slides, notes, every stylesheet and runtime inlined, the theme toggle. */
104
+ export function deckPage(compiled: CompiledDeck, assets: PageAssets): string;
105
+
106
+ export function loadDeck(dir: string, options?: { dev?: boolean }): { source: DeckSource; compiled: CompiledDeck };
107
+ /** The page of a deck folder, as a string (no file written). */
108
+ export function deckHtml(dir: string, options?: { dev?: unknown }): { source: DeckSource; compiled: CompiledDeck; html: string };
109
+ /**
110
+ * Write the page (deck.json "out", or `out` — a path you choose, anywhere) and deck-data.json when deck.json names
111
+ * one. A new file is renamed into place, never written through a link; a file deck.json names never lands outside
112
+ * the deck folder.
113
+ */
114
+ export function buildDeck(dir: string, options?: { out?: string }): { file: string; bytes: number; slides: number; clicks: number; data: string | null; ms: number };
115
+
116
+ /** The whole deck, one line per click. */
117
+ export function outline(compiled: CompiledDeck): string;
118
+ /** One click (a number from 1, or a label) as text: headline (and its words), focus, route, lit, greyed, what changes, motion, notes. */
119
+ export function reviewClick(compiled: CompiledDeck, which: number | string, options?: { maxWords?: number }): string;
120
+
121
+ export interface DeckProblem {
122
+ readonly level: 'error' | 'warn' | 'info';
123
+ /** words · lit-on-entry · no-change · never-shown · no-notes · compile · spills · outside */
124
+ readonly rule: string;
125
+ readonly click: number | null;
126
+ readonly label: string | null;
127
+ readonly message: string;
128
+ }
129
+ export function checkDeck(compiled: CompiledDeck): DeckProblem[];
130
+ /** Problems as text, one per line, worst first; 'no problems' when there are none. */
131
+ export function formatProblems(problems: readonly DeckProblem[]): string;
132
+
133
+ /** The browser port the layout check drives (the video's deckStageDriver, with measure). */
134
+ export interface LayoutDriver {
135
+ open(url: string): Promise<void>;
136
+ measure(n: number): Promise<readonly { kind: 'spills' | 'outside'; piece: string | null; text: string; dx: number; dy: number }[]>;
137
+ close(): Promise<unknown> | unknown;
138
+ }
139
+ /** Text that spills out of its box and lit pieces off the slide, measured click by click in a browser. */
140
+ export function layoutCheck(url: string, options: { clicks: readonly DeckClick[]; driver?: LayoutDriver; chromium?: unknown }): Promise<DeckProblem[]>;
141
+
142
+ export interface DeckServer {
143
+ readonly url: string;
144
+ readonly port: number;
145
+ rebuild(): void;
146
+ state(): { version: number; problem: string | null };
147
+ close(): Promise<void>;
148
+ }
149
+ /**
150
+ * Serve a deck folder for editing: a save rebuilds, and the page reloads on the same click. Loopback only (host
151
+ * 127.0.0.1, ::1 or localhost; anything else is refused), requests addressed to localhost only. It reads, and takes
152
+ * two edits from its own page (its origin and its per-session token, JSON, at most 16 KB): a note on a click, appended
153
+ * to feedback.md and signed with the person's note key (kept in `home`: STORYDECK_HOME or ~/.storydeck), and a piece
154
+ * moved by Alt-drag, written into its map file. Only StoryDeck's own scripts run in what it serves (a nonce policy).
155
+ * `poll` (ms, 0 for none) compares the folder's stamp so a save the watcher missed still rebuilds.
156
+ */
157
+ export function serveDeck(dir: string, options?: { port?: number; host?: '127.0.0.1' | '::1' | 'localhost'; watch?: boolean; poll?: number; log?: (line: string) => void; tries?: number; home?: string }): Promise<DeckServer>;
158
+
159
+ /** Write the starter deck into a new or empty folder. */
160
+ export function initDeck(dir: string): { dir: string; files: string[] };
161
+
162
+ /**
163
+ * The read-first tools (outline, review, check, build) over MCP: stdio, one JSON-RPC message per line. A call may name
164
+ * another deck only inside `dir`. Resolves when the input closes.
165
+ */
166
+ export function serveMcp(options?: { dir?: string; input?: NodeJS.ReadableStream; output?: { write(text: string): unknown } }): Promise<void>;
167
+
168
+ /** One `storydeck` command (argv without node and the script); resolves to its exit code. */
169
+ export function runCli(argv: readonly string[], io?: {
170
+ stdout?: { write(text: string): unknown };
171
+ stderr?: { write(text: string): unknown };
172
+ stdin?: NodeJS.ReadableStream;
173
+ /** `dev`: the server once it listens, with stop(). */
174
+ onStart?: (server: DeckServer & { stop(): Promise<number | void> }) => void;
175
+ }): Promise<number>;
@@ -0,0 +1,48 @@
1
+ "use client";
2
+ import { jsx, jsxs } from "react/jsx-runtime";
3
+ import { useState } from "react";
4
+ import SlideFigure from "./SlideFigure.js";
5
+ import { finalStep } from "./sections.js";
6
+ import { slugify } from "./slug.js";
7
+ function BlogView({ sections }) {
8
+ const [copied, setCopied] = useState("");
9
+ function copyLink(e, id) {
10
+ e.preventDefault();
11
+ const url = `${window.location.origin}${window.location.pathname}#${id}`;
12
+ if (window.location.hash !== `#${id}`) window.history.replaceState(null, "", `#${id}`);
13
+ try {
14
+ navigator.clipboard.writeText(url);
15
+ } catch (_) {
16
+ }
17
+ setCopied(id);
18
+ setTimeout(() => setCopied((c) => c === id ? "" : c), 1600);
19
+ }
20
+ return /* @__PURE__ */ jsx("article", { children: sections.map((s) => {
21
+ const id = slugify(s.key);
22
+ return /* @__PURE__ */ jsxs("section", { id, className: "post-section", children: [
23
+ /* @__PURE__ */ jsxs("div", { className: "section-head", children: [
24
+ /* @__PURE__ */ jsx(
25
+ "a",
26
+ {
27
+ className: "section-anchor",
28
+ href: `#${id}`,
29
+ onClick: (e) => copyLink(e, id),
30
+ title: "Copy link to this section",
31
+ "aria-label": `Copy link to \u201C${s.heading}\u201D`,
32
+ children: "#"
33
+ }
34
+ ),
35
+ /* @__PURE__ */ jsxs("div", { className: "section-head-text", children: [
36
+ /* @__PURE__ */ jsx("p", { className: "eyebrow", children: s.label }),
37
+ /* @__PURE__ */ jsx("h2", { children: s.heading })
38
+ ] }),
39
+ copied === id ? /* @__PURE__ */ jsx("span", { className: "section-copied", "aria-live": "polite", children: "link copied \u2713" }) : null
40
+ ] }),
41
+ /* @__PURE__ */ jsx(SlideFigure, { html: finalStep(s) }),
42
+ s.body ? /* @__PURE__ */ jsx("div", { className: "prose", dangerouslySetInnerHTML: { __html: s.body } }) : null
43
+ ] }, id);
44
+ }) });
45
+ }
46
+ export {
47
+ BlogView as default
48
+ };