Skip to content

spaces: a reading copy you can hand to someone who will not edit it - #435

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

spaces: a reading copy you can hand to someone who will not edit it#435
nyblnet wants to merge 1 commit into
mainfrom
spaces-reading

Conversation

@nyblnet

@nyblnet nyblnet commented Sep 10, 2026

Copy link
Copy Markdown
Owner

A space is already the whole artifact. What was missing was a file you can hand
over without also handing over the workspace — the editing tools, the
half-finished conversations in the margins, and the keys to the live room.

"Save a reading copy…" in the share popover writes that file. It opens as a
document: the pages, the tree, ⌘K search and print, and none of the machinery
for changing them. The eye toggle is the same view for the file you are writing
in, and every page now carries a Previous / Next pair.

Tiers — no new one, and that is the point

bento/slides has three. Spaces already carried two of the same shapes in its
format, settled before this branch:

shape what it is
doc.readonly SEALED reading copy — no session, opens in the reader
collab.role:'reader' LIVE view-only copy — follows the room, cryptographically cannot write (share.ts readerCopy)
neither writer

Slides' third tier answers a question spaces does not ask (a presentation
package boots into a show). Adding a "published" field would be a second
spelling of readonly that old files answer only one of. So this branch adds no
tier — it writes the one that had a field in the format, a check in main.ts,
and no button anywhere: the one property a sender could choose was the one
they had to hand-edit the JSON to set. It also only locked the store, so a
reading copy arrived as the full editor with every control inert.

What is stripped, in three honest grades

The failure this app's history says to avoid is presenting a cosmetic guarantee
as protection, so:

  • CRYPTOGRAPHIC. doc.collab is deleted outright — through share.ts's own
    stripCollabSecrets with no keepRoom, so it is that list and not a second
    copy of it. No room, no symmetric key (the read capability for every frame
    and blob the relay holds), no ownerPriv, writerPriv or invite.priv. The
    recipient cannot read the room, write to it, or join it — because the material
    a socket must present to the relay is not in the file. An invite keeps
    room + key because it is meant to follow; a sealed copy follows nothing,
    so it keeps nothing.
  • FORMAT-LEVEL. Comment threads are gone from the bytes — Page.comments
    and Block.comments, replies included. Not hidden: absent. The starter space
    itself says a comment is saved in the file and anyone you send it to can read
    it; this is the export where that stops being the reader's problem.
  • COSMETIC. doc.readonly, and every piece of chrome the reader hides.
    Anyone can open the HTML and flip it back — the document block is plaintext by
    design. It is intent, never a lock, and nothing in this PR, the changelog or
    the source describes it as one.

What I found already in share.ts: it does this well. stripCollabSecrets
is derived by deleting rather than by rebuilding from an allow-list, with the
reasoning written down, so a private field added to CollabCreds later is
covered without anyone remembering. Its default (no keepRoom) already drops
collab outright, which is exactly what a reading copy needs — so the strip
here is a call, not a reimplementation. No hole found; readerCopy and
inviteCopy both keep the boundary they claim.

Format additivity

Absent readonly = old behaviour. A reading copy is always sealed, so the flag
is never written false — the transform adds exactly one top-level key and
removes exactly one, and the rig asserts both. An older build opening a reading
copy does not fail
: it ignores the flag and opens the space editable. That is
the correct degradation, and it is why the guarantees that matter are removal
rather than a flag — bytes that are not in the file are not in the file whatever
opens it.

The static preview, print, and encrypted spaces

  • Preview: unchanged and inherited. A reading copy is written through
    serializeAuto/writeUpdatedFileAs like every other export, so it carries the
    still render — verified on the real file: host div + its remover script as
    the immediate next sibling, 5,222 B, and no comment text in it (the renderer
    has never heard of comments).
  • Print: paper and reading mode want the same things, and the existing
    @media print block already hides the bar, the panels and the gutters. The
    footer is the one thing reading mode adds, so it is the one thing print has
    to take away again — a "Next ›" card is a control.
  • Encrypted spaces: nothing here touches encryption, deliberately. A reading
    copy of a locked space is written encrypted with the same password, so its
    reader meets the password gate before the reader — and previewAllowed
    refuses a still for an encrypted body, so it carries none. A plaintext home
    page beside the ciphertext is the leak the password exists to prevent, and a
    reading copy is the last file to make that exception for.

Verification

node scripts/test-spaces.mjs      all 9 rigs green (model, agent, journal, calc,
                                  undo, invite, roundtrip, reading, size)
node scripts/test-spaces-reading.ts                40/40 checks
spaces && tsc --noEmit -p tsconfig.json            clean
npm run build:single && shell-gate                 splice contract OK
node scripts/build-spaces-i18n.mjs --check         602 strings x 8 locales, 100%

scripts/test-spaces-reading.ts (new) asserts on the serialized document,
never on "a strip function was called" — JSON.stringify(copy) is literally what
serializeAuto writes into #bento-doc, so a full-text scan of it is a scan of
what a recipient opens in a text editor. It follows test-spaces-invite.ts,
including its premise checks (the open document really does hold the owner key
and the comments) and its full-text scan for key material rather than a field
check — the indirection hole this repo has shipped twice.

Sabotage-verified, four ways, each restored afterwards:

sabotage red
stripCollabSecrets(out) → no-op 9 checks, incl. all four key/room scans
stripComments(out) → no-op 5 checks, incl. the reply canary
delete page.comments → no-op (block-level left intact) 2 checks — proves the page anchor is asserted separately
out.readonly = true removed 5 checks

