@marver-design/marver 0.18.0 → 0.19.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 (32) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +2 -1
  3. package/dist/{bake-jr38C_pX.mjs → bake-kaf5kGZ7.mjs} +1 -1
  4. package/dist/{build-ER_7T6Cw.mjs → build-7ed5H2vT.mjs} +40 -7
  5. package/dist/cli.mjs +3 -3
  6. package/dist/{daemon-CKhg0zuT.mjs → daemon-DbHvLQUL.mjs} +1 -1
  7. package/dist/{dev-BvLbY98O.mjs → dev-D3mP2x27.mjs} +6 -5
  8. package/dist/{init-BWbHqVng.mjs → init-BQYCS3EU.mjs} +1 -1
  9. package/dist/{manifest-DAnEL8_a.mjs → manifest-B01PSyDc.mjs} +33 -6
  10. package/dist/{plugin-Cai5C1WV.mjs → plugin-DI-7NAnx.mjs} +9 -9
  11. package/dist/{poster-CIuz_PwH.mjs → poster-DNh6N27C.mjs} +1 -1
  12. package/dist/{publish-bakes-D0LhbQQ3.mjs → publish-bakes-Dp-ZFk3d.mjs} +2 -2
  13. package/dist/{shot-iicees2e.mjs → shot-DMDvDbeP.mjs} +2 -2
  14. package/docs/sticky-notes.md +43 -0
  15. package/package.json +2 -1
  16. package/src/client/content/diagram.tsx +27 -1
  17. package/src/client/content/index.tsx +2 -2
  18. package/src/client/content/md.ts +48 -0
  19. package/src/client/shell/App.tsx +7 -63
  20. package/src/client/shell/Comments.tsx +75 -12
  21. package/src/client/shell/Play.tsx +6 -3
  22. package/src/client/shell/canvas/Canvas.tsx +10 -1
  23. package/src/client/shell/canvas/FrameNode.tsx +17 -0
  24. package/src/client/shell/canvas/Sticky.tsx +284 -0
  25. package/src/client/shell/goto.ts +72 -0
  26. package/src/client/shell/notes.ts +151 -0
  27. package/src/client/shell/store.ts +22 -18
  28. package/src/client/shell/styles.css +83 -0
  29. package/src/client/shell/tidy.ts +20 -2
  30. package/templates/AGENTS-embedded.md +15 -0
  31. package/templates/AGENTS-studio.md +15 -0
  32. package/templates/instructions/shape.md +62 -0
