@book.dev/sdk 1.76.2 → 2.1.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/dist/templates.js CHANGED
@@ -1,8 +1,37 @@
1
+ import { TITLE_PROPERTY_ID } from './database';
2
+ import { buildSampleDocument } from './sampleDocument';
1
3
  const emptySnapshot = (blocks) => ({
2
4
  editorjs: { blocks },
3
5
  values: [],
4
6
  names: [],
5
7
  });
8
+ // ── The standardized "how to use this" guidance callout ─────────────────────
9
+ //
10
+ // The five database fixtures — which don't open with strong in-doc guidance of
11
+ // their own — lead with one consistent `info` callout: what the template
12
+ // demonstrates, then how to try it. The English text below is the canonical
13
+ // default; the gallery passes a localized override at instantiation (the ui
14
+ // package's `templates.<id>.guidance` i18n keys mirror these strings).
15
+ const GUIDANCE = {
16
+ taskBoard: 'This template shows a task database: the Status property drives the kanban columns, and the same rows back the Table and Calendar views. Try it: drag a card to another column, switch views, or right-click the view to export CSV.',
17
+ readingList: 'Each shelf is a gallery group, and the same books also list in a Table view. Try it: rate a book, move one to another shelf, or add your own.',
18
+ roadmap: 'This template shows swimlanes: the Timeline bands and the Board lanes both split by Area. Try it: drag a bar to reschedule, collapse a lane, or move a card between stages.',
19
+ fieldMap: 'This template shows a map database: rows with a location render as region-coloured pins, and the address-only row waits under Unplaced. Try it: click a pin, geocode the unplaced row, or switch to the Table view.',
20
+ productHq: 'This template shows two linked databases: each initiative relates to tasks on the Tasks sub-page, and the Progress and Task count columns roll those tasks up. Try it: tick a task done on the sub-page and watch the rollup move, or open the Tasks timeline for the dependency arrows.',
21
+ dashboard: 'This dashboard reads a sample sales database: the KPI tiles total the rows, and the bar, pie and trend charts group them — all live. Try it: edit a deal on the “… data” sub-page and watch a tile move.',
22
+ };
23
+ /** The guidance callout as a block-doc block (ids are stable per template). */
24
+ const guidanceCallout = (id, text) => ({
25
+ id,
26
+ type: 'callout',
27
+ text: [{ t: text }],
28
+ props: { variant: 'info' },
29
+ });
30
+ /** A host-page snapshot: empty, or a single leading guidance callout rendered
31
+ * (block-doc) above the hosted database view. */
32
+ const guidanceSnapshot = (guide) => guide
33
+ ? { editorjs: { blocks: [] }, values: [], names: [], editor: 'blocks', blockdoc: { blocks: [guidanceCallout(guide.id, guide.text)] } }
34
+ : emptySnapshot([]);
6
35
  /** A local `YYYY-MM-DD` day string offset by `days` from today. */
