@marver-design/marver 0.3.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CHANGELOG.md +151 -0
  2. package/README.md +2 -0
  3. package/dist/auth-B36fMCM3.mjs +245 -0
  4. package/dist/{build-Ckmyci3O.mjs → build-BrCl9hJS.mjs} +56 -14
  5. package/dist/cli.mjs +15 -6
  6. package/dist/collab-CXy8gqoz.mjs +297 -0
  7. package/dist/comments-Ba8mU600.mjs +90 -0
  8. package/dist/comments-odHzYdO3.mjs +179 -0
  9. package/dist/dev-DdeU-Jst.mjs +162 -0
  10. package/dist/{init-DolP_Ld4.mjs → init-DsCUmlCW.mjs} +2 -1
  11. package/dist/{manifest-DW-T52MM.mjs → manifest-C8FODq2S.mjs} +26 -1
  12. package/dist/{plugin-Crp4CAma.mjs → plugin-BtSGAm2h.mjs} +169 -19
  13. package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
  14. package/dist/serve-D_KBK7Oy.mjs +431 -0
  15. package/dist/sync-CkBk-tUk.mjs +248 -0
  16. package/package.json +3 -1
  17. package/src/client/content/diagram.tsx +46 -2
  18. package/src/client/content/index.tsx +6 -2
  19. package/src/client/content/md.ts +29 -0
  20. package/src/client/content/palette.ts +6 -0
  21. package/src/client/frame-host/bridge.js +269 -3
  22. package/src/client/frame-host/serialize.ts +195 -0
  23. package/src/client/shell/App.tsx +131 -17
  24. package/src/client/shell/Comments.tsx +464 -0
  25. package/src/client/shell/Play.tsx +24 -0
  26. package/src/client/shell/canvas/Canvas.tsx +66 -29
  27. package/src/client/shell/canvas/FrameNode.tsx +130 -10
  28. package/src/client/shell/canvas/frame-registry.ts +18 -0
  29. package/src/client/shell/canvas/snapshots.ts +233 -0
  30. package/src/client/shell/comments-store.ts +180 -0
  31. package/src/client/shell/cursor-arrow-dark.svg +1 -0
  32. package/src/client/shell/cursor-arrow.svg +1 -0
  33. package/src/client/shell/hash.ts +14 -3
  34. package/src/client/shell/icons.tsx +6 -0
  35. package/src/client/shell/labels.ts +10 -0
  36. package/src/client/shell/perf.ts +92 -0
  37. package/src/client/shell/store.ts +101 -6
  38. package/src/client/shell/styles.css +196 -7
  39. package/src/client/stage/main.tsx +2 -0
  40. package/src/shared/events.ts +103 -0
  41. package/templates/AGENTS-embedded.md +13 -1
  42. package/templates/AGENTS-studio.md +13 -1
  43. package/templates/instructions/boards.md +6 -3
  44. package/templates/instructions/configure.md +28 -0
  45. package/templates/instructions/iterate.md +38 -0
  46. package/templates/instructions/publish.md +129 -0
  47. package/templates/instructions/reference/color.md +22 -1
  48. package/templates/instructions/shape.md +23 -16
  49. package/dist/dev-C2oTKuXe.mjs +0 -97
  50. package/dist/serve-OtA9Nlow.mjs +0 -207
@@ -28,6 +28,13 @@
28
28
  --interact: #db35f2; --interact-ring: rgba(219, 53, 242, .16);
29
29
  --interact-strong: rgba(219, 53, 242, 1); --interact-soft: rgba(234, 141, 255, .95);
30
30
  --interact-deep: rgba(176, 47, 194, .85); --interact-spark: rgba(255, 255, 255, .95);
31
+ /* comments own a third mode color (Apple systemGreen): selection = blue,
32
+ interact = purple, comments = green - same geometry, unique hue per mode */
33
+ --comment: #34c759; --comment-ring: rgba(52, 199, 89, .22);
34
+ /* comment-card field surface: APP-scoped on purpose - the card keys to the board
35
+ theme, and node-scoped --node-bg would flip with the frame underneath it */
36
+ --cm-field: #fff;
37
+ --cm-modal-bg: rgba(255, 255, 255, .95);
31
38
 
32
39
  --r-node: 10px; --r-panel: 24px; --edge: 10px;
33
40
  --blur: blur(20px) saturate(150%);
@@ -60,6 +67,9 @@
60
67
  --interact: #ea8dff; --interact-ring: rgba(219, 53, 242, .3);
61
68
  --interact-strong: rgba(219, 53, 242, 1); --interact-soft: rgba(234, 141, 255, .95);
62
69
  --interact-deep: rgba(203, 48, 224, .8); --interact-spark: rgba(255, 255, 255, .9);
70
+ --comment: #30d158; --comment-ring: rgba(48, 209, 88, .34);
71
+ --cm-field: #0f1015;
72
+ --cm-modal-bg: rgba(22, 22, 27, .96);
63
73
 
64
74
  --shadow-node: 0 1px 2px rgba(0, 0, 0, .35); /* shadow-sm, alpha raised to read on dark */
65
75
  }
@@ -112,6 +122,13 @@ button { font-family: inherit }
112
122
  button svg { pointer-events: none } /* event targets stay on the button - pan exclusion depends on it */
113
123
  @media (prefers-reduced-motion: reduce) { *, *::before, *::after { transition: none !important } }
114
124
 