@@ -0,0 +1,284 @@
1
+ /**
2
+ * Sticky notes (spec 18): the yellow column left of a frame - the scene's note (when this node
3
+ * hosts it) above the frame's own. Shell DOM, never inside the iframe: sleep, bakes and the frame
4
+ * document are untouched. Markdown through the content package's renderer (the Md block's link
5
+ * and image policy, raw HTML inert); a ```mermaid fence renders hand-drawn on demand.
6
+ *
7
+ * Fold: the dog-ear at the column's top-right corner folds the whole column down to that corner
8
+ * (scale + fade toward it); the corner stays as the tab that unfolds it. Per viewer, in
9
+ * localStorage - never in the board file.
10
+ *
11
+ * Comments: in comment mode a click on a note element stages a draft on the node, anchored like
12
+ * a frame element (notes.ts builds the bundle; the CommentLayer resolves it here in the shell).
13
+ * Links: `goto:` navigates through the one goto path; in comment mode a click picks, never
14
+ * navigates (the bridge rule).
15
+ */
16
+ import { memo, useEffect, useMemo, useRef } from 'react'
17
+ import { renderMarkdown, sanitizeMarkdownHtml } from '../../content/md.ts'
18
+ import { cleanSource, guardDiagramSource, sanitizeSvg } from '../../content/diagram.tsx'
19
+ import { useComments } from '../comments-store.ts'
20
+ import { goTo } from '../goto.ts'
21
+ import { NOTE_W, SCENE_NOTE_W, noteAnchor, noteVisible, useNotes, type NoteKind } from '../notes.ts'
22
+
23
+ export interface NoteSpec { kind: NoteKind; id: string; text: string }
24
+
25
+ /** Diagram text only: the sketched look is the diagram's, prose stays in the shell font. */
26
+ const HAND_FONT = `"Segoe Print", "Bradley Hand", "Chalkboard SE", "Comic Sans MS", "Comic Neue", cursive`
27
+ /** Yellow paper for the hand-drawn look - the same values the sheet paints the sticky with. */
28
+ const PAPER = { bg: '#fff3a3', ink: '#2b2500', line: '#6b5a00', soft: '#ffe680', pale: '#fffbdc', hatch: '#f0cc4e' }
29
+ const THEME_VARS = {
30
+ background: PAPER.bg, fontFamily: HAND_FONT, fontSize: '17px',
31
+ primaryColor: PAPER.soft, primaryTextColor: PAPER.ink, primaryBorderColor: PAPER.line,
32
+ secondaryColor: PAPER.pale, secondaryTextColor: PAPER.ink, secondaryBorderColor: PAPER.line,
33
+ tertiaryColor: PAPER.pale, tertiaryTextColor: PAPER.ink, tertiaryBorderColor: PAPER.line,
34
+ lineColor: PAPER.line, textColor: PAPER.ink, mainBkg: PAPER.soft, nodeBorder: PAPER.line,
35
+ clusterBkg: PAPER.pale, clusterBorder: PAPER.line, titleColor: PAPER.ink,
36
+ edgeLabelBackground: PAPER.bg, noteBkgColor: PAPER.soft, noteTextColor: PAPER.ink, noteBorderColor: PAPER.line,
37
+ actorBkg: PAPER.soft, actorBorder: PAPER.line, actorTextColor: PAPER.ink, signalColor: PAPER.line, signalTextColor: PAPER.ink,
38
+ labelBoxBkgColor: PAPER.pale, labelBoxBorderColor: PAPER.line, labelTextColor: PAPER.ink, loopTextColor: PAPER.ink,
39
+ activationBkgColor: PAPER.pale, activationBorderColor: PAPER.line, sequenceNumberColor: PAPER.ink,
40
+ pie1: PAPER.soft, pie2: PAPER.pale, pie3: '#f3d96a', pie4: '#e9c94f', pie5: '#fff0b8', pie6: '#e0c04a', pieTitleTextColor: PAPER.ink, pieSectionTextColor: PAPER.ink, pieLegendTextColor: PAPER.ink, pieStrokeColor: PAPER.line, pieOuterStrokeColor: PAPER.line,
41
+ // every family that would otherwise bring its own rainbow: gantt sections, journey fills,
42
+ // git branches, the cScale mindmap and timeline use - all the paper's yellows, ink for text
43
+ sectionBkgColor: PAPER.pale, altSectionBkgColor: PAPER.bg, sectionBkgColor2: PAPER.soft,
44
+ taskBkgColor: PAPER.soft, taskBorderColor: PAPER.line, taskTextColor: PAPER.ink, taskTextLightColor: PAPER.ink, taskTextOutsideColor: PAPER.ink, taskTextDarkColor: PAPER.ink,
45
+ activeTaskBkgColor: '#f3d96a', activeTaskBorderColor: PAPER.line, doneTaskBkgColor: PAPER.pale, doneTaskBorderColor: PAPER.line,
46
+ critBkgColor: '#e9c94f', critBorderColor: PAPER.ink, gridColor: PAPER.line, todayLineColor: PAPER.ink,
47
+ fillType0: PAPER.soft, fillType1: PAPER.pale, fillType2: '#f3d96a', fillType3: PAPER.soft, fillType4: PAPER.pale, fillType5: '#f3d96a', fillType6: PAPER.soft, fillType7: PAPER.pale,
48
+ git0: '#e9c94f', git1: '#f3d96a', git2: PAPER.soft, git3: '#e0c04a', git4: PAPER.pale, git5: '#e9c94f', git6: '#f3d96a', git7: PAPER.soft,
49
+ gitBranchLabel0: PAPER.ink, gitBranchLabel1: PAPER.ink, gitBranchLabel2: PAPER.ink, gitBranchLabel3: PAPER.ink, gitBranchLabel4: PAPER.ink, gitBranchLabel5: PAPER.ink, gitBranchLabel6: PAPER.ink, gitBranchLabel7: PAPER.ink,
50
+ gitInv0: PAPER.ink, gitInv1: PAPER.ink, gitInv2: PAPER.ink, gitInv3: PAPER.ink, gitInv4: PAPER.ink, gitInv5: PAPER.ink, gitInv6: PAPER.ink, gitInv7: PAPER.ink,
51
+ commitLabelColor: PAPER.ink, commitLabelBackground: PAPER.bg, tagLabelColor: PAPER.ink, tagLabelBackground: PAPER.soft, tagLabelBorder: PAPER.line,
52
+ ...Object.fromEntries([...Array(12)].flatMap((_, i) => [[`cScale${i}`, [PAPER.soft, '#f3d96a', PAPER.pale, '#e9c94f'][i % 4]], [`cScaleLabel${i}`, PAPER.ink], [`cScalePeer${i}`, PAPER.line]])),
53
+ quadrant1Fill: PAPER.soft, quadrant2Fill: PAPER.pale, quadrant3Fill: PAPER.pale, quadrant4Fill: PAPER.soft,
54
+ quadrant1TextFill: PAPER.ink, quadrant2TextFill: PAPER.ink, quadrant3TextFill: PAPER.ink, quadrant4TextFill: PAPER.ink,
55
+ quadrantPointFill: PAPER.ink, quadrantPointTextFill: PAPER.ink, quadrantXAxisTextFill: PAPER.ink, quadrantYAxisTextFill: PAPER.ink,
56
+ quadrantInternalBorderStrokeFill: PAPER.line, quadrantExternalBorderStrokeFill: PAPER.line, quadrantTitleFill: PAPER.ink,
57
+ attributeBackgroundColorOdd: PAPER.pale, attributeBackgroundColorEven: PAPER.bg,
58
+ }
59
+ let diagramSeq = 0
60
+
61
+ /** A sticky is one colour of paper. Whatever a diagram family brings that the theme variables
62
+ * did not reach (journey actor dots, ER marker circles, a white attribute row, block fills) is
63
+ * mapped onto the palette AFTER render, by computed colour: white and light fills become paper,
64
+ * dark fills become ink, strokes likewise. Text is never touched (its fill is set above). */
65
+ const PALETTE = new Set([PAPER.bg, PAPER.ink, PAPER.line, PAPER.soft, PAPER.pale, PAPER.hatch, '#f3d96a', '#e9c94f', '#e0c04a', '#fff0b8', '#a88f2a'].map((c) => c.toLowerCase()))
66
+ const hex = (rgb: string): string | null => {
67
+ const m = /^rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)$/.exec(rgb)
68
+ if (!m || (m[4] !== undefined && Number(m[4]) === 0)) return null
69
+ return '#' + [m[1], m[2], m[3]].map((n) => Number(n).toString(16).padStart(2, '0')).join('')
70
+ }
71
+ const luminance = (h: string) => { const n = parseInt(h.slice(1), 16); return (0.2126 * (n >> 16) + 0.7152 * ((n >> 8) & 255) + 0.0722 * (n & 255)) / 255 }
72
+ function inkPalette(svg: SVGSVGElement) {
73
+ for (const el of svg.querySelectorAll('path, rect, circle, ellipse, polygon, polyline, line') as NodeListOf<SVGElement>) {
74
+ const cs = getComputedStyle(el)
75
+ const fill = hex(cs.fill), stroke = hex(cs.stroke)
76
+ if (fill && !PALETTE.has(fill)) el.style.setProperty('fill', luminance(fill) > 0.55 ? (fill === '#ffffff' ? PAPER.pale : PAPER.soft) : PAPER.line, 'important')
77
+ // a LIGHT stroke is a hachure line or a faint grid (rough draws a white fill as white
78
+ // hatching): it becomes the hatch yellow; a mid stroke the line brown; a dark one the ink
79
+ if (stroke && !PALETTE.has(stroke)) el.style.setProperty('stroke', luminance(stroke) > 0.7 ? PAPER.hatch : luminance(stroke) > 0.35 ? PAPER.line : PAPER.ink, 'important')
80
+ }
81
+ }
82
+
83
+ /** One look for every diagram type. mermaid's hand-drawn look (rough.js) reaches flowchart,
84
+ * state, class, ER and block natively; sequence actors, journey and timeline boxes, mindmap
85
+ * nodes, quadrants, git commits, pie slices and legends come out as plain rects, circles and
86
+ * paths. Every visible one of those that mermaid did not already sketch is redrawn here with the
87
+ * same rough.js recipe the flowchart nodes got - hachured fill, sketched border - in the paper's
88
+ * colours, the plain shape kept underneath as a flat backing. Seeded per shape, so a re-render
89
+ * of the same note draws the same lines. */
90
+ const FILLS = ['hachure', 'cross-hatch', 'dots', 'zigzag-line', 'dashed'] as const
91
+ function sketchShapes(svg: SVGSVGElement, rough: any) {
92
+ const rc = rough.svg(svg)
93
+ let seed = 7, slice = 0
94
+ const visible = (el: Element) => { const f = getComputedStyle(el).fill; return f !== 'none' && !/rgba\(\d+, \d+, \d+, 0\)/.test(f) }
95
+ const skip = (el: Element) =>
96
+ (el as any).__sketched
97
+ || !!el.closest('.label, foreignObject, marker, defs, .face, .legend-text')
98
+ || sketchedByMermaid(el)
99
+ || (el.nextElementSibling?.tagName === 'g' && !!el.nextElementSibling.querySelector('path')) // mermaid's own rough
100
+ || !visible(el)
101
+ // the unified renderer's nodes carry `g.node` / `g.rough-node`, but the class is a promise, not
102
+ // a fact: a mindmap node wears `rough-node` around a plain path. Trust the evidence - rough
103
+ // output is classless paths, direct or inside the label container
104
+ const sketchedByMermaid = (el: Element) => {
105
+ const node = el.parentElement?.closest('g.node, g.rough-node, g.cluster')
106
+ if (!node) return false
107
+ const plain = (e: Element) => !(e.getAttribute('class') ?? '').trim() // an EMPTY class attribute is no class
108
+ for (const c of node.children) {
109
+ if (c.tagName === 'path' && plain(c)) return true
110
+ if (c.tagName === 'g' && (plain(c) || /\b(basic|label-container)\b/.test(c.getAttribute('class') ?? '')) && [...c.children].some((cc) => cc.tagName === 'path' && plain(cc))) return true
111
+ }
112
+ return false
113
+ }
114
+ const place = (el: Element, g: SVGGElement, backing: string) => {
115
+ // rough draws with presentation attributes; inside a mermaid node group the diagram's own
116
+ // stylesheet (`.node path { fill; stroke }`) would beat them - pin them as inline styles
117
+ for (const path of g.querySelectorAll('path')) {
118
+ path.style.fill = path.getAttribute('fill') ?? 'none'
119
+ path.style.stroke = path.getAttribute('stroke') ?? 'none'
120
+ path.style.strokeWidth = path.getAttribute('stroke-width') ?? '1'
121
+ }
122
+ el.setAttribute('style', `fill: ${backing}; stroke: none;`)
123
+ ;(el as any).__sketched = true
124
+ el.after(g)
125
+ }
126
+ const recipe = (extra: Record<string, unknown> = {}) => ({
127
+ seed: seed++, roughness: 0.7, bowing: 0.6, stroke: PAPER.line, strokeWidth: 1.6,
128
+ fill: PAPER.hatch, fillStyle: 'hachure', hachureAngle: 120, hachureGap: 4, fillWeight: 1.2, ...extra,
129
+ })
130
+ for (const r of [...svg.querySelectorAll('rect')]) {
131
+ if (skip(r)) continue
132
+ const w = Number(r.getAttribute('width') ?? 0), h = Number(r.getAttribute('height') ?? 0)
133
+ if (!(w > 6 && h > 6)) continue
134
+ const x = Number(r.getAttribute('x') ?? 0), y = Number(r.getAttribute('y') ?? 0)
135
+ // a quadrant is a field, not a box: the sketched border only, a flat pale backing
136
+ const field = !!r.closest('.quadrants') || w * h > 40_000
137
+ place(r, rc.rectangle(x, y, w, h, recipe(field ? { fill: undefined, fillStyle: 'solid' } : {})), field ? PAPER.pale : PAPER.soft)
138
+ }
139
+ for (const c of [...svg.querySelectorAll('circle')]) {
140
+ if (skip(c)) continue
141
+ const d = 2 * Number(c.getAttribute('r') ?? 0)
142
+ if (d < 6) continue
143
+ place(c, rc.circle(Number(c.getAttribute('cx') ?? 0), Number(c.getAttribute('cy') ?? 0), d, recipe(d < 30 ? { fillStyle: 'solid', fill: PAPER.hatch } : {})), PAPER.soft)
144
+ }
145
+ for (const e of [...svg.querySelectorAll('ellipse')]) {
146
+ if (skip(e)) continue
147
+ place(e, rc.ellipse(Number(e.getAttribute('cx') ?? 0), Number(e.getAttribute('cy') ?? 0), 2 * Number(e.getAttribute('rx') ?? 0), 2 * Number(e.getAttribute('ry') ?? 0), recipe()), PAPER.soft)
148
+ }
149
+ // pie slices: one fill style per slice, so the wedges read apart in one colour of ink;
150
+ // mindmap and timeline draw their boxes as paths (node-bkg) - the same box recipe
151
+ for (const p of [...svg.querySelectorAll('path.pieCircle, path.node-bkg')]) {
152
+ if (skip(p)) continue
153
+ const d = p.getAttribute('d')
154
+ if (!d) continue
155
+ const pie = p.classList.contains('pieCircle')
156
+ place(p, rc.path(d, recipe(pie ? { fillStyle: FILLS[slice++ % FILLS.length], hachureGap: 5, fillWeight: 1 } : {})), PAPER.soft)
157
+ }
158
+ }
159
+
160
+ /** Render every ```mermaid fence in a sticky body: hand-drawn, strict, sanitized. The pre stays
161
+ * mounted through an error (the message replaces the code), so a healed edit heals in place. */
162
+ async function renderDiagrams(body: HTMLElement, alive: () => boolean) {
163
+ const fences = [...body.querySelectorAll('pre > code.language-mermaid')] as HTMLElement[]
164
+ if (!fences.length) return
165
+ const [{ default: mermaid }, { default: rough }] = await Promise.all([import('mermaid'), import('roughjs')])
166
+ if (!alive()) return
167
+ mermaid.initialize({
168
+ startOnLoad: false, securityLevel: 'strict', theme: 'base', look: 'handDrawn', themeVariables: THEME_VARS, fontFamily: HAND_FONT,
169
+ // legible at sticky size. Flowcharts get rough.js boxes and HTML labels; every other type
170
+ // draws plain SVG text in a handwriting face that has no bold - so SVG text is inked with a
171
+ // thin stroke (paint-order keeps the fill on top), lines and boxes are darker and thicker.
172
+ themeCSS: [
173
+ `.nodeLabel, .edgeLabel, .label { font-weight: 700 !important; letter-spacing: .01em }`,
174
+ `text, tspan { font-weight: 700 !important; fill: ${PAPER.ink}; stroke: ${PAPER.ink}; stroke-width: .45px; paint-order: stroke fill; letter-spacing: .01em }`,
175
+ `.edgePath path, .flowchart-link { stroke-width: 2px } .node path, .node rect { stroke-width: 1.6px }`,
176
+ // sequence: actor boxes, lifelines, messages and arrowheads in the paper's ink
177
+ `.actor-line { stroke: ${PAPER.line} !important; stroke-width: 1.4px !important }`,
178
+ `.messageLine0, .messageLine1 { stroke: ${PAPER.ink} !important; stroke-width: 1.8px !important } #arrowhead path, .arrowheadPath { fill: ${PAPER.ink} !important; stroke: ${PAPER.ink} !important }`,
179
+ `.messageText, .actor > tspan, text.actor { fill: ${PAPER.ink} !important }`,
180
+ `.loopLine { stroke: ${PAPER.line} !important } .labelBox, .loopText, .note { stroke: ${PAPER.line} }`,
181
+ `.er.attributeBoxOdd { fill: ${PAPER.pale} !important; stroke: ${PAPER.line} } .er.attributeBoxEven { fill: ${PAPER.bg} !important; stroke: ${PAPER.line} } .er.relationshipLine { stroke: ${PAPER.ink} !important }`,
182
+ `.section0, .section2, .section4 { fill: ${PAPER.pale} } .section1, .section3 { fill: ${PAPER.bg} } .grid .tick line { stroke: ${PAPER.line} } .today { stroke: ${PAPER.ink} }`,
183
+ `.commit-label, .branchLabel text { fill: ${PAPER.ink} !important } .commit-label-bkg { fill: ${PAPER.bg} !important } .commit-arrow, .arrow { stroke: ${PAPER.ink} !important }`,
184
+ `.mindmap-node > path, .mindmap-node rect { stroke: ${PAPER.line}; stroke-width: 1.4px } .edge { stroke: ${PAPER.line}; stroke-width: 2px }`,
185
+ // state / class / er: the same ink
186
+ `.statediagram-state rect, .classGroup rect, .er.entityBox { stroke: ${PAPER.line}; stroke-width: 1.6px; fill: ${PAPER.soft} } .transition, .relation, .er.relationshipLine { stroke: ${PAPER.ink} !important; stroke-width: 1.8px !important }`,
187
+ ].join(' '),
188
+ flowchart: { padding: 10, nodeSpacing: 32, rankSpacing: 36 },
189
+ // one actor row (the mirrored bottom row is noise at note size), roomier boxes, bigger text
190
+ sequence: { mirrorActors: false, actorFontSize: 18, actorFontWeight: 700, messageFontSize: 17, messageFontWeight: 700, noteFontSize: 16, width: 96, height: 50, actorMargin: 26, boxMargin: 8, messageMargin: 30, diagramMarginX: 6, diagramMarginY: 6, wrap: true, bottomMarginAdj: 4 },
191
+ // the wide-by-nature types drawn AT the note's width instead of shrunk into it
192
+ gantt: { useWidth: 380, fontSize: 13, sectionFontSize: 13, barHeight: 22, barGap: 6, topPadding: 44, leftPadding: 64, rightPadding: 12, gridLineStartPadding: 20, numberSectionStyles: 2 },
193
+ journey: { width: 118, height: 40, leftMargin: 56, taskMargin: 12, taskFontSize: 13, diagramMarginX: 8, diagramMarginY: 8, boxMargin: 6, actorColours: [PAPER.line, PAPER.ink, '#a88f2a', '#e9c94f'] },
194
+ timeline: { disableMulticolor: true, padding: 10, width: 84, height: 40, leftMargin: 40, taskMargin: 8, diagramMarginX: 8, diagramMarginY: 8 },
195
+ quadrantChart: { chartWidth: 360, chartHeight: 360, titleFontSize: 16, quadrantLabelFontSize: 14, pointLabelFontSize: 13, xAxisLabelFontSize: 13, yAxisLabelFontSize: 13, pointRadius: 5 },
196
+ mindmap: { padding: 10, maxNodeWidth: 150 },
197
+ er: { minEntityWidth: 90, minEntityHeight: 50, entityPadding: 12, fontSize: 14, diagramPadding: 10 },
198
+ gitGraph: { showBranches: true, rotateCommitLabel: false, nodeLabel: { width: 60 } } as any,
199
+ })
200
+ for (const code of fences) {
201
+ const pre = code.parentElement!
202
+ const src = cleanSource(code.textContent ?? '') // front matter and %%{init}%% directives never reach mermaid (the Diagram rule)
203
+ const host = document.createElement('div')
204
+ host.className = 'sh-sticky-diagram'
205
+ try {
206
+ guardDiagramSource(src) // the zero-external-request boundary (the Diagram block's)
207
+ const { svg } = await mermaid.render(`sh-note-d${++diagramSeq}`, src)
208
+ if (!alive()) return
209
+ host.innerHTML = sanitizeSvg(svg)
210
+ const el = host.querySelector('svg')
211
+ // never larger than drawn: a family without mermaid's max-width (block) would upscale to
212
+ // the note's width and its hatching with it - the viewBox width is the ceiling
213
+ const vb = el?.viewBox?.baseVal
214
+ if (el && vb && vb.width > 0 && !el.style.maxWidth) el.style.maxWidth = `${Math.ceil(vb.width)}px`
215
+ pre.replaceWith(host)
216
+ if (el) { sketchShapes(el, rough); inkPalette(el) } // computed colours need the svg in the document
217
+ } catch (e) {
218
+ if (!alive()) return
219
+ pre.classList.add('err')
220
+ code.textContent = `diagram: ${String((e as Error)?.message ?? e).split('\n')[0]}`
221
+ }
222
+ }
223
+ }
224
+
225
+ function StickyBody({ text, kind, nodeKey, frameId }: { text: string; kind: NoteKind; nodeKey: string; frameId: string }) {
226
+ const ref = useRef<HTMLDivElement>(null)
227
+ const html = useMemo(() => sanitizeMarkdownHtml(renderMarkdown(text)), [text])
228
+ useEffect(() => {
229
+ const el = ref.current
230
+ if (!el) return
231
+ let live = true
232
+ el.innerHTML = html
233
+ void renderDiagrams(el, () => live && ref.current === el)
234
+ return () => { live = false }
235
+ }, [html])
236
+
237
+ const onClick = (e: React.MouseEvent<HTMLDivElement>) => {
238
+ const root = ref.current
239
+ const target = e.target instanceof Element ? e.target : null
240
+ if (!root || !target) return
241
+ const c = useComments.getState()
242
+ if (c.commentMode) {
243
+ // comment mode owns every click: pick the element, exactly like a frame pick
244
+ e.preventDefault(); e.stopPropagation()
245
+ // the picked element is the innermost element under the pointer; a click in the body's
246
+ // own padding picks the first block instead of the whole note
247
+ const pick = target === root ? root.firstElementChild : target
248
+ if (!pick) return
249
+ const nodeEl = root.closest('.sh-node') as HTMLElement | null
250
+ const bodyEl = nodeEl?.querySelector('.cm-layer') as HTMLElement | null
251
+ if (!nodeEl || !bodyEl) return
252
+ const origin = bodyEl.getBoundingClientRect()
253
+ const s = origin.width ? origin.width / nodeEl.offsetWidth : 1 // on-screen px per world px
254
+ const toBody = (r: DOMRect) => ({ x: (r.left - origin.left) / s, y: (r.top - origin.top) / s, w: r.width / s, h: r.height / s })
255
+ if (c.active) c.setActive(null)
256
+ c.setDraft({ nodeKey, frame: frameId, anchor: noteAnchor(pick, root, kind, e, toBody) })
257
+ return
258
+ }
259
+ const link = target.closest('a[data-goto]')
260
+ if (link) { e.preventDefault(); e.stopPropagation(); goTo(link.getAttribute('data-goto') ?? '') }
261
+ }
262
+ return <div ref={ref} className="sh-sticky-body" onClick={onClick} />
263
+ }
264
+
265
+ /** The column for one node. `underBadge`: a variant badge owns the top of the gutter. */
266
+ export const Stickies = memo(function Stickies({ nodeKey, frameId, notes, underBadge }: { nodeKey: string; frameId: string; notes: NoteSpec[]; underBadge: boolean }) {
267
+ const ids = notes.map((n) => n.id)
268
+ const on = useNotes((s) => noteVisible(s, ids))
269
+ if (!notes.length) return null
270
+ const width = Math.max(...notes.map((n) => (n.kind === 'scene' ? SCENE_NOTE_W : NOTE_W)))
271
+ return (
272
+ <div className={`sh-notes${on ? '' : ' off'}${underBadge ? ' below-vbadge' : ''}`} data-node-notes={nodeKey} style={{ width }}>
273
+ <button className="sh-notes-fold sh-no-pan" type="button" aria-label={on ? 'hide notes' : 'show notes'} title={on ? 'hide notes (N: all)' : 'show notes (N: all)'}
274
+ onPointerDown={(e) => e.stopPropagation()}
275
+ onClick={(e) => { e.stopPropagation(); useNotes.getState().toggle(ids) }} />
276
+ {notes.map((n) => (
277
+ <div key={n.id} className="sh-sticky sh-no-pan" data-sticky={n.kind} data-note={n.id} style={{ width: n.kind === 'scene' ? SCENE_NOTE_W : NOTE_W }}
278
+ onPointerDown={(e) => e.stopPropagation()} onDoubleClick={(e) => e.stopPropagation()}>
279
+ <StickyBody text={n.text} kind={n.kind} nodeKey={nodeKey} frameId={frameId} />
280
+ </div>
281
+ ))}
282
+ </div>
283
+ )
284
+ })
@@ -0,0 +1,72 @@
1
+ /**
2
+ * `goto:` navigation, one path for every caller: a frame's data-goto (sh:go from the bridge)
3
+ * and a sticky note's link (shell DOM, spec 18). On this board: select and fit. Elsewhere: follow
4
+ * the frame home (below). Never edits a board unless no board pins the target.
5
+ */
6
+ import { boardFrames, fetchBoardNames, useStore } from './store.ts'
7
+ import { canvasCtl } from './canvas/ctl.ts'
8
+
9
+ /** Navigate to a frame id. `carry` keeps interact mode across the hop (a link inside an
10
+ * interacting frame walks a flow - it must not eject you to design mode). */
11
+ export function goTo(target: string, carry = false): void {
12
+ const s = useStore.getState()
13
+ const existing = s.nodes.find((n) => n.frame === target && !n.missing)
14
+ if (existing) {
15
+ gotoSeq++ // a local goto supersedes any cross-board one in flight
16
+ s.select(existing.key)
17
+ if (carry) s.setInteract(existing.key)
18
+ setTimeout(() => canvasCtl.fitNode(existing.key), 50)
19
+ } else void gotoAcrossBoards(target, carry)
20
+ }
21
+
22
+ /** A data-goto whose target frame is not on the current board follows the frame HOME:
23
+ * the first curated board (switcher rank) that pins it is switched to and the frame
24
+ * focused there - a link is navigation, and navigation never edits a board. Only a
25
+ * frame NO board pins spawns onto the current board (the original prototype behavior
26
+ * for unpinned targets); an id the manifest doesn't know stays a toast. Every goto
27
+ * bumps `gotoSeq` so a slow older resolution can never override newer navigation. */
28
+ let gotoSeq = 0
29
+ async function gotoAcrossBoards(target: string, carry: boolean) {
30
+ const s = useStore.getState()
31
+ // an id the manifest doesn't know resolves NOWHERE - a tombstone pin on some board
32
+ // must not send us on a trip that ends in a silent timeout
33
+ if (!s.manifest?.frames.some((f) => f.id === target)) return s.toast(`unknown goto target "${target}"`)
34
+ const seq = ++gotoSeq
35
+ let home: string | null = null
36
+ try {
37
+ const names = (await fetchBoardNames()).filter((n) => n !== s.board && n !== 'all-scenes')
38
+ for (const name of names) {
39
+ if ((await boardFrames(name)).includes(target)) { home = name; break }
40
+ }
41
+ } catch {
42
+ // a transport failure is NOT proof the frame is unpinned - spawning here would
43
+ // recreate the board mutation this function exists to prevent
44
+ return useStore.getState().toast(`goto: could not read the boards - try again`)
45
+ }
46
+ if (seq !== gotoSeq) return // superseded by newer navigation
47
+ if (!home) {
48
+ // no curated board pins it - the original prototype behavior: spawn beside you
49
+ const st = useStore.getState()
50
+ const node = st.spawn(target)
51
+ if (!node) return st.toast(`unknown goto target "${target}"`)
52
+ st.select(node.key)
53
+ if (carry) st.setInteract(node.key)
54
+ setTimeout(() => canvasCtl.fitNode(node.key), 50)
55
+ return
56
+ }
57
+ await s.switchBoard(home)
58
+ for (let i = 0; i < 12; i++) { // the board commits async - retry like viewNote
59
+ if (seq !== gotoSeq) return
60
+ const st = useStore.getState()
61
+ if (st.board === home) { // a cancelled/failed switch must not select here
62
+ const node = st.nodes.find((n) => n.frame === target && !n.missing)
63
+ if (node) {
64
+ st.select(node.key)
65
+ if (carry) st.setInteract(node.key)
66
+ setTimeout(() => canvasCtl.fitNode(node.key), 50)
67
+ return
68
+ }
69
+ }
70
+ await new Promise((r) => setTimeout(r, 250))
71
+ }
72
+ }
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Sticky notes (spec 18): the pure half. Widths the layout reserves, which node hosts a
3
+ * scene's note, the per-viewer hide state, and the comment anchor a note builds and resolves
4
+ * in the shell (a note is shell DOM - inspect.js never sees it, so the shell plays its part).
5
+ * No React, no store import: unit tests read this straight.
6
+ */
7
+ import { create } from 'zustand'
8
+
9
+ /** World px. A frame note is a column beside one frame; a scene note is wider, an intro. */
10
+ export const NOTE_W = 260
11
+ export const SCENE_NOTE_W = 380
12
+ /** The gutter between a note and its frame (the variant badge lives in the same gutter). */
13
+ export const NOTE_GAP = 24
14
+ /** Pin and card tint for anchors on a note - the paper's own hue. */
15
+ export const NOTE_HUE = 48
16
+
17
+ export type NoteKind = 'frame' | 'scene'
18
+ export const noteId = (kind: NoteKind, name: string): string => `${kind}:${name}`
19
+
20
+ /** The width tidy reserves in front of a node: its own note, and the scene note when this node
21
+ * hosts it - one column, so the wider of the two, never the sum. */
22
+ export function noteReserve(frameNote: boolean, sceneNote: boolean): number {
23
+ const w = Math.max(frameNote ? NOTE_W : 0, sceneNote ? SCENE_NOTE_W : 0)
24
+ return w ? w + NOTE_GAP : 0
25
+ }
26
+
27
+ /** The node that shows a scene's note: the scene's first node on the board in reading order -
28
+ * smallest y, then x, then original index (the deck rule), missing nodes skipped. Null when the
29
+ * scene has no node here. */
30
+ export function sceneNoteHost(
31
+ nodes: readonly { key: string; frame: string; x: number; y: number; missing?: boolean }[],
32
+ sceneOf: (frame: string) => string | undefined,
33
+ scene: string,
34
+ ): string | null {
35
+ let best: { key: string; x: number; y: number; i: number } | null = null
36
+ nodes.forEach((n, i) => {
37
+ if (n.missing || sceneOf(n.frame) !== scene) return
38
+ if (!best || n.y < best.y || (n.y === best.y && (n.x < best.x || (n.x === best.x && i < best.i)))) best = { key: n.key, x: n.x, y: n.y, i }
39
+ })
40
+ return best ? (best as { key: string }).key : null
41
+ }
42
+
43
+ // ---- per-viewer visibility -------------------------------------------------------------
44
+
45
+ const STORAGE = 'mv-notes'
46
+ interface NotesState {
47
+ /** the N toggle: false hides every note on the canvas */
48
+ all: boolean
49
+ /** note ids folded by their own corner */
50
+ hidden: string[]
51
+ /** fold or unfold one column (its ids together) */
52
+ toggle(ids: string[]): void
53
+ /** N: any note visible -> hide all; none -> show all (and unfold every column) */
54
+ toggleAll(anyVisible: boolean): void
55
+ }
56
+ const load = (): { all: boolean; hidden: string[] } => {
57
+ try {
58
+ const raw = JSON.parse(localStorage.getItem(STORAGE) ?? 'null')
59
+ if (raw && typeof raw === 'object') return { all: raw.all !== false, hidden: Array.isArray(raw.hidden) ? raw.hidden.filter((x: unknown) => typeof x === 'string') : [] }
60
+ } catch { /* storage unavailable or junk: shown by default */ }
61
+ return { all: true, hidden: [] }
62
+ }
63
+ const save = (s: { all: boolean; hidden: string[] }) => { try { localStorage.setItem(STORAGE, JSON.stringify(s)) } catch { /* private mode */ } }
64
+
65
+ export const useNotes = create<NotesState>((set, get) => ({
66
+ ...load(),
67
+ toggle(ids) {
68
+ const { all, hidden } = get()
69
+ const off = !all || ids.some((id) => hidden.includes(id))
70
+ // unfolding a column while N hid everything shows that column alone: N flips back on and
71
+ // every OTHER column is folded, so the one asked for is the one that appears
72
+ const next = off
73
+ ? { all: true, hidden: all ? hidden.filter((id) => !ids.includes(id)) : [...new Set([...allIds(), ...hidden].filter((id) => !ids.includes(id)))] }
74
+ : { all, hidden: [...new Set([...hidden, ...ids])] }
75
+ set(next); save(next)
76
+ },
77
+ toggleAll(anyVisible) {
78
+ const next = anyVisible ? { all: false, hidden: get().hidden } : { all: true, hidden: [] }
79
+ set(next); save(next)
80
+ },
81
+ }))
82
+ /** Every note id currently on the canvas - read from the DOM, the one place that knows. */
83
+ const allIds = (): string[] => (typeof document === 'undefined' ? [] : [...document.querySelectorAll('[data-note]')].map((el) => el.getAttribute('data-note')!))
84
+
85
+ export const noteVisible = (s: { all: boolean; hidden: string[] }, ids: string[]): boolean => s.all && !ids.some((id) => s.hidden.includes(id))
86
+
87
+ // ---- comment anchors on a note ------------------------------------------------------------
88
+
89
+ /** The same bundle inspect.js posts for a frame element, plus `note` so the shell resolves it
90
+ * itself. `rect` and `pos` are in node-body coordinates (the CommentLayer's), so a pin on a note
91
+ * sits at a negative x - left of the frame. */
92
+ export interface NoteAnchor {
93
+ el: { semantics: { tag: string; quote?: string }; cssPath: string; note: NoteKind; hue: number }
94
+ pos: { fx: number; fy: number }
95
+ rect: { x: number; y: number; w: number; h: number }
96
+ }
97
+
98
+ /** An nth-of-type chain from the sticky body to the element - stable across re-renders of the
99
+ * same markdown, honest when it changes (the quote fallback then decides). */
100
+ export function cssPathWithin(el: Element, root: Element): string {
101
+ const parts: string[] = []
102
+ let cur: Element | null = el
103
+ while (cur && cur !== root) {
104
+ const tag = cur.tagName.toLowerCase()
105
+ const parent: Element | null = cur.parentElement
106
+ if (!parent) break
107
+ const same = [...parent.children].filter((c) => c.tagName === cur!.tagName)
108
+ parts.unshift(same.length > 1 ? `${tag}:nth-of-type(${same.indexOf(cur) + 1})` : tag)
109
+ cur = parent
110
+ }
111
+ return parts.join(' > ')
112
+ }
113
+
114
+ const quoteOf = (el: Element) => (el.textContent ?? '').trim().replace(/\s+/g, ' ')
115
+
116
+ /** Build the anchor for a click at (clientX, clientY) on `el` inside a sticky whose body is
117
+ * `root`; `toBody` maps a screen rect to node-body coordinates. */
118
+ export function noteAnchor(el: Element, root: Element, kind: NoteKind, at: { clientX: number; clientY: number }, toBody: (r: DOMRect) => { x: number; y: number; w: number; h: number }): NoteAnchor {
119
+ const r = el.getBoundingClientRect()
120
+ const quote = quoteOf(el).slice(0, 200)
121
+ return {
122
+ el: { semantics: { tag: el.tagName.toLowerCase(), ...(quote ? { quote } : {}) }, cssPath: cssPathWithin(el, root), note: kind, hue: NOTE_HUE },
123
+ pos: {
124
+ fx: r.width ? Math.min(1, Math.max(0, (at.clientX - r.left) / r.width)) : .5,
125
+ fy: r.height ? Math.min(1, Math.max(0, (at.clientY - r.top) / r.height)) : .5,
126
+ },
127
+ rect: toBody(r),
128
+ }
129
+ }
130
+
131
+ /** inspect.js's resolve, for a note: the path when it still matches, else tag + quote. */
132
+ export function resolveNoteAnchor(anchor: unknown, root: Element): Element | null {
133
+ const a = anchor as Partial<NoteAnchor> | null
134
+ const want = a?.el?.semantics ?? { tag: '' }
135
+ const match = (el: Element) => {
136
+ if (want.tag && el.tagName.toLowerCase() !== want.tag) return false
137
+ if (want.quote) { const t = quoteOf(el); if (!(t.startsWith(want.quote.slice(0, 60)) || t.includes(want.quote.slice(0, 40)))) return false }
138
+ return true
139
+ }
140
+ try {
141
+ const byPath = a?.el?.cssPath ? root.querySelector(a.el.cssPath) : null
142
+ if (byPath && match(byPath)) return byPath
143
+ } catch { /* stale selector */ }
144
+ if (want.quote && /^[a-z][a-z0-9-]*$/.test(want.tag)) for (const el of root.querySelectorAll(want.tag)) if (match(el)) return el
145
+ return null
146
+ }
147
+
148
+ export const isNoteAnchor = (anchor: unknown): anchor is NoteAnchor => {
149
+ const n = (anchor as any)?.el?.note
150
+ return n === 'frame' || n === 'scene'
151
+ }
@@ -1,6 +1,7 @@
1
1
  import { create } from 'zustand'
