@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.
- package/CHANGELOG.md +90 -0
- package/README.md +8 -10
- package/dist/{bake-kaf5kGZ7.mjs → bake-BID6mo-N.mjs} +1 -1
- package/dist/{boards-BmxcT3Lc.mjs → boards-BwiDAmPf.mjs} +95 -48
- package/dist/{boards-PuVzw5Wp.mjs → boards-DnLewfj8.mjs} +20 -11
- package/dist/{build-7ed5H2vT.mjs → build-C7MqQ7hq.mjs} +18 -8
- package/dist/cli.mjs +19 -13
- package/dist/{daemon-DbHvLQUL.mjs → daemon-CRZFpl6K.mjs} +1 -1
- package/dist/{dev-D3mP2x27.mjs → dev-BNZF4Mup.mjs} +5 -5
- package/dist/{init-BQYCS3EU.mjs → init-C34BY3R4.mjs} +34 -33
- package/dist/{manifest-B01PSyDc.mjs → manifest-mMfUhPtL.mjs} +8 -5
- package/dist/{plugin-DI-7NAnx.mjs → plugin-omHLCn91.mjs} +34 -26
- package/dist/{poster-DNh6N27C.mjs → poster-BvxiAzy1.mjs} +1 -1
- package/dist/{publish-bakes-Dp-ZFk3d.mjs → publish-bakes-BqzAAa3w.mjs} +8 -2
- package/dist/{shot-DMDvDbeP.mjs → shot-DswS4iRK.mjs} +7 -7
- package/docs/live-jam.md +1 -1
- package/docs/slides.md +89 -89
- package/docs/sticky-notes.md +9 -0
- package/package.json +1 -1
- package/src/client/const.ts +27 -9
- package/src/client/content/chart.tsx +8 -8
- package/src/client/content/index.tsx +5 -4
- package/src/client/content/slide.tsx +26 -201
- package/src/client/frame-host/main.tsx +10 -0
- package/src/client/shell/BoardList.tsx +111 -52
- package/src/client/shell/Comments.tsx +12 -4
- package/src/client/shell/Play.tsx +26 -14
- package/src/client/shell/Toolbar.tsx +7 -5
- package/src/client/shell/canvas/FrameNode.tsx +16 -2
- package/src/client/shell/store.ts +8 -8
- package/src/client/shell/styles.css +10 -9
- package/src/client/stage/main.tsx +56 -7
- package/src/shared/board-tree.ts +271 -140
- package/templates/AGENTS-embedded.md +3 -3
- package/templates/AGENTS-studio.md +3 -3
- package/templates/instructions/boards.md +47 -22
- package/templates/instructions/reference/deck-layouts.md +153 -199
- package/templates/instructions/reference/deck-story.md +6 -6
- 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,
|
|
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
|
|
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
|
|
27
|
-
|
|
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.
|
|
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 {
|
|
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
|
|
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
|
|
298
|
-
|
|
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) => {
|
|
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
|
-
//
|
|
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
|
-
//
|
|
341
|
-
//
|
|
342
|
-
//
|
|
343
|
-
//
|
|
344
|
-
const
|
|
345
|
-
|
|
346
|
-
|
|
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
|
-
|
|
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
|
|
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${
|
|
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={
|
|
359
|
-
className={`it board${
|
|
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
|
|
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${
|
|
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
|
-
|
|
386
|
-
const
|
|
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' : ''}${
|
|
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)
|
|
475
|
+
if (open) rows.push(...listRows(it.items, f, depth + 1))
|
|
404
476
|
return rows
|
|
405
477
|
}
|
|
406
478
|
|
|
407
|
-
|
|
408
|
-
|
|
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
|
|
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
|
|
74
|
-
//
|
|
75
|
-
const device = over?.device
|
|
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
|
|
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
|
|
560
|
-
|
|
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
|
-
:
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
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 =
|
|
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
|
|
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="
|
|
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>{
|
|
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
|
-
|
|
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={{
|
|
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
|
|
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
|
|
260
|
+
// the precedence chain (spec 09 slice 1): slide stage → authored
|
|
261
261
|
// viewport → content sizing → default (one helper, shared with shot) -
|
|
262
|
-
//
|
|
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:
|
|
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;
|
|
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
|
|
514
|
-
|
|
515
|
-
|
|
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 }
|