@marver-design/marver 0.2.4 → 0.3.1
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/CHANGELOG.md +52 -0
- package/README.md +1 -0
- package/dist/{build-Bqd6OEsQ.mjs → build-Ckmyci3O.mjs} +55 -4
- package/dist/cli.mjs +12 -5
- package/dist/{dev-9-80L5i5.mjs → dev-C2oTKuXe.mjs} +6 -4
- package/dist/{init-Di4geblA.mjs → init-DolP_Ld4.mjs} +36 -7
- package/dist/{manifest-mYlO_1Pj.mjs → manifest-DW-T52MM.mjs} +29 -1
- package/dist/{plugin-YVpBNTB3.mjs → plugin-Crp4CAma.mjs} +2 -2
- package/dist/{serve-BvbAbWeK.mjs → serve-OtA9Nlow.mjs} +1 -1
- package/package.json +6 -2
- package/src/client/const.ts +5 -0
- package/src/client/content/diagram.tsx +96 -0
- package/src/client/content/index.tsx +196 -0
- package/src/client/content/md.ts +50 -0
- package/src/client/content/palette.ts +93 -0
- package/src/client/shell/App.tsx +50 -6
- package/src/client/shell/Play.tsx +7 -1
- package/src/client/shell/canvas/FrameNode.tsx +3 -1
- package/src/client/shell/icons.tsx +22 -0
- package/src/client/shell/store.ts +150 -17
- package/src/client/shell/styles.css +29 -7
- package/templates/AGENTS-embedded.md +5 -0
- package/templates/AGENTS-studio.md +5 -0
- package/templates/instructions/boards.md +6 -0
- package/templates/instructions/brand.md +3 -1
- package/templates/instructions/craft.md +49 -0
- package/templates/instructions/discover.md +2 -2
- package/templates/instructions/iterate.md +50 -0
- package/templates/instructions/shape.md +171 -0
- package/templates/instructions/welcome.md +27 -12
- package/templates/instructions/wireframe.md +8 -1
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content-frame block primitives (SPEC-026): Doc, Row, Col, Space, Md, Diagram, Img.
|
|
3
|
+
* One vocabulary at two scales: boards arrange frames in rows/columns with space
|
|
4
|
+
* units; these primitives do the same for blocks INSIDE a frame - as CSS flex,
|
|
5
|
+
* deliberately NOT the SPEC-024 lane grammar (shared vocabulary, not semantics).
|
|
6
|
+
*
|
|
7
|
+
* Doc owns the measurement protocol: it reports its content height so the shell
|
|
8
|
+
* can give the frame a natural size (the shell alone owns node dimensions).
|
|
9
|
+
*/
|
|
10
|
+
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
|
11
|
+
import { CONTENT_WIDTH } from '../const.ts'
|
|
12
|
+
import { assetUrl, renderMarkdown } from './md.ts'
|
|
13
|
+
|
|
14
|
+
export { Diagram } from './diagram.tsx'
|
|
15
|
+
|
|
16
|
+
const UNIT = 16 // one gap unit, px - plain adjacency on boards is one gutter; same feel here
|
|
17
|
+
|
|
18
|
+
/* ---------------------------------- Doc ---------------------------------- */
|
|
19
|
+
|
|
20
|
+
export function Doc({ layout = 'document', children }: { layout?: 'document' | 'wide'; children?: ReactNode }) {
|
|
21
|
+
const ref = useRef<HTMLDivElement>(null)
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
ensureStyles()
|
|
24
|
+
const el = ref.current
|
|
25
|
+
if (!el || window.parent === window) return
|
|
26
|
+
let t: ReturnType<typeof setTimeout> | undefined
|
|
27
|
+
const params = new URLSearchParams(location.search)
|
|
28
|
+
const post = () => {
|
|
29
|
+
window.parent.postMessage({
|
|
30
|
+
type: 'sh:measure',
|
|
31
|
+
// identity guards: board files may reuse node keys (frame id must match), and
|
|
32
|
+
// a WindowProxy survives navigation (gen = THIS document's URL rev - the shell
|
|
33
|
+
// drops echoes that don't match the iframe's current src)
|
|
34
|
+
frame: params.get('id') ?? '',
|
|
35
|
+
gen: params.get('r') ?? '',
|
|
36
|
+
ownWidth: CONTENT_WIDTH[layout] ?? CONTENT_WIDTH.document,
|
|
37
|
+
measuredWidth: window.innerWidth, // the width this height is TRUE at (r3 #1)
|
|
38
|
+
height: Math.ceil(el.getBoundingClientRect().height),
|
|
39
|
+
}, '*')
|
|
40
|
+
}
|
|
41
|
+
// debounced ~300ms after the last content change; the shell guards staleness
|
|
42
|
+
// on its side (event.source must map to a mounted iframe; reflow is board-scoped)
|
|
43
|
+
const ro = new ResizeObserver(() => { clearTimeout(t); t = setTimeout(post, 300) })
|
|
44
|
+
ro.observe(el)
|
|
45
|
+
post()
|
|
46
|
+
return () => { ro.disconnect(); clearTimeout(t) }
|
|
47
|
+
}, [layout])
|
|
48
|
+
return <div ref={ref} className={`mv-doc mv-doc-${layout}`}>{children}</div>
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/* ----------------------------- layout blocks ------------------------------ */
|
|
52
|
+
|
|
53
|
+
export function Row({ space = 1, children }: { space?: number; children?: ReactNode }) {
|
|
54
|
+
return <div className="mv-row" style={{ gap: space * UNIT }}>{children}</div>
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function Col({ space = 1, children }: { space?: number; children?: ReactNode }) {
|
|
58
|
+
return <div className="mv-col" style={{ gap: space * UNIT }}>{children}</div>
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function Space({ n = 1 }: { n?: number }) {
|
|
62
|
+
return <div aria-hidden className="mv-space" style={{ flex: `0 0 ${n * UNIT}px`, minWidth: n * UNIT, minHeight: n * UNIT }} />
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/* ---------------------------------- Md ------------------------------------ */
|
|
66
|
+
|
|
67
|
+
export function Md({ children }: { children?: ReactNode }) {
|
|
68
|
+
const src = typeof children === 'string' ? children : Array.isArray(children) ? children.join('') : String(children ?? '')
|
|
69
|
+
const html = useMemo(() => renderMarkdown(src), [src])
|
|
70
|
+
return <div className="mv-md" dangerouslySetInnerHTML={{ __html: html }} />
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/* ---------------------------------- Img ----------------------------------- */
|
|
74
|
+
|
|
75
|
+
export function Img({ src, caption, alt, h }: { src: string; caption?: string; alt?: string; h?: number }) {
|
|
76
|
+
const url = assetUrl(src)
|
|
77
|
+
const [err, setErr] = useState(false)
|
|
78
|
+
if (!url || err) {
|
|
79
|
+
return (
|
|
80
|
+
<div className="mv-block mv-imgerr">
|
|
81
|
+
<b>image unavailable</b>
|
|
82
|
+
<span>{src}</span>
|
|
83
|
+
{!url && <span className="dim">must be a relative path inside design/assets/</span>}
|
|
84
|
+
</div>
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
return (
|
|
88
|
+
<figure className="mv-block mv-img">
|
|
89
|
+
{/* h: shared rendered height for a row of mixed-aspect images - equal widths
|
|
90
|
+
alone never make unequal images READ equal; cover-crop to one height does */}
|
|
91
|
+
<img src={url} alt={alt ?? caption ?? ''} loading="lazy"
|
|
92
|
+
style={h ? { height: h, width: '100%', objectFit: 'cover' } : undefined}
|
|
93
|
+
onError={() => setErr(true)} />
|
|
94
|
+
{caption && <figcaption>{caption}</figcaption>}
|
|
95
|
+
</figure>
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/* --------------------------------- styles --------------------------------- */
|
|
100
|
+
|
|
101
|
+
/** Injected once per document. Content frames paint their own surface and read
|
|
102
|
+
* the frame theme from the bridge's signals (html.dark / [data-theme]). */
|
|
103
|
+
let stylesIn = false
|
|
104
|
+
function ensureStyles() {
|
|
105
|
+
if (stylesIn || document.getElementById('mv-content-css')) { stylesIn = true; return }
|
|
106
|
+
stylesIn = true
|
|
107
|
+
const el = document.createElement('style')
|
|
108
|
+
el.id = 'mv-content-css'
|
|
109
|
+
el.textContent = CSS
|
|
110
|
+
document.head.appendChild(el)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const CSS = `
|
|
114
|
+
/* only content frames inject this stylesheet, and their document is the Doc -
|
|
115
|
+
zeroing the body margin makes the measured height the truth (a min-height
|
|
116
|
+
would echo the frame's own height back and the measurement could never shrink) */
|
|
117
|
+
body { margin: 0; }
|
|
118
|
+
.mv-doc {
|
|
119
|
+
--mv-bg: #FFFFFF; --mv-surface: #F7F7F9; --mv-line: #D1D1D6; --mv-line-soft: #E5E5EA;
|
|
120
|
+
--mv-text: #1C1C1E; --mv-muted: #636366; --mv-faint: #8E8E93; --mv-accent: #0088FF;
|
|
121
|
+
--mv-block-bg: rgba(20, 22, 28, 0.02); --mv-block-line: rgba(20, 22, 28, 0.07);
|
|
122
|
+
box-sizing: border-box; width: 100%;
|
|
123
|
+
background: var(--mv-bg); color: var(--mv-text);
|
|
124
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
|
125
|
+
font-size: 15px; line-height: 1.6; -webkit-font-smoothing: antialiased;
|
|
126
|
+
padding: 40px; display: flex; flex-direction: column; gap: ${UNIT}px;
|
|
127
|
+
}
|
|
128
|
+
.dark .mv-doc, [data-theme="dark"] .mv-doc {
|
|
129
|
+
--mv-bg: #1C1C1E; --mv-surface: #26262A; --mv-line: #3A3A3C; --mv-line-soft: #2C2C2E;
|
|
130
|
+
--mv-text: #F2F2F7; --mv-muted: #AEAEB2; --mv-faint: #8E8E93; --mv-accent: #0091FF;
|
|
131
|
+
--mv-block-bg: rgba(255, 255, 255, 0.025); --mv-block-line: rgba(255, 255, 255, 0.07);
|
|
132
|
+
}
|
|
133
|
+
.mv-doc * { box-sizing: border-box; }
|
|
134
|
+
.mv-row { display: flex; flex-wrap: wrap; align-items: flex-start; }
|
|
135
|
+
.mv-row > * { flex: 1 1 260px; min-width: 0; }
|
|
136
|
+
.mv-row > .mv-space { flex: none; }
|
|
137
|
+
.mv-col { display: flex; flex-direction: column; min-width: 0; }
|
|
138
|
+
|
|
139
|
+
/* the rubber: diagram + image blocks own their breathing room. The surface is
|
|
140
|
+
a whisper, not a card - the content pops, the block only frames it */
|
|
141
|
+
.mv-block { margin: 0; padding: ${UNIT}px; border: 1px solid var(--mv-block-line);
|
|
142
|
+
border-radius: 10px; background: var(--mv-block-bg); }
|
|
143
|
+
.mv-block figcaption, .mv-img figcaption { font-size: 12.5px; color: var(--mv-faint); padding-top: 8px; }
|
|
144
|
+
.mv-diagram-svg { display: flex; justify-content: center; }
|
|
145
|
+
.mv-diagram-svg svg { max-width: 100%; height: auto; }
|
|
146
|
+
.mv-diagram-err { display: flex; flex-direction: column; gap: 4px; font-size: 13px; color: var(--mv-muted);
|
|
147
|
+
font-family: ui-monospace, "SF Mono", Menlo, monospace; }
|
|
148
|
+
.mv-diagram-err b { color: #E0402F; font-family: inherit; }
|
|
149
|
+
.mv-diagram-err .dim, .mv-imgerr .dim { color: var(--mv-faint); font-size: 12px; }
|
|
150
|
+
.mv-img img { display: block; max-width: 100%; border-radius: 6px; }
|
|
151
|
+
.mv-imgerr { display: flex; flex-direction: column; gap: 4px; font-size: 13px; color: var(--mv-muted); }
|
|
152
|
+
.mv-imgerr b { color: #E0402F; }
|
|
153
|
+
|
|
154
|
+
/* markdown typography - the HIG scale, Notion's calm. Body 16 on generous
|
|
155
|
+
leading; headings tighten tracking as they grow (SF behavior); sections
|
|
156
|
+
breathe ABOVE far more than below, so structure reads at a glance. Every
|
|
157
|
+
list-style is re-asserted: host Tailwind preflight strips markers. */
|
|
158
|
+
.mv-md { max-width: 72ch; min-width: 0; font-size: 16px; line-height: 1.65; }
|
|
159
|
+
.mv-md > :first-child { margin-top: 0; }
|
|
160
|
+
.mv-md > :last-child { margin-bottom: 0; }
|
|
161
|
+
.mv-md h1 { font-size: 30px; line-height: 1.16; letter-spacing: -0.021em; font-weight: 700; margin: 44px 0 14px; }
|
|
162
|
+
.mv-md h2 { font-size: 22px; line-height: 1.22; letter-spacing: -0.017em; font-weight: 700; margin: 40px 0 12px; }
|
|
163
|
+
.mv-md h3 { font-size: 18px; line-height: 1.3; letter-spacing: -0.01em; font-weight: 650; margin: 30px 0 8px; }
|
|
164
|
+
.mv-md h4, .mv-md h5, .mv-md h6 { font-size: 15px; font-weight: 650; margin: 24px 0 6px; }
|
|
165
|
+
.mv-md p { margin: 0 0 14px; }
|
|
166
|
+
.mv-md ul, .mv-md ol { margin: 0 0 14px; padding-left: 26px; }
|
|
167
|
+
.mv-md ul { list-style: disc; }
|
|
168
|
+
.mv-md ul ul { list-style: circle; }
|
|
169
|
+
.mv-md ol { list-style: decimal; }
|
|
170
|
+
.mv-md li { margin-bottom: 5px; }
|
|
171
|
+
.mv-md li::marker { color: var(--mv-faint); }
|
|
172
|
+
.mv-md li:has(> input[type="checkbox"]) { list-style: none; margin-left: -26px; }
|
|
173
|
+
.mv-md a { color: var(--mv-accent); text-decoration: none; }
|
|
174
|
+
.mv-md a[data-goto] { border-bottom: 1px dashed var(--mv-accent); cursor: pointer; }
|
|
175
|
+
.mv-md blockquote { margin: 4px 0 16px; padding: 2px 0 2px 18px; border-left: 3px solid var(--mv-text);
|
|
176
|
+
color: var(--mv-text); }
|
|
177
|
+
.mv-md blockquote p:last-child { margin-bottom: 0; }
|
|
178
|
+
.mv-md code { font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; font-size: 0.85em;
|
|
179
|
+
background: var(--mv-surface); border: 1px solid var(--mv-line-soft); border-radius: 5px; padding: 1.5px 6px; }
|
|
180
|
+
.mv-md pre { background: var(--mv-surface); border: 1px solid var(--mv-line-soft); border-radius: 10px;
|
|
181
|
+
padding: 16px 18px; overflow-x: auto; margin: 0 0 16px; }
|
|
182
|
+
.mv-md pre code { background: none; border: 0; padding: 0; font-size: 13px; line-height: 1.65; }
|
|
183
|
+
/* tables are CONTAINED (Notion): outer border + radius, header surface, row hairlines */
|
|
184
|
+
.mv-md table { border-collapse: separate; border-spacing: 0; width: 100%; margin: 4px 0 18px;
|
|
185
|
+
font-size: 14.5px; border: 1px solid var(--mv-line-soft); border-radius: 10px; overflow: hidden; }
|
|
186
|
+
.mv-md th, .mv-md td { text-align: left; padding: 9px 14px; }
|
|
187
|
+
.mv-md th { font-size: 11.5px; text-transform: uppercase; letter-spacing: 0.07em; font-weight: 600;
|
|
188
|
+
color: var(--mv-faint); background: var(--mv-surface); border-bottom: 1px solid var(--mv-line-soft); }
|
|
189
|
+
.mv-md td { border-bottom: 1px solid var(--mv-line-soft); }
|
|
190
|
+
.mv-md tr:last-child td { border-bottom: 0; }
|
|
191
|
+
.mv-md img { max-width: 100%; border-radius: 8px; }
|
|
192
|
+
.mv-md hr { border: 0; border-top: 1px solid var(--mv-line-soft); margin: 32px 0; }
|
|
193
|
+
.mv-md input[type="checkbox"] { appearance: auto; accent-color: var(--mv-accent); width: 15px; height: 15px;
|
|
194
|
+
margin: 0 8px 0 0; vertical-align: -2px; }
|
|
195
|
+
.mv-md-noimg { font-size: 13px; color: var(--mv-faint); font-style: italic; }
|
|
196
|
+
`
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Markdown rendering for the Md block (SPEC-026 security section).
|
|
3
|
+
* Raw HTML is inert (escaped, never parsed). Links and images have SEPARATE
|
|
4
|
+
* policies: links may be goto:/http(s)/mailto (external ones open a new tab,
|
|
5
|
+
* never navigate the iframe); images are local-only - design/assets/ paths.
|
|
6
|
+
*/
|
|
7
|
+
import { Marked } from 'marked'
|
|
8
|
+
|
|
9
|
+
const escapeHtml = (s: string) => s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`)
|
|
10
|
+
|
|
11
|
+
/** Relative design/assets/ path -> served URL; null for anything else (fail closed).
|
|
12
|
+
* Decodes BEFORE validating (a %2e%2e must not sneak past the ".." check) and
|
|
13
|
+
* re-encodes per segment, so the validated path is the path the browser requests. */
|
|
14
|
+
export function assetUrl(src: string): string | null {
|
|
15
|
+
let p = String(src ?? '').trim()
|
|
16
|
+
try { p = decodeURIComponent(p) } catch { return null }
|
|
17
|
+
if (p.includes('%')) return null // still encoded after one decode - refuse double-encoding games
|
|
18
|
+
if (!p || p.includes('://') || p.includes(':') || p.startsWith('/') || p.startsWith('\\') || p.includes('\\')) return null
|
|
19
|
+
if (p.split('/').some((seg) => seg === '..' || seg === '.' || seg === '')) return null
|
|
20
|
+
return `/design/assets/${p.split('/').map(encodeURIComponent).join('/')}`
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const marked = new Marked({
|
|
24
|
+
gfm: true,
|
|
25
|
+
renderer: {
|
|
26
|
+
// raw HTML (block or inline) renders as its literal text - inert by construction
|
|
27
|
+
html(token: any) { return escapeHtml(String(token.text ?? '')) },
|
|
28
|
+
link(token: any) {
|
|
29
|
+
const href = String(token.href ?? '')
|
|
30
|
+
const inner = this.parser.parseInline(token.tokens ?? [])
|
|
31
|
+
if (href.startsWith('goto:')) {
|
|
32
|
+
const target = href.slice('goto:'.length)
|
|
33
|
+
return `<a href="#" data-goto="${escapeHtml(target)}">${inner}</a>`
|
|
34
|
+
}
|
|
35
|
+
if (/^https?:\/\//i.test(href) || /^mailto:/i.test(href))
|
|
36
|
+
return `<a href="${escapeHtml(href)}" target="_blank" rel="noopener noreferrer">${inner}</a>`
|
|
37
|
+
return inner // disallowed protocol: the text, no link
|
|
38
|
+
},
|
|
39
|
+
image(token: any) {
|
|
40
|
+
const url = assetUrl(String(token.href ?? ''))
|
|
41
|
+
const alt = escapeHtml(String(token.text ?? ''))
|
|
42
|
+
if (!url) return `<span class="mv-md-noimg">[image unavailable: ${alt || 'external images are not allowed'}]</span>`
|
|
43
|
+
return `<img src="${escapeHtml(url)}" alt="${alt}" loading="lazy" />`
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
export function renderMarkdown(src: string): string {
|
|
49
|
+
return marked.parse(src, { async: false }) as string
|
|
50
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The marver diagram identity (SPEC-026): the full Apple system palette (HIG),
|
|
3
|
+
* every value the HIG's own light/dark pair - never inverted. Structure comes
|
|
4
|
+
* from the systemGray ramp, emphasis from blue+purple, series diversity from
|
|
5
|
+
* the 12-color set.
|
|
6
|
+
*/
|
|
7
|
+
export const FONT_STACK = `-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif`
|
|
8
|
+
|
|
9
|
+
/** systemGray 1..6 (index 0 = gray1). */
|
|
10
|
+
const GRAY_LIGHT = ['#8E8E93', '#AEAEB2', '#C7C7CC', '#D1D1D6', '#E5E5EA', '#F2F2F7']
|
|
11
|
+
const GRAY_DARK = ['#8E8E93', '#636366', '#48484A', '#3A3A3C', '#2C2C2E', '#1C1C1E']
|
|
12
|
+
|
|
13
|
+
/** red orange yellow green mint teal cyan blue indigo purple pink brown. */
|
|
14
|
+
const SERIES_LIGHT = ['#FF383C', '#FF8D28', '#FFCC00', '#34C759', '#00C8B3', '#00C3D0', '#00C0E8', '#0088FF', '#6155F5', '#CB30E0', '#FF2D55', '#AC7F5E']
|
|
15
|
+
const SERIES_DARK = ['#FF4245', '#FF9230', '#FFD600', '#30D158', '#00DAC3', '#00D2E0', '#3CD3FE', '#0091FF', '#6D7CFF', '#DB34F2', '#FF375F', '#B78A66']
|
|
16
|
+
|
|
17
|
+
export const ACCENT = { blue: { light: '#0088FF', dark: '#0091FF' }, purple: { light: '#CB30E0', dark: '#DB34F2' } }
|
|
18
|
+
|
|
19
|
+
/** Rides INSIDE the SVG via mermaid's themeCSS option - labels must be MEASURED
|
|
20
|
+
* with this treatment (host CSS landing after render would change text metrics
|
|
21
|
+
* without mermaid re-laying-out the boxes). Same voice as the Md typography:
|
|
22
|
+
* medium-weight labels, SF-style tightening, breathing room on wrapped lines. */
|
|
23
|
+
export const THEME_CSS = `
|
|
24
|
+
.nodeLabel, .cluster-label, .label, text { letter-spacing: -0.01em; }
|
|
25
|
+
.nodeLabel, .cluster-label { font-weight: 600; line-height: 1.4; }
|
|
26
|
+
.nodeLabel p, .edgeLabel p, .label p { margin: 0; }
|
|
27
|
+
.edgeLabel, .edgeLabel .label { font-weight: 500; }
|
|
28
|
+
`
|
|
29
|
+
|
|
30
|
+
/** Mermaid themeVariables for one mode. Base theme + these = the marver look. */
|
|
31
|
+
export function themeVars(dark: boolean): Record<string, string> {
|
|
32
|
+
const g = dark ? GRAY_DARK : GRAY_LIGHT
|
|
33
|
+
const s = dark ? SERIES_DARK : SERIES_LIGHT
|
|
34
|
+
const text = dark ? '#F2F2F7' : '#1C1C1E'
|
|
35
|
+
const surface = dark ? '#1C1C1E' : '#FFFFFF'
|
|
36
|
+
const blue = dark ? ACCENT.blue.dark : ACCENT.blue.light
|
|
37
|
+
const purple = dark ? ACCENT.purple.dark : ACCENT.purple.light
|
|
38
|
+
// Nodes are ACCENTED by default - a plain flowchart only ever touches
|
|
39
|
+
// primaryColor, so a gray primary meant every default diagram rendered
|
|
40
|
+
// gray-on-gray (Nic's dogfood catch, 2026-08-13). Structure (lines, clusters,
|
|
41
|
+
// labels) stays grayscale; the shapes carry the marver blue.
|
|
42
|
+
const blueWash = dark ? '#123A5C' : '#E4F0FF'
|
|
43
|
+
const purpleWash = dark ? '#3A1440' : '#F8E4FC'
|
|
44
|
+
const tealWash = dark ? '#0C3B40' : '#DFF7F9'
|
|
45
|
+
const vars: Record<string, string> = {
|
|
46
|
+
fontFamily: FONT_STACK,
|
|
47
|
+
fontSize: '15px',
|
|
48
|
+
background: surface,
|
|
49
|
+
mainBkg: blueWash, // the default node: blue-washed, blue-edged
|
|
50
|
+
primaryColor: blueWash,
|
|
51
|
+
primaryTextColor: text,
|
|
52
|
+
primaryBorderColor: blue,
|
|
53
|
+
nodeBorder: blue,
|
|
54
|
+
lineColor: g[0], // gray1 - edges read as drawing strokes
|
|
55
|
+
textColor: text,
|
|
56
|
+
secondaryColor: purpleWash,
|
|
57
|
+
secondaryTextColor: text,
|
|
58
|
+
secondaryBorderColor: purple,
|
|
59
|
+
tertiaryColor: tealWash,
|
|
60
|
+
tertiaryTextColor: text,
|
|
61
|
+
tertiaryBorderColor: dark ? '#00D2E0' : '#00C3D0',
|
|
62
|
+
clusterBkg: dark ? '#232326' : '#FAFAFC',
|
|
63
|
+
clusterBorder: g[3],
|
|
64
|
+
edgeLabelBackground: surface,
|
|
65
|
+
titleColor: text,
|
|
66
|
+
actorBkg: blueWash,
|
|
67
|
+
actorBorder: blue,
|
|
68
|
+
actorTextColor: text,
|
|
69
|
+
signalColor: g[0],
|
|
70
|
+
signalTextColor: text,
|
|
71
|
+
labelBoxBkgColor: g[4],
|
|
72
|
+
labelTextColor: text,
|
|
73
|
+
noteBkgColor: dark ? '#2C2A20' : '#FFF9E0',
|
|
74
|
+
noteTextColor: text,
|
|
75
|
+
noteBorderColor: g[2],
|
|
76
|
+
activationBkgColor: dark ? '#123A5C' : '#E0F0FF',
|
|
77
|
+
activationBorderColor: blue,
|
|
78
|
+
errorBkgColor: dark ? '#4A1D1E' : '#FFE5E5',
|
|
79
|
+
errorTextColor: text,
|
|
80
|
+
}
|
|
81
|
+
// categorical series: pie slices, git branches, generic color scales
|
|
82
|
+
s.forEach((c, i) => {
|
|
83
|
+
vars[`pie${i + 1}`] = c
|
|
84
|
+
vars[`cScale${i}`] = c
|
|
85
|
+
if (i < 8) vars[`git${i}`] = c
|
|
86
|
+
})
|
|
87
|
+
vars.pieTitleTextColor = text
|
|
88
|
+
vars.pieSectionTextColor = dark ? '#1C1C1E' : '#FFFFFF'
|
|
89
|
+
vars.pieLegendTextColor = text
|
|
90
|
+
vars.pieStrokeColor = surface
|
|
91
|
+
vars.pieOuterStrokeColor = g[2]
|
|
92
|
+
return vars
|
|
93
|
+
}
|
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, PUBLISHED, boardLabel, cap, fetchBoardNames } from './store.ts'
|
|
3
|
+
import { useStore, CONFIG, PUBLISHED, boardLabel, cap, fetchBoardNames, type FrameEntry } from './store.ts'
|
|
4
4
|
import { Tip } from './Tip.tsx'
|
|
5
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, VariantsIcon, XIcon, deviceIcon } from './icons.tsx'
|
|
9
|
+
import { CardsIcon, CardsThreeIcon, CaretIcon, CheckIcon, DevicesIcon, FrameRectIcon, GridIcon, IntentGlyph, 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
|
|
|
@@ -529,6 +529,16 @@ export function App() {
|
|
|
529
529
|
s.setStatus(nodeKey, 'error', String(data.message ?? 'unknown error'))
|
|
530
530
|
} else if (data.type === 'sh:exit-interact') {
|
|
531
531
|
if (s.interact === nodeKey) setInteract(null)
|
|
532
|
+
} else if (data.type === 'sh:measure') {
|
|
533
|
+
// SPEC-026 generation guard: the sender echoes ITS document's URL rev; a
|
|
534
|
+
// WindowProxy survives navigation, so a stale pre-navigation message would
|
|
535
|
+
// otherwise route as if it came from the current document. Compare against
|
|
536
|
+
// the iframe's CURRENT src - mismatched generations are dropped.
|
|
537
|
+
const gen = el.src.match(/[?&]r=(\d+)/)?.[1] ?? ''
|
|
538
|
+
if (String(data.gen ?? '') !== gen) return
|
|
539
|
+
// measureNode does the rest of the admission (content frames only,
|
|
540
|
+
// frame-id match, finite positive, clamped)
|
|
541
|
+
s.measureNode(nodeKey, String(data.frame ?? ''), Number(data.ownWidth), Number(data.measuredWidth), Number(data.height))
|
|
532
542
|
} else if (data.type === 'sh:go') {
|
|
533
543
|
const target = String(data.target ?? '')
|
|
534
544
|
const existing = s.nodes.find((n) => n.frame === target && !n.missing)
|
|
@@ -605,7 +615,18 @@ export function App() {
|
|
|
605
615
|
// the sidebar reflects the ACTIVE BOARD: only scenes/frames with a node on this board
|
|
606
616
|
const onBoard = new Set(nodes.map((n) => n.frame))
|
|
607
617
|
const frames = (manifest?.frames ?? []).filter((f) => onBoard.has(f.id))
|
|
608
|
-
|
|
618
|
+
// scene groups follow the CANVAS reading order (earliest node y, then x), the
|
|
619
|
+
// same law as frame rows - a story board's phases list top-to-bottom as laid out
|
|
620
|
+
const scenes = [...new Set(frames.map((f) => f.scene))].sort((a, b) => {
|
|
621
|
+
const min = (sc: string) => nodes.reduce((acc, n) => {
|
|
622
|
+
const f = frames.find((x) => x.id === n.frame)
|
|
623
|
+
if (f?.scene !== sc) return acc
|
|
624
|
+
return !acc || n.y < acc.y || (n.y === acc.y && n.x < acc.x) ? { y: n.y, x: n.x } : acc
|
|
625
|
+
}, null as { y: number; x: number } | null)
|
|
626
|
+
const ma = min(a), mb = min(b)
|
|
627
|
+
if (!ma || !mb) return ma ? -1 : mb ? 1 : a.localeCompare(b)
|
|
628
|
+
return ma.y - mb.y || ma.x - mb.x || a.localeCompare(b)
|
|
629
|
+
})
|
|
609
630
|
.map((name) => ({ name, frames: frames.filter((f) => f.scene === name).length }))
|
|
610
631
|
// frame ids currently selected, for marking their parent scenes as `held`
|
|
611
632
|
const selFrames = new Set(nodes.filter((n) => selection.includes(n.key)).map((n) => n.frame))
|
|
@@ -651,8 +672,24 @@ export function App() {
|
|
|
651
672
|
}}>
|
|
652
673
|
{(() => {
|
|
653
674
|
// 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
675
|
const nodeFor = (id: string) => nodes.find((x) => x.frame === id && !x.missing) ?? nodes.find((x) => x.frame === id)
|
|
676
|
+
// sidebar order follows the CANVAS (reading order: rows top-to-bottom,
|
|
677
|
+
// left-to-right) - a story board's list must tell the same story as its
|
|
678
|
+
// layout. Grouped frames anchor at their group's earliest position so
|
|
679
|
+
// the run stays contiguous; members sort by letter within it.
|
|
680
|
+
const pos = (id: string) => { const n = nodeFor(id); return n ? { y: n.y, x: n.x } : { y: Number.MAX_SAFE_INTEGER, x: 0 } }
|
|
681
|
+
const sceneFrames = frames.filter((f) => f.scene === sc.name)
|
|
682
|
+
const groupPos = new Map<string, { y: number; x: number }>()
|
|
683
|
+
for (const f of sceneFrames) {
|
|
684
|
+
if (!f.variantGroup) continue
|
|
685
|
+
const p = pos(f.id), g = groupPos.get(f.variantGroup)
|
|
686
|
+
if (!g || p.y < g.y || (p.y === g.y && p.x < g.x)) groupPos.set(f.variantGroup, p)
|
|
687
|
+
}
|
|
688
|
+
const keyOf = (f: FrameEntry) => f.variantGroup ? groupPos.get(f.variantGroup)! : pos(f.id)
|
|
689
|
+
sceneFrames.sort((a, b) => {
|
|
690
|
+
const ka = keyOf(a), kb = keyOf(b)
|
|
691
|
+
return ka.y - kb.y || ka.x - kb.x || (a.variant ?? '').localeCompare(b.variant ?? '')
|
|
692
|
+
})
|
|
656
693
|
const go = (id: string, shift: boolean) => {
|
|
657
694
|
const n = nodeFor(id)
|
|
658
695
|
if (!n) return
|
|
@@ -675,12 +712,14 @@ export function App() {
|
|
|
675
712
|
// group participates without claiming full selection
|
|
676
713
|
const held = !allOn && memberKeys.some((k) => selection.includes(k) || useStore.getState().interact === k)
|
|
677
714
|
// group header: click selects EVERY variant (the quick compare-and-test grab)
|
|
715
|
+
// the group row leads with the flask - variant-ness IS the row's
|
|
716
|
+
// identity; members carry their letter chips, indented below it
|
|
678
717
|
rows.push(
|
|
679
718
|
<div key={`g:${f.variantGroup}`} className={`sub vgroup${allOn ? ' on' : ''}${held ? ' held' : ''}`}
|
|
680
719
|
title="Select all variants"
|
|
681
720
|
onClick={() => { useStore.getState().selectMany(memberKeys); canvasCtl.fitNodes(memberKeys) }}>
|
|
721
|
+
<VariantsIcon size={13} className="iicon" />
|
|
682
722
|
<span className="glabel">{rel}</span>
|
|
683
|
-
<VariantsIcon size={14} className="gicon" />
|
|
684
723
|
</div>,
|
|
685
724
|
)
|
|
686
725
|
// one row per variant: [letter chip] + name, individually selectable
|
|
@@ -700,7 +739,12 @@ export function App() {
|
|
|
700
739
|
const n = nodeFor(f.id)
|
|
701
740
|
const on = !!n && selection.includes(n.key)
|
|
702
741
|
rows.push(
|
|
703
|
-
<div key={f.id} className={`sub${on ? ' on' : ''}`} onClick={(e) => go(f.id, e.shiftKey)}>
|
|
742
|
+
<div key={f.id} className={`sub${on ? ' on' : ''}`} onClick={(e) => go(f.id, e.shiftKey)} title={f.intent}>
|
|
743
|
+
{/* every frame leads with an icon: intent glyph for content
|
|
744
|
+
frames, the plain frame rectangle for UI frames */}
|
|
745
|
+
{f.intent
|
|
746
|
+
? <IntentGlyph intent={f.intent} size={13} className="iicon" aria-label={f.intent} />
|
|
747
|
+
: <FrameRectIcon size={13} className="iicon" />}
|
|
704
748
|
{cap(f.id.split('/').slice(1).join('/') || f.id)}
|
|
705
749
|
</div>,
|
|
706
750
|
)
|
|
@@ -420,7 +420,6 @@ function PlayInner() {
|
|
|
420
420
|
</Tip>
|
|
421
421
|
{variants.length > 1 && <>
|
|
422
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
423
|
{variants.map((v) => (
|
|
425
424
|
<Tip inv key={v.id} label={<><b>{v.title ?? v.id}</b><span className="k">[ ]</span></>}>
|
|
426
425
|
<button className={`vchip${v.id === play.at ? ' on' : ''}`} onClick={() => goTo(v.id)}>
|
|
@@ -428,6 +427,13 @@ function PlayInner() {
|
|
|
428
427
|
</button>
|
|
429
428
|
</Tip>
|
|
430
429
|
))}
|
|
430
|
+
{(() => {
|
|
431
|
+
// chips lead as the stable anchor; the name trails at natural width, so
|
|
432
|
+
// a length change grows the pill rightward instead of shifting the chips
|
|
433
|
+
const c = variants.find((v) => v.id === play.at)
|
|
434
|
+
const full = c ? (c.title ?? (c.id.split('/').pop() ?? '').replace(/^[a-z]-/, '').replace(/-/g, ' ')) : ''
|
|
435
|
+
return <Tip inv label={<b>{full}</b>}><span className="vname">{full}</span></Tip>
|
|
436
|
+
})()}
|
|
431
437
|
</>}
|
|
432
438
|
</div>
|
|
433
439
|
</div>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { memo, useEffect, useRef } from 'react'
|
|
2
2
|
import { cap, frameUrl, useStore, CONFIG, type Node } from '../store.ts'
|
|
3
|
-
import { CopyIcon, ReloadIcon, XIcon } from '../icons.tsx'
|
|
3
|
+
import { CopyIcon, IntentGlyph, ReloadIcon, XIcon } from '../icons.tsx'
|
|
4
4
|
|
|
5
5
|
export const HEADER = 28
|
|
6
6
|
const SNAP = 12
|
|
@@ -169,6 +169,8 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
|
|
|
169
169
|
</div>
|
|
170
170
|
)}
|
|
171
171
|
<div className="sh-node-head sh-no-pan" onPointerDown={(e) => drag(e, 'move')} title={frame.file}>
|
|
172
|
+
{/* content frames carry their intent glyph in the chrome (SPEC-026) */}
|
|
173
|
+
{frame.intent && <IntentGlyph intent={frame.intent} size={12} className="iicon sh-no-pan" aria-label={frame.intent} />}
|
|
172
174
|
<span className="id sh-no-pan">{frame.title ?? frame.id}</span>
|
|
173
175
|
<span className="dim sh-no-pan">{Math.round(node.w)} · {node.theme}</span>
|
|
174
176
|
</div>
|
|
@@ -38,6 +38,28 @@ export const ParallelogramDuoIcon = icon(<><path d="M239.29,59.28l-64.8,144a8,8,
|
|
|
38
38
|
/** Sidebar trigger, custom in Phosphor 256-space: outer frame + panel pill -
|
|
39
39
|
* pill filled while the sidebar is open, hollow while collapsed. */
|
|
40
40
|
export const PanelFilledIcon = icon(<><rect x="32" y="48" width="192" height="160" rx="32" fill="none" stroke="currentColor" strokeWidth="16" /><rect x="72" y="88" width="48" height="80" rx="24" /></>)
|
|
41
|
+
|
|
42
|
+
/** Plain UI frame - the rectangle every non-content sidebar row leads with. */
|
|
43
|
+
export const FrameRectIcon = icon(<rect x="40" y="52" width="176" height="152" rx="20" fill="none" stroke="currentColor" strokeWidth="16" />)
|
|
44
|
+
|
|
45
|
+
/* Content-frame intent glyphs (SPEC-026), custom in the same 256-space. */
|
|
46
|
+
export const DiagramShapeIcon = icon(<><rect x="28" y="36" width="88" height="64" rx="14" fill="none" stroke="currentColor" strokeWidth="16" /><rect x="140" y="156" width="88" height="64" rx="14" fill="none" stroke="currentColor" strokeWidth="16" /><path d="M116 68 H184 V156" fill="none" stroke="currentColor" strokeWidth="16" strokeLinecap="round" strokeLinejoin="round" /></>)
|
|
47
|
+
export const SpecDocIcon = icon(<><path d="M44 48 H180 M44 96 H212 M44 144 H212 M44 192 H140" fill="none" stroke="currentColor" strokeWidth="16" strokeLinecap="round" /></>)
|
|
48
|
+
export const MoodboardIcon = icon(<><rect x="32" y="48" width="192" height="160" rx="20" fill="none" stroke="currentColor" strokeWidth="16" /><circle cx="96" cy="104" r="18" /><path d="M56 192 L112 128 L150 168 L178 140 L200 164 V192 Z" /></>)
|
|
49
|
+
export const NoteStickyIcon = icon(<><path d="M52 40 H204 a16 16 0 0 1 16 16 V152 L156 216 H52 a16 16 0 0 1 -16 -16 V56 a16 16 0 0 1 16 -16 Z" fill="none" stroke="currentColor" strokeWidth="16" strokeLinejoin="round" /><path d="M156 216 V168 a16 16 0 0 1 16 -16 h48" fill="none" stroke="currentColor" strokeWidth="16" strokeLinejoin="round" /></>)
|
|
50
|
+
export const ContentBlocksIcon = icon(<><rect x="36" y="36" width="80" height="80" rx="14" fill="none" stroke="currentColor" strokeWidth="16" /><circle cx="180" cy="180" r="44" fill="none" stroke="currentColor" strokeWidth="16" /><path d="M180 36 L224 116 H136 Z" fill="none" stroke="currentColor" strokeWidth="16" strokeLinejoin="round" /></>)
|
|
51
|
+
|
|
52
|
+
/** intent -> glyph; unknown intents fall to the generic content icon (forward-compatible).
|
|
53
|
+
* Accessible by default: role img + label + tooltip carry the intent name (the base
|
|
54
|
+
* icon() is aria-hidden; the spread below overrides it). */
|
|
55
|
+
export function IntentGlyph({ intent, size = 14, ...rest }: IconProps & { intent: string }) {
|
|
56
|
+
const I = intent === 'diagram' ? DiagramShapeIcon
|
|
57
|
+
: intent === 'spec' ? SpecDocIcon
|
|
58
|
+
: intent === 'moodboard' ? MoodboardIcon
|
|
59
|
+
: intent === 'notes' ? NoteStickyIcon
|
|
60
|
+
: ContentBlocksIcon
|
|
61
|
+
return <I size={size} role="img" aria-hidden={false} aria-label={intent} {...({ title: intent } as object)} {...rest} />
|
|
62
|
+
}
|
|
41
63
|
export const PanelHollowIcon = icon(<><rect x="32" y="48" width="192" height="160" rx="32" fill="none" stroke="currentColor" strokeWidth="16" /><rect x="72" y="88" width="48" height="80" rx="24" fill="none" stroke="currentColor" strokeWidth="14" /></>)
|
|
42
64
|
/** Board glyphs: CardsThree = the built-in `all-scenes` board; Cards = curated boards. */
|
|
43
65
|
export const CardsThreeIcon = icon(<path d="M208,88H48a16,16,0,0,0-16,16v96a16,16,0,0,0,16,16H208a16,16,0,0,0,16-16V104A16,16,0,0,0,208,88Zm0,112H48V104H208v96ZM48,64a8,8,0,0,1,8-8H200a8,8,0,0,1,0,16H56A8,8,0,0,1,48,64ZM64,32a8,8,0,0,1,8-8H184a8,8,0,0,1,0,16H72A8,8,0,0,1,64,32Z" />)
|