@marver-design/marver 0.19.2 → 0.21.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.
Files changed (39) hide show
  1. package/CHANGELOG.md +90 -0
  2. package/README.md +8 -10
  3. package/dist/{bake-kaf5kGZ7.mjs → bake-BID6mo-N.mjs} +1 -1
  4. package/dist/{boards-BmxcT3Lc.mjs → boards-BwiDAmPf.mjs} +95 -48
  5. package/dist/{boards-PuVzw5Wp.mjs → boards-DnLewfj8.mjs} +20 -11
  6. package/dist/{build-7ed5H2vT.mjs → build-C7MqQ7hq.mjs} +18 -8
  7. package/dist/cli.mjs +19 -13
  8. package/dist/{daemon-DbHvLQUL.mjs → daemon-CRZFpl6K.mjs} +1 -1
  9. package/dist/{dev-D3mP2x27.mjs → dev-BNZF4Mup.mjs} +5 -5
  10. package/dist/{init-BQYCS3EU.mjs → init-C34BY3R4.mjs} +34 -33
  11. package/dist/{manifest-B01PSyDc.mjs → manifest-mMfUhPtL.mjs} +8 -5
  12. package/dist/{plugin-DI-7NAnx.mjs → plugin-omHLCn91.mjs} +34 -26
  13. package/dist/{poster-DNh6N27C.mjs → poster-BvxiAzy1.mjs} +1 -1
  14. package/dist/{publish-bakes-Dp-ZFk3d.mjs → publish-bakes-BqzAAa3w.mjs} +8 -2
  15. package/dist/{shot-DMDvDbeP.mjs → shot-DswS4iRK.mjs} +7 -7
  16. package/docs/live-jam.md +1 -1
  17. package/docs/slides.md +89 -89
  18. package/docs/sticky-notes.md +9 -0
  19. package/package.json +1 -1
  20. package/src/client/const.ts +27 -9
  21. package/src/client/content/chart.tsx +8 -8
  22. package/src/client/content/index.tsx +5 -4
  23. package/src/client/content/slide.tsx +26 -201
  24. package/src/client/frame-host/main.tsx +10 -0
  25. package/src/client/shell/BoardList.tsx +111 -52
  26. package/src/client/shell/Comments.tsx +12 -4
  27. package/src/client/shell/Play.tsx +26 -14
  28. package/src/client/shell/Toolbar.tsx +7 -5
  29. package/src/client/shell/canvas/FrameNode.tsx +16 -2
  30. package/src/client/shell/store.ts +8 -8
  31. package/src/client/shell/styles.css +10 -9
  32. package/src/client/stage/main.tsx +56 -7
  33. package/src/shared/board-tree.ts +271 -140
  34. package/templates/AGENTS-embedded.md +3 -3
  35. package/templates/AGENTS-studio.md +3 -3
  36. package/templates/instructions/boards.md +47 -22
  37. package/templates/instructions/reference/deck-layouts.md +153 -199
  38. package/templates/instructions/reference/deck-story.md +6 -6
  39. package/templates/instructions/slides.md +275 -383
