@puredesktop/puredesktop-ui-bridge 2.1.6 → 2.1.8

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 (64) hide show
  1. package/package.json +55 -1
  2. package/src/bridge/agentTypes.ts +21 -1
  3. package/src/bridge/collectionImagePaste.ts +19 -1
  4. package/src/bridge/context.mjs +32 -0
  5. package/src/bridge/documents.d.mts +76 -0
  6. package/src/bridge/documents.mjs +40 -0
  7. package/src/bridge/methods.d.mts +23 -0
  8. package/src/bridge/methods.mjs +23 -0
  9. package/src/bridge/pureRender/base.css +44 -0
  10. package/src/bridge/pureRender/document.ts +29 -2
  11. package/src/bridge/pureRender/index.ts +3 -0
  12. package/src/bridge/pureRender/normalize.ts +167 -0
  13. package/src/bridge/pureRender/sanitizeExportBody.test.ts +51 -0
  14. package/src/bridge/pureRender/sanitizeExportBody.ts +61 -0
  15. package/src/bridge/pureRender/styles.ts +125 -5
  16. package/src/bridge/pureRender/theme.test.ts +259 -0
  17. package/src/bridge/pureRender/theme.ts +19 -1
  18. package/src/bridge/pureRender/types.ts +33 -0
  19. package/src/bridge/pureRender/visualLayoutInspection.test.ts +26 -0
  20. package/src/bridge/pureRender/visualLayoutInspection.ts +75 -0
  21. package/src/bridge/react/useDocumentHotkeys.ts +41 -0
  22. package/src/bridge/react/useDocumentLifecycle.tsx +359 -0
  23. package/src/bridge/react/usePlatformAppSettings.test.tsx +105 -0
  24. package/src/bridge/react/usePlatformAppSettings.tsx +104 -0
  25. package/src/bridge/react/usePlatformJsonStore.test.tsx +152 -0
  26. package/src/bridge/react/usePlatformJsonStore.tsx +149 -0
  27. package/src/bridge/storage.d.mts +3 -1
  28. package/src/bridge/storage.test.ts +2 -2
  29. package/src/bridge/types.ts +7 -0
  30. package/src/components/agents/AgentDrawerPanel.tsx +2 -0
  31. package/src/components/agents/AgentMessageList.tsx +6 -1
  32. package/src/components/agents/AgentToolCallCard.tsx +20 -20
  33. package/src/components/agents/ContextPicker.tsx +239 -0
  34. package/src/components/agents/agentPanelStyles.ts +3 -4
  35. package/src/components/agents/agentTypes.ts +2 -0
  36. package/src/components/chrome/WorkspaceTabStrip.tsx +18 -1
  37. package/src/components/chrome/workspaceTabTypes.ts +2 -0
  38. package/src/components/common/containers/AppFrame.test.tsx +4 -4
  39. package/src/components/common/containers/AppFrame.tsx +10 -1
  40. package/src/components/common/containers/AppHeader.tsx +22 -0
  41. package/src/components/common/documents/DocumentHeaderActions.tsx +206 -0
  42. package/src/components/common/documents/DocumentSwitcher.tsx +1176 -0
  43. package/src/components/common/documents/index.ts +8 -0
  44. package/src/components/common/research/EvidenceDossier.tsx +1232 -150
  45. package/src/components/common/research/index.ts +2 -0
  46. package/src/components/print-design/PrintDesignPanel.test.tsx +430 -40
  47. package/src/components/print-design/PrintDesignPanel.tsx +1770 -207
  48. package/src/components/print-design/index.ts +2 -0
  49. package/src/components/print-design/previewContent.test.ts +32 -0
  50. package/src/components/print-design/previewContent.ts +31 -0
  51. package/src/context/buildContextDocument.test.ts +240 -0
  52. package/src/context/buildContextDocument.ts +309 -0
  53. package/src/context/contextAccess.ts +66 -0
  54. package/src/context/contextConfig.ts +72 -0
  55. package/src/context/contextDocument.test.ts +155 -0
  56. package/src/context/contextDocument.ts +300 -0
  57. package/src/context/contextSections.test.ts +88 -0
  58. package/src/context/contextSections.ts +84 -0
  59. package/src/context/htmlToContextMarkdown.test.ts +134 -0
  60. package/src/context/htmlToContextMarkdown.ts +369 -0
  61. package/src/theme/appAccents.test.ts +36 -35
  62. package/src/theme/appAccents.ts +87 -104
  63. package/src/theme/appIdentityCss.mjs +169 -231
  64. package/src/theme/appIdentityCss.ts +213 -233
