@marver-design/marver 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -35,9 +35,10 @@ export interface Node {
35
35
  }
36
36
  export interface Toast { id: number; text: string }
37
37
 
38
- export const CONFIG: { viewports: Record<string, { width: number; height: number }>; themes: string[]; zoomSpeed?: number; noTheme: boolean; setup?: boolean } = shConfig
38
+ export const CONFIG: { viewports: Record<string, { width: number; height: number }>; themes: string[]; zoomSpeed?: number; noTheme: boolean; setup?: boolean; projectName?: string } = shConfig
39
39
 
40
- export const cap = (s: string) => (s ? s[0].toUpperCase() + s.slice(1) : s)
40
+ export { cap, humanize } from './labels.ts'
41
+ import { cap, humanize } from './labels.ts'
41
42
 
42
43
  /** Board names for switchers: all-scenes first, the rest sorted. Throws on transport
43
44
  * failure - callers keep their last known list. */
@@ -47,7 +48,7 @@ export async function fetchBoardNames(): Promise<string[]> {
47
48
  return ['all-scenes', ...list.map((b) => b.name).filter((n) => n !== 'all-scenes').sort()]
48
49
  }
49
50
  /** Display name for a board: the reserved 'all-scenes' key reads as "All scenes". */
50
- export const boardLabel = (n: string) => (n === 'all-scenes' ? 'All scenes' : cap(n))
51
+ export const boardLabel = (n: string) => humanize(n)
51
52
 
52
53
  const HEADER = 28
53
54
  let toastSeq = 0