@@ -1,17 +1,18 @@
1
- import { useEffect, useRef, useState, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react'
1
+ import { useEffect, useRef, useState, type CSSProperties, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react'
2
2
  import { useStore, HAS_ALL_SCENES, PUBLISHED, fetchBoardTree, rememberTitles, type TreeBase } from './store.ts'
3
3
  import { canvasCtl } from './canvas/Canvas.tsx'
4
4
  import { Tip } from './Tip.tsx'
5
5
  import { copyToClipboard, type MenuItem, type MenuOpener } from './ContextMenu.tsx'
6
6
  import { ArrowLineUpIcon, CardsIcon, CardsThreeIcon, FolderIcon, FolderMinusIcon, FolderOpenIcon, FolderPlusIcon, PencilSimpleIcon, SignpostIcon } from './icons.tsx'
7
7
  import {
8
- applyDrop, boardsIn, createFolder, deleteFolder, folderIn, folderOf, foldersIn, humanize, isOwnSlot, labelOf, moveBoard, newFolderSlot, readTitle, resolveDrop, retitleFolder, rootIndex, slugFor,
8
+ applyDrop, boardsIn, createFolder, deleteFolder, depthOf, folderEntries, folderIn, folderOf, foldersIn, holdsFolders, humanize, INDENT, isOwnSlot, labelOf, listIn, moveBoard, moveFolderToRoot, newFolderSlot, parentOf, readTitle,
9
+ resolveDrop, retitleFolder, rootIndex, slugFor,
9
10
  type Drag, type Drop, type Folder, type Row, type TreeItem,
10
11
  } from '../../shared/board-tree.ts'
11
12
 
12
13
  /**
13
- * Boards live at the top of the sidebar - always visible, one click to switch - in ONE level
14
- * of folders. The tree (shared/board-tree.ts) comes from the board files' `order`/`folder`
14
+ * Boards live at the top of the sidebar - always visible, one click to switch - in up to TWO
15
+ * levels of folders (a folder holds boards and folders; a sub-folder holds boards). The tree (shared/board-tree.ts) comes from the board files' `order`/`folder`
15
16
  * fields plus the `_folders.json` registry; every mutation here (drag, new/rename/delete
16
17
  * folder, move) is one optimistic tree write through `arrangeBoards`, replayed once on a
17
18
  * 409 (someone else wrote first). The list refreshes on mount, window focus, a slow poll and
@@ -23,8 +24,9 @@ const CLOSED_KEY = 'mv-folders-closed' // collapsed folders are a viewer prefe
23
24
  const readClosed = (): Record<string, true> => { try { return JSON.parse(localStorage.getItem(CLOSED_KEY) ?? '{}') } catch { return {} } }
24
25
 
25
26
  /** The inline input: renaming a board or a folder, or naming a NEW folder that does not exist
26
- * yet - drawn at root `index`, optionally with `board` already inside it. */
27
- type Naming = { kind: 'board' | 'folder'; name: string } | { kind: 'new'; index: number; board?: string }
27
+ * yet - drawn at `index` in `parent`'s items (null = the root), optionally with `board`
28
+ * already inside it. */
29
+ type Naming = { kind: 'board' | 'folder'; name: string } | { kind: 'new'; index: number; board?: string; parent: string | null }
28
30
  /** A mutation as intent: applied to whichever tree is current, so a 409 can replay it. */
29
31
  type Intent = (tree: TreeItem[]) => TreeItem[] | null
30
32
 
@@ -185,7 +187,7 @@ export function BoardList({ onMenu }: { onMenu: MenuOpener }) {
185
187
  refresh()
186
188
  return
187
189
  }
188
- const folderLabels = tree.filter((it): it is Folder => it.kind === 'folder' && (n.kind !== 'folder' || it.name !== n.name)).map((it) => labelOf(it.name, it.title))
190
+ const folderLabels = folderEntries(tree).map((e) => e.folder).filter((f) => n.kind !== 'folder' || f.name !== n.name).map((f) => labelOf(f.name, f.title))
189
191
  if (n.kind === 'folder') {
190
192
  const current = folderIn(tree, n.name)
191
193
  if (!current || title === labelOf(n.name, current.title)) { setNaming(null); return }
@@ -196,11 +198,11 @@ export function BoardList({ onMenu }: { onMenu: MenuOpener }) {
196
198
  }
197
199
  if (n.kind !== 'new') return
198
200
  if (folderLabels.includes(title)) { taken('folder'); return }
199
- const { index, board: withBoard } = n
201
+ const { index, board: withBoard, parent } = n
200
202
  const slug = slugFor(title, foldersIn(tree)) // "Old stuff" → old-stuff (-2 past a namesake); "🚀" alone → folder
201
203
  setNaming(null)
202
204
  setOpen(slug, true) // a new folder opens, whatever an old namesake left behind
203
- if (!mutate((t) => createFolder(t, slug, index, withBoard, stored(slug) || undefined))) useStore.getState().toast('that board is gone - nothing changed')
205
+ if (!mutate((t) => createFolder(t, slug, index, withBoard, stored(slug) || undefined, parent))) useStore.getState().toast(withBoard ? 'that board is gone - nothing changed' : 'that folder is gone - nothing changed')
204
206
  } finally { commitBusy.current = false }
205
207
  }
206
208
 
@@ -210,7 +212,10 @@ export function BoardList({ onMenu }: { onMenu: MenuOpener }) {
210
212
  const measure = (): Row[] => Array.from(rootRef.current?.querySelectorAll<HTMLElement>('[data-board-row],[data-folder-row]') ?? []).map((el) => {
211
213
  const r = el.getBoundingClientRect()
212
214
  const folder = el.hasAttribute('data-folder-row')
213
- return { kind: folder ? 'folder' : 'board', name: el.dataset.folderRow ?? el.dataset.board ?? '', parent: folder ? null : (el.dataset.folder ?? null), open: el.dataset.open === '1', top: r.top, bottom: r.bottom, left: r.left }
215
+ return {
216
+ kind: folder ? 'folder' : 'board', name: el.dataset.folderRow ?? el.dataset.board ?? '', parent: (folder ? el.dataset.parent : el.dataset.folder) ?? null,
217
+ depth: Number(el.dataset.depth ?? 0), open: el.dataset.open === '1', top: r.top, bottom: r.bottom, left: r.left,
218
+ }
214
219
  })
215
220
  /** The drop target for the pointer at (x, y), or null: outside the panel (a release there
216
221
  * cancels), or a slot that would change nothing. Inside the panel there is always one - the
@@ -289,22 +294,36 @@ export function BoardList({ onMenu }: { onMenu: MenuOpener }) {
289
294
  }, [])
290
295
 
291
296
  // ---- menus (flat lists, no submenus) ----
292
- const newFolderAt = (index: number, withBoard?: string) => setNaming({ kind: 'new', index, board: withBoard })
297
+ const newFolderAt = (index: number, withBoard?: string, parent: string | null = null) => {
298
+ if (parent) setOpen(parent, true) // the new folder is drawn inside its parent: show it
299
+ setNaming({ kind: 'new', index, board: withBoard, parent })
300
+ }
293
301
  const boardMenu = (n: string, parent: string | null): MenuItem[] => {
294
302
  const items: MenuItem[] = [{ label: 'Copy path', icon: <SignpostIcon size={15} />, onClick: () => copyToClipboard(`board: ${n}`, 'path copied') }]
295
303
  if (PUBLISHED || n === 'all-scenes') return items
296
304
  items.push({ label: 'Rename', icon: <PencilSimpleIcon size={15} />, onClick: () => setNaming({ kind: 'board', name: n }) })
297
- // the new folder takes the board's own slot (or the slot after its current folder)
298
- items.push({ label: 'Move to new folder', icon: <FolderPlusIcon size={15} />, onClick: () => newFolderAt(newFolderSlot(treeRef.current, n), n) })
305
+ // the new folder takes the board's own slot at its own level (a sub-folder inside a top-level
306
+ // folder; right after its sub-folder when it sits in one - that level holds no folders)
307
+ items.push({ label: 'Move to new folder', icon: <FolderPlusIcon size={15} />, onClick: () => { const at = newFolderSlot(treeRef.current, n); newFolderAt(at.index, n, at.parent) } })
299
308
  // moving into an EXISTING folder is a drag, not a menu - the list stays short
300
- if (parent) items.push({ label: 'Move to top level', icon: <ArrowLineUpIcon size={15} />, onClick: () => mutate((t) => { const p = folderOf(t, n); return moveBoard(t, n, null, p ? rootIndex(t, 'folder', p) + 1 : undefined) }) })
309
+ if (parent) items.push({ label: 'Move to top level', icon: <ArrowLineUpIcon size={15} />, onClick: () => mutate((t) => {
310
+ const p = folderOf(t, n)
311
+ const top = p ? parentOf(t, p) ?? p : null // lands right after the top-level folder it was in
312
+ return moveBoard(t, n, null, top ? rootIndex(t, 'folder', top) + 1 : undefined)
313
+ }) })
301
314
  return items
302
315
  }
303
- const folderMenu = (f: string, boards: string[]): MenuItem[] => {
304
- const items: MenuItem[] = [{ label: 'Copy path', icon: <SignpostIcon size={15} />, onClick: () => copyToClipboard(`folder: ${f} (boards: ${boards.join(', ') || 'none'})`, 'path copied') }]
316
+ const folderMenu = (f: string, boards: string[], parent: string | null): MenuItem[] => {
317
+ const items: MenuItem[] = [{ label: 'Copy path', icon: <SignpostIcon size={15} />, onClick: () => copyToClipboard(`folder: ${f}${parent ? ` (in ${parent})` : ''} (boards: ${boards.join(', ') || 'none'})`, 'path copied') }]
305
318
  if (PUBLISHED) return items
306
319
  items.push({ label: 'Rename', icon: <PencilSimpleIcon size={15} />, onClick: () => setNaming({ kind: 'folder', name: f }) })
307
- // folders organise, never own: deleting one puts its boards back at the top level, in order
320
+ // two levels: a top-level folder can hold folders; a sub-folder can leave its parent
321
+ if (!parent) items.push({ label: 'New folder inside', icon: <FolderPlusIcon size={15} />, onClick: () => newFolderAt(listIn(treeRef.current, f)?.length ?? 0, undefined, f) })
322
+ else items.push({ label: 'Move to top level', icon: <ArrowLineUpIcon size={15} />, onClick: () => mutate((t) => {
323
+ const p = parentOf(t, f)
324
+ return moveFolderToRoot(t, f, p ? rootIndex(t, 'folder', p) + 1 : undefined)
325
+ }) })
326
+ // folders organise, never own: deleting one moves what it holds up one level, into its place, in order
308
327
  items.push({ label: 'Delete folder', icon: <FolderMinusIcon size={15} />, onClick: () => { setOpen(f, true); mutate((t) => deleteFolder(t, f)) } })
309
328
  return items
310
329
  }
@@ -327,6 +346,15 @@ export function BoardList({ onMenu }: { onMenu: MenuOpener }) {
327
346
  // eslint-disable-next-line react-hooks/exhaustive-deps
328
347
  }, [])
329
348
 
349
+ // a draft new folder whose parent vanished or got nested mid-naming is cancelled, said once
350
+ const orphanDraft = naming?.kind === 'new' && !holdsFolders(tree, naming.parent)
351
+ useEffect(() => {
352
+ if (!orphanDraft) return
353
+ setNaming(null)
354
+ useStore.getState().toast('that folder changed - the new folder was not made')
355
+ // eslint-disable-next-line react-hooks/exhaustive-deps
356
+ }, [orphanDraft])
357
+
330
358
  // ---- rows ----
331
359
  const input = (defaultValue: string, placeholder?: string) => (
332
360
  <input autoFocus defaultValue={defaultValue} placeholder={placeholder} spellCheck={false}
@@ -337,26 +365,51 @@ export function BoardList({ onMenu }: { onMenu: MenuOpener }) {
337
365
  }}
338
366
  onBlur={(e) => { if (namingRef.current) void commit(e.currentTarget.value) }} />
339
367
  )
340
- // ONE seam per gap, from the insertion index: drop-before the row AT that index, or drop-after
341
- // the last row of a folder's list. Overlay (::after), so no layout shift. Never on the row
342
- // being dragged. The root end slot draws before the pinned all-scenes row, never after a
343
- // folder header (that seam would read as "inside").
344
- const seam = (list: string | null, index: number, last: boolean): string => {
345
- if (!drag || !drop || 'into' in drop || drop.list !== list) return ''
346
- return drop.index === index ? ' drop-before' : last && drop.index === index + 1 ? ' drop-after' : ''
368
+ // a new folder being named is drawn at its future slot, its board (if any) already inside;
369
+ // that board leaves its usual row for the duration. A draft whose parent vanished or can no
370
+ // longer hold folders (an agent deleted or nested it mid-naming) draws nothing and hides
371
+ // nothing - the effect below cancels it, so the board it held shows where it now is
372
+ const draftLive = naming?.kind === 'new' && holdsFolders(tree, naming.parent)
373
+ const draft = naming?.kind === 'new' && draftLive ? (naming.board && !boardsIn(tree).includes(naming.board) ? { ...naming, board: undefined } : naming) : null // a board deleted mid-naming leaves the draft
374
+ const visible = (items: TreeItem[]) => (draft?.board ? items.filter((k) => !(k.kind === 'board' && k.name === draft.board)) : items)
375
+ const key = (it: TreeItem) => `${it.kind === 'board' ? 'b' : 'f'}:${it.name}`
376
+ /** The last row an item draws: a board's own, a closed or empty folder's header, else its last child's last row. */
377
+ const lastRow = (it: TreeItem): string => {
378
+ if (it.kind === 'board') return key(it)
379
+ const kids = visible(it.items)
380
+ return !closed[it.name] && kids.length ? lastRow(kids[kids.length - 1]!) : key(it)
347
381
  }
348
- const boardRow = (n: string, parent: string | null, index: number, last: boolean) => {
382
+ // ONE seam per gap, overlaid (::after, no layout shift) on the row the gap touches: before the
383
+ // first row of the item AT the index, or after the last row of a list's last item at its end.
384
+ // Its left edge follows the indent of the list it lands in, so "inside" and "outside" never
385
+ // draw alike. The root end draws before the pinned all-scenes row. An open EMPTY folder has no
386
+ // row to draw on: its header draws the indented seam under itself (drop-in).
387
+ const seam = ((): { row: string; where: 'before' | 'after'; depth: number } | null => {
388
+ if (!drag || !drop || 'into' in drop) return null
389
+ const items = listIn(tree, drop.list)
390
+ if (!items) return null
391
+ const depth = depthOf(tree, drop.list)
392
+ if (drop.index < items.length) return { row: key(items[drop.index]!), where: 'before', depth }
393
+ if (drop.list === null && HAS_ALL_SCENES) return { row: 'b:all-scenes', where: 'before', depth: 0 }
394
+ return items.length ? { row: lastRow(items[items.length - 1]!), where: 'after', depth } : null
395
+ })()
396
+ const seamLeft = (depth: number): CSSProperties => ({ ['--seam-left' as string]: `${depth ? depth * INDENT : 6}px` })
397
+ const seamOf = (row: string): { cls: string; style?: CSSProperties } =>
398
+ seam && seam.row === row ? { cls: ` drop-${seam.where}`, style: seamLeft(seam.depth) } : { cls: '' }
399
+ const indent = (depth: number) => (depth >= 2 ? ' in-folder in-sub' : depth === 1 ? ' in-folder' : '')
400
+
401
+ const boardRow = (n: string, parent: string | null, depth: number) => {
349
402
  const dragging = drag?.kind === 'board' && drag.name === n
350
- const dropCls = dragging ? '' : n === 'all-scenes' ? (drop && !('into' in drop) && drop.list === null && drop.index === tree.length ? ' drop-before' : '') : seam(parent, index, last)
403
+ const s = dragging ? { cls: '' } : seamOf(`b:${n}`)
351
404
  // the row being renamed is still a row: measured by a drag (its slot exists) and it draws its seam
352
405
  if (naming?.kind === 'board' && naming.name === n) return (
353
- <div key={n} data-board-row data-board={n} data-folder={parent ?? undefined} className={`it board editing${parent ? ' in-folder' : ''}${dropCls}`}><CardsIcon size={14} />{input(label(n))}</div>
406
+ <div key={`b:${n}`} data-board-row data-board={n} data-folder={parent ?? undefined} data-depth={depth} className={`it board editing${indent(depth)}${s.cls}`} style={s.style}><CardsIcon size={14} />{input(label(n))}</div>
354
407
  )
355
408
  const canDrag = !PUBLISHED && n !== 'all-scenes'
356
409
  const item: Drag = { kind: 'board', name: n }
357
410
  return (
358
- <button key={`${parent ?? ''}/${n}`} data-board-row data-board={n} data-folder={parent ?? undefined} data-reorderable={canDrag || undefined}
359
- className={`it board${parent ? ' in-folder' : ''}${n === board ? ' cur' : ''}${dragging ? ' dragging' : ''}${dropCls}`}
411
+ <button key={`b:${n}`} data-board-row data-board={n} data-folder={parent ?? undefined} data-depth={depth} data-reorderable={canDrag || undefined}
412
+ className={`it board${indent(depth)}${n === board ? ' cur' : ''}${dragging ? ' dragging' : ''}${s.cls}`} style={s.style}
360
413
  // draggable rows switch on the pointer tap (onPointerUp), so their trailing mouse
361
414
  // click (detail >= 1) must be ignored to avoid a double switch; keyboard clicks
362
415
  // (detail === 0) and non-draggable rows (all-scenes, published) switch here as normal
@@ -372,23 +425,42 @@ export function BoardList({ onMenu }: { onMenu: MenuOpener }) {
372
425
  </button>
373
426
  )
374
427
  }
375
- const folderRow = (it: Folder, boards: string[], index: number): ReactNode[] => {
428
+ const draftRows = (depth: number): ReactNode[] => !draft ? [] : [
429
+ <div key="f:new" className={`it folder editing${indent(depth)}`}><FolderOpenIcon size={14} />{input('', 'Folder name')}</div>,
430
+ ...(draft.board ? [<div key="new/board" className={`it board draft${indent(depth + 1)}${draft.board === board ? ' cur' : ''}`}><CardsIcon size={14} /><span>{label(draft.board)}</span></div>] : []),
431
+ ]
432
+ /** A list's rows - the root's, a folder's, a sub-folder's - with a draft new folder at its slot. */
433
+ const listRows = (items: TreeItem[], parent: string | null, depth: number): ReactNode[] => {
434
+ const out: ReactNode[] = []
435
+ const here = draft && draft.parent === parent ? draft : null
436
+ items.forEach((it, i) => {
437
+ if (here && here.index === i) out.push(...draftRows(depth))
438
+ if (it.kind === 'board') { if (draft?.board !== it.name) out.push(boardRow(it.name, parent, depth)) }
439
+ else out.push(...folderRows(it, parent, depth))
440
+ })
441
+ if (here && here.index >= items.length) out.push(...draftRows(depth))
442
+ return out
443
+ }
444
+ const folderRows = (it: Folder, parent: string | null, depth: number): ReactNode[] => {
376
445
  const f = it.name
377
446
  const open = !closed[f]
447
+ const kids = visible(it.items)
448
+ const boards = boardsIn(kids)
449
+ const s = seamOf(`f:${f}`)
378
450
  const rows: ReactNode[] = []
379
451
  if (naming?.kind === 'folder' && naming.name === f) {
380
- rows.push(<div key={`f:${f}`} data-folder-row={f} data-open={open ? '1' : '0'} className={`it folder editing${seam(null, index, false)}`}>{open ? <FolderOpenIcon size={14} /> : <FolderIcon size={14} />}{input(labelOf(f, it.title))}</div>)
452
+ rows.push(<div key={`f:${f}`} data-folder-row={f} data-parent={parent ?? undefined} data-depth={depth} data-open={open ? '1' : '0'} className={`it folder editing${indent(depth)}${s.cls}`} style={s.style}>{open ? <FolderOpenIcon size={14} /> : <FolderIcon size={14} />}{input(labelOf(f, it.title))}</div>)
381
453
  } else {
382
454
  const item: Drag = { kind: 'folder', name: f }
383
455
  const dragging = drag?.kind === 'folder' && drag.name === f
384
456
  const into = !!drag && !!drop && 'into' in drop && drop.into === f
385
- // a slot inside an open EMPTY folder has no board row to draw on: the indented seam sits under the header
386
- const inSeam = !!drag && !!drop && !('into' in drop) && drop.list === f && open && boards.length === 0
457
+ const inSeam = !!drag && !!drop && !('into' in drop) && drop.list === f && open && kids.length === 0
458
+ const sc = dragging ? { cls: '' } : inSeam ? { cls: ' drop-in', style: seamLeft(depth + 1) } : s
387
459
  rows.push(
388
- <button key={`f:${f}`} data-folder-row={f} data-open={open ? '1' : '0'} data-reorderable={!PUBLISHED || undefined}
389
- className={`it folder${boards.includes(board) ? ' held' : ''}${dragging ? ' dragging' : ''}${into ? ' drop-into' : ''}${inSeam ? ' drop-in' : ''}${dragging ? '' : seam(null, index, false)}`}
460
+ <button key={`f:${f}`} data-folder-row={f} data-parent={parent ?? undefined} data-depth={depth} data-open={open ? '1' : '0'} data-reorderable={!PUBLISHED || undefined}
461
+ className={`it folder${indent(depth)}${boards.includes(board) ? ' held' : ''}${dragging ? ' dragging' : ''}${into ? ' drop-into' : ''}${sc.cls}`} style={sc.style}
390
462
  onClick={(e) => { if (PUBLISHED || e.detail === 0) setOpen(f, !open) }}
391
- onContextMenu={(e) => onMenu(e, folderMenu(f, boards))}
463
+ onContextMenu={(e) => onMenu(e, folderMenu(f, boards, parent))}
392
464
  onPointerDown={!PUBLISHED ? (e) => onPointerDown(e, item) : undefined}
393
465
  onPointerMove={!PUBLISHED ? onPointerMove : undefined}
394
466
  onPointerUp={!PUBLISHED ? onPointerUp : undefined}
@@ -400,25 +472,12 @@ export function BoardList({ onMenu }: { onMenu: MenuOpener }) {
400
472
  </button>,
401
473
  )
402
474
  }
403
- if (open) boards.forEach((b, i) => rows.push(boardRow(b, f, i, i === boards.length - 1)))
475
+ if (open) rows.push(...listRows(it.items, f, depth + 1))
404
476
  return rows
405
477
  }
406
478
 
407
- // a new folder being named is drawn at its future slot, its board (if any) already inside;
408
- // that board leaves its usual row for the duration
409
- const draft = naming?.kind === 'new' ? (naming.board && !boardsIn(tree).includes(naming.board) ? { ...naming, board: undefined } : naming) : null // a board deleted mid-naming leaves the draft
410
- const rows: ReactNode[] = []
411
- const draftRows = draft ? [
412
- <div key="f:new" className="it folder editing"><FolderOpenIcon size={14} />{input('', 'Folder name')}</div>,
413
- ...(draft.board ? [<div key="new/board" className={`it board in-folder draft${draft.board === board ? ' cur' : ''}`}><CardsIcon size={14} /><span>{label(draft.board)}</span></div>] : []),
414
- ] : []
415
- tree.forEach((it, i) => {
416
- if (draft && draft.index === i) rows.push(...draftRows)
417
- if (it.kind === 'board') { if (draft?.board !== it.name) rows.push(boardRow(it.name, null, i, false)) }
418
- else rows.push(...folderRow(it, draft?.board ? it.boards.filter((b) => b !== draft.board) : it.boards, i))
419
- })
420
- if (draft && draft.index >= tree.length) rows.push(...draftRows)
421
- if (HAS_ALL_SCENES) rows.push(boardRow('all-scenes', null, -1, false)) // a published bundle without it shows none
479
+ const rows: ReactNode[] = listRows(tree, null, 0)
480
+ if (HAS_ALL_SCENES) rows.push(boardRow('all-scenes', null, 0)) // a published bundle without it shows none
422
481
  return (
423
482
  <div className="sh-boards" ref={rootRef} onContextMenu={(e: ReactMouseEvent) => blankMenu(e)}>
424
483
  <div className="hd">
@@ -7,7 +7,7 @@
7
7
  import { useEffect, useMemo, useRef, useState } from 'react'
8
8
  import { createPortal } from 'react-dom'
9
9
  import { avatarFallback, useComments } from './comments-store.ts'
10
- import { useStore, type Node } from './store.ts'
10
+ import { CONFIG, useStore, type Node } from './store.ts'
11
11
  import { threadHostKey } from './keys.ts'
12
12
  import { isNoteAnchor, resolveNoteAnchor, useNotes } from './notes.ts'
13
13
  import { canvasCtl } from './canvas/ctl.ts'
@@ -15,7 +15,7 @@ import { bootHash, buildHash, parseHash, writeHash } from './hash.ts'
15
15
  import { ArrowUpIcon, CheckIcon, CheckSquareOffsetIcon, LinkIcon, ParallelogramFillIcon, PencilSimpleIcon, PlusIcon, XIcon } from './icons.tsx'
16
16
  import { Tip } from './Tip.tsx'
17
17
  import { mentionAlerts, mentionPeople, mentionQueryAt, parseBody, type MentionPerson } from './mentions.ts'
18
- import { ROUTE } from '../const.ts'
18
+ import { ROUTE, slideSize, stageFit } from '../const.ts'
19
19
  import type { AgentMeta, Thread } from '../../shared/events.ts'
20
20
 
21
21
  /** Tint the pin / composer / thread card EDGES (border, outline, focus ring) in the anchored
@@ -132,6 +132,13 @@ export function CommentLayer({ node, frameId, iframe }: { node: Node; frameId: s
132
132
  const selected = useStore((s) => s.selection.includes(node.key))
133
133
  const { setActive } = useComments.getState()
134
134
  const [rects, setRects] = useState<Record<string, { x: number; y: number; w: number; h: number } | null>>({})
135
+ // a slide renders at its stage and the node scales it (FrameNode): rects the frame reports are
136
+ // in stage px, so they map through the same fit into the node's coordinates
137
+ const frameEntry = useStore((s) => s.frameFor(node))
138
+ const stage = frameEntry ? slideSize(frameEntry, CONFIG.viewports) : null
139
+ const fit = stage ? stageFit(stage, { w: node.w, h: node.h }) : null
140
+ const inNode = <R extends { x: number; y: number; w: number; h: number }>(r: R): R =>
141
+ fit ? { ...r, x: fit.ox + r.x * fit.k, y: fit.oy + r.y * fit.k, w: r.w * fit.k, h: r.h * fit.k } : r
135
142
 
136
143
  const open = threads.filter((t) => !t.resolved)
137
144
  const anchored = open.filter((t) => (t.anchor as any)?.el)
@@ -249,7 +256,8 @@ export function CommentLayer({ node, frameId, iframe }: { node: Node; frameId: s
249
256
 
250
257
  const pinPos = (t: Thread) => {
251
258
  const a = t.anchor as any
252
- const r = isNoteAnchor(a) ? noteRects[t.id] : rects[t.id]
259
+ const fr = rects[t.id]
260
+ const r = isNoteAnchor(a) ? noteRects[t.id] : fr && inNode(fr)
253
261
  if (a?.el && r) return { x: r.x + (a.pos?.fx ?? 0.5) * r.w, y: r.y + (a.pos?.fy ?? 0.5) * r.h, orphan: false }
254
262
  if (a?.el && r === null) return { x: node.w - 16, y: 16, orphan: true } // orphan parks top-right
255
263
  const p = a?.pos // frame-level: stored fraction of the frame
@@ -310,7 +318,7 @@ export function CommentLayer({ node, frameId, iframe }: { node: Node; frameId: s
310
318
  )}
311
319
  {draft?.nodeKey === node.key && (() => {
312
320
  const a = draft.anchor as any
313
- const r = (draftOnNote && noteRects.draft) || a?.rect
321
+ const r = (draftOnNote && noteRects.draft) || (a?.rect && inNode(a.rect))
314
322
  return <DraftComposer at={{ x: (r?.x ?? 0) + (a?.pos?.fx ?? 0.5) * (r?.w ?? 0), y: (r?.y ?? 0) + (a?.pos?.fy ?? 0.5) * (r?.h ?? 0) }} bounds={{ w: node.w, h: node.h }} hue={anchorHue(draft.anchor)} />
315
323
  })()}
316
324
  </>
@@ -15,7 +15,7 @@ import { useStore, BOARD_POLICY, BRANDING, CONFIG, PUBLISHED, SOURCE_REVEALED, b
15
15
  import { deckOrder } from './play-order.ts'
16
16
  import { useComments } from './comments-store.ts'
17
17
  import { isNoteAnchor } from './notes.ts'
18
- import { ROUTE } from '../const.ts'
18
+ import { ROUTE, slideSize } from '../const.ts'
19
19
  import { poweredByUrl } from '../../shared/utm.ts'
20
20
  import { canvasCtl } from './canvas/ctl.ts'
21
21
  import { Tip } from './Tip.tsx'
@@ -70,9 +70,9 @@ export function enterSlides(over?: { at?: string; device?: string; theme?: strin
70
70
  const at = over?.at && deck.includes(over.at) ? over.at : deck[0]
71
71
  const frame = s.manifest?.frames.find((f) => f.id === at)
72
72
  const theme = (over?.theme && CONFIG.themes.includes(over.theme) ? over.theme : undefined) ?? frame?.theme ?? s.viewTheme
73
- // the deck's own stage is the default device; a restored link may carry a
74
- // picked viewport or fill - the standard prototype picker works in slides
75
- const device = over?.device && (CONFIG.viewports[over.device] || over.device === 'fill' || over.device === 'slide') ? over.device : 'slide'
73
+ // a slide is a fixed stage: the deck shows it fit to the window ('slide') or edge to edge
74
+ // ('fill'); a viewport a restored link carries is not a slide view, so it lands on the stage
75
+ const device = over?.device === 'fill' ? 'fill' : 'slide'
76
76
  s.setPlay({ at, device, theme, slides: true })
77
77
  }
78
78
 
@@ -358,7 +358,11 @@ function PlayInner() {
358
358
 
359
359
  const setDevice = (name: string) => {
360
360
  const p = useStore.getState().play
361
- if (p && (CONFIG.viewports[name] || name === 'fill' || (name === 'slide' && p.slides))) useStore.getState().setPlay({ ...p, device: name })
361
+ if (!p) return
362
+ // slides: the stage fit to the window or edge to edge - a viewport preset has no meaning
363
+ // for a fixed stage, so it lands on the slide view
364
+ if (p.slides) { useStore.getState().setPlay({ ...p, device: name === 'fill' ? 'fill' : 'slide' }); return }
365
+ if (CONFIG.viewports[name] || name === 'fill') useStore.getState().setPlay({ ...p, device: name })
362
366
  }
363
367
  const setTheme = (t: string) => {
364
368
  const p = useStore.getState().play
@@ -556,26 +560,34 @@ function PlayInner() {
556
560
 
557
561
  // doc preset: reading width, natural document scrolling - the iframe takes the
558
562
  // window's height at min(width, 860) CSS pixels, unscaled, and scrolls itself
559
- const slideDev = slides && play.device === 'slide'
560
- const vp = docPreset ? { width: Math.min(win.w, 860), height: win.h }
563
+ const frameEntry = useStore.getState().manifest?.frames.find((f) => f.id === play.at)
564
+ // a slide - in slides mode, or a slide frame met in present or focus - is a fixed stage:
565
+ // the iframe is always its own stage (its declared viewport, else 1280×720), scaled to the
566
+ // window UP as well as down, so a projector shows it at full size and the frame never scales
567
+ // itself. 'fill' fits it edge to edge; otherwise it leaves room for the chrome. A slide never
568
+ // reflows - a device preset has nothing to change on a fixed stage.
569
+ const stage = (frameEntry && slideSize(frameEntry, CONFIG.viewports)) ?? (slides ? SLIDE_INTRINSIC : null)
570
+ const vp = stage ? { width: stage.width, height: stage.height }
571
+ : docPreset ? { width: Math.min(win.w, 860), height: win.h }
561
572
  : fill ? { width: win.w, height: win.h }
562
- : slideDev ? { width: SLIDE_INTRINSIC.width, height: SLIDE_INTRINSIC.height }
563
- : CONFIG.viewports[play.device] ?? (slides ? { width: SLIDE_INTRINSIC.width, height: SLIDE_INTRINSIC.height } : Object.values(CONFIG.viewports)[0])
564
- const scale = fill || docPreset ? 1
565
- : slideDev ? Math.min(1, (win.w - 48) / vp.width, (win.h - (deckChrome === 'none' ? 48 : 88)) / vp.height)
573
+ : CONFIG.viewports[play.device] ?? Object.values(CONFIG.viewports)[0]
574
+ const scale = stage ? Math.max(0.05, fill ? Math.min(win.w / vp.width, win.h / vp.height)
575
+ : slides ? Math.min((win.w - 48) / vp.width, (win.h - (deckChrome === 'none' ? 48 : 88)) / vp.height)
576
+ : Math.min((win.w - 96) / vp.width, (win.h - 128) / vp.height))
577
+ : docPreset || fill ? 1
566
578
  : Math.min(1, (win.w - 96) / vp.width, (win.h - 128) / vp.height)
567
579
  const names = Object.keys(CONFIG.viewports)
568
580
  const list = focus ? [play.at] : slides ? currentDeck() : playList()
569
581
  const pos = list.indexOf(play.at)
570
582
  const variants = variantList()
571
- const frameEntry = useStore.getState().manifest?.frames.find((f) => f.id === play.at)
572
583
  const title = focus ? (frameEntry?.title ?? (play.at.split('/').pop() ?? play.at).replace(/-/g, ' ')) : boardLabel(board)
573
584
 
574
585
  // whole-pixel wrapper + per-axis scale so the iframe lands exactly on its edges -
575
586
  // fractional sizes left subpixel seams glowing at the corners on dark frames
576
587
  const dw = Math.round(vp.width * scale)
577
588
  const dh = Math.round(vp.height * scale)
578
- const deviceHint = fill ? 'Fill window' : `${vp.width} × ${vp.height} · keys 1-${names.length + 1}`
589
+ const deviceHint = stage ? `${vp.width} × ${vp.height} stage · ${fill ? 'edge to edge' : 'fit to the window'}`
590
+ : fill ? 'Fill window' : `${vp.width} × ${vp.height} · keys 1-${names.length + 1}`
579
591
 
580
592
  return (
581
593
  <div className={`sh-play${fill || docPreset ? ' fill' : ''}${docPreset ? ` doc t-${play.theme}` : ''}`}>
@@ -650,7 +662,7 @@ function PlayInner() {
650
662
  {!trimmed && <>
651
663
  <LaserButton />
652
664
  <i className="sep" />
653
- {!docPreset && <DevicePicker value={fill ? 'fill' : play.device} onSelect={(n) => n && setDevice(n)} includeFill includeSlide={slides} hint={deviceHint} dark />}
665
+ {!docPreset && <DevicePicker value={fill ? 'fill' : play.device} onSelect={(n) => n && setDevice(n)} includeFill includeSlide={slides} stageOnly={slides} hint={deviceHint} dark />}
654
666
  <ThemePicker value={play.theme} onSelect={setTheme} hint="D" dark />
655
667
  </>}
656
668
  {playUpdateRevision && <>
@@ -153,18 +153,20 @@ export function HideUIButton() {
153
153
  /** Controlled device dropdown. `value` is the active device name ('fill' allowed when
154
154
  * includeFill), null = default/mixed. The trigger + menu chrome is shared; the adapter
155
155
  * owns what select does. */
156
- export function DevicePicker({ value, onSelect, includeDefault, includeFill, includeSlide, hint, dark }: {
156
+ export function DevicePicker({ value, onSelect, includeDefault, includeFill, includeSlide, stageOnly, hint, dark }: {
157
157
  value: string | null
158
158
  onSelect: (name: string | null) => void
159
159
  includeDefault?: boolean
160
160
  includeFill?: boolean
161
- /** slides mode: the deck's own 1280×720 stage as a first-class device */
161
+ /** slides mode: the slide's own stage, fit to the window, as a first-class device */
162
162
  includeSlide?: boolean
163
+ /** slides mode: a slide is a fixed stage - only the stage views (slide, fill), no viewports */
164
+ stageOnly?: boolean
163
165
  hint?: ReactNode
164
166
  dark?: boolean
165
167
  }) {
166
168
  const pop = usePopover()
167
- const entries = Object.entries(CONFIG.viewports)
169
+ const entries = stageOnly ? [] : Object.entries(CONFIG.viewports)
168
170
  const pick = (name: string | null) => { onSelect(name); pop.setOpen(false) }
169
171
  const triggerIcon = value === 'fill' ? <FrameCornersIcon size={16} /> : value === 'slide' ? <SlideFrameIcon size={16} /> : deviceIcon(value, 16)
170
172
  return (
@@ -177,7 +179,7 @@ export function DevicePicker({ value, onSelect, includeDefault, includeFill, inc
177
179
  </Tip>
178
180
  <Popover pop={pop} dark={dark}>
179
181
  {includeSlide && <>
180
- <button onClick={() => pick('slide')} title="1280 × 720">
182
+ <button onClick={() => pick('slide')} title="the slide's own size, fit to the window">
181
183
  <SlideFrameIcon size={15} /><span>Slide</span>
182
184
  {value === 'slide' && <CheckIcon size={13} className="chk" />}
183
185
  </button>
@@ -197,7 +199,7 @@ export function DevicePicker({ value, onSelect, includeDefault, includeFill, inc
197
199
  </button>
198
200
  ))}
199
201
  {includeFill && <button onClick={() => pick('fill')}>
200
- <FrameCornersIcon size={15} /><span>Fill window</span><kbd>{entries.length + 1}</kbd>
202
+ <FrameCornersIcon size={15} /><span>Fill window</span><kbd>{Object.keys(CONFIG.viewports).length + 1}</kbd>
201
203
  {value === 'fill' && <CheckIcon size={13} className="chk" />}
202
204
  </button>}
203
205
  </Popover>
@@ -11,10 +11,21 @@ import { sleep, wake } from './sleep.ts'
11
11
  import { canAutoReload, shouldArmReadyWatch } from './ready-watch.ts'
12
12
  import { Stickies, type NoteSpec } from './Sticky.tsx'
13
13
  import { noteId, sceneNoteHost } from '../notes.ts'
14
+ import { slideSize, stageFit } from '../../const.ts'
14
15
 
15
16
  export const HEADER = 28
16
17
  const SNAP = 12
17
18
 
19
+ /** The iframe's box in its node: a frame fills the node; a slide renders at its stage and is
20
+ * scaled uniformly into the node and centred - resizing a slide node never reflows it. The
21
+ * identity case carries no transform (no layer, no containing block). */
22
+ function iframeBox(stage: { width: number; height: number } | null, node: { w: number; h: number }) {
23
+ if (!stage) return { width: node.w, height: node.h }
24
+ const { k, ox, oy } = stageFit(stage, node)
25
+ const identity = Math.abs(k - 1) < 1e-6 && Math.abs(ox) < 0.5 && Math.abs(oy) < 0.5
26
+ return { width: stage.width, height: stage.height, ...(identity ? {} : { transform: `translate(${ox}px, ${oy}px) scale(${k})`, transformOrigin: '0 0' }) }
27
+ }
28
+
18
29
  /** Live Jam working shimmer: a slim 2x6 strip of tiny marver marks on the frame's left
19
30
  * flank, top-aligned - each mark twinkles on its own scattered beat, phased per frame by
20
31
  * --mv-w0 (set on the node) so parallel frames never pulse in sync. */
@@ -172,7 +183,10 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
172
183
  const resizing = useRef(false)
173
184
  const [resizeTick, setResizeTick] = useState(0)
174
185
  useEffect(() => { dirty.current = false }, [node.nav]) // a fresh document is pristine again
175
- const w = Math.round(node.w), h = Math.round(node.h)
186
+ // a slide's document is its stage, whatever the node's size (the node only scales it): the
187
+ // sleep key - the size the compiler renders to certify textures - is the document's size
188
+ const stage = frame ? slideSize(frame, CONFIG.viewports) : null
189
+ const w = Math.round(stage?.width ?? node.w), h = Math.round(stage?.height ?? node.h)
176
190
  // a layout effect: the wake lands BEFORE the first paint of the new state (a stretched texture
177
191
  // must never be painted at a new size)
178
192
  useLayoutEffect(() => {
@@ -418,7 +432,7 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
418
432
  src={src}
419
433
  title={frame.id}
420
434
  onLoad={registerWin}
421
- style={{ width: node.w, height: node.h, display: node.missing || node.status === 'error' ? 'none' : 'block' }}
435
+ style={{ ...iframeBox(stage, node), display: node.missing || node.status === 'error' ? 'none' : 'block' }}
422
436
  />
423
437
  {/* the overlay eats mouse events for drag-by-body; laser and comment mode both
424
438
  need the mouse INSIDE the frame for hover highlights, so it steps aside
@@ -138,15 +138,15 @@ export const modeAllowed = (board: string, mode: 'present' | 'focus' | 'slides')
138
138
  export { cap, humanize } from './labels.ts'
139
139
  import { cap, humanize } from './labels.ts'
140
140
  import { canAutoReload } from './canvas/ready-watch.ts'
141
- import { buildTree, flatten, labelOf, toWire, type TreeItem } from '../../shared/board-tree.ts'
141
+ import { buildTree, flatten, labelOf, toWire, TREE_PROTOCOL, type TreeItem } from '../../shared/board-tree.ts'
142
142
 
143
143
  /** The CAS tokens a tree write echoes: the sha256 of every board file as last seen, and of
144
144
  * the folder registry (null = there was no file). */
145
145
  export interface TreeBase { boards: Record<string, string>; folders: string | null }
146
146
  export interface TreeSnapshot { tree: TreeItem[]; base: TreeBase; titles: Record<string, string> }
147
147
 
148
- /** The sidebar tree: root boards and folders in rank order, each folder's boards inside
149
- * (shared/board-tree.ts), plus the hashes it was built from. `all-scenes` is not in it - it
148
+ /** The sidebar tree: root boards and folders in rank order, each folder's boards and
149
+ * sub-folders inside (shared/board-tree.ts), plus the hashes it was built from. `all-scenes` is not in it - it
150
150
  * is pinned last by the callers. Throws on transport failure and on a malformed registry
151
151
  * (the server's 422 message) - callers keep their last known tree. */
152
152
  export async function fetchBoardTree(): Promise<TreeSnapshot> {
@@ -154,7 +154,7 @@ export async function fetchBoardTree(): Promise<TreeSnapshot> {
154
154
  const [boards, reg] = await Promise.all([
155
155
  fetch(`${ROUTE}/api/boards`).then((r) => r.json()) as Promise<{ name: string; sha256: string; order?: number; folder?: string; title?: string }[]>,
156
156
  fetch(`${ROUTE}/api/folders`).then(async (r) => {
157
- const j = await r.json() as { folders?: { name: string; order?: number; title?: string }[]; sha256?: string | null; error?: string }
157
+ const j = await r.json() as { folders?: { name: string; order?: number; parent?: string; title?: string }[]; sha256?: string | null; error?: string }
158
158
  if (!r.ok) throw new Error(j?.error ?? `folders ${r.status}`)
159
159
  return j
160
160
  }),
@@ -257,10 +257,10 @@ let liveScenes: Manifest['scenes'] | null = null // the last sh:
257
257
  const measuredHeights = new Map<string, number>()
258
258
 
259
259
  function defaultSize(frame: FrameEntry) {
260
- // the precedence chain (spec 09 slice 1): slide intrinsic → authored
260
+ // the precedence chain (spec 09 slice 1): slide stage → authored
261
261
  // viewport → content sizing → default (one helper, shared with shot) -
262
- // the Slide root renders fixed 1280×720, so nothing may size it smaller
263
- const sl = slideSize(frame)
262
+ // a slide's stage is its declared viewport, else 1280×720
263
+ const sl = slideSize(frame, CONFIG.viewports)
264
264
  if (sl) return { w: sl.width, h: sl.height }
265
265
  // content frames: own width from Doc layout; height from the latest
266
266
  // measurement at that width, or a placeholder until sh:measure lands.
@@ -795,7 +795,7 @@ export const useStore = create<State>((set, get) => {
795
795
  const active = get().board
796
796
  const boards = { ...base.boards, ...(get().boardHash && base.boards[active] !== undefined ? { [active]: get().boardHash } : {}) }
797
797
  let res: Response
798
- try { res = await postOwner('boards/reorder', { tree: toWire(tree), base: { boards, folders: base.folders } }) }
798
+ try { res = await postOwner('boards/reorder', { tree: toWire(tree), base: { boards, folders: base.folders }, protocol: TREE_PROTOCOL }) }
799
799
  catch { return { ok: false as const, stale: false, error: 'could not reach the dev server' } }
800
800
  const body = await res.json().catch(() => ({} as { error?: string; sha256?: { boards?: Record<string, string> } }))
801
801
  if (!res.ok) return { ok: false as const, stale: res.status === 409, error: body?.error }
@@ -484,7 +484,7 @@ body.sh-space .sh-node { pointer-events: none } /* space-drag
484
484
  #0088ff - never the interact-mode purple that --glass-accent shifts to. */
485
485
  .sh-panel .it.board.drop-before, .sh-panel .it.board.drop-after { position: relative }
486
486
  .sh-panel .it.board.drop-before::after, .sh-panel .it.board.drop-after::after {
487
- content: ''; position: absolute; left: 6px; right: 6px; height: 3px; border-radius: 3px;
487
+ content: ''; position: absolute; left: var(--seam-left, 6px); right: 6px; height: 3px; border-radius: 3px;
488
488
  background: #0088ff; pointer-events: none }
489
489
  .sh-panel .it.board.drop-before::after { top: -1px }
490
490
  .sh-panel .it.board.drop-after::after { bottom: -1px }
@@ -493,28 +493,29 @@ body.sh-space .sh-node { pointer-events: none } /* space-drag
493
493
  .sh-panel .it.editing input { flex: 1; min-width: 0; padding: 0; border: 0; outline: 0; background: none;
494
494
  color: var(--glass-ink); font: inherit }
495
495
  .sh-panel .it.editing input::placeholder { color: var(--glass-ink-3) }
496
- /* folders: one level, same row grammar as boards (idle icon tertiary, count tertiary). A folder
496
+ /* folders: two levels, same row grammar as boards (idle icon tertiary, count tertiary). A folder
497
497
  holding the current board wears the ancestor wash so the active board's home survives a
498
- collapse; its boards indent one step. Folder rows share the board seam rules; dropping INTO a
498
+ collapse; what it holds indents one step - a sub-folder's header one step, its boards two. Folder rows share the board seam rules; dropping INTO a
499
499
  folder lights the whole row (wash + brand-blue ring) instead of a seam. */
500
500
  .sh-panel .it.folder svg { color: var(--glass-ink-3); flex: none; margin: 0 1px; transition: color .2s ease }
501
501
  .sh-panel .it.folder[data-reorderable]:active { cursor: grabbing }
502
502
  .sh-panel .it.folder.dragging { opacity: .4 }
503
503
  .sh-panel .it.folder.drop-before, .sh-panel .it.folder.drop-after { position: relative }
504
504
  .sh-panel .it.folder.drop-before::after, .sh-panel .it.folder.drop-after::after {
505
- content: ''; position: absolute; left: 6px; right: 6px; height: 3px; border-radius: 3px;
505
+ content: ''; position: absolute; left: var(--seam-left, 6px); right: 6px; height: 3px; border-radius: 3px;
506
506
  background: #0088ff; pointer-events: none }
507
507
  .sh-panel .it.folder.drop-before::after { top: -1px }
508
508
  .sh-panel .it.folder.drop-after::after { bottom: -1px }
509
509
  .sh-panel .it.folder.drop-into { background: var(--accent-wash); box-shadow: inset 0 0 0 1.5px #0088ff }
510
510
  .sh-panel .it.folder.drop-into svg { color: #0088ff }
511
- .sh-panel .it.board.in-folder { padding-left: 28px }
511
+ .sh-panel .it.board.in-folder, .sh-panel .it.folder.in-folder { padding-left: 28px }
512
+ .sh-panel .it.board.in-sub { padding-left: 56px }
512
513
  .sh-panel .it.board.draft { cursor: default; opacity: .6 }
513
- /* seams inside a folder are indented with its rows, so "inside" and "at the root" never draw alike;
514
- an open EMPTY folder draws that indented seam under its own header (drop-in) */
515
- .sh-panel .it.board.in-folder.drop-before::after, .sh-panel .it.board.in-folder.drop-after::after { left: 28px }
514
+ /* seams are indented with the list they land in (--seam-left, set on the row that draws them),
515
+ so "inside", "inside the sub-folder" and "at the root" never draw alike; an open EMPTY folder
516
+ draws that indented seam under its own header (drop-in) */
516
517
  .sh-panel .it.folder.drop-in { position: relative }
517
- .sh-panel .it.folder.drop-in::after { content: ''; position: absolute; left: 28px; right: 6px; bottom: -1px; height: 3px; border-radius: 3px;
518
+ .sh-panel .it.folder.drop-in::after { content: ''; position: absolute; left: var(--seam-left, 28px); right: 6px; bottom: -1px; height: 3px; border-radius: 3px;
518
519
  background: #0088ff; pointer-events: none }
519
520
  /* the Boards header carries a quiet folder-plus: tertiary, shows on hover of the header row */
520
521
  .sh-panel .sh-boards .hd { display: flex; align-items: center; margin-right: 6px }