Skip to content

spaces: a page can have a cover, and a view can be a gallery - #392

Open
nyblnet wants to merge 3 commits into
mainfrom
spaces-gallery
Open

spaces: a page can have a cover, and a view can be a gallery#392
nyblnet wants to merge 3 commits into
mainfrom
spaces-gallery

Conversation

@nyblnet

@nyblnet nyblnet commented Aug 28, 2026

Copy link
Copy Markdown
Owner

Stage three of the Bases work, stacked on #389 (--base spaces-bases-views). Stage one gave a page properties, stage two gave a view a set of pages and a table to read them in. A base that can hold books and films still had nothing that made them look like books and films. This is the picture, and the shape that shows it.

A cover is never a URL

Page.cover takes asset:<key> or a data: URI and nothing else — for the same reason icon is one emoji and never an address. Opening a document must not touch the network (PLATFORM §1), and a cover is the field most likely to tempt somebody into a link, because that is how every hosted notes app stores one.

The refusal is one function, coverSrc, that the page and the gallery card both go through. A file arriving with a remote cover keeps the field (additivity) and renders nothing; validate() names it rather than leaving the silence:

Page "Welcome" has a remote cover (https://example.com/x.jpg), which is DROPPED at render — opening a space never contacts a third party.

The same pipeline an image block uses

Not a second policy. prepareImage downscales the phone photo before it travels, internAsset content-addresses the bytes so two pages with one cover store it once, and IMAGE_EMBED_BUDGET asks the same 4MB question at the same size. Hashing and interning happen before the commit, so a cover is one undo step.

A cover is the only asset reference that is not on a block

Every asset sweep in the app was written as a loop over page.blocks. Missed, the failure is silent and specific: the readout calls every cover an orphan and offers to delete it, and a page grafted into another space arrives with a cover pointing at nothing. pageAssetKeys is the one answer, and orphanAssets, stats(), validate(), extractSpace and planGraft all ask it.

How it looks

The cover is full-bleed — it belongs to the page, not to the prose, so it ignores the measure and escapes the reading column's padding. That padding is now --sp-pad-x/y rather than four literals scattered through styles.css: a hard-coded -44px would be wrong on a phone, in reading view and on paper, all silently.

The page's own icon rides up over the picture's lower edge, in a 58px disc of the page background. Lifting the icon rather than the title is deliberate — a line of text over an arbitrary photograph needs a scrim, contrast rules and a colour that works over every possible image; a disc does not.

Gallery

Fourth shape in the layout cycle: board → list → table → gallery → board, where board is still the absent key, so a view cycled all the way round is byte-identical to one nobody touched.

  • each card is a page: its cover, its title, its values
  • cards show every field the page carries, not the tracker's four — a gallery of books showing Priority and Estimate and not the Author would be the board wearing a different shape
  • a page with no cover gets a tinted panel on a hue derived from its id, carrying its icon or its first character. A gallery nobody has given pictures to still reads as a set of distinct things rather than a grid of grey rectangles
  • auto-fill with a minimum card width, never a column count: three across with both panels open, five on a wide screen, nobody configures anything

Measured in the built shell, not inferred

Drove dist-single/Bento_Spaces.bento.html in a browser. File pickers cannot be driven from the pane, so covers were set through the model directly and everything else was the real UI.

checked result
cover renders full-bleed above the column, icon disc on its lower edge
layout button ×3 view.layout === 'gallery' in the document, button reads Gallery
gallery contents 5 cards, 3 with cover images, 2 tinted panels showing the page's initial
panel with a cover rows read Cover · Replace… · Remove
Remove 'cover' in pagefalse (the key is deleted, not blanked), picture gone, button back to Choose…
undo after Remove cover restored
remote cover nothing rendered, zero non-data: images in the document, field kept in the model, validate() reported remote-cover
dark theme card rgb(27,32,39) on rgb(46,53,63) hairline, tinted panel stays a tint (hsl with alpha), mark in --muted

Verified by sabotage

Every new assertion in scripts/test-spaces-model.ts was watched to fail with the thing it guards deliberately broken:

  • coverSrc stops refusing a remote cover → 2 fail
  • coverSrc never returns a picture → 2 fail
  • pageAssetKeys returns [] → 5 fail
  • orphanAssets forgets covers → 1 fail
  • extract drops the cover bytes → 3 fail
  • graft leaves the cover on the old key → 2 fail
  • the gallery layout disappears → 1 fail
  • a gallery card bypasses coverSrc → 1 fail
  • the coverless panel loses its class → 1 fail
  • removing a cover stores "" → 1 fail
  • the cover picker skips the image pipeline → 1 fail
  • the cycle stores "board" instead of clearing → 1 fail
  • the panel stops offering a cover → 1 fail
  • the fixture stops carrying a cover → 6 fail (it reads what it claims to)

node scripts/test-spaces.mjs — all eight rigs pass (model 826/826). i18n 567 × 8 locales, all complete; three new keys (Cover, Choose…, A picture across the top of this page), each a literal at its t() call site.

Byte cost

263,602 B, +5,148 B over the reference — about 2%, for the format field, the picker, the gallery renderer and its CSS. No ceiling on spaces; the rig reports the drift.

Not in this PR

  • no cover reposition / focal point — one more permanent format field for a crop nobody has asked for yet
  • covers on the gallery card are the page's own; a view cannot override one
  • no drag-and-drop of an image file onto the cover area

Stage two of the Bases work. Stage one let a page carry properties; this makes
a view that can hold those pages, and a shape to read them in.

A view meant ONE thing — every page carrying a `status` — which is why the
tracker worked and nothing else did. Two selectors now, and deliberately only
two. `has` pairs with a property somebody just invented: the vocabulary is flat
and a page carries only the fields it uses, so "has an Author" IS "is a book".
`under` is what people reach for anyway, because nesting is how a space is
already organised. A selector language grows without limit and can never
shrink; every operator ships permanently into files on other disks.

ABSENT MEANS ISSUES, and that is the compatibility rule rather than a default:
every view block written before `source` existed carries none and must keep
showing the backlog forever. A view set back to Issues is byte-identical to one
that never moved — the rule `filter` already follows, and the reason cycling the
layout past table CLEARS the key instead of storing "board".

TABLE is the shape a base is usually looked at in and the one this app did not
have. Its columns are the fields the ROWS ACTUALLY CARRY, in the schema order:
a table of books must not grow an Estimate column because the vocabulary
happens to contain one. Select values render through their option, so a cell
reads "Read" with its colour rather than the id the model stores. It scrolls
inside its own box — a wide table must never scroll the page sideways.

Built the article use case end to end on a real build to check it: three book
pages with Author and Shelf, a view sourced by `has: author`. The table came
out with exactly Page/Author/Shelf, no Priority or Estimate; the layout button
cycled to a board; the group picker offered Shelf — the user-defined select,
because grouping only offers fields with options — and the board came out To
read / Reading / Read with one card each. That is a Kanban reading list built
from fields that did not exist in the app an hour ago.

Three sabotages, each failing the assertion that names it: a sourceless view
returning nothing, table columns taken from the vocabulary instead of the rows,
and cycling storing "board" rather than clearing.

i18n: "Show as a {what}" was written and then withdrawn. It reads fine in
English and breaks in half the catalogs, where the article and adjective agree
with the noun gender — die Liste against der Tafel, une liste against un
tableau. Three whole sentences instead, two of which already existed.

+3,028 B, reported by the tracker rather than blocked.
Stage three of the Bases work, on top of #389. A base that can hold books and
films had a table to read them in and nothing that made them LOOK like books
and films. This is the picture, and the shape that shows it.

A COVER IS NEVER A URL. `Page.cover` takes `asset:<key>` or a `data:` URI and
nothing else, for the same reason `icon` is one emoji and never an address:
opening a document must not touch the network (PLATFORM §1), and a cover is the
field most likely to tempt somebody into a link, because that is how every
hosted notes app stores one. The refusal is one function — `coverSrc` — that
the page and the gallery card both go through. A file arriving with a remote
cover KEEPS the field (additivity) and renders nothing; validate() names it
rather than leaving the silence.

THE SAME PIPELINE AN IMAGE BLOCK USES, not a second one: prepareImage
downscales the phone photo before it travels, internAsset content-addresses the
bytes so two pages with one cover store it once, and IMAGE_EMBED_BUDGET asks
the same question at the same size. Hashing and interning happen BEFORE the
commit, so a cover is one undo step.

A cover is also the only asset reference in the format that is NOT on a block,
and every sweep in the app was written as a loop over blocks. Missed, the
failure is silent and specific: the readout calls every cover an orphan and
offers to delete it, and a page grafted into another space arrives with a cover
pointing at nothing. `pageAssetKeys` is the one answer, and orphanAssets,
stats(), validate(), extractSpace and planGraft all ask it.

The cover is FULL-BLEED — it belongs to the page, not to the prose, so it
ignores the measure and escapes the reading column's padding. That padding is
now `--sp-pad-x/y` rather than four literals scattered through styles.css: a
hard-coded -44px would be wrong on a phone, in reading view and on paper, all
silently. The page's own icon rides up over the picture's lower edge, which is
what makes it read as a cover rather than as a banner. Lifting the icon rather
than the TITLE is deliberate: a line of text over an arbitrary photograph needs
a scrim, contrast rules and a colour that works over every possible image; a
58px disc in the page background does not.

GALLERY joins the layout cycle: board → list → table → gallery → board, where
board is still the ABSENT key, so a view cycled all the way round is
byte-identical to one nobody touched. Cards show every field the page carries,
not the tracker's four — a gallery of books showing Priority and Estimate and
not the Author would be the board wearing a different shape. A page with no
cover gets a tinted panel on a hue derived from its id, carrying its icon or
its first character, so a gallery nobody has given pictures to still reads as a
set of distinct things.

Verified in the built shell, not inferred: covers set through the model render
full-bleed; the layout button cycled to `layout: 'gallery'` in the DOCUMENT;
Remove deleted the key (`'cover' in page` false) and undo put it back; a remote
cover rendered nothing, loaded no remote image, and validate() reported it.
Every new assertion in scripts/test-spaces-model.ts was watched to fail with
the thing it guards deliberately broken.
Rebase first: #390 (table edited in place), #393 (graph), #396 (remote-asset
gate), #395 (canvas) and #375 (name sweep) all landed underneath this branch,
conflicting on seven paths. render.ts hunk 1 was the one to get right — ours
imported coverSrc, main imported loadsRemotely/assetValue, and taking either
side whole would have dropped a feature or the security fix. Composed by hand;
the rest kept both, per PARALLEL-WORK section 3. packed.ts regenerated, never
hand-merged.