125
+ /* Default app pointer: the marver arrowhead (tilted left, rounded, thick border, soft shadow),
126
+ theme-adaptive - black/white on light, white/dark on dark. Hotspot at its tip (5,2). Overridden by
127
+ the state cursors: grab (space-pan), resize handles, comment-pin / laser-crosshair (set in-frame by
128
+ the bridge), and a normal pointer in interact / prototype mode. */
129
+ .sh-app { --sh-cursor: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='22' height='22' viewBox='0 0 26 26'%3E%3Cdefs%3E%3Cfilter id='s' x='-40%' y='-40%' width='180%' height='180%'%3E%3CfeDropShadow dx='0.4' dy='0.9' stdDeviation='0.7' flood-color='%23000000' flood-opacity='0.4'/%3E%3C/filter%3E%3C/defs%3E%3Cg filter='url(%23s)'%3E%3Cg transform='rotate(-20 6 3)'%3E%3Cpath fill='%23000000' stroke='%23FFFFFF' stroke-width='1.6' stroke-linejoin='round' stroke-linecap='round' d='M5.5 3.21V20.8c0 .45.54.67.85.35l4.86-4.86a1 1 0 0 1 .7-.3h6.87a1 1 0 0 0 .7-1.7L6.35 2.85a.5.5 0 0 0-.85.35Z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E") 5 2, default; cursor: var(--sh-cursor) }
130
+ .sh-app.dark { --sh-cursor: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='22' height='22' viewBox='0 0 26 26'%3E%3Cdefs%3E%3Cfilter id='s' x='-40%' y='-40%' width='180%' height='180%'%3E%3CfeDropShadow dx='0.4' dy='0.9' stdDeviation='0.7' flood-color='%23000000' flood-opacity='0.4'/%3E%3C/filter%3E%3C/defs%3E%3Cg filter='url(%23s)'%3E%3Cg transform='rotate(-20 6 3)'%3E%3Cpath fill='%23FFFFFF' stroke='%2318181b' stroke-width='1.6' stroke-linejoin='round' stroke-linecap='round' d='M5.5 3.21V20.8c0 .45.54.67.85.35l4.86-4.86a1 1 0 0 1 .7-.3h6.87a1 1 0 0 0 .7-1.7L6.35 2.85a.5.5 0 0 0-.85.35Z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E") 5 2, default }
131
+ .sh-app.interacting { cursor: auto }
115
132
  .sh-app { position: relative; height: 100%; color: var(--glass-ink); outline: none;
116
133
  transition: --accent .4s ease, --accent-ring .4s ease, --beam .4s ease,
117
134
  --edge-accent .4s ease, --inner-tint .4s ease, --accent-wash .4s ease,
@@ -131,15 +148,20 @@ button svg { pointer-events: none } /* event targets stay on the button - pan
131
148
  opacity: var(--grid-alpha, 1) }
132
149
  .sh-content { will-change: auto }
133
150
  #sh-world { position: relative; width: 1px; height: 1px }
134
- #sh-world.sh-gesturing iframe { pointer-events: none } /* law G-4 */
151
+ #sh-world.sh-gesturing .sh-live { pointer-events: none } /* law G-4 (live iframe only; lean is always pe:none) */
135
152
  .sh-gesturing .sh-content { will-change: transform } /* law G-3: gesture-scoped only */
136
153
  /* preset transitions: armed by animateLayout() around device/tidy mutations - nodes ease
137
154
  to their new frame on the same 320ms ease-out the camera fit animates with */
138
155
  #sh-world.sh-preset .sh-node { transition: transform .32s cubic-bezier(.25,.46,.45,.94),
139
156
  width .32s cubic-bezier(.25,.46,.45,.94), height .32s cubic-bezier(.25,.46,.45,.94) }
140
157
  #sh-world.sh-preset .sh-node-body,
141
- #sh-world.sh-preset .sh-node iframe { transition: width .32s cubic-bezier(.25,.46,.45,.94),
158
+ #sh-world.sh-preset .sh-node .sh-live { transition: width .32s cubic-bezier(.25,.46,.45,.94),
142
159
  height .32s cubic-bezier(.25,.46,.45,.94) }
160
+ /* SPEC-M5 device-sweep: a frame WITH a ready lean cover jumps its LIVE iframe straight to the final
161
+ width (ONE reflow, hidden under the cover) while the lean cover - being width:100% of the animating
162
+ body - reflows smoothly every frame (real CSS, the device-sweep fix). A frame WITHOUT a usable
163
+ cover keeps the live width animation above. */
164
+ body:not(.sh-laser):not(.sh-commenting) #sh-world.sh-preset .sh-node:has(.sh-lean[data-ready]):not(.interact) .sh-live { transition: none }
143
165
 
144
166
  /* cursor conventions (Figma): arrow everywhere; grab only while space is held */
145
167
  body.sh-space .sh-canvas { cursor: grab }
@@ -173,7 +195,7 @@ body.sh-space .sh-node { pointer-events: none } /* space-drag
173
195
  box-shadow: 0 0 0 calc(4px * var(--sh-inv, 1)) var(--accent-ring), var(--shadow-node) }
174
196
  .sh-node-head { height: 28px; display: flex; align-items: center; gap: 8px; padding: 0 11px;
175
197
  font-size: 11px; color: var(--head-dim);
176
- border-bottom: 1px solid var(--node-brd); cursor: default; user-select: none;
198
+ border-bottom: 1px solid var(--node-brd); user-select: none;
177
199
  border-radius: var(--r-node) var(--r-node) 0 0;
178
200
  background-color: var(--head-bg); background-image: var(--head-sheen);
179
201
  backdrop-filter: var(--blur); -webkit-backdrop-filter: var(--blur) }
@@ -187,7 +209,20 @@ body.sh-space .sh-node { pointer-events: none } /* space-drag
187
209
  .sh-node-head .dim { margin-left: auto; color: var(--head-dim); flex: none; font-variant-numeric: tabular-nums }
188
210
  .sh-node-body { position: relative; background: var(--node-bg); border-radius: 0 0 var(--r-node) var(--r-node); overflow: hidden }
189
211
  .sh-node iframe { border: 0; display: block }
190
- .sh-overlay { position: absolute; inset: 0; cursor: default }
212
+ /* SPEC-M5 LEAN-PRIMARY: the lean DOM-snapshot <iframe> (static html, 0 JS) is what you SEE for a
213
+ passive frame - at rest AND during pan/zoom/resize. There is NO per-gesture swap between the lean
214
+ and the live iframe (the swap shifted text ~1-2px = "jiggle", and flashed mermaid/theme colors,
215
+ because two documents never render pixel-identically). The live app (.sh-live) sits underneath and
216
+ shows ONLY when: the frame is interacted (.interact), laser/comment mode is on, or the lean is not
217
+ yet built (no data-ready = live-fallback). Hard cut, never a crossfade - two ~1px-offset text docs
218
+ would ghost into double text. */
219
+ .sh-lean { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; z-index: 1;
220
+ opacity: 0; pointer-events: none; transition: none; background: var(--node-bg) }
221
+ .sh-lean[data-ready] { opacity: 1 }
222
+ .sh-node.interact .sh-lean,
223
+ body.sh-laser .sh-lean,
224
+ body.sh-commenting .sh-lean { opacity: 0 }
225
+ .sh-overlay { position: absolute; inset: 0; z-index: 2 } /* above the lean (z1) so drag-by-body works; inherits the app arrow cursor */
191
226
  .sh-node.interact { border-color: var(--interact);
192
227
  outline: calc(2px * var(--sh-inv, 1)) solid var(--interact);
193
228
  outline-offset: calc(-1px * var(--sh-inv, 1));
@@ -309,8 +344,11 @@ body.sh-space .sh-node { pointer-events: none } /* space-drag
309
344
  transform-origin: 22px 22px; transition: transform var(--morph), opacity .2s ease }
310
345
  .sh-panel.closed { transform: scale(.16); opacity: 0; pointer-events: none }
311
346
  .sh-panel-top { display: flex; align-items: center; gap: 8px; padding: 2px 2px 8px 8px }
312
- .sh-panel-top .mark { flex: none; color: var(--accent) }
313
- .sh-panel-top .name { font-weight: 700; font-size: 15px; flex: 1; letter-spacing: -0.01em }
347
+ .sh-panel-top .mark-link { flex: none; display: flex; border-radius: 6px; outline-offset: 2px; transition: opacity .15s ease }
348
+ .sh-panel-top .mark-link:hover { opacity: .72 }
349
+ .sh-panel-top .mark { flex: none; color: var(--accent); display: block }
350
+ .sh-panel-top .name { margin-right: auto; min-width: 0; font-weight: 700; font-size: 15px; letter-spacing: -0.01em;
351
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap }
314
352
  .sh-panel-scroll { overflow-y: auto; min-height: 0; padding-bottom: 2px; scrollbar-width: thin;
315
353
  scrollbar-color: var(--glass-ink-3) transparent }
316
354
  /* Sidebar list system (macOS-sidebar conventions): every row is 28px tall with an
@@ -373,6 +411,8 @@ body.sh-space .sh-node { pointer-events: none } /* space-drag
373
411
  .sh-pill-btn.pct { font-variant-numeric: tabular-nums; min-width: 46px }
374
412
  .sh-pill-btn.off { color: var(--glass-ink-3); cursor: default }
375
413
  .sh-pill-btn.off:hover { background: none; color: var(--glass-ink-3) }
414
+ .sh-pill-btn.on { color: var(--accent); background: color-mix(in srgb, var(--accent) 12%, transparent) }
415
+ .sh-pill-btn.on:hover { color: var(--accent); background: color-mix(in srgb, var(--accent) 18%, transparent) }
376
416
  .sh-pill .sep { width: 1px; height: 16px; background: var(--glass-brd); margin: 0 3px }
377
417
 
378
418
  /* dropdown menu (theme) - portaled to .sh-app (its own backdrop root), left-aligned to its trigger */
@@ -437,7 +477,7 @@ body.sh-space .sh-node { pointer-events: none } /* space-drag
437
477
  /* ---- Play mode (SPEC-M2 §1): near-black stage, one centered device, auto-hiding bar.
438
478
  Fixed dark palette - the backdrop never follows the board theme, so no tokens here. */
439
479
  .sh-play { position: absolute; inset: 0; z-index: 30; background: #0a0a0b; display: flex;
440
- align-items: center; justify-content: center; animation: sh-play-in .28s ease-out }
480
+ align-items: center; justify-content: center; animation: sh-play-in .28s ease-out; cursor: auto }
441
481
  @keyframes sh-play-in { from { opacity: 0 } }
442
482
  /* the device wears the frame-node card language: same theme border tokens, the same
443
483
  masked 1px edge-light gradient ring (glass catching light), and a real drop shadow
@@ -486,6 +526,8 @@ body.sh-space .sh-node { pointer-events: none } /* space-drag
486
526
  /* board switcher */
487
527
  .sh-play-bar .bd-wrap { position: relative }
488
528
  .sh-play-bar .bd { width: auto; gap: 6px; padding: 0 10px 0 12px; white-space: nowrap; font: inherit }
529
+ .sh-play-bar .sh-play-update { width: auto; gap: 5px; padding: 0 11px; font-size: 12px; font-weight: 600; color: #6db3ff; white-space: nowrap }
530
+ .sh-play-bar .sh-play-update:hover { background: rgba(109, 179, 255, .16); color: #8ec5ff }
489
531
  /* no backdrop-filter here: the menu nests inside the blurred bar, and glass never nests
490
532
  (a nested backdrop-filter samples the bar, not the page) - near-opaque ink instead */
491
533
  .sh-play-menu { position: absolute; top: calc(100% + 10px); left: 0; min-width: 150px; padding: 4px;
@@ -633,3 +675,150 @@ body.sh-kbd .sh-app :focus-visible { outline: 2px solid var(--accent); outline-o
633
675
  .sub.vgroup { cursor: pointer }
634
676
  .sub.vgroup.held { background: color-mix(in srgb, var(--glass-hover) 55%, transparent) }
635
677
  .sub.vgroup.held .gicon { color: var(--glass-ink-2) }
678
+
679
+ /* ---- comments (SPEC-M3 §6) ----------------------------------------------------------
680
+ The marker family (pins, stacks) is SOLID with the Figma teardrop silhouette - a
681
+ marker must hold contrast on any artwork. The surfaces (card, composer, dialog)
682
+ are OUR glass - the shell's own token language, theme-aware where Figma is flat. */
683
+ .cm-avatar { display: inline-flex; align-items: center; justify-content: center; border-radius: 50%;
684
+ color: #fff; font-weight: 700; font-family: -apple-system, system-ui, sans-serif;
685
+ object-fit: cover; flex: none; user-select: none }
686
+
687
+ /* markers - pins and stacks share one face: overlapping avatars + a total count.
688
+ A pin shows one thread's participants (tail points at its div); a stack shows
689
+ everyone across the frame's threads (tail points up-right at the frame corner). */
690
+ .cm-pin, .cm-stack { display: inline-flex; align-items: center; padding: 4px;
691
+ background: #fff; border: 1px solid rgba(24,24,27,.1); cursor: pointer;
692
+ font: 700 11px -apple-system, system-ui, sans-serif; color: #18181b }
693
+ .cm-pin .cm-avatar, .cm-stack .cm-avatar { border: 2px solid #fff }
694
+ .cm-pin .cm-avatar + .cm-avatar, .cm-stack .cm-avatar + .cm-avatar { margin-left: -8px }
695
+ .cm-pin b, .cm-stack b { padding: 0 8px 0 6px }
696
+
697
+ /* pin: the teardrop. Bottom-left tail points at the anchored element. */
698
+ .cm-pin { position: absolute; z-index: 30; transform: translate(-2px, -100%) scale(var(--sh-inv, 1));
699
+ transform-origin: bottom left; border-radius: 18px 18px 18px 4px;
700
+ box-shadow: 0 1px 2px rgba(24,24,27,.18), 0 6px 16px -6px rgba(24,24,27,.35);
701
+ transition: box-shadow .15s, transform .15s }
702
+ .cm-pin:hover { box-shadow: 0 1px 3px rgba(24,24,27,.22), 0 10px 24px -6px rgba(24,24,27,.45) }
703
+ /* selected pin wears the selected frame's exact geometry - border, 2px
704
+ outline hugging the edge, 4px ring halo - in the comment green (the pin
705
+ is already screen-space via scale(--sh-inv), so plain px match the
706
+ frame's calc'd screen px) */
707
+ .cm-pin.on { border-color: var(--comment);
708
+ outline: 2px solid var(--comment); outline-offset: -1px;
709
+ box-shadow: 0 0 0 4px var(--comment-ring), 0 1px 2px rgba(24,24,27,.18), 0 6px 16px -6px rgba(24,24,27,.35) }
710
+ .cm-pin.orphan { border-style: dashed; opacity: .75 }
711
+
712
+ /* stack: the frame-corner cluster - everyone across the frame's threads */
713
+ .cm-stack { position: absolute; top: 10px; right: 10px; z-index: 30;
714
+ transform: scale(var(--sh-inv, 1)); transform-origin: top right; border-radius: 18px 18px 4px 18px;
715
+ box-shadow: 0 1px 2px rgba(24,24,27,.15), 0 6px 16px -6px rgba(24,24,27,.3);
716
+ transition: box-shadow .15s }
717
+ .cm-stack:hover { box-shadow: 0 1px 3px rgba(24,24,27,.2), 0 10px 24px -6px rgba(24,24,27,.4) }
718
+
719
+ /* card: OUR glass - the floating-menu language, theme-aware */
720
+ .cm-card { position: absolute; z-index: 40; width: 300px; padding: 16px;
721
+ transform: scale(var(--sh-inv, 1)); transform-origin: top left;
722
+ background: var(--glass); backdrop-filter: var(--blur); -webkit-backdrop-filter: var(--blur);
723
+ /* an open card is the active selection - the frame's exact geometry, comment green */
724
+ color: var(--glass-ink); border-radius: 20px; border: 1px solid var(--comment);
725
+ outline: 2px solid var(--comment); outline-offset: -1px;
726
+ box-shadow: 0 0 0 4px var(--comment-ring), var(--shadow-glass), 0 16px 40px -12px rgba(24,24,27,.35);
727
+ display: flex; flex-direction: column; gap: 12px;
728
+ font: 400 13px -apple-system, system-ui, sans-serif; letter-spacing: -.005em; text-align: left }
729
+ .cm-card.flip { transform: translateX(-100%) scale(var(--sh-inv, 1)); transform-origin: top right }
730
+ .cm-card header { display: flex; align-items: center; gap: 9px }
731
+ .cm-card header b { font-size: 14px; font-weight: 600; letter-spacing: -.01em }
732
+ .cm-card .dim { color: var(--glass-ink-3); font-size: 11.5px; font-variant-numeric: tabular-nums }
733
+ .cm-actions { position: absolute; top: 13px; right: 12px; display: flex; gap: 2px }
734
+ /* the root header shares its line with the pinned actions - reserve their width
735
+ so a long display name ellipsizes instead of running under the icons */
736
+ .cm-card > header { padding-right: 84px }
737
+ .cm-card > header b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap }
738
+ /* every message - root and replies - is the same unit: avatar + name + time,
739
+ body indented to the name column (24px avatar + 9px gap) */
740
+ .cm-card .cm-body { margin: 0; padding-left: 33px; font-size: 13px; line-height: 1.55;
741
+ color: var(--glass-ink); white-space: pre-wrap; overflow-wrap: break-word }
742
+ .cm-card > .cm-body { margin-top: -10px } /* pulls against the card's 12px gap -> 2px under the header */
743
+ .cm-msg { display: flex; flex-direction: column; gap: 2px }
744
+ .cm-icon { display: inline-flex; align-items: center; justify-content: center; width: 26px; height: 26px;
745
+ border: 0; border-radius: 8px; background: none; color: var(--glass-ink-3); cursor: pointer;
746
+ transition: background .12s, color .12s }
747
+ .cm-icon:hover { background: var(--glass-hover); color: var(--glass-ink) }
748
+ /* copy-link morphs into a check on the shell's morph curve, then reverts */
749
+ .cm-copy { position: relative }
750
+ .cm-copy .a, .cm-copy .b { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
751
+ transition: transform var(--morph), opacity .2s ease }
752
+ .cm-copy .b { transform: scale(.4); opacity: 0; color: var(--comment) }
753
+ .cm-copy.ok .a { transform: scale(.4); opacity: 0 }
754
+ .cm-copy.ok .b { transform: scale(1); opacity: 1 }
755
+ /* composer row: my avatar (list size) + a pill input with a round send button inside */
756
+ .cm-compose { display: flex; align-items: center; gap: 8px }
757
+ .cm-inputwrap { position: relative; flex: 1; min-width: 0 }
758
+ .cm-compose input { width: 100%; height: 36px; padding: 0 42px 0 15px; border-radius: 999px;
759
+ border: 1px solid var(--glass-brd); background: var(--cm-field); color: var(--glass-ink);
760
+ font: 400 13px -apple-system, system-ui, sans-serif; outline: none;
761
+ transition: border-color .15s, box-shadow .15s }
762
+ .cm-compose input::placeholder { color: var(--glass-ink-3) }
763
+ .cm-compose input:focus { border-color: var(--comment); box-shadow: 0 0 0 3px var(--comment-ring) }
764
+ .cm-send { position: absolute; right: 4px; top: 50%; transform: translateY(-50%);
765
+ width: 28px; height: 28px; border-radius: 50%; border: 0; display: flex; align-items: center; justify-content: center;
766
+ background: var(--comment); color: #fff; cursor: pointer; transition: background .15s, opacity .15s }
767
+ .cm-send:hover:not(:disabled) { filter: brightness(1.08) }
768
+ .cm-send:disabled { background: var(--glass-ink-3); opacity: .5; cursor: default }
769
+ /* signed-out viewer: one clear button in place of the composer */
770
+ .cm-signin-cta { width: 100%; height: 40px; border: 0; border-radius: 999px;
771
+ background: var(--comment); color: #fff; cursor: pointer;
772
+ font: 600 13px -apple-system, system-ui, sans-serif; transition: filter .15s }
773
+ .cm-signin-cta:hover { filter: brightness(1.06) }
774
+ /* the draft composer is JUST the pill - no card chrome around a single input */
775
+ .cm-draft { padding: 0; width: 272px; background: none; border: none; box-shadow: none; outline: none;
776
+ backdrop-filter: none; -webkit-backdrop-filter: none }
777
+ .cm-draft input { height: 40px; box-shadow: 0 2px 6px rgba(24,24,27,.15), 0 12px 32px -8px rgba(24,24,27,.35) }
778
+ .cm-draft .cm-send { right: 6px } /* nudge in for the taller draft pill */
779
+ /* identity modal - same glass family */
780
+ .cm-modal-wrap { position: fixed; inset: 0; z-index: 300; display: flex; align-items: center; justify-content: center;
781
+ background: rgba(24,24,27,.45); backdrop-filter: blur(5px) }
782
+ /* the auth modal is a focused surface, not a floating glass panel - near-solid so
783
+ the canvas behind never bleeds through the fields */
784
+ .cm-modal { width: 344px; padding: 26px; border-radius: 24px;
785
+ background: var(--cm-modal-bg); backdrop-filter: var(--blur); -webkit-backdrop-filter: var(--blur);
786
+ color: var(--glass-ink); border: 1px solid var(--glass-brd);
787
+ display: flex; flex-direction: column; gap: 8px; font: 400 13px -apple-system, system-ui, sans-serif;
788
+ box-shadow: 0 1px 2px rgba(24,24,27,.1), 0 20px 60px -12px rgba(24,24,27,.5) }
789
+ .cm-modal h2 { margin: 0; font-size: 16px; letter-spacing: -.01em }
790
+ .cm-modal .dim { margin: 0 0 8px; color: var(--glass-ink-2); font-size: 12.5px; line-height: 1.5 }
791
+ .cm-fields { display: flex; flex-direction: column; gap: 9px }
792
+ .cm-modal input { height: 38px; padding: 0 14px; border-radius: 999px; border: 1px solid var(--glass-brd);
793
+ background: var(--cm-field); color: var(--glass-ink); font: inherit; outline: none }
794
+ .cm-modal input:focus { border-color: var(--comment); box-shadow: 0 0 0 3px var(--comment-ring) }
795
+ .cm-err { color: #b42318; font-size: 12px }
796
+ .cm-chip { height: 38px; box-sizing: border-box; padding: 0 14px; border-radius: 999px;
797
+ border: 1px solid transparent; background: var(--glass-hover);
798
+ display: flex; align-items: center; justify-content: space-between; gap: 10px; overflow: hidden }
799
+ .cm-div { border: 0; height: 1px; background: var(--glass-brd); margin: 1px 2px; align-self: stretch }
800
+ .cm-chip b { font-weight: 500; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap }
801
+ .cm-chip span { font-size: 10.5px; font-weight: 600; letter-spacing: .04em; color: var(--glass-ink-3); flex: none }
802
+ .cm-idrow { display: flex; align-items: center; gap: 10px }
803
+ .cm-pfp { width: 44px; height: 44px; border-radius: 50%; border: 1.5px dashed var(--glass-ink-3);
804
+ background: var(--cm-field) center/cover no-repeat; color: var(--glass-ink-3); font-size: 18px; flex: none;
805
+ display: flex; align-items: center; justify-content: center; cursor: pointer; padding: 0;
806
+ transition: border-color .15s, color .15s }
807
+ .cm-pfp:hover { border-color: var(--comment); color: var(--comment) }
808
+ .cm-pfp.set { border-style: solid; border-color: var(--glass-brd); color: transparent }
809
+ .cm-row { display: flex; gap: 8px; margin-top: 12px }
810
+ .cm-row button:disabled, .cm-row button.cm-primary:disabled {
811
+ background: var(--glass-ink-3); color: var(--glass); cursor: default; opacity: 1 }
812
+ .cm-switch { margin: 2px auto 0; border: 0; background: none; padding: 4px 8px; cursor: pointer;
813
+ font: 500 12px -apple-system, system-ui, sans-serif; color: var(--glass-ink-3); border-radius: 999px }
814
+ .cm-switch:hover { color: var(--glass-ink); background: var(--glass-hover) }
815
+ .cm-row button { height: 36px; padding: 0 16px; border-radius: 999px; border: 1px solid var(--glass-brd);
816
+ background: none; color: var(--glass-ink); font: 600 12.5px -apple-system, system-ui, sans-serif; cursor: pointer }
817
+ .cm-row button:hover { background: var(--glass-hover) }
818
+ .cm-row button.cm-primary { background: var(--glass-ink); color: var(--glass); border-color: transparent; flex: 1 }
819
+ .cm-row button.cm-primary:hover { opacity: .88 }
820
+
821
+ /* the unclipped comment plane over each frame: itself transparent to the pointer,
822
+ its children (pins, cards, stacks) fully interactive */
823
+ .cm-layer { position: absolute; left: 0; width: 100%; pointer-events: none; z-index: 25 }
824
+ .cm-layer > * { pointer-events: auto }
@@ -31,6 +31,8 @@ window.addEventListener('unhandledrejection', (e) => post({ type: 'sh:stage-erro
31
31
  // pinch inside the stage must not zoom the parent page (same rule as the frame bridge)
32
32
  window.addEventListener('wheel', (e) => { if (e.ctrlKey || e.metaKey) e.preventDefault() }, { passive: false })
33
33
  document.addEventListener('gesturestart', (e) => e.preventDefault())
34
+ // B0.2: a nested scroll container hitting its boundary must not chain into the shell page
35
+ document.documentElement.style.overscrollBehavior = 'contain'
34
36
 
35
37
  interface Mounted { id: string; Frame: ComponentType; wrappers: ComponentType[] }
36
38
 
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Comment events - the PURE half of the store (SPEC-M3 §1), shared by the node side
3
+ * (JSONL persistence in server/comments.ts) and the browser shell (thread state from
4
+ * fetched events). No node imports here, ever.
5
+ */
6
+
7
+ export type EventType = 'create' | 'reply' | 'edit' | 'resolve' | 'reopen' | 'react' | 'profile'
8
+
9
+ export interface CommentEvent {
10
+ id: string // client-generated UUID - the idempotency key
11
+ ts: number // ms epoch at creation
12
+ type: EventType
13
+ commentId?: string // the comment this event belongs to (root id = thread id)
14
+ parentId?: string // replies: the ROOT comment id (threads are flat)
15
+ board?: string
16
+ nodeKey?: string // a frame can sit on a board twice - comments are node-scoped
17
+ frame?: string
18
+ anchor?: unknown // SPEC-M3 §5 bundle; absent = frame-level comment
19
+ author?: { email: string; name?: string; avatar?: string }
20
+ body?: string // plain text in v1
21
+ emoji?: string // react events
22
+ addressedIn?: string // resolve events: the variant frame that answered
23
+ }
24
+
25
+ export interface Thread {
26
+ id: string
27
+ board?: string; nodeKey?: string; frame?: string
28
+ anchor?: unknown
29
+ author?: CommentEvent['author']
30
+ body?: string
31
+ ts: number
32
+ resolved: boolean
33
+ addressedIn?: string
34
+ replies: { id: string; author?: CommentEvent['author']; body?: string; ts: number }[]
35
+ reactions: Record<string, string[]> // emoji -> author emails (toggle semantics)
36
+ }
37
+
38
+ /** Events the other side lacks, by id - the sync payload in either direction. */
39
+ export function diffEvents(mine: CommentEvent[], theirIds: Iterable<string>): CommentEvent[] {
40
+ const have = new Set(theirIds)
41
+ return mine.filter((e) => !have.has(e.id))
42
+ }
43
+
44
+ /** Replay a board's events into current thread state. Deterministic: replay order is
45
+ * (ts, id) so two stores holding the same event SET always derive the same state.
46
+ * TWO passes - creates first, then everything else - so a reply whose author's clock
47
+ * ran ahead of the creator's still lands instead of being dropped forever. */
48
+ export function replay(events: CommentEvent[]): Thread[] {
49
+ const ordered = [...events].sort((a, b) => a.ts - b.ts || (a.id < b.id ? -1 : 1))
50
+ const threads = new Map<string, Thread>()
51
+ for (const ev of ordered) {
52
+ if (ev.type !== 'create') continue
53
+ if (!ev.commentId || threads.has(ev.commentId)) continue
54
+ threads.set(ev.commentId, {
55
+ id: ev.commentId, board: ev.board, nodeKey: ev.nodeKey, frame: ev.frame,
56
+ anchor: ev.anchor, author: ev.author, body: ev.body, ts: ev.ts,
57
+ resolved: false, replies: [], reactions: {},
58
+ })
59
+ }
60
+ for (const ev of ordered) {
61
+ switch (ev.type) {
62
+ case 'reply': {
63
+ const t = ev.parentId ? threads.get(ev.parentId) : undefined
64
+ if (!t || !ev.commentId || t.replies.some((r) => r.id === ev.commentId)) break
65
+ t.replies.push({ id: ev.commentId, author: ev.author, body: ev.body, ts: ev.ts })
66
+ break
67
+ }
68
+ case 'edit': {
69
+ if (!ev.commentId || ev.body === undefined) break
70
+ const t = threads.get(ev.commentId)
71
+ if (t) { t.body = ev.body; break }
72
+ for (const th of threads.values()) {
73
+ const r = th.replies.find((r) => r.id === ev.commentId)
74
+ if (r) { r.body = ev.body; break }
75
+ }
76
+ break
77
+ }
78
+ case 'resolve': {
79
+ const t = ev.commentId ? threads.get(ev.commentId) : undefined
80
+ if (t) { t.resolved = true; if (ev.addressedIn) t.addressedIn = ev.addressedIn }
81
+ break
82
+ }
83
+ case 'reopen': {
84
+ const t = ev.commentId ? threads.get(ev.commentId) : undefined
85
+ if (t) { t.resolved = false; t.addressedIn = undefined }
86
+ break
87
+ }
88
+ case 'react': {
89
+ // toggle keyed on comment+author+emoji: present removes, absent adds
90
+ const t = ev.commentId ? threads.get(ev.commentId) : undefined
91
+ const who = ev.author?.email
92
+ if (!t || !who || !ev.emoji) break
93
+ const users = (t.reactions[ev.emoji] ??= [])
94
+ const at = users.indexOf(who)
95
+ if (at === -1) users.push(who)
96
+ else { users.splice(at, 1); if (!users.length) delete t.reactions[ev.emoji] }
97
+ break
98
+ }
99
+ // 'profile' events update author snapshots at write time on the client - replay ignores them
100
+ }
101
+ }
102
+ return [...threads.values()]
103
+ }
@@ -21,7 +21,8 @@ file in design/instructions/ - they are short, strict, and part of this contract
21
21
  | Build | hi-fi frames from real components | instructions/craft.md + components.md |
22
22
  | Iterate | changing a frame the human has seen, or retiring explorations | instructions/iterate.md |
23
23
  | Review | before presenting anything | instructions/review.md |
24
- | Boards | creating a board or publishing | instructions/boards.md |
24
+ | Boards | creating a board, choosing what ships | instructions/boards.md |
25
+ | Publish | deploying the canvas: gate, volume, accounts, invites | instructions/publish.md |
25
26
 
26
27
  Refining an existing screen: Configure must hold, then Build + Review. New work runs
27
28
  the full ladder. Unsure which phase you are in? Ask the human - one question beats a
@@ -37,6 +38,17 @@ Stuck, or the human is unhappy with a result? instructions/reference/ holds the
37
38
  guides (layout, typography, color, motion, copy, states, tuning, critique, concepts) -
38
39
  the routing index is at the top of instructions/craft.md. Pull ONE file, apply, return.
39
40
 
41
+ ## When the human points at a specific element
42
+
43
+ Two channels carry element-precise feedback - honor both:
44
+ - **A pasted address** like `design/scenes/hero/a.tsx · #root > div > h1 (a.tsx:12)` is
45
+ a LASER-COPIED pointer: the human pressed L (laser mode), hovered to see the element,
46
+ clicked it, and its exact address landed on their clipboard. Open that frame file and
47
+ go straight to that element - the css path (and source location, when present) are exact.
48
+ - **A pinned comment** on an element: run `npx marver comments list --open --json` - each
49
+ thread carries the anchored element (tag, quoted text, css path, frame). Work that queue
50
+ per instructions/iterate.md; the comment names the div, so read the anchor before the words.
51
+
40
52
  ## Frames
41
53
  - A frame = one file: design/scenes/<scene>/<name>.tsx or .html. One frame, one surface.
42
54
  - It default-exports a React component. No imports from the tool are needed. Optional:
@@ -21,7 +21,8 @@ file in design/instructions/ - they are short, strict, and part of this contract
21
21
  | Build | hi-fi frames from real components | instructions/craft.md + components.md |
22
22
  | Iterate | changing a frame the human has seen, or retiring explorations | instructions/iterate.md |
23
23
  | Review | before presenting anything | instructions/review.md |
24
- | Boards | creating a board or publishing | instructions/boards.md |
24
+ | Boards | creating a board, choosing what ships | instructions/boards.md |
25
+ | Publish | deploying the canvas: gate, volume, accounts, invites | instructions/publish.md |
25
26
 
26
27
  Refining an existing screen: Configure must hold, then Build + Review. New work runs
27
28
  the full ladder. Unsure which phase you are in? Ask the human - one question beats a
@@ -37,6 +38,17 @@ Stuck, or the human is unhappy with a result? instructions/reference/ holds the
37
38
  guides (layout, typography, color, motion, copy, states, tuning, critique, concepts) -
38
39
  the routing index is at the top of instructions/craft.md. Pull ONE file, apply, return.
39
40
 
41
+ ## When the human points at a specific element
42
+
43
+ Two channels carry element-precise feedback - honor both:
44
+ - **A pasted address** like `design/scenes/hero/a.tsx · #root > div > h1 (a.tsx:12)` is
45
+ a LASER-COPIED pointer: the human pressed L (laser mode), hovered to see the element,
46
+ clicked it, and its exact address landed on their clipboard. Open that frame file and
47
+ go straight to that element - the css path (and source location, when present) are exact.
48
+ - **A pinned comment** on an element: run `npx marver comments list --open --json` - each
49
+ thread carries the anchored element (tag, quoted text, css path, frame). Work that queue
50
+ per instructions/iterate.md; the comment names the div, so read the anchor before the words.
51
+
40
52
  ## Frames
41
53
  - A frame = one file: design/scenes/<scene>/<name>.tsx or .html. One frame, one surface.
42
54
  - It default-exports a React component. No imports from the tool are needed. Optional:
@@ -78,9 +78,12 @@ ordered list of atoms and `{ "space": n }` tokens.
78
78
 
79
79
  ## Publishing
80
80
 
81
- Boards are the unit of publishing (`marver build --boards <name>`): every frame a
82
- published flow data-gotos must be ON that board - unlisted frames are excluded from
83
- the bundle at build time.
81
+ Boards are the unit of publishing: `design/publish.json` names each shipped board
82
+ with `read` or `comment` rights (`marver build` fails without it - default-closed;
83
+ `--boards <name>` overrides ad hoc). Every frame a published flow data-gotos must
84
+ be ON a published board - unlisted frames are excluded from the bundle at build
85
+ time. Deploying the built canvas - gate password, the collaboration volume,
86
+ accounts and invites - is its own phase: **instructions/publish.md**.
84
87
 
85
88
  The published gate page shows the app's identity: `design/logo.svg` + the host
86
89
  package name (overridable via config `share`). If the app has no logo asset yet,
@@ -39,6 +39,34 @@ canvas, the tour - that flow lives in instructions/welcome.md, run it alongside.
39
39
  several half-brands - document the one the app actually ships in DESIGN.md and
40
40
  name the others as legacy.
41
41
 
42
+ ## Working with teammates (branches, merges, a second engineer)
43
+
44
+ The whole `design/` folder is git-tracked - boards, scenes, comment logs, the
45
+ publish policy, these instructions. Only `design/.local/` (this machine's
46
+ connect credential) and `design/.dist/` (built on the host) are ignored. So a
47
+ teammate gets the exact canvas by pulling the branch: `pnpm install` →
48
+ `npx marver dev`. Nothing else to sync.
49
+
50
+ How the three kinds of design state merge across branches:
51
+
52
+ - **Comment logs (`design/comments/*.jsonl`) merge themselves.** They're
53
+ append-only and keyed by event id, and init writes a `merge=union` git
54
+ attribute for them - two branches that both collected feedback union
55
+ cleanly, no conflict. Even a hand-botched merge self-heals: replay dedupes
56
+ by id. This is the point of the event-log design - multiplayer comments
57
+ Just Work through plain git.
58
+ - **Scenes (`design/scenes/**.tsx`) merge like any code** - they're React
59
+ components. Standard review, standard conflicts.
60
+ - **Boards (`design/boards/*.json`) are the one friction** - they're node
61
+ positions, so two people rearranging the same board conflict on x/y. It's
62
+ cosmetic: take either side and re-run Tidy, or give features their own
63
+ boards so layouts don't overlap. Never let a boards conflict block a merge.
64
+
65
+ If collaboration is deployed, each engineer runs `marver comments connect
66
+ <url>` ONCE with their own account (the owner invites them) to get the live
67
+ cloud sync on top of git. Git carries the committed comments; `connect` adds
68
+ the real-time stream from published viewers.
69
+
42
70
  ## When it breaks mid-project
43
71
 
44
72
  Frames suddenly unstyled → the theme import path moved: fix `design/theme.css`.
@@ -48,3 +48,41 @@ The human picks a direction; then, in one pass:
48
48
  Archived frames are history, not options: never link them from live flows,
49
49
  never count them as current design. They exist so "what did we try for the
50
50
  editor?" has a visual answer.
51
+
52
+ ## Comments are your work queue
53
+
54
+ When collaboration is on, humans pin comments to specific elements inside
55
+ frames. Those threads are addressed to YOU as much as to the designer - treat
56
+ the open list as a queue:
57
+
58
+ ```bash
59
+ npx marver comments list --open --json # what needs you (anchors included)
60
+ npx marver comments reply <thread> --body "…"
61
+ npx marver comments resolve <thread> --addressed-in <scene/frame>
62
+ ```
63
+
64
+ The discipline:
65
+
66
+ 1. **Read the anchor before the words.** Each thread carries the element it
67
+ points at - tag, quote, source hint, position. "Too cramped" pinned to a
68
+ button is a different task than "too cramped" on the whole frame.
69
+ 2. **Fork, don't overwrite.** Address feedback by creating a NEW variant of the
70
+ frame (the letter convention above) and iterating there. The commented
71
+ frame stays as the before; your variant is the after.
72
+ 3. **Resolve with the receipt.** `--addressed-in <the-new-variant>` records
73
+ WHICH frame answered the feedback - the thread becomes an auditable link
74
+ from complaint to fix. Reply first when the change deserves a sentence of
75
+ explanation; resolve silently only for trivial mechanical fixes.
76
+ 4. **Never resolve what you didn't address.** Disagree? Reply with your
77
+ reasoning and leave the thread open - the human closes debates, you close
78
+ completed work.
79
+ 5. **A frame with open comments is load-bearing - never delete, rename, or
80
+ gut it.** Its threads are anchored to elements INSIDE it; restructure the
81
+ frame and the anchors strand (a dead anchor parks the pin at the frame
82
+ edge; deleting the whole frame strands the thread off-canvas entirely -
83
+ never lost from the log, but invisible until the frame returns). So: fork
84
+ the variant and iterate THERE, leave the commented frame untouched as the
85
+ before, and only once you `resolve --addressed-in <variant>` its threads
86
+ may it move to `archive/`. Resolve first, restructure second - never the
87
+ reverse. Check `comments list --board <b>` (no `--open`) to see resolved
88
+ threads too; the full history lives in the append-only log and in git.