@@ -125,6 +126,14 @@ interface State {
125
126
  toasts: Toast[]
126
127
  boardHash: string | null // sha256 of the board file on disk, when materialized
127
128
  dirty: boolean
129
+ // A6/A7 controlled HMR (transient, never persisted): a frame the user is actively using
130
+ // (interact/play target, mid-gesture, laser/comment engaged) is "leased" - a hot update to
131
+ // it is DEFERRED (coalesced to the latest revision) until a safe point, so an agent edit
132
+ // never yanks the user out of their flow.
133
+ pendingFrameRevisions: Record<string, string> // frameId -> latest deferred revision
134
+ externalLeases: Record<string, { laser?: true; comment?: true }> // nodeKey -> transient engagement
135
+ playUpdateRevision: string | null // a revision arrived while play is open
136
+ playNav: number // bumps to reload the play stage on demand
128
137
 
129
138
  boot(): Promise<boolean>
130
139
  applyManifest(m: Manifest): void
@@ -141,6 +150,10 @@ interface State {
141
150
  setPlay(p: State['play']): void
142
151
  setGesture(g: boolean): void
143
152
  setLaser(on: boolean): void
153
+ invalidateFrames(frameIds: string[], revision: string): void
154
+ setExternalLease(nodeKey: string, reason: 'laser' | 'comment', on: boolean): void
155
+ flushFrameUpdates(frameIds?: string[]): void
156
+ applyPlayUpdate(): void
144
157
  moveSelectedBy(dx: number, dy: number, starts: Record<string, { x: number; y: number }>): void
145
158
  setSelectedTheme(theme: string): void
146
159
  setDeviceView(name: string | null): void
@@ -155,6 +168,16 @@ interface State {
155
168
  save(): Promise<boolean>
156
169
  }
157
170
 
171
+ /** A6: is any node of this frame in active use, so a hot update must DEFER? Frame-scoped -
172
+ * if the same frame is placed twice and one copy is active, both stay on the same revision. */
173
+ function frameIsLeased(frameId: string, s: State): boolean {
174
+ return s.nodes.some((n) =>
175
+ n.frame === frameId && n.status !== 'error' && (
176
+ s.interact === n.key ||
177
+ (s.gesture && s.selection.includes(n.key)) ||
178
+ !!s.externalLeases[n.key]))
179
+ }
180
+
158
181
  export const useStore = create<State>((set, get) => {
159
182
  let saveTimer: ReturnType<typeof setTimeout> | undefined
160
183
  let saveChain: Promise<boolean> = Promise.resolve(true) // saves are serialized; responses only commit if the board is still active
@@ -393,6 +416,7 @@ export const useStore = create<State>((set, get) => {
393
416
  manifest: null, nodes: [], selection: [], interact: null, viewTheme: initialViewTheme(), play: null, gesture: false, laser: false,
394
417
  board: DATA?.default ?? 'all-scenes', boardAuto: (DATA?.default ?? 'all-scenes') === 'all-scenes', deviceView: null, sceneRows: null, layout: null, layoutRaw: undefined, baseLayout: null,
395
418
  panelOpen: true, scale: 1, toasts: [], boardHash: null, dirty: false,
419
+ pendingFrameRevisions: {}, externalLeases: {}, playUpdateRevision: null, playNav: 0,
396
420
 
397
421
  async boot() {
398
422
  const seq = ++loadSeq
@@ -687,9 +711,22 @@ export const useStore = create<State>((set, get) => {
687
711
  // interact is a ONE-frame mode: entering it collapses any multi-selection to the
688
712
  // interacted frame (a 4-frame selection double-clicked otherwise leaves all four
689
713
  // painted as "interactive"). Exiting keeps the frame selected for continuity.
690
- setInteract(key) { set((s) => ({ interact: key, selection: key ? [key] : s.selection })) },
714
+ setInteract(key) {
715
+ set((s) => ({ interact: key, selection: key ? [key] : s.selection }))
716
+ get().flushFrameUpdates() // A6: the frame just left interact is no longer leased
717
+ },
691
718
  setPlay(play) { set({ play }) },
692
- setLaser(laser) { set({ laser }) },
719
+ setLaser(laser) {
720
+ if (laser) { set({ laser }); return }
721
+ // leaving laser mode drops every laser engagement lease (the bridge stops reporting), but
722
+ // keeps comment engagement; the freed frames may now flush a deferred update
723
+ set((s) => ({
724
+ laser,
725
+ externalLeases: Object.fromEntries(
726
+ Object.entries(s.externalLeases).filter(([, v]) => v.comment).map(([k]) => [k, { comment: true as const }])),
727
+ }))
728
+ get().flushFrameUpdates()
729
+ },
693
730
  setGesture(gesture) {
694
731
  set({ gesture })
695
732
  // SPEC-024 §4: a board WITH a layout recipe re-applies it when a resize
@@ -699,6 +736,49 @@ export const useStore = create<State>((set, get) => {
699
736
  if (get().layout || get().sceneRows?.length) get().runTidy() // schedules the save
700
737
  else scheduleSave()
701
738
  }
739
+ if (!gesture) get().flushFrameUpdates() // A6: gesture end is a safe point for deferred updates
740
+ },
741
+ // A6/A7: apply a controlled hot update. A leased frame's revision is deferred (coalesced to
742
+ // the latest) and applied at the next safe point; an idle frame reloads now via its nav nonce.
743
+ invalidateFrames(frameIds, revision) {
744
+ const ids = new Set(frameIds.filter((x) => typeof x === 'string'))
745
+ if (!ids.size) return
746
+ bumpManifestRev()
747
+ const s = get()
748
+ const pending = { ...s.pendingFrameRevisions }
749
+ const nodes = s.nodes.map((n) => {
750
+ if (!ids.has(n.frame)) return n
751
+ if (frameIsLeased(n.frame, s)) { pending[n.frame] = revision; return n }
752
+ return { ...n, status: 'loading' as const, error: undefined, nav: (n.nav ?? 0) + 1 }
753
+ })
754
+ set({ nodes, pendingFrameRevisions: pending, ...(s.play ? { playUpdateRevision: revision } : {}) })
755
+ },
756
+ flushFrameUpdates(frameIds) {
757
+ const s = get()
758
+ const ids = (frameIds ?? Object.keys(s.pendingFrameRevisions)).filter((id) => s.pendingFrameRevisions[id] && !frameIsLeased(id, s))
759
+ if (!ids.length) return
760
+ bumpManifestRev()
761
+ const apply = new Set(ids)
762
+ const pending = { ...s.pendingFrameRevisions }
763
+ for (const id of ids) delete pending[id]
764
+ const nodes = s.nodes.map((n) => apply.has(n.frame)
765
+ ? { ...n, status: 'loading' as const, error: undefined, nav: (n.nav ?? 0) + 1 } : n)
766
+ set({ nodes, pendingFrameRevisions: pending })
767
+ },
768
+ setExternalLease(nodeKey, reason, on) {
769
+ set((s) => {
770
+ const cur = { ...(s.externalLeases[nodeKey] ?? {}) }
771
+ if (on) cur[reason] = true; else delete cur[reason]
772
+ const leases = { ...s.externalLeases }
773
+ if (Object.keys(cur).length) leases[nodeKey] = cur; else delete leases[nodeKey]
774
+ return { externalLeases: leases }
775
+ })
776
+ if (!on) get().flushFrameUpdates()
777
+ },
778
+ applyPlayUpdate() {
779
+ if (!get().playUpdateRevision) return
780
+ bumpManifestRev()
781
+ set((s) => ({ playUpdateRevision: null, playNav: s.playNav + 1 }))
702
782
  },
703
783
  setScale(scale) { set({ scale }) },
704
784
  togglePanel() { set((s) => ({ panelOpen: !s.panelOpen })) },
@@ -803,10 +883,19 @@ export const useStore = create<State>((set, get) => {
803
883
  try {
804
884
  const res = await fetch(`${ROUTE}/api/boards/${boardName}`, {
805
885
  method: 'PUT', headers: { 'content-type': 'application/json' },
806
- body: JSON.stringify({ board, baseHash: boardHash }),
886
+ // A9: a board loaded from disk (boardHash set) autosaves with mustExist so a
887
+ // rename/delete out from under us returns 409 instead of resurrecting a ghost.
888
+ body: JSON.stringify({ board, baseHash: boardHash, mustExist: !!boardHash }),
807
889
  })
808
890
  if (get().board !== boardName) return true // switched boards mid-flight; stale response must not touch state
809
891
  if (res.status === 409) {
892
+ const info = await res.json().catch(() => ({} as { gone?: boolean }))
893
+ if (info?.gone) {
894
+ // renamed/deleted externally - drop the write, don't recreate it or loop
895
+ set({ dirty: false })
896
+ get().toast('this board was renamed or removed - not recreating it')
897
+ return true
898
+ }
810
899
  const reloaded = await get().boot() // hash advances only when the authoritative reload commits
811
900
  if (reloaded) get().toast('board changed on disk - canvas layout reloaded')
812
901
  return reloaded // false keeps dirty set; the next debounce retries once edits settle
@@ -825,3 +914,6 @@ export const useStore = create<State>((set, get) => {
825
914
  },
826
915
  }
827
916
  })
917
+
918
+ // dev-only inspection hook for tests/debugging (never installed in published builds)
919
+ if (import.meta.env.DEV) (window as unknown as { __mvStore: unknown }).__mvStore = useStore
@@ -122,6 +122,13 @@ button { font-family: inherit }
122
122
  button svg { pointer-events: none } /* event targets stay on the button - pan exclusion depends on it */
123
123
  @media (prefers-reduced-motion: reduce) { *, *::before, *::after { transition: none !important } }
124
124
 
125
+ /* Default app pointer: the marver arrowhead (tilted left, rounded, thick border, soft shadow),
126
+ theme-adaptive - black/white on light, white/dark on dark. Hotspot at its tip (5,2). Overridden by
127
+ the state cursors: grab (space-pan), resize handles, comment-pin / laser-crosshair (set in-frame by
128
+ the bridge), and a normal pointer in interact / prototype mode. */
129
+ .sh-app { --sh-cursor: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='22' height='22' viewBox='0 0 26 26'%3E%3Cdefs%3E%3Cfilter id='s' x='-40%' y='-40%' width='180%' height='180%'%3E%3CfeDropShadow dx='0.4' dy='0.9' stdDeviation='0.7' flood-color='%23000000' flood-opacity='0.4'/%3E%3C/filter%3E%3C/defs%3E%3Cg filter='url(%23s)'%3E%3Cg transform='rotate(-20 6 3)'%3E%3Cpath fill='%23000000' stroke='%23FFFFFF' stroke-width='1.6' stroke-linejoin='round' stroke-linecap='round' d='M5.5 3.21V20.8c0 .45.54.67.85.35l4.86-4.86a1 1 0 0 1 .7-.3h6.87a1 1 0 0 0 .7-1.7L6.35 2.85a.5.5 0 0 0-.85.35Z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E") 5 2, default; cursor: var(--sh-cursor) }
130
+ .sh-app.dark { --sh-cursor: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='22' height='22' viewBox='0 0 26 26'%3E%3Cdefs%3E%3Cfilter id='s' x='-40%' y='-40%' width='180%' height='180%'%3E%3CfeDropShadow dx='0.4' dy='0.9' stdDeviation='0.7' flood-color='%23000000' flood-opacity='0.4'/%3E%3C/filter%3E%3C/defs%3E%3Cg filter='url(%23s)'%3E%3Cg transform='rotate(-20 6 3)'%3E%3Cpath fill='%23FFFFFF' stroke='%2318181b' stroke-width='1.6' stroke-linejoin='round' stroke-linecap='round' d='M5.5 3.21V20.8c0 .45.54.67.85.35l4.86-4.86a1 1 0 0 1 .7-.3h6.87a1 1 0 0 0 .7-1.7L6.35 2.85a.5.5 0 0 0-.85.35Z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E") 5 2, default }
131
+ .sh-app.interacting { cursor: auto }
125
132
  .sh-app { position: relative; height: 100%; color: var(--glass-ink); outline: none;
126
133
  transition: --accent .4s ease, --accent-ring .4s ease, --beam .4s ease,
127
134
  --edge-accent .4s ease, --inner-tint .4s ease, --accent-wash .4s ease,
@@ -141,15 +148,20 @@ button svg { pointer-events: none } /* event targets stay on the button - pan
141
148
  opacity: var(--grid-alpha, 1) }
142
149
  .sh-content { will-change: auto }
143
150
  #sh-world { position: relative; width: 1px; height: 1px }
144
- #sh-world.sh-gesturing iframe { pointer-events: none } /* law G-4 */
151
+ #sh-world.sh-gesturing .sh-live { pointer-events: none } /* law G-4 (live iframe only; lean is always pe:none) */
145
152
  .sh-gesturing .sh-content { will-change: transform } /* law G-3: gesture-scoped only */
146
153
  /* preset transitions: armed by animateLayout() around device/tidy mutations - nodes ease
147
154
  to their new frame on the same 320ms ease-out the camera fit animates with */
148
155
  #sh-world.sh-preset .sh-node { transition: transform .32s cubic-bezier(.25,.46,.45,.94),
149
156
  width .32s cubic-bezier(.25,.46,.45,.94), height .32s cubic-bezier(.25,.46,.45,.94) }
150
157
  #sh-world.sh-preset .sh-node-body,
151
- #sh-world.sh-preset .sh-node iframe { transition: width .32s cubic-bezier(.25,.46,.45,.94),
158
+ #sh-world.sh-preset .sh-node .sh-live { transition: width .32s cubic-bezier(.25,.46,.45,.94),
152
159
  height .32s cubic-bezier(.25,.46,.45,.94) }
160
+ /* SPEC-M5 device-sweep: a frame WITH a ready lean cover jumps its LIVE iframe straight to the final
161
+ width (ONE reflow, hidden under the cover) while the lean cover - being width:100% of the animating
162
+ body - reflows smoothly every frame (real CSS, the device-sweep fix). A frame WITHOUT a usable
163
+ cover keeps the live width animation above. */
164
+ body:not(.sh-laser):not(.sh-commenting) #sh-world.sh-preset .sh-node:has(.sh-lean[data-ready]):not(.interact) .sh-live { transition: none }
153
165
 
154
166
  /* cursor conventions (Figma): arrow everywhere; grab only while space is held */
155
167
  body.sh-space .sh-canvas { cursor: grab }
@@ -183,7 +195,7 @@ body.sh-space .sh-node { pointer-events: none } /* space-drag
183
195
  box-shadow: 0 0 0 calc(4px * var(--sh-inv, 1)) var(--accent-ring), var(--shadow-node) }
184
196
  .sh-node-head { height: 28px; display: flex; align-items: center; gap: 8px; padding: 0 11px;
185
197
  font-size: 11px; color: var(--head-dim);
186
- border-bottom: 1px solid var(--node-brd); cursor: default; user-select: none;
198
+ border-bottom: 1px solid var(--node-brd); user-select: none;
187
199
  border-radius: var(--r-node) var(--r-node) 0 0;
188
200
  background-color: var(--head-bg); background-image: var(--head-sheen);
189
201
  backdrop-filter: var(--blur); -webkit-backdrop-filter: var(--blur) }
@@ -197,7 +209,20 @@ body.sh-space .sh-node { pointer-events: none } /* space-drag
197
209
  .sh-node-head .dim { margin-left: auto; color: var(--head-dim); flex: none; font-variant-numeric: tabular-nums }
198
210
  .sh-node-body { position: relative; background: var(--node-bg); border-radius: 0 0 var(--r-node) var(--r-node); overflow: hidden }
199
211
  .sh-node iframe { border: 0; display: block }
200
- .sh-overlay { position: absolute; inset: 0; cursor: default }
212
+ /* SPEC-M5 LEAN-PRIMARY: the lean DOM-snapshot <iframe> (static html, 0 JS) is what you SEE for a
213
+ passive frame - at rest AND during pan/zoom/resize. There is NO per-gesture swap between the lean
214
+ and the live iframe (the swap shifted text ~1-2px = "jiggle", and flashed mermaid/theme colors,
215
+ because two documents never render pixel-identically). The live app (.sh-live) sits underneath and
216
+ shows ONLY when: the frame is interacted (.interact), laser/comment mode is on, or the lean is not
217
+ yet built (no data-ready = live-fallback). Hard cut, never a crossfade - two ~1px-offset text docs
218
+ would ghost into double text. */
219
+ .sh-lean { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; z-index: 1;
220
+ opacity: 0; pointer-events: none; transition: none; background: var(--node-bg) }
221
+ .sh-lean[data-ready] { opacity: 1 }
222
+ .sh-node.interact .sh-lean,
223
+ body.sh-laser .sh-lean,
224
+ body.sh-commenting .sh-lean { opacity: 0 }
225
+ .sh-overlay { position: absolute; inset: 0; z-index: 2 } /* above the lean (z1) so drag-by-body works; inherits the app arrow cursor */
201
226
  .sh-node.interact { border-color: var(--interact);
202
227
  outline: calc(2px * var(--sh-inv, 1)) solid var(--interact);
203
228
  outline-offset: calc(-1px * var(--sh-inv, 1));
@@ -319,8 +344,11 @@ body.sh-space .sh-node { pointer-events: none } /* space-drag
319
344
  transform-origin: 22px 22px; transition: transform var(--morph), opacity .2s ease }
320
345
  .sh-panel.closed { transform: scale(.16); opacity: 0; pointer-events: none }
321
346
  .sh-panel-top { display: flex; align-items: center; gap: 8px; padding: 2px 2px 8px 8px }
322
- .sh-panel-top .mark { flex: none; color: var(--accent) }
323
- .sh-panel-top .name { font-weight: 700; font-size: 15px; flex: 1; letter-spacing: -0.01em }
347
+ .sh-panel-top .mark-link { flex: none; display: flex; border-radius: 6px; outline-offset: 2px; transition: opacity .15s ease }
348
+ .sh-panel-top .mark-link:hover { opacity: .72 }
349
+ .sh-panel-top .mark { flex: none; color: var(--accent); display: block }
350
+ .sh-panel-top .name { margin-right: auto; min-width: 0; font-weight: 700; font-size: 15px; letter-spacing: -0.01em;
351
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap }
324
352
  .sh-panel-scroll { overflow-y: auto; min-height: 0; padding-bottom: 2px; scrollbar-width: thin;
325
353
  scrollbar-color: var(--glass-ink-3) transparent }
326
354
  /* Sidebar list system (macOS-sidebar conventions): every row is 28px tall with an
@@ -449,7 +477,7 @@ body.sh-space .sh-node { pointer-events: none } /* space-drag
449
477
  /* ---- Play mode (SPEC-M2 §1): near-black stage, one centered device, auto-hiding bar.
450
478
  Fixed dark palette - the backdrop never follows the board theme, so no tokens here. */
451
479
  .sh-play { position: absolute; inset: 0; z-index: 30; background: #0a0a0b; display: flex;
452
- align-items: center; justify-content: center; animation: sh-play-in .28s ease-out }
480
+ align-items: center; justify-content: center; animation: sh-play-in .28s ease-out; cursor: auto }
453
481
  @keyframes sh-play-in { from { opacity: 0 } }
454
482
  /* the device wears the frame-node card language: same theme border tokens, the same
455
483
  masked 1px edge-light gradient ring (glass catching light), and a real drop shadow
@@ -498,6 +526,8 @@ body.sh-space .sh-node { pointer-events: none } /* space-drag
498
526
  /* board switcher */
499
527
  .sh-play-bar .bd-wrap { position: relative }
500
528
  .sh-play-bar .bd { width: auto; gap: 6px; padding: 0 10px 0 12px; white-space: nowrap; font: inherit }
529
+ .sh-play-bar .sh-play-update { width: auto; gap: 5px; padding: 0 11px; font-size: 12px; font-weight: 600; color: #6db3ff; white-space: nowrap }
530
+ .sh-play-bar .sh-play-update:hover { background: rgba(109, 179, 255, .16); color: #8ec5ff }
501
531
  /* no backdrop-filter here: the menu nests inside the blurred bar, and glass never nests
502
532
  (a nested backdrop-filter samples the bar, not the page) - near-opaque ink instead */
503
533
  .sh-play-menu { position: absolute; top: calc(100% + 10px); left: 0; min-width: 150px; padding: 4px;
@@ -31,6 +31,8 @@ window.addEventListener('unhandledrejection', (e) => post({ type: 'sh:stage-erro
31
31
  // pinch inside the stage must not zoom the parent page (same rule as the frame bridge)
32
32
  window.addEventListener('wheel', (e) => { if (e.ctrlKey || e.metaKey) e.preventDefault() }, { passive: false })
33
33
  document.addEventListener('gesturestart', (e) => e.preventDefault())
34
+ // B0.2: a nested scroll container hitting its boundary must not chain into the shell page
35
+ document.documentElement.style.overscrollBehavior = 'contain'
34
36
 
35
37
  interface Mounted { id: string; Frame: ComponentType; wrappers: ComponentType[] }
36
38
 
@@ -46,8 +46,29 @@ present). Focus indicators need contrast AND a clearly visible change of appeara
46
46
  Check interactive states, text over images, and BOTH themes.
47
47
  Anything conveyed by color alone also needs text, shape, icon, or position.
48
48
 
49
+ ## Content-frame families (one palette, prose + diagrams)
50
+
51
+ In content frames (`Md`, `Diagram`), color-code by MEANING using the built-in family
52
+ names - never hand-roll hex or mermaid `classDef`. The same six families work in both,
53
+ so a sentence and the diagram beside it read as one color language:
54
+
55
+ - **Prose:** `:blue[the shipper's world]`, `:orange[the carrier market]`, `:purple[the
56
+ driver pool]`, `:green[...]`, `:red[...]`, `:gray[...]` inside any `Md` block.
57
+ - **Diagram nodes:** tag a node with the family - `HQ:::blue`, `Carriers:::orange`,
58
+ `Drivers:::purple`. No `classDef` needed; they're injected for you.
59
+
60
+ Pick ONE family per concept and hold it everywhere it appears (the intro word, the
61
+ diagram box, the section heading). That consistency is what lets a reader link the
62
+ picture to the prose at a glance. Reserve `gray` for the neutral/background ("the platform",
63
+ "out of scope"); use the vivid families for the actors that matter.
64
+
65
+ Node text: write labels as `Head :: gloss` - marver renders the head bold on top
66
+ with the gloss lighter and smaller below, so a box scans as label-then-detail,
67
+ never a run-on. Just the ` :: ` token; no backticks or `**` needed.
68
+
49
69
  ## Verify
50
70
 
51
71
  Every color has a stable role; attention lands on the intended action; the palette
52
72
  holds across quiet, dense, error, and empty states; both themes are composed; the
53
- result is recognizably THIS product, not a generic colorful treatment.
73
+ result is recognizably THIS product, not a generic colorful treatment. In content
74
+ frames, each concept keeps ONE family across prose and diagram.
@@ -110,27 +110,34 @@ a quadrant for prioritization, a state diagram for lifecycle logic, a sequence
110
110
  for API choreography. The syntax reference is the Mermaid docs:
111
111
  https://mermaid.js.org/intro/ - pull the one page you need, apply, return.
112
112
 
113
- **Color carries meaning - and the floor is never gray-on-gray.** The marver
114
- theme already colors every node (accent-washed fills, accent borders, both
115
- modes) - a default diagram looks designed with zero effort, so never hand-set
116
- grays "to be safe" and never re-theme (init directives are stripped anyway).
117
- Where the diagram has SEMANTICS, add them with `classDef` on top:
113
+ **Two pieces of built-in sugar make a flowchart read well with zero fiddling -
114
+ use them, don't hand-roll their raw mermaid equivalents.**
115
+
116
+ *Label hierarchy (`::`).* Write a node label as `Head :: gloss` and marver
117
+ renders the **head bold** on top with the gloss on a lighter, smaller line
118
+ below - no backticks, no `**`, no `<br>`. A box should scan as label-then-
119
+ detail, so lead with the actor and let the example follow:
118
120
 
119
121
  ```
120
122
  flowchart LR
121
- A[Draft] --> B{Review?} -->|approved| C[Published]
122
- B -->|rejected| D[Archived]
123
- classDef win fill:#34C759,stroke:#248A3D,color:#fff
124
- classDef stop fill:#FF383C,stroke:#D70015,color:#fff
125
- class C win
126
- class D stop
123
+ S["Shipper :: the company that needs freight moved"]:::blue
124
+ C["Carrier :: the trucking company that hauls it"]:::orange
125
+ D["Driver :: the person behind the wheel"]:::purple
126
+ S --> C --> D
127
127
  ```
128
128
 
129
- Use the system palette (the same 12 colors the series ramp uses), a few
130
- classes at most, and never encode meaning in color ALONE - the label or shape
131
- must carry it too. Check the diagram in BOTH themes (`d`): fills flip with
132
- the theme, hand-set classDef colors do not, so pick values that hold on both
133
- grounds (the system colors do).
129
+ *Family colors (`:::name`).* Tag a node with a built-in family and it gets a
130
+ filled, on-brand color with a legible border in both themes - no `classDef`.
131
+ The SAME six names work in `Md` prose (`:blue[the shipper's world]`), so a
132
+ sentence and the diagram beside it read as one color language:
133
+ `blue orange purple green red gray`. Pick ONE family per concept and hold it
134
+ everywhere; reserve `gray` for the neutral/background actor. Never encode
135
+ meaning in color ALONE - the label carries it too.
136
+
137
+ The marver theme already accent-washes every default node (both modes), so a
138
+ plain flowchart looks designed with zero effort - never hand-set grays "to be
139
+ safe" and never re-theme (init directives are stripped anyway). Check in BOTH
140
+ themes (`d`).
134
141
 
135
142
  ## Images and mood boards
136
143