@book.dev/sdk 1.76.1 → 2.0.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.
@@ -5,32 +5,59 @@ import type { StoredPage } from './types';
5
5
  * through the normal data APIs (no server involvement, same as the sample
6
6
  * document). Two shapes:
7
7
  *
8
- * - **Block-doc artifacts** (the five showcases) ship a native block-editor
8
+ * - **Block-doc artifacts** (the showcases) ship a native block-editor
9
9
  * JSON projection in `blockdoc: {blocks}`. They lean on the whole editor:
10
10
  * reactive inputs feeding *collapsed* live-code, status lights, info/link/
11
11
  * tooltip cards, charts, progress bars, multi-column layouts, callouts, and
12
12
  * `divider`/`notes` blocks so every page doubles as a slide deck with
13
13
  * speaker notes (see blockeditor/present.ts).
14
- * - **Databases** (roadmap, field map) ship a schema, views, and sample rows;
15
- * they back the swimlane and map e2e fixtures.
14
+ * - **Databases** (task board, reading list, roadmap, field map) ship a
15
+ * schema, views, and sample rows; roadmap and field map back the swimlane
16
+ * and map e2e fixtures.
16
17
  *
17
18
  * Ids are stable so the gallery, its i18n keys, and the e2e suite can reference
18
19
  * a template without depending on display strings.
19
20
  */
21
+ /**
22
+ * What a template demonstrates, surfaced as gallery chips so the reader knows
23
+ * what a card is before inserting it:
24
+ * - `interactive` — reactive inputs feeding live code, charts, status lights.
25
+ * - `slides` — divider-cut slides with speaker notes (present-mode ready).
26
+ * - `database` — a schema with views and sample rows.
27
+ */
28
+ export type TemplateTag = 'interactive' | 'slides' | 'database';
20
29
  export interface PageTemplate {
21
30
  /** Stable identifier (i18n keys + tests hang off this). */
22
- id: 'grocery-tracker' | 'task-board' | 'reading-list' | 'project-intake' | 'savings-planner' | 'roadmap' | 'field-map';
31
+ id: 'grocery-tracker' | 'task-board' | 'reading-list' | 'project-intake' | 'savings-planner' | 'roadmap' | 'field-map' | 'pitch-deck' | 'compound-growth' | 'team-status' | 'product-hq';
23
32
  /** Emoji shown on the gallery card and applied to the created page. */
24
33
  icon: string;
25
34
  /** Canonical (English) page name; suffixed when it collides. */
26
35
  pageName: string;
27
- /** Creates the page (and database, if any) and returns the stored page. */
28
- create: (client: DataClient, name: string) => Promise<StoredPage>;
36
+ /** What the template shows off — rendered as chips on the gallery card. */
37
+ tags: TemplateTag[];
38
+ /**
39
+ * Canonical (English) text of the leading "how to use this" callout, for
40
+ * templates that don't already open with strong in-doc guidance (the five
41
+ * database fixtures). The gallery passes a localized override through
42
+ * {@link instantiateTemplate}; `create` bakes this English text in by
43
+ * default. Templates whose documents already guide (grocery, pitch deck,
44
+ * team status, and the sample-document copy — which opens with its own
45
+ * intro paragraph) leave it unset.
46
+ */
47
+ guidance?: string;
48
+ /** Creates the page (and database, if any) and returns the stored page.
49
+ * `guidance` overrides the template's canonical guidance-callout text
50
+ * (ignored by templates that define none). */
51
+ create: (client: DataClient, name: string, guidance?: string) => Promise<StoredPage>;
29
52
  }
30
53
  export declare const PAGE_TEMPLATES: PageTemplate[];
31
54
  /**
32
55
  * Instantiate a template: pick a distinct display name (courtesy numbering —
33
56
  * duplicates are allowed but unhelpful for ready-made pages) and build the page
34
- * through the client, retrying transient failures.
57
+ * through the client, retrying transient failures. `opts.guidance` localizes
58
+ * the leading guidance callout of templates that carry one (the gallery passes
59
+ * the user's locale text; absent, the canonical English default applies).
35
60
  */
