@marver-design/marver 0.7.0 → 0.8.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 (54) hide show
  1. package/CHANGELOG.md +141 -3
  2. package/README.md +72 -16
  3. package/dist/{auth-B36fMCM3.mjs → auth-KQ9Aj-nB.mjs} +1 -1
  4. package/dist/{build-BZaPa2DS.mjs → build-BBVQRetk.mjs} +5 -5
  5. package/dist/cli.mjs +16 -7
  6. package/dist/{collab-CXy8gqoz.mjs → collab-s3k5byM1.mjs} +18 -7
  7. package/dist/{comments-Ba8mU600.mjs → comments-BZBKhKRO.mjs} +4 -10
  8. package/dist/{comments-odHzYdO3.mjs → comments-J06jqCVV.mjs} +10 -16
  9. package/dist/daemon-DkyNOwIt.mjs +878 -0
  10. package/dist/{dev-DaPQ9xA5.mjs → dev-BTAhTie-.mjs} +28 -3
  11. package/dist/events-BMtBvvgU.mjs +101 -0
  12. package/dist/{init-DsCUmlCW.mjs → init-DWdhjJD5.mjs} +28 -3
  13. package/dist/ledger-CbzTJrV2.mjs +64 -0
  14. package/dist/{manifest-C8FODq2S.mjs → manifest-B4zcDGBf.mjs} +19 -4
  15. package/dist/{plugin-wMY9lNf3.mjs → plugin-BdQEeTLg.mjs} +89 -41
  16. package/dist/profile-BkiWglVE.mjs +39 -0
  17. package/dist/{serve-D_KBK7Oy.mjs → serve-CZqPnj19.mjs} +32 -8
  18. package/dist/{sync-CkBk-tUk.mjs → sync-BJKKmy1n.mjs} +5 -103
  19. package/dist/work-CLrmY-vQ.mjs +97 -0
  20. package/dist/work-lzC-lPY0.mjs +76 -0
  21. package/package.json +16 -1
  22. package/src/client/const.ts +1 -1
  23. package/src/client/content/diagram.tsx +1 -1
  24. package/src/client/content/index.tsx +2 -2
  25. package/src/client/content/md.ts +1 -1
  26. package/src/client/content/palette.ts +2 -2
  27. package/src/client/frame-host/bridge.js +1 -1
  28. package/src/client/frame-host/inspect.js +16 -3
  29. package/src/client/frame-host/serialize.ts +2 -2
  30. package/src/client/shell/App.tsx +165 -26
  31. package/src/client/shell/Comments.tsx +434 -78
  32. package/src/client/shell/Play.tsx +12 -8
  33. package/src/client/shell/Toolbar.tsx +14 -2
  34. package/src/client/shell/canvas/Canvas.tsx +1 -1
  35. package/src/client/shell/canvas/FrameNode.tsx +47 -15
  36. package/src/client/shell/canvas/snapshots.ts +3 -3
  37. package/src/client/shell/comments-store.ts +147 -33
  38. package/src/client/shell/hash.ts +2 -2
  39. package/src/client/shell/icons.tsx +5 -1
  40. package/src/client/shell/keys.ts +39 -0
  41. package/src/client/shell/mentions.ts +18 -0
  42. package/src/client/shell/perf.ts +1 -1
  43. package/src/client/shell/store.ts +127 -39
  44. package/src/client/shell/styles.css +303 -30
  45. package/src/client/shell/tidy.ts +5 -5
  46. package/src/client/stage/main.tsx +2 -2
  47. package/src/shared/events.ts +23 -6
  48. package/src/shared/utm.ts +22 -0
  49. package/templates/AGENTS-embedded.md +49 -1
  50. package/templates/AGENTS-studio.md +49 -1
  51. package/templates/instructions/configure.md +15 -0
  52. package/templates/instructions/jam.md +85 -0
  53. package/templates/instructions/publish.md +1 -1
  54. package/dist/rolldown-runtime-D7D4PA-g.mjs +0 -13
@@ -157,7 +157,7 @@ button svg { pointer-events: none } /* event targets stay on the button - pan
157
157
  #sh-world.sh-preset .sh-node-body,
158
158
  #sh-world.sh-preset .sh-node .sh-live { transition: width .32s cubic-bezier(.25,.46,.45,.94),
159
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
160
+ /* Device-sweep: a frame WITH a ready lean cover jumps its LIVE iframe straight to the final
161
161
  width (ONE reflow, hidden under the cover) while the lean cover - being width:100% of the animating
162
162
  body - reflows smoothly every frame (real CSS, the device-sweep fix). A frame WITHOUT a usable
163
163
  cover keeps the live width animation above. */
@@ -200,7 +200,7 @@ body.sh-space .sh-node { pointer-events: none } /* space-drag
200
200
  background-color: var(--head-bg); background-image: var(--head-sheen);
201
201
  backdrop-filter: var(--blur); -webkit-backdrop-filter: var(--blur) }
202
202
  .sh-node-head .id { font-weight: 600; color: var(--head-ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap }
203
- /* frame icons (SPEC-026 + feedback): every sidebar row leads with one */
203
+ /* frame icons: every sidebar row leads with one */
204
204
  .sh-node-head .iicon { flex: none; color: var(--head-dim) }
205
205
  .sh-panel .sub .iicon { flex: none; margin-right: 7px; opacity: .72 }