7
36
  const day = (days) => {
8
37
  const d = new Date();
@@ -304,6 +333,151 @@ const SAVINGS_BLOCKS = [
304
333
  { id: 's-link', type: 'linkcard', props: { title: 'How compound interest works', description: 'A plain-English primer.', url: 'https://www.investor.gov/financial-tools-calculators/calculators/compound-interest-calculator' } },
305
334
  { id: 's-notes-4', type: 'notes', text: [{ t: 'One ask to close on: automate the monthly contribution so the plan happens without willpower.' }] },
306
335
  ];
336
+ // ── 📽️ Pitch deck ────────────────────────────────────────────────────────────
337
+ // A present-first showcase: five slides (title · agenda · a live revenue-mix
338
+ // donut · a quote · the ask), each with a speaker `notes` block, so the ⋯ →
339
+ // Present flow lands on a real deck. The donut slide is the interactive core:
340
+ // three sliders feed the chart and a recurring-revenue status light.
341
+ const PITCH_DECK_BLOCKS = [
342
+ // Slide 1 — title
343
+ { id: 'pd-h1', type: 'heading', text: [{ t: 'Brightloop' }], props: { level: 1 } },
344
+ { id: 'pd-tag', type: 'paragraph', text: [{ t: 'The pitch as a ' }, { t: 'live document', a: { b: true } }, { t: ' — the numbers on slide 3 recompute while you talk.' }] },
345
+ { id: 'pd-call', type: 'callout', text: [{ t: 'Open the ⋯ menu → Present to run this as a deck.' }], props: { variant: 'info' } },
346
+ { id: 'pd-notes-1', type: 'notes', text: [{ t: 'Thirty seconds, tops: who you are, what Brightloop is, why the room should care. Land the tagline, then advance — the deck itself makes the “live document” point on slide 3.' }] },
347
+ { id: 'pd-div-1', type: 'divider' },
348
+ // Slide 2 — agenda
349
+ { id: 'pd-h2', type: 'heading', text: [{ t: 'Agenda' }], props: { level: 2 } },
350
+ { id: 'pd-ag1', type: 'list', text: [{ t: 'The problem — decks go stale the moment they’re exported.' }], props: { kind: 'number' } },
351
+ { id: 'pd-ag2', type: 'list', text: [{ t: 'The product — one page that is both the model and the deck.' }], props: { kind: 'number' } },
352
+ { id: 'pd-ag3', type: 'list', text: [{ t: 'Revenue mix — live, draggable, no screenshots.' }], props: { kind: 'number' } },
353
+ { id: 'pd-ag4', type: 'list', text: [{ t: 'What early users say.' }], props: { kind: 'number' } },
354
+ { id: 'pd-ag5', type: 'list', text: [{ t: 'The ask.' }], props: { kind: 'number' } },
355
+ { id: 'pd-notes-2', type: 'notes', text: [{ t: 'Signpost, don’t read: five stops, four minutes. Flag that slide 3 is interactive so nobody mistakes the demo for a rehearsed video.' }] },
356
+ { id: 'pd-div-2', type: 'divider' },
357
+ // Slide 3 — the live donut
358
+ { id: 'pd-h3', type: 'heading', text: [{ t: 'Revenue mix — live' }], props: { level: 2 } },
359
+ { id: 'pd-recurring', type: 'code', text: [{ t: 'Math.round(subs / (subs + services + partners) * 100)' }], props: { live: true, name: 'recurring', language: 'js', collapsed: true } },
360
+ {
361
+ id: 'pd-cols',
362
+ type: 'columns',
363
+ children: [
364
+ {
365
+ id: 'pd-col-l',
366
+ type: 'column',
367
+ props: { span: 5 },
368
+ children: [
369
+ { id: 'pd-subs', type: 'slider', props: { name: 'subs', label: 'Subscriptions (£k/yr)', value: 62, min: 0, max: 200 } },
370
+ { id: 'pd-services', type: 'slider', props: { name: 'services', label: 'Services (£k/yr)', value: 26, min: 0, max: 200 } },
371
+ { id: 'pd-partners', type: 'slider', props: { name: 'partners', label: 'Partnerships (£k/yr)', value: 12, min: 0, max: 200 } },
372
+ ],
373
+ },
374
+ {
375
+ id: 'pd-col-r',
376
+ type: 'column',
377
+ props: { span: 7 },
378
+ children: [
379
+ { id: 'pd-donut', type: 'kitchart', props: { kind: 'donut', title: 'Revenue by stream (£k/yr)', labels: 'Subscriptions, Services, Partnerships', source: '[subs, services, partners]' } },
380
+ { id: 'pd-status', type: 'statuslight', props: { label: 'Recurring revenue ≥ 60%', source: 'recurring', okAt: 60, warnAt: 45 } },
381
+ ],
382
+ },
383
+ ],
384
+ },
385
+ { id: 'pd-notes-3', type: 'notes', text: [{ t: 'The money moment: drag Services up until the light drops to amber, then pull Subscriptions back up until recurring clears 60% and watch it recover. The maths is a one-line code block above the columns — open it if anyone asks.' }] },
386
+ { id: 'pd-div-3', type: 'divider' },
387
+ // Slide 4 — the quote
388
+ { id: 'pd-h4', type: 'heading', text: [{ t: 'What early users say' }], props: { level: 2 } },
389
+ { id: 'pd-quote', type: 'quote', text: [{ t: 'We pitched with the model itself — when the room asked “what if churn doubles?”, we dragged a slider instead of promising a follow-up.' }] },
390
+ { id: 'pd-notes-4', type: 'notes', text: [{ t: 'Pause after reading the quote — let it sit. If pressed for attribution, it’s a composite of three design-partner calls; offer intros rather than names.' }] },
391
+ { id: 'pd-div-4', type: 'divider' },
392
+ // Slide 5 — the ask
393
+ { id: 'pd-h5', type: 'heading', text: [{ t: 'The ask' }], props: { level: 2 } },
394
+ { id: 'pd-ask', type: 'paragraph', text: [{ t: 'We’re raising ' }, { t: '£1.2M', a: { b: true } }, { t: ' to take Brightloop from private beta to launch: two engineers, one designer, and twelve months of runway.' }] },
395
+ { id: 'pd-call2', type: 'callout', text: [{ t: 'Make it yours: duplicate this page, swap in your numbers, and pitch with live charts instead of screenshots.' }], props: { variant: 'success' } },
396
+ { id: 'pd-notes-5', type: 'notes', text: [{ t: 'Close with the concrete next step: a 30-minute working session in the live model this week. Stop talking after the ask.' }] },
397
+ ];
398
+ // ── 🚦 Team status dashboard ─────────────────────────────────────────────────
399
+ // The kit-breadth showcase, as a single-page dashboard (no slides): a **locked
400
+ // group** whose controls stay live for readers (toggle, dropdown, a kudos
401
+ // counter driven by an action button, a formula and a status light reading it),
402
+ // a **funnel** chart (a kind no other template uses), a **tabs** container, and
403
+ // a cross-page **sync** key — the same Pulse group pasted on another page stays
404
+ // in lockstep under `team-pulse`.
405
+ const TEAM_STATUS_BLOCKS = [
406
+ { id: 'td-tag', type: 'paragraph', text: [{ t: 'One page the whole team reads: a ' }, { t: 'locked', a: { b: true } }, { t: ' Pulse panel whose controls stay live, a delivery funnel, and the week’s rituals in tabs.' }] },
407
+ { id: 'td-call', type: 'callout', text: [{ t: 'The Pulse group is locked (the 🔒 in its header): its text and layout are frozen, but readers keep every control. It also syncs across pages under the sync key “team-pulse” — paste the same group on another page and the two stay in lockstep.' }], props: { variant: 'info' } },
408
+ // The locked, synced control panel.
409
+ { id: 'td-h2', type: 'heading', text: [{ t: 'Team pulse' }], props: { level: 2 } },
410
+ {
411
+ id: 'td-group',
412
+ type: 'group',
413
+ props: { name: 'Pulse', locked: true, sync: 'team-pulse' },
414
+ children: [
415
+ { id: 'td-g-note', type: 'paragraph', text: [{ t: 'This panel is locked — this very sentence can’t be edited in place — yet every control below still works.' }] },
416
+ { id: 'td-oncall', type: 'toggle', props: { name: 'onCall', label: 'On-call rotation active', value: true } },
417
+ { id: 'td-focus', type: 'dropdown', props: { name: 'focus', label: 'Focus this week', value: 'shipping', opts: [{ label: 'Shipping' }, { label: 'Stability' }, { label: 'Growth' }] } },
418
+ { id: 'td-kudos', type: 'number', props: { name: 'kudos', label: 'Kudos given', value: 2, min: 0, max: 99, step: 1 } },
419
+ { id: 'td-give', type: 'actionbutton', props: { btnlabel: 'Give kudos', action: 'increment', target: 'kudos', amount: 1 } },
420
+ // Inputs inside a named group publish namespaced — pulse.kudos.value —
421
+ // which is exactly what this formula (and the light below) read.
422
+ { id: 'td-score', type: 'formula', props: { name: 'morale', source: 'pulse.kudos.value * 10 + (pulse.onCall.value ? 5 : 0)' } },
423
+ { id: 'td-light', type: 'statuslight', props: { label: 'Momentum', source: 'pulse.kudos.value', okAt: 3, warnAt: 1 } },
424
+ ],
425
+ },
426
+ // The delivery funnel: a chart kind no other template exercises.
427
+ { id: 'td-h3', type: 'heading', text: [{ t: 'Delivery pipeline' }], props: { level: 2 } },
428
+ { id: 'td-pipe', type: 'code', text: [{ t: '({Ideas: 24, Building: 12, "In review": 7, Shipped: shipped})' }], props: { live: true, name: 'pipeline', language: 'js', collapsed: true } },
429
+ {
430
+ id: 'td-cols',
431
+ type: 'columns',
432
+ children: [
433
+ {
434
+ id: 'td-col-l',
435
+ type: 'column',
436
+ props: { span: 5 },
437
+ children: [
438
+ { id: 'td-shipped', type: 'number', props: { name: 'shipped', label: 'Shipped this quarter', value: 5, min: 0, max: 50, step: 1 } },
439
+ { id: 'td-tip', type: 'tooltipcard', props: { term: 'Funnel', tip: 'Each stage narrows: ideas → building → review → shipped. Step the shipped count and the funnel redraws.' } },
440
+ ],
441
+ },
442
+ {
443
+ id: 'td-col-r',
444
+ type: 'column',
445
+ props: { span: 7 },
446
+ children: [
447
+ { id: 'td-funnel', type: 'kitchart', props: { kind: 'funnel', title: 'Ideas → Shipped', source: 'pipeline' } },
448
+ ],
449
+ },
450
+ ],
451
+ },
452
+ // The week's rituals, in a tabs container.
453
+ { id: 'td-h4', type: 'heading', text: [{ t: 'Rituals' }], props: { level: 2 } },
454
+ {
455
+ id: 'td-tabs',
456
+ type: 'tabs',
457
+ props: { name: 'Rituals', active: 0 },
458
+ children: [
459
+ {
460
+ id: 'td-tab-week',
461
+ type: 'tab',
462
+ props: { label: 'This week' },
463
+ children: [
464
+ { id: 'td-t1', type: 'todo', text: [{ t: 'Monday kick-off — pick the focus in the Pulse panel' }], props: { checked: true } },
465
+ { id: 'td-t2', type: 'todo', text: [{ t: 'Thursday demo — show, don’t tell' }], props: { checked: false } },
466
+ ],
467
+ },
468
+ {
469
+ id: 'td-tab-next',
470
+ type: 'tab',
471
+ props: { label: 'Next week' },
472
+ children: [
473
+ { id: 'td-n1', type: 'list', text: [{ t: 'Rotate the on-call — flip the Pulse toggle' }], props: { kind: 'bullet' } },
474
+ { id: 'td-n2', type: 'list', text: [{ t: 'Reset the kudos counter at retro' }], props: { kind: 'bullet' } },
475
+ ],
476
+ },
477
+ ],
478
+ },
479
+ { id: 'td-call2', type: 'callout', text: [{ t: 'Make it yours: rename the Pulse group, change its sync key, and unlock it (the 🔓 in the group header) to re-arrange the controls.' }], props: { variant: 'success' } },
480
+ ];
307
481
  // ════════════════════════════════════════════════════════════════════════════
308
482
  // Databases (the task board, reading list, and the swimlane + map e2e fixtures)
309
483
  // ════════════════════════════════════════════════════════════════════════════
@@ -337,9 +511,11 @@ const TASK_BOARD_SCHEMA = {
337
511
  { id: 'p_effort', name: 'Effort', type: 'number', numberDisplay: 'bar', numberTarget: 8 },
338
512
  ],
339
513
  views: [
340
- // Board first → the page opens as a kanban grouped by status; a table backs it.
514
+ // Board first → the page opens as a kanban grouped by status; a table backs
515
+ // it, and a calendar lays the same tasks out on a month grid by due date.
341
516
  { id: 'v_board', name: 'Board', type: 'board', filters: [], sorts: [], groupByPropertyId: 'p_status' },
342
517
  { id: 'v_table', name: 'Table', type: 'table', filters: [], sorts: [] },
518
+ { id: 'v_calendar', name: 'Calendar', type: 'calendar', filters: [], sorts: [], datePropertyId: 'p_due' },
343
519
  ],
344
520
  };
