@marver-design/marver 0.2.2 → 0.2.3
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/dist/{build-CNoXE13J.mjs → build-Bqd6OEsQ.mjs} +2 -2
- package/dist/cli.mjs +3 -3
- package/dist/{dev-Blyy4jOL.mjs → dev-9-80L5i5.mjs} +2 -2
- package/dist/{init-3h9pXEzp.mjs → init-ClhCgn4v.mjs} +26 -5
- package/dist/{manifest-CHmKAAtG.mjs → manifest-mYlO_1Pj.mjs} +78 -2
- package/dist/{plugin-DiDJA9n-.mjs → plugin-YVpBNTB3.mjs} +1 -1
- package/package.json +1 -1
- package/src/client/shell/App.tsx +78 -16
- package/src/client/shell/Play.tsx +36 -0
- package/src/client/shell/canvas/Canvas.tsx +54 -7
- package/src/client/shell/canvas/FrameNode.tsx +14 -1
- package/src/client/shell/icons.tsx +2 -0
- package/src/client/shell/store.ts +115 -14
- package/src/client/shell/styles.css +86 -0
- package/src/client/shell/tidy.ts +347 -17
- package/src/client/stage/main.tsx +11 -3
- package/templates/AGENTS-embedded.md +13 -9
- package/templates/AGENTS-studio.md +13 -9
- package/templates/design-tsconfig.json +7 -2
- package/templates/instructions/boards.md +47 -1
- package/templates/instructions/discover.md +6 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { create } from 'zustand'
|
|
2
2
|
import { ROUTE } from '../const.ts'
|
|
3
|
-
import { tidy } from './tidy.ts'
|
|
3
|
+
import { tidy, parseLayout, type BoardLayout } from './tidy.ts'
|
|
4
4
|
// @ts-expect-error virtual module provided by the plugin
|
|
5
5
|
import shConfig from 'virtual:sh-config'
|
|
6
6
|
// @ts-expect-error virtual module: null in dev; a published build inlines manifest+boards
|
|
@@ -14,7 +14,7 @@ const DATA: { manifest: Manifest; boards: Record<string, unknown>; names: string
|
|
|
14
14
|
/** True on a published static canvas - no dev server, no API, no update checks. */
|
|
15
15
|
export const PUBLISHED = DATA !== null
|
|
16
16
|
|
|
17
|
-
export interface FrameEntry { id: string; file: string; kind: 'tsx' | 'html'; scene: string; title?: string; viewport?: string; theme?: string }
|
|
17
|
+
export interface FrameEntry { id: string; file: string; kind: 'tsx' | 'html'; scene: string; title?: string; viewport?: string; theme?: string; variantGroup?: string; variant?: string }
|
|
18
18
|
export interface Manifest { frames: FrameEntry[]; scenes: { name: string; frames: number }[] }
|
|
19
19
|
export interface Node {
|
|
20
20
|
key: string; frame: string; x: number; y: number; w: number; h: number
|
|
@@ -76,6 +76,18 @@ function defaultSize(frame: FrameEntry) {
|
|
|
76
76
|
return { w: vp.width, h: vp.height }
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
let resizedInGesture = false
|
|
80
|
+
|
|
81
|
+
/** sceneRows is the legacy shorthand for a plain rows layout (SPEC-024 §5). */
|
|
82
|
+
const effectiveLayout = (layout: BoardLayout | null, sceneRows: string[][] | null): BoardLayout | undefined =>
|
|
83
|
+
layout ?? (sceneRows?.length ? { rows: sceneRows.map((r) => [...r]) } : undefined)
|
|
84
|
+
|
|
85
|
+
const layoutWarn = (msg: string) => {
|
|
86
|
+
console.warn('[marver layout]', msg)
|
|
87
|
+
try { useStore.getState().toast(`layout: ${msg}`) } catch { /* store not ready during boot */ }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
|
|
79
91
|
interface State {
|
|
80
92
|
manifest: Manifest | null
|
|
81
93
|
nodes: Node[] // append-only order; NEVER sorted or reordered (iframe law G-1)
|
|
@@ -87,6 +99,9 @@ interface State {
|
|
|
87
99
|
board: string // active board name; 'all-scenes' is the auto board
|
|
88
100
|
boardAuto: boolean // auto boards gain new frames on arrival; curated boards never do
|
|
89
101
|
deviceView: string | null // board-wide device preview (viewport name), null = free-form layout
|
|
102
|
+
sceneRows: string[][] | null // LEGACY scene arrangement (SPEC-023 §2); still round-trips through save
|
|
103
|
+
layout: BoardLayout | null // lane-flow recipe (SPEC-024), parsed; wins over sceneRows when both exist
|
|
104
|
+
layoutRaw: unknown // the author's layout VERBATIM - save round-trips this, never the parse
|
|
90
105
|
baseLayout: Record<string, { x: number; y: number; w: number; h: number }> | null // snapshot taken on entering a device view; Default restores it exactly
|
|
91
106
|
panelOpen: boolean
|
|
92
107
|
scale: number
|
|
@@ -102,6 +117,7 @@ interface State {
|
|
|
102
117
|
setStatus(key: string, status: Node['status'], error?: string): void
|
|
103
118
|
removeNode(key: string): void
|
|
104
119
|
select(key: string | null, additive?: boolean): void
|
|
120
|
+
selectMany(keys: string[]): void
|
|
105
121
|
selectAll(): void
|
|
106
122
|
setInteract(key: string | null): void
|
|
107
123
|
setPlay(p: State['play']): void
|
|
@@ -170,6 +186,9 @@ export const useStore = create<State>((set, get) => {
|
|
|
170
186
|
let boardHash: string | null = null
|
|
171
187
|
let boardAuto = boardName === 'all-scenes'
|
|
172
188
|
let deviceView: string | null = null
|
|
189
|
+
let sceneRows: string[][] | null = null
|
|
190
|
+
let layout: BoardLayout | null = null
|
|
191
|
+
let layoutRaw: unknown = undefined
|
|
173
192
|
let baseLayout: State['baseLayout'] = null
|
|
174
193
|
let needTidy = false
|
|
175
194
|
// published build: boards come from the inlined data; absent = fresh (the 404 path)
|
|
@@ -224,6 +243,15 @@ export const useStore = create<State>((set, get) => {
|
|
|
224
243
|
// device view and the free-form snapshot survive reloads - independently:
|
|
225
244
|
// a scoped "exception" clears deviceView but the snapshot must keep restoring
|
|
226
245
|
if (typeof board?.deviceView === 'string' && CONFIG.viewports[board.deviceView]) deviceView = board.deviceView
|
|
246
|
+
if (Array.isArray(board?.sceneRows)) {
|
|
247
|
+
const rows = board.sceneRows
|
|
248
|
+
.map((r: unknown) => (Array.isArray(r) ? r.filter((x: unknown) => typeof x === 'string') : []))
|
|
249
|
+
.filter((r: string[]) => r.length)
|
|
250
|
+
if (rows.length) sceneRows = rows
|
|
251
|
+
}
|
|
252
|
+
layoutRaw = board?.layout
|
|
253
|
+
layout = parseLayout(board?.layout, layoutWarn)
|
|
254
|
+
if (layout && sceneRows) layoutWarn('board has layout AND sceneRows - layout wins')
|
|
227
255
|
if (board?.baseLayout && typeof board.baseLayout === 'object') baseLayout = board.baseLayout
|
|
228
256
|
}
|
|
229
257
|
// auto-managed goes both ways (friction log #15): an auto board gains new frames
|
|
@@ -236,15 +264,30 @@ export const useStore = create<State>((set, get) => {
|
|
|
236
264
|
nodes = nodes.filter((n) => !n.missing)
|
|
237
265
|
prunedAtLoad = !DATA && nodes.length !== before
|
|
238
266
|
}
|
|
239
|
-
// frames not on the board yet → auto boards only (a curated board shows exactly its list)
|
|
267
|
+
// frames not on the board yet → auto boards only (a curated board shows exactly its list).
|
|
268
|
+
// Grouped newcomers land beside their siblings (like the live JOIN); ungrouped ones
|
|
269
|
+
// go past the board's right edge (the live JOIN flows them below) - NEVER at (0,0)
|
|
270
|
+
// (the published all-scenes bug: frames created after the board file was last
|
|
271
|
+
// saved piled up at the origin)
|
|
240
272
|
if (boardAuto) {
|
|
241
273
|
const placed = new Set(nodes.map((n) => n.frame))
|
|
274
|
+
let maxX = nodes.reduce((a, n) => Math.max(a, n.x + n.w), 0)
|
|
242
275
|
for (const f of manifest.frames) {
|
|
243
276
|
if (placed.has(f.id)) continue
|
|
244
277
|
placed.add(f.id)
|
|
245
278
|
const d = defaultSize(f)
|
|
246
279
|
const vp = deviceView ? CONFIG.viewports[deviceView] : null
|
|
247
|
-
|
|
280
|
+
const w = vp?.width ?? d.w, h = vp?.height ?? d.h
|
|
281
|
+
let x: number, y: number
|
|
282
|
+
const sibling = f.variantGroup
|
|
283
|
+
? nodes.filter((n) => manifest.frames.find((m) => m.id === n.frame)?.variantGroup === f.variantGroup)
|
|
284
|
+
.reduce<Node | null>((a, n) => (!a || n.x + n.w > a.x + a.w ? n : a), null)
|
|
285
|
+
: null
|
|
286
|
+
if (sibling) { x = sibling.x + sibling.w + Math.max(140, sibling.w * 0.12); y = sibling.y }
|
|
287
|
+
else { x = nodes.length ? maxX + 96 : 0; y = 0 }
|
|
288
|
+
const node: Node = { key: nodeKey(), frame: f.id, x, y, w, h, theme: resolveTheme(f), status: 'loading' }
|
|
289
|
+
nodes.push(node)
|
|
290
|
+
maxX = Math.max(maxX, x + w)
|
|
248
291
|
}
|
|
249
292
|
}
|
|
250
293
|
// published: re-apply this visit's pins over the inlined data
|
|
@@ -256,19 +299,31 @@ export const useStore = create<State>((set, get) => {
|
|
|
256
299
|
}
|
|
257
300
|
}
|
|
258
301
|
if ((!boardHash || needTidy) && nodes.length) {
|
|
259
|
-
const
|
|
260
|
-
const placedAll = tidy(nodes.map((n) =>
|
|
302
|
+
const entryOf = (id: string) => manifest.frames.find((f) => f.id === id)
|
|
303
|
+
const placedAll = tidy(nodes.map((n) => {
|
|
304
|
+
const f = entryOf(n.frame)
|
|
305
|
+
return { key: n.key, frame: n.frame, scene: f?.scene ?? '', group: f?.variantGroup, variant: f?.variant, w: n.w, h: n.h + HEADER }
|
|
306
|
+
}), effectiveLayout(layout, sceneRows), layoutWarn)
|
|
261
307
|
for (const pl of placedAll) { const n = nodes.find((x) => x.key === pl.key)!; n.x = pl.x; n.y = pl.y }
|
|
262
308
|
}
|
|
263
309
|
// dirty matches disk by construction - except when load-time pruning changed the
|
|
264
310
|
// node set; callers see dirty:true and schedule the save that persists the prune
|
|
265
|
-
|
|
311
|
+
// surface recipe problems at load (dry-run): materialized boards otherwise
|
|
312
|
+
// never run tidy, so a broken agent-authored layout would fail silently
|
|
313
|
+
if (layout && boardHash && !needTidy && nodes.length) {
|
|
314
|
+
const entryOf2 = (id: string) => manifest.frames.find((f) => f.id === id)
|
|
315
|
+
tidy(nodes.map((n) => {
|
|
316
|
+
const f = entryOf2(n.frame)
|
|
317
|
+
return { key: n.key, frame: n.frame, scene: f?.scene ?? '', group: f?.variantGroup, variant: f?.variant, w: n.w, h: n.h + HEADER }
|
|
318
|
+
}), layout, layoutWarn)
|
|
319
|
+
}
|
|
320
|
+
return { manifest, nodes, boardHash, boardAuto, deviceView, sceneRows, layout, layoutRaw, baseLayout, selection: [], dirty: prunedAtLoad }
|
|
266
321
|
} catch { return null }
|
|
267
322
|
}
|
|
268
323
|
|
|
269
324
|
return {
|
|
270
325
|
manifest: null, nodes: [], selection: [], interact: null, viewTheme: initialViewTheme(), play: null, gesture: false,
|
|
271
|
-
board: DATA?.default ?? 'all-scenes', boardAuto: (DATA?.default ?? 'all-scenes') === 'all-scenes', deviceView: null, baseLayout: null,
|
|
326
|
+
board: DATA?.default ?? 'all-scenes', boardAuto: (DATA?.default ?? 'all-scenes') === 'all-scenes', deviceView: null, sceneRows: null, layout: null, layoutRaw: undefined, baseLayout: null,
|
|
272
327
|
panelOpen: true, scale: 1, toasts: [], boardHash: null, dirty: false,
|
|
273
328
|
|
|
274
329
|
async boot() {
|
|
@@ -290,6 +345,12 @@ export const useStore = create<State>((set, get) => {
|
|
|
290
345
|
async switchBoard(name) {
|
|
291
346
|
const mySwitch = ++switchSeq // also cancels any pending switch (incl. re-clicking the current board)
|
|
292
347
|
if (name === get().board) return
|
|
348
|
+
// a resize held across a switch: settle THIS board (retidy if it has a recipe)
|
|
349
|
+
// before the flush, so the torn mid-gesture state is never what reaches disk
|
|
350
|
+
if (resizedInGesture) {
|
|
351
|
+
resizedInGesture = false
|
|
352
|
+
if (get().layout || get().sceneRows?.length) get().runTidy()
|
|
353
|
+
}
|
|
293
354
|
// flush loop: an edit landing mid-save keeps dirty set and gets its own pass
|
|
294
355
|
let ok = true
|
|
295
356
|
for (let i = 0; i < 5 && get().dirty && ok; i++) { clearTimeout(saveTimer); ok = await get().save() }
|
|
@@ -326,7 +387,15 @@ export const useStore = create<State>((set, get) => {
|
|
|
326
387
|
if (known.has(f.id)) continue
|
|
327
388
|
const d = defaultSize(f)
|
|
328
389
|
const maxY = next.reduce((a, n) => Math.max(a, n.y + n.h), 0)
|
|
329
|
-
|
|
390
|
+
// a grouped newcomer lands BESIDE its siblings, not at the canvas bottom - the
|
|
391
|
+
// badge/caption say "these belong together"; the position must agree (SPEC-023 §3)
|
|
392
|
+
const sibs = f.variantGroup
|
|
393
|
+
? next.filter((n) => { const g = m.frames.find((x) => x.id === n.frame)?.variantGroup; return g === f.variantGroup && !n.missing })
|
|
394
|
+
: []
|
|
395
|
+
const right = sibs.length ? sibs.reduce((a, n) => (n.x > a.x ? n : a)) : null
|
|
396
|
+
const node = right
|
|
397
|
+
? { key: nodeKey(), frame: f.id, x: right.x + right.w + Math.max(140, right.w * 0.12), y: right.y, w: vp?.width ?? d.w, h: vp?.height ?? d.h, theme: resolveTheme(f), status: 'loading' as const }
|
|
398
|
+
: { key: nodeKey(), frame: f.id, x: 0, y: maxY + 96, w: vp?.width ?? d.w, h: vp?.height ?? d.h, theme: resolveTheme(f), status: 'loading' as const }
|
|
330
399
|
next.push(node)
|
|
331
400
|
// in a device view, the snapshot learns the newcomer's DEFAULT size so 0 restores it sanely
|
|
332
401
|
if (vp && nextBase) nextBase = { ...nextBase, [node.key]: { x: node.x, y: node.y, w: d.w, h: d.h } }
|
|
@@ -402,6 +471,8 @@ export const useStore = create<State>((set, get) => {
|
|
|
402
471
|
scheduleSave()
|
|
403
472
|
},
|
|
404
473
|
resizeNode(key, w, h) {
|
|
474
|
+
resizedInGesture = true
|
|
475
|
+
const inGesture = get().gesture
|
|
405
476
|
// a manual resize means the board no longer uniformly shows one device; the resized
|
|
406
477
|
// frame's snapshot entry follows the user's new size so Default will not undo it
|
|
407
478
|
set((s) => {
|
|
@@ -416,7 +487,9 @@ export const useStore = create<State>((set, get) => {
|
|
|
416
487
|
: s.baseLayout,
|
|
417
488
|
}
|
|
418
489
|
})
|
|
419
|
-
|
|
490
|
+
// mid-gesture saves would persist torn state (new sizes, pre-recipe positions);
|
|
491
|
+
// the gesture-end hook saves once, after any retidy
|
|
492
|
+
if (!inGesture) scheduleSave()
|
|
420
493
|
},
|
|
421
494
|
setDeviceView(name) {
|
|
422
495
|
const vp = name ? CONFIG.viewports[name] : null
|
|
@@ -484,12 +557,28 @@ export const useStore = create<State>((set, get) => {
|
|
|
484
557
|
selectAll() {
|
|
485
558
|
set((s) => ({ selection: s.nodes.map((n) => n.key) }))
|
|
486
559
|
},
|
|
560
|
+
// group/scene selection from sidebar rows and canvas captions (SPEC-023 feedback)
|
|
561
|
+
selectMany(keys) {
|
|
562
|
+
set((s) => {
|
|
563
|
+
const valid = keys.filter((k) => s.nodes.some((n) => n.key === k))
|
|
564
|
+
return { selection: valid, interact: s.interact && valid.includes(s.interact) ? s.interact : null }
|
|
565
|
+
})
|
|
566
|
+
},
|
|
487
567
|
// interact is a ONE-frame mode: entering it collapses any multi-selection to the
|
|
488
568
|
// interacted frame (a 4-frame selection double-clicked otherwise leaves all four
|
|
489
569
|
// painted as "interactive"). Exiting keeps the frame selected for continuity.
|
|
490
570
|
setInteract(key) { set((s) => ({ interact: key, selection: key ? [key] : s.selection })) },
|
|
491
571
|
setPlay(play) { set({ play }) },
|
|
492
|
-
setGesture(gesture) {
|
|
572
|
+
setGesture(gesture) {
|
|
573
|
+
set({ gesture })
|
|
574
|
+
// SPEC-024 §4: a board WITH a layout recipe re-applies it when a resize
|
|
575
|
+
// gesture ends - sizes changed, so lanes and gaps must recompute
|
|
576
|
+
if (!gesture && resizedInGesture) {
|
|
577
|
+
resizedInGesture = false
|
|
578
|
+
if (get().layout || get().sceneRows?.length) get().runTidy() // schedules the save
|
|
579
|
+
else scheduleSave()
|
|
580
|
+
}
|
|
581
|
+
},
|
|
493
582
|
setScale(scale) { set({ scale }) },
|
|
494
583
|
togglePanel() { set((s) => ({ panelOpen: !s.panelOpen })) },
|
|
495
584
|
// global theme = the VIEW preference: persists across boards + reloads, clears
|
|
@@ -507,9 +596,12 @@ export const useStore = create<State>((set, get) => {
|
|
|
507
596
|
scheduleSave()
|
|
508
597
|
},
|
|
509
598
|
runTidy() {
|
|
510
|
-
const { nodes, manifest } = get()
|
|
511
|
-
const
|
|
512
|
-
const placed = tidy(nodes.map((n) =>
|
|
599
|
+
const { nodes, manifest, sceneRows, layout } = get()
|
|
600
|
+
const entryOf = (id: string) => manifest?.frames.find((f) => f.id === id)
|
|
601
|
+
const placed = tidy(nodes.map((n) => {
|
|
602
|
+
const f = entryOf(n.frame)
|
|
603
|
+
return { key: n.key, frame: n.frame, scene: f?.scene ?? '', group: f?.variantGroup, variant: f?.variant, w: n.w, h: n.h + HEADER }
|
|
604
|
+
}), effectiveLayout(layout, sceneRows), layoutWarn)
|
|
513
605
|
set((s) => ({
|
|
514
606
|
nodes: s.nodes.map((n) => {
|
|
515
607
|
const p = placed.find((x) => x.key === n.key)
|
|
@@ -543,6 +635,9 @@ export const useStore = create<State>((set, get) => {
|
|
|
543
635
|
},
|
|
544
636
|
|
|
545
637
|
save() {
|
|
638
|
+
// a resize gesture in flight = torn state (new sizes, pre-recipe positions):
|
|
639
|
+
// even a PREVIOUSLY scheduled timer must defer to the gesture-end save
|
|
640
|
+
if (get().gesture && resizedInGesture) { scheduleSave(); return Promise.resolve(false) }
|
|
546
641
|
// a published canvas is read-only ON DISK (spec §12): theme pins persist to the
|
|
547
642
|
// session instead, and dirty must clear or switchBoard's save-flush loop wedges
|
|
548
643
|
if (DATA) {
|
|
@@ -551,6 +646,10 @@ export const useStore = create<State>((set, get) => {
|
|
|
551
646
|
return Promise.resolve(true)
|
|
552
647
|
}
|
|
553
648
|
const p = saveChain.then(async () => {
|
|
649
|
+
// recheck INSIDE the serialized chain: a save queued behind an in-flight PUT
|
|
650
|
+
// may only execute after a resize gesture began - the entry guard above ran
|
|
651
|
+
// too early to see it (codex probe: torn PUT #2 behind blocked PUT #1)
|
|
652
|
+
if (get().gesture && resizedInGesture) { scheduleSave(); return false }
|
|
554
653
|
const rev = editRev
|
|
555
654
|
const { nodes, boardHash, deviceView, baseLayout, board: boardName, boardAuto } = get()
|
|
556
655
|
// Missing nodes persist too - only the explicit remove button drops them (spec §7).
|
|
@@ -559,6 +658,8 @@ export const useStore = create<State>((set, get) => {
|
|
|
559
658
|
// deviceView and the free-form snapshot persist independently: a scoped
|
|
560
659
|
// exception clears the view but 0 must still restore the snapshot after reload
|
|
561
660
|
...(deviceView ? { deviceView } : {}),
|
|
661
|
+
...(get().sceneRows?.length ? { sceneRows: get().sceneRows } : {}),
|
|
662
|
+
...(get().layoutRaw !== undefined ? { layout: get().layoutRaw } : {}),
|
|
562
663
|
...(baseLayout ? { baseLayout } : {}),
|
|
563
664
|
// only PINNED themes persist - inherited values follow viewTheme at load time
|
|
564
665
|
nodes: nodes.map(({ key, frame, x, y, w, h, themeUser }) => ({ key, frame, x: Math.round(x), y: Math.round(y), w: Math.round(w), h: Math.round(h), ...(themeUser ? { themeUser } : {}) })),
|
|
@@ -525,3 +525,89 @@ body.sh-space .sh-node { pointer-events: none } /* space-drag
|
|
|
525
525
|
Inset so scroll containers can never clip it; outline follows the border radius. */
|
|
526
526
|
.sh-app :focus { outline: none }
|
|
527
527
|
body.sh-kbd .sh-app :focus-visible { outline: 2px solid var(--accent); outline-offset: -2px }
|
|
528
|
+
|
|
529
|
+
/* ---- Variant groups (SPEC-023 §4): badge left of grouped frames + group caption.
|
|
530
|
+
Both live in WORLD space (scale with the canvas, per Nic's ask) with a screen-space
|
|
531
|
+
minimum via --sh-inv, the resize-handle trick - legible at overview zoom, proportionate
|
|
532
|
+
up close. Colors are on-canvas ink (the dot-grid's world), not chrome glass. */
|
|
533
|
+
/* gap to the frame is ~12px screen at working zooms, capped at 40 world px far out:
|
|
534
|
+
a pure world gap reads ever wider zoomed in, an uncapped screen gap outgrows the
|
|
535
|
+
gutter zoomed out */
|
|
536
|
+
.sh-vbadge { position: absolute; right: 100%; top: 0;
|
|
537
|
+
/* margin caps at 40 WORLD px: below ~30% zoom an uncapped screen-constant margin
|
|
538
|
+
alone would outgrow the gutter and push the badge onto the left neighbor */
|
|
539
|
+
margin-right: clamp(6px, calc(12px * var(--sh-inv, 1)), 40px);
|
|
540
|
+
display: flex; flex-direction: column; align-items: flex-end; gap: 2px;
|
|
541
|
+
text-align: right; user-select: none; -webkit-user-select: none;
|
|
542
|
+
font-family: -apple-system, system-ui, sans-serif }
|
|
543
|
+
.sh-vbadge b { font-size: min(clamp(40px, calc(20px * var(--sh-inv, 1)), 120px), calc(40px * var(--sh-inv, 1))); line-height: 1; font-weight: 750;
|
|
544
|
+
/* world cap 120px: past ~17% zoom the letter stops growing (in world terms) and
|
|
545
|
+
shrinks with the canvas instead of bleeding across the gutter onto the neighbor */
|
|
546
|
+
letter-spacing: -0.02em; color: rgba(24, 24, 27, .55);
|
|
547
|
+
/* the letter OUTLIVES the name (drive feedback 2026-08-12): the name leaves at
|
|
548
|
+
~30%, the letter stays useful until ~10-16% - the world cap keeps it inside the
|
|
549
|
+
gutter down there - and then hands identity to the group caption */
|
|
550
|
+
opacity: clamp(0, calc((var(--sh-s, 1) - 0.1) * 15), 1) }
|
|
551
|
+
.sh-vbadge span { font-size: min(max(15px, calc(10px * var(--sh-inv, 1))), calc(15px * var(--sh-inv, 1))); font-weight: 550;
|
|
552
|
+
color: rgba(24, 24, 27, .38); max-width: 96px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
553
|
+
/* the letter is the identity at overview zoom; the name earns pixels only close up */
|
|
554
|
+
opacity: clamp(0, calc((var(--sh-s, 1) - 0.3) * 6), 1) }
|
|
555
|
+
.sh-app.dark .sh-vbadge b { color: rgba(245, 245, 247, .6) }
|
|
556
|
+
.sh-app.dark .sh-vbadge span { color: rgba(245, 245, 247, .4) }
|
|
557
|
+
|
|
558
|
+
.sh-gcaption { position: absolute; top: 0; left: 0; user-select: none; -webkit-user-select: none;
|
|
559
|
+
/* screen-clamped both ways: 12px floor at overview, 18px ceiling zoomed in - world
|
|
560
|
+
text that keeps growing past 100% zoom dwarfs the fixed-size floating menu above */
|
|
561
|
+
font: 600 clamp(calc(12px * var(--sh-inv, 1)), 17px, calc(18px * var(--sh-inv, 1)))/1.2 -apple-system, system-ui, sans-serif;
|
|
562
|
+
letter-spacing: .01em; color: rgba(24, 24, 27, .45); white-space: nowrap }
|
|
563
|
+
.sh-app.dark .sh-gcaption { color: rgba(245, 245, 247, .45) }
|
|
564
|
+
|
|
565
|
+
/* sidebar variant rows: group header carries the experimentation mark; each variant
|
|
566
|
+
row carries its letter CHIP (accent-filled when selected) + name (SPEC-023 §5) */
|
|
567
|
+
.sub.vgroup { display: flex; align-items: center; gap: 8px }
|
|
568
|
+
.sub.vgroup .glabel { overflow: hidden; text-overflow: ellipsis; white-space: nowrap }
|
|
569
|
+
.sub.vgroup .gicon { margin-left: auto; flex: none; color: var(--glass-ink-3); transition: color .15s }
|
|
570
|
+
.sub.vgroup:hover .gicon { color: var(--glass-ink) }
|
|
571
|
+
.sub.vgroup.on .gicon { color: var(--accent) }
|
|
572
|
+
.sub .chip { width: 20px; height: 20px; flex: none; display: inline-flex; align-items: center; justify-content: center;
|
|
573
|
+
border: 1px solid var(--glass-brd); border-radius: 7px; background: none; cursor: pointer;
|
|
574
|
+
font: 700 10px -apple-system, system-ui, sans-serif; color: var(--glass-ink-3);
|
|
575
|
+
/* optical centering: trim the text box to CAP height so flex centers the glyph
|
|
576
|
+
itself, not the em box (whose descender space floats caps high) */
|
|
577
|
+
line-height: 1; letter-spacing: 0; text-box: trim-both cap alphabetic;
|
|
578
|
+
transition: color .15s, background .15s, border-color .15s }
|
|
579
|
+
.sub .chip:hover { color: var(--glass-ink); background: var(--glass-hover) }
|
|
580
|
+
.sub .chip.on { color: #fff; background: var(--accent); border-color: var(--accent) }
|
|
581
|
+
|
|
582
|
+
/* play-mode variant chips (SPEC-023 §6) */
|
|
583
|
+
.sh-play-nav .vchip { min-width: 24px; font-weight: 700; font-size: 11px }
|
|
584
|
+
.sh-play-nav .vchip.on { color: #fff; background: var(--accent); border-radius: 7px }
|
|
585
|
+
|
|
586
|
+
.sh-play-nav .vname { font-size: 11px; font-weight: 600; color: var(--glass-ink-2); max-width: 120px;
|
|
587
|
+
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-transform: capitalize; padding: 0 2px }
|
|
588
|
+
|
|
589
|
+
/* variant chrome goes LIVE with selection (drive feedback 2026-08-12): accent when the
|
|
590
|
+
frame is selected, the interact purple when it is being interacted with - the badge,
|
|
591
|
+
name, caption, and frame title all agree with the frame's state */
|
|
592
|
+
.sh-vbadge { cursor: pointer }
|
|
593
|
+
/* children stay click-transparent: rzpp's pan exclusion and the select handler both
|
|
594
|
+
need the BADGE DIV to be the event target (same pattern as button svg) */
|
|
595
|
+
.sh-vbadge b, .sh-vbadge span { pointer-events: none }
|
|
596
|
+
.sh-node.sel .sh-vbadge b, .sh-node.sel .sh-vbadge span { color: var(--accent) }
|
|
597
|
+
.sh-node.interact .sh-vbadge b, .sh-node.interact .sh-vbadge span { color: var(--interact) }
|
|
598
|
+
/* the frame header sits INSIDE the selection border - tinting its title too would
|
|
599
|
+
double-signal (drive feedback 2026-08-12); the badge/caption/sidebar carry state */
|
|
600
|
+
.sh-gcaption { cursor: pointer }
|
|
601
|
+
.sh-gcaption.on { color: var(--accent) }
|
|
602
|
+
.sh-gcaption:hover { color: var(--glass-ink-2) }
|
|
603
|
+
.sh-gcaption.on:hover { color: var(--accent) }
|
|
604
|
+
|
|
605
|
+
/* per-variant sidebar rows under the group header */
|
|
606
|
+
/* variant rows read one level QUIETER than frame rows: shorter, smaller, lighter -
|
|
607
|
+
the scannable hierarchy is scene > frame/surface > variants */
|
|
608
|
+
.sub.vrow { display: flex; align-items: center; gap: 7px; padding-left: 31px; height: 24px; font-size: 12px }
|
|
609
|
+
.sub.vrow .chip { width: 16px; height: 16px; border-radius: 5px; font-size: 8.5px }
|
|
610
|
+
.sub.vrow .nm { overflow: hidden; text-overflow: ellipsis; white-space: nowrap }
|
|
611
|
+
.sub.vgroup { cursor: pointer }
|
|
612
|
+
.sub.vgroup.held { background: color-mix(in srgb, var(--glass-hover) 55%, transparent) }
|
|
613
|
+
.sub.vgroup.held .gicon { color: var(--glass-ink-2) }
|