@marver-design/marver 0.2.1 → 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/README.md +1 -0
- package/dist/{build-BYZDMiIS.mjs → build-Bqd6OEsQ.mjs} +2 -2
- package/dist/cli.mjs +3 -3
- package/dist/{dev-Dt9D4O7Z.mjs → dev-9-80L5i5.mjs} +2 -2
- package/dist/init-ClhCgn4v.mjs +358 -0
- package/dist/{manifest-CHmKAAtG.mjs → manifest-mYlO_1Pj.mjs} +78 -2
- package/dist/{plugin-lVQoABEx.mjs → plugin-YVpBNTB3.mjs} +103 -9
- package/package.json +1 -1
- package/src/client/shell/App.tsx +123 -19
- 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 +119 -15
- package/src/client/shell/styles.css +107 -2
- package/src/client/shell/tidy.ts +347 -17
- package/src/client/stage/main.tsx +11 -3
- package/templates/AGENTS-embedded.md +38 -33
- package/templates/AGENTS-studio.md +38 -33
- package/templates/design-tsconfig.json +7 -2
- package/templates/instructions/boards.md +84 -0
- package/templates/instructions/brand.md +60 -0
- package/templates/instructions/components.md +52 -0
- package/templates/instructions/configure.md +44 -0
- package/templates/instructions/craft.md +90 -0
- package/templates/instructions/discover.md +58 -0
- package/templates/instructions/reference/color.md +53 -0
- package/templates/instructions/reference/concepts.md +68 -0
- package/templates/instructions/reference/copy.md +57 -0
- package/templates/instructions/reference/critique.md +53 -0
- package/templates/instructions/reference/delight.md +35 -0
- package/templates/instructions/reference/layout.md +51 -0
- package/templates/instructions/reference/motion.md +66 -0
- package/templates/instructions/reference/operate.md +38 -0
- package/templates/instructions/reference/slop.md +76 -0
- package/templates/instructions/reference/states.md +48 -0
- package/templates/instructions/reference/tune.md +61 -0
- package/templates/instructions/reference/typography.md +45 -0
- package/templates/instructions/review.md +51 -0
- package/templates/instructions/wireframe.md +49 -0
- package/dist/init-CHUZTYG7.mjs +0 -224
|
@@ -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
|
|
@@ -11,7 +11,10 @@ import shData from 'virtual:sh-data'
|
|
|
11
11
|
* is where `/` opens - never a synthesized aggregate of a filtered build. */
|
|
12
12
|
const DATA: { manifest: Manifest; boards: Record<string, unknown>; names: string[]; default: string } | null = shData
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
/** True on a published static canvas - no dev server, no API, no update checks. */
|
|
15
|
+
export const PUBLISHED = DATA !== null
|
|
16
|
+
|
|
17
|
+
export interface FrameEntry { id: string; file: string; kind: 'tsx' | 'html'; scene: string; title?: string; viewport?: string; theme?: string; variantGroup?: string; variant?: string }
|
|
15
18
|
export interface Manifest { frames: FrameEntry[]; scenes: { name: string; frames: number }[] }
|
|
16
19
|
export interface Node {
|
|
17
20
|
key: string; frame: string; x: number; y: number; w: number; h: number
|
|
@@ -27,7 +30,7 @@ export interface Node {
|
|
|
27
30
|
}
|
|
28
31
|
export interface Toast { id: number; text: string }
|
|
29
32
|
|
|
30
|
-
export const CONFIG: { viewports: Record<string, { width: number; height: number }>; themes: string[]; zoomSpeed?: number; noTheme: boolean } = shConfig
|
|
33
|
+
export const CONFIG: { viewports: Record<string, { width: number; height: number }>; themes: string[]; zoomSpeed?: number; noTheme: boolean; setup?: boolean } = shConfig
|
|
31
34
|
|
|
32
35
|
export const cap = (s: string) => (s ? s[0].toUpperCase() + s.slice(1) : s)
|
|
33
36
|
|
|
@@ -73,6 +76,18 @@ function defaultSize(frame: FrameEntry) {
|
|
|
73
76
|
return { w: vp.width, h: vp.height }
|
|
74
77
|
}
|
|
75
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
|
+
|
|
76
91
|
interface State {
|
|
77
92
|
manifest: Manifest | null
|
|
78
93
|
nodes: Node[] // append-only order; NEVER sorted or reordered (iframe law G-1)
|
|
@@ -84,6 +99,9 @@ interface State {
|
|
|
84
99
|
board: string // active board name; 'all-scenes' is the auto board
|
|
85
100
|
boardAuto: boolean // auto boards gain new frames on arrival; curated boards never do
|
|
86
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
|
|
87
105
|
baseLayout: Record<string, { x: number; y: number; w: number; h: number }> | null // snapshot taken on entering a device view; Default restores it exactly
|
|
88
106
|
panelOpen: boolean
|
|
89
107
|
scale: number
|
|
@@ -99,6 +117,7 @@ interface State {
|
|
|
99
117
|
setStatus(key: string, status: Node['status'], error?: string): void
|
|
100
118
|
removeNode(key: string): void
|
|
101
119
|
select(key: string | null, additive?: boolean): void
|
|
120
|
+
selectMany(keys: string[]): void
|
|
102
121
|
selectAll(): void
|
|
103
122
|
setInteract(key: string | null): void
|
|
104
123
|
setPlay(p: State['play']): void
|
|
@@ -167,6 +186,9 @@ export const useStore = create<State>((set, get) => {
|
|
|
167
186
|
let boardHash: string | null = null
|
|
168
187
|
let boardAuto = boardName === 'all-scenes'
|
|
169
188
|
let deviceView: string | null = null
|
|
189
|
+
let sceneRows: string[][] | null = null
|
|
190
|
+
let layout: BoardLayout | null = null
|
|
191
|
+
let layoutRaw: unknown = undefined
|
|
170
192
|
let baseLayout: State['baseLayout'] = null
|
|
171
193
|
let needTidy = false
|
|
172
194
|
// published build: boards come from the inlined data; absent = fresh (the 404 path)
|
|
@@ -221,6 +243,15 @@ export const useStore = create<State>((set, get) => {
|
|
|
221
243
|
// device view and the free-form snapshot survive reloads - independently:
|
|
222
244
|
// a scoped "exception" clears deviceView but the snapshot must keep restoring
|
|
223
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')
|
|
224
255
|
if (board?.baseLayout && typeof board.baseLayout === 'object') baseLayout = board.baseLayout
|
|
225
256
|
}
|
|
226
257
|
// auto-managed goes both ways (friction log #15): an auto board gains new frames
|
|
@@ -233,15 +264,30 @@ export const useStore = create<State>((set, get) => {
|
|
|
233
264
|
nodes = nodes.filter((n) => !n.missing)
|
|
234
265
|
prunedAtLoad = !DATA && nodes.length !== before
|
|
235
266
|
}
|
|
236
|
-
// 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)
|
|
237
272
|
if (boardAuto) {
|
|
238
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)
|
|
239
275
|
for (const f of manifest.frames) {
|
|
240
276
|
if (placed.has(f.id)) continue
|
|
241
277
|
placed.add(f.id)
|
|
242
278
|
const d = defaultSize(f)
|
|
243
279
|
const vp = deviceView ? CONFIG.viewports[deviceView] : null
|
|
244
|
-
|
|
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)
|
|
245
291
|
}
|
|
246
292
|
}
|
|
247
293
|
// published: re-apply this visit's pins over the inlined data
|
|
@@ -253,19 +299,31 @@ export const useStore = create<State>((set, get) => {
|
|
|
253
299
|
}
|
|
254
300
|
}
|
|
255
301
|
if ((!boardHash || needTidy) && nodes.length) {
|
|
256
|
-
const
|
|
257
|
-
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)
|
|
258
307
|
for (const pl of placedAll) { const n = nodes.find((x) => x.key === pl.key)!; n.x = pl.x; n.y = pl.y }
|
|
259
308
|
}
|
|
260
309
|
// dirty matches disk by construction - except when load-time pruning changed the
|
|
261
310
|
// node set; callers see dirty:true and schedule the save that persists the prune
|
|
262
|
-
|
|
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 }
|
|
263
321
|
} catch { return null }
|
|
264
322
|
}
|
|
265
323
|
|
|
266
324
|
return {
|
|
267
325
|
manifest: null, nodes: [], selection: [], interact: null, viewTheme: initialViewTheme(), play: null, gesture: false,
|
|
268
|
-
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,
|
|
269
327
|
panelOpen: true, scale: 1, toasts: [], boardHash: null, dirty: false,
|
|
270
328
|
|
|
271
329
|
async boot() {
|
|
@@ -287,6 +345,12 @@ export const useStore = create<State>((set, get) => {
|
|
|
287
345
|
async switchBoard(name) {
|
|
288
346
|
const mySwitch = ++switchSeq // also cancels any pending switch (incl. re-clicking the current board)
|
|
289
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
|
+
}
|
|
290
354
|
// flush loop: an edit landing mid-save keeps dirty set and gets its own pass
|
|
291
355
|
let ok = true
|
|
292
356
|
for (let i = 0; i < 5 && get().dirty && ok; i++) { clearTimeout(saveTimer); ok = await get().save() }
|
|
@@ -323,7 +387,15 @@ export const useStore = create<State>((set, get) => {
|
|
|
323
387
|
if (known.has(f.id)) continue
|
|
324
388
|
const d = defaultSize(f)
|
|
325
389
|
const maxY = next.reduce((a, n) => Math.max(a, n.y + n.h), 0)
|
|
326
|
-
|
|
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 }
|
|
327
399
|
next.push(node)
|
|
328
400
|
// in a device view, the snapshot learns the newcomer's DEFAULT size so 0 restores it sanely
|
|
329
401
|
if (vp && nextBase) nextBase = { ...nextBase, [node.key]: { x: node.x, y: node.y, w: d.w, h: d.h } }
|
|
@@ -399,6 +471,8 @@ export const useStore = create<State>((set, get) => {
|
|
|
399
471
|
scheduleSave()
|
|
400
472
|
},
|
|
401
473
|
resizeNode(key, w, h) {
|
|
474
|
+
resizedInGesture = true
|
|
475
|
+
const inGesture = get().gesture
|
|
402
476
|
// a manual resize means the board no longer uniformly shows one device; the resized
|
|
403
477
|
// frame's snapshot entry follows the user's new size so Default will not undo it
|
|
404
478
|
set((s) => {
|
|
@@ -413,7 +487,9 @@ export const useStore = create<State>((set, get) => {
|
|
|
413
487
|
: s.baseLayout,
|
|
414
488
|
}
|
|
415
489
|
})
|
|
416
|
-
|
|
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()
|
|
417
493
|
},
|
|
418
494
|
setDeviceView(name) {
|
|
419
495
|
const vp = name ? CONFIG.viewports[name] : null
|
|
@@ -481,12 +557,28 @@ export const useStore = create<State>((set, get) => {
|
|
|
481
557
|
selectAll() {
|
|
482
558
|
set((s) => ({ selection: s.nodes.map((n) => n.key) }))
|
|
483
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
|
+
},
|
|
484
567
|
// interact is a ONE-frame mode: entering it collapses any multi-selection to the
|
|
485
568
|
// interacted frame (a 4-frame selection double-clicked otherwise leaves all four
|
|
486
569
|
// painted as "interactive"). Exiting keeps the frame selected for continuity.
|
|
487
570
|
setInteract(key) { set((s) => ({ interact: key, selection: key ? [key] : s.selection })) },
|
|
488
571
|
setPlay(play) { set({ play }) },
|
|
489
|
-
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
|
+
},
|
|
490
582
|
setScale(scale) { set({ scale }) },
|
|
491
583
|
togglePanel() { set((s) => ({ panelOpen: !s.panelOpen })) },
|
|
492
584
|
// global theme = the VIEW preference: persists across boards + reloads, clears
|
|
@@ -504,9 +596,12 @@ export const useStore = create<State>((set, get) => {
|
|
|
504
596
|
scheduleSave()
|
|
505
597
|
},
|
|
506
598
|
runTidy() {
|
|
507
|
-
const { nodes, manifest } = get()
|
|
508
|
-
const
|
|
509
|
-
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)
|
|
510
605
|
set((s) => ({
|
|
511
606
|
nodes: s.nodes.map((n) => {
|
|
512
607
|
const p = placed.find((x) => x.key === n.key)
|
|
@@ -540,6 +635,9 @@ export const useStore = create<State>((set, get) => {
|
|
|
540
635
|
},
|
|
541
636
|
|
|
542
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) }
|
|
543
641
|
// a published canvas is read-only ON DISK (spec §12): theme pins persist to the
|
|
544
642
|
// session instead, and dirty must clear or switchBoard's save-flush loop wedges
|
|
545
643
|
if (DATA) {
|
|
@@ -548,6 +646,10 @@ export const useStore = create<State>((set, get) => {
|
|
|
548
646
|
return Promise.resolve(true)
|
|
549
647
|
}
|
|
550
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 }
|
|
551
653
|
const rev = editRev
|
|
552
654
|
const { nodes, boardHash, deviceView, baseLayout, board: boardName, boardAuto } = get()
|
|
553
655
|
// Missing nodes persist too - only the explicit remove button drops them (spec §7).
|
|
@@ -556,6 +658,8 @@ export const useStore = create<State>((set, get) => {
|
|
|
556
658
|
// deviceView and the free-form snapshot persist independently: a scoped
|
|
557
659
|
// exception clears the view but 0 must still restore the snapshot after reload
|
|
558
660
|
...(deviceView ? { deviceView } : {}),
|
|
661
|
+
...(get().sceneRows?.length ? { sceneRows: get().sceneRows } : {}),
|
|
662
|
+
...(get().layoutRaw !== undefined ? { layout: get().layoutRaw } : {}),
|
|
559
663
|
...(baseLayout ? { baseLayout } : {}),
|
|
560
664
|
// only PINNED themes persist - inherited values follow viewTheme at load time
|
|
561
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 } : {}) })),
|
|
@@ -194,7 +194,7 @@ body.sh-space .sh-node { pointer-events: none } /* space-drag
|
|
|
194
194
|
.sh-handle.se { right: -5px; bottom: -5px; width: 11px; height: 11px; cursor: nwse-resize }
|
|
195
195
|
|
|
196
196
|
/* glass surfaces share one recipe; chrome text is never selectable (shift-click = multi-select) */
|
|
197
|
-
.sh-ctx, .sh-panel, .sh-fab, .sh-pill, .sh-pill-fab, .sh-menu, .sh-banner, .sh-toast {
|
|
197
|
+
.sh-ctx, .sh-panel, .sh-fab, .sh-pill, .sh-pill-fab, .sh-menu, .sh-banner, .sh-toast, .sh-update {
|
|
198
198
|
background: var(--glass); backdrop-filter: var(--blur); -webkit-backdrop-filter: var(--blur);
|
|
199
199
|
border: 1px solid var(--glass-brd); box-shadow: var(--shadow-glass); position: relative;
|
|
200
200
|
user-select: none; -webkit-user-select: none }
|
|
@@ -202,7 +202,7 @@ body.sh-space .sh-node { pointer-events: none } /* space-drag
|
|
|
202
202
|
/* edge light: a 1px gradient ring over the border, so glass reads as glass even
|
|
203
203
|
with nothing behind it to blur (masked-border technique) */
|
|
204
204
|
.sh-ctx::after, .sh-panel::after, .sh-fab::after, .sh-pill::after, .sh-pill-fab::after,
|
|
205
|
-
.sh-menu::after, .sh-banner::after, .sh-toast::after, .sh-node::after {
|
|
205
|
+
.sh-menu::after, .sh-banner::after, .sh-toast::after, .sh-update::after, .sh-node::after {
|
|
206
206
|
content: ''; position: absolute; inset: -1px; padding: 1px; border-radius: inherit;
|
|
207
207
|
background: var(--edge-light); pointer-events: none;
|
|
208
208
|
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
|
|
@@ -404,6 +404,25 @@ body.sh-space .sh-node { pointer-events: none } /* space-drag
|
|
|
404
404
|
.sh-banner { position: absolute; bottom: var(--edge); right: var(--edge); z-index: 10; font: 500 12px -apple-system, system-ui, sans-serif;
|
|
405
405
|
color: var(--glass-warn); border-radius: 999px; padding: 8px 14px }
|
|
406
406
|
|
|
407
|
+
/* update pill: bottom-center, the quietest possible member of the pill family.
|
|
408
|
+
The command IS the button - one click copies it for the terminal or the agent. */
|
|
409
|
+
.sh-update { position: absolute; left: 50%; bottom: var(--edge); transform: translateX(-50%); z-index: 10;
|
|
410
|
+
display: flex; align-items: center; gap: 10px; height: 40px; padding: 0 7px 0 16px; border-radius: 999px;
|
|
411
|
+
max-width: min(92vw, 620px); white-space: nowrap;
|
|
412
|
+
font: 500 12.5px -apple-system, system-ui, sans-serif; color: var(--glass-ink-2);
|
|
413
|
+
animation: sh-update-in .35s cubic-bezier(.2, .9, .3, 1.2) }
|
|
414
|
+
@keyframes sh-update-in { from { opacity: 0; transform: translate(-50%, 8px) scale(.96) } }
|
|
415
|
+
.sh-update b { font-weight: 650; color: var(--glass-ink) }
|
|
416
|
+
.sh-update .cmd { border: 1px solid var(--glass-brd); background: var(--glass-hover); border-radius: 999px;
|
|
417
|
+
padding: 4px 11px; cursor: pointer; min-width: 0; overflow: hidden; transition: color .15s }
|
|
418
|
+
.sh-update .cmd code { font: 500 11.5px ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--glass-ink-2);
|
|
419
|
+
display: block; overflow: hidden; text-overflow: ellipsis }
|
|
420
|
+
.sh-update .cmd:hover code { color: var(--glass-ink) }
|
|
421
|
+
.sh-update .x { display: inline-flex; align-items: center; justify-content: center; width: 26px; height: 26px;
|
|
422
|
+
border: 0; background: none; border-radius: 999px; color: var(--glass-ink-3); cursor: pointer;
|
|
423
|
+
transition: color .15s, background .15s }
|
|
424
|
+
.sh-update .x:hover { background: var(--glass-hover); color: var(--glass-ink) }
|
|
425
|
+
|
|
407
426
|
.sh-toasts { position: absolute; left: var(--edge); bottom: var(--edge); z-index: 12; display: flex; flex-direction: column; gap: 6px }
|
|
408
427
|
.sh-toast { display: flex; align-items: center; gap: 6px; font: 500 12px -apple-system, system-ui, sans-serif;
|
|
409
428
|
color: var(--glass-ink-2); border-radius: 999px; padding: 7px 13px }
|
|
@@ -506,3 +525,89 @@ body.sh-space .sh-node { pointer-events: none } /* space-drag
|
|
|
506
525
|
Inset so scroll containers can never clip it; outline follows the border radius. */
|
|
507
526
|
.sh-app :focus { outline: none }
|
|
508
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) }
|