@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.
Files changed (190) hide show
  1. package/CANVAS.md +858 -0
  2. package/INSTALL.md +603 -0
  3. package/LICENSE +27 -0
  4. package/README.md +150 -0
  5. package/TRADEMARKS.md +72 -0
  6. package/agents/README.md +65 -0
  7. package/agents/canon-extract.md +117 -0
  8. package/agents/canon.md +151 -0
  9. package/agents/canvas.md +88 -0
  10. package/agents/currency.md +74 -0
  11. package/agents/drafts.md +92 -0
  12. package/agents/identity.md +148 -0
  13. package/agents/prototype-contract.md +69 -0
  14. package/agents/publishing.md +324 -0
  15. package/agents/review-feedback.md +76 -0
  16. package/agents/space-json.md +132 -0
  17. package/agents/ui-skill.md +101 -0
  18. package/agents/working-marks.md +86 -0
  19. package/brand/augur-eye.svg +3 -0
  20. package/brand/augur-icon.svg +41 -0
  21. package/brand/augur-mark.svg +5 -0
  22. package/brand/lentianova-bulky.otf +0 -0
  23. package/brand/lentianova-median.otf +0 -0
  24. package/brand/lentianova.woff2 +0 -0
  25. package/build.js +8409 -0
  26. package/changelog.md +133 -0
  27. package/fonts/inter-latin-wght-normal.woff2 +0 -0
  28. package/package.json +57 -0
  29. package/pitis/PETS.md +121 -0
  30. package/pitis/README.md +117 -0
  31. package/pitis/index.html +266 -0
  32. package/pitis/piti.build.js +81 -0
  33. package/pitis/piti.js +929 -0
  34. package/pitis/roast-agent.md +185 -0
  35. package/realtime/src/index.js +119 -0
  36. package/realtime/wrangler.example.toml +44 -0
  37. package/scripts/adopt.mjs +106 -0
  38. package/scripts/board-snapshot.mjs +197 -0
  39. package/scripts/build-seed-pack.mjs +33 -0
  40. package/scripts/bundle-rekey.mjs +112 -0
  41. package/scripts/bundle-tenancy-rehearsal.mjs +781 -0
  42. package/scripts/canon-extract.mjs +578 -0
  43. package/scripts/canon.mjs +599 -0
  44. package/scripts/canvas-screen.mjs +333 -0
  45. package/scripts/check-local.mjs +136 -0
  46. package/scripts/clawd-art.mjs +75 -0
  47. package/scripts/clawd-canvas.mjs +741 -0
  48. package/scripts/cli.mjs +100 -0
  49. package/scripts/clone.mjs +422 -0
  50. package/scripts/close.mjs +30 -0
  51. package/scripts/connect.mjs +103 -0
  52. package/scripts/deploy.mjs +157 -0
  53. package/scripts/dev.mjs +54 -0
  54. package/scripts/doc-lint.mjs +143 -0
  55. package/scripts/export.mjs +300 -0
  56. package/scripts/fork.mjs +64 -0
  57. package/scripts/freeze.mjs +74 -0
  58. package/scripts/frontdoor-parity.mjs +180 -0
  59. package/scripts/hook.mjs +67 -0
  60. package/scripts/identity-rekey.mjs +112 -0
  61. package/scripts/init.mjs +122 -0
  62. package/scripts/land.mjs +40 -0
  63. package/scripts/lib/adapters.mjs +156 -0
  64. package/scripts/lib/board-snapshot.mjs +511 -0
  65. package/scripts/lib/draft.mjs +385 -0
  66. package/scripts/lib/graduate.mjs +276 -0
  67. package/scripts/lib/instance.mjs +74 -0
  68. package/scripts/lib/marks.mjs +107 -0
  69. package/scripts/lib/materialize.mjs +138 -0
  70. package/scripts/lib/merge3.mjs +124 -0
  71. package/scripts/lib/offline-posture.mjs +120 -0
  72. package/scripts/lib/offline-respawn.mjs +18 -0
  73. package/scripts/lib/offline-wrangler.mjs +78 -0
  74. package/scripts/lib/publish-compose.mjs +12 -0
  75. package/scripts/lib/publish-conflict.mjs +78 -0
  76. package/scripts/lib/publish-evidence.mjs +144 -0
  77. package/scripts/lib/refine-compare.mjs +140 -0
  78. package/scripts/lib/refine-ledger.mjs +150 -0
  79. package/scripts/lib/refine-png.mjs +149 -0
  80. package/scripts/lib/seed-pack-build.mjs +141 -0
  81. package/scripts/lib/state-compare.mjs +68 -0
  82. package/scripts/lib/store.mjs +122 -0
  83. package/scripts/login.mjs +84 -0
  84. package/scripts/mark.mjs +112 -0
  85. package/scripts/migrate-board-keys.mjs +164 -0
  86. package/scripts/migrate.mjs +265 -0
  87. package/scripts/no-foreign-vocabulary.mjs +216 -0
  88. package/scripts/no-product-names.mjs +157 -0
  89. package/scripts/no-tenant-globals.mjs +1134 -0
  90. package/scripts/offline.mjs +247 -0
  91. package/scripts/og.mjs +170 -0
  92. package/scripts/one-tenant-resolver.mjs +146 -0
  93. package/scripts/open.mjs +63 -0
  94. package/scripts/publish.mjs +1041 -0
  95. package/scripts/read.mjs +31 -0
  96. package/scripts/refine.mjs +455 -0
  97. package/scripts/release-drift.mjs +138 -0
  98. package/scripts/restore.mjs +322 -0
  99. package/scripts/review.mjs +125 -0
  100. package/scripts/save.mjs +21 -0
  101. package/scripts/shell-lint.mjs +189 -0
  102. package/scripts/ship.mjs +460 -0
  103. package/scripts/shoot.mjs +264 -0
  104. package/scripts/smoke-invite.mjs +113 -0
  105. package/scripts/state-inventory.mjs +223 -0
  106. package/scripts/status.mjs +177 -0
  107. package/scripts/sync.mjs +27 -0
  108. package/scripts/tenant-do-rehearsal.mjs +1314 -0
  109. package/scripts/ui-copy-lint.mjs +202 -0
  110. package/scripts/watch.mjs +32 -0
  111. package/scripts/wrangler-preflight.mjs +246 -0
  112. package/seed/CANON.md +43 -0
  113. package/seed/README.md +119 -0
  114. package/seed/prototype-status.json +9 -0
  115. package/seed/registry.json +88 -0
  116. package/seed/skills/starter-ui/SKILL.md +52 -0
  117. package/seed/skills/starter-ui/skill.json +11 -0
  118. package/seed/skills/starter-ui/starter-tokens.css +73 -0
  119. package/seed/skills/starter-ui/starter-ui.css +318 -0
  120. package/seed/skills/starter-ui/starter-ui.js +50 -0
  121. package/seed/space.json +5 -0
  122. package/seed/start-here/prototypes/connect-your-terminal/index.html +428 -0
  123. package/seed/start-here/prototypes/connect-your-terminal/preview.webp +0 -0
  124. package/seed/start-here/prototypes/sample-with-comments/index.html +172 -0
  125. package/seed/start-here/prototypes/sample-with-comments/preview.webp +0 -0
  126. package/seed/start-here/prototypes/set-up-your-design-system/index.html +353 -0
  127. package/seed/start-here/prototypes/set-up-your-design-system/preview.webp +0 -0
  128. package/seed/threads.json +52 -0
  129. package/seed/worked-examples/prototypes/field-readings/index.html +273 -0
  130. package/seed/worked-examples/prototypes/field-readings/preview.webp +0 -0
  131. package/seed/worked-examples/prototypes/slide-deck/index.html +214 -0
  132. package/seed/worked-examples/prototypes/slide-deck/preview.webp +0 -0
  133. package/seed/worked-examples/prototypes/specimen-viewer/index.html +203 -0
  134. package/seed/worked-examples/prototypes/specimen-viewer/preview.webp +0 -0
  135. package/src/_worker.js +12668 -0
  136. package/src/board-key.mjs +57 -0
  137. package/src/board-room.mjs +881 -0
  138. package/src/bundle-keys.mjs +138 -0
  139. package/src/canon/collect-in-browser.js +215 -0
  140. package/src/canon/emit.mjs +232 -0
  141. package/src/canon/observe.mjs +260 -0
  142. package/src/canon/schema.mjs +451 -0
  143. package/src/canvas/DSEG-LICENSE.txt +95 -0
  144. package/src/canvas/DSEG7Classic-Bold.woff2 +0 -0
  145. package/src/canvas/canvas.css +885 -0
  146. package/src/canvas/canvas.js +5780 -0
  147. package/src/canvas/capture.js +485 -0
  148. package/src/chrome/appchrome.mjs +568 -0
  149. package/src/currency.mjs +203 -0
  150. package/src/delete-confirmation.mjs +254 -0
  151. package/src/drafts/drafts.js +326 -0
  152. package/src/entry.js +58 -0
  153. package/src/galleries.mjs +400 -0
  154. package/src/health-cron.mjs +173 -0
  155. package/src/identity.json +1 -0
  156. package/src/kv-codec.mjs +135 -0
  157. package/src/kv-identity.mjs +259 -0
  158. package/src/mail.mjs +599 -0
  159. package/src/provenance.mjs +74 -0
  160. package/src/publish-compose.mjs +363 -0
  161. package/src/publish-fork.mjs +221 -0
  162. package/src/publish-units.mjs +44 -0
  163. package/src/purge.mjs +81 -0
  164. package/src/review/COMMENTING-UX.md +306 -0
  165. package/src/review/cat.png +0 -0
  166. package/src/review/comment-cursor.svg +7 -0
  167. package/src/review/comments.js +1738 -0
  168. package/src/room-ticket.mjs +96 -0
  169. package/src/roster-chip.mjs +32 -0
  170. package/src/seed-pack.mjs +261 -0
  171. package/src/state-inventory.mjs +249 -0
  172. package/src/sw-logic.mjs +48 -0
  173. package/src/tenant-cache.mjs +141 -0
  174. package/src/tenant-context.mjs +386 -0
  175. package/src/tenant-do.js +2940 -0
  176. package/src/tenant-host.mjs +144 -0
  177. package/src/tenant-quotas.mjs +140 -0
  178. package/src/unit-core.mjs +103 -0
  179. package/src/unit-object.mjs +338 -0
  180. package/templates/README.md +67 -0
  181. package/templates/shell/deploy.yml +145 -0
  182. package/templates/shell/engine-bump.yml +142 -0
  183. package/templates/shell/health.yml +233 -0
  184. package/templates/shell/kv-backup.yml +201 -0
  185. package/templates/shell/roster-update.yml +110 -0
  186. package/templates/shell/space-preflight.yml +35 -0
  187. package/templates/shell/space-rebake.yml +79 -0
  188. package/templates/shell/store-backup.yml +152 -0
  189. package/templates/shell/wrangler.example.toml +266 -0
  190. 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).