Skip to content

spaces: a page becomes a bento/slides deck - #434

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

spaces: a page becomes a bento/slides deck#434
nyblnet wants to merge 1 commit into
mainfrom
spaces-to-deck

Conversation

@nyblnet

@nyblnet nyblnet commented Sep 9, 2026

Copy link
Copy Markdown
Owner

Save → Export page as slides… turns one bento/spaces page into a
bento/slides presentation. No hosted notes app can do this; we can, because
both apps are the same repository.

The coupling, stated plainly

This writes another app's format while being forbidden to edit slides/.
Every file changed here is under spaces/src/, plus the shared rig, the
changelog and the decision log. What holds the two sides together:

  • a type-only import of slides/src/model.ts — erased at build time, so it
    costs the shell nothing, and a field renamed over there is a compile error
    here. It caught a missing required modified on the first run.
  • rig coverage that runs the emitted document through slides' own
    parseDoc
    and checks every key it writes against
    slides/src/modelkeys.generated.ts.

Neither guard covers behaviour, and nothing on the slides side knows this
exporter exists.
A renderer that stopped honouring valign would pass both.
That is a real cost, knowingly accepted, not a solved problem — it is written
into docs/DECISIONS.md and into the file's own header so the next session
does not find it as a surprise. Three defects the node rig could not see were
found by loading the output into a built slides shell and running its
validate(); they are listed below.

Shape: the document, not the file

It emits the deck's document JSON, handed to slides through the
"Replace from JSON…" round trip it already documents (or
window.bento.loadDoc()). A self-contained .bento.html deck is a document
spliced into a slides shell, and this app can obtain one only by bundling
~560KB of another app into every space, by fetching it (PLATFORM §1 forbids
it), or by a joint shell built in two zones this one may not edit. The Markdown
exporter is the precedent: write the other format faithfully and hand it over.
If a joint shell ever exists, that is the one decision to revisit — the
document this produces is already the whole payload.

The mapping

in a page on a slide
h1, h2 start a slide and become its title
divider starts a slide with no title
h3 a bold lead-in in the body
p, quote, prop a text box; a quote gets an accent rule
bullet / number / todo one text box holding a real <ul>/<ol>, nesting kept, / for a to-do
code monospace on a tinted panel — slides' code element hangs its highlighting off grammarAssetId, which this exporter has no grammar to fill
callout, toggle a tinted panel; the callout keeps its tone as a label, the toggle is shown open with its content
table a real slides table: column weights, per-column alignment, header, split across further slides when too tall
view (board / list) a table of the rows it stands for — same source, filter, sort and grouping as the screen
canvas its own slide, each card where the author put it (percentages in, percentages out)
image, media its own slide, bytes embedded
link, pagelink the words, plus the address on a second line
title / icon / cover the title slide; a cover is full-bleed under a scrim
theme background, ink, accent, body and heading faces

Content taller than a slide continues on the next one under the same title
rather than sitting off the canvas.

What is dropped, and how loudly

Fourteen machine codes (DeckNote), aggregated per heading, shown in the dialog
before anything is downloaded and written into the affected slide's
speaker notes — a toast is gone in four seconds, and the presenter who opens
the deck next week is the person who needs to know the page had a video on it.
Codes cover: a picture or clip that would have to be fetched, an embedded clip
that makes the deck large, a flattened link, a pagelink, an unfolded toggle, a
plain-panel callout, a flattened canvas, a split table, a derived board, an
unknown block type, an empty block, an RTL space, review comments, and a page
icon that is one of this app's own glyphs rather than an emoji.

Speaker notes are not invented. The tempting mapping is review comments →
slide.notes. It was rejected: a comment is workspace, addressed to a named
person, and a deck's notes travel in every copy — the mapping would disclose a
remark its author never put in the document.

Rules honoured

  • No network. Every picture goes through assetValue to its bytes and is
    re-interned in the deck's asset table; anything still remote — including
    bytes hidden one asset: indirection away, the hole closed on the reading
    side in spaces: ask the remote question about the URL that will actually be fetched #396 — is left out and reported.
  • Untrusted input. Deck html is built from the parsed run list, not
    filtered out of the input: every text run is escaped, only
    strong/em/u/s/code/br/ul/ol/li are emitted, no attributes. Map lookups on
    document data use Object.hasOwn. No eval, no new Function.
  • i18n. 25 new strings, literals only, a switch of t() calls rather than
    t(MAP[code]); all eight catalogs at 623/623, 100%. The English copy
    written into the document stays English on purpose — a saved artefact's
    words are its author's, not its next reader's browser's.
  • portable.ts and the Markdown export are untouched.

Verification

node scripts/test-spaces.mjs        all 8 rigs pass (model 987/987)
spaces tsc --noEmit -p              clean
npm run build:single                ok
scripts/shell-gate.mjs              splice contract OK
build-spaces-i18n.mjs --check       623 × 8, all complete

