@marver-design/marver 0.15.0 → 0.16.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/CHANGELOG.md +75 -0
- package/README.md +2 -1
- package/dist/boards-6hKVW42a.mjs +57 -0
- package/dist/boards-BdG1TJwU.mjs +267 -0
- package/dist/{build-DfuTQZlY.mjs → build-Ct0iMoSi.mjs} +68 -29
- package/dist/cli.mjs +13 -4
- package/dist/{daemon-DalgvoA9.mjs → daemon-Cb4JjpgL.mjs} +1 -1
- package/dist/{dev-BxCmeU_H.mjs → dev-CdcIuhZJ.mjs} +4 -4
- package/dist/{init-QKNi9gvF.mjs → init-BQIpIpIv.mjs} +6 -3
- package/dist/{manifest-BzxSMoDB.mjs → manifest-CpbsqQ_v.mjs} +77 -11
- package/dist/{plugin-DJyjmQeh.mjs → plugin-BXyezwfN.mjs} +164 -69
- package/dist/{poster-CbpzSzJu.mjs → poster-DOY7pax8.mjs} +1 -1
- package/dist/{shot-BWhoz6cU.mjs → shot-CwmHO5T4.mjs} +2 -2
- package/docs/publish.md +4 -1
- package/package.json +1 -1
- package/src/client/shell/App.tsx +4 -246
- package/src/client/shell/BoardList.tsx +408 -0
- package/src/client/shell/ContextMenu.tsx +59 -0
- package/src/client/shell/icons.tsx +6 -0
- package/src/client/shell/store.ts +105 -47
- package/src/client/shell/styles.css +36 -4
- package/src/shared/board-tree.ts +285 -0
- package/templates/AGENTS-embedded.md +18 -5
- package/templates/AGENTS-studio.md +18 -5
- package/templates/instructions/boards.md +73 -2
- package/templates/instructions/craft.md +4 -0
- package/templates/instructions/discover.md +7 -2
- package/templates/instructions/iterate.md +5 -4
- package/templates/instructions/review.md +4 -0
- package/templates/instructions/shape.md +1 -1
- package/templates/instructions/welcome.md +4 -1
- package/templates/instructions/wireframe.md +3 -0
- package/dist/{comments-DHB_8BRa.mjs → comments-oYcZ3cE-.mjs} +1 -1
|
@@ -12,6 +12,8 @@ import shData from 'virtual:sh-data'
|
|
|
12
12
|
* is where `/` opens - never a synthesized aggregate of a filtered build. */
|
|
13
13
|
const DATA: {
|
|
14
14
|
manifest: Manifest; boards: Record<string, unknown>; names: string[]; default: string
|
|
15
|
+
/** the sidebar's folder tree over the published boards (0.16.0 bundles; older ones have none) */
|
|
16
|
+
tree?: TreeItem[]
|
|
15
17
|
/** publish.json v2: per-board artifact type + open/lock, and the reveal flags. */
|
|
16
18
|
policy?: { boards: Record<string, { type?: string; open?: string; lock?: boolean }>; reveal?: { structure?: boolean; source?: boolean }; lockedShell?: boolean }
|
|
17
19
|
} | null = shData
|
|
@@ -123,19 +125,42 @@ export const modeAllowed = (board: string, mode: 'present' | 'focus' | 'slides')
|
|
|
123
125
|
export { cap, humanize } from './labels.ts'
|
|
124
126
|
import { cap, humanize } from './labels.ts'
|
|
125
127
|
import { canAutoReload } from './canvas/ready-watch.ts'
|
|
128
|
+
import { buildTree, flatten, toWire, type TreeItem } from '../../shared/board-tree.ts'
|
|
126
129
|
|
|
127
|
-
/**
|
|
128
|
-
*
|
|
130
|
+
/** The CAS tokens a tree write echoes: the sha256 of every board file as last seen, and of
|
|
131
|
+
* the folder registry (null = there was no file). */
|
|
132
|
+
export interface TreeBase { boards: Record<string, string>; folders: string | null }
|
|
133
|
+
export interface TreeSnapshot { tree: TreeItem[]; base: TreeBase }
|
|
134
|
+
|
|
135
|
+
/** The sidebar tree: root boards and folders in rank order, each folder's boards inside
|
|
136
|
+
* (shared/board-tree.ts), plus the hashes it was built from. `all-scenes` is not in it - it
|
|
137
|
+
* is pinned last by the callers. Throws on transport failure and on a malformed registry
|
|
138
|
+
* (the server's 422 message) - callers keep their last known tree. */
|
|
139
|
+
export async function fetchBoardTree(): Promise<TreeSnapshot> {
|
|
140
|
+
if (DATA) return { tree: DATA.tree ?? DATA.names.filter((n) => n !== 'all-scenes').map((n) => ({ kind: 'board', name: n })), base: { boards: {}, folders: null } }
|
|
141
|
+
const [boards, reg] = await Promise.all([
|
|
142
|
+
fetch(`${ROUTE}/api/boards`).then((r) => r.json()) as Promise<{ name: string; sha256: string; order?: number; folder?: string }[]>,
|
|
143
|
+
fetch(`${ROUTE}/api/folders`).then(async (r) => {
|
|
144
|
+
const j = await r.json() as { folders?: { name: string; order?: number }[]; sha256?: string | null; error?: string }
|
|
145
|
+
if (!r.ok) throw new Error(j?.error ?? `folders ${r.status}`)
|
|
146
|
+
return j
|
|
147
|
+
}),
|
|
148
|
+
])
|
|
149
|
+
return {
|
|
150
|
+
tree: buildTree(boards, reg.folders ?? []),
|
|
151
|
+
base: { boards: Object.fromEntries(boards.map((b) => [b.name, b.sha256])), folders: reg.sha256 ?? null },
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Board names for switchers: the sidebar's reading order (folders flattened depth-first) and
|
|
156
|
+
* the auto `all-scenes` everything-board LAST - it is the expensive one, never the landing.
|
|
129
157
|
* Throws on transport failure - callers keep their last known list. */
|
|
130
158
|
export async function fetchBoardNames(): Promise<string[]> {
|
|
131
159
|
if (DATA) return DATA.names
|
|
132
|
-
|
|
133
|
-
const curated = list
|
|
134
|
-
.filter((b) => b.name !== 'all-scenes')
|
|
135
|
-
.sort((a, b) => (a.order ?? Infinity) - (b.order ?? Infinity) || a.name.localeCompare(b.name))
|
|
136
|
-
.map((b) => b.name)
|
|
137
|
-
return [...curated, 'all-scenes']
|
|
160
|
+
return [...flatten((await fetchBoardTree()).tree), 'all-scenes']
|
|
138
161
|
}
|
|
162
|
+
/** Does the switcher carry the auto `all-scenes` board? Always in dev; published only when it shipped. */
|
|
163
|
+
export const HAS_ALL_SCENES = !DATA || DATA.names.includes('all-scenes')
|
|
139
164
|
/** Display name for a board: the reserved 'all-scenes' key reads as "All scenes". */
|
|
140
165
|
export const boardLabel = (n: string) => humanize(n)
|
|
141
166
|
|
|
@@ -280,7 +305,7 @@ interface State {
|
|
|
280
305
|
resizeSelected(name: string | null): void
|
|
281
306
|
switchBoard(name: string): Promise<void>
|
|
282
307
|
renameBoard(from: string, to: string): Promise<{ ok: boolean; error?: string }>
|
|
283
|
-
|
|
308
|
+
arrangeBoards(tree: TreeItem[], base: TreeBase): Promise<{ ok: true } | { ok: false; stale: boolean; error?: string }>
|
|
284
309
|
pulsePath(): void
|
|
285
310
|
copyFrameImage(scale: 2 | 4): void
|
|
286
311
|
setScale(s: number): void
|
|
@@ -317,8 +342,18 @@ export const useStore = create<State>((set, get) => {
|
|
|
317
342
|
let editRev = 0 // bumps per edit; a stale save response may never clear dirty over a newer edit
|
|
318
343
|
let loadSeq = 0 // stale boot() responses never overwrite a newer board
|
|
319
344
|
let switchSeq = 0 // last click wins when board switches race
|
|
320
|
-
|
|
321
|
-
|
|
345
|
+
// While a board file is being renamed or rewritten by a structural write (rename, the
|
|
346
|
+
// sidebar's tree arrange), autosave must not fire against it (a mustExist PUT would
|
|
347
|
+
// 409-gone and clear dirty, losing the in-flight edit; a stale baseHash would 409 and
|
|
348
|
+
// reload). A COUNT, not a flag: two holders never release each other. Structural writes
|
|
349
|
+
// also run one at a time through `structChain`, so a rename can never interleave a
|
|
350
|
+
// tree write on the same file.
|
|
351
|
+
let saveHolds = 0
|
|
352
|
+
const holdSaves = () => { saveHolds++; clearTimeout(saveTimer) }
|
|
353
|
+
const releaseSaves = () => { if (--saveHolds === 0 && get().dirty) scheduleSave() }
|
|
354
|
+
let structChain: Promise<unknown> = Promise.resolve()
|
|
355
|
+
const structural = <T,>(fn: () => Promise<T>): Promise<T> => { const p = structChain.then(fn, fn); structChain = p.catch(() => {}); return p }
|
|
356
|
+
const scheduleSave = () => { editRev++; if (saveHolds > 0) return; clearTimeout(saveTimer); saveTimer = setTimeout(() => get().save(), 500) }
|
|
322
357
|
|
|
323
358
|
// One cancelable, BOARD-SCOPED reflow after content measurements settle.
|
|
324
359
|
// The captured board name is the generation guard - a debounce surviving a board
|
|
@@ -616,43 +651,66 @@ export const useStore = create<State>((set, get) => {
|
|
|
616
651
|
if (live && manifestKey(live) !== manifestKey(next.manifest as Manifest)) get().applyManifest(live)
|
|
617
652
|
},
|
|
618
653
|
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
654
|
+
renameBoard(from, to) {
|
|
655
|
+
return structural(async () => {
|
|
656
|
+
const active = from === get().board
|
|
657
|
+
// Renaming the ACTIVE board renames its file out from under the autosave. Flush any
|
|
658
|
+
// pending write FIRST (switchBoard's pattern), so a mustExist PUT never races the move.
|
|
659
|
+
if (active) {
|
|
660
|
+
let ok = true
|
|
661
|
+
for (let i = 0; i < 5 && get().dirty && ok; i++) { clearTimeout(saveTimer); ok = await get().save() }
|
|
662
|
+
if (get().dirty) return { ok: false, error: 'unsaved changes - try again' }
|
|
663
|
+
// hold autosave across the round-trip: an edit landing mid-rename must NOT save to the
|
|
664
|
+
// old name (a mustExist PUT would 409-gone and clear dirty, losing the edit)
|
|
665
|
+
holdSaves()
|
|
666
|
+
}
|
|
667
|
+
// Release the hold and resume autosave. Re-read the LIVE board, not the captured `active`:
|
|
668
|
+
// a board switch may have completed while we awaited, so only the board that is STILL
|
|
669
|
+
// `from` gets renamed to `to` in state (a switched-away board keeps its own name/nodes).
|
|
670
|
+
try {
|
|
671
|
+
let res: Response
|
|
672
|
+
try { res = await postOwner('boards/rename', { from, to }) }
|
|
673
|
+
catch { return { ok: false, error: 'could not reach the dev server' } }
|
|
674
|
+
if (!res.ok) {
|
|
675
|
+
const e = await res.json().catch(() => ({} as { error?: string }))
|
|
676
|
+
return { ok: false, error: e?.error ?? `rename failed (${res.status})` }
|
|
677
|
+
}
|
|
678
|
+
// Content is byte-identical after a file move, so boardHash still matches for the next
|
|
679
|
+
// autosave; only the name (and the URL, via the board-change subscription) changes. Set
|
|
680
|
+
// the new name BEFORE releasing the hold so a resumed save targets `to`.
|
|
681
|
+
if (active && get().board === from) set({ board: to })
|
|
682
|
+
return { ok: true }
|
|
683
|
+
} finally { if (active) releaseSaves() } // whatever happened, autosave resumes
|
|
684
|
+
})
|
|
650
685
|
},
|
|
651
686
|
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
687
|
+
// The whole sidebar tree in one write: order, membership, the folders themselves. The
|
|
688
|
+
// write rewrites the ACTIVE board's file too (its `order`/`folder`), so it runs like a
|
|
689
|
+
// rename: flush the pending autosave first, hold autosave across the round-trip, then
|
|
690
|
+
// advance boardHash to the hash the server answered - the autosave's CAS token stays
|
|
691
|
+
// true and the watcher's echo is filtered as our own. `base` carries the hashes the
|
|
692
|
+
// sidebar last saw; a 409 (`stale`) means someone else wrote first - refetch and replay.
|
|
693
|
+
arrangeBoards(tree, base) {
|
|
694
|
+
return structural(async () => {
|
|
695
|
+
let ok = true
|
|
696
|
+
for (let i = 0; i < 5 && get().dirty && ok; i++) { clearTimeout(saveTimer); ok = await get().save() }
|
|
697
|
+
if (get().dirty) return { ok: false as const, stale: false, error: 'unsaved changes - try again' }
|
|
698
|
+
holdSaves()
|
|
699
|
+
try {
|
|
700
|
+
// the active board's hash is freshest in the store (an autosave may have landed since
|
|
701
|
+
// the sidebar looked); every other board's is the sidebar's
|
|
702
|
+
const active = get().board
|
|
703
|
+
const boards = { ...base.boards, ...(get().boardHash && base.boards[active] !== undefined ? { [active]: get().boardHash } : {}) }
|
|
704
|
+
let res: Response
|
|
705
|
+
try { res = await postOwner('boards/reorder', { tree: toWire(tree), base: { boards, folders: base.folders } }) }
|
|
706
|
+
catch { return { ok: false as const, stale: false, error: 'could not reach the dev server' } }
|
|
707
|
+
const body = await res.json().catch(() => ({} as { error?: string; sha256?: { boards?: Record<string, string> } }))
|
|
708
|
+
if (!res.ok) return { ok: false as const, stale: res.status === 409, error: body?.error }
|
|
709
|
+
const sha = body?.sha256?.boards?.[get().board]
|
|
710
|
+
if (sha) set({ boardHash: sha })
|
|
711
|
+
return { ok: true as const }
|
|
712
|
+
} finally { releaseSaves() } // whatever happened, autosave resumes
|
|
713
|
+
})
|
|
656
714
|
},
|
|
657
715
|
|
|
658
716
|
applyManifest(m) {
|
|
@@ -1141,7 +1199,7 @@ export const useStore = create<State>((set, get) => {
|
|
|
1141
1199
|
save() {
|
|
1142
1200
|
// a rename is moving this board's file - defer every save so nothing writes to the OLD
|
|
1143
1201
|
// name mid-move (renameBoard reschedules once the move commits under the new name)
|
|
1144
|
-
if (
|
|
1202
|
+
if (saveHolds > 0) return Promise.resolve(false)
|
|
1145
1203
|
// a resize gesture in flight = torn state (new sizes, pre-recipe positions):
|
|
1146
1204
|
// even a PREVIOUSLY scheduled timer must defer to the gesture-end save
|
|
1147
1205
|
if (get().gesture && resizedInGesture) { scheduleSave(); return Promise.resolve(false) }
|
|
@@ -505,9 +505,38 @@ body.sh-commenting .sh-lean { opacity: 0 }
|
|
|
505
505
|
.sh-panel .it.board.drop-before::after { top: -1px }
|
|
506
506
|
.sh-panel .it.board.drop-after::after { bottom: -1px }
|
|
507
507
|
/* inline rename: the input wears the row's own type so the swap is seamless */
|
|
508
|
-
.sh-panel .it.board.editing { cursor: default }
|
|
509
|
-
.sh-panel .it.
|
|
508
|
+
.sh-panel .it.board.editing, .sh-panel .it.folder.editing { cursor: default }
|
|
509
|
+
.sh-panel .it.editing input { flex: 1; min-width: 0; padding: 0; border: 0; outline: 0; background: none;
|
|
510
510
|
color: var(--glass-ink); font: inherit }
|
|
511
|
+
.sh-panel .it.editing input::placeholder { color: var(--glass-ink-3) }
|
|
512
|
+
/* folders: one level, same row grammar as boards (idle icon tertiary, count tertiary). A folder
|
|
513
|
+
holding the current board wears the ancestor wash so the active board's home survives a
|
|
514
|
+
collapse; its boards indent one step. Folder rows share the board seam rules; dropping INTO a
|
|
515
|
+
folder lights the whole row (wash + brand-blue ring) instead of a seam. */
|
|
516
|
+
.sh-panel .it.folder svg { color: var(--glass-ink-3); flex: none; margin: 0 1px; transition: color .2s ease }
|
|
517
|
+
.sh-panel .it.folder[data-reorderable]:active { cursor: grabbing }
|
|
518
|
+
.sh-panel .it.folder.dragging { opacity: .4 }
|
|
519
|
+
.sh-panel .it.folder.drop-before, .sh-panel .it.folder.drop-after { position: relative }
|
|
520
|
+
.sh-panel .it.folder.drop-before::after, .sh-panel .it.folder.drop-after::after {
|
|
521
|
+
content: ''; position: absolute; left: 6px; right: 6px; height: 3px; border-radius: 3px;
|
|
522
|
+
background: #0088ff; pointer-events: none }
|
|
523
|
+
.sh-panel .it.folder.drop-before::after { top: -1px }
|
|
524
|
+
.sh-panel .it.folder.drop-after::after { bottom: -1px }
|
|
525
|
+
.sh-panel .it.folder.drop-into { background: var(--accent-wash); box-shadow: inset 0 0 0 1.5px #0088ff }
|
|
526
|
+
.sh-panel .it.folder.drop-into svg { color: #0088ff }
|
|
527
|
+
.sh-panel .it.board.in-folder { padding-left: 28px }
|
|
528
|
+
.sh-panel .it.board.draft { cursor: default; opacity: .6 }
|
|
529
|
+
/* seams inside a folder are indented with its rows, so "inside" and "at the root" never draw alike */
|
|
530
|
+
.sh-panel .it.board.in-folder.drop-before::after, .sh-panel .it.board.in-folder.drop-after::after { left: 28px }
|
|
531
|
+
/* the Boards header carries a quiet folder-plus: tertiary, shows on hover of the header row */
|
|
532
|
+
.sh-panel .sh-boards .hd { display: flex; align-items: center; margin-right: 6px }
|
|
533
|
+
.sh-panel .sh-boards .hd span { flex: 1 }
|
|
534
|
+
/* a bare glyph, no button chrome (padding would shrink it): tertiary, pointer, full ink on hover */
|
|
535
|
+
.sh-panel .sh-hd-add { flex: none; color: var(--glass-ink-3); cursor: pointer; outline: none; border-radius: 4px;
|
|
536
|
+
opacity: 0; transition: opacity .15s ease, color .15s ease }
|
|
537
|
+
.sh-panel .sh-boards .hd:hover .sh-hd-add, .sh-panel .sh-hd-add:focus-visible { opacity: 1 }
|
|
538
|
+
.sh-panel .sh-hd-add:hover { color: var(--glass-ink) }
|
|
539
|
+
.sh-panel .sh-hd-add:focus-visible { box-shadow: 0 0 0 2px var(--accent) }
|
|
511
540
|
/* frame rows: one indent in (aligned to the label line), quieter ink */
|
|
512
541
|
.sh-panel .sub { display: flex; align-items: center; height: 28px; padding: 0 8px 0 31px; margin-bottom: 1px;
|
|
513
542
|
color: var(--glass-ink-2); font-size: 13px; font-weight: 400; border-radius: 8px; cursor: pointer }
|
|
@@ -562,8 +591,11 @@ body.sh-commenting .sh-lean { opacity: 0 }
|
|
|
562
591
|
cursor: pointer; text-align: left }
|
|
563
592
|
.sh-menu button:hover { background: var(--glass-hover) }
|
|
564
593
|
.sh-menu button span { flex: 1; text-transform: capitalize }
|
|
565
|
-
/* the sidebar right-click menu keeps its labels verbatim (they match the toolbar exactly)
|
|
566
|
-
|
|
594
|
+
/* the sidebar right-click menu keeps its labels verbatim (they match the toolbar exactly);
|
|
595
|
+
it scrolls rather than overflowing the window when the folder list grows long */
|
|
596
|
+
.sh-ctxmenu { max-height: calc(100vh - 16px); max-width: 240px; overflow-y: auto; scrollbar-width: thin }
|
|
597
|
+
.sh-ctxmenu button { flex: none; max-width: 100% }
|
|
598
|
+
.sh-ctxmenu button span { text-transform: none; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0 }
|
|
567
599
|
.sh-menu .chk { color: var(--glass-accent) }
|
|
568
600
|
.sh-menu kbd { font: 500 10.5px -apple-system, system-ui, sans-serif; color: var(--glass-ink-3); background: none; border: 0 }
|
|
569
601
|
.sh-menu .div { height: 1px; background: var(--glass-brd); margin: 4px 6px }
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Board folders - the pure tree shared by the sidebar, the dev API, the build and the
|
|
3
|
+
* tests. Files are the truth: a board says which folder it sits in (`folder` on the
|
|
4
|
+
* board file, ranked among its siblings by `order`), and `design/boards/_folders.json`
|
|
5
|
+
* says which folders exist and where they rank at the root. One level only: folders
|
|
6
|
+
* hold boards, never folders. `all-scenes` never enters the tree - callers pin it last.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** The on-disk name grammar shared by boards and folders (a board name is a filename). */
|
|
10
|
+
export const BOARD_NAME = /^[a-z0-9][a-z0-9-]*$/
|
|
11
|
+
export const NAME_MAX = 64
|
|
12
|
+
export const isBoardName = (n: unknown): n is string => typeof n === 'string' && n.length >= 1 && n.length <= NAME_MAX && BOARD_NAME.test(n)
|
|
13
|
+
|
|
14
|
+
/** The folder registry beside the boards - underscore = infrastructure, never a board. */
|
|
15
|
+
export const FOLDERS_FILE = '_folders.json'
|
|
16
|
+
/** Is this basename in design/boards/ a board file? `_folders.json`, temp files and any
|
|
17
|
+
* off-grammar name are not - every lister (dev API, build, watcher) shares this rule. */
|
|
18
|
+
export const isBoardFile = (f: string): boolean => f.endsWith('.json') && isBoardName(f.slice(0, -5))
|
|
19
|
+
|
|
20
|
+
export type Folder = { kind: 'folder'; name: string; boards: string[]; description?: string }
|
|
21
|
+
export type TreeItem = { kind: 'board'; name: string } | Folder
|
|
22
|
+
|
|
23
|
+
export interface BoardRow { name: string; order?: number; folder?: string }
|
|
24
|
+
export interface FolderRow { name: string; order?: number; description?: string }
|
|
25
|
+
|
|
26
|
+
/** A description off a file: one sentence, trimmed, capped - absent when empty or not a string. */
|
|
27
|
+
export const DESCRIPTION_MAX = 300
|
|
28
|
+
export const readDescription = (v: unknown): string | undefined => {
|
|
29
|
+
if (typeof v !== 'string') return undefined
|
|
30
|
+
const s = v.trim().replace(/\s+/g, ' ').slice(0, DESCRIPTION_MAX)
|
|
31
|
+
return s || undefined
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const rank = (o: number | undefined) => (typeof o === 'number' && Number.isFinite(o) ? o : Infinity)
|
|
35
|
+
|
|
36
|
+
/** The registry file's shape. Returns the rows, or a string naming what is wrong - a
|
|
37
|
+
* malformed registry is an ERROR the human must fix (silently reading it as empty would
|
|
38
|
+
* let the next drag overwrite their folders), while a missing file is simply no folders. */
|
|
39
|
+
export function parseFolders(raw: unknown): FolderRow[] | string {
|
|
40
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return 'expected an object'
|
|
41
|
+
const { version, folders } = raw as { version?: unknown; folders?: unknown }
|
|
42
|
+
if (version !== undefined && version !== 1) return `unsupported version ${String(version)}`
|
|
43
|
+
if (!Array.isArray(folders)) return 'expected a "folders" array'
|
|
44
|
+
const out: FolderRow[] = []
|
|
45
|
+
const seen = new Set<string>()
|
|
46
|
+
for (const f of folders) {
|
|
47
|
+
const name = (f as { name?: unknown })?.name
|
|
48
|
+
if (!isBoardName(name)) return 'a folder needs a name - lowercase letters, numbers and dashes'
|
|
49
|
+
if (seen.has(name)) return `folder "${name}" is listed twice`
|
|
50
|
+
seen.add(name)
|
|
51
|
+
const o = (f as { order?: unknown }).order
|
|
52
|
+
const d = readDescription((f as { description?: unknown }).description)
|
|
53
|
+
out.push({ name, ...(typeof o === 'number' && Number.isFinite(o) ? { order: o } : {}), ...(d ? { description: d } : {}) })
|
|
54
|
+
}
|
|
55
|
+
return out
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Sidebar order from the files. Root: boards with no folder + every folder (registered
|
|
59
|
+
* or implied by a board), ranked by `order` then kind (board before folder) then name.
|
|
60
|
+
* Inside a folder: its boards by `order` then name. Unranked sorts after ranked. */
|
|
61
|
+
export function buildTree(boards: BoardRow[], folders: FolderRow[]): TreeItem[] {
|
|
62
|
+
const folderOrder = new Map<string, number | undefined>()
|
|
63
|
+
const folderDesc = new Map<string, string>()
|
|
64
|
+
for (const f of folders) if (isBoardName(f.name) && !folderOrder.has(f.name)) { folderOrder.set(f.name, f.order); if (f.description) folderDesc.set(f.name, f.description) }
|
|
65
|
+
const members = new Map<string, BoardRow[]>()
|
|
66
|
+
const rootBoards: BoardRow[] = []
|
|
67
|
+
for (const b of boards) {
|
|
68
|
+
if (!isBoardName(b.name) || b.name === 'all-scenes') continue
|
|
69
|
+
const folder = isBoardName(b.folder) ? b.folder : undefined
|
|
70
|
+
if (!folder) { rootBoards.push(b); continue }
|
|
71
|
+
if (!folderOrder.has(folder)) folderOrder.set(folder, undefined) // implied by the board alone
|
|
72
|
+
const list = members.get(folder) ?? []
|
|
73
|
+
list.push(b)
|
|
74
|
+
members.set(folder, list)
|
|
75
|
+
}
|
|
76
|
+
const byRank = (a: BoardRow, b: BoardRow) => rank(a.order) - rank(b.order) || a.name.localeCompare(b.name)
|
|
77
|
+
type Root = { item: TreeItem; order: number | undefined }
|
|
78
|
+
const root: Root[] = [
|
|
79
|
+
...rootBoards.map((b) => ({ item: { kind: 'board', name: b.name } as TreeItem, order: b.order })),
|
|
80
|
+
...[...folderOrder].map(([name, order]) => ({
|
|
81
|
+
item: { kind: 'folder', name, boards: (members.get(name) ?? []).sort(byRank).map((b) => b.name), ...(folderDesc.has(name) ? { description: folderDesc.get(name) } : {}) } as TreeItem,
|
|
82
|
+
order,
|
|
83
|
+
})),
|
|
84
|
+
]
|
|
85
|
+
root.sort((a, b) => rank(a.order) - rank(b.order) || (a.item.kind === b.item.kind ? 0 : a.item.kind === 'board' ? -1 : 1) || a.item.name.localeCompare(b.item.name))
|
|
86
|
+
return root.map((r) => r.item)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Every board in reading order - the order the switchers and the landing pick use. */
|
|
90
|
+
export function flatten(tree: TreeItem[]): string[] {
|
|
91
|
+
const out: string[] = []
|
|
92
|
+
for (const it of tree) { if (it.kind === 'board') out.push(it.name); else out.push(...it.boards) }
|
|
93
|
+
return out
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The wire shape of a tree write (`POST boards/reorder`): plain strings for root boards,
|
|
97
|
+
* `{ folder, boards }` for folders - what the sidebar posts and what the server validates. */
|
|
98
|
+
export type WireItem = string | { folder: string; boards: string[]; description?: string }
|
|
99
|
+
export const toWire = (tree: TreeItem[]): WireItem[] =>
|
|
100
|
+
tree.map((it) => (it.kind === 'board' ? it.name : { folder: it.name, boards: [...it.boards], ...(it.description ? { description: it.description } : {}) }))
|
|
101
|
+
export const fromWire = (wire: WireItem[]): TreeItem[] =>
|
|
102
|
+
wire.map((w) => (typeof w === 'string' ? { kind: 'board', name: w } : { kind: 'folder', name: w.folder, boards: [...w.boards], ...(w.description ? { description: w.description } : {}) }))
|
|
103
|
+
|
|
104
|
+
/** Validate a wire tree off the network. Returns the error, or null when it is sound:
|
|
105
|
+
* every name on-grammar, `all-scenes` nowhere, no board twice, no folder twice, no
|
|
106
|
+
* nesting (a folder's boards are strings), bounded. */
|
|
107
|
+
export const TREE_MAX_BOARDS = 200
|
|
108
|
+
export const TREE_MAX_FOLDERS = 50
|
|
109
|
+
export function validateWire(wire: unknown): string | null {
|
|
110
|
+
if (!Array.isArray(wire)) return 'invalid tree'
|
|
111
|
+
const boards = new Set<string>(), folders = new Set<string>()
|
|
112
|
+
const board = (n: unknown): string | null => {
|
|
113
|
+
if (!isBoardName(n) || n === 'all-scenes') return 'invalid board name in tree'
|
|
114
|
+
if (boards.has(n)) return `board "${n}" appears twice`
|
|
115
|
+
boards.add(n)
|
|
116
|
+
return null
|
|
117
|
+
}
|
|
118
|
+
for (const w of wire) {
|
|
119
|
+
if (typeof w === 'string') { const e = board(w); if (e) return e; continue }
|
|
120
|
+
if (!w || typeof w !== 'object' || Array.isArray(w)) return 'invalid tree item'
|
|
121
|
+
const { folder, boards: kids, description } = w as { folder?: unknown; boards?: unknown; description?: unknown }
|
|
122
|
+
if (!isBoardName(folder)) return 'invalid folder name in tree'
|
|
123
|
+
if (description !== undefined && (typeof description !== 'string' || description.length > DESCRIPTION_MAX)) return 'invalid folder description'
|
|
124
|
+
if (folders.has(folder)) return `folder "${folder}" appears twice`
|
|
125
|
+
folders.add(folder)
|
|
126
|
+
if (!Array.isArray(kids)) return 'invalid folder in tree'
|
|
127
|
+
for (const k of kids) { const e = board(k); if (e) return e }
|
|
128
|
+
}
|
|
129
|
+
if (boards.size > TREE_MAX_BOARDS || folders.size > TREE_MAX_FOLDERS) return 'tree too large'
|
|
130
|
+
return null
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** What the human types becomes a slug: "Old stuff" → "old-stuff". Empty when nothing
|
|
134
|
+
* survives - the caller keeps the input open and says so. Always on-grammar or empty. */
|
|
135
|
+
export function slugify(raw: string): string {
|
|
136
|
+
const s = raw.trim().toLowerCase().replace(/[\s_]+/g, '-').replace(/[^a-z0-9-]/g, '').replace(/-+/g, '-').replace(/^-+|-+$/g, '').slice(0, NAME_MAX).replace(/-+$/g, '')
|
|
137
|
+
return isBoardName(s) ? s : ''
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ---- tree mutations (pure; the sidebar shows the result at once and persists it) ----
|
|
141
|
+
|
|
142
|
+
export const cloneTree = (t: TreeItem[]): TreeItem[] => t.map((it) => (it.kind === 'board' ? { ...it } : { ...it, boards: [...it.boards] })) // a folder's description rides along
|
|
143
|
+
export const rootIndex = (t: TreeItem[], kind: TreeItem['kind'], name: string) => t.findIndex((it) => it.kind === kind && it.name === name)
|
|
144
|
+
export const folderIn = (t: TreeItem[], name: string): Folder | undefined => t.find((it): it is Folder => it.kind === 'folder' && it.name === name)
|
|
145
|
+
export const folderOf = (t: TreeItem[], board: string): string | null => t.find((it): it is Folder => it.kind === 'folder' && it.boards.includes(board))?.name ?? null
|
|
146
|
+
export const boardsIn = (t: TreeItem[]): string[] => flatten(t)
|
|
147
|
+
export const foldersIn = (t: TreeItem[]): string[] => t.filter((it) => it.kind === 'folder').map((it) => it.name)
|
|
148
|
+
|
|
149
|
+
/** Remove a board wherever it sits; returns the list it left (root = null) and its index there. */
|
|
150
|
+
export function takeBoard(t: TreeItem[], board: string): { list: string | null; index: number } | null {
|
|
151
|
+
const ri = rootIndex(t, 'board', board)
|
|
152
|
+
if (ri >= 0) { t.splice(ri, 1); return { list: null, index: ri } }
|
|
153
|
+
for (const it of t) {
|
|
154
|
+
if (it.kind !== 'folder') continue
|
|
155
|
+
const i = it.boards.indexOf(board)
|
|
156
|
+
if (i >= 0) { it.boards.splice(i, 1); return { list: it.name, index: i } }
|
|
157
|
+
}
|
|
158
|
+
return null
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** What is being dragged, and where it may land: a slot in a list (root = null) or the
|
|
162
|
+
* inside of a folder (appended at its end). */
|
|
163
|
+
export type Drag = { kind: TreeItem['kind']; name: string }
|
|
164
|
+
export type Drop = { list: string | null; index: number } | { into: string }
|
|
165
|
+
|
|
166
|
+
/** The row under the pointer, measured by the DOM and handed here as plain facts. */
|
|
167
|
+
export interface Hit {
|
|
168
|
+
kind: TreeItem['kind']; name: string
|
|
169
|
+
parent: string | null // the folder a board row lives in
|
|
170
|
+
below: boolean // pointer in the lower half of the row
|
|
171
|
+
topEdge: boolean // pointer in the top 30% of the row (folder rows: "before me")
|
|
172
|
+
gutter: boolean // pointer left of a child row's indent (the root gutter = outdent)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** The drop target for a drag over a row, or null. Boards land in any slot - root or inside
|
|
176
|
+
* a folder - or INTO a folder; folders land in root slots only. Over a folder's child row the
|
|
177
|
+
* root gutter means "after that folder" (the natural outdent). `all-scenes` = root end. */
|
|
178
|
+
export function resolveDrop(t: TreeItem[], d: Drag, hit: Hit): Drop | null {
|
|
179
|
+
if (hit.kind === 'folder') {
|
|
180
|
+
const fi = rootIndex(t, 'folder', hit.name)
|
|
181
|
+
if (fi < 0) return null
|
|
182
|
+
if (d.kind === 'folder') return { list: null, index: hit.below ? fi + 1 : fi }
|
|
183
|
+
return hit.topEdge ? { list: null, index: fi } : { into: hit.name } // top edge = before; the rest = inside
|
|
184
|
+
}
|
|
185
|
+
if (hit.name === 'all-scenes') return { list: null, index: t.length } // over the pinned last row = root end slot
|
|
186
|
+
if (hit.parent) {
|
|
187
|
+
const pi = rootIndex(t, 'folder', hit.parent)
|
|
188
|
+
if (pi < 0) return null
|
|
189
|
+
if (d.kind === 'folder' || hit.gutter) return { list: null, index: pi + 1 } // after that folder
|
|
190
|
+
const i = folderIn(t, hit.parent)?.boards.indexOf(hit.name) ?? -1
|
|
191
|
+
return i < 0 ? null : { list: hit.parent, index: hit.below ? i + 1 : i }
|
|
192
|
+
}
|
|
193
|
+
const i = rootIndex(t, 'board', hit.name)
|
|
194
|
+
return i < 0 ? null : { list: null, index: hit.below ? i + 1 : i }
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** A target that would leave the item where it is: nothing to show, nothing to drop. Into
|
|
198
|
+
* the folder a board already sits in means "to its end" - a no-op only when it is last. */
|
|
199
|
+
export function isOwnSlot(t: TreeItem[], d: Drag, target: Drop): boolean {
|
|
200
|
+
if ('into' in target) {
|
|
201
|
+
if (d.kind !== 'board') return true
|
|
202
|
+
const f = folderIn(t, target.into)
|
|
203
|
+
return !!f && f.boards[f.boards.length - 1] === d.name
|
|
204
|
+
}
|
|
205
|
+
let from = -1
|
|
206
|
+
if (d.kind === 'folder') from = target.list === null ? rootIndex(t, 'folder', d.name) : -1
|
|
207
|
+
else if (target.list === null) from = rootIndex(t, 'board', d.name)
|
|
208
|
+
else from = folderIn(t, target.list)?.boards.indexOf(d.name) ?? -1
|
|
209
|
+
return from >= 0 && (target.index === from || target.index === from + 1)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** The tree after a drop, or null when the drop is impossible on this tree. */
|
|
213
|
+
export function applyDrop(tree: TreeItem[], d: Drag, target: Drop): TreeItem[] | null {
|
|
214
|
+
const next = cloneTree(tree)
|
|
215
|
+
if (d.kind === 'folder') {
|
|
216
|
+
if ('into' in target || target.list !== null) return null
|
|
217
|
+
const from = rootIndex(next, 'folder', d.name)
|
|
218
|
+
if (from < 0) return null
|
|
219
|
+
const [it] = next.splice(from, 1)
|
|
220
|
+
next.splice(target.index > from ? target.index - 1 : target.index, 0, it!) // removing `from` shifts later slots left
|
|
221
|
+
return next
|
|
222
|
+
}
|
|
223
|
+
const src = takeBoard(next, d.name)
|
|
224
|
+
if (!src) return null
|
|
225
|
+
if ('into' in target) {
|
|
226
|
+
const f = folderIn(next, target.into)
|
|
227
|
+
if (!f) return null
|
|
228
|
+
f.boards.push(d.name)
|
|
229
|
+
return next
|
|
230
|
+
}
|
|
231
|
+
const to = src.list === target.list && target.index > src.index ? target.index - 1 : target.index
|
|
232
|
+
if (target.list === null) { next.splice(Math.min(to, next.length), 0, { kind: 'board', name: d.name }); return next }
|
|
233
|
+
const f = folderIn(next, target.list)
|
|
234
|
+
if (!f) return null
|
|
235
|
+
f.boards.splice(Math.min(to, f.boards.length), 0, d.name)
|
|
236
|
+
return next
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Move a board into a folder (at its end) or to the root at `atRoot` (default: the end). */
|
|
240
|
+
export function moveBoard(tree: TreeItem[], board: string, folder: string | null, atRoot?: number): TreeItem[] | null {
|
|
241
|
+
const next = cloneTree(tree)
|
|
242
|
+
if (!takeBoard(next, board)) return null
|
|
243
|
+
if (folder === null) { next.splice(Math.min(atRoot ?? next.length, next.length), 0, { kind: 'board', name: board }); return next }
|
|
244
|
+
const f = folderIn(next, folder)
|
|
245
|
+
if (!f) return null
|
|
246
|
+
f.boards.push(board)
|
|
247
|
+
return next
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** A new folder at root `index`, holding `board` (pulled from wherever it sat) when given. */
|
|
251
|
+
export function createFolder(tree: TreeItem[], name: string, index: number, board?: string): TreeItem[] | null {
|
|
252
|
+
if (foldersIn(tree).includes(name)) return null
|
|
253
|
+
const next = cloneTree(tree)
|
|
254
|
+
const boards: string[] = []
|
|
255
|
+
if (board) { if (!takeBoard(next, board)) return null; boards.push(board) }
|
|
256
|
+
next.splice(Math.min(index, next.length), 0, { kind: 'folder', name, boards })
|
|
257
|
+
return next
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export function renameFolder(tree: TreeItem[], from: string, to: string): TreeItem[] | null {
|
|
261
|
+
if (from === to) return cloneTree(tree)
|
|
262
|
+
if (foldersIn(tree).includes(to)) return null
|
|
263
|
+
const next = cloneTree(tree)
|
|
264
|
+
const f = folderIn(next, from)
|
|
265
|
+
if (!f) return null
|
|
266
|
+
f.name = to
|
|
267
|
+
return next
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Folders organise, never own: deleting one puts its boards back at the root, in its slot, in order. */
|
|
271
|
+
export function deleteFolder(tree: TreeItem[], name: string): TreeItem[] | null {
|
|
272
|
+
const next = cloneTree(tree)
|
|
273
|
+
const i = rootIndex(next, 'folder', name)
|
|
274
|
+
if (i < 0) return null
|
|
275
|
+
const f = next[i] as Folder
|
|
276
|
+
next.splice(i, 1, ...f.boards.map((b): TreeItem => ({ kind: 'board', name: b })))
|
|
277
|
+
return next
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Where "Move to new folder" puts the folder: the board's own root slot, or right after the
|
|
281
|
+
* folder it sits in. */
|
|
282
|
+
export const newFolderSlot = (tree: TreeItem[], board: string): number => {
|
|
283
|
+
const parent = folderOf(tree, board)
|
|
284
|
+
return parent ? rootIndex(tree, 'folder', parent) + 1 : Math.max(0, rootIndex(tree, 'board', board))
|
|
285
|
+
}
|
|
@@ -91,7 +91,7 @@ Report where the request came from: chat requests get chat replies; only comment
|
|
|
91
91
|
## Frames
|
|
92
92
|
- A frame = one file: design/scenes/<scene>/<name>.tsx or .html. One frame, one surface.
|
|
93
93
|
- It default-exports a React component. No imports from the tool are needed. Optional:
|
|
94
|
-
export const meta = { title: "...", viewport: "mobile" } // literal values only
|
|
94
|
+
export const meta = { title: "...", viewport: "mobile", description: "..." } // literal values only
|
|
95
95
|
// viewport names come from design/config.ts (default: mobile, tablet, laptop, monitor;
|
|
96
96
|
// tv available commented-out). Pick the one the screen is designed for - the human can
|
|
97
97
|
// flip the whole board to any device (Devices menu; digit keys - 0 restores each
|
|
@@ -131,8 +131,17 @@ Report where the request came from: chat requests get chat replies; only comment
|
|
|
131
131
|
setTimeout(() => r(orders), 800)) and let the frame render its skeleton while awaiting.
|
|
132
132
|
|
|
133
133
|
## Orientation
|
|
134
|
-
- design/manifest.json
|
|
135
|
-
|
|
134
|
+
- design/manifest.json is the canvas with its purpose: the project (name, description),
|
|
135
|
+
every folder and board in sidebar order, every scene and frame - each with its
|
|
136
|
+
`description` when one was written. Read it before exploring; `marver dev` keeps it
|
|
137
|
+
fresh. `npx marver boards` prints the boards part as a tree.
|
|
138
|
+
- **Descriptions.** Every object takes one optional `description`: one sentence, what it
|
|
139
|
+
is for and its state when that is not obvious (≤ ~160 chars). Project: `description`
|
|
140
|
+
in design/config.ts. Board: `"description"` in its JSON. Folder: on its `_folders.json`
|
|
141
|
+
entry. Scene: the FIRST line of its `_brief.md`. Frame: `meta.description`. Write it
|
|
142
|
+
when you create the thing; keep it true when the state changes (retired, winning
|
|
143
|
+
direction, superseded); before a session ends, re-read the manifest and fix any
|
|
144
|
+
description your session made false. That is how the next session orients in one read.
|
|
136
145
|
- Component galleries: create design/components/<name>/variants.tsx rendering each variant
|
|
137
146
|
and each state (default / hover-styled / focus / disabled / loading) of one ui component.
|
|
138
147
|
|
|
@@ -159,8 +168,12 @@ edges; a variant-group name is one indivisible atom). **The default composition
|
|
|
159
168
|
ONE horizontal band**: scenes side by side, frames flowing left to right; a second
|
|
160
169
|
band only when you can say why the eye should move down, and then with generous
|
|
161
170
|
vertical space. Without a recipe the shell stacks every scene as its own row - so
|
|
162
|
-
every curated board carries one.
|
|
163
|
-
|
|
171
|
+
every curated board carries one. Boards can sit in **folders** (one level): put
|
|
172
|
+
`"folder": "<name>"` on a board file; `design/boards/_folders.json` names empty folders
|
|
173
|
+
and ranks them - the human creates, renames, and drags folders in the sidebar too, so
|
|
174
|
+
run `npx marver boards` (the tree as the files say it is) before you organise. BEFORE
|
|
175
|
+
creating a board, organising boards, or publishing anything, read instructions/boards.md
|
|
176
|
+
(the layout grammar, file format, folders and their moves, publishing rules).
|
|
164
177
|
|
|
165
178
|
A round of feedback on a scene the human has already reviewed starts with a
|
|
166
179
|
**version snapshot** (`design/scenes/<scene>-v<N>/` on the `archive` board) BEFORE
|
|
@@ -91,7 +91,7 @@ Report where the request came from: chat requests get chat replies; only comment
|
|
|
91
91
|
## Frames
|
|
92
92
|
- A frame = one file: design/scenes/<scene>/<name>.tsx or .html. One frame, one surface.
|
|
93
93
|
- It default-exports a React component. No imports from the tool are needed. Optional:
|
|
94
|
-
export const meta = { title: "...", viewport: "mobile" } // literal values only
|
|
94
|
+
export const meta = { title: "...", viewport: "mobile", description: "..." } // literal values only
|
|
95
95
|
// viewport names come from design/config.ts (default: mobile, tablet, laptop, monitor;
|
|
96
96
|
// tv available commented-out). Pick the one the screen is designed for - the human can
|
|
97
97
|
// flip the whole board to any device (Devices menu; digit keys - 0 restores each
|
|
@@ -130,8 +130,17 @@ Report where the request came from: chat requests get chat replies; only comment
|
|
|
130
130
|
setTimeout(() => r(orders), 800)) and let the frame render its skeleton while awaiting.
|
|
131
131
|
|
|
132
132
|
## Orientation
|
|
133
|
-
- design/manifest.json
|
|
134
|
-
|
|
133
|
+
- design/manifest.json is the canvas with its purpose: the project (name, description),
|
|
134
|
+
every folder and board in sidebar order, every scene and frame - each with its
|
|
135
|
+
`description` when one was written. Read it before exploring; `marver dev` keeps it
|
|
136
|
+
fresh. `npx marver boards` prints the boards part as a tree.
|
|
137
|
+
- **Descriptions.** Every object takes one optional `description`: one sentence, what it
|
|
138
|
+
is for and its state when that is not obvious (≤ ~160 chars). Project: `description`
|
|
139
|
+
in design/config.ts. Board: `"description"` in its JSON. Folder: on its `_folders.json`
|
|
140
|
+
entry. Scene: the FIRST line of its `_brief.md`. Frame: `meta.description`. Write it
|
|
141
|
+
when you create the thing; keep it true when the state changes (retired, winning
|
|
142
|
+
direction, superseded); before a session ends, re-read the manifest and fix any
|
|
143
|
+
description your session made false. That is how the next session orients in one read.
|
|
135
144
|
- Component galleries: create design/components/<name>/variants.tsx rendering each variant
|
|
136
145
|
and each state (default / hover-styled / focus / disabled / loading) of one ui component.
|
|
137
146
|
|
|
@@ -159,8 +168,12 @@ edges; a variant-group name is one indivisible atom). **The default composition
|
|
|
159
168
|
ONE horizontal band**: scenes side by side, frames flowing left to right; a second
|
|
160
169
|
band only when you can say why the eye should move down, and then with generous
|
|
161
170
|
vertical space. Without a recipe the shell stacks every scene as its own row - so
|
|
162
|
-
every curated board carries one.
|
|
163
|
-
|
|
171
|
+
every curated board carries one. Boards can sit in **folders** (one level): put
|
|
172
|
+
`"folder": "<name>"` on a board file; `design/boards/_folders.json` names empty folders
|
|
173
|
+
and ranks them - the human creates, renames, and drags folders in the sidebar too, so
|
|
174
|
+
run `npx marver boards` (the tree as the files say it is) before you organise. BEFORE
|
|
175
|
+
creating a board, organising boards, or publishing anything, read instructions/boards.md
|
|
176
|
+
(the layout grammar, file format, folders and their moves, publishing rules).
|
|
164
177
|
|
|
165
178
|
A round of feedback on a scene the human has already reviewed starts with a
|
|
166
179
|
**version snapshot** (`design/scenes/<scene>-v<N>/` on the `archive` board) BEFORE
|