Then the review findings, each measured before and after:

THE LIFT NEVER HAPPENED. .sp-cover carried margin-bottom:26px and the inner
column margin-top:-30px — adjacent siblings, so the margins COLLAPSED to -4px.
Measured: cover bottom 317, icon top 313. The disc sat tangent to the picture
while the comment beside it described an arrangement that was not happening.
The cover gives up its bottom margin; only one of the two can own that space.
Now +26px of the disc over the photograph.

AND IN THE READING VIEW THE TITLE LANDED ON THE PHOTOGRAPH. No icon renders
there, so the -30px lift was absorbed by the title instead: cover bottom 197,
title top 193 — unscrimmed text on an arbitrary image, the one thing that
comment says the design exists to avoid. It survived over a dark sky and would
not over a light one. The lift is the ICON'S now, gated on :has, and the title
clears the photo by 26px.

A NEAR-WHITE COVER READ AS A HOLE — same colour as the page, so a scan or a
snow shot looked like a failed image. An inset hairline on the cover and on the
gallery shot, invisible on every other picture.

FREE HUE WAS NOT DISTINCT. Measured 61 and 54 in one grid (the same dirty
chartreuse) and five cards as peach/pink/pink/lavender/lavender. Eight curated
stops instead, and the mark goes from --muted to --ink-2, which was the same
value as its own tint in the dark theme.

