@marver-design/marver 0.15.0 → 0.16.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 +101 -0
- package/README.md +2 -1
- package/dist/boards-BmxcT3Lc.mjs +290 -0
- package/dist/boards-PuVzw5Wp.mjs +62 -0
- package/dist/{build-DfuTQZlY.mjs → build-B4yPgFNF.mjs} +72 -29
- package/dist/cli.mjs +33 -13
- package/dist/{daemon-DalgvoA9.mjs → daemon-Bbh_jmui.mjs} +1 -1
- package/dist/{dev-BxCmeU_H.mjs → dev-DH2W7Ffw.mjs} +4 -4
- package/dist/{init-QKNi9gvF.mjs → init-B7YhcN2o.mjs} +6 -3
- package/dist/{manifest-BzxSMoDB.mjs → manifest-CaslQIAO.mjs} +175 -12
- package/dist/{plugin-DJyjmQeh.mjs → plugin-BeBGu3gH.mjs} +239 -82
- package/dist/{poster-CbpzSzJu.mjs → poster-CoyobbGW.mjs} +1 -1
- package/dist/{shot-BWhoz6cU.mjs → shot-z-d-zMzf.mjs} +2 -2
- package/docs/publish.md +4 -1
- package/package.json +1 -1
- package/src/client/shell/App.tsx +45 -259
- package/src/client/shell/BoardList.tsx +438 -0
- package/src/client/shell/ContextMenu.tsx +59 -0
- package/src/client/shell/LockedApp.tsx +2 -1
- package/src/client/shell/Play.tsx +1 -0
- package/src/client/shell/icons.tsx +6 -0
- package/src/client/shell/labels.ts +4 -4
- package/src/client/shell/store.ts +156 -51
- package/src/client/shell/styles.css +40 -4
- package/src/shared/board-tree.ts +343 -0
- package/templates/AGENTS-embedded.md +25 -5
- package/templates/AGENTS-studio.md +25 -5
- package/templates/instructions/boards.md +91 -2
- package/templates/instructions/craft.md +4 -0
- package/templates/instructions/discover.md +20 -3
- 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,10 @@ 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[]
|
|
17
|
+
/** slug → title of the published boards (0.16.1 bundles); folder titles ride on `tree` */
|
|
18
|
+
titles?: Record<string, string>
|
|
15
19
|
/** publish.json v2: per-board artifact type + open/lock, and the reveal flags. */
|
|
16
20
|
policy?: { boards: Record<string, { type?: string; open?: string; lock?: boolean }>; reveal?: { structure?: boolean; source?: boolean }; lockedShell?: boolean }
|
|
17
21
|
} | null = shData
|
|
@@ -42,7 +46,7 @@ export function hydrateBoardPolicy(boards: Record<string, { type?: string; open?
|
|
|
42
46
|
}
|
|
43
47
|
|
|
44
48
|
export interface FrameEntry { id: string; file: string; kind: 'tsx' | 'html'; scene: string; title?: string; viewport?: string; theme?: string; variantGroup?: string; variant?: string; intent?: string; contentWidth?: number; slide?: boolean }
|
|
45
|
-
export interface Manifest { frames: FrameEntry[]; scenes: { name: string; frames: number }[] }
|
|
49
|
+
export interface Manifest { frames: FrameEntry[]; scenes: { name: string; frames: number; title?: string; description?: string }[] }
|
|
46
50
|
export interface Node {
|
|
47
51
|
key: string; frame: string; x: number; y: number; w: number; h: number
|
|
48
52
|
/** RESOLVED theme (what renders): themeUser ?? frame meta.theme ?? viewTheme. */
|
|
@@ -123,21 +127,56 @@ export const modeAllowed = (board: string, mode: 'present' | 'focus' | 'slides')
|
|
|
123
127
|
export { cap, humanize } from './labels.ts'
|
|
124
128
|
import { cap, humanize } from './labels.ts'
|
|
125
129
|
import { canAutoReload } from './canvas/ready-watch.ts'
|
|
130
|
+
import { buildTree, flatten, labelOf, toWire, type TreeItem } from '../../shared/board-tree.ts'
|
|
126
131
|
|
|
127
|
-
/**
|
|
128
|
-
*
|
|
132
|
+
/** The CAS tokens a tree write echoes: the sha256 of every board file as last seen, and of
|
|
133
|
+
* the folder registry (null = there was no file). */
|
|
134
|
+
export interface TreeBase { boards: Record<string, string>; folders: string | null }
|
|
135
|
+
export interface TreeSnapshot { tree: TreeItem[]; base: TreeBase; titles: Record<string, string> }
|
|
136
|
+
|
|
137
|
+
/** The sidebar tree: root boards and folders in rank order, each folder's boards inside
|
|
138
|
+
* (shared/board-tree.ts), plus the hashes it was built from. `all-scenes` is not in it - it
|
|
139
|
+
* is pinned last by the callers. Throws on transport failure and on a malformed registry
|
|
140
|
+
* (the server's 422 message) - callers keep their last known tree. */
|
|
141
|
+
export async function fetchBoardTree(): Promise<TreeSnapshot> {
|
|
142
|
+
if (DATA) return { tree: DATA.tree ?? DATA.names.filter((n) => n !== 'all-scenes').map((n) => ({ kind: 'board', name: n })), base: { boards: {}, folders: null }, titles: DATA.titles ?? {} }
|
|
143
|
+
const [boards, reg] = await Promise.all([
|
|
144
|
+
fetch(`${ROUTE}/api/boards`).then((r) => r.json()) as Promise<{ name: string; sha256: string; order?: number; folder?: string; title?: string }[]>,
|
|
145
|
+
fetch(`${ROUTE}/api/folders`).then(async (r) => {
|
|
146
|
+
const j = await r.json() as { folders?: { name: string; order?: number; title?: string }[]; sha256?: string | null; error?: string }
|
|
147
|
+
if (!r.ok) throw new Error(j?.error ?? `folders ${r.status}`)
|
|
148
|
+
return j
|
|
149
|
+
}),
|
|
150
|
+
])
|
|
151
|
+
return {
|
|
152
|
+
tree: buildTree(boards, reg.folders ?? []),
|
|
153
|
+
base: { boards: Object.fromEntries(boards.map((b) => [b.name, b.sha256])), folders: reg.sha256 ?? null },
|
|
154
|
+
titles: Object.fromEntries(boards.flatMap((b) => (b.title ? [[b.name, b.title]] : []))),
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
/** A tree read's board titles become the labels' - called by the reader once it knows the
|
|
158
|
+
* read is its latest (an older response must not overwrite a newer one's labels). Folder
|
|
159
|
+
* titles ride on the tree's folder items: a board and a folder may share a slug, never one map. */
|
|
160
|
+
export function rememberTitles(titles: Record<string, string>) {
|
|
161
|
+
if (JSON.stringify(useStore.getState().boardTitles) !== JSON.stringify(titles)) useStore.setState({ boardTitles: titles })
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Board names for switchers: the sidebar's reading order (folders flattened depth-first) and
|
|
165
|
+
* the auto `all-scenes` everything-board LAST - it is the expensive one, never the landing.
|
|
129
166
|
* Throws on transport failure - callers keep their last known list. */
|
|
130
167
|
export async function fetchBoardNames(): Promise<string[]> {
|
|
131
168
|
if (DATA) return DATA.names
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
|
|
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']
|
|
169
|
+
const snap = await fetchBoardTree()
|
|
170
|
+
rememberTitles(snap.titles)
|
|
171
|
+
return [...flatten(snap.tree), 'all-scenes']
|
|
138
172
|
}
|
|
139
|
-
/**
|
|
140
|
-
export const
|
|
173
|
+
/** Does the switcher carry the auto `all-scenes` board? Always in dev; published only when it shipped. */
|
|
174
|
+
export const HAS_ALL_SCENES = !DATA || DATA.names.includes('all-scenes')
|
|
175
|
+
/** Display name for a board: its title when one is set (subscribe to `boardTitles` to
|
|
176
|
+
* re-render on change), else the Title-Cased slug ('all-scenes' reads "All Scenes"). */
|
|
177
|
+
export const boardLabel = (n: string) => labelOf(n, useStore.getState().boardTitles[n])
|
|
178
|
+
/** Display name for a scene: the title in its brief's front matter, else the Title-Cased directory. */
|
|
179
|
+
export const sceneLabel = (n: string) => labelOf(n, useStore.getState().manifest?.scenes.find((s) => s.name === n)?.title)
|
|
141
180
|
|
|
142
181
|
/** The double-submit CSRF token the owner gate wants echoed (same read as comments-store). */
|
|
143
182
|
const csrf = () => /(?:^|;\s*)mv_c=([\w-]+)/.exec(document.cookie)?.[1] ?? ''
|
|
@@ -183,6 +222,8 @@ const initialViewTheme = () => {
|
|
|
183
222
|
return CONFIG.themes[0] ?? 'light'
|
|
184
223
|
}
|
|
185
224
|
const manifestKey = (m: Manifest) => JSON.stringify(m.frames) // any change counts, not just added/removed ids
|
|
225
|
+
let scenesRev = 0 // bumps per sh:scenes, so a load that straddled one keeps the live labels
|
|
226
|
+
let liveScenes: Manifest['scenes'] | null = null // the last sh:scenes payload - applied late when it beat the first manifest
|
|
186
227
|
|
|
187
228
|
/** Latest measured content heights, keyed frameId@width. TRANSIENT by design:
|
|
188
229
|
* auto sizes are never serialized - a reload remeasures. */
|
|
@@ -279,8 +320,17 @@ interface State {
|
|
|
279
320
|
setDeviceView(name: string | null): void
|
|
280
321
|
resizeSelected(name: string | null): void
|
|
281
322
|
switchBoard(name: string): Promise<void>
|
|
282
|
-
|
|
283
|
-
|
|
323
|
+
/** what the sidebar labels boards by: slug → title, off the last tree read */
|
|
324
|
+
boardTitles: Record<string, string>
|
|
325
|
+
/** retitle a board - what humans see ('' = back to the Title-Cased slug). The file never
|
|
326
|
+
* moves: its name is the board's identity (agents, publish.json, URLs, comment threads).
|
|
327
|
+
* `baseHash` = the file as last seen; a 409 (`stale`) means someone wrote it since. */
|
|
328
|
+
renameBoard(name: string, title: string, baseHash?: string): Promise<{ ok: true } | { ok: false; stale?: boolean; error?: string }>
|
|
329
|
+
/** a scene's title, into its brief's front matter (the directory never moves) */
|
|
330
|
+
renameScene(scene: string, title: string): Promise<{ ok: boolean; error?: string }>
|
|
331
|
+
/** `sh:scenes`: the scenes changed (a brief's title or description) with the frames intact */
|
|
332
|
+
setScenes(scenes: Manifest['scenes']): void
|
|
333
|
+
arrangeBoards(tree: TreeItem[], base: TreeBase): Promise<{ ok: true } | { ok: false; stale: boolean; error?: string }>
|
|
284
334
|
pulsePath(): void
|
|
285
335
|
copyFrameImage(scale: 2 | 4): void
|
|
286
336
|
setScale(s: number): void
|
|
@@ -317,8 +367,18 @@ export const useStore = create<State>((set, get) => {
|
|
|
317
367
|
let editRev = 0 // bumps per edit; a stale save response may never clear dirty over a newer edit
|
|
318
368
|
let loadSeq = 0 // stale boot() responses never overwrite a newer board
|
|
319
369
|
let switchSeq = 0 // last click wins when board switches race
|
|
320
|
-
|
|
321
|
-
|
|
370
|
+
// While a board file is being renamed or rewritten by a structural write (rename, the
|
|
371
|
+
// sidebar's tree arrange), autosave must not fire against it (a mustExist PUT would
|
|
372
|
+
// 409-gone and clear dirty, losing the in-flight edit; a stale baseHash would 409 and
|
|
373
|
+
// reload). A COUNT, not a flag: two holders never release each other. Structural writes
|
|
374
|
+
// also run one at a time through `structChain`, so a rename can never interleave a
|
|
375
|
+
// tree write on the same file.
|
|
376
|
+
let saveHolds = 0
|
|
377
|
+
const holdSaves = () => { saveHolds++; clearTimeout(saveTimer) }
|
|
378
|
+
const releaseSaves = () => { if (--saveHolds === 0 && get().dirty) scheduleSave() }
|
|
379
|
+
let structChain: Promise<unknown> = Promise.resolve()
|
|
380
|
+
const structural = <T,>(fn: () => Promise<T>): Promise<T> => { const p = structChain.then(fn, fn); structChain = p.catch(() => {}); return p }
|
|
381
|
+
const scheduleSave = () => { editRev++; if (saveHolds > 0) return; clearTimeout(saveTimer); saveTimer = setTimeout(() => get().save(), 500) }
|
|
322
382
|
|
|
323
383
|
// One cancelable, BOARD-SCOPED reflow after content measurements settle.
|
|
324
384
|
// The captured board name is the generation guard - a debounce surviving a board
|
|
@@ -566,13 +626,13 @@ export const useStore = create<State>((set, get) => {
|
|
|
566
626
|
return {
|
|
567
627
|
manifest: null, nodes: [], selection: [], interact: null, viewTheme: initialViewTheme(), play: null, gesture: false, laser: false,
|
|
568
628
|
board: DATA?.default ?? 'all-scenes', boardAuto: (DATA?.default ?? 'all-scenes') === 'all-scenes', deviceView: null, sceneRows: null, layout: null, layoutRaw: undefined, baseLayout: null,
|
|
569
|
-
panelOpen: true, scale: 1, toasts: [], working: [], workingSince: {}, boardHash: null, dirty: false,
|
|
629
|
+
panelOpen: true, scale: 1, toasts: [], working: [], workingSince: {}, boardHash: null, dirty: false, boardTitles: DATA?.titles ?? {},
|
|
570
630
|
pendingFrameRevisions: {}, externalLeases: {}, playUpdateRevision: null, playNav: 0, pathPulse: 0, imagePulse: 0, imageBusy: false,
|
|
571
631
|
|
|
572
632
|
async boot() {
|
|
573
633
|
const seq = ++loadSeq
|
|
574
634
|
const boardName = get().board
|
|
575
|
-
const revAtStart = editRev
|
|
635
|
+
const revAtStart = editRev, scenesAtStart = scenesRev
|
|
576
636
|
const next = await loadBoardState(boardName)
|
|
577
637
|
if (seq !== loadSeq) return false // a newer load superseded this one
|
|
578
638
|
if (!next) { get().toast(`board "${boardName}" failed to load`); return false }
|
|
@@ -582,11 +642,13 @@ export const useStore = create<State>((set, get) => {
|
|
|
582
642
|
set(next)
|
|
583
643
|
if (next.dirty) scheduleSave() // load-time prune must reach the disk
|
|
584
644
|
if (live && manifestKey(live) !== manifestKey(next.manifest as Manifest)) get().applyManifest(live)
|
|
645
|
+
else if (scenesRev !== scenesAtStart && liveScenes) set({ manifest: { ...get().manifest!, scenes: liveScenes } }) // an sh:scenes that landed mid-fetch outranks the file we read
|
|
585
646
|
return true
|
|
586
647
|
},
|
|
587
648
|
|
|
588
649
|
async switchBoard(name) {
|
|
589
650
|
const mySwitch = ++switchSeq // also cancels any pending switch (incl. re-clicking the current board)
|
|
651
|
+
const scenesAtStart = scenesRev
|
|
590
652
|
if (name === get().board) return
|
|
591
653
|
// a resize held across a switch: settle THIS board (retidy if it has a recipe)
|
|
592
654
|
// before the flush, so the torn mid-gesture state is never what reaches disk
|
|
@@ -614,45 +676,88 @@ export const useStore = create<State>((set, get) => {
|
|
|
614
676
|
set({ board: name, interact: null, ...next })
|
|
615
677
|
if (next.dirty) scheduleSave() // load-time prune must reach the disk
|
|
616
678
|
if (live && manifestKey(live) !== manifestKey(next.manifest as Manifest)) get().applyManifest(live)
|
|
679
|
+
else if (scenesRev !== scenesAtStart && liveScenes) set({ manifest: { ...get().manifest!, scenes: liveScenes } })
|
|
617
680
|
},
|
|
618
681
|
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
682
|
+
renameBoard(name, title, baseHash) {
|
|
683
|
+
return structural(async () => {
|
|
684
|
+
const from = name
|
|
685
|
+
const active = from === get().board
|
|
686
|
+
// Retitling the ACTIVE board rewrites its file under the autosave. Flush any pending
|
|
687
|
+
// write FIRST (switchBoard's pattern), so a PUT never races the rewrite.
|
|
688
|
+
if (active) {
|
|
689
|
+
let ok = true
|
|
690
|
+
for (let i = 0; i < 5 && get().dirty && ok; i++) { clearTimeout(saveTimer); ok = await get().save() }
|
|
691
|
+
if (get().dirty) return { ok: false, error: 'unsaved changes - try again' }
|
|
692
|
+
// hold autosave across the round-trip: an edit landing mid-write would PUT against a
|
|
693
|
+
// hash the rewrite is about to move
|
|
694
|
+
holdSaves()
|
|
695
|
+
}
|
|
696
|
+
try {
|
|
697
|
+
// the active board's hash is freshest in the store (an autosave may have landed since
|
|
698
|
+
// the sidebar looked); every other board's is the sidebar's
|
|
699
|
+
const base = active && get().boardHash ? get().boardHash : baseHash
|
|
700
|
+
let res: Response
|
|
701
|
+
try { res = await postOwner('boards/rename', { from, title, ...(base ? { baseHash: base } : {}) }) }
|
|
702
|
+
catch { return { ok: false, error: 'could not reach the dev server' } }
|
|
703
|
+
const body = await res.json().catch(() => ({} as { error?: string; sha256?: string }))
|
|
704
|
+
if (!res.ok) return { ok: false, stale: res.status === 409, error: body?.error ?? `rename failed (${res.status})` }
|
|
705
|
+
// the write rewrote the file: the active board's CAS token moves to the hash the server
|
|
706
|
+
// answered (else the next autosave 409s against our own write) - before releasing the hold
|
|
707
|
+
if (active && get().board === from && body?.sha256) set({ boardHash: body.sha256 })
|
|
708
|
+
const titles = { ...get().boardTitles } // the label changes now; the next tree read confirms it from the file
|
|
709
|
+
if (title) titles[from] = title; else delete titles[from]
|
|
710
|
+
set({ boardTitles: titles })
|
|
711
|
+
return { ok: true }
|
|
712
|
+
} finally { if (active) releaseSaves() } // whatever happened, autosave resumes
|
|
713
|
+
})
|
|
714
|
+
},
|
|
715
|
+
|
|
716
|
+
async renameScene(scene, title) {
|
|
636
717
|
let res: Response
|
|
637
|
-
try { res = await postOwner('
|
|
638
|
-
catch {
|
|
639
|
-
if (!res.ok) {
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
}
|
|
644
|
-
// Content is byte-identical after a file move, so boardHash still matches for the next
|
|
645
|
-
// autosave; only the name (and the URL, via the board-change subscription) changes. Set
|
|
646
|
-
// the new name BEFORE releasing the lock so a resumed save targets `to`.
|
|
647
|
-
if (active && get().board === from) set({ board: to })
|
|
648
|
-
release()
|
|
718
|
+
try { res = await postOwner('scenes/rename', { scene, title }) }
|
|
719
|
+
catch { return { ok: false, error: 'could not reach the dev server' } }
|
|
720
|
+
if (!res.ok) { const e = await res.json().catch(() => ({} as { error?: string })); return { ok: false, error: e?.error ?? `rename failed (${res.status})` } }
|
|
721
|
+
// the label changes now; the watcher's sh:scenes confirms it from the file
|
|
722
|
+
const m = get().manifest
|
|
723
|
+
if (m) set({ manifest: { ...m, scenes: m.scenes.map((s) => (s.name === scene ? { ...s, ...(title ? { title } : {}), ...(title ? {} : { title: undefined }) } : s)) } })
|
|
649
724
|
return { ok: true }
|
|
650
725
|
},
|
|
726
|
+
setScenes(scenes) {
|
|
727
|
+
if (!Array.isArray(scenes)) return
|
|
728
|
+
scenesRev++
|
|
729
|
+
liveScenes = scenes
|
|
730
|
+
const m = get().manifest
|
|
731
|
+
if (m) set({ manifest: { ...m, scenes } }) // before the first manifest: kept for the boot's commit
|
|
732
|
+
},
|
|
651
733
|
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
734
|
+
// The whole sidebar tree in one write: order, membership, the folders themselves. The
|
|
735
|
+
// write rewrites the ACTIVE board's file too (its `order`/`folder`), so it runs like a
|
|
736
|
+
// rename: flush the pending autosave first, hold autosave across the round-trip, then
|
|
737
|
+
// advance boardHash to the hash the server answered - the autosave's CAS token stays
|
|
738
|
+
// true and the watcher's echo is filtered as our own. `base` carries the hashes the
|
|
739
|
+
// sidebar last saw; a 409 (`stale`) means someone else wrote first - refetch and replay.
|
|
740
|
+
arrangeBoards(tree, base) {
|
|
741
|
+
return structural(async () => {
|
|
742
|
+
let ok = true
|
|
743
|
+
for (let i = 0; i < 5 && get().dirty && ok; i++) { clearTimeout(saveTimer); ok = await get().save() }
|
|
744
|
+
if (get().dirty) return { ok: false as const, stale: false, error: 'unsaved changes - try again' }
|
|
745
|
+
holdSaves()
|
|
746
|
+
try {
|
|
747
|
+
// the active board's hash is freshest in the store (an autosave may have landed since
|
|
748
|
+
// the sidebar looked); every other board's is the sidebar's
|
|
749
|
+
const active = get().board
|
|
750
|
+
const boards = { ...base.boards, ...(get().boardHash && base.boards[active] !== undefined ? { [active]: get().boardHash } : {}) }
|
|
751
|
+
let res: Response
|
|
752
|
+
try { res = await postOwner('boards/reorder', { tree: toWire(tree), base: { boards, folders: base.folders } }) }
|
|
753
|
+
catch { return { ok: false as const, stale: false, error: 'could not reach the dev server' } }
|
|
754
|
+
const body = await res.json().catch(() => ({} as { error?: string; sha256?: { boards?: Record<string, string> } }))
|
|
755
|
+
if (!res.ok) return { ok: false as const, stale: res.status === 409, error: body?.error }
|
|
756
|
+
const sha = body?.sha256?.boards?.[get().board]
|
|
757
|
+
if (sha) set({ boardHash: sha })
|
|
758
|
+
return { ok: true as const }
|
|
759
|
+
} finally { releaseSaves() } // whatever happened, autosave resumes
|
|
760
|
+
})
|
|
656
761
|
},
|
|
657
762
|
|
|
658
763
|
applyManifest(m) {
|
|
@@ -1141,7 +1246,7 @@ export const useStore = create<State>((set, get) => {
|
|
|
1141
1246
|
save() {
|
|
1142
1247
|
// a rename is moving this board's file - defer every save so nothing writes to the OLD
|
|
1143
1248
|
// name mid-move (renameBoard reschedules once the move commits under the new name)
|
|
1144
|
-
if (
|
|
1249
|
+
if (saveHolds > 0) return Promise.resolve(false)
|
|
1145
1250
|
// a resize gesture in flight = torn state (new sizes, pre-recipe positions):
|
|
1146
1251
|
// even a PREVIOUSLY scheduled timer must defer to the gesture-end save
|
|
1147
1252
|
if (get().gesture && resizedInGesture) { scheduleSave(); return Promise.resolve(false) }
|
|
@@ -505,9 +505,42 @@ 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
|
+
an open EMPTY folder draws that indented seam under its own header (drop-in) */
|
|
531
|
+
.sh-panel .it.board.in-folder.drop-before::after, .sh-panel .it.board.in-folder.drop-after::after { left: 28px }
|
|
532
|
+
.sh-panel .it.folder.drop-in { position: relative }
|
|
533
|
+
.sh-panel .it.folder.drop-in::after { content: ''; position: absolute; left: 28px; right: 6px; bottom: -1px; height: 3px; border-radius: 3px;
|
|
534
|
+
background: #0088ff; pointer-events: none }
|
|
535
|
+
/* the Boards header carries a quiet folder-plus: tertiary, shows on hover of the header row */
|
|
536
|
+
.sh-panel .sh-boards .hd { display: flex; align-items: center; margin-right: 6px }
|
|
537
|
+
.sh-panel .sh-boards .hd span { flex: 1 }
|
|
538
|
+
/* a bare glyph, no button chrome (padding would shrink it): tertiary, pointer, full ink on hover */
|
|
539
|
+
.sh-panel .sh-hd-add { flex: none; color: var(--glass-ink-3); cursor: pointer; outline: none; border-radius: 4px;
|
|
540
|
+
opacity: 0; transition: opacity .15s ease, color .15s ease }
|
|
541
|
+
.sh-panel .sh-boards .hd:hover .sh-hd-add, .sh-panel .sh-hd-add:focus-visible { opacity: 1 }
|
|
542
|
+
.sh-panel .sh-hd-add:hover { color: var(--glass-ink) }
|
|
543
|
+
.sh-panel .sh-hd-add:focus-visible { box-shadow: 0 0 0 2px var(--accent) }
|
|
511
544
|
/* frame rows: one indent in (aligned to the label line), quieter ink */
|
|
512
545
|
.sh-panel .sub { display: flex; align-items: center; height: 28px; padding: 0 8px 0 31px; margin-bottom: 1px;
|
|
513
546
|
color: var(--glass-ink-2); font-size: 13px; font-weight: 400; border-radius: 8px; cursor: pointer }
|
|
@@ -562,8 +595,11 @@ body.sh-commenting .sh-lean { opacity: 0 }
|
|
|
562
595
|
cursor: pointer; text-align: left }
|
|
563
596
|
.sh-menu button:hover { background: var(--glass-hover) }
|
|
564
597
|
.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
|
-
|
|
598
|
+
/* the sidebar right-click menu keeps its labels verbatim (they match the toolbar exactly);
|
|
599
|
+
it scrolls rather than overflowing the window when the folder list grows long */
|
|
600
|
+
.sh-ctxmenu { max-height: calc(100vh - 16px); max-width: 240px; overflow-y: auto; scrollbar-width: thin }
|
|
601
|
+
.sh-ctxmenu button { flex: none; max-width: 100% }
|
|
602
|
+
.sh-ctxmenu button span { text-transform: none; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0 }
|
|
567
603
|
.sh-menu .chk { color: var(--glass-accent) }
|
|
568
604
|
.sh-menu kbd { font: 500 10.5px -apple-system, system-ui, sans-serif; color: var(--glass-ink-3); background: none; border: 0 }
|
|
569
605
|
.sh-menu .div { height: 1px; background: var(--glass-brd); margin: 4px 6px }
|