@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.
- package/CHANGELOG.md +73 -0
- package/README.md +41 -19
- package/dist/{build-BxGrHFT2.mjs → build-ByYafIhj.mjs} +37 -5
- package/dist/cli.mjs +19 -5
- package/dist/{daemon-BChkzDqQ.mjs → daemon-Bfucyf1o.mjs} +1 -1
- package/dist/{dev-DLwt3Brb.mjs → dev-yZMyQeUj.mjs} +4 -4
- package/dist/{init-BpitOqRQ.mjs → init-Dvaso7YO.mjs} +66 -2
- package/dist/{manifest-CS6krOTe.mjs → manifest-DvOmglFp.mjs} +7 -0
- package/dist/{marver-id-gate-B2uraTHS.mjs → marver-id-gate-D6By7XHj.mjs} +1 -1
- package/dist/{plugin-DNc4Jpae.mjs → plugin-BsmG5i2X.mjs} +21 -3
- package/dist/{serve-EjEqsiYa.mjs → serve-Bcwfpvhl.mjs} +3 -2
- package/dist/{shot-Cyv3GN79.mjs → shot-kbR_xzJH.mjs} +7 -1
- package/docs/live-jam.md +173 -0
- package/docs/publish.md +270 -0
- package/docs/sharing.md +333 -0
- package/docs/slides.md +134 -0
- package/package.json +3 -1
- package/src/client/const.ts +13 -0
- package/src/client/content/chart-engine.ts +33 -0
- package/src/client/content/chart.tsx +100 -0
- package/src/client/content/index.tsx +25 -4
- package/src/client/content/slide.tsx +238 -0
- package/src/client/content/video.tsx +126 -0
- package/src/client/shell/App.tsx +30 -12
- package/src/client/shell/LockedApp.tsx +7 -2
- package/src/client/shell/Play.tsx +138 -24
- package/src/client/shell/Toolbar.tsx +12 -3
- package/src/client/shell/canvas/FrameNode.tsx +5 -3
- package/src/client/shell/hash.ts +3 -1
- package/src/client/shell/icons.tsx +2 -0
- package/src/client/shell/play-order.ts +22 -0
- package/src/client/shell/store.ts +29 -8
- package/src/client/shell/styles.css +17 -27
- package/src/client/stage/main.tsx +54 -3
- package/src/shared/utm.ts +3 -2
- package/templates/AGENTS-embedded.md +1 -0
- package/templates/AGENTS-studio.md +1 -0
- package/templates/instructions/publish.md +7 -0
- package/templates/instructions/reference/deck-layouts.md +230 -0
- package/templates/instructions/reference/deck-story.md +110 -0
- package/templates/instructions/slides.md +398 -0
package/docs/sharing.md
ADDED
|
@@ -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.
|
|
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",
|
package/src/client/const.ts
CHANGED
|
@@ -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
|
+
}
|