Browser-measured on a built shell over http://127.0.0.1:5321, on a
uniquely-named copy whose marker was confirmed in the served bytes (fetch of
location.href, since the app overwrites <title>), with
getBoundingClientRect:

  • Editor → reading view: insert 77→0 px, undo/redo 74→0, properties toggle
    36→0, properties panel 0, gutters 18→0. Kept: eye 36, search 36, ⋯ 36, save
    128, sidebar 244. Reader footer appears at 726×81.
  • Sealed copy (a real readonly:true file, produced from the page and served
    back): boots sp-app sp-sealed sp-reading; the only visible bar buttons are
    About, Search, ⋯; save, the eye and the live control are all 0; 0
    contenteditable nodes, 0 comment markers; Esc leaves it in reading mode.
  • Phone, 390×844: no horizontal overflow (scrollWidth 390 = innerWidth),
    sidebar collapsed, both nav cards 158 px side by side, prose column 354 px.
  • Print rule confirmed as a parsed CSSOM rule in the inflated shipped
    stylesheet, not as source text.
  • Shell 277,769 → 280,245 B (+0.9%), origin/main built side by side.

Could not verify

  • Actual print output. Emulation.setEmulatedMedia is not reachable from the
    tools here, so @media print was checked as a parsed rule rather than by
    rendering to paper. Note printNow() renders its own tree via renderPage
    and never includes the footer anyway — the rule covers the other path, a
    browser ⌘P over the on-screen DOM.
  • The FSA save path (writeUpdatedFileAs) — the embedded browser denies
    file-system-access writes, so the copy under test was produced by
    serializeAuto + POST rather than by pressing the button. The button's own
    wiring (onShareCopyserializeAutowriteUpdatedFileAs, keepHandle
    false) is the same path the invite and view-only copies already use.
  • A reading copy of an encrypted space end to end. The reasoning is
    inherited from serializeAuto + previewAllowed, both kernel-tested; I did
    not set a password and re-export.
  • QuickLook / iOS thumbnailing of a reading copy — the preview element is
    verified present and inert, not rendered by a real thumbnailer.

Shared-surface note

scripts/test-spaces.mjs and .github/workflows/ci.yml are touched by path.
The CI step is not deferrable to a follow-up: test-spaces.mjs --manifest
fails on a rig that is listed but unregistered, so registering it is part of
adding it. Flagging for the lead, per the standing conflict on ci.yml.

"Save a reading copy…" (share popover) writes a sealed second file that opens
as a document rather than as an editor, and the eye toggle becomes the same
view for the file you are writing in.

TIERS. No new one. The format already carries both published shapes and they
were settled before this: `doc.readonly` is the SEALED copy (no session) and
`collab.role:'reader'` is the LIVE view-only copy. `readonly` had been declared
and read by almost nothing — it locked the store and left every editing control
on screen, inert, which reads as a broken editor. It opens the reader now.

WHAT THE COPY LEAVES BEHIND, in three honest grades:

  CRYPTOGRAPHIC — `doc.collab` is deleted outright (share.ts's own list, via
    stripCollabSecrets with no keepRoom), so no room, no symmetric read key and
    no private signing key of any kind travels. The recipient cannot read the
    room, write to it, or join it, because the material a socket must present
    is not in the file. An invite keeps room+key because it is MEANT to follow;
    a sealed copy follows nothing.
  FORMAT-LEVEL — every comment thread is gone from the bytes, page-level and
    block-level, replies included.
  COSMETIC — `doc.readonly` itself, and the chrome the reader hides. The
    document block is plaintext by design, so anyone can clear the flag. It is
    intent, never a lock, and nothing here presents it as one.

An older build ignores the flag and opens the space editable, which is the
correct degradation: the guarantees that matter are absent bytes, and those are
absent whatever opens the file.

READING MODE. New spaces/src/reading.ts holds the transform and the reader's
Previous/Next navigation; the diffs in editor.ts, collabui.ts, share.ts,
main.ts and styles.css are deliberately small. Reading mode now removes the
tools rather than disabling them — insert, undo/redo, the properties panel and
toggle, the format bar, block gutters, the page-icon picker and the sidebar's
new-page button — and keeps the page tree, ⌘K, print and ⋯. A sealed copy also
loses the eye, the save split and the live control, and Esc cannot leave it.
Print already hides the chrome and now hides the reader's footer too.

VERIFIED. scripts/test-spaces-reading.ts asserts on the SERIALIZED document,
not on "the strip function ran" — 40 checks, following test-spaces-invite.ts.
Sabotaged four ways (collab strip, comment strip, page-comment strip, the flag)
and each turned exactly its own assertions red. Browser-measured on a built
shell over http with getBoundingClientRect at 1728px and in a 390px frame: no
horizontal overflow, both nav ends present, sealed bar down to About/Search/⋯.
Shell 277,769 -> 280,245 B against origin/main built side by side (+0.9%).

Rig registered in scripts/test-spaces.mjs and in ci.yml — the manifest check
fails on an unregistered rig, so the step could not be a follow-up.
@github-actions

Copy link
Copy Markdown

Build size

main (0100083) → spaces-reading (8ea7664)

app base PR change
bento/slides 677.9 KiB 677.9 KiB 0.0 KiB (0.00%)
bento/spaces 271.3 KiB 273.7 KiB +2.4 KiB (+0.89%)
bento/dash 424.1 KiB 424.1 KiB 0.0 KiB (0.00%)

Updated: 2026-09-10T00:19:06Z

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