@augurworks/augur 0.15.1
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/CANVAS.md +858 -0
- package/INSTALL.md +603 -0
- package/LICENSE +27 -0
- package/README.md +150 -0
- package/TRADEMARKS.md +72 -0
- package/agents/README.md +65 -0
- package/agents/canon-extract.md +117 -0
- package/agents/canon.md +151 -0
- package/agents/canvas.md +88 -0
- package/agents/currency.md +74 -0
- package/agents/drafts.md +92 -0
- package/agents/identity.md +148 -0
- package/agents/prototype-contract.md +69 -0
- package/agents/publishing.md +324 -0
- package/agents/review-feedback.md +76 -0
- package/agents/space-json.md +132 -0
- package/agents/ui-skill.md +101 -0
- package/agents/working-marks.md +86 -0
- package/brand/augur-eye.svg +3 -0
- package/brand/augur-icon.svg +41 -0
- package/brand/augur-mark.svg +5 -0
- package/brand/lentianova-bulky.otf +0 -0
- package/brand/lentianova-median.otf +0 -0
- package/brand/lentianova.woff2 +0 -0
- package/build.js +8409 -0
- package/changelog.md +133 -0
- package/fonts/inter-latin-wght-normal.woff2 +0 -0
- package/package.json +57 -0
- package/pitis/PETS.md +121 -0
- package/pitis/README.md +117 -0
- package/pitis/index.html +266 -0
- package/pitis/piti.build.js +81 -0
- package/pitis/piti.js +929 -0
- package/pitis/roast-agent.md +185 -0
- package/realtime/src/index.js +119 -0
- package/realtime/wrangler.example.toml +44 -0
- package/scripts/adopt.mjs +106 -0
- package/scripts/board-snapshot.mjs +197 -0
- package/scripts/build-seed-pack.mjs +33 -0
- package/scripts/bundle-rekey.mjs +112 -0
- package/scripts/bundle-tenancy-rehearsal.mjs +781 -0
- package/scripts/canon-extract.mjs +578 -0
- package/scripts/canon.mjs +599 -0
- package/scripts/canvas-screen.mjs +333 -0
- package/scripts/check-local.mjs +136 -0
- package/scripts/clawd-art.mjs +75 -0
- package/scripts/clawd-canvas.mjs +741 -0
- package/scripts/cli.mjs +100 -0
- package/scripts/clone.mjs +422 -0
- package/scripts/close.mjs +30 -0
- package/scripts/connect.mjs +103 -0
- package/scripts/deploy.mjs +157 -0
- package/scripts/dev.mjs +54 -0
- package/scripts/doc-lint.mjs +143 -0
- package/scripts/export.mjs +300 -0
- package/scripts/fork.mjs +64 -0
- package/scripts/freeze.mjs +74 -0
- package/scripts/frontdoor-parity.mjs +180 -0
- package/scripts/hook.mjs +67 -0
- package/scripts/identity-rekey.mjs +112 -0
- package/scripts/init.mjs +122 -0
- package/scripts/land.mjs +40 -0
- package/scripts/lib/adapters.mjs +156 -0
- package/scripts/lib/board-snapshot.mjs +511 -0
- package/scripts/lib/draft.mjs +385 -0
- package/scripts/lib/graduate.mjs +276 -0
- package/scripts/lib/instance.mjs +74 -0
- package/scripts/lib/marks.mjs +107 -0
- package/scripts/lib/materialize.mjs +138 -0
- package/scripts/lib/merge3.mjs +124 -0
- package/scripts/lib/offline-posture.mjs +120 -0
- package/scripts/lib/offline-respawn.mjs +18 -0
- package/scripts/lib/offline-wrangler.mjs +78 -0
- package/scripts/lib/publish-compose.mjs +12 -0
- package/scripts/lib/publish-conflict.mjs +78 -0
- package/scripts/lib/publish-evidence.mjs +144 -0
- package/scripts/lib/refine-compare.mjs +140 -0
- package/scripts/lib/refine-ledger.mjs +150 -0
- package/scripts/lib/refine-png.mjs +149 -0
- package/scripts/lib/seed-pack-build.mjs +141 -0
- package/scripts/lib/state-compare.mjs +68 -0
- package/scripts/lib/store.mjs +122 -0
- package/scripts/login.mjs +84 -0
- package/scripts/mark.mjs +112 -0
- package/scripts/migrate-board-keys.mjs +164 -0
- package/scripts/migrate.mjs +265 -0
- package/scripts/no-foreign-vocabulary.mjs +216 -0
- package/scripts/no-product-names.mjs +157 -0
- package/scripts/no-tenant-globals.mjs +1134 -0
- package/scripts/offline.mjs +247 -0
- package/scripts/og.mjs +170 -0
- package/scripts/one-tenant-resolver.mjs +146 -0
- package/scripts/open.mjs +63 -0
- package/scripts/publish.mjs +1041 -0
- package/scripts/read.mjs +31 -0
- package/scripts/refine.mjs +455 -0
- package/scripts/release-drift.mjs +138 -0
- package/scripts/restore.mjs +322 -0
- package/scripts/review.mjs +125 -0
- package/scripts/save.mjs +21 -0
- package/scripts/shell-lint.mjs +189 -0
- package/scripts/ship.mjs +460 -0
- package/scripts/shoot.mjs +264 -0
- package/scripts/smoke-invite.mjs +113 -0
- package/scripts/state-inventory.mjs +223 -0
- package/scripts/status.mjs +177 -0
- package/scripts/sync.mjs +27 -0
- package/scripts/tenant-do-rehearsal.mjs +1314 -0
- package/scripts/ui-copy-lint.mjs +202 -0
- package/scripts/watch.mjs +32 -0
- package/scripts/wrangler-preflight.mjs +246 -0
- package/seed/CANON.md +43 -0
- package/seed/README.md +119 -0
- package/seed/prototype-status.json +9 -0
- package/seed/registry.json +88 -0
- package/seed/skills/starter-ui/SKILL.md +52 -0
- package/seed/skills/starter-ui/skill.json +11 -0
- package/seed/skills/starter-ui/starter-tokens.css +73 -0
- package/seed/skills/starter-ui/starter-ui.css +318 -0
- package/seed/skills/starter-ui/starter-ui.js +50 -0
- package/seed/space.json +5 -0
- package/seed/start-here/prototypes/connect-your-terminal/index.html +428 -0
- package/seed/start-here/prototypes/connect-your-terminal/preview.webp +0 -0
- package/seed/start-here/prototypes/sample-with-comments/index.html +172 -0
- package/seed/start-here/prototypes/sample-with-comments/preview.webp +0 -0
- package/seed/start-here/prototypes/set-up-your-design-system/index.html +353 -0
- package/seed/start-here/prototypes/set-up-your-design-system/preview.webp +0 -0
- package/seed/threads.json +52 -0
- package/seed/worked-examples/prototypes/field-readings/index.html +273 -0
- package/seed/worked-examples/prototypes/field-readings/preview.webp +0 -0
- package/seed/worked-examples/prototypes/slide-deck/index.html +214 -0
- package/seed/worked-examples/prototypes/slide-deck/preview.webp +0 -0
- package/seed/worked-examples/prototypes/specimen-viewer/index.html +203 -0
- package/seed/worked-examples/prototypes/specimen-viewer/preview.webp +0 -0
- package/src/_worker.js +12668 -0
- package/src/board-key.mjs +57 -0
- package/src/board-room.mjs +881 -0
- package/src/bundle-keys.mjs +138 -0
- package/src/canon/collect-in-browser.js +215 -0
- package/src/canon/emit.mjs +232 -0
- package/src/canon/observe.mjs +260 -0
- package/src/canon/schema.mjs +451 -0
- package/src/canvas/DSEG-LICENSE.txt +95 -0
- package/src/canvas/DSEG7Classic-Bold.woff2 +0 -0
- package/src/canvas/canvas.css +885 -0
- package/src/canvas/canvas.js +5780 -0
- package/src/canvas/capture.js +485 -0
- package/src/chrome/appchrome.mjs +568 -0
- package/src/currency.mjs +203 -0
- package/src/delete-confirmation.mjs +254 -0
- package/src/drafts/drafts.js +326 -0
- package/src/entry.js +58 -0
- package/src/galleries.mjs +400 -0
- package/src/health-cron.mjs +173 -0
- package/src/identity.json +1 -0
- package/src/kv-codec.mjs +135 -0
- package/src/kv-identity.mjs +259 -0
- package/src/mail.mjs +599 -0
- package/src/provenance.mjs +74 -0
- package/src/publish-compose.mjs +363 -0
- package/src/publish-fork.mjs +221 -0
- package/src/publish-units.mjs +44 -0
- package/src/purge.mjs +81 -0
- package/src/review/COMMENTING-UX.md +306 -0
- package/src/review/cat.png +0 -0
- package/src/review/comment-cursor.svg +7 -0
- package/src/review/comments.js +1738 -0
- package/src/room-ticket.mjs +96 -0
- package/src/roster-chip.mjs +32 -0
- package/src/seed-pack.mjs +261 -0
- package/src/state-inventory.mjs +249 -0
- package/src/sw-logic.mjs +48 -0
- package/src/tenant-cache.mjs +141 -0
- package/src/tenant-context.mjs +386 -0
- package/src/tenant-do.js +2940 -0
- package/src/tenant-host.mjs +144 -0
- package/src/tenant-quotas.mjs +140 -0
- package/src/unit-core.mjs +103 -0
- package/src/unit-object.mjs +338 -0
- package/templates/README.md +67 -0
- package/templates/shell/deploy.yml +145 -0
- package/templates/shell/engine-bump.yml +142 -0
- package/templates/shell/health.yml +233 -0
- package/templates/shell/kv-backup.yml +201 -0
- package/templates/shell/roster-update.yml +110 -0
- package/templates/shell/space-preflight.yml +35 -0
- package/templates/shell/space-rebake.yml +79 -0
- package/templates/shell/store-backup.yml +152 -0
- package/templates/shell/wrangler.example.toml +266 -0
- package/templates/space/publish.yml +91 -0
package/CANVAS.md
ADDED
|
@@ -0,0 +1,858 @@
|
|
|
1
|
+
# Canvas — infinite-canvas boards
|
|
2
|
+
|
|
3
|
+
The shared engine every canvas board mounts (`/__canvas/canvas.js`): a
|
|
4
|
+
whiteboard-grade infinite canvas — stickies, 16 shapes + connectors, rich text,
|
|
5
|
+
sections, tables, stamps, images, freehand drawing, and **live prototype tiles**
|
|
6
|
+
— fully multiplayer (colored cursors, presence, follow mode, co-typing), with
|
|
7
|
+
agents able to join a board's room as the Clawd mascot.
|
|
8
|
+
|
|
9
|
+
This file has two audiences. The **top half is the agent/consumer contract** —
|
|
10
|
+
node schemas, `/__board`, `/__asset`, the LWW write rule, the co-work protocol —
|
|
11
|
+
what you need to drive a board. The bottom half, **"Engine internals"**, is the
|
|
12
|
+
implementation forensics for editing `canvas.js` itself. The `canvas.js` header
|
|
13
|
+
carries a section MAP.
|
|
14
|
+
|
|
15
|
+
Prototypes are authored in the terminal, not typed into an in-canvas generator —
|
|
16
|
+
the canvas is where prototypes live and are arranged, not where they're written.
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
# The agent / consumer contract
|
|
21
|
+
|
|
22
|
+
## The model: a canvas is a file, not a feature
|
|
23
|
+
|
|
24
|
+
A canvas instance IS a prototype: it lives in `<opportunity>/prototypes/<name>/`,
|
|
25
|
+
ships, and gates exactly like any prototype — no new publishing path, no
|
|
26
|
+
special-casing in `build.js`. Its `index.html` is a short loader
|
|
27
|
+
(`window.GV_CANVAS = {name}` + the two engine tags); the engine is a shared
|
|
28
|
+
asset, so every board upgrades centrally. The one seam: the **file** ships
|
|
29
|
+
read-only (the template, versioned with the work), but the **contents**
|
|
30
|
+
(stickies, positions, dropped tiles) are live state in KV keyed by the file's URL
|
|
31
|
+
— the same generic per-URL persist rail comments use. The file stays a file; only
|
|
32
|
+
its memory is server-side.
|
|
33
|
+
|
|
34
|
+
## AI-legible by design
|
|
35
|
+
|
|
36
|
+
Co-work is tractable because the board is **structured data, not pixels** — Claude
|
|
37
|
+
reads the scene graph. Three things make it work: **structured nodes** (`{id,
|
|
38
|
+
type, x, y, w, h, …}`, so geometry = meaning: proximity = grouping, columns =
|
|
39
|
+
sequence); **named nodes** (every node renameable → a shared vocabulary, so you
|
|
40
|
+
refer by *name* — "move the onboarding tile next to voting" — not by pointing;
|
|
41
|
+
unnamed nodes fall back to an auto-name or their text); and **tile-to-source
|
|
42
|
+
refs** (a tile names its prototype by path, so Claude reads the real source, not a
|
|
43
|
+
thumbnail). The **collaboration skill** then follows: resolve "that", describe the
|
|
44
|
+
board back spatially, cluster/summarise/connect on request, don't tidy away
|
|
45
|
+
meaningful mess.
|
|
46
|
+
|
|
47
|
+
## Node schemas — the write-side reference (agents: this is your contract)
|
|
48
|
+
|
|
49
|
+
`doc.nodes` is an ARRAY of plain node objects (a full-state `POST /__board` with a
|
|
50
|
+
keyed map is refused as `bad-input`). Positions/sizes are WORLD px.
|
|
51
|
+
Every node gets `id` (any unique string; the engine uses `n<rand>`) and should
|
|
52
|
+
get a human `name`. Omitted optional fields take defaults. Write nodes through
|
|
53
|
+
the daemon's `upsert` (or `GVCanvas.addNode` in-page) — never a raw board POST
|
|
54
|
+
while people are on the board.
|
|
55
|
+
|
|
56
|
+
**Versions (agents MUST respect this):** every node also carries `v` (int) + `vn`
|
|
57
|
+
(random tiebreak), and the room applies ops under version-checked LWW — an update
|
|
58
|
+
that doesn't OUT-version the room's copy bounces (a corrective comes back).
|
|
59
|
+
Creating a node: leave `v`/`vn` off, the engine/room stamps it. Updating one
|
|
60
|
+
through the proper doors (`ClawdCanvas.upsert/del/rename`, `GVCanvas` mutations,
|
|
61
|
+
the in-page editor) bumps automatically — do nothing. Hand-rolling ops or a
|
|
62
|
+
**full-state seed over an existing board**: bump each modified node's `v` above
|
|
63
|
+
the value you read (and randomize `vn`), or the room treats your write as a stale
|
|
64
|
+
echo and keeps its own copy. Deleted nodes leave tombstones in `doc.tombs` —
|
|
65
|
+
recreating an id needs a `v` above the tomb's.
|
|
66
|
+
|
|
67
|
+
| type | required | optional (what it means) |
|
|
68
|
+
|------|----------|--------------------------|
|
|
69
|
+
| `sticky` | `x y` | `w h` (220×220) · `text` · `rich` (see below) · `color` (pastel bg) · `author` · `fontSize` px (a CEILING — the text shrinks below it to fit) · `bold` · `align` · `hFixed` (true = height pinned by a manual resize; omit = hugs its text) |
|
|
70
|
+
| `text` | `x y` | `text` · `rich` · `w` (none = hug/`max-content`; set = fixed width + wrap) · `fontSize` · `color` · `bold italic strike` · `align` |
|
|
71
|
+
| `shape` | `shape x y` | `w h` (per-shape default) · `text`/`rich` (centered) · `color` (fill). Shapes: square round circle diamond triangle triangle-down pill cylinder bubble star hexagon pentagon parallelogram trapezoid plus arrow-right |
|
|
72
|
+
| `image` | `x y src` | `w h` · `name` · `desc` (one line — the claim the image makes; see the description contract) · `crop` `{x,y,w,h}` as FRACTIONS of the full src · `alpha` (true = cut-out PNG, rendered without the card background; auto-probed on load when omitted). `src` = any **same-origin path**: `/__asset/<hash>` for uploads or a path to an image committed in the space repo (`/…/img/04-method.jpg`, how hand- and agent-built boards usually do it). Data URLs are legacy-render-only, don't write new ones |
|
|
73
|
+
| `tile` | `x y url` | `w h` (420×300) · `name` · `device` desktop\|tablet\|phone (viewport the iframe renders at) · `liveUrl` (in-frame navigation, room-managed) · `viewAt` (framed scroll view, room-managed) |
|
|
74
|
+
| `arrow` | `x1 y1 x2 y2` | `kind` arrow\|elbow\|curved\|line (default arrow) |
|
|
75
|
+
| `draw` | `x y points` | `mode` marker\|highlighter\|tape · `color` · `size` stroke px · `w h` bbox · `points` = [[dx,dy],…] RELATIVE to x,y |
|
|
76
|
+
| `section` | `x y` | `w h` · `name` · `color` · `locked` "all" (contents inert) \| "bg" (bg only). Dragging a section carries every node whose CENTRE is inside |
|
|
77
|
+
| `table` | `x y` | `rows cols` (2×2) · `w h` · `cells` = { "r-c": text } (zero-based, e.g. "0-1") |
|
|
78
|
+
| `stamp` | `stamp x y` | `w h` (46×46). Stamps: thumbs-up +1 star question thumbs-down sticker heart, plus **`avatar`** — the face stamp, which also carries `src` (the stamper's `/__avatar/<key>` path), `name` and `color` (the initials chip it falls back to when there's no photo). A face stamp shows WHOEVER STAMPED IT, so those three travel on the node. (`laugh` is a retired key that still paints, for boards made before the face slot.) |
|
|
79
|
+
|
|
80
|
+
**Every box node also accepts `rot`** — degrees, normalised to (−180, 180], clockwise,
|
|
81
|
+
about the box centre. It is a plain CSS transform on the node host, so children (a
|
|
82
|
+
sticky's text, an image, a live tile's iframe) come along and hit-testing follows. `x y w
|
|
83
|
+
h` stay the UNROTATED box; a node's occupied rect is the axis-aligned box of the rotated
|
|
84
|
+
one (`nodeRect`). Not applicable to `arrow` (it has endpoints, not a box) or `section` (a
|
|
85
|
+
container whose contents would not follow). Drag just outside any corner handle to rotate
|
|
86
|
+
(Shift = 15° detents, double-click the pad to straighten); stamps land with a few degrees
|
|
87
|
+
of tilt of their own. `rot` is a GEO_KEY, so a peer's rotation rides the geometry
|
|
88
|
+
fast-path — write `0` to straighten a node, never delete the field.
|
|
89
|
+
|
|
90
|
+
**Rich text (`node.rich`)** — sanitized HTML, whitelist: `b strong i em u s strike
|
|
91
|
+
del br div p ul ol li span`, ALL attributes stripped. One `<div>` per line; lists
|
|
92
|
+
as `<ul>/<ol>` runs, nesting = lists inside `<li>`. When you write `rich`, ALSO
|
|
93
|
+
write `text` as the plain `innerText` equivalent (it names the node and is the
|
|
94
|
+
no-rich fallback). **Unknown tags are UNWRAPPED — their text is kept, the tag
|
|
95
|
+
dropped**; `<script>`/`<style>` and every attribute are removed outright. This
|
|
96
|
+
sanitizer is the XSS gate for a doc that round-trips through shared KV and the
|
|
97
|
+
room socket — don't fight it.
|
|
98
|
+
|
|
99
|
+
**Opaque nodes — the description contract.** `image` and `tile` are the only node
|
|
100
|
+
types whose meaning isn't recoverable from the doc, so an agent can't tell whether
|
|
101
|
+
one is worth opening without opening it:
|
|
102
|
+
|
|
103
|
+
- **Images: write `desc` on the node** — one line stating the CLAIM the image
|
|
104
|
+
makes ("today's builder: type palette left, questions as grey rows, no
|
|
105
|
+
preview"), not what it looks like. `/__asset` images are immutable, so a desc
|
|
106
|
+
written once never goes stale. Whoever uploads writes it. Agent-facing only,
|
|
107
|
+
rendered nowhere.
|
|
108
|
+
- **Tiles: never cache prototype meaning on the node — the PROTOTYPE owns it**, as
|
|
109
|
+
a one-line `<meta name="description">` in its own `index.html`, updated in the
|
|
110
|
+
same commit that changes the prototype. To read them: `/__canvas/catalog.json`
|
|
111
|
+
carries `desc` for every top-level prototype; canvas-owned screens aren't in the
|
|
112
|
+
catalog — fetch the tile's `url` (same-origin, cheap) or run `canvas-screen.mjs
|
|
113
|
+
ls`, which flags opaque screens and desc-less images.
|
|
114
|
+
- **The rubric is one line:** *state the claim the node makes.* A board that needs
|
|
115
|
+
special instructions gets an "Agents: …" sticky on the board itself.
|
|
116
|
+
- **Triage + disclose:** descriptions exist so you can judge what's worth opening
|
|
117
|
+
— but before writing conclusions next to tiles you haven't opened, either open
|
|
118
|
+
them or SAY what you skipped. Silent skipping is the failure that created this
|
|
119
|
+
rule.
|
|
120
|
+
|
|
121
|
+
## Reading & writing the board — `/__board`
|
|
122
|
+
|
|
123
|
+
`boardApi` (`src/_worker.js`) serves the KV mirror at `GET/POST
|
|
124
|
+
/__board?path=<url>` (KV key `board:<path>`, 20MB cap). **PUBLIC route** — a
|
|
125
|
+
canvas is a published prototype, no login to load.
|
|
126
|
+
|
|
127
|
+
- **GET** → `{doc}` (or `{doc: null}` if never saved). `doc` is the board document
|
|
128
|
+
— `{name, nameV, nodes, tombs, clock}` as the room writes it, or the client's
|
|
129
|
+
`{v, name, view, nodes, tombs}` from a solo POST. Either way `nodes` and `tombs`
|
|
130
|
+
live INSIDE `doc`; there is no top-level `id` and no `focus`.
|
|
131
|
+
- **POST** full-state — the body MUST be the doc envelope `{"doc": {…, "nodes":
|
|
132
|
+
[…]}}`. A bare node array, or any body whose `doc.nodes` isn't an array, gets a
|
|
133
|
+
400. This is the **solo fallback** only (see Multiplayer); the KV write is the
|
|
134
|
+
mirror the room reconciles on its next cold load.
|
|
135
|
+
|
|
136
|
+
**The one write rule that bites:** on an EXISTING board, a hand-rolled full-state
|
|
137
|
+
POST must bump each modified node's `v` above what you read — the room folds the
|
|
138
|
+
mirror in **per-node, version-ruled**, so an un-bumped write reads as a stale echo
|
|
139
|
+
and is dropped. Prefer live per-node ops (below) over full-state writes.
|
|
140
|
+
|
|
141
|
+
## Images — `/__asset`
|
|
142
|
+
|
|
143
|
+
`assetApi` stores canvas images content-addressed and OUTSIDE the doc, so a doc
|
|
144
|
+
write never carries image bytes:
|
|
145
|
+
|
|
146
|
+
- **POST `/__asset`** with the image body; content type must be
|
|
147
|
+
`image/jpeg|png|webp|gif` (else **415**), 4MB hard cap (else **413**). Returns
|
|
148
|
+
`{url: "/__asset/<40-hex-hash>"}`. Identical bytes dedup to the same hash (no
|
|
149
|
+
duplicate write).
|
|
150
|
+
- **GET `/__asset/<40-hex-hash>`** → the bytes, `immutable` cache. Old boards with
|
|
151
|
+
inline data-URL `src`s still render; don't write new ones.
|
|
152
|
+
|
|
153
|
+
## Created canvases (the in-app "+ New canvas" button)
|
|
154
|
+
|
|
155
|
+
A canvas needs no repo scaffold to be *born*: the loader is generic and the
|
|
156
|
+
contents live in KV anyway. Folder index pages (Playground + each project folder)
|
|
157
|
+
carry a "+ New canvas" button — signed-in users only (`/__me` reveals it) — that
|
|
158
|
+
registers `<dir><slug>/` in the `canvases` KV map and navigates there. The worker
|
|
159
|
+
serves the standard loader at any registered path (`virtualCanvas` in
|
|
160
|
+
`src/_worker.js`), so the page exists the moment it's named.
|
|
161
|
+
|
|
162
|
+
`canvasesApi` (`POST /__canvases`) ops:
|
|
163
|
+
|
|
164
|
+
- `{dir, name}` — create: slugs `name`, refuses to shadow a real shipped file
|
|
165
|
+
(any non-404 at the target URL) or the site root; dirs are slug-segment paths
|
|
166
|
+
only. Returns `{map, path}`.
|
|
167
|
+
- `{path, rename: true, name}` — rename in place: the display name changes, the
|
|
168
|
+
path (and its board doc) stays. Returns `{map}`.
|
|
169
|
+
- `{path, remove: true}` — unregister: the board doc (`board:<path>`) is left in
|
|
170
|
+
KV, so recreating the same name restores the board. Returns `{map}`.
|
|
171
|
+
- **GET `/__canvases`** → `{map}`.
|
|
172
|
+
|
|
173
|
+
Created canvases are **public**, same obscure-share-link model as published
|
|
174
|
+
prototypes (boards under an admin-only space stay sealed — `isRestrictedPath`
|
|
175
|
+
runs first). Materializing — committing the loader at the matching repo path and
|
|
176
|
+
removing the registry entry — promotes a board to a real repo file; contents
|
|
177
|
+
carry over untouched (the doc is keyed by URL, which doesn't change). The registry
|
|
178
|
+
is one KV key (`canvases`): one get per folder-page view and per 404.
|
|
179
|
+
|
|
180
|
+
## Canvas-owned prototypes ("build a prototype on the canvas")
|
|
181
|
+
|
|
182
|
+
A canvas is a **container of prototypes**. "Build a prototype on this canvas"
|
|
183
|
+
means: author it the normal way (in the terminal — a real static HTML/JS
|
|
184
|
+
prototype), scaffolded into a SUBFOLDER of the canvas
|
|
185
|
+
(`<space>/<opp>/prototypes/<canvas>/<slug>/index.html` → ships at
|
|
186
|
+
`/<opp>/<canvas>/<slug>/`; `build.js` copies the canvas folder recursively, so
|
|
187
|
+
nested screens ship and do NOT appear as separate opportunity cards). A tile is
|
|
188
|
+
auto-placed on the board. The screen is **owned by the canvas**: removing it
|
|
189
|
+
deletes the folder (contrast a tile added via the picker pointing at a pre-existing
|
|
190
|
+
prototype — removing that just unlinks).
|
|
191
|
+
|
|
192
|
+
**The tool:** `node scripts/canvas-screen.mjs`
|
|
193
|
+
|
|
194
|
+
- `add <canvasUrl> <slug> [--title T] [--desc "one-line claim"]` — create the
|
|
195
|
+
subfolder (+ a starter `index.html` with the `<meta name="description">`
|
|
196
|
+
scaffolded) and place the tile. Then write the real prototype into that
|
|
197
|
+
index.html and commit + push.
|
|
198
|
+
- `dup <canvasUrl> <srcSlug> <newSlug> [--title T] [--tile name]` — **fork** an
|
|
199
|
+
owned screen: copy the folder and repoint the duplicate tile at it. Cmd+D in the
|
|
200
|
+
canvas clones only the TILE (named "… copy", pointing at the SAME folder — the
|
|
201
|
+
browser can't write git), so **any agent asked to change a duplicate must run
|
|
202
|
+
`dup` FIRST** (it repoints the "… copy" tile, or `--tile <name>`, at the fork),
|
|
203
|
+
then edit the fork and commit + push. Never edit a folder two tiles share unless
|
|
204
|
+
the change is meant for both.
|
|
205
|
+
- `rm <canvasUrl> <slug>` — remove the tile AND delete the folder. Commit + push.
|
|
206
|
+
- `gc <canvasUrl>` — the browser half of the remove coupling: deleting a tile in
|
|
207
|
+
the canvas UI can't delete the folder, so `gc` deletes orphaned folders after a
|
|
208
|
+
**1-hour grace** (covers a ⌘Z), anchored at the first gc run that noticed the
|
|
209
|
+
orphan. Run it whenever you start working a canvas; commit + push if it deletes.
|
|
210
|
+
Grace state: `.canvas-gc.json` at the workspace root (local only).
|
|
211
|
+
- `ls <canvasUrl>` — reconcile view: nested screen folders vs board tiles; flags
|
|
212
|
+
orphaned folders, dangling tiles, and opaque nodes (screens with no meta
|
|
213
|
+
description, images with no `desc`).
|
|
214
|
+
|
|
215
|
+
It talks to two stores: **files** (space repo — you commit + push) and the
|
|
216
|
+
**board**. Board writes go **THROUGH THE ROOM**, not a raw KV overwrite:
|
|
217
|
+
`mutateBoard` opens the board as a `ClawdCanvas` room client, diffs the desired
|
|
218
|
+
doc against the live one, and sends the difference as per-node `upsert`/`del` ops
|
|
219
|
+
(the room folds them in and persists; when the room is empty the one-shot client
|
|
220
|
+
seeds from KV and flushes on disconnect). The ownership coupling is enforced here
|
|
221
|
+
(terminal) because a canvas can't write git from the browser.
|
|
222
|
+
|
|
223
|
+
## Working on the canvas (co-work live as Clawd)
|
|
224
|
+
|
|
225
|
+
**DEFAULT WORKING MODE:** when asked to change a board (add / move / retitle /
|
|
226
|
+
arrange / delete nodes), **join the board's multiplayer room as a real
|
|
227
|
+
participant and stream per-node ops** — NOT a full-state `POST /__board`. The
|
|
228
|
+
human then sees your Clawd cursor move, a focus ring on the node you touch, and
|
|
229
|
+
each edit land live; per-node LWW means you never clobber their concurrent work.
|
|
230
|
+
Do this unless prompted otherwise.
|
|
231
|
+
|
|
232
|
+
- **The tool:** `scripts/clawd-canvas.mjs` (`ClawdCanvas`, a raw-WS Node client —
|
|
233
|
+
needs **Node 22+** for the global WebSocket). `const c = new
|
|
234
|
+
ClawdCanvas({boardPath}); await c.connect();` then: `moveCursorTo(x,y)` (glides
|
|
235
|
+
so the human sees Clawd walk), `pose('thinking'|'sparkles'|'happy'|'sleeping'|
|
|
236
|
+
'love'|'sunglasses'|'idle')`, `focus(id)`/`focus(null)`, `upsert(node)`/
|
|
237
|
+
`del(id)`/`rename(name)`, `save()` (no-op while connected — the ROOM persists;
|
|
238
|
+
it POSTs `/__board` only as the disconnected fallback), `say(text)`/`unsay()`
|
|
239
|
+
(an ephemeral speech bubble by the cursor — always `unsay()` when done),
|
|
240
|
+
`streamUpsert/streamDel` (generic ephemeral ops), and `stub({x,y,w,h,label})`
|
|
241
|
+
(a persistent "🔨 Clawd is building: …" placeholder section). A section's visible
|
|
242
|
+
label is its **`name`**, not its `text` — so a hand-rolled placeholder must put
|
|
243
|
+
the caption in `name` (`upsert({type:'section', name:'🔨 building…', …})`), or it
|
|
244
|
+
renders blank. It reads the live doc on connect. CLI: `node clawd-canvas.mjs
|
|
245
|
+
probe|demo|chill|daemon <boardPath>`.
|
|
246
|
+
- **Humanized verbs (PREFER these for visible work):** `dragNode(id,x,y)` (walk
|
|
247
|
+
over, grab, node + cursor travel together), `typeNode(node)` (create and TYPE
|
|
248
|
+
word-by-word under the focus ring — conversational scale only, bulk seeding
|
|
249
|
+
stays instant), `sel(ids)` (point with selection rings), `status(text,
|
|
250
|
+
'working'|'idle'|'attention'|'done')` (the state behind your avatar chip in the
|
|
251
|
+
top-right presence row — `attention` rings the chip amber and jumps it = you
|
|
252
|
+
need the human; `done` flashes green; keep it current, it's how the human works
|
|
253
|
+
with the terminal hidden), `chat(text)` (cursor-chat bubble), `follow(name)`/
|
|
254
|
+
`unfollow()`. `dragNode` and `typeNode` are POLITE — they `waitUnheld()` while a
|
|
255
|
+
human has the node. Daemon commands mirror all of these 1:1.
|
|
256
|
+
- **Humans talk back on the board:** "/" opens cursor chat; every line a human
|
|
257
|
+
types is appended to `<cmdfile dir>/clawd-events.jsonl` by the daemon — **read
|
|
258
|
+
that file at the start of a turn** to hear what was said to you.
|
|
259
|
+
|
|
260
|
+
**SHOW ACTIVITY FIRST (the co-working protocol).** The human watches the canvas,
|
|
261
|
+
not your terminal. On ANY ask, your first move is visual, before real work:
|
|
262
|
+
|
|
263
|
+
1. **Thinking:** walk to the relevant section → `pose('thinking')` → `say('reading
|
|
264
|
+
the numbers…')` — *then* read/reason. Update the bubble as focus shifts.
|
|
265
|
+
2. **Building:** walk to where the artifact will land → `stub({...})` → `pose(
|
|
266
|
+
'sparkles')` + `say('building…')` — *then* build. When the real nodes land,
|
|
267
|
+
**`del` the stub — never retitle it into a permanent frame.** A finished
|
|
268
|
+
prototype tile stands ALONE on the board, no section wrapped around it.
|
|
269
|
+
3. **Emotions as punctuation:** `happy` when something lands, `sunglasses` when it
|
|
270
|
+
ships live, `love` when the human likes it, `sleeping` when parked. Statuses
|
|
271
|
+
are the grammar, emotions the punctuation.
|
|
272
|
+
4. `focus(id)` whatever node you're editing; `focus(null)` + `unsay()` when the
|
|
273
|
+
burst ends.
|
|
274
|
+
|
|
275
|
+
**The daemon (staying commandable across turns):** `node clawd-canvas.mjs daemon
|
|
276
|
+
<boardPath> <cmdFile>` — one connection, tails `<cmdFile>` (JSONL; command set in
|
|
277
|
+
the script header), executes in order, mirrors the live doc to `clawd-board.json`.
|
|
278
|
+
Reacting visually costs one `echo '{"cmd":"pose","v":"thinking"}' >> <cmdFile>` —
|
|
279
|
+
do that FIRST, then work. **Launch it DETACHED**, not as a harness-tracked
|
|
280
|
+
background task (task-list cleanups reap tracked daemons and Clawd vanishes):
|
|
281
|
+
|
|
282
|
+
```sh
|
|
283
|
+
nohup node scripts/clawd-canvas.mjs daemon <boardPath> <cmdFile> \
|
|
284
|
+
> <scratchpad>/clawd-daemon.log 2>&1 & echo $! > <scratchpad>/clawd-daemon.pid; disown
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
Identity is **deterministic** — derived from the session's cmd-file path — so do
|
|
288
|
+
NOT pass `--name` (the daemon **refuses a bare `--name`**; it's reserved for
|
|
289
|
+
sibling agents launched with `--sibling`). A detached daemon does NOT die with the
|
|
290
|
+
session — dismiss it explicitly (`{"cmd":"quit"}`, or `kill $(cat
|
|
291
|
+
clawd-daemon.pid)`). Check the pidfile before launching (a live one means YOUR
|
|
292
|
+
previous daemon is up — reuse or quit it; two connections = two cursors). Never
|
|
293
|
+
touch other sessions' daemons.
|
|
294
|
+
|
|
295
|
+
- **Ambient chill (daemon default):** connected-but-idle ≠ frozen. With no
|
|
296
|
+
commands for ~12s and the pose plain `idle`, Clawd fidgets, strolls near the
|
|
297
|
+
human's cursor, the odd happy blip — so presence reads as alive. Any command
|
|
298
|
+
pauses it; explicit poses hold. `{"cmd":"chill","v":false}` turns it off.
|
|
299
|
+
- Work left-to-right / in a sensible order so the human can follow your cursor
|
|
300
|
+
(`board.view` is per-user — you can't pan them).
|
|
301
|
+
- **Park when idle (stay present):** when finished but staying available, **don't
|
|
302
|
+
disconnect** — `pose('sleeping')` in a quiet corner; the daemon (or chill mode)
|
|
303
|
+
holds the process, a 25s keepalive keeps Clawd in the room across turns, and a
|
|
304
|
+
sleeping agent stays fully visible.
|
|
305
|
+
- **When NOT to co-work live:** the **bulk first-seed** of a brand-new board (use
|
|
306
|
+
a full-state seed script while the board is closed), or when told to work
|
|
307
|
+
silently. A full-state seed over an EXISTING board must bump each node's `v` (see
|
|
308
|
+
the write rule above).
|
|
309
|
+
- **⚠️ Test on a throwaway `boardPath` under `/__test/`** — opening a board (or
|
|
310
|
+
connecting a client) joins its REAL room and writes real data. See the isolation
|
|
311
|
+
rule under Multiplayer.
|
|
312
|
+
|
|
313
|
+
**Multi-agent (several Clawds at once):** each session joins with its OWN identity.
|
|
314
|
+
For the session's own Clawd, pass **no** `--name` — it reads the name from the
|
|
315
|
+
session transcript and follows `/rename`; color derives from the name (stable hash
|
|
316
|
+
→ palette; plain "Clawd" = the orange `#d97757`), and the presence chip wears the
|
|
317
|
+
same color. For a **sibling** agent that needs a separate fixed identity, launch
|
|
318
|
+
`node clawd-canvas.mjs daemon <boardPath> <cmdFile> --sibling --name Scout --color
|
|
319
|
+
'#4e8fd9'` — its own daemon, name, color, and command file (use the session's
|
|
320
|
+
scratchpad so they never collide). `--name` PINS identity and turns
|
|
321
|
+
session-following off (right for a sibling, rejected without `--sibling`). Only
|
|
322
|
+
ever `kill` your own daemon — never a blanket `pkill` (that murders siblings).
|
|
323
|
+
`{"cmd":"identity","name":"…"}` forces a rename by hand. ⚠️ `rename` renames the
|
|
324
|
+
**BOARD**, not the agent — identity changes go through `identity`.
|
|
325
|
+
|
|
326
|
+
## Multiplayer — every canvas is a live room
|
|
327
|
+
|
|
328
|
+
**The model.** A `BoardRoom` Durable Object per board path relays cursors,
|
|
329
|
+
presence, node ops, live selections, and editing focus between everyone on that
|
|
330
|
+
board — and **the room OWNS the document**: the doc lives in the DO's own SQLite
|
|
331
|
+
storage (one row per node + a meta row with name/tombstones; strongly consistent;
|
|
332
|
+
survives hibernation), loaded before every welcome, written on every accepted ops
|
|
333
|
+
batch, migrated lazily from KV the first time an older KV-only board is touched.
|
|
334
|
+
Workers KV holds the same doc as a **write-through mirror** (dirty flag → 45s
|
|
335
|
+
alarm → put; last-one-out flushes immediately; failed writes re-arm) serving the
|
|
336
|
+
public GET and the solo fallback, folded back in per-node on every cold load — so
|
|
337
|
+
solo clients and terminal scripts that wrote `/__board` while the room was empty
|
|
338
|
+
are never steamrolled. Ops apply under **version-checked LWW**: a write must
|
|
339
|
+
out-version (`v`, then `vn`) what the room holds; losers get a corrective op back;
|
|
340
|
+
deletions leave tombstones stale upserts can't cross; a `{t:"doc"}` seed is
|
|
341
|
+
reconciled per-node, never adopted wholesale. **`/__test/` rooms are RAM-only —
|
|
342
|
+
they never persist to SQLite or KV; every other path does.** Clients POST
|
|
343
|
+
`/__board` only as the **solo fallback** (socket down or provably stale). Strictly
|
|
344
|
+
an enhancement layer: if the socket can't connect, the canvas behaves exactly as
|
|
345
|
+
solo. Net: a hot multi-person board costs ≤ ~80 KV writes/hour.
|
|
346
|
+
|
|
347
|
+
**The solo rail cannot latch shut and cannot fail silently** (canvas.js, the save
|
|
348
|
+
watchdog): a save POST aborts after 20s instead of hanging forever, the in-flight
|
|
349
|
+
guard is a timestamp that expires rather than a boolean that can stick, and a 5s
|
|
350
|
+
watchdog re-kicks the rail whenever the doc holds unconfirmed edits with no live
|
|
351
|
+
room and nothing in flight, scheduled, or backing off. Once the dirt survives two
|
|
352
|
+
ticks the client shows a floating bottom-right chip — "Changes not saved —
|
|
353
|
+
retrying", or "Offline — changes not saved" when the browser reports no network —
|
|
354
|
+
that flips to a brief green "Saved" when the dirt lands (cleared by the next 2xx
|
|
355
|
+
or a live room). Closing the tab while the warning shows asks first, and
|
|
356
|
+
regaining connectivity kicks a save immediately instead of waiting out a backoff.
|
|
357
|
+
(Born of a measured failure: a dev-server restart hung one save fetch, the old
|
|
358
|
+
boolean guard then suppressed every save for the rest of the session, and an
|
|
359
|
+
hour's board edits existed only on screen.)
|
|
360
|
+
|
|
361
|
+
**The pieces:**
|
|
362
|
+
- `realtime/` — the room worker (BoardRoom DO, WebSocket Hibernation API).
|
|
363
|
+
Deployed **standalone**, NOT via Pages: `npm run deploy:realtime` (Pages can't
|
|
364
|
+
define DO classes). **One worker per instance** — rooms are keyed by board path,
|
|
365
|
+
so two instances sharing a worker would share rooms and storage. The engine
|
|
366
|
+
carries only the code; the worker's name and `BOARD_KV` binding come from the
|
|
367
|
+
shell's `realtime.wrangler.toml` (template `realtime/wrangler.example.toml`),
|
|
368
|
+
and the site finds it through `realtimeOrigin` in `deploy.config.json`. Protocol
|
|
369
|
+
documented at the top of its `index.js`. Redeploy it yourself when you touch it.
|
|
370
|
+
- `src/_worker.js` `rtProxy` — `/__rt` proxies the WebSocket same-origin to that
|
|
371
|
+
worker (no hardcoded URL; works offline too, where it reaches the REAL prod
|
|
372
|
+
rooms — same live-KV-while-offline posture as the overlay data — **but only when
|
|
373
|
+
`.env.deploy` also carries `RT_SHARED_SECRET`**: a secret-gated realtime worker
|
|
374
|
+
403s a proxy without it and the canvas silently degrades to solo). In offline
|
|
375
|
+
SANDBOX mode (no KV creds) the proxy is sealed shut (`GV_RT_DISABLE` → 501), so
|
|
376
|
+
sandbox boards can't half-escape into the shared prod rooms.
|
|
377
|
+
- `src/canvas/canvas.js` "multiplayer" section — the client. **No hooks in the
|
|
378
|
+
mutation paths**: a 120ms diff tick compares each node against a shadow signature
|
|
379
|
+
(long strings collapsed, so image boards stay cheap) and broadcasts
|
|
380
|
+
`upsert/del/name`; applying a remote op writes the shadow FIRST so the tick never
|
|
381
|
+
echoes. Conflicts are per-node LWW; a node you're dragging/editing ignores remote
|
|
382
|
+
writes. `board.view` is per-user, never synced. Geometry-only remote changes
|
|
383
|
+
patch styles on the live element (`.gvc-remote-move` tween) instead of
|
|
384
|
+
re-rendering — smooth drags, no iframe/image churn.
|
|
385
|
+
- **Cursors** — ONE glyph for everybody: the custom arrow from piti mode
|
|
386
|
+
(`pitis/piti.js` CURSOR_SVG), tinted per visitor from the room palette. Your own
|
|
387
|
+
OS pointer wears it too; peers render it with a name pill. Cursor layer lives
|
|
388
|
+
OUTSIDE `#gvc-ui` so ⌘. keeps people visible. Names come from `/__me`, else
|
|
389
|
+
"Guest".
|
|
390
|
+
- **Follow mode** — every client publishes its camera as
|
|
391
|
+
`{t:"view", v:{x,y,s,w,h}}` (throttled ~10/s, change-gated, kept on the socket
|
|
392
|
+
attachment so a fresh follow syncs before the peer next moves). Click a presence
|
|
393
|
+
chip → your camera soft-lerps to *their viewport* (pan AND zoom), tracked live;
|
|
394
|
+
a border + a "Following ‹name›" pill with a Stop button appear in the peer's
|
|
395
|
+
color. Your own pan/zoom/space-drag (or Stop, the chip, the peer leaving, a
|
|
396
|
+
socket drop) breaks it. Agents publish no viewport (a daemon has no window) —
|
|
397
|
+
following one falls back to a centred cursor chase. Following chains just work.
|
|
398
|
+
|
|
399
|
+
**Tiles are ALWAYS LIVE** — there is no ▶ Live/■ Stop control; `node.live` (the
|
|
400
|
+
old shared Stop/Live state) is written nowhere and ignored everywhere. Every tile
|
|
401
|
+
mounts its real iframe when it nears the viewport (IntersectionObserver-gated,
|
|
402
|
+
`MOUNT_BUDGET` LRU backstop unmounts offscreen tiles to their poster), under a
|
|
403
|
+
transparent `.gvc-hit` overlay so it selects/drags like any node. **Double-click a
|
|
404
|
+
tile to interact** (overlay off, blue ring, you drive the prototype); click
|
|
405
|
+
outside or Esc leaves (Esc is caught inside the frame too). Interact mode is
|
|
406
|
+
per-user; what you DO mirrors: prototypes are SAME-ORIGIN, so `mpFrameLoad` hooks
|
|
407
|
+
each frame and clicks/input/scroll/navigation broadcast as ephemeral `{t:"proto"}`
|
|
408
|
+
relays. Navigation also persists as `node.liveUrl` (synced) so late joiners mount
|
|
409
|
+
at the URL you navigated to, and **where in that page you left it persists as
|
|
410
|
+
`node.viewAt`** — scroll a tile while driving it and that becomes the tile's view
|
|
411
|
+
for the board, restored on every later mount (reload, late joiner, LRU remount)
|
|
412
|
+
until the next person scrolls it. Only a driver writes it, it is stamped with the
|
|
413
|
+
page it was taken on (a stale view never lands on another page), and it is kept
|
|
414
|
+
out of the undo stack — an undo re-renders, and re-rendering a tile would reload
|
|
415
|
+
the prototype. Anti-echo = `isTrusted` filter + a 400ms quiet window
|
|
416
|
+
per frame after each replay. A tile can embed ANOTHER canvas (joins its own room
|
|
417
|
+
from inside). Cross-origin tiles safely no-op. Limits: replay is event-level, not
|
|
418
|
+
DOM mirroring — mid-flow SPA state doesn't transfer to late joiners; simultaneous
|
|
419
|
+
drivers fight politely (LWW).
|
|
420
|
+
|
|
421
|
+
**⚠️ Playwright/testing rule (bit on day one, twice).** Blocking `POST
|
|
422
|
+
**/__board` is NOT enough — a test that opens a canvas page ALSO **joins its real
|
|
423
|
+
room and broadcasts ops to real visitors**. Isolate the room by overriding
|
|
424
|
+
`GV_CANVAS.boardPath` to a throwaway `/__test/` path — and because instance HTML
|
|
425
|
+
does `window.GV_CANVAS = {...}` (full overwrite), a plain `addInitScript` value
|
|
426
|
+
gets clobbered: use `Object.defineProperty(window, "GV_CANVAS", ...)` with a
|
|
427
|
+
setter that forces `boardPath` back in, **guarded `if (window.top !== window)
|
|
428
|
+
return;`** — `addInitScript` runs in every frame, and an unguarded override leaks
|
|
429
|
+
into tile iframes, so a canvas-typed prototype embedded in a tile joins the TEST
|
|
430
|
+
room and haunts presence as phantom "Guest" chips (bit #2 — cost an afternoon of
|
|
431
|
+
zombie-hunting). Don't navigate test tiles to canvas-typed prototypes. Reference
|
|
432
|
+
pattern: two browser contexts joined to one ISOLATED room, then mount/interact in
|
|
433
|
+
one and assert the mirror in the other. (Rooms outside `/__test/` are NOT
|
|
434
|
+
self-healing — they write real SQLite + KV.)
|
|
435
|
+
|
|
436
|
+
## Session — the shared timer + music
|
|
437
|
+
|
|
438
|
+
The top-right corner is ONE white card holding the whole room: the presence
|
|
439
|
+
avatars (humans and Clawd agents alike) and a **session inset** (mini record +
|
|
440
|
+
seven-segment time) that opens a *Timer and music* panel. One timer and one track
|
|
441
|
+
per board, the same for everyone; anyone can drive it. Start / pause / resume /
|
|
442
|
+
stop / +1 min, up to 99:59; the digits are editable when idle (`7` → 7:00, `7:30`
|
|
443
|
+
→ 7m30s). **The ending is audible, not visual**: each of the last
|
|
444
|
+
five seconds carries a whole tick-TOCK (a bandpassed noise knock at two pitches,
|
|
445
|
+
260ms apart), a three-note mallet bell closes 00:00 — all synthesized, no
|
|
446
|
+
assets — and then the timer simply REVERTS to the duration it just ran, back
|
|
447
|
+
in its idle state ready to go again. Nothing turns red, nothing shakes, and no
|
|
448
|
+
alarm state is left parked in the corner of everyone's board. An expired countdown
|
|
449
|
+
wires as `null` from the room too, so a late joiner sees that same idle state.
|
|
450
|
+
Both sounds run through the per-user volume/mute (as the music does). The clock renders
|
|
451
|
+
in the DSEG7 font (`/__canvas/DSEG7Classic-Bold.woff2`, SIL OFL); the music section
|
|
452
|
+
is an SVG turntable that spins while playing.
|
|
453
|
+
|
|
454
|
+
**It is not board content.** Session state lives on the room (`ctx.storage` in the
|
|
455
|
+
DO), never in the document — no node, no ops tick, no undo, no KV doc write.
|
|
456
|
+
`{t:"timer",do:…}`/`{t:"music",do:…}` go up; the room broadcasts
|
|
457
|
+
`{t:"session",timer,music}` to everyone (including the sender) and hands it to late
|
|
458
|
+
joiners on `welcome`. Three rules bought with bugs:
|
|
459
|
+
|
|
460
|
+
- **The wire carries REMAINING ms, never a deadline.** Clients stamp arrival with
|
|
461
|
+
`performance.now()` and count down locally — no clock agreement, and cost is per
|
|
462
|
+
*click*, not per second.
|
|
463
|
+
- **No alarm.** The DO's one alarm slot belongs to the KV persist rail; a timer
|
|
464
|
+
borrowing it would silently cancel a pending document write. Expiry is computed
|
|
465
|
+
client-side.
|
|
466
|
+
- **Session mutations are serialized** (`sessQ`): two people hitting `+1 min` in
|
|
467
|
+
the same tick must stack, not overwrite.
|
|
468
|
+
|
|
469
|
+
**Music is a hook, not content — the engine ships no audio.** The picker is built
|
|
470
|
+
from `/__canvas/tracks.json`, accumulated at build time from each space's `tracks/`
|
|
471
|
+
folder (ids namespaced `<space>:<id>`). A space authors `tracks/tracks.json` as
|
|
472
|
+
`[{id,name,file,duration,color?,motif?}]`; `duration` (seconds) lets every client
|
|
473
|
+
seek to the same point. `color` + `motif` (`bird`·`face`·`burst`·`scribble`·
|
|
474
|
+
`gridsun`·`sail`) dress the label, else both derive from the id hash. No tracks →
|
|
475
|
+
the turntable renders grayed + inert; the timer is unaffected. Volume/mute are
|
|
476
|
+
per-user, localStorage, never synced. `tracks/` is gitignored in space repos by
|
|
477
|
+
design (audio you may listen to isn't audio you may redistribute; the space repos
|
|
478
|
+
build a public site). Position-sync gotchas: **seek on the `playing` event** (a
|
|
479
|
+
paused element accepts a seek then sits, baking in an offset); **gate on
|
|
480
|
+
`seekable`** (an origin that doesn't serve byte ranges drops every seek silently —
|
|
481
|
+
`wrangler pages dev` is such an origin, so music position does NOT sync in offline
|
|
482
|
+
preview, but Cloudflare Pages does); dead zone 0.25s; re-run `sessApplyMusic()`
|
|
483
|
+
when the manifest lands (the fetch can lose the race against `welcome`); autoplay
|
|
484
|
+
policy blocks a joiner who hasn't clicked — mark it and let their next click start
|
|
485
|
+
playback.
|
|
486
|
+
|
|
487
|
+
## The plumbing (what's where)
|
|
488
|
+
|
|
489
|
+
- **Engine:** `src/canvas/canvas.js` + `canvas.css`, emitted to `dist/__canvas/`
|
|
490
|
+
by `build.js`, served public via `isPublicPath()`. `capture.js` rides along in
|
|
491
|
+
the same copy step — on NO page; `canvas.js` fetches it by absolute path on the
|
|
492
|
+
first ⌘⇧C.
|
|
493
|
+
- **Board doc:** authoritative copy in the BoardRoom DO's SQLite; worker `boardApi`
|
|
494
|
+
(`/__board`, KV key `board:<path>`, 20MB cap) serves the KV mirror. PUBLIC route.
|
|
495
|
+
- **Images:** worker `assetApi` (`/__asset`), KV `basset:<sha256[0:40]>`, immutable
|
|
496
|
+
cache, dedup on identical bytes.
|
|
497
|
+
- **Room:** `realtime/` — separate worker (BoardRoom DO), `npm run deploy:realtime`.
|
|
498
|
+
- **Comments overlay:** two guarded hooks in `src/review/comments.js`
|
|
499
|
+
(`pinXY`/`anchorAt` prefer `GVCanvas` world coords); the engine dispatches a
|
|
500
|
+
window `scroll` on every transform so the overlay repositions. Pages without
|
|
501
|
+
`GVCanvas` are byte-identical.
|
|
502
|
+
- **Insert-picker catalog:** `build.js` writes `dist/__canvas/catalog.json`
|
|
503
|
+
(prototypes + pages + components across spaces, with poster thumbs).
|
|
504
|
+
- **API:** `window.GVCanvas` = board + `nodes()`/`addNode` + `screenToWorld`/
|
|
505
|
+
`worldToScreen`/`onTransform`/`setTool`.
|
|
506
|
+
|
|
507
|
+
**Dev loop.** `npm --prefix augur run offline` (background) →
|
|
508
|
+
`http://localhost:8788/<opportunity>/<board>/`; watches sibling clones + Augur,
|
|
509
|
+
~1s hot reload. **⚠️ Offline KV is LIVE prod** — board/overlay writes are real.
|
|
510
|
+
`/__canvas/*.js|css|json` is served **`no-cache`** (a revalidation on every use, so
|
|
511
|
+
a reload gets the fresh engine while a 304 still lets the browser reuse its cached
|
|
512
|
+
copy — no stale-JS ghosts, no full re-download). **Ship:** commit + push per repo;
|
|
513
|
+
Augur first (its push auto-deploys via the engine pin bump), then the space repo
|
|
514
|
+
(push saves the page; it goes LIVE via `augur publish`, not the push). Stage only
|
|
515
|
+
your paths. Bump `UI_VERSION` only when you touch `comments.js` / the build shell.
|
|
516
|
+
**Playwright** is available via a sibling space clone's `node_modules/playwright`;
|
|
517
|
+
always block `POST **/__board` and test on a `/__test/` boardPath.
|
|
518
|
+
|
|
519
|
+
---
|
|
520
|
+
|
|
521
|
+
# Engine internals (editing canvas.js only)
|
|
522
|
+
|
|
523
|
+
Everything below is implementation forensics — read it when you're editing the
|
|
524
|
+
engine, not when you're driving a board. Every gotcha here is load-bearing;
|
|
525
|
+
where the anecdote doesn't change what you do, only the rule is kept.
|
|
526
|
+
|
|
527
|
+
## Architecture — three layers, native to Augur's vanilla-JS stack
|
|
528
|
+
|
|
529
|
+
1. **Canvas engine** — one "world" layer, one CSS transform (`translate(x,y)
|
|
530
|
+
scale(z)`); nodes absolutely positioned in world coords; pointer
|
|
531
|
+
pan/drag/select; rAF-batched transform writes. DOM is plenty at this scale
|
|
532
|
+
(tens–low-hundreds of nodes); no WebGL. Virtualize only if boards get big.
|
|
533
|
+
2. **Node registry** — pluggable node types, each `render + serialize`. Every box
|
|
534
|
+
resizes from all four corners + all four edges (a text box has no draggable
|
|
535
|
+
height → e/w edges only) and every text-bearing node takes rich text.
|
|
536
|
+
3. **Board document** — the JSON described in the contract above, in KV/SQLite,
|
|
537
|
+
keyed by URL.
|
|
538
|
+
|
|
539
|
+
## Tiles (the one real performance rule)
|
|
540
|
+
|
|
541
|
+
The infinite canvas is cheap; **live prototypes = iframes are the only heavy
|
|
542
|
+
thing** (mount policy in Multiplayer). Tiles render at a fixed DEVICE viewport
|
|
543
|
+
width (`DEVICE_W`) and CSS-scale to fit (`fitFrame`, `transform-origin: top left`;
|
|
544
|
+
clientWidth/Height are layout px, immune to the world transform). The name chip
|
|
545
|
+
floats above the tile, counter-scaled (`scaleTileChrome`) to read at 12px at any
|
|
546
|
+
zoom. Posters reuse the poster stack (`scripts/shoot.mjs` / `og.mjs`).
|
|
547
|
+
|
|
548
|
+
## Option-drag = duplicate; ⌘D
|
|
549
|
+
|
|
550
|
+
Hold Option and drag a node (or selection) to leave a copy behind (the duplicate-drag
|
|
551
|
+
idiom; the copy cursor shows while Option is held).
|
|
552
|
+
|
|
553
|
+
- **Option is a LIVE modifier, checked continuously for the whole drag — NOT
|
|
554
|
+
decided once at pointerdown.** People press-and-drag, then reach for Option a
|
|
555
|
+
moment later; `altCopySync()` owns the flip and is called from both
|
|
556
|
+
`pointermove` and the Alt keydown/keyup (so it works with the mouse held still).
|
|
557
|
+
- Entering: the copy is born where the drag has GOT TO and the original snaps back
|
|
558
|
+
to its start (the node under the cursor stays under it). Leaving: the original
|
|
559
|
+
takes over from where the copy had got to and the copy is deleted (releasing
|
|
560
|
+
Option early is a clean plain move).
|
|
561
|
+
- **Originals keep their ids** — deep links (`#n=<id>`), comment threads and a
|
|
562
|
+
tile's prototype folder stay attached to the node already there; the new one is
|
|
563
|
+
what you place.
|
|
564
|
+
- Copies are made only once the drag has MOVED (an Option-click must not leave an
|
|
565
|
+
invisible stacked duplicate).
|
|
566
|
+
- Dragging a **section** carries its contents, so Option-drag duplicates the
|
|
567
|
+
section AND children. ⚠️ **⌘D does not** — it duplicates only the selection, so
|
|
568
|
+
⌘D on a section gives an empty section. Known asymmetry, left alone; fix in
|
|
569
|
+
`duplicateSelection` if it bites.
|
|
570
|
+
- After the split, snapping is re-armed on the copies (originals become valid snap
|
|
571
|
+
targets). `cloneNode(n, dx, dy)` is shared with ⌘D and leans on `histClone` for
|
|
572
|
+
the deep-copy of `points`/`cells`/`crop`.
|
|
573
|
+
|
|
574
|
+
## Clipboard (⌘C · ⌘X · ⌘V)
|
|
575
|
+
|
|
576
|
+
The payload rides the **system clipboard**, not a JS variable — that's the whole
|
|
577
|
+
feature. ⌘C serialises the selection to `{tag:"augur.canvas/1", origin:<board
|
|
578
|
+
path>, nodes:[…]}` as `text/plain`; ⌘V is a `paste` event listener (the only way
|
|
579
|
+
to read clipboardData without a permission prompt).
|
|
580
|
+
|
|
581
|
+
- **Images cross for free** — an image node's `src` is an absolute `/__asset/<hash>`
|
|
582
|
+
path, so a URL travels, not pixels. A **different origin** does NOT work
|
|
583
|
+
(`/__asset` is per-site — the node would 404). Deliberately unsolved.
|
|
584
|
+
- ⌘V also accepts non-canvas clipboards: an image → the same compress + `/__asset`
|
|
585
|
+
upload a drop gets; plain text → a text node.
|
|
586
|
+
- **Paste lands centred on the pointer**, not at source coords (which mean nothing
|
|
587
|
+
on another board). Repeat-paste without moving walks copies diagonally.
|
|
588
|
+
- ⌘X deletes originals only **after** the clipboard write resolves.
|
|
589
|
+
- **A pasted node is REBUILT field by field, never spread in** (`clipSanitize`).
|
|
590
|
+
The clipboard is untrusted input that goes into shared KV and out over the room
|
|
591
|
+
socket — a bad paste is stored XSS for the whole board. Known type, fresh id,
|
|
592
|
+
numbers coerced, enums whitelisted, `rich` through `sanitizeRich`, **`color`
|
|
593
|
+
hex-only** (renderShape/renderDraw concatenate it into innerHTML), `image.src` /
|
|
594
|
+
`tile.url` held to **same-origin paths** — no scheme, no protocol-relative
|
|
595
|
+
`//host` (a tile is an iframe). ⚠️ Validate `src` acceptance against a LIVE board
|
|
596
|
+
(`curl '<site>/__board?path=<path>'`), not the schema doc: most real images are
|
|
597
|
+
repo paths, and an over-strict `/__asset`-only rule silently drops every node.
|
|
598
|
+
- ⚠️ **`sanitizeRichEl` parses in a DOMParser document, on purpose.** Assigning
|
|
599
|
+
untrusted markup to a live element's `innerHTML` — even detached — loads its
|
|
600
|
+
resources immediately, so `<img src=x onerror=…>` fires before the strip-walk
|
|
601
|
+
reaches it. Parse inert, clean, then hand the cleaned markup to a live element.
|
|
602
|
+
Don't "simplify" this back to `box.innerHTML = html`.
|
|
603
|
+
|
|
604
|
+
## Copy as PNG (⌘⇧C)
|
|
605
|
+
|
|
606
|
+
A picture of the selection straight onto the clipboard (also the camera button on
|
|
607
|
+
the single-selection toolbar). It **re-renders** the nodes rather than grabbing the
|
|
608
|
+
screen: 2x the node's NATURAL size regardless of `board.view.scale`, exact bounds,
|
|
609
|
+
no permission prompt. Rasterizer: `nodesToPng({els, rect, scale, background,
|
|
610
|
+
poster, onInfo})` in `src/canvas/capture.js`, lazy-loaded from
|
|
611
|
+
`/__canvas/capture.js` on first use. SCREENSHOT semantics (not a cut-out on transparency):
|
|
612
|
+
the frame is the selection box + 12px bleed, holding everything visible in that
|
|
613
|
+
rectangle; selection/resize/hit/focus chrome is stripped, but tile/section/image
|
|
614
|
+
labels are kept (rendered at 100%-zoom, the box GROWS upward to hold them). Three
|
|
615
|
+
composited layers so one bad node degrades instead of killing the shot: paper + dot
|
|
616
|
+
grid native; nodes cloned into a mini-world at scale 1 through `<foreignObject>`;
|
|
617
|
+
each live tile in its OWN pass (a framed page's stylesheet would leak; node passes
|
|
618
|
+
cut at each tile so z-order holds). Per-tile fallback: live frame → poster →
|
|
619
|
+
placeholder. Clipboard failure downloads the PNG; ~40MP cap halves the scale.
|
|
620
|
+
|
|
621
|
+
Gotchas that change what you do:
|
|
622
|
+
- ⚠️ **The SVG must be a `data:` URL, never `blob:`.** A blob taints the canvas
|
|
623
|
+
(opaque origin), and the taint surfaces only at the end as `toBlob` throwing
|
|
624
|
+
`SecurityError`.
|
|
625
|
+
- ⚠️ **A rasterizing SVG is frozen at time ZERO**, so `animation: rise .45s both`
|
|
626
|
+
is caught at its FIRST keyframe (usually `opacity:0`) — a slide came out blank.
|
|
627
|
+
Fix: `animation-duration:0s` + `animation-delay:0s` (NOT `animation:none`), which
|
|
628
|
+
lands every animation on its END state.
|
|
629
|
+
- ⚠️ **Nothing is fetched during rasterization** — stylesheets read from
|
|
630
|
+
`document.styleSheets`, every `url()` absolutized + inlined as a data URI, every
|
|
631
|
+
`<img>` src swapped for one; anything un-inlinable is dropped; cross-origin sheets
|
|
632
|
+
(`cssRules` throws) skipped.
|
|
633
|
+
- ⚠️ **Chrome-class stripping runs on the ENGINE's nodes only** — a framed page is
|
|
634
|
+
someone else's markup, where `sel`/`active` are theirs.
|
|
635
|
+
- Live DOM state must be written into the inert clone: `<canvas>` → `toDataURL` img,
|
|
636
|
+
input/textarea/select values → attributes (source and clone walked in parallel).
|
|
637
|
+
- ⚠️ **ORDER TRAP in the keydown handler:** with Shift held `e.key` is `"C"`, so the
|
|
638
|
+
plain ⌘C branch matches ⌘⇧C too. The PNG branch sits ABOVE it and tests
|
|
639
|
+
`e.shiftKey`; the ⌘C branch tests `!e.shiftKey`. Both guards must stay. (The
|
|
640
|
+
comment overlay's Shift+C binding also had to stop swallowing ⌘⇧C.)
|
|
641
|
+
- Limits: while interacting inside a tile the iframe owns the keyboard, so ⌘⇧C
|
|
642
|
+
no-ops — Esc out first. On Windows Chrome `Ctrl+Shift+C` is DevTools and can't be
|
|
643
|
+
intercepted.
|
|
644
|
+
|
|
645
|
+
## Rich text — through the LINE model
|
|
646
|
+
|
|
647
|
+
`node.rich` holds sanitized HTML, `node.text` stays in sync as plain `innerText`
|
|
648
|
+
(the fallback when `rich` is absent). Node-level styles
|
|
649
|
+
(`bold/italic/strike/align/fontSize/color`, `applyTextStyle`) mean "the whole box";
|
|
650
|
+
the toolbar applies to the SELECTION when one exists (`toggleFormat`).
|
|
651
|
+
|
|
652
|
+
- **Never `execCommand("insertUnorderedList")` on our boxes.** They're `white-space:
|
|
653
|
+
pre-wrap`, where Chrome keeps Enter as literal `"\n"`, so the browser makes ONE
|
|
654
|
+
bullet from every line. Lists are ours: `flattenLines` → toggle `kind` →
|
|
655
|
+
`serializeLines` (one `<div>` per line, same-kind runs merged into `<ul>/<ol>`).
|
|
656
|
+
- **Map the selection to lines with MARKERS, not a counter** — a hand-written
|
|
657
|
+
counter drifts on empty `<div><br></div>` lines. `markSelection` plants
|
|
658
|
+
`<gv-mk1>/<gv-mk2>` and the same `flattenLines` reports where they landed.
|
|
659
|
+
- **Sanitize on render AND on commit** (board HTML round-trips through shared KV +
|
|
660
|
+
the socket). `sanitizeRichEl` whitelists tags and strips every attribute; paste
|
|
661
|
+
is forced to plain text.
|
|
662
|
+
- **Format buttons need `keepFocus`** (mousedown `preventDefault`) or the focus
|
|
663
|
+
move blurs the editable before the command runs.
|
|
664
|
+
- After an inline conversion (`execCommand("bold")` on a range), **toggle the style
|
|
665
|
+
OFF again** — it leaves the PENDING typing style on, so text after `**bold**`
|
|
666
|
+
stays bold. Collapse to end, run the command a second time.
|
|
667
|
+
- Input rules can't assume the line model exists: a brand-new sticky's text is a
|
|
668
|
+
bare text node with no `<div>`, so `autoFormat` falls back to the editable itself
|
|
669
|
+
and anchors on the last `"\n"`.
|
|
670
|
+
- Typed shorthands convert in place: `- `/`* `/`1. ` open a list, `**bold**` /
|
|
671
|
+
`_italic_` / `~~strike~~` convert on the closing delimiter, and `->` (or `-->`)
|
|
672
|
+
becomes `→` as the `>` lands.
|
|
673
|
+
|
|
674
|
+
## Sticky / shape auto-sizing
|
|
675
|
+
|
|
676
|
+
Stickies and shapes size to their text (`autoFit(node, allowShrink)`): grow so they
|
|
677
|
+
never clip, shrink back on edit while the height is auto. `allowShrink` is false on
|
|
678
|
+
render, so opening an old board never reflows it; a resize-drag sets `node.hFixed`
|
|
679
|
+
and the box stops hugging.
|
|
680
|
+
|
|
681
|
+
- **A sticky shrinks its TEXT before it grows its BOX** (`fitStickyFont`, the sticky-note
|
|
682
|
+
model): `fontSize` is a CEILING, the text steps down `STICKY_FONT_RAMP` until it
|
|
683
|
+
fits; only when the bottom rung overflows does the note grow. Two traps: (1) fit
|
|
684
|
+
against the height the note WANTS (`STICKY_H` in auto mode, its own height once
|
|
685
|
+
`hFixed`) — never the current grown height, or the fit feeds back into itself; (2)
|
|
686
|
+
`STICKY_PAD_V` must track `.gvc-stickyin`'s vertical CSS padding. The walk starts
|
|
687
|
+
from the cached size (`txt.dataset.fit`), so a keystroke costs 1–2 reflows.
|
|
688
|
+
- **A sticky's auto floor is the size it was dropped at** (`STICKY_H`), not the CSS
|
|
689
|
+
`min-height` (with the CSS floor, two words collapsed a 160px note to 96). Floor
|
|
690
|
+
skipped when `hFixed` and on render.
|
|
691
|
+
- A shape's text is inset 12%/side (hence `/0.76`) and height-capped by the insets,
|
|
692
|
+
so `scrollHeight` can't see shorter content — measure the line blocks (`contentH`).
|
|
693
|
+
Its `.gvc-txt` needs `flex-direction: column` or rich-text line blocks lay out in
|
|
694
|
+
a row.
|
|
695
|
+
|
|
696
|
+
## Resize, snapping, sections, drag
|
|
697
|
+
|
|
698
|
+
- **Resize handle needs a real starting size.** `startResize` reads `node.w/h` for
|
|
699
|
+
`ow/oh`, but text nodes carry neither until first resized → `ow+dx = NaN` and the
|
|
700
|
+
handle silently did nothing. Fall back to the host's measured
|
|
701
|
+
`offsetWidth/Height`. Text is width-only on resize (clear `style.height` → auto).
|
|
702
|
+
- **A clipping host eats its own resize handles** — corner handles straddle the
|
|
703
|
+
edge, so an `overflow:hidden` host shows quarter-circles. Clip in an INNER wrap
|
|
704
|
+
(`.gvc-stickyin`), leave the host visible (same for `.gvc-image`).
|
|
705
|
+
- **Decor doesn't inherit the zoom fix.** Handles are world-space children, so at
|
|
706
|
+
40% zoom a 13px handle paints at 5px; `scaleDecor` counter-scales them
|
|
707
|
+
(`transform: scale(1/zoom)`, registered on `transformCbs`). An **edge** handle
|
|
708
|
+
counter-scales ONE axis only (`scaleY`/`scaleX`) so it keeps full length; edges
|
|
709
|
+
sit at `z-index:2` under the corners.
|
|
710
|
+
- **Resize math is direction-driven; a one-letter direction is not a corner.**
|
|
711
|
+
Derive `west/north` + `doW/doH` by explicit membership, not character positions
|
|
712
|
+
(`dir.charAt(1)==="w"` mis-reads `"w"`). Shift's aspect lock is corner-only.
|
|
713
|
+
- **Resizing a ROTATED node happens in the node's own axes.** Rotate the pointer
|
|
714
|
+
delta into node space first (`ldx/ldy`), and place the result by the ANCHOR rule:
|
|
715
|
+
the side opposite the one you grabbed stays fixed in world space, which — because
|
|
716
|
+
CSS rotates about the centre — means `c1 = c0 + R·(a_before − a_after)`, never
|
|
717
|
+
"x = ox + (ow − w)". At `rot 0` the two are the same expression, so upright
|
|
718
|
+
resizing is unchanged. Snap guides are axis-aligned and switch OFF while rotated;
|
|
719
|
+
a tilted box has no edge to latch onto.
|
|
720
|
+
- **The rotate pad is OUTSIDE the corner, and below it.** `.gvc-rot` hangs off each
|
|
721
|
+
corner (22px, invisible, cursor-only) with `transform-origin` set to that corner so
|
|
722
|
+
the same `scaleDecor` counter-scale keeps it pinned; `z-index` under the resize dot,
|
|
723
|
+
so the overlap always resizes. Double-click straightens.
|
|
724
|
+
- **Shift is overloaded on a node drag** — shift-CLICK toggles selection, shift-DRAG
|
|
725
|
+
locks the axis. Decide at pointer-UP: apply the toggle only if `drag.moved` is
|
|
726
|
+
false. A finished drag must clear `lastTap`, or a second drag within 350ms reads
|
|
727
|
+
as a double-tap into the editor.
|
|
728
|
+
- **Auto-adapt text = `max-content`, never `width:auto`.** `#gvc-world` is
|
|
729
|
+
`width:0`, so an `auto`-width absolutely-positioned text node shrinks to its
|
|
730
|
+
minimum content width (one word per line). `renderText` sets `max-content` when
|
|
731
|
+
`node.w` is null, explicit px only after a resize.
|
|
732
|
+
- **Sections carry contents by CENTRE containment**, resolved once at pointerdown
|
|
733
|
+
(`withSectionChildren`). Passengers are excluded from snap candidates
|
|
734
|
+
(`armSnap(moving)`), but the snap box stays the SELECTION's rect.
|
|
735
|
+
- **Snapping is measured in SCREEN pixels** (`SNAP_PX / view.scale`). Candidate rects
|
|
736
|
+
collected ONCE per drag (`armSnap`), never per pointermove. Shift beats snapping on
|
|
737
|
+
the pinned axis; ⌘/Ctrl bypasses it entirely (also what a test holds to assert
|
|
738
|
+
exact pixel deltas).
|
|
739
|
+
- **A node must never be a native HTML5 drag source.** The browser starts its own
|
|
740
|
+
drag from a text run (and from `<img>`), painting a ghost and firing `dragover` —
|
|
741
|
+
which lit the "Drop image" overlay mid-drag of a text node. Two guards: `dragstart`
|
|
742
|
+
inside `.gvc-node` is `preventDefault`ed (except while `.editing`), and the drop
|
|
743
|
+
overlay only answers a drag carrying files (`dataTransfer.types` has `"Files"`).
|
|
744
|
+
- **Rename** (tile bar, image label): **manual double-tap**, not native `dblclick`
|
|
745
|
+
(the root's pointer capture eats `dblclick`); `stopPropagation` so a tap doesn't
|
|
746
|
+
start a drag.
|
|
747
|
+
|
|
748
|
+
## Undo / history
|
|
749
|
+
|
|
750
|
+
- **Undo must be per-USER, not per-document, in a live room** — restoring a
|
|
751
|
+
whole-board snapshot would revert a teammate's work. `histCommit` diffs against a
|
|
752
|
+
shadow on the save debounce and records only changed nodes as `{before, after}`;
|
|
753
|
+
`mpApplyOps` folds every REMOTE change into that shadow (`histSeen`/`histForget`)
|
|
754
|
+
so a peer's work never enters your stack. Adopting a room doc (`mpAdoptDoc`) folds
|
|
755
|
+
per-node through the same rail, so your undo stack survives a reconnect (adopt is a
|
|
756
|
+
per-node diff, not a wholesale replace). Inside a text box the browser's own undo
|
|
757
|
+
wins.
|
|
758
|
+
- **Snapshots share their strings.** `histClone` is a shallow copy with
|
|
759
|
+
`points`/`cells`/`crop` deep-copied — never `JSON.parse(JSON.stringify(node))`,
|
|
760
|
+
which would duplicate every inlined image data-URL dozens of times.
|
|
761
|
+
|
|
762
|
+
## KV writes — the scarce resource
|
|
763
|
+
|
|
764
|
+
The free tier allows ~1k writes/day and the doc runs to hundreds of KB with images
|
|
765
|
+
inlined. **The rule: never spend a KV write on the camera.** The viewport is
|
|
766
|
+
per-user (the room never syncs it), so it lives in `localStorage` under
|
|
767
|
+
`gvc:view:<path>` (`saveView`/`storedView`), which also stops one person's camera
|
|
768
|
+
overwriting everyone else's. If you add a feature that touches `board.view`, call
|
|
769
|
+
**`saveView()`, NOT `scheduleSave()`.** Belt and braces: `save()` compares a content
|
|
770
|
+
signature (`docSig` = nodes + name) and skips the POST when only the camera moved;
|
|
771
|
+
the save debounce is 1200ms; adopting a room doc reseeds the signature. The client
|
|
772
|
+
POST runs only as the SOLO fallback — the BoardRoom DO owns the doc and writes the
|
|
773
|
+
KV mirror itself (`realtime/src/index.js`: `applyOps`/`markDirty`/`alarm`/`mirror`,
|
|
774
|
+
flush on empty, `/__test/` rooms exempt). The solo save confirms the 2xx before
|
|
775
|
+
marking saved, retries with backoff, ships `keepalive`, beacons on `pagehide`, and
|
|
776
|
+
a failed board LOAD holds saves off entirely (an empty stand-in must never overwrite
|
|
777
|
+
the real board). Playwright note: blocking `POST /__board` doesn't prove "no KV
|
|
778
|
+
writes" — the room writes server-side; test rooms must stay under `/__test/`, and
|
|
779
|
+
blocking the socket needs a WebSocket-constructor stub (HTTP routes don't intercept
|
|
780
|
+
upgrades).
|
|
781
|
+
|
|
782
|
+
## SVG, toolbar, wheel, deep links
|
|
783
|
+
|
|
784
|
+
- SVG nodes: build via an innerHTML string (or `createElementNS`), never
|
|
785
|
+
`createElement("svg")` (no namespace → never paints).
|
|
786
|
+
- Insert-picker cards live in a **flex-column → grid**: without `flex:1;
|
|
787
|
+
min-height:0` on the grid + `grid-auto-rows:max-content`, flex collapses the auto
|
|
788
|
+
rows to ~8px and `overflow:hidden` clips each poster. `aspect-ratio` did NOT
|
|
789
|
+
contribute block height — use a fixed thumb height.
|
|
790
|
+
- **Wheel over the fixed UI** (`#gvc-ui`, e.g. the picker) must `return` early in the
|
|
791
|
+
wheel handler (no `preventDefault`, no pan) or it eats native scroll and pans the
|
|
792
|
+
board.
|
|
793
|
+
- **Interaction model:** empty drag = marquee multi-select; pan = scroll/trackpad or
|
|
794
|
+
Space-drag / hand tool. Don't revert to drag-to-pan. Touch exception: one finger on
|
|
795
|
+
empty canvas pans, two fingers pinch-zoom; a second finger mid-stroke cancels the
|
|
796
|
+
stroke (palm) and pinches.
|
|
797
|
+
- **The main toolbar:** tool state is one `TOOL` object (`setTool()`), sub-toolbars
|
|
798
|
+
sync via `syncBars()`. Shortcuts: V select · H hand · M marker (⇧P too) · S sticky
|
|
799
|
+
· T text · E stamp · R square · O circle · L line · X elbow · ⇧S section · ⇧T
|
|
800
|
+
table · C comment · Esc select. ⌘⇧C, ⌘C/⌘X/⌘V and ⌘D/⌘Z/⌘⇧Z are handled ahead of
|
|
801
|
+
the tool letters, so ⌘C never toggles comments.
|
|
802
|
+
- **←↑→↓ nudge the selection** one WORLD unit, ⇧ ten — world, not
|
|
803
|
+
screen, so alignment done at 400% survives zooming out. `nudge()` reuses the drag's
|
|
804
|
+
`withSectionChildren` (a section takes its contents along) and ends on the same
|
|
805
|
+
`scheduleSave()`, so key-repeat lands as ONE undo step and one write. It is handled
|
|
806
|
+
ABOVE the tool letters: with Shift held that branch reads the key as a letter chord
|
|
807
|
+
and returns, which would swallow ⇧←. Snapping deliberately stays out — the nudge is
|
|
808
|
+
what you reach for when the snap put a node where you didn't want it.
|
|
809
|
+
- **The selection bar styles the WHOLE selection.** `showSelBar(nodes)` takes the
|
|
810
|
+
selection, not a node: ten stickies go blue in one click, a run of text nodes takes
|
|
811
|
+
one size. Two rules keep it honest — a control only appears when `uniformType()`
|
|
812
|
+
says every selected node is the same type (a colour means a different thing to a
|
|
813
|
+
sticky and to a drawing; mixed selections get "copy as PNG" alone), and each control
|
|
814
|
+
READS its state off the first selected node and WRITES that one value to all of
|
|
815
|
+
them. Never a per-node flip: ⌘B on a mixed-bold selection must land everything bold,
|
|
816
|
+
not invert each node into a new mess. Single-only by nature: the two list buttons
|
|
817
|
+
(they work through a live editable, and entering an edit collapses the selection to
|
|
818
|
+
one node), Interact/Open on a tile, and the deep link (it addresses ONE node id).
|
|
819
|
+
The bar is suppressed mid-marquee — the hit list changes on every pointermove and a
|
|
820
|
+
bar rebuilt that often strobes over the nodes you're sweeping — and re-formed on
|
|
821
|
+
pointerup, which is why `setSelection` reads `drag.mode`.
|
|
822
|
+
- **⚠️ A node must NEVER be a native HTML5 drag source, editing or not.** The
|
|
823
|
+
`dragstart` handler cancels unconditionally. Letting an EDITING node through (it
|
|
824
|
+
once did, to allow dragging a text selection inside the box) means pressing inside
|
|
825
|
+
the text box you just typed into — i.e. going to MOVE it — hands Chromium a
|
|
826
|
+
selection drag, and Chromium paints that drag image from the DOM *without* the
|
|
827
|
+
world's zoom transform: an 80px "Huge" text node smears a translucent page-sized
|
|
828
|
+
copy of itself across the board while the node doesn't move. The eraser deletes whole `draw`
|
|
829
|
+
strokes only; sections render behind everything (`insertBefore`). Illustrated arts
|
|
830
|
+
are inline SVGs (`PEN_ART`/`STICKY_ART`/`CLUSTER_ICON` — keep gradient ids unique);
|
|
831
|
+
small line icons are **Lucide** via `lucideIcon()` (extend with Lucide paths, don't
|
|
832
|
+
hand-draw). The speech-bubble tool IS the comment layer (dispatches
|
|
833
|
+
`toggleComments()`, no new node type). Default sticky color is soft blue
|
|
834
|
+
(`#a9cbf5`).
|
|
835
|
+
- **The stamp wheel's left slot is YOUR FACE.** It paints the signed-in user's avatar
|
|
836
|
+
(initials on their room colour when there's no photo), and what it stamps carries
|
|
837
|
+
that identity on the node — a face stamp shows who pressed it, for everyone. Identity
|
|
838
|
+
lands asynchronously (`/__me`, then the room's colour), so the slot is repainted by
|
|
839
|
+
`refreshFaceStamp()` rather than being drawn once at boot.
|
|
840
|
+
- **Deep links to one node:** the link button on the selection bar (present for every
|
|
841
|
+
node type) copies `<board URL>#n=<node id>`. Opening it flies the camera to the node
|
|
842
|
+
(`flyToRect`, capped at 1:1), selects and pulses it. **The hash is CONSUMED on
|
|
843
|
+
arrival** (`history.replaceState`'d away) — comment threads scope to `pathname +
|
|
844
|
+
search + hash`, so a lingering `#n=` would file later comments under a view nobody
|
|
845
|
+
else is on (that's also why it isn't a `?query`). Deleting the node gets a toast,
|
|
846
|
+
not a dead board.
|
|
847
|
+
|
|
848
|
+
## Backlog (open items — pick with the human on the live URL)
|
|
849
|
+
|
|
850
|
+
- Connectors that snap to nodes.
|
|
851
|
+
- Frames / groups.
|
|
852
|
+
- Voting / timers as board primitives.
|
|
853
|
+
- A poster shot for a freshly-created owned screen so its tile isn't blank until it
|
|
854
|
+
mounts live.
|
|
855
|
+
- Proper cache-busting for `/__canvas/*.js|css` if we move off `no-cache`.
|
|
856
|
+
- The "read the board + reason spatially" half of the collaboration skill (resolve
|
|
857
|
+
"that", cluster, summarise on request) and a continuous sub-second presence loop
|
|
858
|
+
(today the agent works in turn-by-turn bursts).
|