@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.
Files changed (36) hide show
  1. package/CHANGELOG.md +101 -0
  2. package/README.md +2 -1
  3. package/dist/boards-BmxcT3Lc.mjs +290 -0
  4. package/dist/boards-PuVzw5Wp.mjs +62 -0
  5. package/dist/{build-DfuTQZlY.mjs → build-B4yPgFNF.mjs} +72 -29
  6. package/dist/cli.mjs +33 -13
  7. package/dist/{daemon-DalgvoA9.mjs → daemon-Bbh_jmui.mjs} +1 -1
  8. package/dist/{dev-BxCmeU_H.mjs → dev-DH2W7Ffw.mjs} +4 -4
  9. package/dist/{init-QKNi9gvF.mjs → init-B7YhcN2o.mjs} +6 -3
  10. package/dist/{manifest-BzxSMoDB.mjs → manifest-CaslQIAO.mjs} +175 -12
  11. package/dist/{plugin-DJyjmQeh.mjs → plugin-BeBGu3gH.mjs} +239 -82
  12. package/dist/{poster-CbpzSzJu.mjs → poster-CoyobbGW.mjs} +1 -1
  13. package/dist/{shot-BWhoz6cU.mjs → shot-z-d-zMzf.mjs} +2 -2
  14. package/docs/publish.md +4 -1
  15. package/package.json +1 -1
  16. package/src/client/shell/App.tsx +45 -259
  17. package/src/client/shell/BoardList.tsx +438 -0
  18. package/src/client/shell/ContextMenu.tsx +59 -0
  19. package/src/client/shell/LockedApp.tsx +2 -1
  20. package/src/client/shell/Play.tsx +1 -0
  21. package/src/client/shell/icons.tsx +6 -0
  22. package/src/client/shell/labels.ts +4 -4
  23. package/src/client/shell/store.ts +156 -51
  24. package/src/client/shell/styles.css +40 -4
  25. package/src/shared/board-tree.ts +343 -0
  26. package/templates/AGENTS-embedded.md +25 -5
  27. package/templates/AGENTS-studio.md +25 -5
  28. package/templates/instructions/boards.md +91 -2
  29. package/templates/instructions/craft.md +4 -0
  30. package/templates/instructions/discover.md +20 -3
  31. package/templates/instructions/iterate.md +5 -4
  32. package/templates/instructions/review.md +4 -0
  33. package/templates/instructions/shape.md +1 -1
  34. package/templates/instructions/welcome.md +4 -1
  35. package/templates/instructions/wireframe.md +3 -0
  36. 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
- /** Board names for switchers: the agent's curated boards FIRST (ranked by each board's `order`, then
128
- * name), and the auto `all-scenes` everything-board LAST - it is the expensive one, never the landing.
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 list: { name: string; order?: number }[] = await (await fetch(`${ROUTE}/api/boards`)).json()
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']
169
+ const snap = await fetchBoardTree()
170
+ rememberTitles(snap.titles)
171
+ return [...flatten(snap.tree), 'all-scenes']
138
172
  }
139
- /** Display name for a board: the reserved 'all-scenes' key reads as "All scenes". */
140
- export const boardLabel = (n: string) => humanize(n)
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
- renameBoard(from: string, to: string): Promise<{ ok: boolean; error?: string }>
283
- reorderBoards(order: string[]): Promise<boolean>
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
- let renameLock = false // while a board file is being renamed, autosave must not fire against the OLD name (a mustExist PUT would 409-gone and clear dirty, losing the in-flight edit)
321
- const scheduleSave = () => { editRev++; if (renameLock) return; clearTimeout(saveTimer); saveTimer = setTimeout(() => get().save(), 500) }
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
- async renameBoard(from, to) {
620
- const active = from === get().board
621
- // Renaming the ACTIVE board renames its file out from under the autosave. Flush any
622
- // pending write FIRST (switchBoard's pattern), so a mustExist PUT never races the move.
623
- if (active) {
624
- let ok = true
625
- for (let i = 0; i < 5 && get().dirty && ok; i++) { clearTimeout(saveTimer); ok = await get().save() }
626
- if (get().dirty) return { ok: false, error: 'unsaved changes - try again' }
627
- // hold autosave across the round-trip: an edit landing mid-rename must NOT save to the
628
- // old name (a mustExist PUT would 409-gone and clear dirty, losing the edit)
629
- renameLock = true
630
- clearTimeout(saveTimer)
631
- }
632
- // Release the lock and resume autosave. Re-read the LIVE board, not the captured `active`:
633
- // a board switch may have completed while we awaited, so only the board that is STILL
634
- // `from` gets renamed to `to` in state (a switched-away board keeps its own name/nodes).
635
- const release = () => { if (active) { renameLock = false; if (get().dirty) scheduleSave() } }
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('boards/rename', { from, to }) }
638
- catch { release(); return { ok: false, error: 'could not reach the dev server' } }
639
- if (!res.ok) {
640
- release()
641
- const e = await res.json().catch(() => ({} as { error?: string }))
642
- return { ok: false, error: e?.error ?? `rename failed (${res.status})` }
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
- async reorderBoards(order) {
653
- let res: Response
654
- try { res = await postOwner('boards/reorder', { order }) } catch { return false }
655
- return res.ok
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 (renameLock) return Promise.resolve(false)
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.board.editing input { flex: 1; min-width: 0; padding: 0; border: 0; outline: 0; background: none;
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
- .sh-ctxmenu button span { text-transform: none }
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 }