36
- export declare function instantiateTemplate(client: DataClient, template: PageTemplate): Promise<StoredPage>;
61
+ export declare function instantiateTemplate(client: DataClient, template: PageTemplate, opts?: {
62
+ guidance?: string;
63
+ }): Promise<StoredPage>;
package/dist/templates.js CHANGED
@@ -1,8 +1,36 @@
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
+ };
22
+ /** The guidance callout as a block-doc block (ids are stable per template). */
23
+ const guidanceCallout = (id, text) => ({
24
+ id,
25
+ type: 'callout',
26
+ text: [{ t: text }],
27
+ props: { variant: 'info' },
28
+ });
29
+ /** A host-page snapshot: empty, or a single leading guidance callout rendered
30
+ * (block-doc) above the hosted database view. */
31
+ const guidanceSnapshot = (guide) => guide
32
+ ? { editorjs: { blocks: [] }, values: [], names: [], editor: 'blocks', blockdoc: { blocks: [guidanceCallout(guide.id, guide.text)] } }
33
+ : emptySnapshot([]);
6
34
  /** A local `YYYY-MM-DD` day string offset by `days` from today. */
7
35
  const day = (days) => {
8
36
  const d = new Date();
@@ -304,6 +332,151 @@ const SAVINGS_BLOCKS = [
304
332
  { 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
333
  { id: 's-notes-4', type: 'notes', text: [{ t: 'One ask to close on: automate the monthly contribution so the plan happens without willpower.' }] },
306
334
  ];
335
+ // ── 📽️ Pitch deck ────────────────────────────────────────────────────────────
336
+ // A present-first showcase: five slides (title · agenda · a live revenue-mix
337
+ // donut · a quote · the ask), each with a speaker `notes` block, so the ⋯ →
338
+ // Present flow lands on a real deck. The donut slide is the interactive core:
339
+ // three sliders feed the chart and a recurring-revenue status light.
340
+ const PITCH_DECK_BLOCKS = [
341
+ // Slide 1 — title
342
+ { id: 'pd-h1', type: 'heading', text: [{ t: 'Brightloop' }], props: { level: 1 } },
343
+ { 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.' }] },
344
+ { id: 'pd-call', type: 'callout', text: [{ t: 'Open the ⋯ menu → Present to run this as a deck.' }], props: { variant: 'info' } },
345
+ { 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.' }] },
346
+ { id: 'pd-div-1', type: 'divider' },
347
+ // Slide 2 — agenda
348
+ { id: 'pd-h2', type: 'heading', text: [{ t: 'Agenda' }], props: { level: 2 } },
349
+ { id: 'pd-ag1', type: 'list', text: [{ t: 'The problem — decks go stale the moment they’re exported.' }], props: { kind: 'number' } },
350
+ { id: 'pd-ag2', type: 'list', text: [{ t: 'The product — one page that is both the model and the deck.' }], props: { kind: 'number' } },
351
+ { id: 'pd-ag3', type: 'list', text: [{ t: 'Revenue mix — live, draggable, no screenshots.' }], props: { kind: 'number' } },
352
+ { id: 'pd-ag4', type: 'list', text: [{ t: 'What early users say.' }], props: { kind: 'number' } },
353
+ { id: 'pd-ag5', type: 'list', text: [{ t: 'The ask.' }], props: { kind: 'number' } },
354
+ { 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.' }] },
355
+ { id: 'pd-div-2', type: 'divider' },
356
+ // Slide 3 — the live donut
357
+ { id: 'pd-h3', type: 'heading', text: [{ t: 'Revenue mix — live' }], props: { level: 2 } },
358
+ { id: 'pd-recurring', type: 'code', text: [{ t: 'Math.round(subs / (subs + services + partners) * 100)' }], props: { live: true, name: 'recurring', language: 'js', collapsed: true } },
359
+ {
360
+ id: 'pd-cols',
361
+ type: 'columns',
362
+ children: [
363
+ {
364
+ id: 'pd-col-l',
365
+ type: 'column',
366
+ props: { span: 5 },
367
+ children: [
368
+ { id: 'pd-subs', type: 'slider', props: { name: 'subs', label: 'Subscriptions (£k/yr)', value: 62, min: 0, max: 200 } },
369
+ { id: 'pd-services', type: 'slider', props: { name: 'services', label: 'Services (£k/yr)', value: 26, min: 0, max: 200 } },
370
+ { id: 'pd-partners', type: 'slider', props: { name: 'partners', label: 'Partnerships (£k/yr)', value: 12, min: 0, max: 200 } },
371
+ ],
372
+ },
373
+ {
374
+ id: 'pd-col-r',
375
+ type: 'column',
376
+ props: { span: 7 },
377
+ children: [
378
+ { id: 'pd-donut', type: 'kitchart', props: { kind: 'donut', title: 'Revenue by stream (£k/yr)', labels: 'Subscriptions, Services, Partnerships', source: '[subs, services, partners]' } },
379
+ { id: 'pd-status', type: 'statuslight', props: { label: 'Recurring revenue ≥ 60%', source: 'recurring', okAt: 60, warnAt: 45 } },
380
+ ],
381
+ },
382
+ ],
383
+ },
384
+ { 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.' }] },
385
+ { id: 'pd-div-3', type: 'divider' },
386
+ // Slide 4 — the quote
387
+ { id: 'pd-h4', type: 'heading', text: [{ t: 'What early users say' }], props: { level: 2 } },
388
+ { 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.' }] },
389
+ { 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.' }] },
390
+ { id: 'pd-div-4', type: 'divider' },
391
+ // Slide 5 — the ask
392
+ { id: 'pd-h5', type: 'heading', text: [{ t: 'The ask' }], props: { level: 2 } },
393
+ { 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.' }] },
394
+ { 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' } },
395
+ { 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.' }] },
396
+ ];
397
+ // ── 🚦 Team status dashboard ─────────────────────────────────────────────────
398
+ // The kit-breadth showcase, as a single-page dashboard (no slides): a **locked
399
+ // group** whose controls stay live for readers (toggle, dropdown, a kudos
400
+ // counter driven by an action button, a formula and a status light reading it),
401
+ // a **funnel** chart (a kind no other template uses), a **tabs** container, and
402
+ // a cross-page **sync** key — the same Pulse group pasted on another page stays
403
+ // in lockstep under `team-pulse`.
404
+ const TEAM_STATUS_BLOCKS = [
405
+ { 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.' }] },
406
+ { 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' } },
407
+ // The locked, synced control panel.
408
+ { id: 'td-h2', type: 'heading', text: [{ t: 'Team pulse' }], props: { level: 2 } },
409
+ {
410
+ id: 'td-group',
411
+ type: 'group',
412
+ props: { name: 'Pulse', locked: true, sync: 'team-pulse' },
413
+ children: [
414
+ { 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.' }] },
415
+ { id: 'td-oncall', type: 'toggle', props: { name: 'onCall', label: 'On-call rotation active', value: true } },
416
+ { id: 'td-focus', type: 'dropdown', props: { name: 'focus', label: 'Focus this week', value: 'shipping', opts: [{ label: 'Shipping' }, { label: 'Stability' }, { label: 'Growth' }] } },
417
+ { id: 'td-kudos', type: 'number', props: { name: 'kudos', label: 'Kudos given', value: 2, min: 0, max: 99, step: 1 } },
418
+ { id: 'td-give', type: 'actionbutton', props: { btnlabel: 'Give kudos', action: 'increment', target: 'kudos', amount: 1 } },
419
+ // Inputs inside a named group publish namespaced — pulse.kudos.value —
420
+ // which is exactly what this formula (and the light below) read.
421
+ { id: 'td-score', type: 'formula', props: { name: 'morale', source: 'pulse.kudos.value * 10 + (pulse.onCall.value ? 5 : 0)' } },
422
+ { id: 'td-light', type: 'statuslight', props: { label: 'Momentum', source: 'pulse.kudos.value', okAt: 3, warnAt: 1 } },
423
+ ],
424
+ },
425
+ // The delivery funnel: a chart kind no other template exercises.
426
+ { id: 'td-h3', type: 'heading', text: [{ t: 'Delivery pipeline' }], props: { level: 2 } },
427
+ { 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 } },
428
+ {
429
+ id: 'td-cols',
430
+ type: 'columns',
431
+ children: [
432
+ {
433
+ id: 'td-col-l',
434
+ type: 'column',
435
+ props: { span: 5 },
436
+ children: [
437
+ { id: 'td-shipped', type: 'number', props: { name: 'shipped', label: 'Shipped this quarter', value: 5, min: 0, max: 50, step: 1 } },
438
+ { id: 'td-tip', type: 'tooltipcard', props: { term: 'Funnel', tip: 'Each stage narrows: ideas → building → review → shipped. Step the shipped count and the funnel redraws.' } },
439
+ ],
440
+ },
441
+ {
442
+ id: 'td-col-r',
443
+ type: 'column',
444
+ props: { span: 7 },
445
+ children: [
446
+ { id: 'td-funnel', type: 'kitchart', props: { kind: 'funnel', title: 'Ideas → Shipped', source: 'pipeline' } },
447
+ ],
448
+ },
449
+ ],
450
+ },
451
+ // The week's rituals, in a tabs container.
452
+ { id: 'td-h4', type: 'heading', text: [{ t: 'Rituals' }], props: { level: 2 } },
453
+ {
454
+ id: 'td-tabs',
455
+ type: 'tabs',
456
+ props: { name: 'Rituals', active: 0 },
457
+ children: [
458
+ {
459
+ id: 'td-tab-week',
460
+ type: 'tab',
461
+ props: { label: 'This week' },
462
+ children: [
463
+ { id: 'td-t1', type: 'todo', text: [{ t: 'Monday kick-off — pick the focus in the Pulse panel' }], props: { checked: true } },
464
+ { id: 'td-t2', type: 'todo', text: [{ t: 'Thursday demo — show, don’t tell' }], props: { checked: false } },
465
+ ],
466
+ },
467
+ {
468
+ id: 'td-tab-next',
469
+ type: 'tab',
470
+ props: { label: 'Next week' },
471
+ children: [
472
+ { id: 'td-n1', type: 'list', text: [{ t: 'Rotate the on-call — flip the Pulse toggle' }], props: { kind: 'bullet' } },
473
+ { id: 'td-n2', type: 'list', text: [{ t: 'Reset the kudos counter at retro' }], props: { kind: 'bullet' } },
474
+ ],
475
+ },
476
+ ],
477
+ },
478
+ { 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' } },
479
+ ];
307
480
  // ════════════════════════════════════════════════════════════════════════════
308
481
  // Databases (the task board, reading list, and the swimlane + map e2e fixtures)
309
482
  // ════════════════════════════════════════════════════════════════════════════
@@ -337,9 +510,11 @@ const TASK_BOARD_SCHEMA = {
337
510
  { id: 'p_effort', name: 'Effort', type: 'number', numberDisplay: 'bar', numberTarget: 8 },
338
511
  ],
339
512
  views: [
340
- // Board first → the page opens as a kanban grouped by status; a table backs it.
513
+ // Board first → the page opens as a kanban grouped by status; a table backs
514
+ // it, and a calendar lays the same tasks out on a month grid by due date.
341
515
  { id: 'v_board', name: 'Board', type: 'board', filters: [], sorts: [], groupByPropertyId: 'p_status' },
342
516
  { id: 'v_table', name: 'Table', type: 'table', filters: [], sorts: [] },
517
+ { id: 'v_calendar', name: 'Calendar', type: 'calendar', filters: [], sorts: [], datePropertyId: 'p_due' },
343
518
  ],
344
519
  };
345
520
  const TASK_BOARD_ROWS = [
@@ -353,6 +528,19 @@ const TASK_BOARD_ROWS = [
353
528
  ];
354
529
  // ── 📚 Reading list ──────────────────────────────────────────────────────────
355
530
  // A shelf-grouped gallery of books, with authors and star ratings; a table backs it.
531
+ //
532
+ // Covers (so the gallery renders real cards, not empty slots): tiny (<300 B) raster
533
+ // PNGs, inlined as `data:` URLs on the `files`-typed `p_cover` cells. Deliberately
534
+ // NOT routed through the content-addressed asset store — the `files` property and
535
+ // the gallery cover render a URL string straight into `<img src>` with no
536
+ // asset-resolution seam, so a store `assetId` wouldn't load there, and this seeds
537
+ // identically on both transports (web PGlite + desktop IPC) with no upload call.
538
+ // PNG, never SVG — honouring the store's image allowlist even though nothing is
539
+ // stored (an `<img src="data:image/png…">` executes no script). One per shelf so
540
+ // each gallery group leads with a cover.
541
+ const COVER_TEAL = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAFAAAAB4CAIAAADqjOKhAAAAsklEQVR42u3ZMQ2AMBRF0YpgR1INoAQPGKidrt2R0KQq2FlJmvJzkmvgjC8vbUd+tV9n4BIwMDAwMDAwMDAwMDAwMDAwMDAwMDAw8BLge/S/BAwMDAwMDAwMDAwMDAwMDAwMDAwMHADseQAGBgYGBgYGBgYGBgYGBgYGBgYGBgYGBgYGBgYGBgYGBgYGBgYG/gIurc4PGBgYGBgYGBgYOADYWgIGBgYGBgYGBgYGBgYO0wNsxNp6TrTOmAAAAABJRU5ErkJggg==';
542
+ const COVER_ORANGE = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAFAAAAB4CAIAAADqjOKhAAAAsklEQVR42u3ZMQ2AMBRF0SpBAlKqBw24wEo1dKkClu5d2VlJmvJzkmvgjC8vnXl7VY89cAkYGBgYGBgYGBgYGBgYGBgYGBgYGBgYeAnwuNtfAgYGBgYGBgYGBgYGBgYGBgYGBgYGDgD2PAADAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDfwH3cs0PGBgYGBgYGBgYOADYWgIGBgYGBgYGBgYGBgYO0wM4vRK/kEih/QAAAABJRU5ErkJggg==';
543
+ const COVER_BLUE = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAFAAAAB4CAIAAADqjOKhAAAAsklEQVR42u3ZMQ2AMBRF0WpBAxqqBgEIQkSXGunCVANVwM5K0pSfk1wDZ3x5acvnq/0ogUvAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwEuAWx9/CRgYGBgYGBgYGBgYGBgYGBgYGBgYOADY8wAMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwM/AV81Xt+wMDAwMDAwMDAwAHA1hIwMDAwMDAwMDAwMDBwmB4f7fGQa+V+2QAAAABJRU5ErkJggg==';
356
544
  const READING_SCHEMA = {
357
545
  properties: [
358
546
  {
@@ -375,11 +563,11 @@ const READING_SCHEMA = {
375
563
  ],
376
564
  };
377
565
  const READING_ROWS = [
378
- { name: 'The Design of Everyday Things', properties: { p_shelf: 'opt_reading', p_author: 'Don Norman', p_rating: 4 } },
566
+ { name: 'The Design of Everyday Things', properties: { p_shelf: 'opt_reading', p_author: 'Don Norman', p_rating: 4, p_cover: [COVER_ORANGE] } },
379
567
  { 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' } },
568
+ { name: 'Thinking, Fast and Slow', properties: { p_shelf: 'opt_toread', p_author: 'Daniel Kahneman', p_cover: [COVER_TEAL] } },
381
569
  { 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 } },
570
+ { name: 'The Pragmatic Programmer', properties: { p_shelf: 'opt_done', p_author: 'Hunt & Thomas', p_rating: 5, p_cover: [COVER_BLUE] } },
383
571
  { name: 'Deep Work', properties: { p_shelf: 'opt_done', p_author: 'Cal Newport', p_rating: 4 } },
384
572
  ];
385
573
  // ── Product roadmap ──────────────────────────────────────────────────────────
@@ -485,14 +673,98 @@ const FIELD_MAP_ROWS = [
485
673
  { 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
674
  { 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
675
  ];
676
+ // ── 🎯 Product HQ ────────────────────────────────────────────────────────────
677
+ // Two databases wired together: Initiatives (the page you land on) and Tasks
678
+ // (a sub-page). A 1:n relation links them BOTH ways (forward `Tasks` column +
679
+ // reverse `Initiative` column), two rollups on Initiatives fold the linked
680
+ // tasks (% done + task count), and a `dependency` property chains the tasks —
681
+ // surfaced as arrows on the Tasks page's timeline view.
682
+ /** Initiatives: status + the forward 1:n relation to Tasks + two rollups over it. */
683
+ const productHqInitiativesSchema = (tasksDbId) => ({
684
+ properties: [
685
+ {
686
+ id: 'p_status',
687
+ name: 'Status',
688
+ type: 'status',
689
+ options: [
690
+ { id: 'opt_next', label: 'Up next', color: 'gray', group: 'todo' },
691
+ { id: 'opt_track', label: 'On track', color: 'blue', group: 'in_progress' },
692
+ { id: 'opt_risk', label: 'At risk', color: 'red', group: 'in_progress' },
693
+ { id: 'opt_shipped', label: 'Shipped', color: 'green', group: 'complete' },
694
+ ],
695
+ },
696
+ // The forward side of the two-way link: one initiative → many tasks.
697
+ { id: 'p_tasks', name: 'Tasks', type: 'relation', relationDatabaseId: tasksDbId, relationCardinality: '1:n', reversePropertyId: 'p_initiative' },
698
+ // Rollups fold the linked tasks: how done, and how many.
699
+ { id: 'p_progress', name: 'Progress', type: 'rollup', rollup: { relationPropertyId: 'p_tasks', targetPropertyId: 'p_done', function: 'percent_checked' } },
700
+ { id: 'p_count', name: 'Task count', type: 'rollup', rollup: { relationPropertyId: 'p_tasks', targetPropertyId: TITLE_PROPERTY_ID, function: 'count' } },
701
+ ],
702
+ views: [
703
+ // The table leads (relation chips + rollups in one glance); a board backs it.
704
+ { id: 'v_table', name: 'Table', type: 'table', filters: [], sorts: [] },
705
+ { id: 'v_board', name: 'Board', type: 'board', filters: [], sorts: [], groupByPropertyId: 'p_status' },
706
+ ],
707
+ });
708
+ /** Tasks: the reverse (single) side of the relation, a Done checkbox the
709
+ * rollup folds, a date range, and the dependency chain the timeline draws. */
710
+ const productHqTasksSchema = (initiativesDbId) => ({
711
+ properties: [
712
+ { id: 'p_initiative', name: 'Initiative', type: 'relation', relationDatabaseId: initiativesDbId, relationSingle: true, reversePropertyId: 'p_tasks' },
713
+ { id: 'p_owner', name: 'Owner', type: 'text' },
714
+ { id: 'p_done', name: 'Done', type: 'checkbox' },
715
+ { id: 'p_when', name: 'When', type: 'date', dateRange: true },
716
+ { id: 'p_blockedby', name: 'Blocked by', type: 'dependency' },
717
+ ],
718
+ views: [
719
+ // Timeline first: bars from the `When` range, dependency arrows from
720
+ // `Blocked by` (predecessor end → dependent start).
721
+ { id: 'v_timeline', name: 'Timeline', type: 'timeline', filters: [], sorts: [], datePropertyId: 'p_when', dependencyPropertyId: 'p_blockedby' },
722
+ { id: 'v_table', name: 'Table', type: 'table', filters: [], sorts: [] },
723
+ ],
724
+ });
725
+ /** Build the two databases, then seed rows across both so the relation, the
726
+ * rollups, and the dependency arrows all render non-empty out of the box. */
727
+ const createProductHq = async (client, name, guidance = GUIDANCE.productHq) => {
728
+ // Pre-minted ids let each schema reference the OTHER database with no
729
+ // second-pass schema update (createDatabase honours a client-supplied id).
730
+ const initiativesDbId = globalThis.crypto.randomUUID();
731
+ const tasksDbId = globalThis.crypto.randomUUID();
732
+ const tasksName = `${name} Tasks`;
733
+ // The guidance callout leads the Initiatives host page; the Tasks sub-page
734
+ // stays bare (it's reached from the guided page).
735
+ const page = await client.savePage({ name, data: guidanceSnapshot({ id: 'hq-guide', text: guidance }) });
736
+ const tasksPage = await client.savePage({ name: tasksName, data: emptySnapshot([]), parentId: page.id });
737
+ await client.createDatabase({ id: initiativesDbId, pageId: page.id, name, schema: productHqInitiativesSchema(tasksDbId) });
738
+ await client.createDatabase({ id: tasksDbId, pageId: tasksPage.id, name: tasksName, schema: productHqTasksSchema(initiativesDbId) });
739
+ const seedRow = async (dbId, rowName, properties) => (await client.createRow(dbId, { name: rowName, properties })).id;
740
+ // Initiatives first (the tasks link back to them)…
741
+ const revamp = await seedRow(initiativesDbId, 'Onboarding revamp', { p_status: 'opt_track' });
742
+ const perf = await seedRow(initiativesDbId, 'Performance push', { p_status: 'opt_risk' });
743
+ const billing = await seedRow(initiativesDbId, 'Billing v2', { p_status: 'opt_shipped' });
744
+ // …then the tasks: linked 1:n, dated for the timeline, chained by `Blocked by`.
745
+ 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) } });
746
+ 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] });
747
+ 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) } });
748
+ 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] });
749
+ 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) } });
750
+ // Mirror the reverse side of the two-way link. Seeding writes both sides
751
+ // explicitly — the live mirror only runs on relation-cell edits. (updateRow
752
+ // replaces the whole properties bag, so re-send the status too.)
753
+ await client.updateRow(initiativesDbId, revamp, { properties: { p_status: 'opt_track', p_tasks: [t1, t2] } });
754
+ await client.updateRow(initiativesDbId, perf, { properties: { p_status: 'opt_risk', p_tasks: [t3, t4] } });
755
+ await client.updateRow(initiativesDbId, billing, { properties: { p_status: 'opt_shipped', p_tasks: [t5] } });
756
+ return page;
757
+ };
488
758
  // ── The gallery ──────────────────────────────────────────────────────────────