Rig coverage went into scripts/test-spaces-model.tsan ops-surface file,
flagged as a cross-zone touch
, claimed under spaces on the board. It is
where this zone's model rigs already live and it avoids adding a CI step while
the queue is contended.

Every assertion is behavioural — build a page, run the exporter, assert on
the result. Nine sabotages, nine caught, green restored after each:

break the assertion that went red
remove the remote-src gate no element src reaches the network once the deck's own asset table has had its say
declare the wrong format the emitted document loads through bento/slides own parseDoc
emit an unknown element key every key the exporter writes exists in bento/slides model
make h3 start a slide h3 does NOT start a slide
stop escaping run text every tag in the deck's html is one bento/slides renders
stop continuing a tall table every body row travels across the split (54/60)
drop a web cover quietly all three would-be-fetched pictures … are reported
write a named icon as text a NAMED icon does not — it would read as the word "image"
size table rows too short the table box is tall enough for every row it holds

Two of those assertions were rewritten because the sabotage exposed them as
vacuous
, which is the point of running it: the src check originally asked
about the string on the element (the exact asset: indirection hole), and the
markup check was a substring test on the serialized JSON that passed with
escaping removed entirely.

End-to-end: it renders

Four pages of the starter space exported and loaded into a real built
slides/dist-single/ shell
, served over http://127.0.0.1:8611 from a
uniquely-named copy carrying an e2e-marker meta tag (asserted in the page
before anything else).

deck slides validate()
Writing 8 measured ✓ — 0 errors, 0 warnings
Tables, pictures and clips 9 measured ✓ — 0 errors, 0 warnings
Tracker 4 measured ✓ — 0 errors, 0 warnings
Welcome 4 measured ✓ — 0 errors, 0 warnings

The one remaining info on each is slides' own collab-secrets-present, from
credentials slides mints on load — the emitted document carries no collab
key at all (verified in the page).

That run is what found the three defects the node rig could not, all fixed
here: every element flagged past-margin (the margin is slides' own 96 now),
two text boxes overflowing by 10px and 15px (the no-DOM width estimate was too
generous for lists, tuned against the real measurement), and a table clipped
to three of its five rows
because rows were sized at 36px when one draws at
about 45. Only the third has a rig assertion, and it is a derived bound rather
than a measurement.

Known, and deliberately not fixed here

about.ts toMarkdown's rowsOf derives a view block's rows from issuesOf
and ignores source, so a view with a has/under source exports the wrong
rows to Markdown. todeck.ts uses viewRows(doc, b.source), which is correct.
Fixing the Markdown path is a change to a shipped exporter and does not belong
in this PR; raising it so it is not lost.

Cost

Compressed shell 277,769 → 294,133 B (+16,364, +5.9%), measured by building
origin/main side by side. Roughly half is the exporter and half the 25 strings
× 8 locales.

Save -> Export page as slides... turns one page into a bento/slides
presentation. Headings and dividers start slides, lists stay lists, a
table stays a table, a board becomes a table of the rows it stands for,
and a canvas becomes a slide with each card where the author put it. The
page's title, icon and cover make the title slide; the space theme
becomes the deck theme.

What it hands over is the deck's DOCUMENT JSON, pasted into Bento Slides
through its own "Replace from JSON...". A self-contained .bento.html deck
is a document spliced into a slides shell, and this app has no way to get
one that does not either bundle half a megabyte of another app into every
space or fetch it on open. The Markdown exporter is the precedent.

Nothing is fetched and nothing dangles: every picture is resolved through
the asset table to its bytes and re-interned in the deck's own assets, and
anything that would still reach the network - including bytes hidden one
asset: indirection away - is left out and reported. The report is shown in
the dialog before the download and written into the deck's speaker notes.
Speaker notes are otherwise not invented; review comments stay behind.

New file spaces/src/todeck.ts; the editor gains one menu entry and one
dialog. The deck's format belongs to another zone and nothing under
slides/ is edited: the guards are a type-only import of slides/src/model.ts
and rig coverage that runs the emitted document through slides' own
parseDoc and checks every key it writes against its generated key list.
Behaviour is not covered on that side - see docs/DECISIONS.md.

24 new UI strings in all eight catalogs; packed catalogs regenerated.
Compressed shell 277,769 -> 294,133 B.
@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown

Build size

main (0100083) → spaces-to-deck (55f0e60)

app base PR change
bento/slides 677.9 KiB 677.9 KiB 0.0 KiB (0.00%)
bento/spaces 271.3 KiB 287.2 KiB +16.0 KiB (+5.89%)
bento/dash 424.1 KiB 424.1 KiB 0.0 KiB (0.00%)

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

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