@marver-design/marver 0.18.0 → 0.19.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 (32) hide show
  1. package/CHANGELOG.md +41 -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 +49 -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 +181 -0
  27. package/src/client/shell/store.ts +52 -25
  28. package/src/client/shell/styles.css +83 -0
  29. package/src/client/shell/tidy.ts +20 -2
  30. package/templates/AGENTS-embedded.md +18 -0
  31. package/templates/AGENTS-studio.md +18 -0
  32. package/templates/instructions/shape.md +69 -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,181 @@
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
+ /** The node header the canvas draws above a frame body (FrameNode's HEADER) - a node's card is
44
+ * `h + NODE_HEADER` tall, and that is the height a note beside it has to clear. */
45
+ export const NODE_HEADER = 28
46
+
47
+ /** True when a note has no room: another node's card stands inside the reserve in front of a
48
+ * noted node (its own note, or the scene's note it hosts). Room is the layout's job, never the
49
+ * author's - a board the shell composes re-applies its layout when this is true, so a note file
50
+ * can land on a saved board and the frames make way. Missing nodes (a deleted frame's card, still
51
+ * drawn full size) block room but never host a note. */
52
+ export function notesCramped(
53
+ nodes: readonly { key: string; frame: string; x: number; y: number; w: number; h: number; missing?: boolean }[],
54
+ manifest: { frames: { id: string; scene: string; note?: string }[]; scenes: { name: string; note?: string }[] } | null,
55
+ ): boolean {
56
+ if (!manifest) return false
57
+ const entry = (id: string) => manifest.frames.find((f) => f.id === id)
58
+ const live = nodes.filter((n) => !n.missing)
59
+ const hosts = new Set<string>()
60
+ for (const s of manifest.scenes) {
61
+ if (!s.note) continue
62
+ const h = sceneNoteHost(live, (id) => entry(id)?.scene, s.name)
63
+ if (h) hosts.add(h)
64
+ }
65
+ return live.some((n) => {
66
+ const r = noteReserve(!!entry(n.frame)?.note, hosts.has(n.key))
67
+ if (!r) return false
68
+ const x0 = n.x - r, y1 = n.y + n.h + NODE_HEADER
69
+ return nodes.some((o) => o !== n && o.x < n.x && o.x + o.w > x0 && o.y < y1 && o.y + o.h + NODE_HEADER > n.y)
70
+ })
71
+ }
72
+
73
+ // ---- per-viewer visibility -------------------------------------------------------------
74
+
75
+ const STORAGE = 'mv-notes'
76
+ interface NotesState {
77
+ /** the N toggle: false hides every note on the canvas */
78
+ all: boolean
79
+ /** note ids folded by their own corner */
80
+ hidden: string[]
81
+ /** fold or unfold one column (its ids together) */
82
+ toggle(ids: string[]): void
83
+ /** N: any note visible -> hide all; none -> show all (and unfold every column) */
84
+ toggleAll(anyVisible: boolean): void
85
+ }
86
+ const load = (): { all: boolean; hidden: string[] } => {
87
+ try {
88
+ const raw = JSON.parse(localStorage.getItem(STORAGE) ?? 'null')
89
+ if (raw && typeof raw === 'object') return { all: raw.all !== false, hidden: Array.isArray(raw.hidden) ? raw.hidden.filter((x: unknown) => typeof x === 'string') : [] }
90
+ } catch { /* storage unavailable or junk: shown by default */ }
91
+ return { all: true, hidden: [] }
92
+ }
93
+ const save = (s: { all: boolean; hidden: string[] }) => { try { localStorage.setItem(STORAGE, JSON.stringify(s)) } catch { /* private mode */ } }
94
+
95
+ export const useNotes = create<NotesState>((set, get) => ({
96
+ ...load(),
97
+ toggle(ids) {
98
+ const { all, hidden } = get()
99
+ const off = !all || ids.some((id) => hidden.includes(id))
100
+ // unfolding a column while N hid everything shows that column alone: N flips back on and
101
+ // every OTHER column is folded, so the one asked for is the one that appears
102
+ const next = off
103
+ ? { all: true, hidden: all ? hidden.filter((id) => !ids.includes(id)) : [...new Set([...allIds(), ...hidden].filter((id) => !ids.includes(id)))] }
104
+ : { all, hidden: [...new Set([...hidden, ...ids])] }
105
+ set(next); save(next)
106
+ },
107
+ toggleAll(anyVisible) {
108
+ const next = anyVisible ? { all: false, hidden: get().hidden } : { all: true, hidden: [] }
109
+ set(next); save(next)
110
+ },
111
+ }))
112
+ /** Every note id currently on the canvas - read from the DOM, the one place that knows. */
113
+ const allIds = (): string[] => (typeof document === 'undefined' ? [] : [...document.querySelectorAll('[data-note]')].map((el) => el.getAttribute('data-note')!))
114
+
115
+ export const noteVisible = (s: { all: boolean; hidden: string[] }, ids: string[]): boolean => s.all && !ids.some((id) => s.hidden.includes(id))
116
+
117
+ // ---- comment anchors on a note ------------------------------------------------------------
118
+
119
+ /** The same bundle inspect.js posts for a frame element, plus `note` so the shell resolves it
120
+ * itself. `rect` and `pos` are in node-body coordinates (the CommentLayer's), so a pin on a note
121
+ * sits at a negative x - left of the frame. */
122
+ export interface NoteAnchor {
123
+ el: { semantics: { tag: string; quote?: string }; cssPath: string; note: NoteKind; hue: number }
124
+ pos: { fx: number; fy: number }
125
+ rect: { x: number; y: number; w: number; h: number }
126
+ }
127
+
128
+ /** An nth-of-type chain from the sticky body to the element - stable across re-renders of the
129
+ * same markdown, honest when it changes (the quote fallback then decides). */
130
+ export function cssPathWithin(el: Element, root: Element): string {
131
+ const parts: string[] = []
132
+ let cur: Element | null = el
133
+ while (cur && cur !== root) {
134
+ const tag = cur.tagName.toLowerCase()
135
+ const parent: Element | null = cur.parentElement
136
+ if (!parent) break
137
+ const same = [...parent.children].filter((c) => c.tagName === cur!.tagName)
138
+ parts.unshift(same.length > 1 ? `${tag}:nth-of-type(${same.indexOf(cur) + 1})` : tag)
139
+ cur = parent
140
+ }
141
+ return parts.join(' > ')
142
+ }
143
+
144
+ const quoteOf = (el: Element) => (el.textContent ?? '').trim().replace(/\s+/g, ' ')
145
+
146
+ /** Build the anchor for a click at (clientX, clientY) on `el` inside a sticky whose body is
147
+ * `root`; `toBody` maps a screen rect to node-body coordinates. */
148
+ 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 {
149
+ const r = el.getBoundingClientRect()
150
+ const quote = quoteOf(el).slice(0, 200)
151
+ return {
152
+ el: { semantics: { tag: el.tagName.toLowerCase(), ...(quote ? { quote } : {}) }, cssPath: cssPathWithin(el, root), note: kind, hue: NOTE_HUE },
153
+ pos: {
154
+ fx: r.width ? Math.min(1, Math.max(0, (at.clientX - r.left) / r.width)) : .5,
155
+ fy: r.height ? Math.min(1, Math.max(0, (at.clientY - r.top) / r.height)) : .5,
156
+ },
157
+ rect: toBody(r),
158
+ }
159
+ }
160
+
161
+ /** inspect.js's resolve, for a note: the path when it still matches, else tag + quote. */
162
+ export function resolveNoteAnchor(anchor: unknown, root: Element): Element | null {
163
+ const a = anchor as Partial<NoteAnchor> | null
164
+ const want = a?.el?.semantics ?? { tag: '' }
165
+ const match = (el: Element) => {
166
+ if (want.tag && el.tagName.toLowerCase() !== want.tag) return false
167
+ if (want.quote) { const t = quoteOf(el); if (!(t.startsWith(want.quote.slice(0, 60)) || t.includes(want.quote.slice(0, 40)))) return false }
168
+ return true
169
+ }
170
+ try {
171
+ const byPath = a?.el?.cssPath ? root.querySelector(a.el.cssPath) : null
172
+ if (byPath && match(byPath)) return byPath
173
+ } catch { /* stale selector */ }
174
+ if (want.quote && /^[a-z][a-z0-9-]*$/.test(want.tag)) for (const el of root.querySelectorAll(want.tag)) if (match(el)) return el
175
+ return null
176
+ }
177
+
178
+ export const isNoteAnchor = (anchor: unknown): anchor is NoteAnchor => {
179
+ const n = (anchor as any)?.el?.note
180
+ return n === 'frame' || n === 'scene'
181
+ }