Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions docs/DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -6390,3 +6390,61 @@ chance to run and it is cheap. Reconciliation for this cycle: 41 commits, 40
mapped, 1 correctly absent, run by bento-team-slides.

Claude-Session: https://claude.ai/code/session_01Jcfdy8A69nonyATtm8vRy8

## 2026-09-12 — bento/spaces: procedural covers are a render-time default on two surfaces, and a document theme preset is not a thing this app can honestly offer

**A page with no `cover` draws a generated one — never stored, never on paper,
never in the thumbnail.** `spaces/src/procedural.ts` builds an SVG (gradient +
one of six geometric figures) seeded by FNV-1a over the page id, so every reader
of one file sees the same cover on the same page and a page keeps it across
renames. Nothing is written: `cover` absent stays absent, an older build sees no
cover, and a saved file does not change. A page with a usable cover of its own
draws only that; a remote cover, which `coverSrc` already refuses, counts as
none. The decision is one pure function (`proceduralCoverFor`) so the model
rig pins every branch without a DOM; the browser pass is what proves the
render.

**Two surfaces, chosen structurally rather than by a field: the HOME page, and
every coverless GALLERY card.** Not every page. A cover is a 150–320px
full-bleed band that pushes the title down and lifts the icon into a disc; on a
space of two hundred plain notes that is two hundred posters, and a journal
entry under a banner is wrong however quiet the figure. The gallery already
drew a procedural tint on its bare cards (the id-derived hue), so the card is a
refinement of an accepted default; the home page is the one page the format
itself names. A per-page opt-in would be a format field for a thing that is
not document data — the reason the surfaces are structural.

**Print and the preview draw nothing procedural.** Both pass `printing: true`
and the decision returns '' under it: five centimetres of toner for artwork
nobody chose, and the file-manager still is a render of the AUTHOR's document,
which this is not in — and every saved file would otherwise grow by the SVG.

**Restraint is by construction, and measured.** The gradient is the gallery's
former CSS tint exactly (hue → hue+40°, 0.30/0.16 on a card; 0.44/0.26 on the
page, where a white disc has to read against it); the figure sits on it at
6–14% alpha at lightness 44. Everything is alpha over the surface's own ground
(`--chrome-2`), which is how one SVG serves both themes. Rasterised over each
ground across 400 ids: the card mark's worst case is 4.29:1 light / 3.28:1
dark (from 6.55 / 4.13 with the tint alone — the figure costs about a point
and stays above the 3:1 large-text line); the disc glyph is 9.94 / 9.30. The
disc EDGE against the cover is 1.33–2.31 light and 1.91–3.53 dark; it was not
chased to 3:1 because the only way there is a wash loud enough to be the thing
this rule exists to refuse, and the glyph, not the boundary, is what identifies
the control — the same bargain a real cover already makes over a pale photo.

**Theme presets were asked for and NOT built, and the reason is the
2026-08-22 entry above.** A preset would be a bundle of `doc.theme` values,
and in this app `doc.theme.background/color/accent/fontFamily` are painted by
NOTHING in the live app — only `preview.ts` reads the colours, only `measure`
and `dir` reach the reading column. That was ruled, not forgotten: the reading
surface is chrome and follows the reader, and "if it ever changes it changes
with the FORMAT". So a "Dark" or "High contrast" document preset would change
the thumbnail and nothing a reader can see, and a rig asserting "the preset
wrote the resolved keys" would be green over a control that does nothing —
the source-grep-over-a-dead-renderer failure this zone has recorded twice.
Painting `doc.theme` onto the column reverses that ruling and pre-empts the
open cross-app dark-mode question, and is not a change one app zone makes on
its own. What is honest without a ruling is smaller and differently shaped:
typography presets (`fontFamily`/`headingFamily`/`measure`, which ARE document
data), which would first need the column to paint the font fields at all. Left
for the ruling rather than shipped under the wrong name.
50 changes: 50 additions & 0 deletions scripts/test-spaces-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ import type { Block, Page } from '../spaces/src/model.ts'
import {
buildGraph, layoutGraph, stepLayout, nodeRadius, graphBounds,
} from '../spaces/src/graph.ts'
import { proceduralCoverSvg, proceduralCoverFor, hueOf, CARD_HUES } from '../spaces/src/procedural.ts'