2
2
  import { ROUTE, slideSize } from '../const.ts'
3
- import { tidy, parseLayout, type BoardLayout } from './tidy.ts'
3
+ import { tidy, parseLayout, type BoardLayout, type TidyNode } from './tidy.ts'
4
+ import { noteReserve } from './notes.ts'
4
5
  import { stableNodeKey } from './keys.ts'
5
6
  // @ts-expect-error virtual module provided by the plugin
6
7
  import shConfig from 'virtual:sh-config'
@@ -50,8 +51,8 @@ export function hydrateBoardPolicy(boards: Record<string, { type?: string; open?
50
51
  for (const [k, v] of Object.entries(boards)) if (!BOARD_POLICY[k]) BOARD_POLICY[k] = v
51
52
  }
52
53
 
53
- export interface FrameEntry { id: string; file: string; kind: 'tsx' | 'html'; scene: string; title?: string; viewport?: string; theme?: string; variantGroup?: string; variant?: string; intent?: string; contentWidth?: number; slide?: boolean }
54
- export interface Manifest { frames: FrameEntry[]; scenes: { name: string; frames: number; title?: string; description?: string }[] }
54
+ export interface FrameEntry { id: string; file: string; kind: 'tsx' | 'html'; scene: string; title?: string; viewport?: string; theme?: string; variantGroup?: string; variant?: string; intent?: string; contentWidth?: number; slide?: boolean; note?: string }
55
+ export interface Manifest { frames: FrameEntry[]; scenes: { name: string; frames: number; title?: string; description?: string; note?: string }[] }
55
56
  export interface Node {
56
57
  key: string; frame: string; x: number; y: number; w: number; h: number
57
58
  /** RESOLVED theme (what renders): themeUser ?? frame meta.theme ?? viewTheme. */
@@ -207,6 +208,21 @@ export async function boardFrames(name: string): Promise<string[]> {
207
208
  }
208
209
 
209
210
  const HEADER = 28
211
+ /** What tidy sees: nodes with their scene, variant run, header-inclusive height, and the sticky
212
+ * note width to reserve in front (spec 18: the frame's note, and the scene's on every member -
213
+ * tidy keeps the scene reserve on the first node it places). */
214
+ export function tidyInput(nodes: readonly Node[], manifest: Manifest | null): TidyNode[] {
215
+ const entryOf = (id: string) => manifest?.frames.find((f) => f.id === id)
216
+ const sceneNote = (scene: string) => !!manifest?.scenes.find((s) => s.name === scene)?.note
217
+ return nodes.map((n) => {
218
+ const f = entryOf(n.frame)
219
+ const scene = f?.scene ?? ''
220
+ return {
221
+ key: n.key, frame: n.frame, scene, group: f?.variantGroup, variant: f?.variant, w: n.w, h: n.h + HEADER,
222
+ noteW: noteReserve(!!f?.note, false), sceneNoteW: noteReserve(false, sceneNote(scene)),
223
+ }
224
+ })
225
+ }
210
226
  let toastSeq = 0
211
227
  const nodeKey = () => 'n_' + Math.random().toString(36).slice(2, 8)
212
228
 
@@ -613,11 +629,7 @@ export const useStore = create<State>((set, get) => {
613
629
  }
614
630
  }
615
631
  if ((!boardHash || needTidy) && nodes.length) {
616
- const entryOf = (id: string) => manifest.frames.find((f) => f.id === id)
617
- const placedAll = tidy(nodes.map((n) => {
618
- const f = entryOf(n.frame)
619
- return { key: n.key, frame: n.frame, scene: f?.scene ?? '', group: f?.variantGroup, variant: f?.variant, w: n.w, h: n.h + HEADER }
620
- }), effectiveLayout(layout, sceneRows), layoutWarn)
632
+ const placedAll = tidy(tidyInput(nodes, manifest), effectiveLayout(layout, sceneRows), layoutWarn)
621
633
  for (const pl of placedAll) { const n = nodes.find((x) => x.key === pl.key)!; n.x = pl.x; n.y = pl.y }
622
634
  }
623
635
  // dirty matches disk by construction - except when load-time pruning changed the
@@ -625,11 +637,7 @@ export const useStore = create<State>((set, get) => {
625
637
  // surface recipe problems at load (dry-run): materialized boards otherwise
626
638
  // never run tidy, so a broken agent-authored layout would fail silently
627
639
  if (layout && boardHash && !needTidy && nodes.length) {
628
- const entryOf2 = (id: string) => manifest.frames.find((f) => f.id === id)
629
- tidy(nodes.map((n) => {
630
- const f = entryOf2(n.frame)
631
- return { key: n.key, frame: n.frame, scene: f?.scene ?? '', group: f?.variantGroup, variant: f?.variant, w: n.w, h: n.h + HEADER }
632
- }), layout, layoutWarn)
640
+ tidy(tidyInput(nodes, manifest), layout, layoutWarn)
633
641
  }
634
642
  return { manifest, nodes, boardHash, boardAuto, deviceView, sceneRows, layout, layoutRaw, baseLayout, selection: [], dirty: prunedAtLoad }
635
643
  } catch { return null }
@@ -1203,11 +1211,7 @@ export const useStore = create<State>((set, get) => {
1203
1211
  },
1204
1212
  runTidy() {
1205
1213
  const { nodes, manifest, sceneRows, layout } = get()
1206
- const entryOf = (id: string) => manifest?.frames.find((f) => f.id === id)
1207
- const placed = tidy(nodes.map((n) => {
1208
- const f = entryOf(n.frame)
1209
- return { key: n.key, frame: n.frame, scene: f?.scene ?? '', group: f?.variantGroup, variant: f?.variant, w: n.w, h: n.h + HEADER }
1210
- }), effectiveLayout(layout, sceneRows), layoutWarn)
1214
+ const placed = tidy(tidyInput(nodes, manifest), effectiveLayout(layout, sceneRows), layoutWarn)
1211
1215
  set((s) => ({
1212
1216
  nodes: s.nodes.map((n) => {
1213
1217
  const p = placed.find((x) => x.key === n.key)