@@ -0,0 +1,1176 @@
1
+ import {
2
+ useEffect,
3
+ useMemo,
4
+ useRef,
5
+ useState,
6
+ type ReactElement,
7
+ type ReactNode,
8
+ } from 'react'
9
+ import { styled } from 'styled-components'
10
+ import {
11
+ Boxes,
12
+ Clock,
13
+ FileStack,
14
+ FileUp,
15
+ MonitorUp,
16
+ Plus,
17
+ Search,
18
+ X,
19
+ } from 'lucide-react'
20
+ import {
21
+ listPlatformDocumentsByType,
22
+ listPlatformRecentDocuments,
23
+ type PlatformDocumentListEntry,
24
+ type PlatformRecentDocumentEntry,
25
+ } from '../../../bridge/documents.mjs'
26
+ import { bridge } from '../../../bridge/client.mjs'
27
+ import { PLATFORM_BRIDGE_METHODS } from '../../../bridge/methods.mjs'
28
+
29
+ /** A "Bring in" route beyond the two defaults (e.g. Research's source URL). */
30
+ export interface DocumentSwitcherSource {
31
+ id: string
32
+ label: string
33
+ icon?: ReactNode
34
+ onSelect: () => void
35
+ }
36
+
37
+ export type DocumentPreviewStyle =
38
+ | 'document'
39
+ | 'structured'
40
+ | 'grid'
41
+ | 'none'
42
+
43
+ export interface DocumentSwitcherProps {
44
+ appSlug: string
45
+ /** Document suffixes this app opens, e.g. ['.research']. */
46
+ suffixes: string[]
47
+ /** 'modal' renders full-screen over a scrim (⌘O); 'landing' renders inline
48
+ * as the empty-app home surface. */
49
+ variant?: 'modal' | 'landing'
50
+ open?: boolean
51
+ onClose?: () => void
52
+ onOpenDocument: (path: string) => void
53
+ onCreateNew: () => void
54
+ newLabel?: string
55
+ /** Main-pane title; defaults to `Open a <noun>`. */
56
+ title?: string
57
+ /** Singular item noun, e.g. 'board', 'dossier', 'report'. */
58
+ itemNoun?: string
59
+ /** Plural noun; defaults to `<noun>s`. */
60
+ itemNounPlural?: string
61
+ /** Wordmark name after "pure"; defaults to `appSlug`. */
62
+ wordmarkName?: string
63
+ /** Reading/authoring apps set true — serif item names + text previews. */
64
+ nameSerif?: boolean
65
+ /** Thumbnail style; defaults from `nameSerif` (document vs structured). */
66
+ previewStyle?: DocumentPreviewStyle
67
+ /** Extension shown in the storage footer; defaults to `suffixes[0]`. */
68
+ extensionLabel?: string
69
+ /** Folder hint shown in the storage footer, e.g. './Documents/Boards'. */
70
+ storageLabel?: string
71
+ /** In-app import of a compatible file (⌘I). Hidden when omitted. */
72
+ onImportFile?: () => void
73
+ /** Native OS file dialog. Defaults to a built-in open-file dialog. */
74
+ onChooseFromSystem?: () => void
75
+ /** Extra "Bring in" routes (bespoke per app). */
76
+ extraSources?: DocumentSwitcherSource[]
77
+ footer?: ReactNode
78
+ className?: string
79
+ }
80
+
81
+ interface DocItem {
82
+ path: string
83
+ name: string
84
+ isDraft: boolean
85
+ mtimeMs: number
86
+ kind: 'package' | 'file'
87
+ }
88
+
89
+ type BrowseSource = 'all' | 'recents' | 'drafts'
90
+ type SortKey = 'recent' | 'name'
91
+
92
+ function relativeDay(iso: string | number): string {
93
+ const time = typeof iso === 'number' ? iso : Date.parse(iso)
94
+ if (!Number.isFinite(time)) return ''
95
+ const days = Math.floor((Date.now() - time) / 86_400_000)
96
+ if (days <= 0) return 'today'
97
+ if (days === 1) return 'yesterday'
98
+ if (days < 7) return `${days}d ago`
99
+ return new Date(time).toLocaleDateString([], {
100
+ month: 'short',
101
+ day: 'numeric',
102
+ })
103
+ }
104
+
105
+ async function chooseFromSystem(): Promise<string | null> {
106
+ try {
107
+ const result = await bridge.call<{ path?: string | null }>(
108
+ PLATFORM_BRIDGE_METHODS.DIALOG_OPEN_FILE,
109
+ )
110
+ const path = result?.path
111
+ return typeof path === 'string' && path.trim() ? path : null
112
+ } catch {
113
+ return null
114
+ }
115
+ }
116
+
117
+ /**
118
+ * The Pure file picker: a full-screen, per-app "Open" surface. One shared
119
+ * shell re-skinned by each app via identity tokens (`--app-*` from `data-app`),
120
+ * document nouns, item-name font, and a preview thumbnail. Open · New · Import ·
121
+ * Choose-from-system, with a recents strip and a sortable browse table. Also
122
+ * serves as the empty-app landing state via `variant: 'landing'`.
123
+ */
124
+ export function DocumentSwitcher({
125
+ appSlug,
126
+ suffixes,
127
+ variant = 'modal',
128
+ open = true,
129
+ onClose,
130
+ onOpenDocument,
131
+ onCreateNew,
132
+ newLabel,
133
+ title,
134
+ itemNoun = 'document',
135
+ itemNounPlural,
136
+ wordmarkName,
137
+ nameSerif = false,
138
+ previewStyle,
139
+ extensionLabel,
140
+ storageLabel,
141
+ onImportFile,
142
+ onChooseFromSystem,
143
+ extraSources,
144
+ footer,
145
+ className,
146
+ }: DocumentSwitcherProps): ReactElement | null {
147
+ const noun = itemNoun
148
+ const plural = itemNounPlural ?? `${noun}s`
149
+ const preview: DocumentPreviewStyle =
150
+ previewStyle ?? (nameSerif ? 'document' : 'structured')
151
+ const heading = title ?? `Open a ${noun}`
152
+ const ext = extensionLabel ?? suffixes[0] ?? ''
153
+
154
+ const [recents, setRecents] = useState<PlatformRecentDocumentEntry[]>([])
155
+ const [items, setItems] = useState<DocItem[] | null>(null)
156
+ const [query, setQuery] = useState('')
157
+ const [source, setSource] = useState<BrowseSource>('all')
158
+ const [sort, setSort] = useState<SortKey>('recent')
159
+ const [selected, setSelected] = useState(0)
160
+ const filterRef = useRef<HTMLInputElement>(null)
161
+ const listRef = useRef<HTMLDivElement>(null)
162
+
163
+ useEffect(() => {
164
+ if (!open) return
165
+ let cancelled = false
166
+ void (async () => {
167
+ const [recentEntries, all] = await Promise.all([
168
+ listPlatformRecentDocuments({ appSlug, limit: 8 }).catch(() => []),
169
+ listPlatformDocumentsByType({ suffixes }).catch(() => []),
170
+ ])
171
+ if (cancelled) return
172
+ setRecents(recentEntries)
173
+ setItems(
174
+ all.map((entry: PlatformDocumentListEntry) => ({
175
+ path: entry.path,
176
+ name: entry.name,
177
+ isDraft: entry.isDraft,
178
+ mtimeMs: entry.mtimeMs,
179
+ kind: entry.kind,
180
+ })),
181
+ )
182
+ })()
183
+ return () => {
184
+ cancelled = true
185
+ }
186
+ }, [appSlug, open, suffixes])
187
+
188
+ // Focus the filter when the picker opens.
189
+ useEffect(() => {
190
+ if (open) {
191
+ const timer = window.setTimeout(() => filterRef.current?.focus(), 20)
192
+ return () => window.clearTimeout(timer)
193
+ }
194
+ return undefined
195
+ }, [open])
196
+
197
+ useEffect(() => {
198
+ setSelected(0)
199
+ }, [source, sort, query])
200
+
201
+ const recentPaths = useMemo(
202
+ () => new Set(recents.map(entry => entry.path)),
203
+ [recents],
204
+ )
205
+
206
+ const filter = query.trim().toLowerCase()
207
+ const rows = useMemo(() => {
208
+ if (!items) return []
209
+ let scoped =
210
+ source === 'drafts'
211
+ ? items.filter(item => item.isDraft)
212
+ : source === 'recents'
213
+ ? items.filter(item => recentPaths.has(item.path))
214
+ : items
215
+ if (filter) {
216
+ scoped = scoped.filter(item => item.name.toLowerCase().includes(filter))
217
+ }
218
+ const sorted = [...scoped]
219
+ if (sort === 'name') {
220
+ sorted.sort((a, b) => a.name.localeCompare(b.name))
221
+ } else {
222
+ sorted.sort((a, b) => b.mtimeMs - a.mtimeMs)
223
+ }
224
+ return sorted
225
+ }, [filter, items, recentPaths, sort, source])
226
+
227
+ // Recent preview cards — up to 3, newest first (recents list, else newest).
228
+ const recentCards = useMemo(() => {
229
+ if (!items) return []
230
+ const byPath = new Map(items.map(item => [item.path, item]))
231
+ const fromRecents = recents
232
+ .map(entry => byPath.get(entry.path))
233
+ .filter((item): item is DocItem => Boolean(item))
234
+ const source =
235
+ fromRecents.length > 0
236
+ ? fromRecents
237
+ : [...items].sort((a, b) => b.mtimeMs - a.mtimeMs)
238
+ return source.slice(0, 3)
239
+ }, [items, recents])
240
+
241
+ const draftCount = items?.filter(item => item.isDraft).length ?? 0
242
+
243
+ const handleChoose = onChooseFromSystem
244
+ ? onChooseFromSystem
245
+ : () => {
246
+ void chooseFromSystem().then(path => {
247
+ if (path) onOpenDocument(path)
248
+ })
249
+ }
250
+
251
+ useEffect(() => {
252
+ if (!open) return
253
+ const onKeyDown = (event: KeyboardEvent): void => {
254
+ const meta = event.metaKey || event.ctrlKey
255
+ if (event.key === 'Escape') {
256
+ event.preventDefault()
257
+ onClose?.()
258
+ } else if (meta && event.key.toLowerCase() === 'n') {
259
+ event.preventDefault()
260
+ onCreateNew()
261
+ } else if (meta && event.key.toLowerCase() === 'i' && onImportFile) {
262
+ event.preventDefault()
263
+ onImportFile()
264
+ } else if (!meta && event.key === 'ArrowDown') {
265
+ event.preventDefault()
266
+ setSelected(index => Math.min(index + 1, Math.max(rows.length - 1, 0)))
267
+ } else if (!meta && event.key === 'ArrowUp') {
268
+ event.preventDefault()
269
+ setSelected(index => Math.max(index - 1, 0))
270
+ } else if (event.key === 'Enter' && rows.length > 0) {
271
+ const active = document.activeElement
272
+ // Let Enter inside the New tile / buttons act normally.
273
+ if (active instanceof HTMLButtonElement) return
274
+ event.preventDefault()
275
+ const item = rows[Math.min(selected, rows.length - 1)]
276
+ if (item) onOpenDocument(item.path)
277
+ }
278
+ }
279
+ window.addEventListener('keydown', onKeyDown)
280
+ return () => window.removeEventListener('keydown', onKeyDown)
281
+ }, [
282
+ onClose,
283
+ onCreateNew,
284
+ onImportFile,
285
+ onOpenDocument,
286
+ open,
287
+ rows,
288
+ selected,
289
+ ])
290
+
291
+ if (!open) return null
292
+
293
+ const substats =
294
+ items === null
295
+ ? 'LOADING…'
296
+ : `${items.length} ${plural.toUpperCase()}${
297
+ draftCount ? ` · ${draftCount} DRAFT${draftCount === 1 ? '' : 'S'}` : ''
298
+ }`
299
+
300
+ const sheet = (
301
+ <Sheet
302
+ data-app={appSlug}
303
+ className={className}
304
+ $variant={variant}
305
+ role={variant === 'modal' ? 'dialog' : undefined}
306
+ aria-modal={variant === 'modal' ? true : undefined}
307
+ aria-label={heading}
308
+ >
309
+ {/* LEFT RAIL */}
310
+ <Rail>
311
+ <Wordmark>
312
+ <span>pure</span>
313
+ <strong>{wordmarkName ?? appSlug}</strong>
314
+ </Wordmark>
315
+
316
+ <RailKicker>Browse</RailKicker>
317
+ <RailItem
318
+ type="button"
319
+ $active={source === 'all'}
320
+ onClick={() => setSource('all')}
321
+ >
322
+ <Boxes size={16} strokeWidth={2} aria-hidden />
323
+ All {plural}
324
+ <RailCount>{items?.length ?? 0}</RailCount>
325
+ </RailItem>
326
+ <RailItem
327
+ type="button"
328
+ $active={source === 'recents'}
329
+ onClick={() => setSource('recents')}
330
+ >
331
+ <Clock size={16} strokeWidth={2} aria-hidden />
332
+ Recents
333
+ </RailItem>
334
+ <RailItem
335
+ type="button"
336
+ $active={source === 'drafts'}
337
+ onClick={() => setSource('drafts')}
338
+ >
339
+ <FileStack size={16} strokeWidth={2} aria-hidden />
340
+ Drafts
341
+ {draftCount ? <RailCount>{draftCount}</RailCount> : null}
342
+ </RailItem>
343
+
344
+ <RailKicker style={{ marginTop: 24 }}>Bring in</RailKicker>
345
+ {onImportFile ? (
346
+ <RailItem type="button" onClick={onImportFile}>
347
+ <FileUp size={16} strokeWidth={2} aria-hidden />
348
+ Import a file…
349
+ </RailItem>
350
+ ) : null}
351
+ <RailItem type="button" onClick={handleChoose}>
352
+ <MonitorUp size={16} strokeWidth={2} aria-hidden />
353
+ Choose from system…
354
+ </RailItem>
355
+ {extraSources?.map(entry => (
356
+ <RailItem key={entry.id} type="button" onClick={entry.onSelect}>
357
+ {entry.icon ?? <FileUp size={16} strokeWidth={2} aria-hidden />}
358
+ {entry.label}
359
+ </RailItem>
360
+ ))}
361
+
362
+ <RailFooter>
363
+ <RailKicker>Storage</RailKicker>
364
+ <StorageText>
365
+ Saved to <strong>PureFiles</strong>
366
+ <StorageMeta>
367
+ {storageLabel ? `${storageLabel} · ` : ''}
368
+ {ext}
369
+ </StorageMeta>
370
+ </StorageText>
371
+ </RailFooter>
372
+ </Rail>
373
+
374
+ {/* MAIN */}
375
+ <Main>
376
+ <MainHead>
377
+ <div>
378
+ <Heading>{heading}</Heading>
379
+ <SubStats>{substats}</SubStats>
380
+ </div>
381
+ <HeadControls>
382
+ <FilterWrap>
383
+ <Search size={16} strokeWidth={2} aria-hidden />
384
+ <FilterInput
385
+ ref={filterRef}
386
+ value={query}
387
+ onChange={event => setQuery(event.target.value)}
388
+ placeholder={`Filter ${plural}…`}
389
+ aria-label={`Filter ${plural}`}
390
+ />
391
+ </FilterWrap>
392
+ {variant === 'modal' ? (
393
+ <CloseButton
394
+ type="button"
395
+ onClick={() => onClose?.()}
396
+ aria-label="Close"
397
+ >
398
+ <X size={17} strokeWidth={2} aria-hidden />
399
+ </CloseButton>
400
+ ) : null}
401
+ </HeadControls>
402
+ </MainHead>
403
+
404
+ <Body>
405
+ <CardRow>
406
+ <NewCard type="button" onClick={onCreateNew}>
407
+ <NewIcon>
408
+ <Plus size={22} strokeWidth={2.2} aria-hidden />
409
+ </NewIcon>
410
+ <NewLabel>{newLabel ?? `New ${noun}`}</NewLabel>
411
+ <NewHint>⌘N</NewHint>
412
+ </NewCard>
413
+ {recentCards.map(item => (
414
+ <PreviewCard
415
+ key={item.path}
416
+ type="button"
417
+ onClick={() => onOpenDocument(item.path)}
418
+ >
419
+ <PreviewThumb>
420
+ <Thumbnail $preview={preview} $serif={nameSerif} name={item.name} />
421
+ </PreviewThumb>
422
+ <PreviewMeta>
423
+ <PreviewName $serif={nameSerif}>{item.name}</PreviewName>
424
+ <PreviewWhen>{relativeDay(item.mtimeMs)}</PreviewWhen>
425
+ </PreviewMeta>
426
+ </PreviewCard>
427
+ ))}
428
+ </CardRow>
429
+
430
+ <ListHead>
431
+ <ListKicker>All {plural}</ListKicker>
432
+ <SortControls>
433
+ <SortButton
434
+ type="button"
435
+ $active={sort === 'recent'}
436
+ onClick={() => setSort('recent')}
437
+ >
438
+ Recent
439
+ </SortButton>
440
+ <SortButton
441
+ type="button"
442
+ $active={sort === 'name'}
443
+ onClick={() => setSort('name')}
444
+ >
445
+ Name
446
+ </SortButton>
447
+ </SortControls>
448
+ </ListHead>
449
+
450
+ <ListBox ref={listRef}>
451
+ {items === null ? (
452
+ <EmptyNote>Loading…</EmptyNote>
453
+ ) : rows.length === 0 ? (
454
+ <EmptyNote>
455
+ {filter
456
+ ? 'Nothing matches that filter.'
457
+ : source === 'drafts'
458
+ ? 'No drafts yet.'
459
+ : `No ${plural} yet — create the first, import one, or choose from your system.`}
460
+ </EmptyNote>
461
+ ) : (
462
+ rows.map((item, index) => (
463
+ <Row
464
+ key={item.path}
465
+ type="button"
466
+ $selected={index === selected}
467
+ onMouseEnter={() => setSelected(index)}
468
+ onClick={() => onOpenDocument(item.path)}
469
+ >
470
+ <RowIcon>
471
+ <Boxes size={17} strokeWidth={2} aria-hidden />
472
+ </RowIcon>
473
+ <RowMain>
474
+ <RowName $serif={nameSerif}>{item.name}</RowName>
475
+ <RowMeta>
476
+ {item.kind === 'package' ? 'Package' : 'File'} · {ext}
477
+ </RowMeta>
478
+ </RowMain>
479
+ <RowWhen>{relativeDay(item.mtimeMs)}</RowWhen>
480
+ <RowBadge $draft={item.isDraft}>
481
+ {item.isDraft ? 'Draft' : capitalize(noun)}
482
+ </RowBadge>
483
+ </Row>
484
+ ))
485
+ )}
486
+ </ListBox>
487
+ {footer}
488
+ </Body>
489
+
490
+ <FooterBar>
491
+ <FooterHints>↑↓ to navigate · ⏎ to open{onImportFile ? ' · ⌘I import' : ''}</FooterHints>
492
+ <FooterActions>
493
+ {onImportFile ? (
494
+ <SecondaryButton type="button" onClick={onImportFile}>
495
+ Import a file…
496
+ </SecondaryButton>
497
+ ) : (
498
+ <SecondaryButton type="button" onClick={handleChoose}>
499
+ Choose from system…
500
+ </SecondaryButton>
501
+ )}
502
+ <PrimaryButton
503
+ type="button"
504
+ disabled={rows.length === 0}
505
+ onClick={() => {
506
+ const item = rows[Math.min(selected, rows.length - 1)]
507
+ if (item) onOpenDocument(item.path)
508
+ }}
509
+ >
510
+ Open {noun}
511
+ </PrimaryButton>
512
+ </FooterActions>
513
+ </FooterBar>
514
+ </Main>
515
+ </Sheet>
516
+ )
517
+
518
+ if (variant === 'landing') return sheet
519
+
520
+ return (
521
+ <Scrim onMouseDown={() => onClose?.()}>
522
+ <SheetHolder onMouseDown={event => event.stopPropagation()}>
523
+ {sheet}
524
+ </SheetHolder>
525
+ </Scrim>
526
+ )
527
+ }
528
+
529
+ function capitalize(value: string): string {
530
+ return value ? value[0]!.toUpperCase() + value.slice(1) : value
531
+ }
532
+
533
+ /** A lightweight preview thumbnail derived from the doc name + app style. */
534
+ function Thumbnail({
535
+ $preview,
536
+ $serif,
537
+ name,
538
+ }: {
539
+ $preview: DocumentPreviewStyle
540
+ $serif: boolean
541
+ name: string
542
+ }): ReactElement | null {
543
+ if ($preview === 'none') {
544
+ return (
545
+ <ThumbFallback>
546
+ <Boxes size={26} strokeWidth={1.6} aria-hidden />
547
+ </ThumbFallback>
548
+ )
549
+ }
550
+ if ($preview === 'document') {
551
+ return (
552
+ <ThumbDoc>
553
+ <ThumbDocTitle $serif={$serif}>{name}</ThumbDocTitle>
554
+ <ThumbLine style={{ width: '92%' }} />
555
+ <ThumbLine style={{ width: '80%' }} />
556
+ <ThumbLine style={{ width: '88%' }} />
557
+ <ThumbPills>
558
+ <ThumbPill $accent style={{ width: 22 }} />
559
+ <ThumbPill style={{ width: 14 }} />
560
+ <ThumbPill style={{ width: 18 }} />
561
+ </ThumbPills>
562
+ </ThumbDoc>
563
+ )
564
+ }
565
+ if ($preview === 'grid') {
566
+ return (
567
+ <ThumbGrid>
568
+ {Array.from({ length: 4 }).map((_, index) => (
569
+ <ThumbCell key={index} $accent={index === 0} />
570
+ ))}
571
+ </ThumbGrid>
572
+ )
573
+ }
574
+ // structured — columns of blocks (kanban-ish)
575
+ return (
576
+ <ThumbCols>
577
+ <ThumbCol>
578
+ <ThumbColHead style={{ width: '60%' }} />
579
+ <ThumbBlock $accent />
580
+ <ThumbBlock />
581
+ </ThumbCol>
582
+ <ThumbCol>
583
+ <ThumbColHead style={{ width: '55%' }} />
584
+ <ThumbBlock />
585
+ </ThumbCol>
586
+ <ThumbCol>
587
+ <ThumbColHead style={{ width: '50%' }} />
588
+ </ThumbCol>
589
+ </ThumbCols>
590
+ )
591
+ }
592
+
593
+ //#region styled-components
594
+
595
+ const serifStack =
596
+ "var(--platform-typography-font-family-serif, 'Source Serif 4', Georgia, serif)"
597
+ const sansStack = 'var(--platform-typography-font-family, Archivo, system-ui, sans-serif)'
598
+ const monoStack =
599
+ "var(--platform-typography-font-family-mono, 'JetBrains Mono', ui-monospace, monospace)"
600
+ const ink = 'var(--platform-colors-text, #1b1b1e)'
601
+ const faint = 'var(--platform-colors-text-muted, #9a9aa0)'
602
+ const line = 'var(--platform-colors-border, #ece7ee)'
603
+ const surface = 'var(--platform-colors-surface, #faf8fb)'
604
+ const elevated = 'var(--platform-colors-elevated, #ffffff)'
605
+
606
+ const Scrim = styled.div`
607
+ position: fixed;
608
+ inset: 0;
609
+ z-index: 60;
610
+ display: flex;
611
+ background: rgba(20, 18, 22, 0.34);
612
+ `
613
+
614
+ const SheetHolder = styled.div`
615
+ position: absolute;
616
+ inset: 28px;
617
+ display: flex;
618
+ `
619
+
620
+ const Sheet = styled.div<{ $variant: 'modal' | 'landing' }>`
621
+ display: grid;
622
+ grid-template-columns: 296px 1fr;
623
+ width: 100%;
624
+ height: 100%;
625
+ min-height: 0;
626
+ overflow: hidden;
627
+ color: ${ink};
628
+ background: ${elevated};
629
+ border-radius: ${({ $variant }) => ($variant === 'modal' ? '10px' : '0')};
630
+ box-shadow: ${({ $variant }) =>
631
+ $variant === 'modal' ? '0 24px 80px rgba(14, 14, 17, 0.4)' : 'none'};
632
+ font-family: ${sansStack};
633
+ `
634
+
635
+ const Rail = styled.div`
636
+ display: flex;
637
+ flex-direction: column;
638
+ padding: 30px 22px;
639
+ background: ${surface};
640
+ border-right: 1px solid ${line};
641
+ `
642
+
643
+ const Wordmark = styled.div`
644
+ margin-bottom: 30px;
645
+ font-size: 19px;
646
+ font-weight: 700;
647
+
648
+ span {
649
+ color: ${faint};
650
+ }
651
+ strong {
652
+ color: var(--app-acc, #1f9d57);
653
+ font-weight: 700;
654
+ }
655
+ `
656
+
657
+ const RailKicker = styled.div`
658
+ margin-bottom: 12px;
659
+ font-family: ${monoStack};
660
+ font-size: 10px;
661
+ font-weight: 600;
662
+ letter-spacing: 0.14em;
663
+ text-transform: uppercase;
664
+ color: ${faint};
665
+ `
666
+
667
+ const RailItem = styled.button<{ $active?: boolean }>`
668
+ display: flex;
669
+ align-items: center;
670
+ gap: 11px;
671
+ width: 100%;
672
+ margin-bottom: 2px;
673
+ padding: 10px 12px;
674
+ border: 0;
675
+ border-left: 3px solid
676
+ ${({ $active }) => ($active ? 'var(--app-acc, #1f9d57)' : 'transparent')};
677
+ border-radius: 7px;
678
+ background: ${({ $active }) =>
679
+ $active ? 'var(--app-bg, #eef7f1)' : 'transparent'};
680
+ color: ${({ $active }) => ($active ? 'var(--app-text, #15703f)' : '#4a4a50')};
681
+ font: inherit;
682
+ font-size: 14px;
683
+ font-weight: ${({ $active }) => ($active ? 600 : 400)};
684
+ text-align: left;
685
+ cursor: pointer;
686
+
687
+ &:hover {
688
+ background: ${({ $active }) =>
689
+ $active ? 'var(--app-bg, #eef7f1)' : 'rgba(20, 18, 22, 0.05)'};
690
+ }
691
+ `
692
+
693
+ const RailCount = styled.span`
694
+ margin-left: auto;
695
+ font-family: ${monoStack};
696
+ font-size: 11px;
697
+ color: ${faint};
698
+ `
699
+
700
+ const RailFooter = styled.div`
701
+ margin-top: auto;
702
+ padding-top: 22px;
703
+ border-top: 1px solid ${line};
704
+ `
705
+
706
+ const StorageText = styled.div`
707
+ font-size: 13px;
708
+ line-height: 1.5;
709
+ color: #6a6a70;
710
+
711
+ strong {
712
+ color: ${ink};
713
+ font-weight: 600;
714
+ }
715
+ `
716
+
717
+ const StorageMeta = styled.span`
718
+ display: block;
719
+ font-family: ${monoStack};
720
+ font-size: 11px;
721
+ color: ${faint};
722
+ `
723
+
724
+ const Main = styled.div`
725
+ display: flex;
726
+ flex-direction: column;
727
+ min-width: 0;
728
+ `
729
+
730
+ const MainHead = styled.div`
731
+ display: flex;
732
+ align-items: flex-start;
733
+ justify-content: space-between;
734
+ gap: 24px;
735
+ padding: 28px 40px 20px;
736
+ border-bottom: 1px solid ${line};
737
+ `
738
+
739
+ const Heading = styled.div`
740
+ font-family: ${serifStack};
741
+ font-size: 31px;
742
+ font-weight: 600;
743
+ letter-spacing: -0.01em;
744
+ line-height: 1.1;
745
+ `
746
+
747
+ const SubStats = styled.div`
748
+ margin-top: 6px;
749
+ font-family: ${monoStack};
750
+ font-size: 12px;
751
+ letter-spacing: 0.02em;
752
+ color: ${faint};
753
+ `
754
+
755
+ const HeadControls = styled.div`
756
+ display: flex;
757
+ align-items: center;
758
+ gap: 12px;
759
+ `
760
+
761
+ const FilterWrap = styled.div`
762
+ position: relative;
763
+ display: flex;
764
+ align-items: center;
765
+
766
+ svg {
767
+ position: absolute;
768
+ left: 13px;
769
+ color: ${faint};
770
+ pointer-events: none;
771
+ }
772
+ `
773
+
774
+ const FilterInput = styled.input`
775
+ width: 260px;
776
+ padding: 11px 14px 11px 38px;
777
+ border: 1px solid ${line};
778
+ border-radius: 8px;
779
+ background: ${surface};
780
+ color: ${ink};
781
+ font: inherit;
782
+ font-size: 14px;
783
+ outline: none;
784
+
785
+ &:focus {
786
+ border-color: var(--app-acc, #1f9d57);
787
+ }
788
+ `
789
+
790
+ const CloseButton = styled.button`
791
+ display: flex;
792
+ align-items: center;
793
+ justify-content: center;
794
+ width: 38px;
795
+ height: 38px;
796
+ border: 1px solid ${line};
797
+ border-radius: 8px;
798
+ background: ${elevated};
799
+ color: #6a6a70;
800
+ cursor: pointer;
801
+
802
+ &:hover {
803
+ background: ${surface};
804
+ }
805
+ `
806
+
807
+ const Body = styled.div`
808
+ flex: 1;
809
+ min-height: 0;
810
+ overflow-y: auto;
811
+ padding: 26px 40px 40px;
812
+ `
813
+
814
+ const CardRow = styled.div`
815
+ display: grid;
816
+ grid-template-columns: repeat(auto-fill, minmax(210px, 1fr));
817
+ gap: 16px;
818
+ margin-bottom: 34px;
819
+ `
820
+
821
+ const cardBase = `
822
+ min-height: 190px;
823
+ border-radius: 10px;
824
+ cursor: pointer;
825
+ transition: transform 0.12s ease, box-shadow 0.12s ease, border-color 0.12s ease;
826
+
827
+ &:hover {
828
+ transform: translateY(-3px);
829
+ box-shadow: 0 12px 30px rgba(14, 14, 17, 0.16);
830
+ }
831
+ `
832
+
833
+ const NewCard = styled.button`
834
+ ${cardBase}
835
+ display: flex;
836
+ flex-direction: column;
837
+ align-items: center;
838
+ justify-content: center;
839
+ gap: 12px;
840
+ border: 1.5px dashed var(--app-acc, #1f9d57);
841
+ background: var(--app-bg, #eef7f1);
842
+ font: inherit;
843
+ `
844
+
845
+ const NewIcon = styled.span`
846
+ display: flex;
847
+ align-items: center;
848
+ justify-content: center;
849
+ width: 46px;
850
+ height: 46px;
851
+ border-radius: 11px;
852
+ background: var(--app-block, #235240);
853
+ color: #fff;
854
+ `
855
+
856
+ const NewLabel = styled.span`
857
+ font-size: 15px;
858
+ font-weight: 600;
859
+ color: var(--app-text, #15703f);
860
+ `
861
+
862
+ const NewHint = styled.span`
863
+ font-family: ${monoStack};
864
+ font-size: 10.5px;
865
+ color: var(--app-text, #15703f);
866
+ opacity: 0.7;
867
+ `
868
+
869
+ const PreviewCard = styled.button`
870
+ ${cardBase}
871
+ display: flex;
872
+ flex-direction: column;
873
+ overflow: hidden;
874
+ border: 1px solid ${line};
875
+ background: ${elevated};
876
+ font: inherit;
877
+ text-align: left;
878
+ `
879
+
880
+ const PreviewThumb = styled.div`
881
+ flex: 1;
882
+ min-height: 0;
883
+ padding: 14px;
884
+ background: ${surface};
885
+ border-bottom: 1px solid ${line};
886
+ overflow: hidden;
887
+ `
888
+
889
+ const PreviewMeta = styled.div`
890
+ display: flex;
891
+ align-items: center;
892
+ justify-content: space-between;
893
+ gap: 8px;
894
+ padding: 13px 15px;
895
+ `
896
+
897
+ const PreviewName = styled.div<{ $serif?: boolean }>`
898
+ overflow: hidden;
899
+ font-family: ${({ $serif }) => ($serif ? serifStack : sansStack)};
900
+ font-size: 14.5px;
901
+ font-weight: 600;
902
+ white-space: nowrap;
903
+ text-overflow: ellipsis;
904
+ `
905
+
906
+ const PreviewWhen = styled.div`
907
+ flex: none;
908
+ font-family: ${monoStack};
909
+ font-size: 10.5px;
910
+ color: ${faint};
911
+ `
912
+
913
+ const ListHead = styled.div`
914
+ display: flex;
915
+ align-items: center;
916
+ justify-content: space-between;
917
+ margin-bottom: 12px;
918
+ `
919
+
920
+ const ListKicker = styled.div`
921
+ font-family: ${monoStack};
922
+ font-size: 11px;
923
+ font-weight: 500;
924
+ letter-spacing: 0.12em;
925
+ text-transform: uppercase;
926
+ color: #6a6a70;
927
+ `
928
+
929
+ const SortControls = styled.div`
930
+ display: flex;
931
+ gap: 16px;
932
+ `
933
+
934
+ const SortButton = styled.button<{ $active?: boolean }>`
935
+ padding: 0 0 3px;
936
+ border: 0;
937
+ border-bottom: 2px solid
938
+ ${({ $active }) => ($active ? 'var(--app-acc, #1f9d57)' : 'transparent')};
939
+ background: transparent;
940
+ color: ${({ $active }) => ($active ? 'var(--app-text, #15703f)' : faint)};
941
+ font-family: ${monoStack};
942
+ font-size: 11px;
943
+ cursor: pointer;
944
+ `
945
+
946
+ const ListBox = styled.div`
947
+ overflow: hidden;
948
+ border: 1px solid ${line};
949
+ border-radius: 10px;
950
+ `
951
+
952
+ const Row = styled.button<{ $selected?: boolean }>`
953
+ display: grid;
954
+ grid-template-columns: 34px 1fr 96px 92px;
955
+ align-items: center;
956
+ gap: 14px;
957
+ width: 100%;
958
+ padding: 13px 18px;
959
+ border: 0;
960
+ border-bottom: 1px solid ${line};
961
+ background: ${({ $selected }) =>
962
+ $selected ? 'var(--app-bg, #eef7f1)' : 'transparent'};
963
+ font: inherit;
964
+ text-align: left;
965
+ cursor: pointer;
966
+
967
+ &:last-child {
968
+ border-bottom: 0;
969
+ }
970
+ `
971
+
972
+ const RowIcon = styled.span`
973
+ display: flex;
974
+ align-items: center;
975
+ justify-content: center;
976
+ width: 34px;
977
+ height: 34px;
978
+ border-radius: 8px;
979
+ background: var(--app-bg, #eef7f1);
980
+ color: var(--app-acc, #1f9d57);
981
+ `
982
+
983
+ const RowMain = styled.div`
984
+ min-width: 0;
985
+ `
986
+
987
+ const RowName = styled.div<{ $serif?: boolean }>`
988
+ overflow: hidden;
989
+ font-family: ${({ $serif }) => ($serif ? serifStack : sansStack)};
990
+ font-size: 14.5px;
991
+ font-weight: 600;
992
+ white-space: nowrap;
993
+ text-overflow: ellipsis;
994
+ `
995
+
996
+ const RowMeta = styled.div`
997
+ margin-top: 2px;
998
+ font-family: ${monoStack};
999
+ font-size: 10.5px;
1000
+ color: ${faint};
1001
+ `
1002
+
1003
+ const RowWhen = styled.div`
1004
+ font-family: ${monoStack};
1005
+ font-size: 11.5px;
1006
+ color: #6a6a70;
1007
+ `
1008
+
1009
+ const RowBadge = styled.span<{ $draft?: boolean }>`
1010
+ justify-self: end;
1011
+ padding: 3px 9px;
1012
+ border-radius: 999px;
1013
+ font-family: ${monoStack};
1014
+ font-size: 10px;
1015
+ letter-spacing: 0.06em;
1016
+ text-transform: uppercase;
1017
+ color: ${({ $draft }) => ($draft ? '#8a5a12' : '#6a6a70')};
1018
+ background: ${({ $draft }) => ($draft ? '#f6ecd6' : 'rgba(20,18,22,0.05)')};
1019
+ `
1020
+
1021
+ const EmptyNote = styled.p`
1022
+ margin: 0;
1023
+ padding: 34px 20px;
1024
+ text-align: center;
1025
+ color: ${faint};
1026
+ font-size: 13.5px;
1027
+ `
1028
+
1029
+ const FooterBar = styled.div`
1030
+ display: flex;
1031
+ align-items: center;
1032
+ justify-content: space-between;
1033
+ padding: 16px 40px;
1034
+ border-top: 1px solid ${line};
1035
+ background: ${surface};
1036
+ `
1037
+
1038
+ const FooterHints = styled.div`
1039
+ font-family: ${monoStack};
1040
+ font-size: 11px;
1041
+ color: ${faint};
1042
+ `
1043
+
1044
+ const FooterActions = styled.div`
1045
+ display: flex;
1046
+ gap: 10px;
1047
+ `
1048
+
1049
+ const SecondaryButton = styled.button`
1050
+ padding: 9px 16px;
1051
+ border: 1px solid ${line};
1052
+ border-radius: 8px;
1053
+ background: ${elevated};
1054
+ color: #4a4a50;
1055
+ font: inherit;
1056
+ font-size: 13px;
1057
+ font-weight: 600;
1058
+ cursor: pointer;
1059
+
1060
+ &:hover {
1061
+ background: ${surface};
1062
+ }
1063
+ `
1064
+
1065
+ const PrimaryButton = styled.button`
1066
+ padding: 9px 18px;
1067
+ border: 0;
1068
+ border-radius: 8px;
1069
+ background: var(--app-block, #235240);
1070
+ color: #fff;
1071
+ font: inherit;
1072
+ font-size: 13px;
1073
+ font-weight: 600;
1074
+ cursor: pointer;
1075
+
1076
+ &:disabled {
1077
+ opacity: 0.5;
1078
+ cursor: not-allowed;
1079
+ }
1080
+ `
1081
+
1082
+ // --- thumbnails ---
1083
+
1084
+ const ThumbFallback = styled.div`
1085
+ display: flex;
1086
+ align-items: center;
1087
+ justify-content: center;
1088
+ width: 100%;
1089
+ height: 100%;
1090
+ color: ${faint};
1091
+ `
1092
+
1093
+ const ThumbDoc = styled.div`
1094
+ display: flex;
1095
+ flex-direction: column;
1096
+ gap: 5px;
1097
+ `
1098
+
1099
+ const ThumbDocTitle = styled.div<{ $serif?: boolean }>`
1100
+ margin-bottom: 4px;
1101
+ font-family: ${({ $serif }) => ($serif ? serifStack : sansStack)};
1102
+ font-size: 13px;
1103
+ font-weight: 600;
1104
+ line-height: 1.25;
1105
+ color: ${ink};
1106
+ overflow: hidden;
1107
+ display: -webkit-box;
1108
+ -webkit-line-clamp: 2;
1109
+ -webkit-box-orient: vertical;
1110
+ `
1111
+
1112
+ const ThumbLine = styled.div`
1113
+ height: 5px;
1114
+ border-radius: 2px;
1115
+ background: rgba(20, 18, 22, 0.12);
1116
+ `
1117
+
1118
+ const ThumbPills = styled.div`
1119
+ display: flex;
1120
+ gap: 5px;
1121
+ margin-top: 6px;
1122
+ `
1123
+
1124
+ const ThumbPill = styled.span<{ $accent?: boolean }>`
1125
+ height: 5px;
1126
+ border-radius: 999px;
1127
+ background: ${({ $accent }) =>
1128
+ $accent ? 'var(--app-acc, #1f9d57)' : 'rgba(20,18,22,0.15)'};
1129
+ `
1130
+
1131
+ const ThumbGrid = styled.div`
1132
+ display: grid;
1133
+ grid-template-columns: 1fr 1fr;
1134
+ grid-template-rows: 1fr 1fr;
1135
+ gap: 6px;
1136
+ width: 100%;
1137
+ height: 100%;
1138
+ `
1139
+
1140
+ const ThumbCell = styled.div<{ $accent?: boolean }>`
1141
+ border-radius: 4px;
1142
+ background: ${elevated};
1143
+ border: 1px solid ${line};
1144
+ border-left: ${({ $accent }) =>
1145
+ $accent ? '2px solid var(--app-acc, #1f9d57)' : `1px solid ${line}`};
1146
+ `
1147
+
1148
+ const ThumbCols = styled.div`
1149
+ display: flex;
1150
+ gap: 7px;
1151
+ width: 100%;
1152
+ `
1153
+
1154
+ const ThumbCol = styled.div`
1155
+ display: flex;
1156
+ flex: 1;
1157
+ flex-direction: column;
1158
+ gap: 5px;
1159
+ `
1160
+
1161
+ const ThumbColHead = styled.div`
1162
+ height: 6px;
1163
+ border-radius: 2px;
1164
+ background: rgba(20, 18, 22, 0.14);
1165
+ `
1166
+
1167
+ const ThumbBlock = styled.div<{ $accent?: boolean }>`
1168
+ height: 22px;
1169
+ border-radius: 3px;
1170
+ background: ${elevated};
1171
+ border: 1px solid ${line};
1172
+ border-left: ${({ $accent }) =>
1173
+ $accent ? '2px solid var(--app-acc, #1f9d57)' : `1px solid ${line}`};
1174
+ `
1175
+
1176
+ //#endregion