@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.
- package/CHANGELOG.md +62 -0
- package/dist/{build-p3xmXU3b.mjs → build-BrCl9hJS.mjs} +5 -3
- package/dist/cli.mjs +3 -3
- package/dist/{dev-DZi1yRhn.mjs → dev-DdeU-Jst.mjs} +57 -8
- package/dist/{init-Ck8z-HiD.mjs → init-DsCUmlCW.mjs} +1 -1
- package/dist/{manifest-DW-T52MM.mjs → manifest-C8FODq2S.mjs} +26 -1
- package/dist/{plugin-CtQqO5ZZ.mjs → plugin-BtSGAm2h.mjs} +55 -19
- package/package.json +2 -1
- package/src/client/content/diagram.tsx +46 -2
- package/src/client/content/index.tsx +6 -2
- package/src/client/content/md.ts +29 -0
- package/src/client/content/palette.ts +6 -0
- package/src/client/frame-host/bridge.js +40 -5
- package/src/client/frame-host/serialize.ts +195 -0
- package/src/client/shell/App.tsx +61 -13
- package/src/client/shell/Comments.tsx +5 -4
- package/src/client/shell/Play.tsx +24 -0
- package/src/client/shell/canvas/Canvas.tsx +66 -29
- package/src/client/shell/canvas/FrameNode.tsx +98 -8
- package/src/client/shell/canvas/frame-registry.ts +18 -0
- package/src/client/shell/canvas/snapshots.ts +233 -0
- package/src/client/shell/cursor-arrow-dark.svg +1 -0
- package/src/client/shell/cursor-arrow.svg +1 -0
- package/src/client/shell/labels.ts +10 -0
- package/src/client/shell/perf.ts +92 -0
- package/src/client/shell/store.ts +98 -6
- package/src/client/shell/styles.css +37 -7
- package/src/client/stage/main.tsx +2 -0
- package/templates/instructions/reference/color.md +22 -1
- package/templates/instructions/shape.md +23 -16
|
@@ -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
|
|
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
|
|
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) {
|
|
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) {
|
|
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
|
-
|
|
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
|
|
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
|
|
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);
|
|
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
|
-
|
|
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;
|
|
323
|
-
.sh-panel-top .
|
|
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
|
-
**
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
|