Skip to content

A page can be saved as a template, and the daily note can start from one - #427

Open
nyblnet wants to merge 1 commit into
mainfrom
spaces-templates
Open

A page can be saved as a template, and the daily note can start from one#427
nyblnet wants to merge 1 commit into
mainfrom
spaces-templates

Conversation

@nyblnet

@nyblnet nyblnet commented Sep 9, 2026

Copy link
Copy Markdown
Owner

Page templates for bento/spaces: save a page as a template, start a new page from one, and a template the daily note starts from — the workflow that makes this worth more than it looks, and the one thing a journal entry here could not do.

The format decision, and why

There was no page-template feature. doc.template in model.ts is the whole-file share export (a file that re-mints its docId on open) and is untouched; a comment now says so beside it, because the name collision is exactly the trap the next reader falls into.

Two honest places to put a template. A flagged page, hidden from the sidebar — no new format shape, and it is editable with the page editor for free. Or doc.templates, a collection nothing that walks pages can see. I took the second, and the reason is a count: doc.pages is enumerated in roughly forty places — search, the graph, backlinks and the tree (buildIndex), issuesOf and every board/table/gallery view in fields.ts, the Markdown export in portable.ts, the About counts, the agent API's pages/stats/outline/validate, the archive list, print, and the file-manager preview. A flagged page needs a gate at every one, and at every surface added afterwards by someone who has never heard of templates. This zone has shipped that class twice (isRemote applied before the asset indirection; the source-grep assertions in #392).

Measured in the built shell, not assumed — a word placed only in a template:

surface result
bento.search('qqzzmarker') 0 hits
Export as Markdown (29,414 B) word absent
bento.stats().pages 14 = doc.pages.length
sidebar tree 14 rows, no template row
Graph "15 pages · 14 links" (15 = the non-archived pages)
bento.validate() ok: true, 0 issues
backlinks on the link target the two pages made from the template; not the template

What it costs, stated rather than hidden: a template is not a page, so it is not searched, graphed, back-linked, printed or exported — and it travels with the file, not with a page you graft into another space. A page-flag design would have grafted, and would have leaked into all thirteen surfaces above. Written up in docs/DECISIONS.md.

doc.journalTemplate names the template new daily notes start from. Absent ⇒ a blank entry, so every file written before this behaves as it always did; turning the setting off deletes the key rather than storing a default.

Tokens

Expanded once, at instantiation — the model stores the result. bento/slides resolves fields at render time because a footer must re-number when slides move; a template has no such need, and a live field would mean a page whose text changed under its author overnight. So the new page is an ordinary page an older build reads exactly as this one does.

{{date}} {{date:iso}} {{date:short}} {{date+1:iso}} {{date-14:iso}} {{time}} {{title}}. Anything else stays literal, so a {{mustache}} in somebody's prose survives.

A journal entry gets the date it is FOR. Backfilling in the browser: bento.journal('2026-01-05') produced Log for 2026-01-05 / Tomorrow is 2026-01-06, not today's date.

Untrusted input

Template ids come out of a file someone mailed you.

  • templateById scans a list rather than indexing an object — journalTemplate: "constructor" resolves to nothing.
  • The date-format lookup uses Object.hasOwn, so {{date:constructor}} is literal text rather than Object's constructor stringified into the reader's page.
  • Instantiated html goes through sanitizeInline at the door, as portable.ts does for a graft — the document never holds the thing.

Verification

node scripts/test-spaces.mjs      all 8 rigs pass (model 991/991, agent, journal ×5 TZ,
                                  calc ×4 TZ, undo, invite, roundtrip, size)
tsc --noEmit -p spaces            clean
npm run build:single              607KB → 278KB
shell-gate.mjs                    old-updater splice contract OK
build-spaces-i18n.mjs --check     618 strings × 8 locales, all complete

41 new assertions in scripts/test-spaces-model.ts, all behavioural — every one imports the function and runs it; not a source grep among them. 991/991.

Sabotage-verified. Nine deliberate breaks, each watched to fail the exact assertions it should and then restored to green:

sabotage assertions that failed
Object.hasOwn(DATE_FMT, key) → bare truthiness the two {{date:constructor}} / {{date:toString}} checks
instantiation reuses template block ids every block gets a FRESH id, with a fresh id
removeTemplate leaves a dangling journal setting removing the journal template takes the setting with it
makeTemplate spreads the whole page the parent, the date, the archive flag and the review threads do NOT
applyTemplate ignores keepTitle the two journal-title checks
templateById indexes a plain record all four prototype checks
token date offsets ignored {{date+1:iso}}, {{date-14:iso}}
docContentKey drops templates adding a template changes the content key
instantiation stops expanding tokens the three expansion checks

