@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
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@marver-design/marver",
|
|
3
|
+
"version": "0.2.0",
|
|
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
|
+
"type": "module",
|
|
6
|
+
"private": false,
|
|
7
|
+
"license": "Apache-2.0",
|
|
8
|
+
"bin": {
|
|
9
|
+
"marver": "./dist/cli.mjs"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"dist",
|
|
13
|
+
"src/client",
|
|
14
|
+
"templates",
|
|
15
|
+
"README.md",
|
|
16
|
+
"LICENSE",
|
|
17
|
+
"NOTICE"
|
|
18
|
+
],
|
|
19
|
+
"exports": {
|
|
20
|
+
"./runtime": "./src/client/runtime/index.ts",
|
|
21
|
+
"./package.json": "./package.json"
|
|
22
|
+
},
|
|
23
|
+
"scripts": {
|
|
24
|
+
"build": "tsdown",
|
|
25
|
+
"test": "vitest run",
|
|
26
|
+
"smoke": "node scripts/smoke.mjs",
|
|
27
|
+
"typecheck": "tsc --noEmit"
|
|
28
|
+
},
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"@vitejs/plugin-react": "^6.0.0",
|
|
31
|
+
"cac": "^7.0.0",
|
|
32
|
+
"react-zoom-pan-pinch": "^3.6.1",
|
|
33
|
+
"vite": "^8.0.0",
|
|
34
|
+
"zustand": "^5.0.0"
|
|
35
|
+
},
|
|
36
|
+
"peerDependencies": {
|
|
37
|
+
"react": "^18.0.0 || ^19.0.0",
|
|
38
|
+
"react-dom": "^18.0.0 || ^19.0.0"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@types/node": "^22.0.0",
|
|
42
|
+
"@types/react": "^19.0.0",
|
|
43
|
+
"@types/react-dom": "^19.0.0",
|
|
44
|
+
"tsdown": "^0.22.0",
|
|
45
|
+
"typescript": "^5.7.0",
|
|
46
|
+
"vitest": "^3.0.0"
|
|
47
|
+
},
|
|
48
|
+
"engines": {
|
|
49
|
+
"node": ">=22.18.0"
|
|
50
|
+
},
|
|
51
|
+
"homepage": "https://marver.design",
|
|
52
|
+
"repository": {
|
|
53
|
+
"type": "git",
|
|
54
|
+
"url": "git+https://github.com/TNEP4/marver.git"
|
|
55
|
+
},
|
|
56
|
+
"publishConfig": {
|
|
57
|
+
"access": "public"
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** Client-safe constants. The single source - src/cli/name.ts re-exports for node-side code.
|
|
2
|
+
* Lives under src/client because that is the only source directory shipped in the package. */
|
|
3
|
+
export const NAME = 'marver'
|
|
4
|
+
export const PKG = '@marver-design/marver' // registry identity; bin stays `marver`
|
|
5
|
+
export const ROUTE = '/__mv'
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Shared frame bridge. TSX frames import it plainly; the plugin injects it into HTML frames
|
|
2
|
+
// with ?html=1, which turns on theme-from-query and auto-ready (TSX posts its own ready after boot).
|
|
3
|
+
const isHtmlFrame = new URL(import.meta.url).searchParams.get('html') === '1'
|
|
4
|
+
const post = (msg) => { if (window.parent !== window) window.parent.postMessage(msg, '*') }
|
|
5
|
+
const id = new URLSearchParams(location.search).get('id') ?? location.pathname
|
|
6
|
+
|
|
7
|
+
if (isHtmlFrame) {
|
|
8
|
+
const theme = new URLSearchParams(location.search).get('theme')
|
|
9
|
+
if (theme) document.documentElement.dataset.theme = theme
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
document.addEventListener('click', (e) => {
|
|
13
|
+
const el = e.target instanceof Element ? e.target.closest('[data-goto]') : null
|
|
14
|
+
if (!el) return
|
|
15
|
+
e.preventDefault()
|
|
16
|
+
const target = el.getAttribute('data-goto')
|
|
17
|
+
if (target) post({ type: 'sh:go', target })
|
|
18
|
+
}, true)
|
|
19
|
+
|
|
20
|
+
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') post({ type: 'sh:exit-interact' }) })
|
|
21
|
+
|
|
22
|
+
window.addEventListener('error', (e) => post({ type: 'sh:error', id, message: String(e.message || e.error) }))
|
|
23
|
+
window.addEventListener('unhandledrejection', (e) => post({ type: 'sh:error', id, message: `unhandled rejection: ${e.reason}` }))
|
|
24
|
+
|
|
25
|
+
window.addEventListener('message', (e) => {
|
|
26
|
+
if (e?.data?.type === 'sh:set-theme') document.documentElement.dataset.theme = e.data.theme
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
if (isHtmlFrame) {
|
|
30
|
+
const ready = () => post({ type: 'sh:ready', id })
|
|
31
|
+
document.readyState === 'loading' ? addEventListener('DOMContentLoaded', ready) : ready()
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// pinch inside a frame must not zoom the parent PAGE (wheel events here belong to the
|
|
35
|
+
// iframe's document, so the shell's blocker cannot see them). Keyboard cmd +/- untouched.
|
|
36
|
+
window.addEventListener('wheel', (e) => { if (e.ctrlKey || e.metaKey) e.preventDefault() }, { passive: false })
|
|
37
|
+
document.addEventListener('gesturestart', (e) => e.preventDefault())
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The frame host - runs inside every iframe. Spec §6.
|
|
3
|
+
* Boot failures (theme, providers, layouts, the frame itself) render a plain-DOM error card
|
|
4
|
+
* and post sh:error; an ErrorBoundary catches render-time throws the same way.
|
|
5
|
+
*/
|
|
6
|
+
import { Component, createElement, type ReactNode } from 'react'
|
|
7
|
+
import { createRoot } from 'react-dom/client'
|
|
8
|
+
|
|
9
|
+
import './bridge.js'
|
|
10
|
+
import { frameFile, frames, layoutChain, layouts, providers } from './registry.ts'
|
|
11
|
+
|
|
12
|
+
const params = new URLSearchParams(location.search)
|
|
13
|
+
const id = params.get('id') ?? ''
|
|
14
|
+
const theme = params.get('theme') ?? 'light'
|
|
15
|
+
document.documentElement.dataset.theme = theme
|
|
16
|
+
|
|
17
|
+
const post = (msg: Record<string, unknown>) => { if (window.parent !== window) window.parent.postMessage(msg, '*') }
|
|
18
|
+
|
|
19
|
+
function fail(message: string) {
|
|
20
|
+
post({ type: 'sh:error', id, message })
|
|
21
|
+
document.getElementById('root')!.innerHTML =
|
|
22
|
+
`<div style="font-family:ui-monospace,monospace;font-size:12px;padding:16px;color:#b42318">
|
|
23
|
+
<div style="font-weight:700;margin-bottom:8px">frame failed</div>
|
|
24
|
+
<div style="white-space:pre-wrap">${escapeHtml(message)}</div>
|
|
25
|
+
<div style="margin-top:8px;color:#7c859a">${escapeHtml(frameFile(id) ?? id)}</div>
|
|
26
|
+
</div>`
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const escapeHtml = (s: string) => s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`)
|
|
30
|
+
|
|
31
|
+
class Boundary extends Component<{ children: ReactNode }, { err: Error | null }> {
|
|
32
|
+
state = { err: null as Error | null }
|
|
33
|
+
static getDerivedStateFromError(err: Error) { return { err } }
|
|
34
|
+
componentDidCatch(err: Error) { post({ type: 'sh:error', id, message: err.message }) }
|
|
35
|
+
render() {
|
|
36
|
+
if (!this.state.err) return this.props.children
|
|
37
|
+
return createElement('div', { style: { fontFamily: 'ui-monospace,monospace', fontSize: 12, padding: 16, color: '#b42318' } },
|
|
38
|
+
createElement('div', { style: { fontWeight: 700, marginBottom: 8 } }, 'frame crashed'),
|
|
39
|
+
createElement('div', { style: { whiteSpace: 'pre-wrap' } }, this.state.err.message),
|
|
40
|
+
createElement('div', { style: { marginTop: 8, color: '#7c859a' } }, frameFile(id) ?? id),
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function boot() {
|
|
46
|
+
try {
|
|
47
|
+
await import('virtual:sh-theme' as string)
|
|
48
|
+
|
|
49
|
+
const fileKey = frameFile(id)
|
|
50
|
+
if (!fileKey) return fail(`unknown frame id "${id}"`)
|
|
51
|
+
|
|
52
|
+
const frameMod: any = await frames[fileKey]()
|
|
53
|
+
const Frame = frameMod.default
|
|
54
|
+
// No typeof gate: memo()/forwardRef() components are objects, not functions.
|
|
55
|
+
// React + the ErrorBoundary validate the element type better than we can.
|
|
56
|
+
if (Frame == null) return fail(`${fileKey} has no default export`)
|
|
57
|
+
|
|
58
|
+
const wrappers: any[] = []
|
|
59
|
+
const providerKey = Object.keys(providers)[0]
|
|
60
|
+
if (providerKey) wrappers.push((await providers[providerKey]() as any).default)
|
|
61
|
+
for (const lk of layoutChain(fileKey)) wrappers.push((await layouts[lk]() as any).default)
|
|
62
|
+
|
|
63
|
+
let tree: ReactNode = createElement(Frame)
|
|
64
|
+
for (const W of wrappers.reverse()) if (W != null) tree = createElement(W, null, tree)
|
|
65
|
+
|
|
66
|
+
createRoot(document.getElementById('root')!).render(createElement(Boundary, null, tree))
|
|
67
|
+
post({ type: 'sh:ready', id, meta: frameMod.meta && typeof frameMod.meta === 'object' ? frameMod.meta : undefined })
|
|
68
|
+
} catch (err) {
|
|
69
|
+
fail((err as Error).message)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
boot()
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The glob registry - a JSX-free module so plugin-react never touches it.
|
|
3
|
+
* It is the HMR boundary for glob-map invalidation (frame files added/removed):
|
|
4
|
+
* accepting here reloads THIS iframe only, instead of Vite broadcasting a full
|
|
5
|
+
* page reload to every client including the shell.
|
|
6
|
+
* Component edits never reach this module - they stop at Fast Refresh in the frame itself.
|
|
7
|
+
*/
|
|
8
|
+
export const frames = import.meta.glob(['/design/scenes/**/*.{tsx,jsx}', '/design/components/**/*.{tsx,jsx}'])
|
|
9
|
+
export const layouts = import.meta.glob(['/design/scenes/**/_layout.{tsx,jsx}', '/design/components/**/_layout.{tsx,jsx}'])
|
|
10
|
+
export const providers = import.meta.glob('/design/providers.{tsx,jsx}')
|
|
11
|
+
|
|
12
|
+
/** id -> glob key. scenes/ ids lost their prefix; components/ ids kept it. */
|
|
13
|
+
export function frameFile(frameId: string): string | null {
|
|
14
|
+
for (const ext of ['tsx', 'jsx']) {
|
|
15
|
+
for (const prefix of ['/design/scenes/', '/design/']) {
|
|
16
|
+
const key = `${prefix}${frameId}.${ext}`
|
|
17
|
+
if (key in frames) return key
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return null
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Layout chain for a frame file: every _layout on the path from the glob base down, outermost = shallowest. */
|
|
24
|
+
export function layoutChain(fileKey: string): string[] {
|
|
25
|
+
const dir = fileKey.slice(0, fileKey.lastIndexOf('/'))
|
|
26
|
+
const chain: string[] = []
|
|
27
|
+
const base = fileKey.startsWith('/design/scenes/') ? '/design/scenes' : '/design/components'
|
|
28
|
+
let cur = dir
|
|
29
|
+
while (cur.length >= base.length) {
|
|
30
|
+
for (const ext of ['tsx', 'jsx']) {
|
|
31
|
+
const key = `${cur}/_layout.${ext}`
|
|
32
|
+
if (key in layouts) { chain.unshift(key); break }
|
|
33
|
+
}
|
|
34
|
+
if (cur === base) break
|
|
35
|
+
cur = cur.slice(0, cur.lastIndexOf('/'))
|
|
36
|
+
}
|
|
37
|
+
return chain
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (import.meta.hot) import.meta.hot.accept(() => location.reload())
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Optional sugar. `data-goto="scene/frame"` on any element is the primitive and needs no import;
|
|
3
|
+
* go() posts the same message programmatically. Safe no-op outside an iframe, so promoted code cannot crash.
|
|
4
|
+
*/
|
|
5
|
+
export function go(target: string): void {
|
|
6
|
+
if (typeof window === 'undefined' || !window.parent || window.parent === window) return
|
|
7
|
+
window.parent.postMessage({ type: 'sh:go', target }, '*')
|
|
8
|
+
}
|