206
206
  /* variant members sit visibly INSIDE their group: the letter chip starts
@@ -209,7 +209,7 @@ body.sh-space .sh-node { pointer-events: none } /* space-drag
209
209
  .sh-node-head .dim { margin-left: auto; color: var(--head-dim); flex: none; font-variant-numeric: tabular-nums }
210
210
  .sh-node-body { position: relative; background: var(--node-bg); border-radius: 0 0 var(--r-node) var(--r-node); overflow: hidden }
211
211
  .sh-node iframe { border: 0; display: block }
212
- /* SPEC-M5 LEAN-PRIMARY: the lean DOM-snapshot <iframe> (static html, 0 JS) is what you SEE for a
212
+ /* LEAN-PRIMARY: the lean DOM-snapshot <iframe> (static html, 0 JS) is what you SEE for a
213
213
  passive frame - at rest AND during pan/zoom/resize. There is NO per-gesture swap between the lean
214
214
  and the live iframe (the swap shifted text ~1-2px = "jiggle", and flashed mermaid/theme colors,
215
215
  because two documents never render pixel-identically). The live app (.sh-live) sits underneath and
@@ -287,7 +287,102 @@ body.sh-commenting .sh-lean { opacity: 0 }
287
287
  }
288
288
  @keyframes sh-orbit { to { --sh-rot: 360deg } }
289
289
  @media (prefers-reduced-motion: reduce) {
290
- .sh-node.interact::before, .sh-node.interact::after { animation: none } }
290
+ .sh-node.interact::before, .sh-node.interact::after,
291
+ .sh-node.working::before { animation: none } }
292
+
293
+ /* Live Jam "working": the SELECTED state's exact geometry - crisp border + 2px screen-px
294
+ outline + 4px ring, edge-light off - in MARVER BLUE, composed of three
295
+ layers: the border shimmer (::before orbit), the periodic content WAVE (.sh-work-wave), and the
296
+ shimmer MATRIX of tiny marks on the left flank (.sh-work-ind). Every animated layer is phased by
297
+ --mv-w0 (negative ms since THIS frame's job started), so parallel frames never pulse in sync.
298
+ When a human is actually IN the frame, interact's magenta treatment wins. */
299
+ .sh-node.working { --work: var(--mv-blue); --work-ring: rgba(0, 136, 255, .2) }
300
+ .sh-app.dark .sh-node.working { --work: #4db1ff; --work-ring: rgba(0, 136, 255, .3) }
301
+ .sh-node.working:not(.interact) { border-color: var(--work);
302
+ outline: calc(2px * var(--sh-inv, 1)) solid var(--work);
303
+ outline-offset: calc(-1px * var(--sh-inv, 1));
304
+ box-shadow: 0 0 0 calc(4px * var(--sh-inv, 1)) var(--work-ring), var(--shadow-node) }
305
+ .sh-node.working:not(.interact)::after { display: none } /* the blue geometry owns the edge, like .sel */
306
+ .sh-node.working:not(.interact)::before {
307
+ content: ''; position: absolute; pointer-events: none;
308
+ inset: calc(-2.5px * var(--sh-inv, 1));
309
+ padding: calc(2.5px * var(--sh-inv, 1));
310
+ border-radius: calc(var(--r-node) + 2px * var(--sh-inv, 1));
311
+ background: conic-gradient(from var(--sh-rot),
312
+ var(--work) 0deg,
313
+ var(--work) 105deg,
314
+ rgba(255, 255, 255, .95) 135deg,
315
+ var(--work) 165deg,
316
+ var(--work) 285deg,
317
+ rgba(255, 255, 255, .95) 315deg,
318
+ var(--work) 345deg,
319
+ var(--work) 360deg);
320
+ -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
321
+ -webkit-mask-composite: xor;
322
+ mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
323
+ mask-composite: exclude;
324
+ opacity: .6;
325
+ animation: sh-orbit 4.2s linear infinite;
326
+ animation-delay: var(--mv-w0, 0s) }
327
+
328
+ /* THE WAVE (Option A: content sweep). One 36s cycle holds five ~1.7s sweeps - dense right after
329
+ the trigger (0s, 5s, 10s), then spaced (19s, 29s) - so a fresh job announces itself and a long
330
+ one keeps a calmer heartbeat. Jumps between sweeps happen at opacity 0 (invisible). */
331
+ .sh-work-wave { position: absolute; inset: 0; overflow: hidden; border-radius: inherit;
332
+ pointer-events: none; z-index: 4 }
333
+ .sh-work-wave::before { content: ''; position: absolute; left: 0; right: 0; top: -55%; height: 55%;
334
+ opacity: 0;
335
+ background: linear-gradient(180deg,
336
+ rgba(0, 136, 255, 0) 0%, rgba(0, 136, 255, .07) 30%, rgba(0, 136, 255, .16) 52%,
337
+ rgba(0, 136, 255, .07) 74%, rgba(0, 136, 255, 0) 100%);
338
+ animation: sh-work-wave 36s linear infinite;
339
+ animation-delay: var(--mv-w0, 0s) }
340
+ @keyframes sh-work-wave {
341
+ 0% { transform: translateY(0); opacity: 0 }
342
+ 0.4% { opacity: 1 }
343
+ 3.9% { opacity: 1 }
344
+ 4.7% { transform: translateY(282%); opacity: 0 }
345
+ 13.8% { transform: translateY(0); opacity: 0 }
346
+ 14.2% { opacity: 1 }
347
+ 17.7% { opacity: 1 }
348
+ 18.5% { transform: translateY(282%); opacity: 0 }
349
+ 27.7% { transform: translateY(0); opacity: 0 }
350
+ 28.1% { opacity: 1 }
351
+ 31.6% { opacity: 1 }
352
+ 32.4% { transform: translateY(282%); opacity: 0 }
353
+ 52.7% { transform: translateY(0); opacity: 0 }
354
+ 53.1% { opacity: 1 }
355
+ 56.6% { opacity: 1 }
356
+ 57.4% { transform: translateY(282%); opacity: 0 }
357
+ 80.5% { transform: translateY(0); opacity: 0 }
358
+ 80.9% { opacity: 1 }
359
+ 84.4% { opacity: 1 }
360
+ 85.2% { transform: translateY(282%); opacity: 0 }
361
+ 100% { transform: translateY(282%); opacity: 0 }
362
+ }
363
+
364
+ /* THE SHIMMER MATRIX: 3 x 6 tiny marver marks on the left flank, top-aligned (the vbadge lane).
365
+ Screen-constant via --sh-inv (tight at every zoom); each mark twinkles on its own scattered
366
+ beat, phased per frame by --mv-w0. */
367
+ /* CLAMPED scaling (the vbadge pattern): screen-constant ~4px marks in the working zoom band,
368
+ but floored at 3 world px (deep zoom-in: grows slightly on screen instead of vanishing) and
369
+ capped at 13 world px (far zoom-out: shrinks with the canvas instead of dominating the frame
370
+ and bleeding onto the left neighbor). No transform scale - the sizes ARE the clamp. */
371
+ .sh-work-ind { position: absolute; top: 0;
372
+ --shm: clamp(3px, calc(4px * var(--sh-inv, 1)), 13px);
373
+ --shg: clamp(1px, calc(1.5px * var(--sh-inv, 1)), 4px);
374
+ right: calc(100% + clamp(6px, calc(10px * var(--sh-inv, 1)), 26px));
375
+ display: grid; grid-template-columns: repeat(2, var(--shm)); gap: var(--shg);
376
+ pointer-events: none; z-index: 5 }
377
+ /* a variant badge (A / B / C + name) owns the top of the left flank - the shimmer sits below it.
378
+ The offset mirrors the badge's own zoom clamps (letter 40px-120px + label), so it tracks the
379
+ badge as it grows and shrinks across the zoom range. */
380
+ .sh-work-ind.below-vbadge { top: min(clamp(64px, calc(32px * var(--sh-inv, 1)), 170px), calc(64px * var(--sh-inv, 1))) }
381
+ .sh-work-ind svg { display: block; width: var(--shm); height: var(--shm); color: var(--work, var(--mv-blue));
382
+ opacity: .12; animation: sh-work-shim .95s ease-in-out infinite }
383
+ @keyframes sh-work-shim { 0%, 100% { opacity: .12; transform: scale(.75) } 35% { opacity: 1; transform: scale(1.15) } }
384
+ @media (prefers-reduced-motion: reduce) {
385
+ .sh-work-wave::before, .sh-work-ind svg { animation: none } }
291
386
 
292
387
  /* selection toolbar: screen-space overlay, constant size at any zoom */
293
388
  .sh-ctx { position: absolute; display: flex; align-items: center; gap: 1px;
@@ -433,7 +528,7 @@ body.sh-commenting .sh-lean { opacity: 0 }
433
528
  .sh-menu .div { height: 1px; background: var(--glass-brd); margin: 4px 6px }
434
529
 
435
530
  /* shadcn-style tooltip: contrast-flipped solid, zoom-fade in */
436
- .sh-tip { position: absolute; z-index: 40; pointer-events: none; padding: 5px 10px; border-radius: 8px;
531
+ .sh-tip { position: absolute; z-index: 400; pointer-events: none; padding: 5px 10px; border-radius: 8px; /* topmost ephemeral chrome - above modals (300) or in-dialog tips vanish */
437
532
  font: 500 12px -apple-system, system-ui, sans-serif; white-space: nowrap; transform: translate(-50%, -100%);
438
533
  display: flex; flex-direction: row; align-items: center; gap: 10px;
439
534
  background: var(--tip-bg); color: var(--tip-ink);
@@ -471,12 +566,65 @@ body.sh-commenting .sh-lean { opacity: 0 }
471
566
  transition: color .15s, background .15s }
472
567
  .sh-update .x:hover { background: var(--glass-hover); color: var(--glass-ink) }
473
568
 
474
- .sh-toasts { position: absolute; left: var(--edge); bottom: var(--edge); z-index: 12; display: flex; flex-direction: column; gap: 6px }
569
+ /* bottom-RIGHT now: jam reply pills live here; plain toasts ride along */
570
+ .sh-toasts { position: absolute; right: var(--edge); bottom: var(--edge); z-index: 32; display: flex;
571
+ flex-direction: column; align-items: flex-end; gap: 8px; max-width: min(360px, 40vw) }
475
572
  .sh-toast { display: flex; align-items: center; gap: 6px; font: 500 12px -apple-system, system-ui, sans-serif;
476
573
  color: var(--glass-ink-2); border-radius: 999px; padding: 7px 13px }
477
574
  .sh-toast svg { color: var(--glass-good); flex: none }
575
+ /* the jam pill's svgs are NOT the toast checkmark: the mark stays white/90 on blue, the X keeps
576
+ its own muted ink - undo the blanket green above */
577
+ .sh-toast.jam svg { color: currentColor }
578
+ .sh-toast-more { color: var(--glass-ink-3); font-size: 11px; padding: 2px 10px }
579
+
580
+ /* the Live Jam reply pill: our full glass treatment, full radius, one row (§9) */
581
+ .sh-toast.jam { gap: 12px; padding: 12px 12px 12px 14px; width: 380px; max-width: 100%; border-radius: 999px;
582
+ background: var(--glass); backdrop-filter: var(--blur); -webkit-backdrop-filter: var(--blur);
583
+ border: 1px solid var(--glass-brd); box-shadow: var(--shadow-glass), 0 12px 32px -12px rgba(24,24,27,.4);
584
+ color: var(--glass-ink); animation: sh-jam-in .18s ease }
585
+ @keyframes sh-jam-in { from { opacity: 0; transform: translateY(6px) } to { opacity: 1; transform: none } }
586
+ /* Marver is ALWAYS its own blue - never the shell accent, which swaps to magenta in interact mode
587
+ (.sh-app.interacting). A fixed var the swap can't touch keeps the avatar + notification on-brand. */
588
+ :root { --mv-blue: #0088ff }
589
+ .sh-jam-mark { flex: none; width: 34px; height: 34px; border-radius: 50%; background: var(--mv-blue); color: rgba(255,255,255,.9);
590
+ display: inline-flex; align-items: center; justify-content: center }
591
+ .sh-jam-txt { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 0; line-height: 1.25 }
592
+ /* row 1: the FRAME is the news - its title in blue (the mark already says it is Marver) */
593
+ .sh-jam-frame { display: flex; align-items: center; font-size: 13.5px; font-weight: 600; color: var(--mv-blue); min-width: 0 }
594
+ .sh-jam-frame .t { overflow: hidden; text-overflow: ellipsis; white-space: nowrap }
595
+ /* row 2: the message alone, darker for readability */
596
+ .sh-jam-prev { font-size: 12.5px; color: var(--glass-ink-2, var(--glass-ink)); overflow: hidden; text-overflow: ellipsis; white-space: nowrap }
597
+
598
+ /* the DECK (3+): newest pill on top, two card edges peeking beneath; +N badge; click to expand */
599
+ .sh-jam-deck { position: relative; cursor: pointer }
600
+ .sh-jam-deck .sh-toast.jam { position: relative; margin: 0 }
601
+ .sh-jam-ghost { position: absolute; left: 0; right: 0; height: 100%; border-radius: 999px;
602
+ background: var(--glass); border: 1px solid var(--glass-brd); box-shadow: var(--shadow-glass) }
603
+ .sh-jam-ghost.g1 { top: 6px; left: 5px; right: 5px; opacity: .8 }
604
+ .sh-jam-ghost.g2 { top: 12px; left: 11px; right: 11px; opacity: .5 }
605
+ .sh-jam-deck { margin-bottom: 12px } /* room for the peeking edges */
606
+ .sh-jam-badge { flex: none; font: 700 11px -apple-system, system-ui, sans-serif; color: var(--glass-ink);
607
+ background: var(--glass-hover); border-radius: 999px; padding: 4px 9px }
478
608
 
479
- /* ---- Play mode (SPEC-M2 §1): near-black stage, one centered device, auto-hiding bar.
609
+ /* the EXPANDED list: newest first, header with count + Clear all, scrolls past ~6.
610
+ The scroller CLIPS at its box - pad it out to the pills' shadow reach (12px drop, 32px
611
+ blur) and cancel with negative margins, so the shadows breathe and nothing moves. */
612
+ .sh-jam-list { display: flex; flex-direction: column; gap: 8px; align-items: stretch;
613
+ max-height: min(60vh, 520px); overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin;
614
+ padding: 22px 26px 32px; margin: -22px -26px -32px }
615
+ .sh-jam-listhead { display: flex; align-items: center; gap: 8px; padding: 0 6px;
616
+ font: 700 10px -apple-system, system-ui, sans-serif; letter-spacing: .06em; color: var(--glass-ink-3) }
617
+ .sh-jam-listhead button { margin-left: auto; border: 0; background: none; cursor: pointer;
618
+ font: 600 11px -apple-system, system-ui, sans-serif; color: var(--mv-blue); padding: 3px 6px }
619
+ .sh-jam-listhead button + button { margin-left: 0; color: var(--glass-ink-3); display: inline-flex }
620
+ .sh-jam-view { flex: none; border: 0; border-radius: 999px; padding: 5px 12px; cursor: pointer;
621
+ background: var(--mv-blue); color: #fff; font: 600 12px -apple-system, system-ui, sans-serif; transition: filter .15s }
622
+ .sh-jam-view:hover { filter: brightness(1.08) }
623
+ .sh-jam-x { flex: none; display: inline-flex; align-items: center; justify-content: center; width: 24px; height: 24px;
624
+ border: 0; border-radius: 50%; background: none; color: var(--glass-ink-3); cursor: pointer; transition: background .12s, color .12s }
625
+ .sh-jam-x:hover { background: var(--glass-hover); color: var(--glass-ink) }
626
+
627
+ /* ---- Play mode: near-black stage, one centered device, auto-hiding bar.
480
628
  Fixed dark palette - the backdrop never follows the board theme, so no tokens here. */
481
629
  .sh-play { position: absolute; inset: 0; z-index: 30; background: #0a0a0b; display: flex;
482
630
  align-items: center; justify-content: center; animation: sh-play-in .28s ease-out; cursor: auto }
@@ -576,8 +724,8 @@ body.sh-commenting .sh-lean { opacity: 0 }
576
724
  .sh-app :focus { outline: none }
577
725
  body.sh-kbd .sh-app :focus-visible { outline: 2px solid var(--accent); outline-offset: -2px }
578
726
 
579
- /* ---- Variant groups (SPEC-023 §4): badge left of grouped frames + group caption.
580
- Both live in WORLD space (scale with the canvas, per Nic's ask) with a screen-space
727
+ /* ---- Variant groups: badge left of grouped frames + group caption.
728
+ Both live in WORLD space (scale with the canvas) with a screen-space
581
729
  minimum via --sh-inv, the resize-handle trick - legible at overview zoom, proportionate
582
730
  up close. Colors are on-canvas ink (the dot-grid's world), not chrome glass. */
583
731
  /* gap to the frame is ~12px screen at working zooms, capped at 40 world px far out:
@@ -613,7 +761,7 @@ body.sh-kbd .sh-app :focus-visible { outline: 2px solid var(--accent); outline-o
613
761
  .sh-app.dark .sh-gcaption { color: rgba(245, 245, 247, .45) }
614
762
 
615
763
  /* sidebar variant rows: group header carries the experimentation mark; each variant
616
- row carries its letter CHIP (accent-filled when selected) + name (SPEC-023 §5) */
764
+ row carries its letter CHIP (accent-filled when selected) + name */
617
765
  /* gap 0: the leading .iicon's margin-right carries the spacing, so the group
618
766
  label lands on the SAME 51px text column as every sibling row */
619
767
  .sub.vgroup { display: flex; align-items: center; gap: 0 }
@@ -631,7 +779,7 @@ body.sh-kbd .sh-app :focus-visible { outline: 2px solid var(--accent); outline-o
631
779
  .sub .chip:hover { color: var(--glass-ink); background: var(--glass-hover) }
632
780
  .sub .chip.on { color: #fff; background: var(--accent); border-color: var(--accent) }
633
781
 
634
- /* play-mode variant chips (SPEC-023 §6): the SAME badge language as the sidebar's
782
+ /* play-mode variant chips: the SAME badge language as the sidebar's
635
783
  variant rows (20px, 7px-radius square, one consistent shape in every state) so the
636
784
  two surfaces read as one concept - but in the pill's own dark-glass inks, never
637
785
  theme tokens (the nav is always dark; theme ink goes invisible in light mode). */
@@ -729,7 +877,7 @@ body.sh-hide-ui .sh-play-nav { display: none !important }
729
877
  .sub.vgroup.held { background: color-mix(in srgb, var(--glass-hover) 55%, transparent) }
730
878
  .sub.vgroup.held .gicon { color: var(--glass-ink-2) }
731
879
 
732
- /* ---- comments (SPEC-M3 §6) ----------------------------------------------------------
880
+ /* ---- comments ----------------------------------------------------------
733
881
  The marker family (pins, stacks) is SOLID with the Figma teardrop silhouette - a
734
882
  marker must hold contrast on any artwork. The surfaces (card, composer, dialog)
735
883
  are OUR glass - the shell's own token language, theme-aware where Figma is flat. */
@@ -761,6 +909,12 @@ body.sh-hide-ui .sh-play-nav { display: none !important }
761
909
  outline: 2px solid var(--cm-line, var(--comment)); outline-offset: -1px;
762
910
  box-shadow: 0 0 0 4px var(--cm-line-ring, var(--comment-ring)), 0 1px 2px rgba(24,24,27,.18), 0 6px 16px -6px rgba(24,24,27,.35) }
763
911
  .cm-pin.orphan { border-style: dashed; opacity: .75 }
912
+ /* the open pin's tail leans TOWARD its card. Default (sharp bottom-left) already reads "left";
913
+ card on the RIGHT mirrors the pin around its anchor - sharp corner bottom-right, box extending
914
+ left+up, so the tail points right at the conversation. */
915
+ .cm-pin.tail-r { border-radius: 18px 18px 4px 18px;
916
+ transform: translate(calc(-100% + 2px), -100%) scale(var(--sh-inv, 1));
917
+ transform-origin: bottom right }
764
918
 
765
919
  /* stack: the frame-corner cluster - everyone across the frame's threads */
766
920
  .cm-stack { position: absolute; top: 10px; right: 10px; z-index: 30;
@@ -780,19 +934,67 @@ body.sh-hide-ui .sh-play-nav { display: none !important }
780
934
  display: flex; flex-direction: column; gap: 12px;
781
935
  font: 400 13px -apple-system, system-ui, sans-serif; letter-spacing: -.005em; text-align: left }
782
936
  .cm-card.flip { transform: translateX(-100%) scale(var(--sh-inv, 1)); transform-origin: top right }
937
+ /* the open thread on the CANVAS is UI, not artwork: a FIXED screen-space panel that never zooms or
938
+ pans with the canvas. Viewport-bounded; the messages scroll inside (.cm-scroll), the composer
939
+ stays pinned at the bottom. */
940
+ /* parked beside the frame (canvas): a WORLD object - it stays put with its frame through panning.
941
+ Scale + max-height come inline (clamped 0.9..1.35 of screen-constant; height capped at the
942
+ frame's). A screen-ish gutter separates card from frame. Long threads scroll inside. */
943
+ /* parked beside the frame (canvas): a world object that scales EXACTLY like the pins - the base
944
+ .cm-card scale(--sh-inv) - so it zooms smoothly with the board and stays readable at any zoom.
945
+ Screen-constant gutters via margins (the left one wider - it clears the working shimmer). */
946
+ .cm-card.parked { width: 320px; overflow: hidden; z-index: 50 }
947
+ .cm-card.parked:hover { z-index: 60 } /* reading/replying wins over neighbouring chrome */
948
+ /* gutters: SNUG by default (~12px screen), and only as wide as what actually occupies the flank -
949
+ the working shimmer strip (.flank-shim) or a variant badge (.flank-badge, the widest). All
950
+ screen-targeted with world caps so they stay sane at both zoom extremes. */
951
+ .cm-card.parked.dock-r { transform-origin: top left; margin-left: clamp(8px, calc(12px * var(--sh-inv, 1)), 36px) }
952
+ .cm-card.parked.dock-l { transform: translateX(-100%) scale(var(--sh-inv, 1)); transform-origin: top right;
953
+ margin-left: calc(-1 * clamp(8px, calc(12px * var(--sh-inv, 1)), 36px)) }
954
+ .cm-card.parked.dock-l.flank-shim { margin-left: calc(-1 * clamp(18px, calc(28px * var(--sh-inv, 1)), 68px)) }
955
+ .cm-card.parked.dock-l.flank-badge { margin-left: calc(-1 * clamp(26px, calc(44px * var(--sh-inv, 1)), 108px)) }
956
+ /* PLAY: fixed beside the static device, viewport-clamped (position comes inline). Near-solid
957
+ ground - the stage floats the card over arbitrary artwork on a dark room, glass is unreadable
958
+ there. No transform: play has no zoom, and a leaked --sh-inv must never scale it. */
959
+ .cm-card.stage-dock { width: 320px; overflow: hidden; z-index: 60; transform: none;
960
+ background: var(--cm-modal-bg, var(--glass)) }
961
+ /* the scroller reaches INTO the card's right padding so its scrollbar rides the card's far
962
+ edge - right of the close button, 3px off the border, a 13px gap to the text. The bar is
963
+ OURS: a slim glass-gray thumb on a transparent track, and it only exists when the thread
964
+ actually overflows. Text width never shifts when it appears. */
965
+ .cm-scroll { display: flex; flex-direction: column; gap: 12px; min-height: 0; overflow-y: auto;
966
+ overscroll-behavior: contain; margin-right: -13px; padding-right: 13px }
967
+ .cm-scroll::-webkit-scrollbar { width: 6px }
968
+ .cm-scroll::-webkit-scrollbar-track { background: transparent }
969
+ .cm-scroll::-webkit-scrollbar-thumb { border-radius: 99px;
970
+ background: color-mix(in srgb, var(--glass-ink-3) 50%, transparent) }
971
+ .cm-scroll::-webkit-scrollbar-thumb:hover { background: var(--glass-ink-3) }
972
+ @supports not selector(::-webkit-scrollbar) {
973
+ .cm-scroll { scrollbar-width: thin;
974
+ scrollbar-color: color-mix(in srgb, var(--glass-ink-3) 50%, transparent) transparent }
975
+ }
976
+ /* "there is more": the scroller masks its OWN content at the overflowing edge - a clean fade of
977
+ the text itself, theme-proof (no paint layer fighting the glass) */
978
+ .cm-scroll.fade-top { -webkit-mask-image: linear-gradient(180deg, transparent, #000 30px); mask-image: linear-gradient(180deg, transparent, #000 30px) }
979
+ .cm-scroll.fade-bot { -webkit-mask-image: linear-gradient(180deg, #000 calc(100% - 30px), transparent); mask-image: linear-gradient(180deg, #000 calc(100% - 30px), transparent) }
980
+ .cm-scroll.fade-top.fade-bot { -webkit-mask-image: linear-gradient(180deg, transparent, #000 30px, #000 calc(100% - 30px), transparent); mask-image: linear-gradient(180deg, transparent, #000 30px, #000 calc(100% - 30px), transparent) }
783
981
  .cm-card header { display: flex; align-items: center; gap: 9px }
784
982
  .cm-card header b { font-size: 14px; font-weight: 600; letter-spacing: -.01em }
785
983
  .cm-card .dim { color: var(--glass-ink-3); font-size: 11.5px; font-variant-numeric: tabular-nums }
786
- .cm-actions { position: absolute; top: 13px; right: 12px; display: flex; gap: 2px }
984
+ /* pinned above the scroller so the close button always wins the click - bare buttons on the
985
+ card's own glass, no chip: the custom scrollbar lives at the card's far edge, right of the
986
+ X, so the two never collide. */
987
+ .cm-actions { position: absolute; top: 13px; right: 12px; display: flex; gap: 2px; z-index: 5 }
787
988
  /* the root header shares its line with the pinned actions - reserve their width
788
- so a long display name ellipsizes instead of running under the icons */
789
- .cm-card > header { padding-right: 84px }
790
- .cm-card > header b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap }
989
+ so a long display name ellipsizes instead of running under the icons.
990
+ (root header + body live INSIDE .cm-scroll - scope there, not .cm-card >) */
991
+ .cm-scroll > header { padding-right: 84px }
992
+ .cm-scroll > header b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap }
791
993
  /* every message - root and replies - is the same unit: avatar + name + time,
792
994
  body indented to the name column (24px avatar + 9px gap) */
793
995
  .cm-card .cm-body { margin: 0; padding-left: 33px; font-size: 13px; line-height: 1.55;
794
996
  color: var(--glass-ink); white-space: pre-wrap; overflow-wrap: break-word }
795
- .cm-card > .cm-body { margin-top: -10px } /* pulls against the card's 12px gap -> 2px under the header */
997
+ .cm-scroll > .cm-body { margin-top: -10px } /* pulls against the scroller's 12px gap -> the same 2px replies get */
796
998
  .cm-msg { display: flex; flex-direction: column; gap: 2px }
797
999
  .cm-icon { display: inline-flex; align-items: center; justify-content: center; width: 26px; height: 26px;
798
1000
  border: 0; border-radius: 8px; background: none; color: var(--glass-ink-3); cursor: pointer;
@@ -805,16 +1007,32 @@ body.sh-hide-ui .sh-play-nav { display: none !important }
805
1007
  .cm-copy .b { transform: scale(.4); opacity: 0; color: var(--comment) }
806
1008
  .cm-copy.ok .a { transform: scale(.4); opacity: 0 }
807
1009
  .cm-copy.ok .b { transform: scale(1); opacity: 1 }
808
- /* composer row: my avatar (list size) + a pill input with a round send button inside */
809
- .cm-compose { display: flex; align-items: center; gap: 8px }
1010
+ /* composer row: my avatar (list size) + an auto-growing textarea with a round send button inside.
1011
+ Multiline: Enter sends, Shift+Enter is a newline (Live Jam §6). The textarea grows 1-6 rows. */
1012
+ .cm-compose { display: flex; align-items: flex-end; gap: 8px }
1013
+ /* the row bottom-aligns (the avatar holds still while the textarea grows), so the avatar
1014
+ needs a lift to sit optically centered against a ONE-LINE pill: (38-24)/2 */
1015
+ .cm-compose > .cm-me, .cm-compose > .cm-avatar { margin-bottom: 7px }
810
1016
  .cm-inputwrap { position: relative; flex: 1; min-width: 0 }
811
- .cm-compose input { width: 100%; height: 36px; padding: 0 42px 0 15px; border-radius: 999px;
812
- border: 1px solid var(--glass-brd); background: var(--cm-field); color: var(--glass-ink);
813
- font: 400 13px -apple-system, system-ui, sans-serif; outline: none;
814
- transition: border-color .15s, box-shadow .15s }
815
- .cm-compose input::placeholder { color: var(--glass-ink-3) }
816
- .cm-compose input:focus { border-color: var(--cm-line, var(--comment)); box-shadow: 0 0 0 3px var(--cm-line-ring, var(--comment-ring)) }
817
- .cm-send { position: absolute; right: 4px; top: 50%; transform: translateY(-50%);
1017
+ /* the mirror layer (.cm-hl) and the textarea share an IDENTICAL box so the caret + styled text align
1018
+ exactly. The textarea sits on top with TRANSPARENT text + bg (caret visible); .cm-hl behind paints the
1019
+ field + the styled text, so @marver shows its trigger colour live as you type (Live Jam §1). */
1020
+ .cm-inputwrap textarea, .cm-hl { box-sizing: border-box; width: 100%; min-height: 38px; max-height: 132px;
1021
+ margin: 0; padding: 9px 42px 9px 15px; border: 1px solid transparent; border-radius: 19px;
1022
+ /* the math must CLOSE: 1 + 9 + 18 + 9 + 1 = 38 exactly, so a single line sits dead-center
1023
+ in the pill (a fractional line box + uncounted borders left the text riding off-center) */
1024
+ font: 400 13px/18px -apple-system, system-ui, sans-serif; letter-spacing: normal;
1025
+ white-space: pre-wrap; overflow-wrap: break-word; word-break: break-word }
1026
+ .cm-hl { position: absolute; inset: 0; z-index: 0; overflow: hidden; pointer-events: none;
1027
+ background: var(--cm-field); color: var(--glass-ink) }
1028
+ .cm-inputwrap textarea { position: relative; z-index: 1; display: block; resize: none; overflow-y: auto;
1029
+ background: transparent; color: transparent; caret-color: var(--glass-ink);
1030
+ border-color: var(--glass-brd); outline: none; transition: border-color .15s, box-shadow .15s }
1031
+ .cm-inputwrap textarea::placeholder { color: var(--glass-ink-3) }
1032
+ .cm-inputwrap textarea:focus { border-color: var(--cm-line, var(--comment)); box-shadow: 0 0 0 3px var(--cm-line-ring, var(--comment-ring)) }
1033
+ .cm-inputwrap textarea:disabled { opacity: .6; cursor: default }
1034
+ .cm-send { position: absolute; right: 5px; bottom: 5px; /* (38 - 28) / 2 - centered in the one-line pill */
1035
+ z-index: 2; /* above the textarea (z1, the @marver mirror stack) - or clicks land on the field, not the button */
818
1036
  width: 28px; height: 28px; border-radius: 50%; border: 0; display: flex; align-items: center; justify-content: center;
819
1037
  background: var(--cm-solid, var(--comment)); color: #fff; cursor: pointer; transition: background .15s, opacity .15s }
820
1038
  .cm-send:hover:not(:disabled) { filter: brightness(1.08) }
@@ -827,8 +1045,47 @@ body.sh-hide-ui .sh-play-nav { display: none !important }
827
1045
  /* the draft composer is JUST the pill - no card chrome around a single input */
828
1046
  .cm-draft { padding: 0; width: 272px; background: none; border: none; box-shadow: none; outline: none;
829
1047
  backdrop-filter: none; -webkit-backdrop-filter: none }
830
- .cm-draft input { height: 40px; box-shadow: 0 2px 6px rgba(24,24,27,.15), 0 12px 32px -8px rgba(24,24,27,.35) }
831
- .cm-draft .cm-send { right: 6px } /* nudge in for the taller draft pill */
1048
+ .cm-draft textarea, .cm-draft .cm-hl { min-height: 40px; padding-top: 10px; padding-bottom: 10px } /* 1+10+18+10+1 = 40, same closed math */
1049
+ .cm-draft textarea { box-shadow: 0 2px 6px rgba(24,24,27,.15), 0 12px 32px -8px rgba(24,24,27,.35) }
1050
+ .cm-draft .cm-send { right: 6px; bottom: 6px } /* (40 - 28) / 2 for the taller draft pill */
1051
+
1052
+ /* Live Jam: @marver mention styling. Owner-authored = a live trigger (bold accent-blue);
1053
+ anyone else's = plain-weight, read like any comment, with a teaching tooltip (§1). */
1054
+ .cm-at { color: var(--glass-ink-2, var(--glass-ink)); font-weight: 500 }
1055
+ .cm-at.owner { color: var(--mv-blue); font-weight: 700 }
1056
+ /* IN THE COMPOSER MIRROR the mention may change COLOR + PAINT, never METRICS: real bold
1057
+ glyphs are WIDER than the invisible textarea's regular ones, so any font-weight change
1058
+ drifts the caret further off with every mention. The bold LOOK comes from a hairline
1059
+ text-stroke instead - it thickens the glyph paint without touching a single advance
1060
+ width, so the mirror stays pixel-locked to the textarea and the caret never lies. */
1061
+ .cm-hl .cm-at { font-weight: inherit; -webkit-text-stroke: .4px currentColor }
1062
+ .cm-at-tip { display: block; max-width: 220px; line-height: 1.4 }
1063
+
1064
+ /* Marver identity: the agent's avatar mark + provenance tooltip (§7). Always accent, never a hue. */
1065
+ .cm-avatar.cm-marver { background: var(--mv-blue); color: rgba(255,255,255,.9); cursor: default } /* white/90 mark, embedded in the accent */
1066
+ .cm-marver-wrap { display: inline-flex; border-radius: 50% }
1067
+ .cm-marver-name { color: var(--glass-ink) } /* black, not blue */
1068
+ /* provenance tooltip: matches the toolbar-button tooltip - a BOLD BRIGHT value (what you scan) + a
1069
+ muted qualifier label after it, one fact per row, left-aligned. Overrides the house single-row
1070
+ `.sh-tip span` dim/right-drift so nothing washes out. */
1071
+ /* two-row shortcut tooltip (toolbar): one action per row, key right-aligned - the same
1072
+ bold-value/muted-key language as the provenance tooltip below. opacity:1 overrides the
1073
+ house `.sh-tip span` dim so the wrapper rows stay full-bright; only keys mute. */
1074
+ .sh-tip:has(.sh-tip-rows) { padding: 7px 11px }
1075
+ .sh-tip .sh-tip-rows { display: flex; flex-direction: column; align-items: stretch; gap: 5px;
1076
+ opacity: 1; margin: 0; font-size: 12px; line-height: 1.2 }
1077
+ .sh-tip .sh-tip-rows .r { display: flex; align-items: baseline; justify-content: space-between;
1078
+ gap: 18px; opacity: 1; margin: 0; font-size: 12px; white-space: nowrap }
1079
+ .sh-tip .sh-tip-rows .r b { font-weight: 600 }
1080
+ .sh-tip .sh-tip-rows .r span { opacity: .55; font-size: 11px; font-weight: 600; margin-left: auto }
1081
+
1082
+ .sh-tip:has(.cm-meta-tip) { padding: 8px 12px }
1083
+ /* opacity:1 is the fix - .cm-meta-tip is a <span>, so the house `.sh-tip span{opacity:.55}` was
1084
+ dimming the ENTIRE tooltip, and a child's opacity:1 can't undo a parent's reduced opacity. */
1085
+ .sh-tip .cm-meta-tip { display: flex; flex-direction: column; align-items: flex-start; gap: 6px; line-height: 1.2; opacity: 1; font-size: 12.5px }
1086
+ .sh-tip .cm-meta-row { display: flex; gap: 8px; align-items: baseline; margin: 0; opacity: 1; font-size: 12.5px; white-space: nowrap }
1087
+ .sh-tip .cm-meta-row b { font-weight: 600; opacity: 1; margin: 0; color: var(--tip-ink) } /* value: bold, full-bright */
1088
+ .sh-tip .cm-meta-row .k { margin: 0; opacity: .5; font-weight: 500; color: var(--tip-ink) } /* label: muted qualifier */
832
1089
  /* identity modal - same glass family */
833
1090
  .cm-modal-wrap { position: fixed; inset: 0; z-index: 300; display: flex; align-items: center; justify-content: center;
834
1091
  background: rgba(24,24,27,.45); backdrop-filter: blur(5px) }
@@ -854,11 +1111,14 @@ body.sh-hide-ui .sh-play-nav { display: none !important }
854
1111
  .cm-chip span { font-size: 10.5px; font-weight: 600; letter-spacing: .04em; color: var(--glass-ink-3); flex: none }
855
1112
  .cm-idrow { display: flex; align-items: center; gap: 10px }
856
1113
  .cm-pfp { width: 44px; height: 44px; border-radius: 50%; border: 1.5px dashed var(--glass-ink-3);
857
- background: var(--cm-field) center/cover no-repeat; color: var(--glass-ink-3); font-size: 18px; flex: none;
1114
+ background: var(--cm-field); color: var(--glass-ink-3); flex: none; overflow: hidden;
858
1115
  display: flex; align-items: center; justify-content: center; cursor: pointer; padding: 0;
859
1116
  transition: border-color .15s, color .15s }
860
1117
  .cm-pfp:hover { border-color: var(--comment); color: var(--comment) }
861
- .cm-pfp.set { border-style: solid; border-color: var(--glass-brd); color: transparent }
1118
+ .cm-pfp.set { border-style: solid; border-color: var(--glass-brd) }
1119
+ /* the photo is a REAL img filling the clipped circle - background-image layering left
1120
+ uncovered edges */
1121
+ .cm-pfp img { width: 100%; height: 100%; object-fit: cover; border-radius: 50%; display: block }
862
1122
  .cm-row { display: flex; gap: 8px; margin-top: 12px }
863
1123
  .cm-row button:disabled, .cm-row button.cm-primary:disabled {
864
1124
  background: var(--glass-ink-3); color: var(--glass); cursor: default; opacity: 1 }
@@ -871,6 +1131,19 @@ body.sh-hide-ui .sh-play-nav { display: none !important }
871
1131
  .cm-row button.cm-primary { background: var(--glass-ink); color: var(--glass); border-color: transparent; flex: 1 }
872
1132
  .cm-row button.cm-primary:hover { opacity: .88 }
873
1133
 
1134
+ /* the dev identity door: your avatar in the composer opens the profile popover. Unset shows a
1135
+ quiet + badge - the whole first-comment nudge, no modal in the way. */
1136
+ .cm-me { position: relative; display: inline-flex; flex: none }
1137
+ .cm-mebtn { position: relative; display: inline-flex; padding: 0; border: 0; background: none;
1138
+ cursor: pointer; border-radius: 50% }
1139
+ /* hover morphs the avatar into a pen on quiet gray - "this is editable" without a badge */
1140
+ .cm-pen-face { position: absolute; inset: 0; border-radius: 50%; box-sizing: border-box;
1141
+ display: flex; align-items: center; justify-content: center;
1142
+ background: color-mix(in srgb, var(--glass-ink) 10%, var(--cm-field));
1143
+ border: 1px solid var(--glass-brd); color: var(--glass-ink-2);
1144
+ opacity: 0; transition: opacity .12s; pointer-events: none }
1145
+ .cm-mebtn:hover .cm-pen-face, .cm-mebtn:focus-visible .cm-pen-face { opacity: 1 }
1146
+
874
1147
  /* the unclipped comment plane over each frame: itself transparent to the pointer,
875
1148
  its children (pins, cards, stacks) fully interactive */
876
1149
  .cm-layer { position: absolute; left: 0; width: 100%; pointer-events: none; z-index: 25 }
@@ -1,7 +1,7 @@
1
1
  export interface TidyNode { key: string; frame: string; scene: string; group?: string; variant?: string; w: number; h: number }
2
2
  export interface Placed { key: string; x: number; y: number }
3
3
 
4
- // SPEC-024 lane-flow grammar: one shape at both scopes. A scope is rows XOR columns
4
+ // Lane-flow grammar: one shape at both scopes. A scope is rows XOR columns
5
5
  // of lanes; a lane is atoms + {space:n} tokens. Board atoms are scene names; a scene
6
6
  // recipe's atoms are frame basenames or a variant-group name (one indivisible run).
7
7
  export interface Space { space: number }
@@ -14,7 +14,7 @@ const isSpace = (x: unknown): x is Space => !!x && typeof x === 'object' && !Arr
14
14
  const units = (s: Space, warn: Warn): number =>
15
15
  Number.isInteger(s.space) && s.space > 0 ? s.space : (warn(`invalid space ${JSON.stringify(s.space)} - using 1`), 1)
16
16
 
17
- // Adaptive units (SPEC-024 §2): a "block" is a multiple of the proportional gutter,
17
+ // Adaptive units: a "block" is a multiple of the proportional gutter,
18
18
  // measured from the touching content's characteristic FRAME size (its largest single
19
19
  // frame) - a wide multi-frame box must not inflate its neighbors' gutters.
20
20
  const frameGapX = (w: number) => Math.max(140, w * 0.12)
@@ -251,7 +251,7 @@ function layoutScene(scene: string, members: TidyNode[], flow: Flow | undefined,
251
251
  }
252
252
 
253
253
  /**
254
- * Pure layout (spec §7 + SPEC-023 + SPEC-024). Returns positions only - the nodes
254
+ * Pure layout. Returns positions only - the nodes
255
255
  * array is never reordered (iframe law G-1). Two passes: each scene lays out its
256
256
  * frames (recipe or default lane), then the board flow places the scene boxes.
257
257
  */
@@ -272,7 +272,7 @@ export function tidy(nodes: TidyNode[], layout?: BoardLayout, warn: Warn = () =>
272
272
  }
273
273
 
274
274
  // pass 2: board flow over scene boxes. rows AND columns is invalid: fall back to
275
- // PLAIN tidy (default trailing lanes), never a silent pick (SPEC-024 §5)
275
+ // PLAIN tidy (default trailing lanes), never a silent pick
276
276
  let boardFlow: Flow
277
277
  if (layout && layout.rows && layout.columns) { warn('layout has rows AND columns - ignoring it (plain tidy)'); boardFlow = { rows: [] } }
278
278
  else if (layout && (layout.rows || layout.columns)) boardFlow = { rows: layout.rows, columns: layout.columns }
@@ -307,7 +307,7 @@ export function tidy(nodes: TidyNode[], layout?: BoardLayout, warn: Warn = () =>
307
307
  return out
308
308
  }
309
309
 
310
- /** Guarded parse of agent-authored board.layout (SPEC-024 §1). Malformed pieces warn
310
+ /** Guarded parse of agent-authored board.layout. Malformed pieces warn
311
311
  * and degrade - they never blank the board, and never vanish silently. */
312
312
  export function parseLayout(raw: unknown, warn: Warn): BoardLayout | null {
313
313
  if (raw === undefined || raw === null) return null
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The stage - play mode's single mount (SPEC-M2 §1). Unlike the frame host (one iframe
2
+ * The stage - play mode's single mount. Unlike the frame host (one iframe
3
3
  * per frame), the stage keeps ONE tree mounted - providers + layout chain - and swaps
4
4
  * only the innermost frame on data-goto, so app shells in _layout persist across
5
5
  * navigation like a real app. Swaps ride document.startViewTransition when available;
@@ -174,7 +174,7 @@ function Stage() {
174
174
  if (err) return createElement(ErrorCard, { title: 'frame failed', message: err.message, detail: err.id })
175
175
  if (!mounted) return null
176
176
  // identical wrapper references across swaps -> React preserves layout state; a chain
177
- // that differs at depth k legitimately remounts everything below (SPEC-M2 §1)
177
+ // that differs at depth k legitimately remounts everything below
178
178
  let tree: ReactNode = createElement(mounted.Frame)
179
179
  for (const W of [...mounted.wrappers].reverse()) tree = createElement(W, null, tree)
180
180
  return createElement(Boundary, { resetKey: mounted.id }, tree)
@@ -1,10 +1,14 @@
1
1
  /**
2
- * Comment events - the PURE half of the store (SPEC-M3 §1), shared by the node side
2
+ * Comment events - the PURE half of the store, shared by the node side
3
3
  * (JSONL persistence in server/comments.ts) and the browser shell (thread state from
4
4
  * fetched events). No node imports here, ever.
5
5
  */
6
6
 
7
- export type EventType = 'create' | 'reply' | 'edit' | 'resolve' | 'reopen' | 'react' | 'profile'
7
+ // 'reanchor' (Live Jam): re-pin a thread to a new element after the agent moved it.
8
+ export type EventType = 'create' | 'reply' | 'edit' | 'resolve' | 'reopen' | 'react' | 'profile' | 'reanchor'
9
+
10
+ // Live Jam: provenance stamped by the daemon on agent-authored events (who orchestrated the change).
11
+ export interface AgentMeta { devUser?: string; harness?: string; model?: string; effort?: string }
8
12
 
9
13
  export interface CommentEvent {
10
14
  id: string // client-generated UUID - the idempotency key
@@ -15,11 +19,15 @@ export interface CommentEvent {
15
19
  board?: string
16
20
  nodeKey?: string // a frame can sit on a board twice - comments are node-scoped
17
21
  frame?: string
18
- anchor?: unknown // SPEC-M3 §5 bundle; absent = frame-level comment
22
+ anchor?: unknown // anchor bundle; absent = frame-level comment; on 'reanchor' = the new anchor
19
23
  author?: { email: string; name?: string; avatar?: string }
20
24
  body?: string // plain text in v1
21
25
  emoji?: string // react events
22
26
  addressedIn?: string // resolve events: the variant frame that answered
27
+ // --- Live Jam additions ---
28
+ agent?: boolean // true = written by the Marver agent (never trusted for execution; render + guard only)
29
+ agentMeta?: AgentMeta // provenance for the Marver avatar tooltip (agent events only)
30
+ origin?: string // server-stamped 'local' on dev-owner writes; the daemon's owner-trigger key
23
31
  }
24
32
 
25
33
  export interface Thread {
@@ -31,7 +39,9 @@ export interface Thread {
31
39
  ts: number
32
40
  resolved: boolean
33
41
  addressedIn?: string
34
- replies: { id: string; author?: CommentEvent['author']; body?: string; ts: number }[]
42
+ agent?: boolean // root authored by the agent
43
+ agentMeta?: AgentMeta
44
+ replies: { id: string; author?: CommentEvent['author']; body?: string; ts: number; agent?: boolean; agentMeta?: AgentMeta }[]
35
45
  reactions: Record<string, string[]> // emoji -> author emails (toggle semantics)
36
46
  }
37
47
 
@@ -54,7 +64,7 @@ export function replay(events: CommentEvent[]): Thread[] {
54
64
  threads.set(ev.commentId, {
55
65
  id: ev.commentId, board: ev.board, nodeKey: ev.nodeKey, frame: ev.frame,
56
66
  anchor: ev.anchor, author: ev.author, body: ev.body, ts: ev.ts,
57
- resolved: false, replies: [], reactions: {},
67
+ resolved: false, agent: ev.agent, agentMeta: ev.agentMeta, replies: [], reactions: {},
58
68
  })
59
69
  }
60
70
  for (const ev of ordered) {
@@ -62,7 +72,7 @@ export function replay(events: CommentEvent[]): Thread[] {
62
72
  case 'reply': {
63
73
  const t = ev.parentId ? threads.get(ev.parentId) : undefined
64
74
  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 })
75
+ t.replies.push({ id: ev.commentId, author: ev.author, body: ev.body, ts: ev.ts, agent: ev.agent, agentMeta: ev.agentMeta })
66
76
  break
67
77
  }
68
78
  case 'edit': {
@@ -85,6 +95,13 @@ export function replay(events: CommentEvent[]): Thread[] {
85
95
  if (t) { t.resolved = false; t.addressedIn = undefined }
86
96
  break
87
97
  }
98
+ case 'reanchor': {
99
+ // Live Jam: re-pin the whole thread to a new element (the agent moved the target).
100
+ // Only a root (thread) can be re-pinned, and a null/absent anchor is ignored (never un-pins).
101
+ const t = ev.commentId ? threads.get(ev.commentId) : undefined
102
+ if (t && ev.anchor != null) t.anchor = ev.anchor
103
+ break
104
+ }
88
105
  case 'react': {
89
106
  // toggle keyed on comment+author+emoji: present removes, absent adds
90
107
  const t = ev.commentId ? threads.get(ev.commentId) : undefined
@@ -0,0 +1,22 @@
1
+ /** Automatic attribution for the powered-by links: every canvas tags its marver.design
2
+ * link so site analytics can see WHICH canvas sent a visitor - zero user setup.
3
+ * Conventional UTM semantics (stable groupings first, the variable last):
4
+ * utm_source = the surface class ('published-canvas' | 'dev-canvas')
5
+ * utm_medium = the link unit ('powered-by')
6
+ * utm_campaign = THIS canvas's name, slugged ("Marver tour" -> "marver-tour")
7
+ * utm_content = the placement ('gate' badge | 'shell' wordmark)
8
+ */
9
+ export function poweredByUrl(
10
+ canvasName: string | undefined,
11
+ source: 'published-canvas' | 'dev-canvas',
12
+ content: 'gate' | 'shell',
13
+ ): string {
14
+ const slug = (canvasName ?? '').toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
15
+ const q = new URLSearchParams({
16
+ utm_source: source,
17
+ utm_medium: 'powered-by',
18
+ ...(slug ? { utm_campaign: slug } : {}),
19
+ utm_content: content,
20
+ })
21
+ return `https://marver.design/?${q.toString()}`
22
+ }