Real browser, over http://127.0.0.1:8791 on a uniquely-named copy of each build, with a marker check proving the build (build 1: Templates… present in ⋯; build 2: the picker items carry <svg>; build 3: .sp-tpl-name computes display: flex). Geometry read with getBoundingClientRect, never by reading back a style. Verified: the + picker opens below its anchor (top 95 > bottom 89) and inside the viewport at 260px; the dialog is 560×162; instantiation gives fresh ids, a remapped block parent, <b> and an #p/ link surviving the real sanitizer; the daily note arrives with its structure and keeps its ISO title; undo takes the whole thing back in one step; serialize() round-trips templates + journalTemplate through #bento-doc; no console errors.

Two defects the browser found that node could not, both fixed here: page icons in this app are usually a name from the stylised set, not an emoji, so the picker read "compass Daily log" (icons now render through a pageIcon supplied on the host, avoiding an import cycle); and .sp-result-ico is display: flex, so inside a plain span the template name wrapped under its own icon (measured at 17px row height after the fix).

Cost, and cross-zone touch

+7,368 B compressed (285,137 B with the feature vs 277,769 B for the same tree without it). The size rig's +26,683 B since reference is drift from the stored watermark, most of it other merged work.

scripts/test-spaces-model.ts and docs/DECISIONS.md are outside spaces/. The rig coverage rides with the feature per the contract; the shared-surface touch is claimed on the board by path (ops · spaces-templates). spaces/src/styles.css gains six appended lines and spaces/src/editor.ts gains four call sites plus a templateHost getter — everything else is the two new files, so this should merge cleanly against the sibling branches.

What I could not verify

  • Collaboration. doc.templates is a top-level array and therefore one last-writer-wins register under the CRDT — two people saving a template at the same moment keeps one. Same shape as Page.rows and documented, not fixed.
  • The graph was checked by its own header ("15 pages") rather than by counting nodes; it draws on canvas, so there is nothing in the DOM to count.
  • No agent verb was added for templates, so docs/spaces-agents.md is unchanged. If templates should be reachable from window.bento, say so and I will add it as a follow-up rather than widening this.

…rt from one

Page templates live in doc.templates — a collection, not pages carrying a flag.
doc.pages is enumerated in ~40 places (search, graph, backlinks, issuesOf and
every view, the Markdown export, About, the agent API, print, preview); a
flagged page needs a gate at every one of them and at every surface added
afterwards by someone who has never heard of templates. Measured: with the
collection, search for a template-only word returns 0, the Markdown export
does not contain it, stats.pages equals doc.pages, the sidebar has no row for
it and the graph counts 15 pages, not 16. What it costs is that a template
travels with the FILE and not with a grafted subtree, which is stated in the
changelog and in docs/DECISIONS.md rather than left to be discovered.

doc.journalTemplate names the template new daily notes start from; absent means
a blank entry, so every file written before this behaves as it always did, and
turning the setting off deletes the key.

Tokens expand ONCE, at instantiation, and the model stores the result — no
field system, and the new page is an ordinary page an older build reads the
same way. {{date}}, {{date:iso}}, {{date:short}}, {{date+1:iso}}, {{time}},
{{title}}; anything else stays literal. A journal entry gets the date it is
FOR: backfilling 2026-01-05 writes 2026-01-05 and 2026-01-06, verified in the
browser.

Two prototype guards, because the ids come out of a file somebody mailed you:
templateById scans a list rather than indexing an object, and the date-format
lookup uses Object.hasOwn. Both are pinned by rig assertions that were watched
to fail under deliberate sabotage.

New logic is in spaces/src/templates.ts and spaces/src/templateui.ts; the
editor gains a narrow TemplateHost and four call sites. Rig coverage is in
scripts/test-spaces-model.ts — a cross-zone touch of the shared rig surface,
claimed on the board by path.

+7,368 B compressed (285,137 B vs 277,769 B for the same tree without it).
@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown

Build size

main (0100083) → spaces-templates (2fbd62d)

app base PR change
bento/slides 677.9 KiB 677.9 KiB 0.0 KiB (0.00%)
bento/spaces 271.3 KiB 278.5 KiB +7.2 KiB (+2.65%)
bento/dash 424.1 KiB 424.1 KiB 0.0 KiB (0.00%)

Updated: 2026-09-09T01:15:55Z

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant