@marver-design/marver 0.2.4 → 0.3.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.
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Content-frame block primitives (SPEC-026): Doc, Row, Col, Space, Md, Diagram, Img.
3
+ * One vocabulary at two scales: boards arrange frames in rows/columns with space
4
+ * units; these primitives do the same for blocks INSIDE a frame - as CSS flex,
5
+ * deliberately NOT the SPEC-024 lane grammar (shared vocabulary, not semantics).
6
+ *
7
+ * Doc owns the measurement protocol: it reports its content height so the shell
8
+ * can give the frame a natural size (the shell alone owns node dimensions).
9
+ */
10
+ import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
11
+ import { CONTENT_WIDTH } from '../const.ts'
12
+ import { assetUrl, renderMarkdown } from './md.ts'
13
+
14
+ export { Diagram } from './diagram.tsx'
15
+
16
+ const UNIT = 16 // one gap unit, px - plain adjacency on boards is one gutter; same feel here
17
+
18
+ /* ---------------------------------- Doc ---------------------------------- */
19
+
20
+ export function Doc({ layout = 'document', children }: { layout?: 'document' | 'wide'; children?: ReactNode }) {
21
+ const ref = useRef<HTMLDivElement>(null)
22
+ useEffect(() => {
23
+ ensureStyles()
24
+ const el = ref.current
25
+ if (!el || window.parent === window) return
26
+ let t: ReturnType<typeof setTimeout> | undefined
27
+ const params = new URLSearchParams(location.search)
28
+ const post = () => {
29
+ window.parent.postMessage({
30
+ type: 'sh:measure',
31
+ // identity guards: board files may reuse node keys (frame id must match), and
32
+ // a WindowProxy survives navigation (gen = THIS document's URL rev - the shell
33
+ // drops echoes that don't match the iframe's current src)
34
+ frame: params.get('id') ?? '',
35
+ gen: params.get('r') ?? '',
36
+ ownWidth: CONTENT_WIDTH[layout] ?? CONTENT_WIDTH.document,
37
+ measuredWidth: window.innerWidth, // the width this height is TRUE at (r3 #1)
38
+ height: Math.ceil(el.getBoundingClientRect().height),
39
+ }, '*')
40
+ }
41
+ // debounced ~300ms after the last content change; the shell guards staleness
42
+ // on its side (event.source must map to a mounted iframe; reflow is board-scoped)
43
+ const ro = new ResizeObserver(() => { clearTimeout(t); t = setTimeout(post, 300) })
44
+ ro.observe(el)
45
+ post()
46
+ return () => { ro.disconnect(); clearTimeout(t) }
47
+ }, [layout])
48
+ return <div ref={ref} className={`mv-doc mv-doc-${layout}`}>{children}</div>
49
+ }
50
+
51
+ /* ----------------------------- layout blocks ------------------------------ */
52
+
53
+ export function Row({ space = 1, children }: { space?: number; children?: ReactNode }) {
54
+ return <div className="mv-row" style={{ gap: space * UNIT }}>{children}</div>
55
+ }
56
+
57
+ export function Col({ space = 1, children }: { space?: number; children?: ReactNode }) {
58
+ return <div className="mv-col" style={{ gap: space * UNIT }}>{children}</div>
59
+ }
60
+
61
+ export function Space({ n = 1 }: { n?: number }) {
62
+ return <div aria-hidden className="mv-space" style={{ flex: `0 0 ${n * UNIT}px`, minWidth: n * UNIT, minHeight: n * UNIT }} />
63
+ }
64
+
65
+ /* ---------------------------------- Md ------------------------------------ */
66
+
67
+ export function Md({ children }: { children?: ReactNode }) {
68
+ const src = typeof children === 'string' ? children : Array.isArray(children) ? children.join('') : String(children ?? '')
69
+ const html = useMemo(() => renderMarkdown(src), [src])
70
+ return <div className="mv-md" dangerouslySetInnerHTML={{ __html: html }} />
71
+ }
72
+
73
+ /* ---------------------------------- Img ----------------------------------- */
74
+
75
+ export function Img({ src, caption, alt, h }: { src: string; caption?: string; alt?: string; h?: number }) {
76
+ const url = assetUrl(src)
77
+ const [err, setErr] = useState(false)
78
+ if (!url || err) {
79
+ return (
80
+ <div className="mv-block mv-imgerr">
81
+ <b>image unavailable</b>
82
+ <span>{src}</span>
83
+ {!url && <span className="dim">must be a relative path inside design/assets/</span>}
84
+ </div>
85
+ )
86
+ }
87
+ return (
88
+ <figure className="mv-block mv-img">
89
+ {/* h: shared rendered height for a row of mixed-aspect images - equal widths
90
+ alone never make unequal images READ equal; cover-crop to one height does */}
91
+ <img src={url} alt={alt ?? caption ?? ''} loading="lazy"
92
+ style={h ? { height: h, width: '100%', objectFit: 'cover' } : undefined}
93
+ onError={() => setErr(true)} />
94
+ {caption && <figcaption>{caption}</figcaption>}
95
+ </figure>
96
+ )
97
+ }
98
+
99
+ /* --------------------------------- styles --------------------------------- */
100
+
101
+ /** Injected once per document. Content frames paint their own surface and read
102
+ * the frame theme from the bridge's signals (html.dark / [data-theme]). */
103
+ let stylesIn = false
104
+ function ensureStyles() {
105
+ if (stylesIn || document.getElementById('mv-content-css')) { stylesIn = true; return }
106
+ stylesIn = true
107
+ const el = document.createElement('style')
108
+ el.id = 'mv-content-css'
109
+ el.textContent = CSS
110
+ document.head.appendChild(el)
111
+ }
112
+
113
+ const CSS = `
114
+ /* only content frames inject this stylesheet, and their document is the Doc -
115
+ zeroing the body margin makes the measured height the truth (a min-height
116
+ would echo the frame's own height back and the measurement could never shrink) */
117
+ body { margin: 0; }
118
+ .mv-doc {
119
+ --mv-bg: #FFFFFF; --mv-surface: #F7F7F9; --mv-line: #D1D1D6; --mv-line-soft: #E5E5EA;
120
+ --mv-text: #1C1C1E; --mv-muted: #636366; --mv-faint: #8E8E93; --mv-accent: #0088FF;
121
+ --mv-block-bg: rgba(20, 22, 28, 0.02); --mv-block-line: rgba(20, 22, 28, 0.07);
122
+ box-sizing: border-box; width: 100%;
123
+ background: var(--mv-bg); color: var(--mv-text);
124
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
125
+ font-size: 15px; line-height: 1.6; -webkit-font-smoothing: antialiased;
126
+ padding: 40px; display: flex; flex-direction: column; gap: ${UNIT}px;
127
+ }
128
+ .dark .mv-doc, [data-theme="dark"] .mv-doc {
129
+ --mv-bg: #1C1C1E; --mv-surface: #26262A; --mv-line: #3A3A3C; --mv-line-soft: #2C2C2E;
130
+ --mv-text: #F2F2F7; --mv-muted: #AEAEB2; --mv-faint: #8E8E93; --mv-accent: #0091FF;
131
+ --mv-block-bg: rgba(255, 255, 255, 0.025); --mv-block-line: rgba(255, 255, 255, 0.07);
132
+ }
133
+ .mv-doc * { box-sizing: border-box; }
134
+ .mv-row { display: flex; flex-wrap: wrap; align-items: flex-start; }
135
+ .mv-row > * { flex: 1 1 260px; min-width: 0; }
136
+ .mv-row > .mv-space { flex: none; }
137
+ .mv-col { display: flex; flex-direction: column; min-width: 0; }
138
+
139
+ /* the rubber: diagram + image blocks own their breathing room. The surface is
140
+ a whisper, not a card - the content pops, the block only frames it */
141
+ .mv-block { margin: 0; padding: ${UNIT}px; border: 1px solid var(--mv-block-line);
142
+ border-radius: 10px; background: var(--mv-block-bg); }
143
+ .mv-block figcaption, .mv-img figcaption { font-size: 12.5px; color: var(--mv-faint); padding-top: 8px; }
144
+ .mv-diagram-svg { display: flex; justify-content: center; }
145
+ .mv-diagram-svg svg { max-width: 100%; height: auto; }
146
+ .mv-diagram-err { display: flex; flex-direction: column; gap: 4px; font-size: 13px; color: var(--mv-muted);
147
+ font-family: ui-monospace, "SF Mono", Menlo, monospace; }
148
+ .mv-diagram-err b { color: #E0402F; font-family: inherit; }
149
+ .mv-diagram-err .dim, .mv-imgerr .dim { color: var(--mv-faint); font-size: 12px; }
150
+ .mv-img img { display: block; max-width: 100%; border-radius: 6px; }
151
+ .mv-imgerr { display: flex; flex-direction: column; gap: 4px; font-size: 13px; color: var(--mv-muted); }
152
+ .mv-imgerr b { color: #E0402F; }
153
+
154
+ /* markdown typography - the HIG scale, Notion's calm. Body 16 on generous
155
+ leading; headings tighten tracking as they grow (SF behavior); sections
156
+ breathe ABOVE far more than below, so structure reads at a glance. Every
157
+ list-style is re-asserted: host Tailwind preflight strips markers. */
158
+ .mv-md { max-width: 72ch; min-width: 0; font-size: 16px; line-height: 1.65; }
159
+ .mv-md > :first-child { margin-top: 0; }
160
+ .mv-md > :last-child { margin-bottom: 0; }
161
+ .mv-md h1 { font-size: 30px; line-height: 1.16; letter-spacing: -0.021em; font-weight: 700; margin: 44px 0 14px; }
162
+ .mv-md h2 { font-size: 22px; line-height: 1.22; letter-spacing: -0.017em; font-weight: 700; margin: 40px 0 12px; }
163
+ .mv-md h3 { font-size: 18px; line-height: 1.3; letter-spacing: -0.01em; font-weight: 650; margin: 30px 0 8px; }
164
+ .mv-md h4, .mv-md h5, .mv-md h6 { font-size: 15px; font-weight: 650; margin: 24px 0 6px; }
165
+ .mv-md p { margin: 0 0 14px; }
166
+ .mv-md ul, .mv-md ol { margin: 0 0 14px; padding-left: 26px; }
167
+ .mv-md ul { list-style: disc; }
168
+ .mv-md ul ul { list-style: circle; }
169
+ .mv-md ol { list-style: decimal; }
170
+ .mv-md li { margin-bottom: 5px; }
171
+ .mv-md li::marker { color: var(--mv-faint); }
172
+ .mv-md li:has(> input[type="checkbox"]) { list-style: none; margin-left: -26px; }
173
+ .mv-md a { color: var(--mv-accent); text-decoration: none; }
174
+ .mv-md a[data-goto] { border-bottom: 1px dashed var(--mv-accent); cursor: pointer; }
175
+ .mv-md blockquote { margin: 4px 0 16px; padding: 2px 0 2px 18px; border-left: 3px solid var(--mv-text);
176
+ color: var(--mv-text); }
177
+ .mv-md blockquote p:last-child { margin-bottom: 0; }
178
+ .mv-md code { font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; font-size: 0.85em;
179
+ background: var(--mv-surface); border: 1px solid var(--mv-line-soft); border-radius: 5px; padding: 1.5px 6px; }
180
+ .mv-md pre { background: var(--mv-surface); border: 1px solid var(--mv-line-soft); border-radius: 10px;
181
+ padding: 16px 18px; overflow-x: auto; margin: 0 0 16px; }
182
+ .mv-md pre code { background: none; border: 0; padding: 0; font-size: 13px; line-height: 1.65; }
183
+ /* tables are CONTAINED (Notion): outer border + radius, header surface, row hairlines */
184
+ .mv-md table { border-collapse: separate; border-spacing: 0; width: 100%; margin: 4px 0 18px;
185
+ font-size: 14.5px; border: 1px solid var(--mv-line-soft); border-radius: 10px; overflow: hidden; }
186
+ .mv-md th, .mv-md td { text-align: left; padding: 9px 14px; }
187
+ .mv-md th { font-size: 11.5px; text-transform: uppercase; letter-spacing: 0.07em; font-weight: 600;
188
+ color: var(--mv-faint); background: var(--mv-surface); border-bottom: 1px solid var(--mv-line-soft); }
189
+ .mv-md td { border-bottom: 1px solid var(--mv-line-soft); }
190
+ .mv-md tr:last-child td { border-bottom: 0; }
191
+ .mv-md img { max-width: 100%; border-radius: 8px; }
192
+ .mv-md hr { border: 0; border-top: 1px solid var(--mv-line-soft); margin: 32px 0; }
193
+ .mv-md input[type="checkbox"] { appearance: auto; accent-color: var(--mv-accent); width: 15px; height: 15px;
194
+ margin: 0 8px 0 0; vertical-align: -2px; }
195
+ .mv-md-noimg { font-size: 13px; color: var(--mv-faint); font-style: italic; }
196
+ `
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Markdown rendering for the Md block (SPEC-026 security section).
3
+ * Raw HTML is inert (escaped, never parsed). Links and images have SEPARATE
4
+ * policies: links may be goto:/http(s)/mailto (external ones open a new tab,
5
+ * never navigate the iframe); images are local-only - design/assets/ paths.
6
+ */
7
+ import { Marked } from 'marked'
8
+
9
+ const escapeHtml = (s: string) => s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`)
10
+
11
+ /** Relative design/assets/ path -> served URL; null for anything else (fail closed).
12
+ * Decodes BEFORE validating (a %2e%2e must not sneak past the ".." check) and
13
+ * re-encodes per segment, so the validated path is the path the browser requests. */
14
+ export function assetUrl(src: string): string | null {
15
+ let p = String(src ?? '').trim()
16
+ try { p = decodeURIComponent(p) } catch { return null }
17
+ if (p.includes('%')) return null // still encoded after one decode - refuse double-encoding games
18
+ if (!p || p.includes('://') || p.includes(':') || p.startsWith('/') || p.startsWith('\\') || p.includes('\\')) return null
19
+ if (p.split('/').some((seg) => seg === '..' || seg === '.' || seg === '')) return null
20
+ return `/design/assets/${p.split('/').map(encodeURIComponent).join('/')}`
21
+ }
22
+
23
+ const marked = new Marked({
24
+ gfm: true,
25
+ renderer: {
26
+ // raw HTML (block or inline) renders as its literal text - inert by construction
27
+ html(token: any) { return escapeHtml(String(token.text ?? '')) },
28
+ link(token: any) {
29
+ const href = String(token.href ?? '')
30
+ const inner = this.parser.parseInline(token.tokens ?? [])
31
+ if (href.startsWith('goto:')) {
32
+ const target = href.slice('goto:'.length)
33
+ return `<a href="#" data-goto="${escapeHtml(target)}">${inner}</a>`
34
+ }
35
+ if (/^https?:\/\//i.test(href) || /^mailto:/i.test(href))
36
+ return `<a href="${escapeHtml(href)}" target="_blank" rel="noopener noreferrer">${inner}</a>`
37
+ return inner // disallowed protocol: the text, no link
38
+ },
39
+ image(token: any) {
40
+ const url = assetUrl(String(token.href ?? ''))
41
+ const alt = escapeHtml(String(token.text ?? ''))
42
+ if (!url) return `<span class="mv-md-noimg">[image unavailable: ${alt || 'external images are not allowed'}]</span>`
43
+ return `<img src="${escapeHtml(url)}" alt="${alt}" loading="lazy" />`
44
+ },
45
+ },
46
+ })
47
+
48
+ export function renderMarkdown(src: string): string {
49
+ return marked.parse(src, { async: false }) as string
50
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * The marver diagram identity (SPEC-026): the full Apple system palette (HIG),
3
+ * every value the HIG's own light/dark pair - never inverted. Structure comes
4
+ * from the systemGray ramp, emphasis from blue+purple, series diversity from
5
+ * the 12-color set.
6
+ */
7
+ export const FONT_STACK = `-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif`
8
+
9
+ /** systemGray 1..6 (index 0 = gray1). */
10
+ const GRAY_LIGHT = ['#8E8E93', '#AEAEB2', '#C7C7CC', '#D1D1D6', '#E5E5EA', '#F2F2F7']
11
+ const GRAY_DARK = ['#8E8E93', '#636366', '#48484A', '#3A3A3C', '#2C2C2E', '#1C1C1E']
12
+
13
+ /** red orange yellow green mint teal cyan blue indigo purple pink brown. */
14
+ const SERIES_LIGHT = ['#FF383C', '#FF8D28', '#FFCC00', '#34C759', '#00C8B3', '#00C3D0', '#00C0E8', '#0088FF', '#6155F5', '#CB30E0', '#FF2D55', '#AC7F5E']
15
+ const SERIES_DARK = ['#FF4245', '#FF9230', '#FFD600', '#30D158', '#00DAC3', '#00D2E0', '#3CD3FE', '#0091FF', '#6D7CFF', '#DB34F2', '#FF375F', '#B78A66']
16
+
17
+ export const ACCENT = { blue: { light: '#0088FF', dark: '#0091FF' }, purple: { light: '#CB30E0', dark: '#DB34F2' } }
18
+
19
+ /** Rides INSIDE the SVG via mermaid's themeCSS option - labels must be MEASURED
20
+ * with this treatment (host CSS landing after render would change text metrics
21
+ * without mermaid re-laying-out the boxes). Same voice as the Md typography:
22
+ * medium-weight labels, SF-style tightening, breathing room on wrapped lines. */
23
+ export const THEME_CSS = `
24
+ .nodeLabel, .cluster-label, .label, text { letter-spacing: -0.01em; }
25
+ .nodeLabel, .cluster-label { font-weight: 600; line-height: 1.4; }
26
+ .nodeLabel p, .edgeLabel p, .label p { margin: 0; }
27
+ .edgeLabel, .edgeLabel .label { font-weight: 500; }
28
+ `
29
+
30
+ /** Mermaid themeVariables for one mode. Base theme + these = the marver look. */
31
+ export function themeVars(dark: boolean): Record<string, string> {
32
+ const g = dark ? GRAY_DARK : GRAY_LIGHT
33
+ const s = dark ? SERIES_DARK : SERIES_LIGHT
34
+ const text = dark ? '#F2F2F7' : '#1C1C1E'
35
+ const surface = dark ? '#1C1C1E' : '#FFFFFF'
36
+ const blue = dark ? ACCENT.blue.dark : ACCENT.blue.light
37
+ const purple = dark ? ACCENT.purple.dark : ACCENT.purple.light
38
+ // Nodes are ACCENTED by default - a plain flowchart only ever touches
39
+ // primaryColor, so a gray primary meant every default diagram rendered
40
+ // gray-on-gray (Nic's dogfood catch, 2026-08-13). Structure (lines, clusters,
41
+ // labels) stays grayscale; the shapes carry the marver blue.
42
+ const blueWash = dark ? '#123A5C' : '#E4F0FF'
43
+ const purpleWash = dark ? '#3A1440' : '#F8E4FC'
44
+ const tealWash = dark ? '#0C3B40' : '#DFF7F9'
45
+ const vars: Record<string, string> = {
46
+ fontFamily: FONT_STACK,
47
+ fontSize: '15px',
48
+ background: surface,
49
+ mainBkg: blueWash, // the default node: blue-washed, blue-edged
50
+ primaryColor: blueWash,
51
+ primaryTextColor: text,
52
+ primaryBorderColor: blue,
53
+ nodeBorder: blue,
54
+ lineColor: g[0], // gray1 - edges read as drawing strokes
55
+ textColor: text,
56
+ secondaryColor: purpleWash,
57
+ secondaryTextColor: text,
58
+ secondaryBorderColor: purple,
59
+ tertiaryColor: tealWash,
60
+ tertiaryTextColor: text,
61
+ tertiaryBorderColor: dark ? '#00D2E0' : '#00C3D0',
62
+ clusterBkg: dark ? '#232326' : '#FAFAFC',
63
+ clusterBorder: g[3],
64
+ edgeLabelBackground: surface,
65
+ titleColor: text,
66
+ actorBkg: blueWash,
67
+ actorBorder: blue,
68
+ actorTextColor: text,
69
+ signalColor: g[0],
70
+ signalTextColor: text,
71
+ labelBoxBkgColor: g[4],
72
+ labelTextColor: text,
73
+ noteBkgColor: dark ? '#2C2A20' : '#FFF9E0',
74
+ noteTextColor: text,
75
+ noteBorderColor: g[2],
76
+ activationBkgColor: dark ? '#123A5C' : '#E0F0FF',
77
+ activationBorderColor: blue,
78
+ errorBkgColor: dark ? '#4A1D1E' : '#FFE5E5',
79
+ errorTextColor: text,
80
+ }
81
+ // categorical series: pie slices, git branches, generic color scales
82
+ s.forEach((c, i) => {
83
+ vars[`pie${i + 1}`] = c
84
+ vars[`cScale${i}`] = c
85
+ if (i < 8) vars[`git${i}`] = c
86
+ })
87
+ vars.pieTitleTextColor = text
88
+ vars.pieSectionTextColor = dark ? '#1C1C1E' : '#FFFFFF'
89
+ vars.pieLegendTextColor = text
90
+ vars.pieStrokeColor = surface
91
+ vars.pieOuterStrokeColor = g[2]
92
+ return vars
93
+ }
@@ -1,12 +1,12 @@
1
1
  import { Component, useEffect, useRef, useState, type ReactNode } from 'react'
2
2
  import { createPortal } from 'react-dom'
3
- import { useStore, CONFIG, PUBLISHED, boardLabel, cap, fetchBoardNames } from './store.ts'
3
+ import { useStore, CONFIG, PUBLISHED, boardLabel, cap, fetchBoardNames, type FrameEntry } from './store.ts'
4
4
  import { Tip } from './Tip.tsx'
5
5
  import { PKG, ROUTE } from '../const.ts'
6
6
  import { animateLayout, Canvas, canvasCtl } from './canvas/Canvas.tsx'
7
7
  import { enterPlay, playCtl, PlayOverlay } from './Play.tsx'
8
8
  import { bootHash, parseHash, writeHash } from './hash.ts'
9
- import { CardsIcon, CardsThreeIcon, CaretIcon, CheckIcon, DevicesIcon, GridIcon, MoonIcon, PanelFilledIcon, PanelHollowIcon, ParallelogramDuoIcon, PlayIcon, PlusIcon, SignpostIcon, SunIcon, VariantsIcon, XIcon, deviceIcon } from './icons.tsx'
9
+ import { CardsIcon, CardsThreeIcon, CaretIcon, CheckIcon, DevicesIcon, FrameRectIcon, GridIcon, IntentGlyph, MoonIcon, PanelFilledIcon, PanelHollowIcon, ParallelogramDuoIcon, PlayIcon, PlusIcon, SignpostIcon, SunIcon, VariantsIcon, XIcon, deviceIcon } from './icons.tsx'
10
10
 
11
11
  let booted = false // survives Fast Refresh; see the boot effect
12
12
 
@@ -529,6 +529,16 @@ export function App() {
529
529
  s.setStatus(nodeKey, 'error', String(data.message ?? 'unknown error'))
530
530
  } else if (data.type === 'sh:exit-interact') {
531
531
  if (s.interact === nodeKey) setInteract(null)
532
+ } else if (data.type === 'sh:measure') {
533
+ // SPEC-026 generation guard: the sender echoes ITS document's URL rev; a
534
+ // WindowProxy survives navigation, so a stale pre-navigation message would
535
+ // otherwise route as if it came from the current document. Compare against
536
+ // the iframe's CURRENT src - mismatched generations are dropped.
537
+ const gen = el.src.match(/[?&]r=(\d+)/)?.[1] ?? ''
538
+ if (String(data.gen ?? '') !== gen) return
539
+ // measureNode does the rest of the admission (content frames only,
540
+ // frame-id match, finite positive, clamped)
541
+ s.measureNode(nodeKey, String(data.frame ?? ''), Number(data.ownWidth), Number(data.measuredWidth), Number(data.height))
532
542
  } else if (data.type === 'sh:go') {
533
543
  const target = String(data.target ?? '')
534
544
  const existing = s.nodes.find((n) => n.frame === target && !n.missing)
@@ -605,7 +615,18 @@ export function App() {
605
615
  // the sidebar reflects the ACTIVE BOARD: only scenes/frames with a node on this board
606
616
  const onBoard = new Set(nodes.map((n) => n.frame))
607
617
  const frames = (manifest?.frames ?? []).filter((f) => onBoard.has(f.id))
608
- const scenes = [...new Set(frames.map((f) => f.scene))].sort()
618
+ // scene groups follow the CANVAS reading order (earliest node y, then x), the
619
+ // same law as frame rows - a story board's phases list top-to-bottom as laid out
620
+ const scenes = [...new Set(frames.map((f) => f.scene))].sort((a, b) => {
621
+ const min = (sc: string) => nodes.reduce((acc, n) => {
622
+ const f = frames.find((x) => x.id === n.frame)
623
+ if (f?.scene !== sc) return acc
624
+ return !acc || n.y < acc.y || (n.y === acc.y && n.x < acc.x) ? { y: n.y, x: n.x } : acc
625
+ }, null as { y: number; x: number } | null)
626
+ const ma = min(a), mb = min(b)
627
+ if (!ma || !mb) return ma ? -1 : mb ? 1 : a.localeCompare(b)
628
+ return ma.y - mb.y || ma.x - mb.x || a.localeCompare(b)
629
+ })
609
630
  .map((name) => ({ name, frames: frames.filter((f) => f.scene === name).length }))
610
631
  // frame ids currently selected, for marking their parent scenes as `held`
611
632
  const selFrames = new Set(nodes.filter((n) => selection.includes(n.key)).map((n) => n.frame))
@@ -651,8 +672,24 @@ export function App() {
651
672
  }}>
652
673
  {(() => {
653
674
  // variant groups render as ONE surface row with A/B/C chips (SPEC-023 §5)
654
- const sceneFrames = frames.filter((f) => f.scene === sc.name)
655
675
  const nodeFor = (id: string) => nodes.find((x) => x.frame === id && !x.missing) ?? nodes.find((x) => x.frame === id)
676
+ // sidebar order follows the CANVAS (reading order: rows top-to-bottom,
677
+ // left-to-right) - a story board's list must tell the same story as its
678
+ // layout. Grouped frames anchor at their group's earliest position so
679
+ // the run stays contiguous; members sort by letter within it.
680
+ const pos = (id: string) => { const n = nodeFor(id); return n ? { y: n.y, x: n.x } : { y: Number.MAX_SAFE_INTEGER, x: 0 } }
681
+ const sceneFrames = frames.filter((f) => f.scene === sc.name)
682
+ const groupPos = new Map<string, { y: number; x: number }>()
683
+ for (const f of sceneFrames) {
684
+ if (!f.variantGroup) continue
685
+ const p = pos(f.id), g = groupPos.get(f.variantGroup)
686
+ if (!g || p.y < g.y || (p.y === g.y && p.x < g.x)) groupPos.set(f.variantGroup, p)
687
+ }
688
+ const keyOf = (f: FrameEntry) => f.variantGroup ? groupPos.get(f.variantGroup)! : pos(f.id)
689
+ sceneFrames.sort((a, b) => {
690
+ const ka = keyOf(a), kb = keyOf(b)
691
+ return ka.y - kb.y || ka.x - kb.x || (a.variant ?? '').localeCompare(b.variant ?? '')
692
+ })
656
693
  const go = (id: string, shift: boolean) => {
657
694
  const n = nodeFor(id)
658
695
  if (!n) return
@@ -675,12 +712,14 @@ export function App() {
675
712
  // group participates without claiming full selection
676
713
  const held = !allOn && memberKeys.some((k) => selection.includes(k) || useStore.getState().interact === k)
677
714
  // group header: click selects EVERY variant (the quick compare-and-test grab)
715
+ // the group row leads with the flask - variant-ness IS the row's
716
+ // identity; members carry their letter chips, indented below it
678
717
  rows.push(
679
718
  <div key={`g:${f.variantGroup}`} className={`sub vgroup${allOn ? ' on' : ''}${held ? ' held' : ''}`}
680
719
  title="Select all variants"
681
720
  onClick={() => { useStore.getState().selectMany(memberKeys); canvasCtl.fitNodes(memberKeys) }}>
721
+ <VariantsIcon size={13} className="iicon" />
682
722
  <span className="glabel">{rel}</span>
683
- <VariantsIcon size={14} className="gicon" />
684
723
  </div>,
685
724
  )
686
725
  // one row per variant: [letter chip] + name, individually selectable
@@ -700,7 +739,12 @@ export function App() {
700
739
  const n = nodeFor(f.id)
701
740
  const on = !!n && selection.includes(n.key)
702
741
  rows.push(
703
- <div key={f.id} className={`sub${on ? ' on' : ''}`} onClick={(e) => go(f.id, e.shiftKey)}>
742
+ <div key={f.id} className={`sub${on ? ' on' : ''}`} onClick={(e) => go(f.id, e.shiftKey)} title={f.intent}>
743
+ {/* every frame leads with an icon: intent glyph for content
744
+ frames, the plain frame rectangle for UI frames */}
745
+ {f.intent
746
+ ? <IntentGlyph intent={f.intent} size={13} className="iicon" aria-label={f.intent} />
747
+ : <FrameRectIcon size={13} className="iicon" />}
704
748
  {cap(f.id.split('/').slice(1).join('/') || f.id)}
705
749
  </div>,
706
750
  )
@@ -420,7 +420,6 @@ function PlayInner() {
420
420
  </Tip>
421
421
  {variants.length > 1 && <>
422
422
  <i className="sep" />
423
- <span className="vname">{(() => { const c = variants.find((v) => v.id === play.at); return c ? (c.title ?? (c.id.split('/').pop() ?? '').replace(/^[a-z]-/, '').replace(/-/g, ' ')) : '' })()}</span>
424
423
  {variants.map((v) => (
425
424
  <Tip inv key={v.id} label={<><b>{v.title ?? v.id}</b><span className="k">[ ]</span></>}>
426
425
  <button className={`vchip${v.id === play.at ? ' on' : ''}`} onClick={() => goTo(v.id)}>
@@ -428,6 +427,13 @@ function PlayInner() {
428
427
  </button>
429
428
  </Tip>
430
429
  ))}
430
+ {(() => {
431
+ // chips lead as the stable anchor; the name trails at natural width, so
432
+ // a length change grows the pill rightward instead of shifting the chips
433
+ const c = variants.find((v) => v.id === play.at)
434
+ const full = c ? (c.title ?? (c.id.split('/').pop() ?? '').replace(/^[a-z]-/, '').replace(/-/g, ' ')) : ''
435
+ return <Tip inv label={<b>{full}</b>}><span className="vname">{full}</span></Tip>
436
+ })()}
431
437
  </>}
432
438
  </div>
433
439
  </div>
@@ -1,6 +1,6 @@
1
1
  import { memo, useEffect, useRef } from 'react'
2
2
  import { cap, frameUrl, useStore, CONFIG, type Node } from '../store.ts'
3
- import { CopyIcon, ReloadIcon, XIcon } from '../icons.tsx'
3
+ import { CopyIcon, IntentGlyph, ReloadIcon, XIcon } from '../icons.tsx'
4
4
 
5
5
  export const HEADER = 28
6
6
  const SNAP = 12
@@ -169,6 +169,8 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
169
169
  </div>
170
170
  )}
171
171
  <div className="sh-node-head sh-no-pan" onPointerDown={(e) => drag(e, 'move')} title={frame.file}>
172
+ {/* content frames carry their intent glyph in the chrome (SPEC-026) */}
173
+ {frame.intent && <IntentGlyph intent={frame.intent} size={12} className="iicon sh-no-pan" aria-label={frame.intent} />}
172
174
  <span className="id sh-no-pan">{frame.title ?? frame.id}</span>
173
175
  <span className="dim sh-no-pan">{Math.round(node.w)} · {node.theme}</span>
174
176
  </div>
@@ -38,6 +38,28 @@ export const ParallelogramDuoIcon = icon(<><path d="M239.29,59.28l-64.8,144a8,8,
38
38
  /** Sidebar trigger, custom in Phosphor 256-space: outer frame + panel pill -
39
39
  * pill filled while the sidebar is open, hollow while collapsed. */
40
40
  export const PanelFilledIcon = icon(<><rect x="32" y="48" width="192" height="160" rx="32" fill="none" stroke="currentColor" strokeWidth="16" /><rect x="72" y="88" width="48" height="80" rx="24" /></>)
41
+
42
+ /** Plain UI frame - the rectangle every non-content sidebar row leads with. */
43
+ export const FrameRectIcon = icon(<rect x="40" y="52" width="176" height="152" rx="20" fill="none" stroke="currentColor" strokeWidth="16" />)
44
+
45
+ /* Content-frame intent glyphs (SPEC-026), custom in the same 256-space. */
46
+ export const DiagramShapeIcon = icon(<><rect x="28" y="36" width="88" height="64" rx="14" fill="none" stroke="currentColor" strokeWidth="16" /><rect x="140" y="156" width="88" height="64" rx="14" fill="none" stroke="currentColor" strokeWidth="16" /><path d="M116 68 H184 V156" fill="none" stroke="currentColor" strokeWidth="16" strokeLinecap="round" strokeLinejoin="round" /></>)
47
+ export const SpecDocIcon = icon(<><path d="M44 48 H180 M44 96 H212 M44 144 H212 M44 192 H140" fill="none" stroke="currentColor" strokeWidth="16" strokeLinecap="round" /></>)
48
+ export const MoodboardIcon = icon(<><rect x="32" y="48" width="192" height="160" rx="20" fill="none" stroke="currentColor" strokeWidth="16" /><circle cx="96" cy="104" r="18" /><path d="M56 192 L112 128 L150 168 L178 140 L200 164 V192 Z" /></>)
49
+ export const NoteStickyIcon = icon(<><path d="M52 40 H204 a16 16 0 0 1 16 16 V152 L156 216 H52 a16 16 0 0 1 -16 -16 V56 a16 16 0 0 1 16 -16 Z" fill="none" stroke="currentColor" strokeWidth="16" strokeLinejoin="round" /><path d="M156 216 V168 a16 16 0 0 1 16 -16 h48" fill="none" stroke="currentColor" strokeWidth="16" strokeLinejoin="round" /></>)
50
+ export const ContentBlocksIcon = icon(<><rect x="36" y="36" width="80" height="80" rx="14" fill="none" stroke="currentColor" strokeWidth="16" /><circle cx="180" cy="180" r="44" fill="none" stroke="currentColor" strokeWidth="16" /><path d="M180 36 L224 116 H136 Z" fill="none" stroke="currentColor" strokeWidth="16" strokeLinejoin="round" /></>)
51
+
52
+ /** intent -> glyph; unknown intents fall to the generic content icon (forward-compatible).
53
+ * Accessible by default: role img + label + tooltip carry the intent name (the base
54
+ * icon() is aria-hidden; the spread below overrides it). */
55
+ export function IntentGlyph({ intent, size = 14, ...rest }: IconProps & { intent: string }) {
56
+ const I = intent === 'diagram' ? DiagramShapeIcon
57
+ : intent === 'spec' ? SpecDocIcon
58
+ : intent === 'moodboard' ? MoodboardIcon
59
+ : intent === 'notes' ? NoteStickyIcon
60
+ : ContentBlocksIcon
61
+ return <I size={size} role="img" aria-hidden={false} aria-label={intent} {...({ title: intent } as object)} {...rest} />
62
+ }
41
63
  export const PanelHollowIcon = icon(<><rect x="32" y="48" width="192" height="160" rx="32" fill="none" stroke="currentColor" strokeWidth="16" /><rect x="72" y="88" width="48" height="80" rx="24" fill="none" stroke="currentColor" strokeWidth="14" /></>)
42
64
  /** Board glyphs: CardsThree = the built-in `all-scenes` board; Cards = curated boards. */
43
65
  export const CardsThreeIcon = icon(<path d="M208,88H48a16,16,0,0,0-16,16v96a16,16,0,0,0,16,16H208a16,16,0,0,0,16-16V104A16,16,0,0,0,208,88Zm0,112H48V104H208v96ZM48,64a8,8,0,0,1,8-8H200a8,8,0,0,1,0,16H56A8,8,0,0,1,48,64ZM64,32a8,8,0,0,1,8-8H184a8,8,0,0,1,0,16H72A8,8,0,0,1,64,32Z" />)