489
759
  /** Create a block-editor template page from a JSON block projection. */
490
760
  const createBlockDocPage = (blocks) => (client, name) => client.savePage({ name, data: { editorjs: { blocks: [] }, values: [], names: [], editor: 'blocks', blockdoc: { blocks } } });
491
761
  /** Create a database template: host page + database + sample rows. Names are
492
762
  * 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([]) });
763
+ * transient failures (untitled as a last resort). `guide` (id + canonical
764
+ * English text) puts the standardized guidance callout on the host page —
765
+ * where a database template's doc surface renders, above the view. */
766
+ const createDatabasePage = (schema, rows, guide) => async (client, name, guidance) => {
767
+ const page = await client.savePage({ name, data: guidanceSnapshot(guide && { id: guide.id, text: guidance ?? guide.text }) });
496
768
  const db = await client.createDatabase({ pageId: page.id, name, schema });
497
769
  for (const row of rows) {
498
770
  let rowName = row.name;
@@ -512,14 +784,30 @@ const createDatabasePage = (schema, rows) => async (client, name) => {
512
784
  }
513
785
  return page;
514
786
  };
787
+ /** A fresh copy of the sample document under its own gallery name. It already
788
+ * opens with its own intro paragraph (`sample-intro`), so — unlike the database
789
+ * fixtures — it carries no standardized guidance callout of its own (that would
790
+ * double-guide, stacking a near-duplicate lead above the intro). */
791
+ const createCompoundGrowth = (client, name) => {
792
+ const input = buildSampleDocument();
793
+ return client.savePage({ ...input, name });
794
+ };
515
795
  export const PAGE_TEMPLATES = [
516
- { id: 'grocery-tracker', icon: '🛒', pageName: 'Grocery price tracker', create: createBlockDocPage(GROCERY_BLOCKS) },
517
- { id: 'task-board', icon: '🗂️', pageName: 'Project task board', create: createDatabasePage(TASK_BOARD_SCHEMA, TASK_BOARD_ROWS) },
518
- { id: 'reading-list', icon: '📚', pageName: 'Reading list', create: createDatabasePage(READING_SCHEMA, READING_ROWS) },
519
- { id: 'project-intake', icon: '📋', pageName: 'Project intake', create: createBlockDocPage(PROJECT_INTAKE_BLOCKS) },
520
- { id: 'savings-planner', icon: '💰', pageName: 'Savings & investing', create: createBlockDocPage(SAVINGS_BLOCKS) },
521
- { id: 'roadmap', icon: '🗺️', pageName: 'Product roadmap', create: createDatabasePage(ROADMAP_SCHEMA, ROADMAP_ROWS) },
522
- { id: 'field-map', icon: '📍', pageName: 'Field map', create: createDatabasePage(FIELD_MAP_SCHEMA, FIELD_MAP_ROWS) },
796
+ { id: 'grocery-tracker', icon: '🛒', pageName: 'Grocery price tracker', tags: ['interactive', 'slides'], create: createBlockDocPage(GROCERY_BLOCKS) },
797
+ { 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 }) },
798
+ { id: 'reading-list', icon: '📚', pageName: 'Reading list', tags: ['database'], guidance: GUIDANCE.readingList, create: createDatabasePage(READING_SCHEMA, READING_ROWS, { id: 'rl-guide', text: GUIDANCE.readingList }) },
799
+ { id: 'project-intake', icon: '📋', pageName: 'Project intake', tags: ['interactive', 'slides'], create: createBlockDocPage(PROJECT_INTAKE_BLOCKS) },
800
+ { id: 'savings-planner', icon: '💰', pageName: 'Savings & investing', tags: ['interactive', 'slides'], create: createBlockDocPage(SAVINGS_BLOCKS) },
801
+ { id: 'roadmap', icon: '🗺️', pageName: 'Product roadmap', tags: ['database'], guidance: GUIDANCE.roadmap, create: createDatabasePage(ROADMAP_SCHEMA, ROADMAP_ROWS, { id: 'rm-guide', text: GUIDANCE.roadmap }) },
802
+ { 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 }) },
803
+ { id: 'pitch-deck', icon: '📽️', pageName: 'Pitch deck', tags: ['interactive', 'slides'], create: createBlockDocPage(PITCH_DECK_BLOCKS) },
804
+ { id: 'team-status', icon: '🚦', pageName: 'Team status dashboard', tags: ['interactive'], create: createBlockDocPage(TEAM_STATUS_BLOCKS) },
805
+ { id: 'product-hq', icon: '🎯', pageName: 'Product HQ', tags: ['database'], guidance: GUIDANCE.productHq, create: createProductHq },
806
+ // The classic sample document, folded into the gallery. Unlike the Home
807
+ // starter's open-or-create (which targets the canonical sample name and never
808
+ // overwrites), the gallery card always mints a FRESH copy under its own
809
+ // display name — the two entry points never race or shadow each other.
810
+ { id: 'compound-growth', icon: '📈', pageName: 'Compound growth', tags: ['interactive'], create: createCompoundGrowth },
523
811
  ];
524
812
  /** Courtesy numbering (names are not unique): a second instance becomes
525
813
  * `name 2`, `name 3`… so repeated instantiations stay tellable-apart. */
@@ -536,13 +824,15 @@ async function availableName(client, base) {
536
824
  /**
537
825
  * Instantiate a template: pick a distinct display name (courtesy numbering —
538
826
  * duplicates are allowed but unhelpful for ready-made pages) and build the page
539
- * through the client, retrying transient failures.
827
+ * through the client, retrying transient failures. `opts.guidance` localizes
828
+ * the leading guidance callout of templates that carry one (the gallery passes
829
+ * the user's locale text; absent, the canonical English default applies).
540
830
  */
541
- export async function instantiateTemplate(client, template) {
831
+ export async function instantiateTemplate(client, template, opts) {
542
832
  let name = await availableName(client, template.pageName);
543
833
  for (let attempt = 0;; attempt += 1) {
544
834
  try {
545
- return await template.create(client, name);
835
+ return await template.create(client, name, opts?.guidance);
546
836
  }
547
837
  catch (err) {
548
838
  // A concurrent create can win the name between the check and the save;