A FOUR-LINE TITLE inflated its grid row and left six neighbours carrying 60px
of nothing. Clamped to two.

The layout cycle reached Object.prototype through a bare `in`: a hand-authored
layout:"toString" rendered a native function as the button label and stored a
FUNCTION into the model. Object.hasOwn now, the same guard resolveSrc has.

I18N, THE REAL FIND. t(LAYOUT_WORD[here]) — the extractor sweeps LITERALS, so
no catalog ever learned those strings existed, and coverage still read 100%
because the packer counts what it swept. "Board", "List" and "Show as a list"
were WRITTEN in de.ts and absent from packed.ts: translations already done,
dropped on the floor. Chasing it found a second instance I shipped in #388 —
FIELD_TYPE_LABEL put five of six property types in NO catalog at all. Both are
chosen at the call site now; eight new keys across eight locales.

So the class is the assertion now. A rig check fails on any t() reading a map
the extractor cannot sweep, with the one indirection it DOES implement (label:
and hint: out of blocks.ts) named rather than assumed. Building it produced two
false positives worth recording: it flagged its own doc comments until it
stripped comments, and a literal on the line after t( because \s* backtracks and
let the character class eat the newline.

Verified by sabotage: labels back behind a map fails; a named map in fields.ts
fails; the cover lift and the reading-view gate measured in a real build.
@nyblnet
nyblnet changed the base branch from spaces-bases-views to main August 30, 2026 19:30
@github-actions

Copy link
Copy Markdown

Build size

main (7422f4f) → spaces-gallery (fb6a5a3)

app base PR change
bento/slides 669.3 KiB 669.3 KiB 0.0 KiB (0.00%)
bento/spaces 267.6 KiB 270.7 KiB +3.2 KiB (+1.18%)
bento/dash 161.3 KiB 161.3 KiB 0.0 KiB (0.00%)

Updated: 2026-08-30T19:31:42Z

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