@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@marver-design/marver",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.3",
|
|
4
4
|
"description": "The agent-native design canvas. A design/ folder, one command, a canvas of live frames built from your repo's real components. The tool ships no AI - your coding agent is the designer.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"private": false,
|
package/src/client/shell/App.tsx
CHANGED
|
@@ -1,24 +1,25 @@
|
|
|
1
1
|
import { Component, useEffect, useRef, useState, type ReactNode } from 'react'
|
|
2
2
|
import { createPortal } from 'react-dom'
|
|
3
|
-
import { useStore, CONFIG, boardLabel, cap, fetchBoardNames } from './store.ts'
|
|
3
|
+
import { useStore, CONFIG, PUBLISHED, boardLabel, cap, fetchBoardNames } from './store.ts'
|
|
4
4
|
import { Tip } from './Tip.tsx'
|
|
5
|
-
import { ROUTE } from '../const.ts'
|
|
5
|
+
import { PKG, ROUTE } from '../const.ts'
|
|
6
6
|
import { animateLayout, Canvas, canvasCtl } from './canvas/Canvas.tsx'
|
|
7
7
|
import { enterPlay, playCtl, PlayOverlay } from './Play.tsx'
|
|
8
8
|
import { bootHash, parseHash, writeHash } from './hash.ts'
|
|
9
|
-
import { CardsIcon, CardsThreeIcon, CaretIcon, CheckIcon, DevicesIcon, GridIcon, MoonIcon, PanelFilledIcon, PanelHollowIcon, ParallelogramDuoIcon, PlayIcon, PlusIcon, SignpostIcon, SunIcon, deviceIcon } from './icons.tsx'
|
|
9
|
+
import { CardsIcon, CardsThreeIcon, CaretIcon, CheckIcon, DevicesIcon, GridIcon, MoonIcon, PanelFilledIcon, PanelHollowIcon, ParallelogramDuoIcon, PlayIcon, PlusIcon, SignpostIcon, SunIcon, VariantsIcon, XIcon, deviceIcon } from './icons.tsx'
|
|
10
10
|
|
|
11
11
|
let booted = false // survives Fast Refresh; see the boot effect
|
|
12
12
|
|
|
13
13
|
/** One collapsible scene group in the sidebar. `held` marks a scene that contains a
|
|
14
14
|
* selected frame - a quiet secondary wash so ancestry survives collapsing the group. */
|
|
15
|
-
function SceneGroup({ name, count, held, children }: { name: string; count: number; held: boolean; children: ReactNode }) {
|
|
15
|
+
function SceneGroup({ name, count, held, onPick, children }: { name: string; count: number; held: boolean; onPick?: () => void; children: ReactNode }) {
|
|
16
16
|
const [open, setOpen] = useState(true)
|
|
17
17
|
return (
|
|
18
18
|
<div>
|
|
19
19
|
<button className={`it${held ? ' held' : ''}`} onClick={() => setOpen(!open)}>
|
|
20
20
|
<CaretIcon size={11} className="tw" style={{ transform: open ? undefined : 'rotate(-90deg)' }} />
|
|
21
|
-
|
|
21
|
+
{/* the NAME selects every frame in the scene; the caret/row still collapses */}
|
|
22
|
+
<span onClick={(e) => { if (!onPick) return; e.stopPropagation(); onPick() }}>{cap(name) || '(root)'}</span>
|
|
22
23
|
<small>{count}</small>
|
|
23
24
|
</button>
|
|
24
25
|
{open && children}
|
|
@@ -151,7 +152,15 @@ function SelectionBar() {
|
|
|
151
152
|
// controls for a selected frame must stay reachable when its top edge is panned
|
|
152
153
|
// off-screen, and must never drift off the sides (friction log #23)
|
|
153
154
|
const centerX = `calc(var(--sh-tx, 0px) + var(--sh-s, 1) * ${(bx0 + bx1) / 2}px)`
|
|
154
|
-
|
|
155
|
+
// a grouped frame carries a caption above it - clear it EXACTLY, in screen terms:
|
|
156
|
+
// frame top - the caption offset (8px screen, world-capped) - the caption's height
|
|
157
|
+
// (screen-clamped 12..18px font) - the bar. Gate on the TOP edge of the selection,
|
|
158
|
+
// not the last-selected frame: a mixed selection whose topmost frames are variants
|
|
159
|
+
// still has a caption to clear (codex final review P2).
|
|
160
|
+
const capAtTop = selNodes.some((n) => n.y === by0 && useStore.getState().frameFor(n)?.variantGroup)
|
|
161
|
+
const rawTop = capAtTop
|
|
162
|
+
? `calc(var(--sh-ty, 0px) + var(--sh-s, 1) * ${by0}px - clamp(4px * var(--sh-s, 1), 8px, 40px * var(--sh-s, 1)) - (clamp(12px, 17px * var(--sh-s, 1), 18px) * 1.4) - 44px)`
|
|
163
|
+
: `calc(var(--sh-ty, 0px) + var(--sh-s, 1) * ${by0}px - 52px)`
|
|
155
164
|
return (
|
|
156
165
|
<div
|
|
157
166
|
className="sh-ctx"
|
|
@@ -251,6 +260,45 @@ function DeviceMenu() {
|
|
|
251
260
|
)
|
|
252
261
|
}
|
|
253
262
|
|
|
263
|
+
/** Update pill (dev only): the daily registry check surfaces here - same glass, same
|
|
264
|
+
* pill, bottom-center. Click the command to copy it; × dismisses THIS version for
|
|
265
|
+
* good (localStorage), so the pill returns only when the next release lands. */
|
|
266
|
+
function UpdatePill() {
|
|
267
|
+
const [latest, setLatest] = useState<string | null>(null)
|
|
268
|
+
const play = useStore((s) => s.play)
|
|
269
|
+
useEffect(() => {
|
|
270
|
+
if (PUBLISHED) return // a shared canvas never nags its viewers
|
|
271
|
+
fetch(`${ROUTE}/api/update`)
|
|
272
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
273
|
+
.then((u) => {
|
|
274
|
+
if (u?.latest && localStorage.getItem('mv-update-seen') !== u.latest) setLatest(u.latest)
|
|
275
|
+
})
|
|
276
|
+
.catch(() => { /* dev server gone or endpoint absent - stay quiet */ })
|
|
277
|
+
}, [])
|
|
278
|
+
if (!latest || play) return null
|
|
279
|
+
// init rides along so managed files (AGENTS.md, instructions/) refresh with the code
|
|
280
|
+
const cmd = `npm i -D ${PKG}@latest && npx marver init`
|
|
281
|
+
const dismiss = () => {
|
|
282
|
+
try { localStorage.setItem('mv-update-seen', latest) } catch { /* storage unavailable */ }
|
|
283
|
+
setLatest(null)
|
|
284
|
+
}
|
|
285
|
+
return (
|
|
286
|
+
<div className="sh-update">
|
|
287
|
+
<span><b>{latest}</b> is out</span>
|
|
288
|
+
<Tip side="top" label="Copy, then paste to your terminal or your agent">
|
|
289
|
+
<button className="cmd" onClick={() => {
|
|
290
|
+
const t = useStore.getState().toast
|
|
291
|
+
navigator.clipboard?.writeText(cmd).then(() => t('update command copied'), () => t('copy blocked - select it manually'))
|
|
292
|
+
?? t('copy unavailable - select it manually')
|
|
293
|
+
}}><code>{cmd}</code></button>
|
|
294
|
+
</Tip>
|
|
295
|
+
<Tip side="top" label="Dismiss this version">
|
|
296
|
+
<button className="x" onClick={dismiss}><XIcon size={13} /></button>
|
|
297
|
+
</Tip>
|
|
298
|
+
</div>
|
|
299
|
+
)
|
|
300
|
+
}
|
|
301
|
+
|
|
254
302
|
const ZOOMS = [2, 1.5, 1, 0.5, 0.25, 0.1]
|
|
255
303
|
|
|
256
304
|
/** Zoom preset dropdown on the percentage readout. */
|
|
@@ -594,18 +642,71 @@ export function App() {
|
|
|
594
642
|
<div className="hd" style={{ marginTop: 10 }}>Scenes</div>
|
|
595
643
|
{scenes.map((sc) => (
|
|
596
644
|
<SceneGroup key={sc.name} name={sc.name} count={sc.frames}
|
|
597
|
-
held={frames.some((f) => f.scene === sc.name && selFrames.has(f.id))}
|
|
598
|
-
{
|
|
599
|
-
const
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
)
|
|
608
|
-
|
|
645
|
+
held={frames.some((f) => f.scene === sc.name && selFrames.has(f.id))}
|
|
646
|
+
onPick={() => {
|
|
647
|
+
const keys = nodes.filter((n) => frames.some((f) => f.scene === sc.name && f.id === n.frame) && !n.missing).map((n) => n.key)
|
|
648
|
+
if (!keys.length) return
|
|
649
|
+
useStore.getState().selectMany(keys)
|
|
650
|
+
canvasCtl.fitNodes(keys)
|
|
651
|
+
}}>
|
|
652
|
+
{(() => {
|
|
653
|
+
// variant groups render as ONE surface row with A/B/C chips (SPEC-023 §5)
|
|
654
|
+
const sceneFrames = frames.filter((f) => f.scene === sc.name)
|
|
655
|
+
const nodeFor = (id: string) => nodes.find((x) => x.frame === id && !x.missing) ?? nodes.find((x) => x.frame === id)
|
|
656
|
+
const go = (id: string, shift: boolean) => {
|
|
657
|
+
const n = nodeFor(id)
|
|
658
|
+
if (!n) return
|
|
659
|
+
select(n.key, shift)
|
|
660
|
+
if (!shift) canvasCtl.fitNode(n.key)
|
|
661
|
+
}
|
|
662
|
+
const seen = new Set<string>()
|
|
663
|
+
const rows: ReactNode[] = []
|
|
664
|
+
for (const f of sceneFrames) {
|
|
665
|
+
if (f.variantGroup && !seen.has(f.variantGroup)) {
|
|
666
|
+
seen.add(f.variantGroup)
|
|
667
|
+
const members = sceneFrames.filter((m) => m.variantGroup === f.variantGroup)
|
|
668
|
+
.sort((a, b) => (a.variant ?? '').localeCompare(b.variant ?? ''))
|
|
669
|
+
if (members.length > 1) {
|
|
670
|
+
const rel = f.variantGroup === sc.name ? 'Variants'
|
|
671
|
+
: cap(f.variantGroup.slice(sc.name.length + 1).replace(/-/g, ' '))
|
|
672
|
+
const memberKeys = members.map((m) => nodeFor(m.id)?.key).filter((k): k is string => !!k)
|
|
673
|
+
const allOn = memberKeys.length > 0 && memberKeys.every((k) => selection.includes(k))
|
|
674
|
+
// held = SOME member active (same quiet wash as scene headers) - the
|
|
675
|
+
// group participates without claiming full selection
|
|
676
|
+
const held = !allOn && memberKeys.some((k) => selection.includes(k) || useStore.getState().interact === k)
|
|
677
|
+
// group header: click selects EVERY variant (the quick compare-and-test grab)
|
|
678
|
+
rows.push(
|
|
679
|
+
<div key={`g:${f.variantGroup}`} className={`sub vgroup${allOn ? ' on' : ''}${held ? ' held' : ''}`}
|
|
680
|
+
title="Select all variants"
|
|
681
|
+
onClick={() => { useStore.getState().selectMany(memberKeys); canvasCtl.fitNodes(memberKeys) }}>
|
|
682
|
+
<span className="glabel">{rel}</span>
|
|
683
|
+
<VariantsIcon size={14} className="gicon" />
|
|
684
|
+
</div>,
|
|
685
|
+
)
|
|
686
|
+
// one row per variant: [letter chip] + name, individually selectable
|
|
687
|
+
for (const m of members) {
|
|
688
|
+
const n = nodeFor(m.id)
|
|
689
|
+
const on = !!n && selection.includes(n.key)
|
|
690
|
+
const nm = m.title ?? cap((m.id.split('/').pop() ?? '').replace(/^[a-z]-/, '').replace(/-/g, ' '))
|
|
691
|
+
rows.push(
|
|
692
|
+
<div key={m.id} className={`sub vrow${on ? ' on' : ''}`} onClick={(e) => go(m.id, e.shiftKey)}>
|
|
693
|
+
<span className={`chip${on ? ' on' : ''}`}>{(m.variant ?? '?').toUpperCase()}</span><span className="nm">{nm}</span>
|
|
694
|
+
</div>,
|
|
695
|
+
)
|
|
696
|
+
}
|
|
697
|
+
continue
|
|
698
|
+
}
|
|
699
|
+
} else if (f.variantGroup) continue
|
|
700
|
+
const n = nodeFor(f.id)
|
|
701
|
+
const on = !!n && selection.includes(n.key)
|
|
702
|
+
rows.push(
|
|
703
|
+
<div key={f.id} className={`sub${on ? ' on' : ''}`} onClick={(e) => go(f.id, e.shiftKey)}>
|
|
704
|
+
{cap(f.id.split('/').slice(1).join('/') || f.id)}
|
|
705
|
+
</div>,
|
|
706
|
+
)
|
|
707
|
+
}
|
|
708
|
+
return rows
|
|
709
|
+
})()}
|
|
609
710
|
</SceneGroup>
|
|
610
711
|
))}
|
|
611
712
|
{frames.length === 0 && <div className="sub dim">no frames yet - ask your agent<br />(design/AGENTS.md)</div>}
|
|
@@ -642,7 +743,10 @@ export function App() {
|
|
|
642
743
|
|
|
643
744
|
<PlayOverlay />
|
|
644
745
|
|
|
645
|
-
{CONFIG.
|
|
746
|
+
{CONFIG.setup
|
|
747
|
+
? <div className="sh-banner">no app detected - designs would be built from nothing. See design/instructions/setup.md, then restart</div>
|
|
748
|
+
: CONFIG.noTheme && <div className="sh-banner">no theme configured - frames render unstyled. Create design/theme.css importing your app's stylesheet (or set theme in design/config.ts)</div>}
|
|
749
|
+
<UpdatePill />
|
|
646
750
|
|
|
647
751
|
<div className="sh-toasts">
|
|
648
752
|
{toasts.map((t) => <div key={t.id} className="sh-toast"><CheckIcon size={12} /> {t.text}</div>)}
|
|
@@ -174,6 +174,28 @@ function PlayInner() {
|
|
|
174
174
|
}
|
|
175
175
|
const restart = () => { const list = playList(); if (list.length) goTo(list[0]) }
|
|
176
176
|
|
|
177
|
+
/** Variant siblings of the CURRENT frame present on this board (SPEC-023 §6): the
|
|
178
|
+
* review question is "which direction is better on THIS screen" - switch in place,
|
|
179
|
+
* device and theme preserved, each variant's own data-goto links drive after. */
|
|
180
|
+
const variantList = () => {
|
|
181
|
+
const s = useStore.getState()
|
|
182
|
+
const cur = s.manifest?.frames.find((f) => f.id === s.play?.at)
|
|
183
|
+
if (!cur?.variantGroup) return []
|
|
184
|
+
const onBoard = new Set(s.nodes.filter((n) => !n.missing).map((n) => n.frame))
|
|
185
|
+
if (!onBoard.has(cur.id)) return [] // off-board frame: no coherent control
|
|
186
|
+
return (s.manifest?.frames ?? [])
|
|
187
|
+
.filter((f) => f.variantGroup === cur.variantGroup && f.kind === 'tsx' && onBoard.has(f.id))
|
|
188
|
+
.sort((a, b) => (a.variant ?? '').localeCompare(b.variant ?? ''))
|
|
189
|
+
}
|
|
190
|
+
const switchVariant = (dir: 1 | -1) => {
|
|
191
|
+
const p = useStore.getState().play
|
|
192
|
+
if (!p) return
|
|
193
|
+
const vs = variantList()
|
|
194
|
+
if (vs.length < 2) return
|
|
195
|
+
const i = vs.findIndex((f) => f.id === p.at)
|
|
196
|
+
goTo(vs[(i + dir + vs.length) % vs.length].id)
|
|
197
|
+
}
|
|
198
|
+
|
|
177
199
|
// history restores + walk: swap the stage silently (no sh:stage-at back) and track here
|
|
178
200
|
useEffect(() => {
|
|
179
201
|
playCtl.setAt = (at: string) => {
|
|
@@ -251,6 +273,8 @@ function PlayInner() {
|
|
|
251
273
|
if (key === 'Escape') { exit(); return }
|
|
252
274
|
if (key === 'ArrowRight') { step(1); return }
|
|
253
275
|
if (key === 'ArrowLeft') { step(-1); return }
|
|
276
|
+
if (key === '[') { switchVariant(-1); return }
|
|
277
|
+
if (key === ']') { switchVariant(1); return }
|
|
254
278
|
if (key === 'r') { restart(); return }
|
|
255
279
|
if (key === 'h') { chromeRef.current === 'hidden' ? setChrome('open') : hideAll(); return }
|
|
256
280
|
if (/^Digit[1-9]$/.test(code)) {
|
|
@@ -313,6 +337,7 @@ function PlayInner() {
|
|
|
313
337
|
const names = Object.keys(CONFIG.viewports)
|
|
314
338
|
const list = playList()
|
|
315
339
|
const pos = list.indexOf(play.at)
|
|
340
|
+
const variants = variantList()
|
|
316
341
|
|
|
317
342
|
// whole-pixel wrapper + per-axis scale so the iframe lands exactly on its edges -
|
|
318
343
|
// fractional sizes left subpixel seams glowing at the corners on dark frames
|
|
@@ -393,6 +418,17 @@ function PlayInner() {
|
|
|
393
418
|
<Tip inv label={<><b>Next frame</b><span className="k">→</span></>}>
|
|
394
419
|
<button onClick={() => step(1)}><ArrowRightIcon size={14} /></button>
|
|
395
420
|
</Tip>
|
|
421
|
+
{variants.length > 1 && <>
|
|
422
|
+
<i className="sep" />
|
|
423
|
+
<span className="vname">{(() => { const c = variants.find((v) => v.id === play.at); return c ? (c.title ?? (c.id.split('/').pop() ?? '').replace(/^[a-z]-/, '').replace(/-/g, ' ')) : '' })()}</span>
|
|
424
|
+
{variants.map((v) => (
|
|
425
|
+
<Tip inv key={v.id} label={<><b>{v.title ?? v.id}</b><span className="k">[ ]</span></>}>
|
|
426
|
+
<button className={`vchip${v.id === play.at ? ' on' : ''}`} onClick={() => goTo(v.id)}>
|
|
427
|
+
{(v.variant ?? '?').toUpperCase()}
|
|
428
|
+
</button>
|
|
429
|
+
</Tip>
|
|
430
|
+
))}
|
|
431
|
+
</>}
|
|
396
432
|
</div>
|
|
397
433
|
</div>
|
|
398
434
|
)
|
|
@@ -90,6 +90,42 @@ export const canvasCtl = {
|
|
|
90
90
|
zoom100() { canvasCtl.zoomTo(1) },
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
/** Variant-group captions (SPEC-023 §4): "Landing · 3 variants" above each group with
|
|
94
|
+
* 2+ members on this board. World-space (scales with the canvas); min screen size via
|
|
95
|
+
* --sh-inv. Groups with one lone member on a curated board keep the badge, no caption. */
|
|
96
|
+
function GroupCaptions() {
|
|
97
|
+
const nodes = useStore((s) => s.nodes)
|
|
98
|
+
const manifest = useStore((s) => s.manifest)
|
|
99
|
+
const selection = useStore((s) => s.selection)
|
|
100
|
+
if (!manifest) return null
|
|
101
|
+
const byId = new Map(manifest.frames.map((f) => [f.id, f])) // O(F+N), not O(F*N) per drag
|
|
102
|
+
const groups = new Map<string, { x: number; y: number; ids: Set<string>; keys: string[] }>()
|
|
103
|
+
for (const n of nodes) {
|
|
104
|
+
if (n.missing) continue
|
|
105
|
+
const f = byId.get(n.frame)
|
|
106
|
+
if (!f?.variantGroup) continue
|
|
107
|
+
const g = groups.get(f.variantGroup)
|
|
108
|
+
if (!g) groups.set(f.variantGroup, { x: n.x, y: n.y, ids: new Set([n.frame]), keys: [n.key] })
|
|
109
|
+
else { g.x = Math.min(g.x, n.x); g.y = Math.min(g.y, n.y); g.ids.add(n.frame); g.keys.push(n.key) }
|
|
110
|
+
}
|
|
111
|
+
return (
|
|
112
|
+
<>
|
|
113
|
+
{[...groups.entries()].filter(([, g]) => g.ids.size > 1).map(([id, g]) => {
|
|
114
|
+
const allOn = g.keys.every((k) => selection.includes(k))
|
|
115
|
+
return (
|
|
116
|
+
<div key={id} className={`sh-gcaption sh-no-pan${allOn ? ' on' : ''}`}
|
|
117
|
+
style={{ transform: `translate(${g.x}px, ${g.y}px) translateY(calc(-100% - clamp(4px, 8px * var(--sh-inv, 1), 40px)))` }}
|
|
118
|
+
title="Select all variants"
|
|
119
|
+
onPointerDown={(e) => e.stopPropagation()}
|
|
120
|
+
onClick={() => { useStore.getState().selectMany(g.keys); canvasCtl.fitNodes(g.keys) }}>
|
|
121
|
+
{id.split('/').map((s) => s[0].toUpperCase() + s.slice(1)).join(' / ')} · {g.ids.size} variants
|
|
122
|
+
</div>
|
|
123
|
+
)
|
|
124
|
+
})}
|
|
125
|
+
</>
|
|
126
|
+
)
|
|
127
|
+
}
|
|
128
|
+
|
|
93
129
|
export function Canvas() {
|
|
94
130
|
const nodes = useStore((s) => s.nodes)
|
|
95
131
|
const gesture = useStore((s) => s.gesture)
|
|
@@ -119,18 +155,28 @@ export function Canvas() {
|
|
|
119
155
|
}
|
|
120
156
|
ref.current.setTransform(px - x * scale, py - y * scale, scale, 320, 'easeOut')
|
|
121
157
|
}
|
|
122
|
-
canvasCtl.fitNode = (key: string) => {
|
|
123
|
-
const n = useStore.getState().nodes.find((x) => x.key === key)
|
|
124
|
-
if (n) fitRect(n.x, n.y, n.w, n.h + HEADER, true)
|
|
125
|
-
}
|
|
126
158
|
const fitKeys = (keys: string[], focus: boolean) => {
|
|
159
|
+
const st = useStore.getState()
|
|
127
160
|
const sel = new Set(keys)
|
|
128
|
-
const ns =
|
|
161
|
+
const ns = st.nodes.filter((n) => sel.has(n.key))
|
|
129
162
|
if (!ns.length) return
|
|
130
|
-
|
|
163
|
+
let x0 = Math.min(...ns.map((n) => n.x)), y0 = Math.min(...ns.map((n) => n.y))
|
|
131
164
|
const x1 = Math.max(...ns.map((n) => n.x + n.w)), y1 = Math.max(...ns.map((n) => n.y + n.h + HEADER))
|
|
165
|
+
// grouped frames carry chrome OUTSIDE their box (badge left, caption above) -
|
|
166
|
+
// fit must include that envelope or the variant text lands under the sidebar.
|
|
167
|
+
// Screen-clamped sizes need a scale estimate first: one pre-pass, then pad.
|
|
168
|
+
if (ns.some((n) => st.frameFor(n)?.variantGroup)) {
|
|
169
|
+
const el = wrap()
|
|
170
|
+
if (el) {
|
|
171
|
+
const p = insets(el)
|
|
172
|
+
const s1 = Math.max(0.05, Math.min(1, (el.clientWidth - p.left - p.right) / (x1 - x0), (el.clientHeight - p.top - p.bottom) / (y1 - y0)))
|
|
173
|
+
x0 -= Math.max(140, 44 / s1) // badge letter+name column (min 44 screen px)
|
|
174
|
+
y0 -= Math.max(64, 36 / s1) // caption line above the group
|
|
175
|
+
}
|
|
176
|
+
}
|
|
132
177
|
fitRect(x0, y0, x1 - x0, y1 - y0, focus)
|
|
133
178
|
}
|
|
179
|
+
canvasCtl.fitNode = (key: string) => fitKeys([key], true)
|
|
134
180
|
canvasCtl.fitNodes = (keys: string[]) => fitKeys(keys, true)
|
|
135
181
|
canvasCtl.fitAll = () => fitKeys(useStore.getState().nodes.map((n) => n.key), false)
|
|
136
182
|
canvasCtl.zoomTo = (target: number) => {
|
|
@@ -177,7 +223,7 @@ export function Canvas() {
|
|
|
177
223
|
if (!el) return
|
|
178
224
|
const down = (e: PointerEvent) => {
|
|
179
225
|
const t = e.target as HTMLElement | null
|
|
180
|
-
if (t?.closest('.sh-node')) return
|
|
226
|
+
if (t?.closest('.sh-node') || t?.closest('.sh-gcaption')) return
|
|
181
227
|
const s = useStore.getState()
|
|
182
228
|
if (s.selection.length || s.interact) s.select(null)
|
|
183
229
|
}
|
|
@@ -277,6 +323,7 @@ export function Canvas() {
|
|
|
277
323
|
>
|
|
278
324
|
<TransformComponent wrapperClass="sh-canvas" contentClass="sh-content">
|
|
279
325
|
<div id="sh-world">
|
|
326
|
+
<GroupCaptions />
|
|
280
327
|
{nodes.map((n) => <FrameNode key={n.key} node={n} />)}
|
|
281
328
|
</div>
|
|
282
329
|
</TransformComponent>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { memo, useEffect, useRef } from 'react'
|
|
2
|
-
import { frameUrl, useStore, CONFIG, type Node } from '../store.ts'
|
|
2
|
+
import { cap, frameUrl, useStore, CONFIG, type Node } from '../store.ts'
|
|
3
3
|
import { CopyIcon, ReloadIcon, XIcon } from '../icons.tsx'
|
|
4
4
|
|
|
5
5
|
export const HEADER = 28
|
|
@@ -148,6 +148,11 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
|
|
|
148
148
|
)
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
+
// variant badge (SPEC-023 §4): letter + name floating LEFT of the frame, outside the
|
|
152
|
+
// artwork, world-anchored (scales with zoom) with a screen-space minimum via --sh-inv
|
|
153
|
+
const variantName = frame.title
|
|
154
|
+
?? cap((frame.id.split('/').pop() ?? '').replace(/^[a-z]-/, '').replace(/-/g, ' '))
|
|
155
|
+
|
|
151
156
|
return (
|
|
152
157
|
<div
|
|
153
158
|
className={`sh-node${selected ? ' sel' : ''}${interact ? ' interact' : ''}`}
|
|
@@ -155,6 +160,14 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
|
|
|
155
160
|
style={{ transform: `translate(${node.x}px, ${node.y}px)`, width: node.w, height: node.h + HEADER }}
|
|
156
161
|
data-node={node.key}
|
|
157
162
|
>
|
|
163
|
+
{frame.variantGroup && (
|
|
164
|
+
<div className="sh-vbadge sh-no-pan" title={`${frame.variantGroup} · variant ${frame.variant?.toUpperCase()} - click to select`}
|
|
165
|
+
onPointerDown={(e) => e.stopPropagation()}
|
|
166
|
+
onClick={(e) => { select(node.key, e.shiftKey) }}>
|
|
167
|
+
<b>{frame.variant?.toUpperCase()}</b>
|
|
168
|
+
<span>{variantName}</span>
|
|
169
|
+
</div>
|
|
170
|
+
)}
|
|
158
171
|
<div className="sh-node-head sh-no-pan" onPointerDown={(e) => drag(e, 'move')} title={frame.file}>
|
|
159
172
|
<span className="id sh-no-pan">{frame.title ?? frame.id}</span>
|
|
160
173
|
<span className="dim sh-no-pan">{Math.round(node.w)} · {node.theme}</span>
|
|
@@ -44,6 +44,8 @@ export const CardsThreeIcon = icon(<path d="M208,88H48a16,16,0,0,0-16,16v96a16,1
|
|
|
44
44
|
export const FrameCornersIcon = icon(<path d="M160 48h40a8 8 0 0 1 8 8v40M208 160v40a8 8 0 0 1-8 8h-40M96 208H56a8 8 0 0 1-8-8v-40M48 96V56a8 8 0 0 1 8-8h40" fill="none" stroke="currentColor" strokeWidth="16" strokeLinecap="round" strokeLinejoin="round" />)
|
|
45
45
|
export const ArrowLeftIcon = icon(<path d="M224,128a8,8,0,0,1-8,8H59.31l58.35,58.34a8,8,0,0,1-11.32,11.32l-72-72a8,8,0,0,1,0-11.32l72-72a8,8,0,0,1,11.32,11.32L59.31,120H216A8,8,0,0,1,224,128Z" />)
|
|
46
46
|
export const ArrowRightIcon = icon(<path d="M221.66,133.66l-72,72a8,8,0,0,1-11.32-11.32L196.69,136H40a8,8,0,0,1,0-16H196.69L138.34,61.66a8,8,0,0,1,11.32-11.32l72,72A8,8,0,0,1,221.66,133.66Z" />)
|
|
47
|
+
/** Variants/experimentation mark: the flask (Phosphor). */
|
|
48
|
+
export const VariantsIcon = icon(<path d="M223.45,208.19,168,101.11V40h8a8,8,0,0,0,0-16H80a8,8,0,0,0,0,16h8v61.11L32.55,208.19A16,16,0,0,0,46.86,232H209.14a16,16,0,0,0,14.31-23.81ZM102.13,108.5A8,8,0,0,0,104,103.35V40h48v63.35a8,8,0,0,0,1.87,5.15L179.36,157c-11.71,2.87-26.5.66-44.16-6.64-14.08-5.82-27.34-8.53-39.66-8.16ZM46.86,216,84.36,143.6c11.68-2.9,26.47-.68,44.13,6.61,13.09,5.42,25.36,8.11,36.65,8.11a67.86,67.86,0,0,0,22.53-3.72L209.14,216Z" />)
|
|
47
49
|
export const CardsIcon = icon(<path d="M184,72H40A16,16,0,0,0,24,88V200a16,16,0,0,0,16,16H184a16,16,0,0,0,16-16V88A16,16,0,0,0,184,72Zm0,128H40V88H184V200ZM232,56V176a8,8,0,0,1-16,0V56H64a8,8,0,0,1,0-16H216A16,16,0,0,1,232,56Z" />)
|
|
48
50
|
/** Viewport-name -> device glyph; unknown custom names fall back to the generic Devices. */
|
|
49
51
|
export function deviceIcon(name: string | null, size = 14) {
|