@marver-design/marver 0.2.0 → 0.2.2
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 +3 -2
- package/dist/{build-D0GnIR4G.mjs → build-CNoXE13J.mjs} +3 -3
- package/dist/cli.mjs +23 -7
- package/dist/{dev-BK4x3PBr.mjs → dev-Blyy4jOL.mjs} +24 -5
- package/dist/init-3h9pXEzp.mjs +337 -0
- package/dist/manifest-CHmKAAtG.mjs +298 -0
- package/dist/{plugin-DB5t2WUl.mjs → plugin-DiDJA9n-.mjs} +157 -120
- package/dist/{serve-BPNmWeJx.mjs → serve-BvbAbWeK.mjs} +8 -1
- package/package.json +4 -3
- package/src/client/frame-host/bridge.js +8 -2
- package/src/client/frame-host/main.tsx +7 -1
- package/src/client/shell/App.tsx +69 -8
- package/src/client/shell/canvas/FrameNode.tsx +9 -0
- package/src/client/shell/store.ts +58 -8
- package/src/client/shell/styles.css +21 -2
- package/templates/AGENTS-embedded.md +38 -26
- package/templates/AGENTS-studio.md +38 -26
- package/templates/instructions/boards.md +38 -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 +52 -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/config-DMBEpdEN.mjs +0 -132
- package/dist/init-DcOy1krf.mjs +0 -168
|
@@ -4,9 +4,15 @@ const isHtmlFrame = new URL(import.meta.url).searchParams.get('html') === '1'
|
|
|
4
4
|
const post = (msg) => { if (window.parent !== window) window.parent.postMessage(msg, '*') }
|
|
5
5
|
const id = new URLSearchParams(location.search).get('id') ?? location.pathname
|
|
6
6
|
|
|
7
|
+
// theme lands as BOTH signals: [data-theme] plus the `dark` class Tailwind/shadcn key on
|
|
8
|
+
const setTheme = (theme) => {
|
|
9
|
+
document.documentElement.dataset.theme = theme
|
|
10
|
+
document.documentElement.classList.toggle('dark', theme === 'dark')
|
|
11
|
+
}
|
|
12
|
+
|
|
7
13
|
if (isHtmlFrame) {
|
|
8
14
|
const theme = new URLSearchParams(location.search).get('theme')
|
|
9
|
-
if (theme)
|
|
15
|
+
if (theme) setTheme(theme)
|
|
10
16
|
}
|
|
11
17
|
|
|
12
18
|
document.addEventListener('click', (e) => {
|
|
@@ -23,7 +29,7 @@ window.addEventListener('error', (e) => post({ type: 'sh:error', id, message: St
|
|
|
23
29
|
window.addEventListener('unhandledrejection', (e) => post({ type: 'sh:error', id, message: `unhandled rejection: ${e.reason}` }))
|
|
24
30
|
|
|
25
31
|
window.addEventListener('message', (e) => {
|
|
26
|
-
if (e?.data?.type === 'sh:set-theme')
|
|
32
|
+
if (e?.data?.type === 'sh:set-theme') setTheme(e.data.theme)
|
|
27
33
|
})
|
|
28
34
|
|
|
29
35
|
if (isHtmlFrame) {
|
|
@@ -12,7 +12,11 @@ import { frameFile, frames, layoutChain, layouts, providers } from './registry.t
|
|
|
12
12
|
const params = new URLSearchParams(location.search)
|
|
13
13
|
const id = params.get('id') ?? ''
|
|
14
14
|
const theme = params.get('theme') ?? 'light'
|
|
15
|
+
// Both signals, always: [data-theme] for token systems keyed on the attribute, and the
|
|
16
|
+
// `dark` class for Tailwind/shadcn (`@custom-variant dark (&:is(.dark *))` never sees a
|
|
17
|
+
// data attribute). The bridge applies the same pair on sh:set-theme.
|
|
15
18
|
document.documentElement.dataset.theme = theme
|
|
19
|
+
document.documentElement.classList.toggle('dark', theme === 'dark')
|
|
16
20
|
|
|
17
21
|
const post = (msg: Record<string, unknown>) => { if (window.parent !== window) window.parent.postMessage(msg, '*') }
|
|
18
22
|
|
|
@@ -47,7 +51,9 @@ async function boot() {
|
|
|
47
51
|
await import('virtual:sh-theme' as string)
|
|
48
52
|
|
|
49
53
|
const fileKey = frameFile(id)
|
|
50
|
-
|
|
54
|
+
// Honest copy: the id usually IS valid on disk - this document's frame registry is
|
|
55
|
+
// what's stale (file just added/renamed, or the dev server restarted). See #20.
|
|
56
|
+
if (!fileKey) return fail(`frame "${id}" is not in this canvas's registry yet - the file was likely just added or renamed. The canvas should recover on its own; if this card persists, reload it.`)
|
|
51
57
|
|
|
52
58
|
const frameMod: any = await frames[fileKey]()
|
|
53
59
|
const Frame = frameMod.default
|
package/src/client/shell/App.tsx
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
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, XIcon, deviceIcon } from './icons.tsx'
|
|
10
10
|
|
|
11
11
|
let booted = false // survives Fast Refresh; see the boot effect
|
|
12
12
|
|
|
@@ -115,6 +115,18 @@ function SelectionBar() {
|
|
|
115
115
|
const node = useStore((s) => s.nodes.find((n) => n.key === s.selection[s.selection.length - 1]))
|
|
116
116
|
const frame = useStore((s) => (node ? s.frameFor(node) : undefined))
|
|
117
117
|
const nodes = useStore((s) => s.nodes)
|
|
118
|
+
// measured width feeds the viewport clamp below; a callback ref because the bar
|
|
119
|
+
// mounts/unmounts with the selection (an effect with [] would miss remounts)
|
|
120
|
+
const [barW, setBarW] = useState(0)
|
|
121
|
+
const roRef = useRef<ResizeObserver | null>(null)
|
|
122
|
+
const barRef = (el: HTMLDivElement | null) => {
|
|
123
|
+
roRef.current?.disconnect()
|
|
124
|
+
roRef.current = null
|
|
125
|
+
if (el) {
|
|
126
|
+
roRef.current = new ResizeObserver(() => setBarW(el.offsetWidth))
|
|
127
|
+
roRef.current.observe(el)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
118
130
|
if (!node || !frame || node.missing) return null
|
|
119
131
|
// anchor: centered over the bounding box of ALL selected frames, above the topmost
|
|
120
132
|
const selNodes = nodes.filter((n) => selection.includes(n.key))
|
|
@@ -135,14 +147,21 @@ function SelectionBar() {
|
|
|
135
147
|
.map((k) => { const n = st.nodes.find((x) => x.key === k); return n ? st.frameFor(n) : undefined })
|
|
136
148
|
.filter((f): f is NonNullable<typeof f> => !!f)
|
|
137
149
|
}
|
|
150
|
+
// centered over the selection's bounding box, then CLAMPED into the viewport: the
|
|
151
|
+
// controls for a selected frame must stay reachable when its top edge is panned
|
|
152
|
+
// off-screen, and must never drift off the sides (friction log #23)
|
|
153
|
+
const centerX = `calc(var(--sh-tx, 0px) + var(--sh-s, 1) * ${(bx0 + bx1) / 2}px)`
|
|
154
|
+
const rawTop = `calc(var(--sh-ty, 0px) + var(--sh-s, 1) * ${by0}px - 52px)`
|
|
138
155
|
return (
|
|
139
156
|
<div
|
|
140
157
|
className="sh-ctx"
|
|
158
|
+
ref={barRef}
|
|
141
159
|
style={{
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
160
|
+
left: barW
|
|
161
|
+
? `clamp(8px, calc(${centerX} - ${Math.round(barW / 2)}px), calc(100vw - ${barW + 8}px))`
|
|
162
|
+
: centerX,
|
|
163
|
+
top: `clamp(8px, ${rawTop}, calc(100vh - 52px))`,
|
|
164
|
+
transform: barW ? undefined : 'translateX(-50%)',
|
|
146
165
|
}}
|
|
147
166
|
>
|
|
148
167
|
{multi && <>
|
|
@@ -232,6 +251,45 @@ function DeviceMenu() {
|
|
|
232
251
|
)
|
|
233
252
|
}
|
|
234
253
|
|
|
254
|
+
/** Update pill (dev only): the daily registry check surfaces here - same glass, same
|
|
255
|
+
* pill, bottom-center. Click the command to copy it; × dismisses THIS version for
|
|
256
|
+
* good (localStorage), so the pill returns only when the next release lands. */
|
|
257
|
+
function UpdatePill() {
|
|
258
|
+
const [latest, setLatest] = useState<string | null>(null)
|
|
259
|
+
const play = useStore((s) => s.play)
|
|
260
|
+
useEffect(() => {
|
|
261
|
+
if (PUBLISHED) return // a shared canvas never nags its viewers
|
|
262
|
+
fetch(`${ROUTE}/api/update`)
|
|
263
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
264
|
+
.then((u) => {
|
|
265
|
+
if (u?.latest && localStorage.getItem('mv-update-seen') !== u.latest) setLatest(u.latest)
|
|
266
|
+
})
|
|
267
|
+
.catch(() => { /* dev server gone or endpoint absent - stay quiet */ })
|
|
268
|
+
}, [])
|
|
269
|
+
if (!latest || play) return null
|
|
270
|
+
// init rides along so managed files (AGENTS.md, instructions/) refresh with the code
|
|
271
|
+
const cmd = `npm i -D ${PKG}@latest && npx marver init`
|
|
272
|
+
const dismiss = () => {
|
|
273
|
+
try { localStorage.setItem('mv-update-seen', latest) } catch { /* storage unavailable */ }
|
|
274
|
+
setLatest(null)
|
|
275
|
+
}
|
|
276
|
+
return (
|
|
277
|
+
<div className="sh-update">
|
|
278
|
+
<span><b>{latest}</b> is out</span>
|
|
279
|
+
<Tip side="top" label="Copy, then paste to your terminal or your agent">
|
|
280
|
+
<button className="cmd" onClick={() => {
|
|
281
|
+
const t = useStore.getState().toast
|
|
282
|
+
navigator.clipboard?.writeText(cmd).then(() => t('update command copied'), () => t('copy blocked - select it manually'))
|
|
283
|
+
?? t('copy unavailable - select it manually')
|
|
284
|
+
}}><code>{cmd}</code></button>
|
|
285
|
+
</Tip>
|
|
286
|
+
<Tip side="top" label="Dismiss this version">
|
|
287
|
+
<button className="x" onClick={dismiss}><XIcon size={13} /></button>
|
|
288
|
+
</Tip>
|
|
289
|
+
</div>
|
|
290
|
+
)
|
|
291
|
+
}
|
|
292
|
+
|
|
235
293
|
const ZOOMS = [2, 1.5, 1, 0.5, 0.25, 0.1]
|
|
236
294
|
|
|
237
295
|
/** Zoom preset dropdown on the percentage readout. */
|
|
@@ -623,7 +681,10 @@ export function App() {
|
|
|
623
681
|
|
|
624
682
|
<PlayOverlay />
|
|
625
683
|
|
|
626
|
-
{CONFIG.
|
|
684
|
+
{CONFIG.setup
|
|
685
|
+
? <div className="sh-banner">no app detected - designs would be built from nothing. See design/instructions/setup.md, then restart</div>
|
|
686
|
+
: 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>}
|
|
687
|
+
<UpdatePill />
|
|
627
688
|
|
|
628
689
|
<div className="sh-toasts">
|
|
629
690
|
{toasts.map((t) => <div key={t.id} className="sh-toast"><CheckIcon size={12} /> {t.text}</div>)}
|
|
@@ -47,6 +47,15 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
|
|
|
47
47
|
fileRef.current = sig
|
|
48
48
|
}, [frame?.kind, frame?.file])
|
|
49
49
|
|
|
50
|
+
// shell-requested renavigation (store bumps node.nav): reload on a FRESH rev-stamped
|
|
51
|
+
// URL - the errored document's own URL may be poisoned by cache (friction log #20)
|
|
52
|
+
const navRef = useRef(node.nav ?? 0)
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
if ((node.nav ?? 0) === navRef.current) return
|
|
55
|
+
navRef.current = node.nav ?? 0
|
|
56
|
+
if (frame && iframeRef.current) iframeRef.current.src = frameUrl(frame, node.theme)
|
|
57
|
+
}, [node.nav])
|
|
58
|
+
|
|
50
59
|
// ready timeout (spec §7): 10s without sh:ready -> error card with reload
|
|
51
60
|
useEffect(() => {
|
|
52
61
|
if (node.status !== 'loading') return
|
|
@@ -11,12 +11,18 @@ 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
|
+
/** True on a published static canvas - no dev server, no API, no update checks. */
|
|
15
|
+
export const PUBLISHED = DATA !== null
|
|
16
|
+
|
|
14
17
|
export interface FrameEntry { id: string; file: string; kind: 'tsx' | 'html'; scene: string; title?: string; viewport?: string; theme?: 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
|
|
18
21
|
/** RESOLVED theme (what renders): themeUser ?? frame meta.theme ?? viewTheme. */
|
|
19
22
|
theme: string
|
|
23
|
+
/** Renavigation nonce: bumped when the shell wants this iframe on a FRESH URL
|
|
24
|
+
* (errored frame whose file is back in the manifest). Never persisted. */
|
|
25
|
+
nav?: number
|
|
20
26
|
/** Explicit per-frame override, set by scoped theme actions; cleared by a global set.
|
|
21
27
|
* The only theme value that persists into the board file. */
|
|
22
28
|
themeUser?: string
|
|
@@ -24,7 +30,7 @@ export interface Node {
|
|
|
24
30
|
}
|
|
25
31
|
export interface Toast { id: number; text: string }
|
|
26
32
|
|
|
27
|
-
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
|
|
28
34
|
|
|
29
35
|
export const cap = (s: string) => (s ? s[0].toUpperCase() + s.slice(1) : s)
|
|
30
36
|
|
|
@@ -42,10 +48,16 @@ const HEADER = 28
|
|
|
42
48
|
let toastSeq = 0
|
|
43
49
|
const nodeKey = () => 'n_' + Math.random().toString(36).slice(2, 8)
|
|
44
50
|
|
|
51
|
+
/** Manifest revision - bumps whenever a manifest lands (boot, switch, sh:manifest).
|
|
52
|
+
* Stamped into frame URLs so a changed frame set mints genuinely NEW iframe URLs:
|
|
53
|
+
* the browser can never revive a pre-change document from cache (friction log #20). */
|
|
54
|
+
let manifestRev = 0
|
|
55
|
+
export const bumpManifestRev = () => { manifestRev++ }
|
|
56
|
+
|
|
45
57
|
export function frameUrl(frame: FrameEntry, theme: string): string {
|
|
46
58
|
return frame.kind === 'html'
|
|
47
59
|
? `/${frame.file}?theme=${theme}`
|
|
48
|
-
: `${ROUTE}/frame/?id=${encodeURIComponent(frame.id)}&theme=${theme}`
|
|
60
|
+
: `${ROUTE}/frame/?id=${encodeURIComponent(frame.id)}&theme=${theme}&r=${manifestRev}`
|
|
49
61
|
}
|
|
50
62
|
|
|
51
63
|
/** The global view theme: the user's sticky preference, applied across boards and
|
|
@@ -148,6 +160,7 @@ export const useStore = create<State>((set, get) => {
|
|
|
148
160
|
raw = await mRes.json().catch(() => undefined)
|
|
149
161
|
}
|
|
150
162
|
if (raw === undefined || raw === null || typeof raw !== 'object') return null
|
|
163
|
+
bumpManifestRev() // fresh manifest → fresh iframe URLs
|
|
151
164
|
const manifest: Manifest = {
|
|
152
165
|
frames: (Array.isArray(raw?.frames) ? raw.frames : [])
|
|
153
166
|
.filter((f: any) => f && typeof f.id === 'string' && typeof f.file === 'string'),
|
|
@@ -164,9 +177,12 @@ export const useStore = create<State>((set, get) => {
|
|
|
164
177
|
if (DATA) loaded = DATA.boards[boardName] ? { board: DATA.boards[boardName], sha256: 'published' } : 'fresh'
|
|
165
178
|
else {
|
|
166
179
|
const res = await fetch(`${ROUTE}/api/boards/${boardName}`)
|
|
167
|
-
if (res.ok)
|
|
168
|
-
|
|
169
|
-
|
|
180
|
+
if (res.ok) {
|
|
181
|
+
const body = await res.json()
|
|
182
|
+
loaded = body?.board == null ? 'fresh' : body // board:null = not materialized yet
|
|
183
|
+
}
|
|
184
|
+
else if (res.status === 404) loaded = 'fresh' // older servers still 404 fresh boards
|
|
185
|
+
else loaded = null // anything else must not commit an empty canvas
|
|
170
186
|
}
|
|
171
187
|
if (loaded === null) return null
|
|
172
188
|
if (loaded !== 'fresh') {
|
|
@@ -210,6 +226,16 @@ export const useStore = create<State>((set, get) => {
|
|
|
210
226
|
if (typeof board?.deviceView === 'string' && CONFIG.viewports[board.deviceView]) deviceView = board.deviceView
|
|
211
227
|
if (board?.baseLayout && typeof board.baseLayout === 'object') baseLayout = board.baseLayout
|
|
212
228
|
}
|
|
229
|
+
// auto-managed goes both ways (friction log #15): an auto board gains new frames
|
|
230
|
+
// AND sheds deleted ones. Tombstone cards are a curated-board concept (spec §7).
|
|
231
|
+
// Pruning at load DIRTIES the board (dev only) so the file on disk sheds the
|
|
232
|
+
// tombstones too - otherwise a recreated frame id resurrects its stale node.
|
|
233
|
+
let prunedAtLoad = false
|
|
234
|
+
if (boardAuto) {
|
|
235
|
+
const before = nodes.length
|
|
236
|
+
nodes = nodes.filter((n) => !n.missing)
|
|
237
|
+
prunedAtLoad = !DATA && nodes.length !== before
|
|
238
|
+
}
|
|
213
239
|
// frames not on the board yet → auto boards only (a curated board shows exactly its list)
|
|
214
240
|
if (boardAuto) {
|
|
215
241
|
const placed = new Set(nodes.map((n) => n.frame))
|
|
@@ -234,8 +260,9 @@ export const useStore = create<State>((set, get) => {
|
|
|
234
260
|
const placedAll = tidy(nodes.map((n) => ({ key: n.key, scene: sceneOf(n.frame), w: n.w, h: n.h + HEADER })))
|
|
235
261
|
for (const pl of placedAll) { const n = nodes.find((x) => x.key === pl.key)!; n.x = pl.x; n.y = pl.y }
|
|
236
262
|
}
|
|
237
|
-
// dirty
|
|
238
|
-
|
|
263
|
+
// dirty matches disk by construction - except when load-time pruning changed the
|
|
264
|
+
// node set; callers see dirty:true and schedule the save that persists the prune
|
|
265
|
+
return { manifest, nodes, boardHash, boardAuto, deviceView, baseLayout, selection: [], dirty: prunedAtLoad }
|
|
239
266
|
} catch { return null }
|
|
240
267
|
}
|
|
241
268
|
|
|
@@ -255,6 +282,7 @@ export const useStore = create<State>((set, get) => {
|
|
|
255
282
|
if (get().board !== boardName || editRev !== revAtStart) return false
|
|
256
283
|
const live = get().manifest // a WS manifest update may have landed mid-fetch
|
|
257
284
|
set(next)
|
|
285
|
+
if (next.dirty) scheduleSave() // load-time prune must reach the disk
|
|
258
286
|
if (live && manifestKey(live) !== manifestKey(next.manifest as Manifest)) get().applyManifest(live)
|
|
259
287
|
return true
|
|
260
288
|
},
|
|
@@ -280,10 +308,12 @@ export const useStore = create<State>((set, get) => {
|
|
|
280
308
|
++loadSeq // invalidate any in-flight boot of the old board
|
|
281
309
|
const live = get().manifest // a WS manifest update may have landed mid-load
|
|
282
310
|
set({ board: name, interact: null, ...next })
|
|
311
|
+
if (next.dirty) scheduleSave() // load-time prune must reach the disk
|
|
283
312
|
if (live && manifestKey(live) !== manifestKey(next.manifest as Manifest)) get().applyManifest(live)
|
|
284
313
|
},
|
|
285
314
|
|
|
286
315
|
applyManifest(m) {
|
|
316
|
+
bumpManifestRev() // frame set changed → new iframes get fresh URLs
|
|
287
317
|
const { nodes, toast, boardAuto } = get()
|
|
288
318
|
const known = new Set(nodes.map((n) => n.frame))
|
|
289
319
|
const next = [...nodes]
|
|
@@ -313,8 +343,28 @@ export const useStore = create<State>((set, get) => {
|
|
|
313
343
|
const f = m.frames.find((x) => x.id === n.frame)
|
|
314
344
|
const want = n.themeUser ?? f?.theme ?? get().viewTheme
|
|
315
345
|
if (n.theme !== want) { n.theme = want; retinted = true }
|
|
346
|
+
// an errored frame whose file IS in the fresh manifest gets one automatic retry
|
|
347
|
+
// on a rev-stamped URL - the "unknown frame id" dead end must self-heal (#20)
|
|
348
|
+
if (!missing && n.status === 'error') { n.status = 'loading'; n.nav = (n.nav ?? 0) + 1; retinted = true }
|
|
316
349
|
}
|
|
317
|
-
|
|
350
|
+
// auto boards prune deleted frames outright - "auto-managed" must manage both
|
|
351
|
+
// directions (friction log #15). Curated boards keep the explicit card (spec §7).
|
|
352
|
+
let final = next
|
|
353
|
+
if (boardAuto) {
|
|
354
|
+
final = next.filter((n) => !n.missing)
|
|
355
|
+
const dropped = next.length - final.length
|
|
356
|
+
if (dropped) {
|
|
357
|
+
changed = true
|
|
358
|
+
toast(dropped === 1 ? 'removed 1 deleted frame' : `removed ${dropped} deleted frames`)
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
set((s) => ({
|
|
362
|
+
manifest: m,
|
|
363
|
+
nodes: changed || retinted ? [...final] : final,
|
|
364
|
+
selection: s.selection.filter((k) => final.some((n) => n.key === k)),
|
|
365
|
+
interact: s.interact && final.some((n) => n.key === s.interact) ? s.interact : null,
|
|
366
|
+
...(changed ? { dirty: true, baseLayout: nextBase } : {}),
|
|
367
|
+
}))
|
|
318
368
|
if (changed) scheduleSave()
|
|
319
369
|
},
|
|
320
370
|
|
|
@@ -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 }
|
|
@@ -3,6 +3,29 @@
|
|
|
3
3
|
You design by writing files. The canvas at the printed localhost URL reflects them live.
|
|
4
4
|
Never run or talk to the canvas tool; read and write files only.
|
|
5
5
|
|
|
6
|
+
## The method (binding)
|
|
7
|
+
|
|
8
|
+
Design work moves through phases. BEFORE working in a phase, read its instruction
|
|
9
|
+
file in design/instructions/ - they are short, strict, and part of this contract:
|
|
10
|
+
|
|
11
|
+
| Phase | When | Read |
|
|
12
|
+
|---|---|---|
|
|
13
|
+
| Configure | first session in a repo, or frames render unstyled | instructions/configure.md |
|
|
14
|
+
| Discover | any new surface, feature, or flow | instructions/discover.md |
|
|
15
|
+
| Wireframe | new work: nail structure + copy in throwaway lo-fi | instructions/wireframe.md |
|
|
16
|
+
| Brand | before the first hi-fi work: extract or create the world | instructions/brand.md |
|
|
17
|
+
| Build | hi-fi frames from real components | instructions/craft.md + components.md |
|
|
18
|
+
| Review | before presenting anything | instructions/review.md |
|
|
19
|
+
| Boards | creating a board or publishing | instructions/boards.md |
|
|
20
|
+
|
|
21
|
+
Refining an existing screen: Configure must hold, then Build + Review. New work runs
|
|
22
|
+
the full ladder. Unsure which phase you are in? Ask the human - one question beats a
|
|
23
|
+
phase of wrong work.
|
|
24
|
+
|
|
25
|
+
Stuck, or the human is unhappy with a result? instructions/reference/ holds the deep
|
|
26
|
+
guides (layout, typography, color, motion, copy, states, tuning, critique, concepts) -
|
|
27
|
+
the routing index is at the top of instructions/craft.md. Pull ONE file, apply, return.
|
|
28
|
+
|
|
6
29
|
## Frames
|
|
7
30
|
- A frame = one file: design/scenes/<scene>/<name>.tsx or .html. One frame, one surface.
|
|
8
31
|
- It default-exports a React component. No imports from the tool are needed. Optional:
|
|
@@ -11,7 +34,14 @@ Never run or talk to the canvas tool; read and write files only.
|
|
|
11
34
|
// tv available commented-out). Pick the one the screen is designed for - the human can
|
|
12
35
|
// flip the whole board to any device (Devices menu, hotkeys 0-5) to check responsiveness.
|
|
13
36
|
- States are sibling frames: empty.tsx, filled.tsx, error.tsx, success.tsx.
|
|
14
|
-
-
|
|
37
|
+
- VERSIONS are sibling frames too - the scene is the surface, each frame one direction:
|
|
38
|
+
design/scenes/landing/a-terminal.tsx, landing/b-editorial.tsx, landing/c-product.tsx.
|
|
39
|
+
Layout and the sidebar follow frame-id order, so variants named under one scene with
|
|
40
|
+
a-/b-/c- prefixes stay adjacent and ordered through tidy and every device view.
|
|
41
|
+
Never spread versions across scenes (terminal/landing, editorial/landing) - they
|
|
42
|
+
interleave with everything else and the comparison falls apart.
|
|
43
|
+
- {{UI_GUIDANCE}}
|
|
44
|
+
{{NEXT_NOTES}}
|
|
15
45
|
- Navigation: put data-goto="scene/frame" on any element. That is the whole prototype system.
|
|
16
46
|
In play mode (the human presses P) frames swap in place inside one device - design flows
|
|
17
47
|
as complete graphs: every screen a data-goto points at should itself link somewhere or be
|
|
@@ -28,13 +58,14 @@ Never run or talk to the canvas tool; read and write files only.
|
|
|
28
58
|
convention). The root design/scenes/_layout.tsx mounts the app's real shell component.
|
|
29
59
|
|
|
30
60
|
## Fixtures
|
|
31
|
-
- design/scenes/<scene>/_fixtures.ts - typed plain objects shaped like the
|
|
61
|
+
- design/scenes/<scene>/_fixtures.ts - typed plain objects shaped like the component's PROPS (containers map real APIs into them at promotion; see instructions/components.md).
|
|
32
62
|
- Fixture shapes should match the component's props so tsc catches drift.
|
|
33
63
|
- Loading states are fixtures too: export const slowOrders = () => new Promise(r =>
|
|
34
64
|
setTimeout(() => r(orders), 800)) and let the frame render its skeleton while awaiting.
|
|
35
65
|
|
|
36
66
|
## Orientation
|
|
37
|
-
- design/manifest.json lists every frame (id, file, scene, title) - read it before
|
|
67
|
+
- design/manifest.json lists every frame (id, file, scene, title) - read it before
|
|
68
|
+
exploring. `init` writes the first one; `marver dev` keeps it fresh.
|
|
38
69
|
- Component galleries: create design/components/<name>/variants.tsx rendering each variant
|
|
39
70
|
and each state (default / hover-styled / focus / disabled / loading) of one ui component.
|
|
40
71
|
|
|
@@ -51,26 +82,7 @@ Never run or talk to the canvas tool; read and write files only.
|
|
|
51
82
|
|
|
52
83
|
## Boards (curated canvases)
|
|
53
84
|
|
|
54
|
-
A board is a saved canvas: `design/boards/<name>.json`
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
```json
|
|
60
|
-
{ "version": 1, "name": "checkout-compare", "auto": false,
|
|
61
|
-
"nodes": [ { "frame": "checkout-a/cart" }, { "frame": "checkout-b/cart" } ] }
|
|
62
|
-
```
|
|
63
|
-
|
|
64
|
-
- The same frame may appear on many boards, or twice on one board (e.g. two widths:
|
|
65
|
-
add `"w"`/`"h"` on a node to pin a size).
|
|
66
|
-
- `auto: false` boards show exactly their list. The `all-scenes` board is auto-managed -
|
|
67
|
-
never write it.
|
|
68
|
-
- Use boards for comparisons: version A vs B vs C of a flow, side by side.
|
|
69
|
-
- Boards are also the unit of PUBLISHING (`marver build --boards <name>`): a flow being
|
|
70
|
-
published must have every frame it data-gotos on that board - unlisted frames are
|
|
71
|
-
excluded from the bundle at build time. The published gate page shows the app's
|
|
72
|
-
identity: design/logo.svg + the host package name (overridable via config `share`).
|
|
73
|
-
If the app has no logo asset yet, create a simple design/logo.svg for it.
|
|
74
|
-
Leave `share.branding` ON unless the human explicitly asks to remove it: Marver is
|
|
75
|
-
free, the gate is already fully personalized to the app, and the small "Powered by"
|
|
76
|
-
line is how the tool spreads. Do not remove it as part of "branding polish".
|
|
85
|
+
A board is a saved canvas: `design/boards/<name>.json` - you create and manage them
|
|
86
|
+
by writing files; `all-scenes` is auto-managed, never write it. BEFORE creating a
|
|
87
|
+
board or publishing anything, read instructions/boards.md (file format, layout
|
|
88
|
+
durability, publishing rules).
|
|
@@ -3,6 +3,29 @@
|
|
|
3
3
|
You design by writing files. The canvas at the printed localhost URL reflects them live.
|
|
4
4
|
Never run or talk to the canvas tool; read and write files only.
|
|
5
5
|
|
|
6
|
+
## The method (binding)
|
|
7
|
+
|
|
8
|
+
Design work moves through phases. BEFORE working in a phase, read its instruction
|
|
9
|
+
file in design/instructions/ - they are short, strict, and part of this contract:
|
|
10
|
+
|
|
11
|
+
| Phase | When | Read |
|
|
12
|
+
|---|---|---|
|
|
13
|
+
| Configure | first session in a repo, or frames render unstyled | instructions/configure.md |
|
|
14
|
+
| Discover | any new surface, feature, or flow | instructions/discover.md |
|
|
15
|
+
| Wireframe | new work: nail structure + copy in throwaway lo-fi | instructions/wireframe.md |
|
|
16
|
+
| Brand | before the first hi-fi work: extract or create the world | instructions/brand.md |
|
|
17
|
+
| Build | hi-fi frames from real components | instructions/craft.md + components.md |
|
|
18
|
+
| Review | before presenting anything | instructions/review.md |
|
|
19
|
+
| Boards | creating a board or publishing | instructions/boards.md |
|
|
20
|
+
|
|
21
|
+
Refining an existing screen: Configure must hold, then Build + Review. New work runs
|
|
22
|
+
the full ladder. Unsure which phase you are in? Ask the human - one question beats a
|
|
23
|
+
phase of wrong work.
|
|
24
|
+
|
|
25
|
+
Stuck, or the human is unhappy with a result? instructions/reference/ holds the deep
|
|
26
|
+
guides (layout, typography, color, motion, copy, states, tuning, critique, concepts) -
|
|
27
|
+
the routing index is at the top of instructions/craft.md. Pull ONE file, apply, return.
|
|
28
|
+
|
|
6
29
|
## Frames
|
|
7
30
|
- A frame = one file: design/scenes/<scene>/<name>.tsx or .html. One frame, one surface.
|
|
8
31
|
- It default-exports a React component. No imports from the tool are needed. Optional:
|
|
@@ -11,7 +34,14 @@ Never run or talk to the canvas tool; read and write files only.
|
|
|
11
34
|
// tv available commented-out). Pick the one the screen is designed for - the human can
|
|
12
35
|
// flip the whole board to any device (Devices menu, hotkeys 0-5) to check responsiveness.
|
|
13
36
|
- States are sibling frames: empty.tsx, filled.tsx, error.tsx, success.tsx.
|
|
14
|
-
-
|
|
37
|
+
- VERSIONS are sibling frames too - the scene is the surface, each frame one direction:
|
|
38
|
+
design/scenes/landing/a-terminal.tsx, landing/b-editorial.tsx, landing/c-product.tsx.
|
|
39
|
+
Layout and the sidebar follow frame-id order, so variants named under one scene with
|
|
40
|
+
a-/b-/c- prefixes stay adjacent and ordered through tidy and every device view.
|
|
41
|
+
Never spread versions across scenes (terminal/landing, editorial/landing) - they
|
|
42
|
+
interleave with everything else and the comparison falls apart.
|
|
43
|
+
- {{UI_GUIDANCE}}
|
|
44
|
+
{{NEXT_NOTES}}
|
|
15
45
|
- Navigation: put data-goto="scene/frame" on any element. That is the whole prototype system.
|
|
16
46
|
In play mode (the human presses P) frames swap in place inside one device - design flows
|
|
17
47
|
as complete graphs: every screen a data-goto points at should itself link somewhere or be
|
|
@@ -27,13 +57,14 @@ Never run or talk to the canvas tool; read and write files only.
|
|
|
27
57
|
convention). The root design/scenes/_layout.tsx is the app shell.
|
|
28
58
|
|
|
29
59
|
## Fixtures
|
|
30
|
-
- design/scenes/<scene>/_fixtures.ts - typed plain objects shaped like the
|
|
60
|
+
- design/scenes/<scene>/_fixtures.ts - typed plain objects shaped like the component's PROPS (containers map real APIs into them at promotion; see instructions/components.md).
|
|
31
61
|
- Frames import fixtures, never stores, never the network, never auth.
|
|
32
62
|
- Loading states are fixtures too: export const slowOrders = () => new Promise(r =>
|
|
33
63
|
setTimeout(() => r(orders), 800)) and let the frame render its skeleton while awaiting.
|
|
34
64
|
|
|
35
65
|
## Orientation
|
|
36
|
-
- design/manifest.json lists every frame (id, file, scene, title) - read it before
|
|
66
|
+
- design/manifest.json lists every frame (id, file, scene, title) - read it before
|
|
67
|
+
exploring. `init` writes the first one; `marver dev` keeps it fresh.
|
|
37
68
|
- Component galleries: create design/components/<name>/variants.tsx rendering each variant
|
|
38
69
|
and each state (default / hover-styled / focus / disabled / loading) of one ui component.
|
|
39
70
|
|
|
@@ -51,26 +82,7 @@ Never run or talk to the canvas tool; read and write files only.
|
|
|
51
82
|
|
|
52
83
|
## Boards (curated canvases)
|
|
53
84
|
|
|
54
|
-
A board is a saved canvas: `design/boards/<name>.json`
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
```json
|
|
60
|
-
{ "version": 1, "name": "checkout-compare", "auto": false,
|
|
61
|
-
"nodes": [ { "frame": "checkout-a/cart" }, { "frame": "checkout-b/cart" } ] }
|
|
62
|
-
```
|
|
63
|
-
|
|
64
|
-
- The same frame may appear on many boards, or twice on one board (e.g. two widths:
|
|
65
|
-
add `"w"`/`"h"` on a node to pin a size).
|
|
66
|
-
- `auto: false` boards show exactly their list. The `all-scenes` board is auto-managed -
|
|
67
|
-
never write it.
|
|
68
|
-
- Use boards for comparisons: version A vs B vs C of a flow, side by side.
|
|
69
|
-
- Boards are also the unit of PUBLISHING (`marver build --boards <name>`): a flow being
|
|
70
|
-
published must have every frame it data-gotos on that board - unlisted frames are
|
|
71
|
-
excluded from the bundle at build time. The published gate page shows the app's
|
|
72
|
-
identity: design/logo.svg + the host package name (overridable via config `share`).
|
|
73
|
-
If the app has no logo asset yet, create a simple design/logo.svg for it.
|
|
74
|
-
Leave `share.branding` ON unless the human explicitly asks to remove it: Marver is
|
|
75
|
-
free, the gate is already fully personalized to the app, and the small "Powered by"
|
|
76
|
-
line is how the tool spreads. Do not remove it as part of "branding polish".
|
|
85
|
+
A board is a saved canvas: `design/boards/<name>.json` - you create and manage them
|
|
86
|
+
by writing files; `all-scenes` is auto-managed, never write it. BEFORE creating a
|
|
87
|
+
board or publishing anything, read instructions/boards.md (file format, layout
|
|
88
|
+
durability, publishing rules).
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# Boards - curated canvases and publishing
|
|
2
|
+
|
|
3
|
+
A board is a saved canvas: `design/boards/<name>.json` (name: `^[a-z0-9][a-z0-9-]*$`).
|
|
4
|
+
The human switches boards in the sidebar; YOU create and manage them by writing files.
|
|
5
|
+
|
|
6
|
+
## The file
|
|
7
|
+
|
|
8
|
+
Minimal is enough - list the frames; the shell fills sizes from each frame's
|
|
9
|
+
viewport and lays it out:
|
|
10
|
+
|
|
11
|
+
```json
|
|
12
|
+
{ "version": 1, "name": "checkout-compare", "auto": false,
|
|
13
|
+
"nodes": [ { "frame": "checkout-a/cart" }, { "frame": "checkout-b/cart" } ] }
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
- The same frame may appear on many boards, or twice on one (add `"w"`/`"h"` on a
|
|
17
|
+
node to pin a size, `"x"`/`"y"` to place it - e.g. a comparison row: same `y`,
|
|
18
|
+
increasing `x`).
|
|
19
|
+
- The human's tidy (`t`) and device views re-layout in frame-id order, so id
|
|
20
|
+
ordering is the durable arrangement; explicit coordinates are one-off setups.
|
|
21
|
+
- `auto: false` boards show exactly their list. `all-scenes` is auto-managed -
|
|
22
|
+
never write it.
|
|
23
|
+
- Do not edit board files while the canvas is open unless asked; the shell owns
|
|
24
|
+
their layout fields.
|
|
25
|
+
- Use boards for comparisons: version A vs B vs C of a flow, side by side.
|
|
26
|
+
|
|
27
|
+
## Publishing
|
|
28
|
+
|
|
29
|
+
Boards are the unit of publishing (`marver build --boards <name>`): every frame a
|
|
30
|
+
published flow data-gotos must be ON that board - unlisted frames are excluded from
|
|
31
|
+
the bundle at build time.
|
|
32
|
+
|
|
33
|
+
The published gate page shows the app's identity: `design/logo.svg` + the host
|
|
34
|
+
package name (overridable via config `share`). If the app has no logo asset yet,
|
|
35
|
+
create a simple `design/logo.svg`. Leave `share.branding` ON unless the human
|
|
36
|
+
explicitly asks to remove it: marver is free, the gate is already personalized to
|
|
37
|
+
the app, and the small "Powered by" line is how the tool spreads. Do not remove it
|
|
38
|
+
as part of "branding polish".
|