let failures = 0
let checks = 0
Expand Down Expand Up @@ -675,6 +676,55 @@ for (const [label, input, err] of [
'…through the IMAGE pipeline: downscaled, content-addressed, and the same budget question')
ok(/delete p\.cover/.test(ed2), 'removing a cover DELETES the key rather than storing an empty string')
ok(/pickCover\(page\.id\)/.test(props2), 'the properties panel offers it, beside the icon')

// 6. PROCEDURAL COVERS (procedural.ts). A render-time default, never data:
// the properties below are the ones a reader would notice breaking, and
// every one is asserted on the OUTPUT, not on the source.
const same = proceduralCoverSvg('p-one') === proceduralCoverSvg('p-one')
ok(same, 'the same id yields the same SVG twice — a page keeps its cover across reloads and readers')
ok(proceduralCoverSvg('p-one') !== proceduralCoverSvg('p-two'),
'two ids yield different SVGs — a gallery of coverless pages is a set of distinct things')
ok(new Set(Array.from({ length: 64 }, (_, i) => proceduralCoverSvg(`id${i}`))).size === 64,
'…sixty-four ids, sixty-four covers')
ok(CARD_HUES.includes(hueOf('p-one')) && hueOf('p-one') === hueOf('p-one'),
'the hue is one of the curated stops, and stable')
// CODE lines only — the file's own commentary explains why it never does
const genSrc = fs.readFileSync(new URL('../spaces/src/procedural.ts', import.meta.url), 'utf8')
.split('\n').filter((l) => !/^\s*(\/\/|\*|\/\*)/.test(l)).join('\n')
ok(!/Math\.random|Date\.now|new Date/.test(genSrc), 'nothing in the generator reads a clock or a die')
const svg = proceduralCoverSvg('p-one', 'page')
ok(/^<svg /.test(svg) && /<\/svg>$/.test(svg) && /aria-hidden="true"/.test(svg),
'the output is one decorative <svg> element')
ok(!/<script|on[a-z]+=|<foreignObject|href=/i.test(svg), 'and it carries nothing that could run, load or link')
ok(proceduralCoverSvg('p-one', 'page') !== proceduralCoverSvg('p-one', 'card')
&& Buffer.byteLength(svg) < 2048,
'the page wash is its own strength, and a cover costs under 2KB — and none of it is saved')
// the decision, as the renderer asks it
const pdoc = (parseDoc(doc({
home: 'h',
pages: [
{ id: 'h', title: 'Home', blocks: [] },
{ id: 'n', title: 'Note', blocks: [] },
{ id: 'c', title: 'Covered', cover: 'data:image/png;base64,AAA', blocks: [] },
{ id: 'r', title: 'Remote', cover: 'https://x/y.png', blocks: [] },
],
})) as { doc: SpacesDoc }).doc
const pg = (id: string) => pdoc.pages.find((p) => p.id === id)!
ok(proceduralCoverFor(pg('h'), pdoc, false).length > 0, 'the home page gets a procedural cover')
ok(proceduralCoverFor(pg('n'), pdoc, false) === '',
'…and an ordinary page does NOT — two hundred notes are not two hundred posters')
ok(proceduralCoverFor(pg('h'), pdoc, true) === '', '…and nothing procedural under printing (paper, the thumbnail)')
pdoc.home = 'c'
ok(proceduralCoverFor(pg('c'), pdoc, false) === '', 'a page with a real cover draws no procedural one, home or not')
const c = pg('c')
delete (c as { cover?: unknown }).cover
ok(proceduralCoverFor(c, pdoc, false).length > 0, '…and a page whose cover is REMOVED gets its procedural one back')
pdoc.home = 'r'
ok(proceduralCoverFor(pg('r'), pdoc, false).length > 0,
'a remote cover is refused (PLATFORM §1), so that page is coverless and gets the procedural one')
ok(/proceduralCoverFor\(page, doc, opts\.printing === true\)/.test(render)
&& /proceduralCoverSvg\(r\.page\.id, 'card'\)/.test(render),
'the renderer asks that decision for the page view and draws the card figure in the gallery')
}


Expand Down
30 changes: 30 additions & 0 deletions spaces/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -477,6 +477,36 @@ Versions follow `0.MINOR.PATCH` while pre-1.0.
2026, from the same file. `bento.journal()` opens today's for an agent, and
`bento.journal('2026-08-06')` any day's.

- **A page with no cover gets a procedural one — on the home page and on every
gallery card, and nowhere else.** A gradient plus a geometric figure (orbs,
bands, a dot lattice, rings, facets or waves), seeded from the page id, drawn
at render time and never written into the file: `cover` absent stays absent,
an older build sees no cover exactly as before, and a saved space does not
grow by a byte. The same id draws the same cover on every machine and every
reload; a page that gains a real cover shows that and nothing else, and a
page whose cover is removed gets its procedural one back. The gallery already
tinted its coverless cards on a hue from the id — this is that tint with a
figure on it, in the same two hues at the same alphas, over the theme's own
ground, so one SVG is right in light and in dark.

Home page and gallery only, on purpose. A cover is a full-bleed band that
pushes the title down and lifts the icon into a disc; on a space of two
hundred plain notes that is two hundred posters, and a journal entry under a
banner is wrong however restrained the artwork. The two surfaces chosen are
the two that already single a page out. Never on paper (5cm of toner for a
figure nobody chose) and never in the file-manager thumbnail (a still of the
author's document, and this is not in it).

Measured with real pixels, the SVG rasterised over the theme ground, across
400 ids covering all eight hues and all six figures: the card's letter-mark
keeps at least 4.29:1 in light and 3.28:1 in dark (34px bold; the figure
costs about a point against the plain tint, which sat at 6.55 and 4.13);
the icon on the home page's disc reads at 9.94:1 light / 9.30:1 dark. The
disc's EDGE against the cover is 1.33–2.31:1 in light and 1.91–3.53:1 in
dark — the disc is white-on-a-wash by design, as it is over a pale
photograph, and its shadow carries the boundary; the glyph is what has to
read. Shell +1,412 B.

## [0.1.0] — 2026-08-03

First release.
Expand Down
Loading
Loading