@marver-design/marver 0.13.0 → 0.14.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 (41) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/README.md +41 -19
  3. package/dist/{build-BxGrHFT2.mjs → build-ByYafIhj.mjs} +37 -5
  4. package/dist/cli.mjs +19 -5
  5. package/dist/{daemon-BChkzDqQ.mjs → daemon-Bfucyf1o.mjs} +1 -1
  6. package/dist/{dev-DLwt3Brb.mjs → dev-yZMyQeUj.mjs} +4 -4
  7. package/dist/{init-BpitOqRQ.mjs → init-Dvaso7YO.mjs} +66 -2
  8. package/dist/{manifest-CS6krOTe.mjs → manifest-DvOmglFp.mjs} +7 -0
  9. package/dist/{marver-id-gate-B2uraTHS.mjs → marver-id-gate-D6By7XHj.mjs} +1 -1
  10. package/dist/{plugin-DNc4Jpae.mjs → plugin-BsmG5i2X.mjs} +21 -3
  11. package/dist/{serve-EjEqsiYa.mjs → serve-Bcwfpvhl.mjs} +3 -2
  12. package/dist/{shot-Cyv3GN79.mjs → shot-kbR_xzJH.mjs} +7 -1
  13. package/docs/live-jam.md +173 -0
  14. package/docs/publish.md +270 -0
  15. package/docs/sharing.md +333 -0
  16. package/docs/slides.md +134 -0
  17. package/package.json +3 -1
  18. package/src/client/const.ts +13 -0
  19. package/src/client/content/chart-engine.ts +33 -0
  20. package/src/client/content/chart.tsx +100 -0
  21. package/src/client/content/index.tsx +25 -4
  22. package/src/client/content/slide.tsx +238 -0
  23. package/src/client/content/video.tsx +126 -0
  24. package/src/client/shell/App.tsx +30 -12
  25. package/src/client/shell/LockedApp.tsx +7 -2
  26. package/src/client/shell/Play.tsx +138 -24
  27. package/src/client/shell/Toolbar.tsx +12 -3
  28. package/src/client/shell/canvas/FrameNode.tsx +5 -3
  29. package/src/client/shell/hash.ts +3 -1
  30. package/src/client/shell/icons.tsx +2 -0
  31. package/src/client/shell/play-order.ts +22 -0
  32. package/src/client/shell/store.ts +29 -8
  33. package/src/client/shell/styles.css +17 -27
  34. package/src/client/stage/main.tsx +54 -3
  35. package/src/shared/utm.ts +3 -2
  36. package/templates/AGENTS-embedded.md +1 -0
  37. package/templates/AGENTS-studio.md +1 -0
  38. package/templates/instructions/publish.md +7 -0
  39. package/templates/instructions/reference/deck-layouts.md +230 -0
  40. package/templates/instructions/reference/deck-story.md +110 -0
  41. package/templates/instructions/slides.md +398 -0
