@marver-design/marver 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/NOTICE +4 -0
- package/README.md +38 -0
- package/dist/build-D0GnIR4G.mjs +244 -0
- package/dist/cli.mjs +58 -0
- package/dist/config-DMBEpdEN.mjs +132 -0
- package/dist/dev-BK4x3PBr.mjs +76 -0
- package/dist/init-DcOy1krf.mjs +168 -0
- package/dist/plugin-DB5t2WUl.mjs +437 -0
- package/dist/serve-BPNmWeJx.mjs +200 -0
- package/package.json +59 -0
- package/src/client/const.ts +5 -0
- package/src/client/frame-host/bridge.js +37 -0
- package/src/client/frame-host/index.html +11 -0
- package/src/client/frame-host/main.tsx +73 -0
- package/src/client/frame-host/registry.ts +40 -0
- package/src/client/runtime/index.ts +8 -0
- package/src/client/shell/App.tsx +633 -0
- package/src/client/shell/Play.tsx +399 -0
- package/src/client/shell/Tip.tsx +43 -0
- package/src/client/shell/canvas/Canvas.tsx +285 -0
- package/src/client/shell/canvas/FrameNode.tsx +194 -0
- package/src/client/shell/favicon/android-chrome-192x192-interactive.png +0 -0
- package/src/client/shell/favicon/android-chrome-192x192.png +0 -0
- package/src/client/shell/favicon/android-chrome-512x512-interactive.png +0 -0
- package/src/client/shell/favicon/android-chrome-512x512.png +0 -0
- package/src/client/shell/favicon/apple-touch-icon-interactive.png +0 -0
- package/src/client/shell/favicon/apple-touch-icon.png +0 -0
- package/src/client/shell/favicon/favicon-16x16-interactive.png +0 -0
- package/src/client/shell/favicon/favicon-16x16.png +0 -0
- package/src/client/shell/favicon/favicon-32x32-interactive.png +0 -0
- package/src/client/shell/favicon/favicon-32x32.png +0 -0
- package/src/client/shell/favicon/favicon.ico +0 -0
- package/src/client/shell/favicon/site.webmanifest +11 -0
- package/src/client/shell/hash.ts +67 -0
- package/src/client/shell/icons.tsx +53 -0
- package/src/client/shell/index.html +17 -0
- package/src/client/shell/main.tsx +9 -0
- package/src/client/shell/store.ts +540 -0
- package/src/client/shell/styles.css +508 -0
- package/src/client/shell/tidy.ts +24 -0
- package/src/client/stage/index.html +11 -0
- package/src/client/stage/main.tsx +160 -0
- package/templates/AGENTS-embedded.md +76 -0
- package/templates/AGENTS-studio.md +76 -0
- package/templates/demo/_fixtures.ts +9 -0
- package/templates/demo/dark.tsx +17 -0
- package/templates/demo/form.tsx +51 -0
- package/templates/demo/welcome.tsx +21 -0
- package/templates/design-tsconfig.json +8 -0
- package/templates/root-layout.tsx +7 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The stage - play mode's single mount (SPEC-M2 §1). Unlike the frame host (one iframe
|
|
3
|
+
* per frame), the stage keeps ONE tree mounted - providers + layout chain - and swaps
|
|
4
|
+
* only the innermost frame on data-goto, so app shells in _layout persist across
|
|
5
|
+
* navigation like a real app. Swaps ride document.startViewTransition when available;
|
|
6
|
+
* agents opt into shared-element morphs with plain view-transition-name CSS.
|
|
7
|
+
*
|
|
8
|
+
* The shell owns chrome, device sizing, walk order, and the URL; the stage owns data-goto:
|
|
9
|
+
* stage -> shell: sh:stage-ready · sh:stage-at {at} · sh:stage-exit · sh:stage-error
|
|
10
|
+
* sh:stage-key {key, code} (forwarded shortcuts)
|
|
11
|
+
* shell -> stage: sh:stage-set {at} (history / walk / restart) · sh:set-theme
|
|
12
|
+
*/
|
|
13
|
+
import { Component, createElement, useEffect, useRef, useState, type ComponentType, type ReactNode } from 'react'
|
|
14
|
+
import { flushSync } from 'react-dom'
|
|
15
|
+
import { createRoot } from 'react-dom/client'
|
|
16
|
+
import { frameFile, frames, layoutChain, layouts, providers } from '../frame-host/registry.ts'
|
|
17
|
+
|
|
18
|
+
const params = new URLSearchParams(location.search)
|
|
19
|
+
document.documentElement.dataset.theme = params.get('theme') ?? 'light'
|
|
20
|
+
const startId = params.get('at') ?? ''
|
|
21
|
+
|
|
22
|
+
const post = (msg: Record<string, unknown>) => { if (window.parent !== window) window.parent.postMessage(msg, '*') }
|
|
23
|
+
|
|
24
|
+
window.addEventListener('error', (e) => post({ type: 'sh:stage-error', message: String(e.message || e.error) }))
|
|
25
|
+
window.addEventListener('unhandledrejection', (e) => post({ type: 'sh:stage-error', message: `unhandled rejection: ${e.reason}` }))
|
|
26
|
+
// pinch inside the stage must not zoom the parent page (same rule as the frame bridge)
|
|
27
|
+
window.addEventListener('wheel', (e) => { if (e.ctrlKey || e.metaKey) e.preventDefault() }, { passive: false })
|
|
28
|
+
document.addEventListener('gesturestart', (e) => e.preventDefault())
|
|
29
|
+
|
|
30
|
+
interface Mounted { id: string; Frame: ComponentType; wrappers: ComponentType[] }
|
|
31
|
+
|
|
32
|
+
/** Resolve a frame id to its component + wrapper chain. Modules are import()-cached by
|
|
33
|
+
* Vite, so re-resolving a chain yields the SAME component references - React keeps
|
|
34
|
+
* unchanged layout instances mounted across swaps, which is the whole point. */
|
|
35
|
+
async function resolve(id: string): Promise<Mounted> {
|
|
36
|
+
const fileKey = frameFile(id)
|
|
37
|
+
if (!fileKey) throw new Error(`unknown frame id "${id}"`)
|
|
38
|
+
const mod: any = await frames[fileKey]()
|
|
39
|
+
if (mod.default == null) throw new Error(`${fileKey} has no default export`)
|
|
40
|
+
const wrappers: ComponentType[] = []
|
|
41
|
+
const providerKey = Object.keys(providers)[0]
|
|
42
|
+
if (providerKey) wrappers.push((await providers[providerKey]() as any).default)
|
|
43
|
+
for (const lk of layoutChain(fileKey)) wrappers.push((await layouts[lk]() as any).default)
|
|
44
|
+
return { id, Frame: mod.default, wrappers: wrappers.filter((w) => w != null) }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
class Boundary extends Component<{ resetKey: string; children?: ReactNode }, { err: Error | null }> {
|
|
48
|
+
state = { err: null as Error | null }
|
|
49
|
+
static getDerivedStateFromError(err: Error) { return { err } }
|
|
50
|
+
componentDidUpdate(prev: { resetKey: string }) {
|
|
51
|
+
if (prev.resetKey !== this.props.resetKey && this.state.err) this.setState({ err: null })
|
|
52
|
+
}
|
|
53
|
+
componentDidCatch(err: Error) { post({ type: 'sh:stage-error', message: err.message }) }
|
|
54
|
+
render() {
|
|
55
|
+
if (!this.state.err) return this.props.children
|
|
56
|
+
return createElement(ErrorCard, { title: 'frame crashed', message: this.state.err.message, detail: this.props.resetKey })
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function ErrorCard({ title, message, detail }: { title: string; message: string; detail: string }) {
|
|
61
|
+
return createElement('div', { style: { fontFamily: 'ui-monospace,monospace', fontSize: 12, padding: 16, color: '#ff8a80' } },
|
|
62
|
+
createElement('div', { style: { fontWeight: 700, marginBottom: 8 } }, title),
|
|
63
|
+
createElement('div', { style: { whiteSpace: 'pre-wrap' } }, message),
|
|
64
|
+
createElement('div', { style: { marginTop: 8, color: '#7c859a' } }, detail),
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function Stage() {
|
|
69
|
+
const [mounted, setMounted] = useState<Mounted | null>(null)
|
|
70
|
+
const [err, setErr] = useState<{ id: string; message: string } | null>(null)
|
|
71
|
+
const current = useRef(startId)
|
|
72
|
+
const swapSeq = useRef(0)
|
|
73
|
+
|
|
74
|
+
/** Swap to a frame. `announce` posts sh:stage-at (user-driven); history restores stay silent. */
|
|
75
|
+
const goto = async (id: string, announce: boolean) => {
|
|
76
|
+
if (id === current.current && mounted && !err) return
|
|
77
|
+
const seq = ++swapSeq.current
|
|
78
|
+
try {
|
|
79
|
+
const next = await resolve(id)
|
|
80
|
+
if (seq !== swapSeq.current) return // a newer swap superseded this one
|
|
81
|
+
current.current = id
|
|
82
|
+
// startViewTransition runs its callback async - recheck the seq there too, or an
|
|
83
|
+
// older pending transition could commit stale state over a newer navigation
|
|
84
|
+
const apply = () => { if (seq === swapSeq.current) flushSync(() => { setErr(null); setMounted(next) }) }
|
|
85
|
+
if (document.startViewTransition) document.startViewTransition(apply)
|
|
86
|
+
else { apply(); document.getElementById('root')?.animate([{ opacity: 0.35 }, { opacity: 1 }], { duration: 180, easing: 'ease-out' }) }
|
|
87
|
+
if (announce) post({ type: 'sh:stage-at', at: id })
|
|
88
|
+
} catch (e) {
|
|
89
|
+
if (seq !== swapSeq.current) return
|
|
90
|
+
current.current = id
|
|
91
|
+
setErr({ id, message: (e as Error).message })
|
|
92
|
+
post({ type: 'sh:stage-error', message: (e as Error).message })
|
|
93
|
+
if (announce) post({ type: 'sh:stage-at', at: id })
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
goto(startId, false)
|
|
99
|
+
post({ type: 'sh:stage-ready', at: startId })
|
|
100
|
+
|
|
101
|
+
// data-goto is handled HERE, in place - never posted up as sh:go (capture phase
|
|
102
|
+
// beats any frame handler; preventDefault stops real <a href> navigations)
|
|
103
|
+
const onClick = (e: MouseEvent) => {
|
|
104
|
+
const el = e.target instanceof Element ? e.target.closest('[data-goto]') : null
|
|
105
|
+
if (!el) return
|
|
106
|
+
e.preventDefault()
|
|
107
|
+
e.stopPropagation()
|
|
108
|
+
const target = el.getAttribute('data-goto')
|
|
109
|
+
if (target) goto(target, true)
|
|
110
|
+
}
|
|
111
|
+
document.addEventListener('click', onClick, true)
|
|
112
|
+
|
|
113
|
+
const onKey = (e: KeyboardEvent) => {
|
|
114
|
+
// Escape always exits, even mid-typing (matches the canvas bridge)
|
|
115
|
+
if (e.key === 'Escape') { post({ type: 'sh:stage-exit' }); return }
|
|
116
|
+
if ((e.metaKey || e.ctrlKey) && e.key === '/') { e.preventDefault(); post({ type: 'sh:stage-key', key: '/', code: e.code, meta: true }); return }
|
|
117
|
+
if (e.metaKey || e.ctrlKey) return // ⌘D is the browser's bookmark, not our theme
|
|
118
|
+
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return
|
|
119
|
+
// every play shortcut belongs to the shell (it owns walk order + chrome) - forward
|
|
120
|
+
if (/^Digit[0-9]$/.test(e.code) || ['d', 'h', 'r', 'ArrowRight', 'ArrowLeft'].includes(e.key))
|
|
121
|
+
post({ type: 'sh:stage-key', key: e.key, code: e.code })
|
|
122
|
+
}
|
|
123
|
+
window.addEventListener('keydown', onKey)
|
|
124
|
+
|
|
125
|
+
const onMsg = (e: MessageEvent) => {
|
|
126
|
+
if (e.source !== window.parent) return
|
|
127
|
+
const data = e.data
|
|
128
|
+
if (data?.type === 'sh:set-theme') document.documentElement.dataset.theme = data.theme
|
|
129
|
+
else if (data?.type === 'sh:stage-set' && typeof data.at === 'string') goto(data.at, false)
|
|
130
|
+
}
|
|
131
|
+
window.addEventListener('message', onMsg)
|
|
132
|
+
|
|
133
|
+
return () => {
|
|
134
|
+
document.removeEventListener('click', onClick, true)
|
|
135
|
+
window.removeEventListener('keydown', onKey)
|
|
136
|
+
window.removeEventListener('message', onMsg)
|
|
137
|
+
}
|
|
138
|
+
}, [])
|
|
139
|
+
|
|
140
|
+
if (err) return createElement(ErrorCard, { title: 'frame failed', message: err.message, detail: err.id })
|
|
141
|
+
if (!mounted) return null
|
|
142
|
+
// identical wrapper references across swaps -> React preserves layout state; a chain
|
|
143
|
+
// that differs at depth k legitimately remounts everything below (SPEC-M2 §1)
|
|
144
|
+
let tree: ReactNode = createElement(mounted.Frame)
|
|
145
|
+
for (const W of [...mounted.wrappers].reverse()) tree = createElement(W, null, tree)
|
|
146
|
+
return createElement(Boundary, { resetKey: mounted.id }, tree)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function boot() {
|
|
150
|
+
try {
|
|
151
|
+
await import('virtual:sh-theme' as string)
|
|
152
|
+
createRoot(document.getElementById('root')!).render(createElement(Stage))
|
|
153
|
+
} catch (err) {
|
|
154
|
+
post({ type: 'sh:stage-error', message: (err as Error).message })
|
|
155
|
+
document.getElementById('root')!.innerHTML =
|
|
156
|
+
`<div style="font-family:ui-monospace,monospace;font-size:12px;padding:16px;color:#ff8a80">stage failed: ${String((err as Error).message).replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`)}</div>`
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
boot()
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# Design canvas - agent contract (embedded mode)
|
|
2
|
+
|
|
3
|
+
You design by writing files. The canvas at the printed localhost URL reflects them live.
|
|
4
|
+
Never run or talk to the canvas tool; read and write files only.
|
|
5
|
+
|
|
6
|
+
## Frames
|
|
7
|
+
- A frame = one file: design/scenes/<scene>/<name>.tsx or .html. One frame, one surface.
|
|
8
|
+
- It default-exports a React component. No imports from the tool are needed. Optional:
|
|
9
|
+
export const meta = { title: "...", viewport: "mobile" } // literal values only
|
|
10
|
+
// viewport names come from design/config.ts (default: mobile, tablet, laptop, monitor;
|
|
11
|
+
// tv available commented-out). Pick the one the screen is designed for - the human can
|
|
12
|
+
// flip the whole board to any device (Devices menu, hotkeys 0-5) to check responsiveness.
|
|
13
|
+
- States are sibling frames: empty.tsx, filled.tsx, error.tsx, success.tsx.
|
|
14
|
+
- Use the app's UI: import from {{UI_ALIAS}}; style with the app's Tailwind classes.
|
|
15
|
+
- Navigation: put data-goto="scene/frame" on any element. That is the whole prototype system.
|
|
16
|
+
In play mode (the human presses P) frames swap in place inside one device - design flows
|
|
17
|
+
as complete graphs: every screen a data-goto points at should itself link somewhere or be
|
|
18
|
+
a terminal state; play mode makes dead ends visible. Give an element the same
|
|
19
|
+
view-transition-name CSS in two frames and play mode morphs it between screens.
|
|
20
|
+
- Files starting with _ are infrastructure (never frames): _layout.tsx, _fixtures.ts.
|
|
21
|
+
|
|
22
|
+
## Structure ladder (embedded mode: screens live in src/)
|
|
23
|
+
1. First pass: write the whole page inline in the frame file. Diverge fast.
|
|
24
|
+
2. When a direction wins: extract the screen into src/features/<feature>/<Name>.tsx as a
|
|
25
|
+
presentational component (props in, JSX out - no hooks into stores or the network),
|
|
26
|
+
and shrink the frame to a ~5-line harness mounting it with fixtures.
|
|
27
|
+
3. Layouts: design/scenes/<scene>/_layout.tsx wraps every frame in the scene (Next.js
|
|
28
|
+
convention). The root design/scenes/_layout.tsx mounts the app's real shell component.
|
|
29
|
+
|
|
30
|
+
## Fixtures
|
|
31
|
+
- design/scenes/<scene>/_fixtures.ts - typed plain objects shaped like the future API.
|
|
32
|
+
- Fixture shapes should match the component's props so tsc catches drift.
|
|
33
|
+
- Loading states are fixtures too: export const slowOrders = () => new Promise(r =>
|
|
34
|
+
setTimeout(() => r(orders), 800)) and let the frame render its skeleton while awaiting.
|
|
35
|
+
|
|
36
|
+
## Orientation
|
|
37
|
+
- design/manifest.json lists every frame (id, file, scene, title) - read it before exploring.
|
|
38
|
+
- Component galleries: create design/components/<name>/variants.tsx rendering each variant
|
|
39
|
+
and each state (default / hover-styled / focus / disabled / loading) of one ui component.
|
|
40
|
+
|
|
41
|
+
## Rules
|
|
42
|
+
- Do not edit design/boards/*.json while the canvas is open unless asked; the shell owns them.
|
|
43
|
+
- Do not import from "design/" inside src/ or app/. The arrow points one way.
|
|
44
|
+
- Do not add network calls, app stores, or auth to frames or the presentational screens.
|
|
45
|
+
- A scene may not be named "components" or "screens".
|
|
46
|
+
|
|
47
|
+
## Handoff (when a design is approved)
|
|
48
|
+
- The screen already lives in src/features/. Implementation wires routes, data, and handlers
|
|
49
|
+
into the same component - fixtures are the only thing replaced. The frame stays, so the
|
|
50
|
+
canvas remains living documentation of every screen and state.
|
|
51
|
+
|
|
52
|
+
## Boards (curated canvases)
|
|
53
|
+
|
|
54
|
+
A board is a saved canvas: `design/boards/<name>.json` (name: `^[a-z0-9][a-z0-9-]*$`).
|
|
55
|
+
The human switches boards in the sidebar; YOU create and manage them by writing files.
|
|
56
|
+
Minimal file - just list the frames; the shell fills sizes from each frame's viewport,
|
|
57
|
+
lays it out, and keeps it tidy:
|
|
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".
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# Design canvas - agent contract
|
|
2
|
+
|
|
3
|
+
You design by writing files. The canvas at the printed localhost URL reflects them live.
|
|
4
|
+
Never run or talk to the canvas tool; read and write files only.
|
|
5
|
+
|
|
6
|
+
## Frames
|
|
7
|
+
- A frame = one file: design/scenes/<scene>/<name>.tsx or .html. One frame, one surface.
|
|
8
|
+
- It default-exports a React component. No imports from the tool are needed. Optional:
|
|
9
|
+
export const meta = { title: "...", viewport: "mobile" } // literal values only
|
|
10
|
+
// viewport names come from design/config.ts (default: mobile, tablet, laptop, monitor;
|
|
11
|
+
// tv available commented-out). Pick the one the screen is designed for - the human can
|
|
12
|
+
// flip the whole board to any device (Devices menu, hotkeys 0-5) to check responsiveness.
|
|
13
|
+
- States are sibling frames: empty.tsx, filled.tsx, error.tsx, success.tsx.
|
|
14
|
+
- Use the app's UI: import from {{UI_ALIAS}}; style with the app's Tailwind classes.
|
|
15
|
+
- Navigation: put data-goto="scene/frame" on any element. That is the whole prototype system.
|
|
16
|
+
In play mode (the human presses P) frames swap in place inside one device - design flows
|
|
17
|
+
as complete graphs: every screen a data-goto points at should itself link somewhere or be
|
|
18
|
+
a terminal state; play mode makes dead ends visible. Give an element the same
|
|
19
|
+
view-transition-name CSS in two frames and play mode morphs it between screens.
|
|
20
|
+
- Files starting with _ are infrastructure (never frames): _layout.tsx, _fixtures.ts.
|
|
21
|
+
|
|
22
|
+
## Structure ladder
|
|
23
|
+
1. First pass: write the whole page inline in the frame file. Diverge fast.
|
|
24
|
+
2. When a direction wins: extract shared markup into design/screens/<Name>.tsx and
|
|
25
|
+
shrink frames to harnesses that mount it with fixtures.
|
|
26
|
+
3. Layouts: design/scenes/<scene>/_layout.tsx wraps every frame in the scene (Next.js
|
|
27
|
+
convention). The root design/scenes/_layout.tsx is the app shell.
|
|
28
|
+
|
|
29
|
+
## Fixtures
|
|
30
|
+
- design/scenes/<scene>/_fixtures.ts - typed plain objects shaped like the future API.
|
|
31
|
+
- Frames import fixtures, never stores, never the network, never auth.
|
|
32
|
+
- Loading states are fixtures too: export const slowOrders = () => new Promise(r =>
|
|
33
|
+
setTimeout(() => r(orders), 800)) and let the frame render its skeleton while awaiting.
|
|
34
|
+
|
|
35
|
+
## Orientation
|
|
36
|
+
- design/manifest.json lists every frame (id, file, scene, title) - read it before exploring.
|
|
37
|
+
- Component galleries: create design/components/<name>/variants.tsx rendering each variant
|
|
38
|
+
and each state (default / hover-styled / focus / disabled / loading) of one ui component.
|
|
39
|
+
|
|
40
|
+
## Rules
|
|
41
|
+
- Do not edit design/boards/*.json while the canvas is open unless asked; the shell owns them.
|
|
42
|
+
- Do not import from "design/" inside src/ or app/. The arrow points one way.
|
|
43
|
+
- Do not add network calls, app stores, or auth to frames. Mocked data only.
|
|
44
|
+
- Keep each frame self-sufficient: it must render from its file + fixtures + ui imports alone.
|
|
45
|
+
- A scene may not be named "components" or "screens".
|
|
46
|
+
|
|
47
|
+
## Promotion (when a design is approved)
|
|
48
|
+
- Move the screen from design/screens/ into the app (src/features/...), replace fixture
|
|
49
|
+
props with live data/handlers, replace data-goto with the router's navigation.
|
|
50
|
+
- Leave the frame in place, importing from its new home, so the canvas stays true.
|
|
51
|
+
|
|
52
|
+
## Boards (curated canvases)
|
|
53
|
+
|
|
54
|
+
A board is a saved canvas: `design/boards/<name>.json` (name: `^[a-z0-9][a-z0-9-]*$`).
|
|
55
|
+
The human switches boards in the sidebar; YOU create and manage them by writing files.
|
|
56
|
+
Minimal file - just list the frames; the shell fills sizes from each frame's viewport,
|
|
57
|
+
lays it out, and keeps it tidy:
|
|
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".
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Typed mock data, shaped like the future API. Frames import from here, never the network.
|
|
2
|
+
export const fx = {
|
|
3
|
+
user: { name: 'Alex Rivera', email: 'alex@example.com' },
|
|
4
|
+
items: [
|
|
5
|
+
{ id: 1, name: 'Aeris Lamp', price: '$39.00' },
|
|
6
|
+
{ id: 2, name: 'Shipping', price: '$9.00' },
|
|
7
|
+
],
|
|
8
|
+
total: '$48.00',
|
|
9
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Demo frame: an error state, dark-styled. Sibling frames = states (spec convention).
|
|
2
|
+
export const meta = { title: 'Demo - error state', viewport: 'mobile' }
|
|
3
|
+
|
|
4
|
+
export default function DarkError() {
|
|
5
|
+
return (
|
|
6
|
+
<div style={{ fontFamily: 'system-ui', minHeight: '100vh', background: '#12151f', color: '#e7eaf2', padding: 24, display: 'flex', flexDirection: 'column', gap: 12 }}>
|
|
7
|
+
<b style={{ fontSize: 16 }}>‹ Checkout</b>
|
|
8
|
+
<div style={{ background: '#2a1215', border: '1px solid #5c2320', borderRadius: 8, padding: '12px 14px', color: '#f0776b', fontSize: 14, lineHeight: 1.5 }}>
|
|
9
|
+
<b style={{ display: 'block', color: '#f9a8a0' }}>Card declined</b>
|
|
10
|
+
Your bank rejected the charge. No money moved.
|
|
11
|
+
</div>
|
|
12
|
+
<button data-goto="demo/form" style={{ marginTop: 'auto', padding: '12px 16px', borderRadius: 8, border: '1px solid #363d4f', background: 'transparent', color: '#e7eaf2', fontWeight: 600, cursor: 'pointer' }}>
|
|
13
|
+
Try another card
|
|
14
|
+
</button>
|
|
15
|
+
</div>
|
|
16
|
+
)
|
|
17
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Demo frame: proves the resize gesture (real breakpoint at 768px) and interact mode (native <dialog>).
|
|
2
|
+
import { useRef, useState } from 'react'
|
|
3
|
+
import { fx } from './_fixtures.ts'
|
|
4
|
+
|
|
5
|
+
export const meta = { title: 'Demo - form', viewport: 'laptop' }
|
|
6
|
+
|
|
7
|
+
export default function Form() {
|
|
8
|
+
const dlg = useRef<HTMLDialogElement>(null)
|
|
9
|
+
const [email, setEmail] = useState(fx.user.email)
|
|
10
|
+
return (
|
|
11
|
+
<div style={{ fontFamily: 'system-ui', minHeight: '100vh', padding: 24 }}>
|
|
12
|
+
<style>{`
|
|
13
|
+
.demo-grid { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 12px }
|
|
14
|
+
.demo-nav-links { display: flex; gap: 16px }
|
|
15
|
+
.demo-burger { display: none }
|
|
16
|
+
@media (max-width: 768px) {
|
|
17
|
+
.demo-grid { grid-template-columns: 1fr }
|
|
18
|
+
.demo-nav-links { display: none }
|
|
19
|
+
.demo-burger { display: block }
|
|
20
|
+
}
|
|
21
|
+
.demo-card { border: 1px solid #e4e8f0; border-radius: 10px; padding: 16px }
|
|
22
|
+
.demo-in { width: 100%; padding: 10px 12px; border: 1px solid #d4d9e4; border-radius: 8px; font-size: 14px }
|
|
23
|
+
.demo-in:focus { outline: 2px solid #2440c4; outline-offset: 1px }
|
|
24
|
+
.demo-btn { padding: 10px 16px; border-radius: 8px; border: 0; background: #111; color: #fff; font-weight: 600; cursor: pointer }
|
|
25
|
+
.demo-btn:hover { opacity: .85 }
|
|
26
|
+
`}</style>
|
|
27
|
+
<nav style={{ display: 'flex', alignItems: 'center', gap: 16, marginBottom: 20 }}>
|
|
28
|
+
<span className="demo-burger">☰</span>
|
|
29
|
+
<b>◆ Demo</b>
|
|
30
|
+
<span className="demo-nav-links" style={{ color: '#667085', fontSize: 14 }}><span>Shop</span><span>Journal</span><span>About</span></span>
|
|
31
|
+
</nav>
|
|
32
|
+
<div className="demo-grid">
|
|
33
|
+
{fx.items.map((it) => (
|
|
34
|
+
<div key={it.id} className="demo-card"><b>{it.name}</b><div style={{ color: '#667085' }}>{it.price}</div></div>
|
|
35
|
+
))}
|
|
36
|
+
<div className="demo-card">
|
|
37
|
+
<label style={{ fontSize: 13, color: '#667085' }}>Email
|
|
38
|
+
<input className="demo-in" value={email} onChange={(e) => setEmail(e.target.value)} style={{ marginTop: 6 }} />
|
|
39
|
+
</label>
|
|
40
|
+
<button className="demo-btn" style={{ marginTop: 12 }} onClick={() => dlg.current?.showModal()}>Open dialog</button>
|
|
41
|
+
</div>
|
|
42
|
+
</div>
|
|
43
|
+
<p style={{ color: '#98a2b3', fontSize: 13, marginTop: 20 }}>Drag this frame's edge across 768px - the nav collapses to a burger and the grid drops to one column. Real CSS, real viewport.</p>
|
|
44
|
+
<dialog ref={dlg} style={{ border: '1px solid #e4e8f0', borderRadius: 12, padding: 24, maxWidth: 320 }}>
|
|
45
|
+
<b>It is a real dialog</b>
|
|
46
|
+
<p style={{ color: '#667085', fontSize: 14 }}>Typing, focus, and click all work in interact mode. Press Escape to close it, Escape again to leave interact mode.</p>
|
|
47
|
+
<button className="demo-btn" onClick={() => dlg.current?.close()}>Close</button>
|
|
48
|
+
</dialog>
|
|
49
|
+
</div>
|
|
50
|
+
)
|
|
51
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Demo frame: deletable. Shows meta, fixtures, data-goto. Styled inline so it works in any repo.
|
|
2
|
+
import { fx } from './_fixtures.ts'
|
|
3
|
+
|
|
4
|
+
export const meta = { title: 'Demo - welcome', viewport: 'mobile' }
|
|
5
|
+
|
|
6
|
+
const s = {
|
|
7
|
+
wrap: { fontFamily: 'system-ui', minHeight: '100vh', display: 'flex', flexDirection: 'column', padding: 24, gap: 12, background: 'var(--background, #fff)', color: 'var(--foreground, #111)' },
|
|
8
|
+
h: { fontSize: 28, fontWeight: 700, letterSpacing: '-0.02em', margin: 0 },
|
|
9
|
+
p: { color: '#667085', margin: 0, lineHeight: 1.5 },
|
|
10
|
+
btn: { marginTop: 'auto', padding: '12px 16px', borderRadius: 8, border: 0, background: '#111', color: '#fff', fontWeight: 600, fontSize: 15, cursor: 'pointer' },
|
|
11
|
+
} as const
|
|
12
|
+
|
|
13
|
+
export default function Welcome() {
|
|
14
|
+
return (
|
|
15
|
+
<div style={s.wrap}>
|
|
16
|
+
<h1 style={s.h}>Hey {fx.user.name.split(' ')[0]} 👋</h1>
|
|
17
|
+
<p style={s.p}>This frame is a plain file: design/scenes/demo/welcome.tsx. Edit it and watch the canvas update. Double-click the frame to interact.</p>
|
|
18
|
+
<button style={s.btn} data-goto="demo/form">Continue → (data-goto)</button>
|
|
19
|
+
</div>
|
|
20
|
+
)
|
|
21
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// The app shell for every scene. Replace the placeholder with your real layout
|
|
2
|
+
// (sidebar, nav) once it exists - frames then render inside it automatically.
|
|
3
|
+
import type { ReactNode } from 'react'
|
|
4
|
+
|
|
5
|
+
export default function RootLayout({ children }: { children: ReactNode }) {
|
|
6
|
+
return <>{children}</>
|
|
7
|
+
}
|