345
521
  const TASK_BOARD_ROWS = [
@@ -353,6 +529,19 @@ const TASK_BOARD_ROWS = [
353
529
  ];
354
530
  // ── 📚 Reading list ──────────────────────────────────────────────────────────
355
531
  // A shelf-grouped gallery of books, with authors and star ratings; a table backs it.
532
+ //
533
+ // Covers (so the gallery renders real cards, not empty slots): tiny (<300 B) raster
534
+ // PNGs, inlined as `data:` URLs on the `files`-typed `p_cover` cells. Deliberately
535
+ // NOT routed through the content-addressed asset store — the `files` property and
536
+ // the gallery cover render a URL string straight into `<img src>` with no
537
+ // asset-resolution seam, so a store `assetId` wouldn't load there, and this seeds
538
+ // identically on both transports (web PGlite + desktop IPC) with no upload call.
539
+ // PNG, never SVG — honouring the store's image allowlist even though nothing is
540
+ // stored (an `<img src="data:image/png…">` executes no script). One per shelf so
541
+ // each gallery group leads with a cover.
542
+ const COVER_TEAL = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAFAAAAB4CAIAAADqjOKhAAAAsklEQVR42u3ZMQ2AMBRF0YpgR1INoAQPGKidrt2R0KQq2FlJmvJzkmvgjC8vbUd+tV9n4BIwMDAwMDAwMDAwMDAwMDAwMDAwMDAw8BLge/S/BAwMDAwMDAwMDAwMDAwMDAwMDAwMHADseQAGBgYGBgYGBgYGBgYGBgYGBgYGBgYGBgYGBgYGBgYGBgYGBgYG/gIurc4PGBgYGBgYGBgYOADYWgIGBgYGBgYGBgYGBgYO0wNsxNp6TrTOmAAAAABJRU5ErkJggg==';
543
+ const COVER_ORANGE = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAFAAAAB4CAIAAADqjOKhAAAAsklEQVR42u3ZMQ2AMBRF0SpBAlKqBw24wEo1dKkClu5d2VlJmvJzkmvgjC8vnXl7VY89cAkYGBgYGBgYGBgYGBgYGBgYGBgYGBgYeAnwuNtfAgYGBgYGBgYGBgYGBgYGBgYGBgYGDgD2PAADAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDfwH3cs0PGBgYGBgYGBgYOADYWgIGBgYGBgYGBgYGBgYO0wM4vRK/kEih/QAAAABJRU5ErkJggg==';
544
+ const COVER_BLUE = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAFAAAAB4CAIAAADqjOKhAAAAsklEQVR42u3ZMQ2AMBRF0WpBAxqqBgEIQkSXGunCVANVwM5K0pSfk1wDZ3x5acvnq/0ogUvAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwEuAWx9/CRgYGBgYGBgYGBgYGBgYGBgYGBgYOADY8wAMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwM/AV81Xt+wMDAwMDAwMDAwAHA1hIwMDAwMDAwMDAwMDBwmB4f7fGQa+V+2QAAAABJRU5ErkJggg==';
356
545
  const READING_SCHEMA = {
357
546
  properties: [
358
547
  {
@@ -375,11 +564,11 @@ const READING_SCHEMA = {
375
564
  ],
376
565
  };
377
566
  const READING_ROWS = [
378
- { name: 'The Design of Everyday Things', properties: { p_shelf: 'opt_reading', p_author: 'Don Norman', p_rating: 4 } },
567
+ { name: 'The Design of Everyday Things', properties: { p_shelf: 'opt_reading', p_author: 'Don Norman', p_rating: 4, p_cover: [COVER_ORANGE] } },
379
568
  { name: 'Project Hail Mary', properties: { p_shelf: 'opt_reading', p_author: 'Andy Weir', p_rating: 5 } },
380
- { name: 'Thinking, Fast and Slow', properties: { p_shelf: 'opt_toread', p_author: 'Daniel Kahneman' } },
569
+ { name: 'Thinking, Fast and Slow', properties: { p_shelf: 'opt_toread', p_author: 'Daniel Kahneman', p_cover: [COVER_TEAL] } },
381
570
  { name: 'Designing Data-Intensive Applications', properties: { p_shelf: 'opt_toread', p_author: 'Martin Kleppmann' } },
382
- { name: 'The Pragmatic Programmer', properties: { p_shelf: 'opt_done', p_author: 'Hunt & Thomas', p_rating: 5 } },
571
+ { name: 'The Pragmatic Programmer', properties: { p_shelf: 'opt_done', p_author: 'Hunt & Thomas', p_rating: 5, p_cover: [COVER_BLUE] } },
383
572
  { name: 'Deep Work', properties: { p_shelf: 'opt_done', p_author: 'Cal Newport', p_rating: 4 } },
384
573
  ];
385
574
  // ── Product roadmap ──────────────────────────────────────────────────────────
@@ -485,14 +674,250 @@ const FIELD_MAP_ROWS = [
485
674
  { name: 'Tokyo office', properties: { p_region: 'opt_apac', p_kind: 'opt_office', p_headcount: 85, p_address: 'Chiyoda, Tokyo', p_place: { lat: 35.6814, lng: 139.7670, label: 'Tokyo office' } } },
486
675
  { name: 'Sydney partner', properties: { p_region: 'opt_apac', p_kind: 'opt_partner', p_headcount: 15, p_address: 'Circular Quay, Sydney', p_place: { lat: -33.8610, lng: 151.2100, label: 'Sydney partner' } } },
487
676
  ];
677
+ // ── 🎯 Product HQ ────────────────────────────────────────────────────────────
678
+ // Two databases wired together: Initiatives (the page you land on) and Tasks
679
+ // (a sub-page). A 1:n relation links them BOTH ways (forward `Tasks` column +
680
+ // reverse `Initiative` column), two rollups on Initiatives fold the linked
681
+ // tasks (% done + task count), and a `dependency` property chains the tasks —
682
+ // surfaced as arrows on the Tasks page's timeline view.
683
+ /** Initiatives: status + the forward 1:n relation to Tasks + two rollups over it. */
684
+ const productHqInitiativesSchema = (tasksDbId) => ({
685
+ properties: [
686
+ {
687
+ id: 'p_status',
688
+ name: 'Status',
689
+ type: 'status',
690
+ options: [
691
+ { id: 'opt_next', label: 'Up next', color: 'gray', group: 'todo' },
692
+ { id: 'opt_track', label: 'On track', color: 'blue', group: 'in_progress' },
693
+ { id: 'opt_risk', label: 'At risk', color: 'red', group: 'in_progress' },
694
+ { id: 'opt_shipped', label: 'Shipped', color: 'green', group: 'complete' },
695
+ ],
696
+ },
697
+ // The forward side of the two-way link: one initiative → many tasks.
698
+ { id: 'p_tasks', name: 'Tasks', type: 'relation', relationDatabaseId: tasksDbId, relationCardinality: '1:n', reversePropertyId: 'p_initiative' },
699
+ // Rollups fold the linked tasks: how done, and how many.
700
+ { id: 'p_progress', name: 'Progress', type: 'rollup', rollup: { relationPropertyId: 'p_tasks', targetPropertyId: 'p_done', function: 'percent_checked' } },
701
+ { id: 'p_count', name: 'Task count', type: 'rollup', rollup: { relationPropertyId: 'p_tasks', targetPropertyId: TITLE_PROPERTY_ID, function: 'count' } },
702
+ ],
703
+ views: [
704
+ // The table leads (relation chips + rollups in one glance); a board backs it.
705
+ { id: 'v_table', name: 'Table', type: 'table', filters: [], sorts: [] },
706
+ { id: 'v_board', name: 'Board', type: 'board', filters: [], sorts: [], groupByPropertyId: 'p_status' },
707
+ ],
708
+ });
709
+ /** Tasks: the reverse (single) side of the relation, a Done checkbox the
710
+ * rollup folds, a date range, and the dependency chain the timeline draws. */
711
+ const productHqTasksSchema = (initiativesDbId) => ({
712
+ properties: [
713
+ { id: 'p_initiative', name: 'Initiative', type: 'relation', relationDatabaseId: initiativesDbId, relationSingle: true, reversePropertyId: 'p_tasks' },
714
+ { id: 'p_owner', name: 'Owner', type: 'text' },
715
+ { id: 'p_done', name: 'Done', type: 'checkbox' },
716
+ { id: 'p_when', name: 'When', type: 'date', dateRange: true },
717
+ { id: 'p_blockedby', name: 'Blocked by', type: 'dependency' },
718
+ ],
719
+ views: [
720
+ // Timeline first: bars from the `When` range, dependency arrows from
721
+ // `Blocked by` (predecessor end → dependent start).
722
+ { id: 'v_timeline', name: 'Timeline', type: 'timeline', filters: [], sorts: [], datePropertyId: 'p_when', dependencyPropertyId: 'p_blockedby' },
723
+ { id: 'v_table', name: 'Table', type: 'table', filters: [], sorts: [] },
724
+ ],
725
+ });
726
+ /** Build the two databases, then seed rows across both so the relation, the
727
+ * rollups, and the dependency arrows all render non-empty out of the box. */
728
+ const createProductHq = async (client, name, guidance = GUIDANCE.productHq) => {
729
+ // Pre-minted ids let each schema reference the OTHER database with no
730
+ // second-pass schema update (createDatabase honours a client-supplied id).
731
+ const initiativesDbId = globalThis.crypto.randomUUID();
732
+ const tasksDbId = globalThis.crypto.randomUUID();
733
+ const tasksName = `${name} Tasks`;
734
+ // The guidance callout leads the Initiatives host page; the Tasks sub-page
735
+ // stays bare (it's reached from the guided page).
736
+ const page = await client.savePage({ name, data: guidanceSnapshot({ id: 'hq-guide', text: guidance }) });
737
+ const tasksPage = await client.savePage({ name: tasksName, data: emptySnapshot([]), parentId: page.id });
738
+ await client.createDatabase({ id: initiativesDbId, pageId: page.id, name, schema: productHqInitiativesSchema(tasksDbId) });
739
+ await client.createDatabase({ id: tasksDbId, pageId: tasksPage.id, name: tasksName, schema: productHqTasksSchema(initiativesDbId) });
740
+ const seedRow = async (dbId, rowName, properties) => (await client.createRow(dbId, { name: rowName, properties })).id;
741
+ // Initiatives first (the tasks link back to them)…
742
+ const revamp = await seedRow(initiativesDbId, 'Onboarding revamp', { p_status: 'opt_track' });
743
+ const perf = await seedRow(initiativesDbId, 'Performance push', { p_status: 'opt_risk' });
744
+ const billing = await seedRow(initiativesDbId, 'Billing v2', { p_status: 'opt_shipped' });
745
+ // …then the tasks: linked 1:n, dated for the timeline, chained by `Blocked by`.
746
+ const t1 = await seedRow(tasksDbId, 'Ship onboarding checklist', { p_initiative: [revamp], p_owner: 'Ada', p_done: true, p_when: { start: day(-10), end: day(-3) } });
747
+ const t2 = await seedRow(tasksDbId, 'Guided first-run tour', { p_initiative: [revamp], p_owner: 'Lin', p_done: false, p_when: { start: day(-2), end: day(6) }, p_blockedby: [t1] });
748
+ const t3 = await seedRow(tasksDbId, 'Profile the hot paths', { p_initiative: [perf], p_owner: 'Sam', p_done: false, p_when: { start: day(1), end: day(5) } });
749
+ const t4 = await seedRow(tasksDbId, 'Cache the page list', { p_initiative: [perf], p_owner: 'Sam', p_done: false, p_when: { start: day(6), end: day(12) }, p_blockedby: [t3] });
750
+ const t5 = await seedRow(tasksDbId, 'Migrate legacy invoices', { p_initiative: [billing], p_owner: 'Lin', p_done: true, p_when: { start: day(-20), end: day(-12) } });
751
+ // Mirror the reverse side of the two-way link. Seeding writes both sides
752
+ // explicitly — the live mirror only runs on relation-cell edits. (updateRow
753
+ // replaces the whole properties bag, so re-send the status too.)
754
+ await client.updateRow(initiativesDbId, revamp, { properties: { p_status: 'opt_track', p_tasks: [t1, t2] } });
755
+ await client.updateRow(initiativesDbId, perf, { properties: { p_status: 'opt_risk', p_tasks: [t3, t4] } });
756
+ await client.updateRow(initiativesDbId, billing, { properties: { p_status: 'opt_shipped', p_tasks: [t5] } });
757
+ return page;
758
+ };
759
+ // ── 📊 Dashboard ─────────────────────────────────────────────────────────────
760
+ // A composite dashboard: a KPI row + DB-backed bar/pie/trend charts laid out in
761
+ // the 12-col column blocks, all reading LIVE from a sample "sales" database the
762
+ // template also seeds (a sub-page). The charts are ordinary in-doc `kitchart`
763
+ // blocks in DATABASE source mode (DASH-3) — their `dbId` is stamped at
764
+ // instantiation, when the sample database's id is minted. Unlike the database
765
+ // fixtures you land on a composed DOCUMENT, not a table/board.
766
+ /** The sample sales database the dashboard charts. One row = one deal, with a
767
+ * Region/Channel/Stage/Quarter to group by and Amount/Units to total. */
768
+ const SALES_SCHEMA = {
769
+ properties: [
770
+ {
771
+ id: 'p_region',
772
+ name: 'Region',
773
+ type: 'select',
774
+ options: [
775
+ { id: 'opt_north', label: 'North', color: 'blue' },
776
+ { id: 'opt_south', label: 'South', color: 'green' },
777
+ { id: 'opt_east', label: 'East', color: 'orange' },
778
+ { id: 'opt_west', label: 'West', color: 'purple' },
779
+ ],
780
+ },
781
+ {
782
+ id: 'p_channel',
783
+ name: 'Channel',
784
+ type: 'select',
785
+ options: [
786
+ { id: 'opt_online', label: 'Online', color: 'blue' },
787
+ { id: 'opt_retail', label: 'Retail', color: 'pink' },
788
+ { id: 'opt_partner', label: 'Partner', color: 'yellow' },
789
+ ],
790
+ },
791
+ {
792
+ id: 'p_stage',
793
+ name: 'Stage',
794
+ type: 'status',
795
+ options: [
796
+ { id: 'opt_pipeline', label: 'Pipeline', color: 'gray', group: 'todo' },
797
+ { id: 'opt_committed', label: 'Committed', color: 'blue', group: 'in_progress' },
798
+ { id: 'opt_won', label: 'Won', color: 'green', group: 'complete' },
799
+ ],
800
+ },
801
+ {
802
+ id: 'p_quarter',
803
+ name: 'Quarter',
804
+ type: 'select',
805
+ options: [
806
+ { id: 'opt_q1', label: 'Q1', color: 'gray' },
807
+ { id: 'opt_q2', label: 'Q2', color: 'gray' },
808
+ { id: 'opt_q3', label: 'Q3', color: 'gray' },
809
+ { id: 'opt_q4', label: 'Q4', color: 'gray' },
810
+ ],
811
+ },
812
+ { id: 'p_amount', name: 'Amount', type: 'number', numberDisplay: 'number' },
813
+ { id: 'p_units', name: 'Units', type: 'number' },
814
+ ],
815
+ views: [
816
+ // The data page opens on the table; a bar view backs it (and exercises the
817
+ // DB chart-view kit engine on the sample data itself).
818
+ { id: 'v_table', name: 'Table', type: 'table', filters: [], sorts: [] },
819
+ { id: 'v_bar', name: 'By region', type: 'bar', filters: [], sorts: [], groupByPropertyId: 'p_region', aggregate: { type: 'sum', propertyId: 'p_amount' } },
820
+ ],
821
+ };
822
+ const SALES_ROWS = [
823
+ { name: 'Northwind renewal', properties: { p_region: 'opt_north', p_channel: 'opt_online', p_stage: 'opt_won', p_quarter: 'opt_q1', p_amount: 24000, p_units: 120 } },
824
+ { name: 'Acme retail order', properties: { p_region: 'opt_north', p_channel: 'opt_retail', p_stage: 'opt_committed', p_quarter: 'opt_q2', p_amount: 18000, p_units: 90 } },
825
+ { name: 'Globex expansion', properties: { p_region: 'opt_south', p_channel: 'opt_online', p_stage: 'opt_won', p_quarter: 'opt_q1', p_amount: 31000, p_units: 150 } },
826
+ { name: 'Initech pilot', properties: { p_region: 'opt_south', p_channel: 'opt_partner', p_stage: 'opt_pipeline', p_quarter: 'opt_q3', p_amount: 12000, p_units: 60 } },
827
+ { name: 'Umbrella upsell', properties: { p_region: 'opt_east', p_channel: 'opt_online', p_stage: 'opt_committed', p_quarter: 'opt_q2', p_amount: 27000, p_units: 130 } },
828
+ { name: 'Soylent reorder', properties: { p_region: 'opt_east', p_channel: 'opt_retail', p_stage: 'opt_won', p_quarter: 'opt_q4', p_amount: 22000, p_units: 110 } },
829
+ { name: 'Hooli trial', properties: { p_region: 'opt_west', p_channel: 'opt_partner', p_stage: 'opt_pipeline', p_quarter: 'opt_q3', p_amount: 9000, p_units: 45 } },
830
+ { name: 'Stark contract', properties: { p_region: 'opt_west', p_channel: 'opt_online', p_stage: 'opt_won', p_quarter: 'opt_q4', p_amount: 35000, p_units: 170 } },
831
+ { name: 'Wayne partnership', properties: { p_region: 'opt_north', p_channel: 'opt_partner', p_stage: 'opt_committed', p_quarter: 'opt_q2', p_amount: 15000, p_units: 70 } },
832
+ { name: 'Cyberdyne restock', properties: { p_region: 'opt_south', p_channel: 'opt_retail', p_stage: 'opt_won', p_quarter: 'opt_q1', p_amount: 20000, p_units: 100 } },
833
+ { name: 'Tyrell evaluation', properties: { p_region: 'opt_east', p_channel: 'opt_online', p_stage: 'opt_pipeline', p_quarter: 'opt_q3', p_amount: 14000, p_units: 68 } },
834
+ { name: 'Massive Dynamic order', properties: { p_region: 'opt_west', p_channel: 'opt_retail', p_stage: 'opt_committed', p_quarter: 'opt_q4', p_amount: 17000, p_units: 82 } },
835
+ ];
836
+ /** A database-bound `kitchart` block (DASH-3 source mode). `dbId` is stamped at
837
+ * instantiation; `count` needs no numeric property, so `aggProp` is optional. A
838
+ * one-line `description` keeps the edit view clean (no ghost "Add a description…"). */
839
+ const dashboardChart = (dbId, id, kind, title, description, groupBy, aggType, aggProp) => ({
840
+ id,
841
+ type: 'kitchart',
842
+ props: { kind, title, description, sourceMode: 'database', dbId, dbGroupBy: groupBy, dbAggType: aggType, ...(aggProp ? { dbAggProp: aggProp } : {}) },
843
+ });
844
+ /** The dashboard document: a leading guidance callout, a KPI row across the top
845
+ * (three tiles in a 12-col columns block), then the bar+pie pair and a
846
+ * full-width quarterly trend — every chart bound to the seeded sales database. */
847
+ const dashboardBlocks = (dbId, guidance) => {
848
+ const chart = (id, kind, title, description, groupBy, aggType, aggProp) => dashboardChart(dbId, id, kind, title, description, groupBy, aggType, aggProp);
849
+ return [
850
+ guidanceCallout('db-guide', guidance),
851
+ { id: 'db-h1', type: 'heading', text: [{ t: 'This quarter at a glance' }], props: { level: 2 } },
852
+ // KPI row — three number tiles across the top (the DASH-5 `kpi` kind). Each
853
+ // folds a grouped DB series to one grand total; the tile's title names it.
854
+ {
855
+ id: 'db-kpis',
856
+ type: 'columns',
857
+ children: [
858
+ { id: 'db-kc1', type: 'column', props: { span: 4 }, children: [chart('db-k-rev', 'kpi', 'Total revenue (£)', 'Summed across every region', 'p_region', 'sum', 'p_amount')] },
859
+ { id: 'db-kc2', type: 'column', props: { span: 4 }, children: [chart('db-k-deals', 'kpi', 'Deals', 'Every row, across all stages', 'p_stage', 'count')] },
860
+ { id: 'db-kc3', type: 'column', props: { span: 4 }, children: [chart('db-k-units', 'kpi', 'Units sold', 'Summed across every channel', 'p_channel', 'sum', 'p_units')] },
861
+ ],
862
+ },
863
+ { id: 'db-h2', type: 'heading', text: [{ t: 'Breakdown' }], props: { level: 2 } },
864
+ // Bar + pie side by side in a 6/6 split.
865
+ {
866
+ id: 'db-cols',
867
+ type: 'columns',
868
+ children: [
869
+ { id: 'db-cl', type: 'column', props: { span: 6 }, children: [chart('db-bar', 'bar', 'Revenue by region (£)', 'Amount summed per sales region', 'p_region', 'sum', 'p_amount')] },
870
+ { id: 'db-cr', type: 'column', props: { span: 6 }, children: [chart('db-pie', 'pie', 'Deals by channel', 'Share of deals won online, retail and via partners', 'p_channel', 'count')] },
871
+ ],
872
+ },
873
+ // A full-width quarterly trend closes the dashboard.
874
+ chart('db-line', 'line', 'Revenue by quarter (£)', 'Amount summed by quarter, Q1 → Q4', 'p_quarter', 'sum', 'p_amount'),
875
+ // Trailing pointer: how to ADD a chart (distinct from the lead callout, which
876
+ // is about editing the data). The slash menu inserts a Chart, then its ⚙
877
+ // Source toggle switches it to Database — there is no "/chart → Database".
878
+ { id: 'db-note', type: 'callout', text: [{ t: 'Want another cut of the data? Type /chart to insert one, then switch its Source to Database in the chart’s ⚙ settings and pick this database.' }], props: { variant: 'info' } },
879
+ ];
880
+ };
881
+ /** Build the dashboard document, then seed the sample sales database it charts.
882
+ * The dashboard host page is saved FIRST (so its charts' `dbId` is the minted
883
+ * sample-db id), and the sample database lands on a sub-page. */
884
+ const createDashboard = async (client, name, guidance = GUIDANCE.dashboard) => {
885
+ const dataDbId = globalThis.crypto.randomUUID();
886
+ const dataName = `${name} data`;
887
+ const page = await client.savePage({
888
+ name,
889
+ data: { editorjs: { blocks: [] }, values: [], names: [], editor: 'blocks', blockdoc: { blocks: dashboardBlocks(dataDbId, guidance) } },
890
+ });
891
+ const dataPage = await client.savePage({ name: dataName, data: emptySnapshot([]), parentId: page.id });
892
+ await client.createDatabase({ id: dataDbId, pageId: dataPage.id, name: dataName, schema: SALES_SCHEMA });
893
+ for (const row of SALES_ROWS) {
894
+ let rowName = row.name;
895
+ for (let attempt = 2;; attempt += 1) {
896
+ try {
897
+ await client.createRow(dataDbId, { ...row, name: rowName });
898
+ break;
899
+ }
900
+ catch {
901
+ if (attempt > 5) {
902
+ await client.createRow(dataDbId, { ...row, name: null });
903
+ break;
904
+ }
905
+ rowName = `${row.name} ${attempt}`;
906
+ }
907
+ }
908
+ }
909
+ return page;
910
+ };
488
911
  // ── The gallery ──────────────────────────────────────────────────────────────
489
912
  /** Create a block-editor template page from a JSON block projection. */
490
913
  const createBlockDocPage = (blocks) => (client, name) => client.savePage({ name, data: { editorjs: { blocks: [] }, values: [], names: [], editor: 'blocks', blockdoc: { blocks } } });
491
914
  /** Create a database template: host page + database + sample rows. Names are
492
915
  * not unique, so a plain create always lands; the retry ladder below survives
493
- * transient failures (untitled as a last resort). */
494
- const createDatabasePage = (schema, rows) => async (client, name) => {
495
- const page = await client.savePage({ name, data: emptySnapshot([]) });
916
+ * transient failures (untitled as a last resort). `guide` (id + canonical
917
+ * English text) puts the standardized guidance callout on the host page —
918
+ * where a database template's doc surface renders, above the view. */
919
+ const createDatabasePage = (schema, rows, guide) => async (client, name, guidance) => {
920
+ const page = await client.savePage({ name, data: guidanceSnapshot(guide && { id: guide.id, text: guidance ?? guide.text }) });
496
921
  const db = await client.createDatabase({ pageId: page.id, name, schema });
497
922
  for (const row of rows) {
498
923
  let rowName = row.name;
@@ -512,14 +937,34 @@ const createDatabasePage = (schema, rows) => async (client, name) => {
512
937
  }
513
938
  return page;
514
939
  };
940
+ /** A fresh copy of the sample document under its own gallery name. It already
941
+ * opens with its own intro paragraph (`sample-intro`), so — unlike the database
942
+ * fixtures — it carries no standardized guidance callout of its own (that would
943
+ * double-guide, stacking a near-duplicate lead above the intro). */
944
+ const createCompoundGrowth = (client, name) => {
945
+ const input = buildSampleDocument();
946
+ return client.savePage({ ...input, name });
947
+ };
515
948
  export const PAGE_TEMPLATES = [
516
949
  { id: 'grocery-tracker', icon: '🛒', pageName: 'Grocery price tracker', tags: ['interactive', 'slides'], create: createBlockDocPage(GROCERY_BLOCKS) },
517
- { id: 'task-board', icon: '🗂️', pageName: 'Project task board', tags: ['database'], create: createDatabasePage(TASK_BOARD_SCHEMA, TASK_BOARD_ROWS) },
518
- { id: 'reading-list', icon: '📚', pageName: 'Reading list', tags: ['database'], create: createDatabasePage(READING_SCHEMA, READING_ROWS) },
950
+ { id: 'task-board', icon: '🗂️', pageName: 'Project task board', tags: ['database'], guidance: GUIDANCE.taskBoard, create: createDatabasePage(TASK_BOARD_SCHEMA, TASK_BOARD_ROWS, { id: 'tb-guide', text: GUIDANCE.taskBoard }) },
951
+ { id: 'reading-list', icon: '📚', pageName: 'Reading list', tags: ['database'], guidance: GUIDANCE.readingList, create: createDatabasePage(READING_SCHEMA, READING_ROWS, { id: 'rl-guide', text: GUIDANCE.readingList }) },
519
952
  { id: 'project-intake', icon: '📋', pageName: 'Project intake', tags: ['interactive', 'slides'], create: createBlockDocPage(PROJECT_INTAKE_BLOCKS) },
520
953
  { id: 'savings-planner', icon: '💰', pageName: 'Savings & investing', tags: ['interactive', 'slides'], create: createBlockDocPage(SAVINGS_BLOCKS) },
521
- { id: 'roadmap', icon: '🗺️', pageName: 'Product roadmap', tags: ['database'], create: createDatabasePage(ROADMAP_SCHEMA, ROADMAP_ROWS) },
522
- { id: 'field-map', icon: '📍', pageName: 'Field map', tags: ['database'], create: createDatabasePage(FIELD_MAP_SCHEMA, FIELD_MAP_ROWS) },
954
+ { id: 'roadmap', icon: '🗺️', pageName: 'Product roadmap', tags: ['database'], guidance: GUIDANCE.roadmap, create: createDatabasePage(ROADMAP_SCHEMA, ROADMAP_ROWS, { id: 'rm-guide', text: GUIDANCE.roadmap }) },
955
+ { id: 'field-map', icon: '📍', pageName: 'Field map', tags: ['database'], guidance: GUIDANCE.fieldMap, create: createDatabasePage(FIELD_MAP_SCHEMA, FIELD_MAP_ROWS, { id: 'fm-guide', text: GUIDANCE.fieldMap }) },
956
+ { id: 'pitch-deck', icon: '📽️', pageName: 'Pitch deck', tags: ['interactive', 'slides'], create: createBlockDocPage(PITCH_DECK_BLOCKS) },
957
+ { id: 'team-status', icon: '🚦', pageName: 'Team status dashboard', tags: ['interactive'], create: createBlockDocPage(TEAM_STATUS_BLOCKS) },
958
+ { id: 'product-hq', icon: '🎯', pageName: 'Product HQ', tags: ['database'], guidance: GUIDANCE.productHq, create: createProductHq },
959
+ // A composite dashboard: KPI tiles + DB-backed charts over a seeded sales
960
+ // database. Tagged `interactive` (you land on a document, not a table), so it
961
+ // groups under Interactive documents; the description names its data backing.
962
+ { id: 'dashboard', icon: '📊', pageName: 'Sales dashboard', tags: ['interactive'], guidance: GUIDANCE.dashboard, create: createDashboard },
963
+ // The classic sample document, folded into the gallery. Unlike the Home
964
+ // starter's open-or-create (which targets the canonical sample name and never
965
+ // overwrites), the gallery card always mints a FRESH copy under its own
966
+ // display name — the two entry points never race or shadow each other.
967
+ { id: 'compound-growth', icon: '📈', pageName: 'Compound growth', tags: ['interactive'], create: createCompoundGrowth },
523
968
  ];
524
969
  /** Courtesy numbering (names are not unique): a second instance becomes
525
970
  * `name 2`, `name 3`… so repeated instantiations stay tellable-apart. */
@@ -536,13 +981,15 @@ async function availableName(client, base) {
536
981
  /**
537
982
  * Instantiate a template: pick a distinct display name (courtesy numbering —
538
983
  * duplicates are allowed but unhelpful for ready-made pages) and build the page
539
- * through the client, retrying transient failures.
984
+ * through the client, retrying transient failures. `opts.guidance` localizes
985
+ * the leading guidance callout of templates that carry one (the gallery passes
986
+ * the user's locale text; absent, the canonical English default applies).
540
987
  */
541
- export async function instantiateTemplate(client, template) {
988
+ export async function instantiateTemplate(client, template, opts) {
542
989
  let name = await availableName(client, template.pageName);
543
990
  for (let attempt = 0;; attempt += 1) {
544
991
  try {
545
- return await template.create(client, name);
992
+ return await template.create(client, name, opts?.guidance);
546
993
  }
547
994
  catch (err) {
548
995
  // A concurrent create can win the name between the check and the save;