@@ -0,0 +1,333 @@
1
+ # Sharing a Marver canvas
2
+
3
+ [Publishing](publish.md) decides which boards ship and how a canvas is gated.
4
+ **Sharing** decides who gets in and who may comment - person by person, from the
5
+ terminal or the browser.
6
+
7
+ The one thing worth holding in your head: **there is a single pure function that
8
+ answers "what can this person do here", and the doors that enforce access all
9
+ call it.** The gate, comment writes, and the browser dialog's owner routes read
10
+ from the same `share.json` and the same resolver, so the policy is one thing in
11
+ one place, not a rule re-implemented per door.
12
+
13
+ > **Sharing needs a place to keep the roster.** `marver share` and the dialog
14
+ > manage a roster of *people*, which needs three things: `MARVER_DATA_DIR` (a
15
+ > persistent volume for `share.json`), an **owner account** on the canvas, and
16
+ > the owner's **device credential** (`marver comments connect`, authenticated
17
+ > with `MARVER_CLI_TOKEN` on an identity canvas - see the
18
+ > [publishing guide](publish.md#marver-sign-in-marver_id_issuer---recommended-for-teams)).
19
+ > With those, a canvas gated by a **password** supports exact-email grants
20
+ > today. **Marver Sign In** adds what only a verified identity can do: domain
21
+ > grants (`@acme.com`), the request-access flow for refused visitors, the
22
+ > hosted browser dialog, and the front door. A password canvas without
23
+ > `MARVER_DATA_DIR` is one shared secret and nothing to share person by person.
24
+
25
+ ## The inputs sharing combines
26
+
27
+ Access is built from a few inputs, and the resolver combines them in one fixed
28
+ order (the precise order is in [How access is computed](#how-access-is-computed-the-resolver-precisely) below):
29
+
30
+ 1. **The blocklist** - the only *deny*, applied first. Its reach is narrower than
31
+ it sounds; see the note under `block` below.
32
+ 2. **The owner** - the canvas owner precedes principal matching and can always
33
+ open and administer the canvas.
34
+ 3. **Grants** - who was let in and at what level (`view` or `comment`), optionally
35
+ until a date. Grants are *additive*: the highest matching grant wins.
36
+ 4. **General access** - the floor for anyone admitted: `private`, `password`, or
37
+ `public`. What you can actually select is clamped by the gate you run (below).
38
+
39
+ Every result is then **clamped by the board's published ceiling** - `publish.json`
40
+ says a board tops out at `read` or `comment`, and no grant can exceed it. A grant
41
+ of `comment` on a board published `read` resolves to `view`.
42
+
43
+ ## `marver share` - the roster from the terminal
44
+
45
+ `marver share` calls the same owner-only routes the browser dialog does,
46
+ authenticated with the device credential [`marver comments connect`](publish.md#marver-sign-in-marver_id_issuer---recommended-for-teams)
47
+ stored. Connect once as the owner, then:
48
+
49
+ ```bash
50
+ marver share add sam@acme.com # grant view (the default)
51
+ marver share add sam@acme.com --role comment # grant comment instead
52
+ marver share add sam@acme.com --expires 2027-06-01 # a grant that lapses on its own
53
+ marver share add @acme.com --role comment # any verified @acme.com address (identity canvas only)
54
+ marver share remove sam@acme.com # take the grant back
55
+
56
+ marver share block troll@spam.com # see the note below - narrower than it reads
57
+ marver share unblock troll@spam.com
58
+
59
+ marver share general private # the stored floor (the gate may clamp it, below)
60
+ marver share general password
61
+ marver share general public
62
+
63
+ marver share list # the roster: general access, grants, blocklist
64
+ marver share requests # pending access requests
65
+ marver share requests --approve sam@acme.com # grant them - canvas-wide, at view
66
+ marver share requests --approve sam@acme.com --role comment
67
+ marver share requests --decline sam@acme.com # resolve it silently - no rejection reaches them
68
+
69
+ marver share explain sam@acme.com # the resolver's trace for one person (see caveats)
70
+ marver share who # granted principals x published boards
71
+ ```
72
+
73
+ **`block` is narrower than "refused everywhere."** The blocklist is the first and
74
+ only deny *inside the resolver*, but two things sit outside it, and the CLI says
75
+ so when you block: **"Blocking only bites while general access is Private."** If
76
+ general access is `password` or `public`, the same person can still enter
77
+ anonymously - there is no identity to block at an anonymous door. And a canvas
78
+ **owner** is admitted ahead of the blocklist (someone must be able to administer),
79
+ though comment authorization can still deny them. Blocking reliably denies an
80
+ *identified, non-owner* person and their access requests; it is a complete entry
81
+ denial only while general access is private.
82
+
83
+ **`explain` asks the canvas; `who` is a local convenience view.** Since 0.13.0
84
+ `marver share explain` calls the canvas's own `share/explain` route - the owner
85
+ role, domain principals, and the winning step included - so it is the enforcing
86
+ trace. `who` still runs the resolver locally over the roster it fetched, and
87
+ takes three shortcuts the enforcing doors do not:
88
+
89
+ - it does not pass the owner role, so a fresh owner with no grant of their own is
90
+ explained as an ordinary ungranted person (the server's own `explain` route
91
+ fills the owner role in; the dialog is the accurate view for the owner);
92
+ - it treats a `@domain` argument as having no address to match, so a domain row
93
+ in `who` shows `(per member)` rather than an effective role;
94
+ - it prints each step's role but not which step *won* - the resolver marks a
95
+ winning step, and the CLI renderer drops that mark, so read the trace as the
96
+ inputs, not a highlighted verdict.
97
+
98
+ So `explain` is reliable for anyone; `who` lists *granted principals* down the side (not the owner,
99
+ who holds no grant row), the blocklist, and - when general access is open - an
100
+ anonymous row; it is the grant matrix, not a census of everyone who could get in.
101
+
102
+ Domain grants (`marver share add @acme.com`) need Marver Sign In: a canvas that
103
+ cannot verify who holds an address cannot verify their domain.
104
+
105
+ ## `publish.json` v2 - the ceiling, and the board's shape
106
+
107
+ `publish.json` is where a board's **ceiling** lives - the most any grant can
108
+ resolve to there - and, in the v2 row shape, a few facts about how the board
109
+ presents. Every 0.11 canvas parses unchanged: a bare `"read"` or `"comment"`
110
+ string is still a valid row. (This is a **schema** version; it is unrelated to
111
+ the read-privacy work also called "v2" further down.)
112
+
113
+ ```jsonc
114
+ {
115
+ "boards": {
116
+ "roadmap": "comment", // v1 row: the ceiling, nothing more
117
+
118
+ "brand": { // v2 row: an object
119
+ "max": "read", // required - the ceiling ("read" | "comment")
120
+ "type": "design", // optional - the artifact type (default "mix")
121
+ "open": "focus", // optional - the landing view
122
+ "lock": true // optional - freeze the landing view (needs "open")
123
+ }
124
+ },
125
+ "reveal": { "structure": true, "source": false }
126
+ }
127
+ ```
128
+
129
+ - **`max`** (`"read"` | `"comment"`) is the ceiling. This is the only field that
130
+ affects *access*; everything else is presentation.
131
+ - **`type`** is one of `doc`, `slides`, `design`, `sketch`, `refs`, `mix`
132
+ (default `mix`). It picks the board's default landing view and its card icon;
133
+ nothing infers a type from content.
134
+ - **`open`** names the landing view: `canvas`, `board`, `present`, `focus`,
135
+ or `slides`. `canvas` and `board` both land on the canvas; `present`,
136
+ `focus`, and `slides` are their own modes. Absent means the type decides
137
+ (`slides` → slides mode, `doc` → focus, `design` / `sketch` / `refs` →
138
+ board, `mix` → canvas).
139
+ - **`lock`** freezes the canvas in the `open` view (no view switcher). It
140
+ requires `open` - freezing an unnamed mode is meaningless, so the build refuses
141
+ it.
142
+ - **`transition`** (`"fade"` default | `"none"`) and **`chrome`** (`"full"`
143
+ default | `"minimal"` | `"none"`) shape slide playback only. They are
144
+ accepted on a board whose `type` or `open` is `slides` and refused
145
+ elsewhere. A slides board plays only its `slide: true` frames: non-slide
146
+ frames on it warn at build, a board with none errors (0.13.0 let `slides`
147
+ alias `present`; 0.14.0 plays only slides - to keep the old behaviour set
148
+ a non-slides `type` such as `mix` AND `open: "present"`, or add
149
+ `slide: true` to the frames).
150
+ - **`reveal.source`** defaults **off** on a published canvas: the bundle would
151
+ otherwise carry every frame's repo path, which is a disclosure the moment a
152
+ canvas is shared beyond its own repo. `reveal.structure` defaults on.
153
+
154
+ Publishing stays **default-closed**: no `publish.json` and no explicit
155
+ `--boards`/`--all-boards` flag means nothing ships. A board absent from the
156
+ policy is absent from the bundle - which is also the read boundary in v1 (see
157
+ ["What v1 does not do"](#what-v1-does-not-do)).
158
+
159
+ ## General access, and what your gate can enforce
160
+
161
+ `marver share general <mode>` is clamped to what the **gate you run can actually
162
+ enforce** - and it is clamped when you *set* it, not when access is resolved. When
163
+ you ask for more than the gate allows, the owner API stores the operative
164
+ (clamped) mode, and the CLI tells you what it stored and why, so the roster never
165
+ holds a state the server cannot enforce:
166
+
167
+ | Gate you run | `private` | `password` | `public` |
168
+ |---|---|---|---|
169
+ | **Marver Sign In** (`MARVER_ID_ISSUER`) | private | private | private |
170
+ | **Password** (`MARVER_PASSWORD`, no issuer) | private | password | password |
171
+ | **No gate** (neither set) | public | public | public |
172
+
173
+ So on an identity canvas general access is always `private` - membership is the
174
+ whole point, and there is no anonymous door to open. On a password canvas,
175
+ `public` clamps to `password` (the password would otherwise be theater). A canvas
176
+ with no gate is `public` by definition. When the CLI clamps your request it tells
177
+ you what it stored and why.
178
+
179
+ ## `share.json` - the grant store
180
+
181
+ The roster lives in `share.json` on your volume (`MARVER_DATA_DIR`), beside
182
+ `auth.json`. It is a plain JSON file on the server, written mode `0600` (readable
183
+ by the OS account running Marver, not by a browser owner - browser owners inspect
184
+ it through the CLI or the dialog, both of which go through the owner API). You
185
+ rarely touch it by hand, but three things about how it behaves are worth knowing:
186
+
187
+ - **It is created once, at serve boot, from your live 0.11 state.** Every existing
188
+ account gets a canvas-wide `comment` grant (clamped per board) because that is
189
+ exactly what they could do the day before. Until that first boot, every door
190
+ falls back to the legacy rules it always had - upgrading changes nothing you
191
+ did not ask for.
192
+ - **A grant carries a per-board ratchet, not just a role.** A grant records what
193
+ you *asked for* (`assigned`) separately from what each board currently resolves
194
+ to (`boardRole`). Reads always take `min(current ceiling, boardRole)`, and every
195
+ boot re-clamps entries *down* to the ceiling, never up. So raising a board's
196
+ ceiling later does **not** silently re-promote everyone who was granted under
197
+ the old, lower one - the entry stays low until you explicitly re-confirm it.
198
+ This is the non-promotion invariant, and it is the reason a ceiling change is
199
+ always safe.
200
+ - **It fails closed.** A present-but-corrupt or malformed `share.json` denies
201
+ rather than falling back to open - a policy typo must never quietly grant the
202
+ ceiling. A *missing* file is the pre-migration signal and keeps legacy rules; a
203
+ broken one stops the doors.
204
+
205
+ ## Access requests - when someone refused wants in
206
+
207
+ When a person is refused at the identity gate, the canvas offers them a request
208
+ form instead of a dead end. The refused visitor has no session to authenticate
209
+ with, so the canvas mints a **short-lived, origin-bound, single-purpose request
210
+ token** and accepts a request only against it (the `marver-reqaccess+jwt`
211
+ contract). The request lands as one pending row per address:
212
+
213
+ - it surfaces in `marver share requests` and in the dialog's requests list;
214
+ - **approving grants canvas-wide at the role you choose** (`view` by default) -
215
+ in v1 an approval covers every published board, and the CLI and dialog both
216
+ say so;
217
+ - **declining resolves the row silently** - no rejection email reaches the asker;
218
+ - a repeat ask from the same address replaces its note, it does not pile up a
219
+ second row, and a row expires on its own after 30 days.
220
+
221
+ ## The front door (`app.marver.design`)
222
+
223
+ The front door is the signed-in home page at `app.marver.design`: a person signs
224
+ in once and sees the canvases they can reach, each row lit by a short summary the
225
+ **canvas itself signs and serves**. The front door holds no roster and makes no
226
+ access decision - it asks each canvas, and each canvas answers for itself.
227
+
228
+ For a canvas operator there are two things to know:
229
+
230
+ - **Your canvas answers its own summary probes, and only signed ones.** The front
231
+ door presents a `marver-summary+jwt` (audience = your exact origin, authorized
232
+ party = the app, valid ≤120s); the canvas verifies it against the identity
233
+ service's published keys before answering, and the answer is signed by the
234
+ canvas so the browser can pin it. Owner mutations from the dialog carry a
235
+ separate `marver-owner-api+jwt` (≤300s), and outbound mail rides a
236
+ `marver-relay+jwt`. You do not configure any of this - it is the wire, listed
237
+ so you can recognize it.
238
+ - **You can keep your canvas out of it.** `share: { frontDoor: false }` in
239
+ `design/config.ts` makes the canvas stop answering front-door identity and
240
+ summary probes - the app receives no signed summary from this canvas, so it has
241
+ nothing to show. The privacy tradeoff around the front door is disclosed in full
242
+ in the [publishing guide](publish.md#who-can-open-your-canvas); this is the
243
+ switch that makes your canvas silent to it.
244
+
245
+ ## How access is computed (the resolver, precisely)
246
+
247
+ For anyone who wants to check rather than trust, here is the exact order the one
248
+ resolver (`resolveAccess`) runs, top to bottom:
249
+
250
+ 1. **Blocklist.** If the address is blocked, every board is `none` and the person
251
+ is refused, and nothing below runs. (An anonymous caller - past a password
252
+ gate, or on a public canvas - has no identity to block, so this step never
253
+ fires for them, which is why blocking does not stop anonymous entry.)
254
+ 2. **Owner.** The canvas owner precedes principal matching and resolves to
255
+ `comment` on every board - still clamped by the ceiling, so an unpublished
256
+ board is empty even for the owner.
257
+ 3. **Grants, additive, highest wins.** Every live grant matching the address
258
+ (exact, or by verified domain) contributes its `boardRole`, read through the
259
+ read-time ceiling `min`. The highest contribution per board wins.
260
+ 4. **General access.** If the operative mode is not `private`, everyone admitted
261
+ gets `view` as a floor.
262
+ 5. **Ceiling clamp.** Every board's result is `min(result, publish.json ceiling)`.
263
+
264
+ `entry` (may they open the canvas at all) is "at least `view` on at least one
265
+ board". Note the gate's own entry check short-circuits step 1 for the owner - the
266
+ owner is always admitted so the canvas can be administered - which is the one
267
+ place the blocklist does not have the last word.
268
+
269
+ ## Mail, mentions, and the bell (v1.1)
270
+
271
+ Sharing's mail rides one relay at the identity service, and the canvas can only
272
+ ever choose a **template** - never a subject or a body. Activity mail carries
273
+ exactly three variables beyond the origin: the commenter's display name, a
274
+ comment snippet capped at 180 characters, and your own unsubscribe link -
275
+ which means the identity service holds those snippets for its delivery window.
276
+ That is a deliberate, disclosed widening (every collaboration product's
277
+ mention mail shows who and what; a mail that names neither gets ignored).
278
+ Transactional mail (invites, approvals, requests) carries no name and no
279
+ content, and `share: { notify: false }` opts out of all of it. Three transactional moments (you were invited, your request
280
+ was approved, someone asked for access) shipped with v1; v1.1 adds the two
281
+ **activity** moments that pull collaborators back:
282
+
283
+ - **A thread you are in moved.** A fresh reply mails the thread's other
284
+ participants - the 10 most recent distinct people, at most one mail per
285
+ person per thread per 6-hour window, and only for replies just written
286
+ (history imports and syncs never mail anyone).
287
+ - **Somebody named you.** Typing `@` in the comment composer offers the people
288
+ already visible in the canvas's comments; a completed `@Name` mails that
289
+ person once for that comment and rings their bell on the front door. You can
290
+ only mention people you can already see - the roster is never disclosed, and
291
+ in the browser mentions travel as the same opaque ids authors do, so no
292
+ member's address ever reaches another viewer.
293
+
294
+ While you are ON the canvas, the same moments notify in place: a mention (or a
295
+ reply in your thread) raises a bottom-right pill with the author's face and
296
+ message plus a soft ping, and the mentioned thread's pin pulses in accent blue
297
+ until you open it. The mail's button deep-links straight to that thread.
298
+
299
+ Only people who still resolve to at least `view` are mailed - a revoked or
300
+ blocked participant's mail stops with their access. Every activity mail carries
301
+ an **unsubscribe link**: it can mute replies, mentions, or all activity mail,
302
+ for that canvas or everywhere, and it mutes **email only** - the bell keeps
303
+ working. Invitations and approvals are never muted (an invite you cannot
304
+ receive is a lockout, not a courtesy). `share: { notify: false }` still
305
+ declines the relay entirely, activity mail included.
306
+
307
+ ## What v1 does not do
308
+
309
+ Sharing v1 controls **who gets in** and **who may comment**. It does **not** yet
310
+ do per-person *read* privacy: every admitted person can read every published
311
+ board. The read boundary in v1 is the bundle itself - a board you do not publish
312
+ is not in the build, so the way to keep something from an audience today is a
313
+ separate canvas for that audience. ("v2" here means this next release, the
314
+ read-privacy one - a different thing from the `publish.json` v2 *row schema*
315
+ above, which ships now.)
316
+
317
+ Three consequences, stated plainly because they are easy to assume otherwise:
318
+
319
+ - **Grants are canvas-scoped.** `share.json` is already shaped for board-scoped
320
+ grants, but the CLI and dialog accept `canvas` scope only - a board-scoped grant
321
+ would open the whole bundle while reading as "just this board", so the door
322
+ refuses it until read privacy makes it real.
323
+ - **Approvals are canvas-wide.** An approved access request covers every published
324
+ board, and the surfaces say so. The request records what the refused link
325
+ pointed at, but that target is context in v1, not an enforced scope.
326
+ - **A deep link is presentation, not a wall.** A single-frame or single-board
327
+ link lands that visit in the right view, but the rest of the canvas stays
328
+ reachable by URL. The door renders because there is one frame to show, not
329
+ because the others are protected.
330
+
331
+ All three become enforced in the read-privacy release, when boards are *served*
332
+ per person rather than *bundled*. The schema is already ready for it; v1 declines
333
+ the operations it cannot honor rather than pretending to.
package/docs/slides.md ADDED
@@ -0,0 +1,134 @@
1
+ # Slides - decks on the canvas
2
+
3
+ A slide is an ordinary frame with `slide: true`:
4
+
5
+ ```tsx
6
+ import { Slide } from '@marver-design/marver/content'
7
+ export const meta = { title: 'Cover', slide: true }
8
+ export default () => (
9
+ <Slide>
10
+ <h1 className="sl-assertion">Churn halved after onboarding v2</h1>
11
+ </Slide>
12
+ )
13
+ ```
14
+
15
+ It renders 1280×720 on the canvas, wears the slide badge, and everything
16
+ you know - comments, lasers, variants, promotion, Live Jam - keeps working.
17
+ **The stage fits every screen**: you author at exactly 1280×720, and the
18
+ slide scales and centers itself to whatever viewport plays it - fill window,
19
+ a laptop, a viewer's phone - author px, Tailwind classes, and charts all
20
+ scale together, so the composition you approved is the composition everyone
21
+ sees. One scene = one deck; numbered files
22
+ (`01-cover.tsx`) are the authoring order; **the board's reading order is the
23
+ played order** - drag slides around the canvas to reorder the deck.
24
+
25
+ ## Why it stays light for the agent
26
+
27
+ There is no slide component library to learn. `Slide` is the ONE primitive:
28
+ it owns the 1280×720 stage, the asymmetric margins, six fixed type roles
29
+ (`sl-display` 160 · `sl-stat` 88 · `sl-assertion` 56 · `sl-support` 30 ·
30
+ `sl-body` 24 · `sl-caption` 18), your theme's tokens, and the motion
31
+ contract. Everything inside it is your project's own markup, classes, and
32
+ components - the same ones the app ships - so a slide is built the way a
33
+ screen is built, and an approved slide can be promoted like one.
34
+
35
+ Looking good at every size costs the agent nothing extra: the fit is pure
36
+ CSS on the root (a resized canvas node, a phone, a projector all get the
37
+ same composition, scaled), so the doctrine forbids `vw`/`vh` and media
38
+ queries inside a slide and asks for flex/grid in the stage's own
39
+ proportions. A dev-only overflow marker outlines a slide whose content escapes the
40
+ stage, or whose flex/grid child outgrows its parent - the agent sees the
41
+ defect on the canvas, and the rule is always "cut or split, never shrink the type".
42
+
43
+ The craft lives in prose, not code. `marver init` ships
44
+ `design/instructions/slides.md` - the doctrine: assertion-first argument,
45
+ the type roles, **the space IS the design** (three bands, the 85% rule, one
46
+ px spacing scale), **seven silhouettes chosen before any recipe** (statement
47
+ / hero / split / grid / stream / field / bookend) with a storyboard step
48
+ and pacing rules so a deck never reads as one repeated shape, 19 core
49
+ recipes with budgets and morph anchors, the choreography rules, and a
50
+ review gate that squints the contact sheet. Two depth references sit
51
+ beside it: `instructions/reference/deck-story.md` (intake, answer-first
52
+ structure, the evidence check, audience calibration, the words) and
53
+ `instructions/reference/deck-layouts.md` (the full layout atlas by job, the
54
+ grid, content budgets, rebuilding an existing deck, chart craft). Your own
55
+ **deck look** (tokens, type, the mark, colour meaning, numbers, voice - a
56
+ fill-in template the agent drafts on the first deck), layouts, and house
57
+ rules live in `design/slides.md`, which overrides the doctrine and which
58
+ marver never overwrites.
59
+
60
+ ## Playing and publishing a deck
61
+
62
+ Press `p` on a board whose publish row says slides and you get slides mode:
63
+ the 16:9 stage with the standard prototype toolbars (with `chrome: full`,
64
+ the default) - arrows / Space / click to advance, `d` cycles the theme,
65
+ devices including a 1280×720 Slide preset and fill window.
66
+ Publish it with:
67
+
68
+ ```json
69
+ { "boards": { "pitch": { "max": "comment", "type": "slides",
70
+ "open": "slides", "transition": "fade" } } }
71
+ ```
72
+
73
+ - `transition`: `fade` (default) or `none`.
74
+ - `chrome`: `full` (default - the standard prototype chrome: the top-right
75
+ toolbar with comment, laser, theme, and devices including fill, plus the
76
+ bottom-left walker; a locked deck-only share also carries the brand pill),
77
+ `minimal` (a slim progress strip, comments, the canvas door when the
78
+ board is not locked, and a pending-update control), or `none` (bare
79
+ stage).
80
+ - Add `"lock": true` to freeze visitors in the deck - no way out to the
81
+ canvas. When every published board is locked to present, focus, or
82
+ slides, the canvas shell is left out of the bundle entirely.
83
+
84
+ Viewers land straight in the deck; the URL survives refresh and back.
85
+
86
+ ## Motion - the diff is the animation
87
+
88
+ A resting slide is STILL - that is a contract, not a hope: charts render
89
+ final-state SVG, videos are posters (no `<video>` element exists), and the
90
+ `Slide` root suspends every CSS animation and transition under it at rest.
91
+ (Your own `<canvas>`, `<video>`, or JS-driven motion is outside the contract
92
+ and stays live, as in any frame.) Motion happens in slides
93
+ mode, one-shot:
94
+
95
+ - **Morphs**: give the same `view-transition-name` to an element on two
96
+ adjacent slides and it travels/grows between them. This is the house move.
97
+ - **Build steps**: progressive disclosure is sibling frames (`03a-`, `03b-`)
98
+ sharing morph names - every step visible and commentable on the board.
99
+ - **Entrances**: `data-animate="fade-up | fade | scale-in"` +
100
+ `data-animate-delay="0-3"`, run once after the transition settles. Never
101
+ on an element that carries a morph name.
102
+
103
+ `prefers-reduced-motion` flattens marver's own motion - the morphs between
104
+ slides and the entrance presets.
105
+
106
+ ## Charts and video
107
+
108
+ - `<Chart option={...} h={420} />` - an Apache ECharts option, on a
109
+ fixed supported surface: series bar, line, pie, scatter, radar, gauge, heatmap, funnel, treemap, sunburst, sankey, boxplot; components grid, polar, radar, tooltip, legend, title, dataset (+ transform), markLine, markPoint, markArea, visualMap, dataZoom. Anything outside
110
+ it is dropped by ECharts without an error, so stay inside. marver
111
+ supplies the house theme (colours, type, tooltip) from your
112
+ `design/theme.css` tokens, strips animation at rest, and lets any
113
+ styling you pass override the theme - so pass data and structure only.
114
+ SVG-rendered, in a lazy chunk chart-free canvases never download.
115
+ - `<Video src="intro.mp4" poster="intro.jpg" />` - the poster is the slide
116
+ at rest (required for local files); in slides mode the glass strip plays
117
+ it (play/pause, seek, mute, fullscreen). Remote https direct files work
118
+ too.
119
+
120
+ ## Theme tokens
121
+
122
+ The `Slide` root reads `--marver-slide-ground / -ink / -muted` (each with a
123
+ `-dark` variant), `--marver-slide-accent` (one value, both themes),
124
+ `--marver-slide-font`, and `--marver-slide-tempo` (one duration that times
125
+ both the entrances and the morphs between slides) from your theme and falls
126
+ back to the house palette. The stage is 1280×720 (`SLIDE_W` / `SLIDE_H`, exported from `/content`)
127
+ with asymmetric margins - 88px sides, 44px top and bottom, overridable in
128
+ px via `--marver-slide-pad-x` / `--marver-slide-pad-y` - leaving a 1104×632
129
+ content box. Morphs between slides are progressive enhancement: where
130
+ `document.startViewTransition` is missing, slides crossfade at the tempo.
131
+ Type roles, fixed: `sl-display` (160px, the one
132
+ oversize - a hero number, a section numeral), `sl-stat` (88px, a row of
133
+ figures), `sl-assertion` (56px),
134
+ `sl-support` (30px), `sl-body` (24px), `sl-caption` (18px).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marver-design/marver",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "The agent-native design canvas. A design/ folder, one command, a canvas of live frames built from your repo's real components - comment @marver and your own coding agent does the work. The tool ships no AI.",
5
5
  "type": "module",
6
6
  "private": false,
@@ -27,6 +27,7 @@
27
27
  "src/client",
28
28
  "src/shared",
29
29
  "templates",
30
+ "docs",
30
31
  "README.md",
31
32
  "LICENSE",
32
33
  "NOTICE",
@@ -47,6 +48,7 @@
47
48
  "@tailwindcss/vite": "^4.0.0",
48
49
  "@vitejs/plugin-react": "^6.0.0",
49
50
  "cac": "^7.0.0",
51
+ "echarts": "^6.1.0",
50
52
  "html-to-image": "^1.11.13",
51
53
  "marked": "^16.0.0",
52
54
  "mermaid": "^11.6.0",
@@ -8,3 +8,16 @@ export const ROUTE = '/__mv'
8
8
  * Shared by the Doc primitive (measurement messages) and the server-side
9
9
  * manifest scan (defaultSize for content frames) - one source, no drift. */
10
10
  export const CONTENT_WIDTH: Record<string, number> = { document: 760, wide: 1280 }
11
+
12
+ /** The slide stage (v1.5): a runtime-reserved intrinsic, deliberately NOT a
13
+ * config viewport - no migration for existing projects, no deck device in
14
+ * sweeps. Dependency-neutral so server (shot) and shell (store) share it. */
15
+ export const SLIDE_INTRINSIC = { width: 1280, height: 720 }
16
+
17
+ /** The one DEFAULT sizing rule for slide frames, shared by canvas and shot:
18
+ * `slide: true` sets the intrinsic 1280×720 stage, over any authored viewport.
19
+ * Board nodes stay resizable (the Slide root scales into whatever box it is
20
+ * given); this governs defaults, shots, and stage coordinates. */
21
+ export function slideSize(frame: { slide?: boolean }): { width: number; height: number } | null {
22
+ return frame.slide ? SLIDE_INTRINSIC : null
23
+ }
@@ -0,0 +1,33 @@
1
+ /** The lazily-loaded ECharts engine - STATIC named imports only, so the
2
+ * bundler tree-shakes to exactly the blessed set (whole-namespace imports
3
+ * drag the entire library into the chunk). chart.tsx dynamic-imports THIS
4
+ * file, which is what splits echarts into its own async chunk. */
5
+ import * as core from 'echarts/core'
6
+ import { SVGRenderer } from 'echarts/renderers'
7
+ import {
8
+ BarChart, LineChart, PieChart, ScatterChart, RadarChart, GaugeChart, HeatmapChart,
9
+ FunnelChart, TreemapChart, SunburstChart, SankeyChart, BoxplotChart,
10
+ } from 'echarts/charts'
11
+ import {
12
+ DatasetComponent, GridComponent, LegendComponent, MarkLineComponent, MarkPointComponent,
13
+ MarkAreaComponent, TitleComponent, TooltipComponent, PolarComponent, RadarComponent,
14
+ VisualMapComponent, DataZoomComponent, TransformComponent,
15
+ } from 'echarts/components'
16
+
17
+ /** THE SUPPORTED SURFACE - docs/slides.md and the doctrine list exactly this.
18
+ * Series: bar, line, pie, scatter, radar, gauge, heatmap, funnel, treemap,
19
+ * sunburst, sankey, boxplot. Components: grid, polar, radar, tooltip, legend,
20
+ * title, dataset (+ transform), markLine, markPoint, markArea, visualMap,
21
+ * dataZoom. Anything else in an option is silently dropped by ECharts - add
22
+ * it HERE and to the docs together, never one without the other. */
23
+ core.use([
24
+ SVGRenderer,
25
+ BarChart, LineChart, ScatterChart, PieChart, RadarChart, GaugeChart, HeatmapChart,
26
+ FunnelChart, TreemapChart, SunburstChart, SankeyChart, BoxplotChart,
27
+ GridComponent, PolarComponent, RadarComponent, TooltipComponent, LegendComponent,
28
+ TitleComponent, DatasetComponent, TransformComponent, MarkLineComponent,
29
+ MarkPointComponent, MarkAreaComponent, VisualMapComponent, DataZoomComponent,
30
+ ])
31
+
32
+ export const init = core.init
33
+ export type EChartsInstance = ReturnType<typeof core.init>
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Chart (v1.5) - Apache ECharts, the Diagram way: the author picks the FORM
3
+ * (the ECharts option surface, pointed at from instructions/slides.md);
4
+ * marver injects the house theme and strips author styling drift where it
5
+ * breaks the deck (animation at rest, above all).
6
+ *
7
+ * SVG renderer ONLY - a canvas-rendered chart would pin its frame live on
8
+ * the board (the lean-DOM serializer keeps <canvas> frames degraded). At
9
+ * rest the chart renders its final state (animation force-disabled); in
10
+ * slides mode (useSlidePlay) it plays its entrance once on mount.
11
+ *
12
+ * echarts is a real dependency loaded through a dynamic import, so it
13
+ * splits into its own lazy chunk: canvases without charts ship zero echarts
14
+ * bytes.
15
+ */
16
+ import { useEffect, useRef, useState, useSyncExternalStore } from 'react'
17
+ import { FONT_STACK } from './palette.ts'
18
+ import { useSlidePlay } from './slide.tsx'
19
+
20
+ type Engine = typeof import('./chart-engine.ts')
21
+
22
+ let enginePromise: Promise<Engine> | null = null
23
+ const loadEngine = (): Promise<Engine> => (enginePromise ??= import('./chart-engine.ts'))
24
+
25
+ /** Strip every way an option can keep moving at rest: top-level and
26
+ * per-series animation flags, and graphic keyframe animations. Pure and
27
+ * exported - the tests own it. */
28
+ export function sanitizeOption(option: Record<string, unknown>, animate: boolean): Record<string, unknown> {
29
+ const out: Record<string, unknown> = { ...option, animation: animate }
30
+ delete out.graphic // free-floating animated graphics have no place on a slide
31
+ const scrub = (s: unknown): unknown =>
32
+ s && typeof s === 'object'
33
+ ? {
34
+ ...Object.fromEntries(Object.entries(s as Record<string, unknown>).filter(([k]) => !/^animation/.test(k))),
35
+ animation: animate,
36
+ }
37
+ : s
38
+ if (Array.isArray(out.series)) out.series = out.series.map(scrub)
39
+ else if (out.series) out.series = scrub(out.series)
40
+ return out
41
+ }
42
+
43
+ /** The house theme, from the slide tokens at render time (computed style so
44
+ * host overrides and dark scheme are both honored). */
45
+ function houseTheme(el: HTMLElement) {
46
+ const css = getComputedStyle(el)
47
+ const v = (name: string, fb: string) => css.getPropertyValue(name).trim() || fb
48
+ const ink = v('--sl-ink', '#18181b')
49
+ const muted = v('--sl-muted', 'rgba(24,24,27,.55)')
50
+ const accent = v('--sl-accent', '#0088ff')
51
+ const font = v('--sl-font', FONT_STACK) // the deck's family, so chart text matches the slide
52
+ return {
53
+ color: [accent, '#7c5cff', '#00b8a9', '#f0883e', '#d6608c', '#5b8def'],
54
+ textStyle: { fontFamily: font, color: ink },
55
+ axisPointer: { lineStyle: { color: muted } },
56
+ categoryAxis: { axisLine: { lineStyle: { color: muted } }, axisLabel: { color: ink, fontSize: 18 }, splitLine: { show: false } },
57
+ valueAxis: { axisLabel: { color: ink, fontSize: 18 }, splitLine: { lineStyle: { color: v('--sl-grid', 'rgba(127,127,127,.15)') } } },
58
+ legend: { textStyle: { color: ink, fontSize: 18 } },
59
+ tooltip: {
60
+ backgroundColor: v('--sl-ground', '#fff'), borderColor: 'rgba(127,127,127,.25)',
61
+ textStyle: { color: ink, fontFamily: font },
62
+ extraCssText: 'border-radius:12px;box-shadow:0 8px 24px rgba(0,0,0,.14);backdrop-filter:blur(8px)',
63
+ },
64
+ }
65
+ }
66
+
67
+ /** The frame's visual theme (light/dark), observed the same way the play
68
+ * flag is - the stage flips documentElement class/data-theme on sh:set-theme
69
+ * and a themed chart must follow, not stay stale. */
70
+ const subscribeTheme = (cb: () => void) => {
71
+ if (typeof document === 'undefined') return () => {}
72
+ const mo = new MutationObserver(cb)
73
+ mo.observe(document.documentElement, { attributes: true, attributeFilter: ['class', 'data-theme'] })
74
+ return () => mo.disconnect()
75
+ }
76
+ const readTheme = () => (typeof document !== 'undefined' && (document.documentElement.classList.contains('dark') || document.documentElement.dataset.theme === 'dark') ? 'dark' : 'light')
77
+ const useFrameTheme = (): string => useSyncExternalStore(subscribeTheme, readTheme, () => 'light')
78
+
79
+ export function Chart({ option, h = 420 }: { option: Record<string, unknown>; h?: number }) {
80
+ const ref = useRef<HTMLDivElement>(null)
81
+ const play = useSlidePlay()
82
+ const theme = useFrameTheme()
83
+ const [failed, setFailed] = useState(false)
84
+ useEffect(() => {
85
+ const el = ref.current
86
+ if (!el) return
87
+ let disposed = false
88
+ let chart: import('./chart-engine.ts').EChartsInstance | null = null
89
+ void loadEngine().then((engine) => {
90
+ if (disposed || !ref.current) return
91
+ // a theme OBJECT per init - never a stale global registration
92
+ chart = engine.init(ref.current, houseTheme(ref.current), { renderer: 'svg' })
93
+ chart.setOption(sanitizeOption(option, play))
94
+ }).catch(() => setFailed(true))
95
+ return () => { disposed = true; chart?.dispose() }
96
+ // re-init on play flip (the entrance) and on theme flip (fresh tokens)
97
+ }, [option, play, theme])
98
+ if (failed) return <div className="mv-block mv-imgerr"><b>chart unavailable</b><span>echarts failed to load</span></div>
99
+ return <div ref={ref} className="mv-block mv-chart" style={{ width: '100%', height: h }} />
100
+ }