Skip to content

feat(bolt-slides): rebuild the slides starter on Postgres - #125

Closed
geotrev wants to merge 30 commits into
mainfrom
geotrev/bou-2538-slides-v2-cloud
Closed

feat(bolt-slides): rebuild the slides starter on Postgres#125
geotrev wants to merge 30 commits into
mainfrom
geotrev/bou-2538-slides-v2-cloud

Conversation

@geotrev

@geotrev geotrev commented Aug 21, 2026

Copy link
Copy Markdown

Summary

Replaces bolt-slides with Donald's slides v2 prototype, re-modernized, and moves the deck out of the project and into the project's database.

The prototype kept a deck in data/deck.json behind dev-server middleware. That gave the app two answers to "what is in this deck" — the file and, for anyone who had ported to a backend, the tables — and it meant publishing could only ever ship a snapshot. A deck now lives in Postgres, reached through one Edge Function, and there is no local copy: the editor, the presenter console and the published deck read the same rows.

Why the rules live in the function. The anon key ships inside every published deck, so anyone the deck is shared with can call the API with whatever headers they like. The tables have row level security enabled with no policies at all — that is the model, not an omission — and every read and write goes through the function's service role. Speaker notes are never sent to the audience view rather than hidden in it.

Why editing survives publishing. The editing credential is the deck's own owner_key, handed to the app only while the dev server serves it and deliberately not prefixed VITE_, so vite build never defines it. A published deck is keyless by construction; sharing the editor is what an edit link is for.

Also here: share links are built on the published address (the published site reports its own Origin, custom domains included), a project with no database says so instead of showing an empty editor, and the React 19 bug that made typed slide text flash and revert is fixed — dangerouslySetInnerHTML is compared by object identity, so a fresh object each render rewrote the field being typed into.

The agent instructions were the origin of the two-truths problem ("one file of truth — or the project's cloud backend", then a storage branch), so they are rewritten cloud-only, including an explicit rule not to add an anon policy — Bolt's own database guidance says to, and here that would hand the audience the notes.

Test plan

  • npx vitest run test/bolt-slides.test.ts test/bolt-slides-deck.test.ts test/bolt-slides-client.test.ts — 48 green across Chromium and Firefox
  • Route and schema rules exercised against real Postgres (pglite): per-mode permissions, notes never reaching the audience, passwords, throttling, rotation, slide ordering, publish-URL capture, preflight, and anon reaching nothing while holding every table privilege
  • Locally end to end against a pglite-backed stand-in: agent import appears without a reload, typing persists across a reload, presenting, audience link without notes, share links on the published address
  • Published build (vite build + preview): presents the live deck, an edit shows up on it, the published editor cannot edit and its write is refused, an edit link makes it editable, a locked link refuses the wrong password, a link-only deck shows a stranger nothing
  • npm run typecheck, npm run format:check, npm run package:check
  • No owner key in any published bundle
  • On a real Bolt project (needs the pin): first prompt provisions a database, applies the schema and deploys the function; Vite picks up the .env Bolt rewrites mid-session; DECK_OWNER_KEY survives those rewrites; the deploy build inlines VITE_SUPABASE_* so the published deck reaches the function

Not user-visible until the pin moves

Nothing ships until templates.json in bolt points at a commit from this branch, so rollback is the current pin.

geotrev added 27 commits August 19, 2026 14:28
Swaps the JSX-authored deck template for Donald's slides v2 studio
(github.com/inkko44/bolt-slides-starter), where a deck is data — layout
plus JSON props rendered through a layout registry — with an editor,
presenter mode, and PDF/OG export.

Imported as-is; the toolchain modernization from #121 is re-applied in
the following commit.

Excluded from the import:
- .bolt/mcp.json — prototype-local MCP servers (linear, notion, mobbin)
  that would be enabled for every user scaffolding this template
- data/allhands.json — an internal Bolt all-hands deck, unreferenced by
  any code and not appropriate for a public repo
- data/deck.json — generated at runtime from data/deck.seed.json
Carries the modernization Chris landed on the v1 template (#121) forward
onto slides v2, which was written against the older scaffold.

- React 18.3 -> 19.2, Vite 5.4 -> 8.2, TypeScript 5.5 -> 6.0,
  @vitejs/plugin-react 4 -> 6, framer-motion ^11 -> motion ^12
- build script typechecks first: tsc -b && vite build
- tsconfig split into project references, with the @/* -> src/* path
  alias and vite's resolve.tsconfigPaths to match

tsconfig.node.json additionally sets allowJs, which #121 did not need:
v2's vite.config.ts imports server/api.mjs, and without it tsc -b fails
with TS7016 on that untyped import.

The v2-only dependencies (@dnd-kit, zustand, jspdf, html-to-image) stay
at their current versions -- all are latest and declare open React peer
ranges, so React 19 needed no changes there.

tsc -b and vite build are both clean.
Rewrites all 200 relative specifiers under src/ (56 files) to the @/
alias configured in the previous commit. 133 of them were ../-style.

This is the change #116 made to bolt-vite-react-ts for a reason: deep
relative paths are a known source of miscounted-depth import errors in
agent-generated code, and v2's tree (data/, deck/, edit/, export/,
layouts/, present/, slide/) is deeper than v1's was.

Specifiers resolving outside src/ were left alone. tsc -b and vite build
are clean.
v2's API is Vite dev-server middleware (server/api.mjs). Publishing a deck
produces a static bundle where that middleware does not exist, so the app
sat on "Loading deck…" forever waiting on /api/state. v1 had no API and no
such problem; without this, publishing a deck — the whole point of making
one — regresses.

The build now bakes the deck into the bundle as deck-snapshot.json, and the
client falls back to it when there is no API, entering read-only present
mode. EditorApp already redirects to /present whenever mode !== 'edit', so
no new UI is needed.

Deciding "there is no API" needs care, because hosts disagree about paths
that do not exist: Bolt's static deploy writes SPA _redirects, so Netlify
answers /api/state with index.html and a 200, while a bare file server 404s.
Both fall back. A 401/403 explicitly does not — that is the API answering,
and falling back there would serve a gated deck to someone it just refused.

The snapshot carries only what renders: deck meta, slides, and speaker
notes. Review state (status, assignee), comments, profiles, and share rows
are stripped, so no share token, password hash, or internal review comment
can ride along to an unauthenticated public URL.

Speaker notes are public in a published deck. Making them private needs a
backend that can enforce it — that is the cloud follow-up, docs/cloud-setup.md.

Also drops the API from the preview server: `npm run preview` serves the
production build, and mounting the API there made preview the one command
that hid this bug.

Verified against both host behaviours (SPA-rewrite and 404) with a populated
deck: snapshot loads, ordering holds, notes survive, nothing sensitive leaks.
Every doc in the prototype told the reader that publishing is impossible
until they port to a cloud backend. That was true of the prototype and is
no longer true, and the agent reads SKILL.md on every deck request — left
alone it would talk users out of publishing a deck that now works.

- SKILL.md: publishing works read-only; publish after authoring, and
  speaker notes are public once published
- README: replace the "sits on a loading screen" section with what a
  published deck actually does, and note preview now shows the same
- cloud-setup.md: reframe from "required before you publish" to "required
  when a published deck must be live" — editing, comments, share links,
  passwords, private notes. Its checklist also said to delete server/*.mjs
  after porting, which now breaks the build unless the vite plugins that
  import it go too.

Restores .bolt/prompt, which v2 did not carry. It keeps #121's @/ alias and
Vite 8 notes and adds two v2-specific ones: author decks as JSON rather than
JSX, and do not "fix" the absent published API by deleting the snapshot
fallback.
The modernization commit made build `tsc -b && vite build`, copying
vite-shadcn. That is the wrong model for this template: templates.json
gives bolt-slides `"build": "vite"`, and framework-detection resolves
vite-react to `npx vite build`, so Bolt's deploy never runs the npm
script. Gating it on tsc only risks a stray type error blocking a local
build while changing nothing about publishing.

Matches bolt-vite-react-ts, which builds with plain `vite build` and keeps
typechecking as its own script. `npm run typecheck` still runs `tsc -b`,
which covers vite.config.ts as well as src.

cloud-setup.md's checklist said to build with the npm script "because that
is what the deploy runs" — it is not; corrected to `npx vite build`.
Two WebContainer tests, matching the shape of the other template tests.

The build test is the one that matters: it imports a deck through the CLI,
builds, and asserts the published snapshot actually carries that deck —
right slides, notes intact for presenter view, and no status, comments or
profiles, which must never reach an unauthenticated URL. Publishing breaks
silently when the snapshot goes missing, so it is worth a test.

The second test covers the authoring loop end to end: CLI import, dev
server, slide rendered in the editor.

Both live in one file with a single build, deliberately. A second
WebContainer in the same file cannot spawn binaries out of node_modules/.bin
once the first has built — `jsh: spawn vite EACCES` — which is why the
sibling test files also build exactly once.
The prototype was written to its own style (no semicolons, 100 columns).
Mechanical prettier pass so `npm run format:check` passes in CI — no
behaviour change. Kept as its own commit so it does not bury the rest.
Importing is how a deck gets authored, and it happens while the editor is
open. Nothing in the bundle imports data/deck.json, so the import produced
no HMR and the page kept rendering the old slides — the agent would report
slides it had added and none of them appeared.

The dev server now watches the deck file and asks the client to re-fetch.
Writes it made itself are skipped: the editor updates local state before
the request lands, so re-fetching on its own saves would race whatever the
user is still typing.
Two files were both called deck.json: the portable deck handed to the CLI
and the live database the app reads. The skill said the deck "persists to
data/deck.json" and then said to "write deck.json", so the agent treated
them as one thing and kept both in sync by hand — editing the database
directly, which skips the row fields the app depends on.

The authored file is now deck.draft.json, and a hard rule states that
data/deck.json is generated and reachable only through the CLI. The import
step is also spelled out as the thing that changes the deck: writing the
draft alone left decks empty while the agent reported slides as added.
A fresh scaffold rendered nothing until an import ran, so any hiccup in
authoring was indistinguishable from a broken template — the user saw a
blank presentation and no way to tell why.

The seed now explains what the project is and how to ask for a real deck,
so the first paint is always a working deck. It doubles as the fallback for
a build with no import, which previously snapshotted zero slides.
persistNow already claimed a stale copy must never clobber a newer write
from another process, but nothing enforced it. With an editor save queued
on its 250ms debounce, an import landing in that window was flushed away:
the CLI reported success, data/deck.json reverted to the server's cached
deck, and the slides never appeared. openDb made it worse by refusing to
reload while anything was unsaved, so the API kept serving the stale deck.

Both paths now yield to a newer file. An import is an explicit replace, so
it outranks a cached copy — at the cost of an edit made in the last 250ms,
which beats discarding the whole imported deck.
…port

Authoring a deck is two steps — write deck.draft.json, then import it — and
the second is the one that gets dropped, leaving the app rendering a deck the
draft has moved on from. Most visibly on the first prompt, where the result is
a deck that looks empty, but it is not specific to it.

A draft is now applied on its own: from `npm run predev` before the server
boots, and from the dev server's watcher if it appears after. Which one fires
is a race with npm install, so both are needed.

The deck records which draft content it already reflects, so a draft applies
once per distinct content and a restart never re-applies one. Editing in the
app does not change the draft, so work done since is what survives. That
record is content, not an mtime: mtimes do not survive a project being stored
and materialized again, so comparing them could re-apply a stale draft on
every open. Everything that brings deck and draft into agreement — importing
it, exporting over it — records it too, as does replacing the deck with
something else, since a restart must not overrule that choice.

Applying a draft repeatedly is only safe because importing is no longer a
wholesale replace: slide ids now travel through the draft format, so a slide
that carries one keeps its identity and its comments. Comments are dropped
only when the slide they were written on is actually gone.
Editing a slide did nothing: the typed text showed for a frame and snapped
back, with nothing logged. Slide text is contenteditable, so keystrokes only
reach the deck on blur and are committed by slide id — and the browser was
left holding ids the deck no longer had.

Two things put it there, both on the path the first prompt takes. The watcher
ignored any write landing within a second of the server's own save, which is
exactly where the skill's import lands, so the browser was never told. And
every import minted fresh ids for slides authored without them, which is
every slide the skill writes, so a missed import was fatal rather than
cosmetic.

The watcher now compares the file it last wrote instead of guessing from
elapsed time, a slide authored without an id inherits the one already in its
position, and a write to an unknown slide says so and re-reads the deck
rather than returning in silence.

The dev-server test moves to its own file: a second Vite process in the same
file cannot start.
Visual editing did nothing: a character appeared and was gone a millisecond
later, and formatting the same. The field hands its content to React as an
object, and React 19 re-applies it whenever that object is not the one it saw
last — by identity, not by comparing the HTML inside it. React 18 compared the
string, so building the object inline each render used to be free; now it
rewrites the field on every render, and typing renders constantly because the
caret moves and the format bar follows it.

Holding the object still until the value really changes is what lets the
browser own the field while it is being edited. Correct under either version:
19 skips the write because the reference is unchanged, 18 because the string
is.
Present and the presenter console opened a second tab, which a WebContainer
preview URL cannot survive: only the tab connected to the project can resolve
it, so presenting from a Bolt preview landed on "connect to project" and then
nothing (webcontainer-core#1087, open since 2023 and broken in every browser).
Slides v1 never left the page, which is why this is a regression.

A window is still right when the app owns its tab — the deck goes up on the
projector while the editor stays put — so the choice lives in one place and the
frame decides. Presenting in place needs a way back, hence the dock's exit for
anyone who has an editor to return to, and a flush of pending saves before
leaving, since text fields commit on blur into a debounce that the navigation
was racing.
A share link built from the address the editor runs on opens for nobody
else: a preview URL belongs to the one tab connected to the project, and
localhost belongs to one machine. So the deck records where it was
published and every link is built on that, with the Share button disabled
until it knows.

What a published deck can enforce is nothing — it is a static build with no
server — so the dialog offers a public presentation link and a presenter
link that says outright that the notes in it are public, rather than the
per-mode links and passwords only a backend can check.
Starting point for the database-first rebuild. This is inkko44/bolt-slides-starter
at 8d19aa1 verbatim, minus its .bolt/mcp.json: that file enables Linear, Notion
and Mobbin for whoever opens it, which is the prototype author's setup and not
something a slides template should impose.

Everything the starter grew around the JSON file goes with it — draft adoption,
the build-time snapshot, the embed check — because all of it existed to make a
file behave like a database. The file layer itself stays for now so the app runs
at every step; it comes out once Postgres is behind it.
React 19.2, Vite 8.2, TypeScript 6, plugin-react 6, and framer-motion 11 →
motion 12, which is the same package under the name it ships as now. Two
mechanical rewrites came with it: the motion import specifier, and 133 `../`
imports moved onto the `@/` alias so a file can be moved without rewriting its
imports. The alias resolves through Vite 8's built-in `resolve.tsconfigPaths`,
reading the paths already declared for the compiler.

Typechecking is `tsc -b` over app and node projects, both strict — the previous
pass through this dropped `strict` from the app project, and nothing needed it
dropped: the tree checks clean with it on.

Verified: typecheck, build, and the editor, present and presenter routes render
a 16-slide deck with no console errors.
Postgres becomes the only place a deck exists. The tables mirror what the JSON
file held — deck, slides, shares, grants, unlock attempts — minus comments and
profiles, which the prototype's own editor had already stopped showing.

Two things the file version could not have. `import_deck(jsonb)` replaces a deck
in one statement while keeping slide identity, by id where the incoming deck
names one and by position otherwise, so re-importing an edited deck is a change
rather than a wholesale replacement; its return value reports the slide count,
which is the verification that the import landed. And a `version` counter bumped
by triggers on every write, which is how an open editor will notice edits made
by the agent or another window — the job the filesystem watcher used to do badly.

Row level security is on with no policies at all, which is the security model
rather than an oversight: the anon key in a published bundle can reach nothing,
so notes cannot be read and slides cannot be rewritten except through the
function that holds the service role.

It lives at supabase/schema.sql rather than supabase/migrations/ deliberately.
Bolt denies agent writes into that directory and tells the agent that files
there do nothing, so a file sitting in it reads as history that has already been
applied. This one has to be applied, and the skill will say so.

Verified on Postgres via pglite: applies, re-applies, refuses a second deck row,
keeps slide ids across re-import, deletes only unclaimed slides, round-trips
export → import unchanged, bumps the version on every write, and — holding every
table privilege Supabase grants anon — reads zero rows and changes nothing.
Every read and write now goes through one Supabase Edge Function holding the
service role, because the rules worth having cannot be enforced anywhere else.
Bolt's own database skill would have the frontend talk to Postgres directly with
the anon key, which for a deck app means the key sitting in a published bundle
lets any visitor rewrite the presentation and read the speaker notes. So the
audience view is never sent the notes rather than being trusted to hide them, a
presenter link may write notes and nothing else, and a revoked link is dead
everywhere including for the owner.

Sharing is real rather than decorative now that a server checks it: 96-bit
tokens, one link per mode, optional passwords hashed with PBKDF2 and gated by a
per-address lockout, and grants so a visitor is asked once. Changing a password
or rotating a link turns out whoever was already inside.

The published deck reports its own address. Browsers set `Origin` and page
scripts cannot forge it, so the first load of the published site records where it
lives — custom domains included — and only ever fills a blank, so a passing site
cannot steal it. That is the base every shareable link is built on, since the
preview address opens for nobody but the tab it belongs to.

Two structural choices. `/state` reports what the caller may do instead of
letting the client infer it from the headers it happens to hold, so a revoked
link shows the truth rather than an editor whose every save fails. And routing
and rules reach Postgres through a one-method adapter, which is what lets the
real handlers run against an in-process Postgres in tests.

Verified: 20 checks against pglite covering owner, public, link-only, presenter
and audience access; notes stripping in both state and export; passwords,
lockout, rotation and grant invalidation; slide add/patch/duplicate/reorder/
delete with positions renumbered; publish-URL capture and correction; and the
CORS preflight listing every header the app sends.
The deck was a JSON file served by dev-server middleware, so the app had two
answers to what is in a deck and could only publish a snapshot of one of them.
It now reads and writes Postgres through the deck function, and there is no
local copy at all: the editor, the presenter console and the published deck are
looking at the same rows.

That removes the file layer outright — server/, scripts/deck.mjs and data/ —
along with the HMR watcher that told an open editor the file had changed. In its
place the editor polls the deck's version, so an import the agent makes appears
within a few seconds without a reload, and never over a focused field or unsent
typing.

Two states the app could not previously describe now have a screen. A project
with no database yet says so and says that asking Bolt for one fixes it, rather
than showing an empty editor that looks like a deck with no slides. Bolt's
placeholder credentials count as no database, because treating them as one means
spending the first load talking to a project that does not exist and calling the
silence a network error.

The editing credential is the deck's own owner_key, handed to the app only while
the dev server is serving it. It is deliberately not prefixed VITE_, so
`vite build` never defines it and a published deck is keyless by construction.
A share link built on the address the editor runs on opens for nobody but the
person who made it, so Share now builds links on where the project is published
and says plainly that publishing comes first when it is not yet known.

The published site reports its own address: the browser sets Origin and page
scripts cannot forge it, so the first load tells the deck where it lives, custom
domains included. Preview and localhost origins are skipped, and only a blank is
ever filled, so a passing site cannot take over a deck's sharing address. The
owner can correct it by hand, and choose whether the published address shows the
deck to anyone or only to whoever holds a link.
Slide text was rendered with dangerouslySetInnerHTML, which React 19 compares by
object identity — and the prop was a fresh object on every render. Every render
therefore rewrote the element's DOM from the stored value, including the renders
that typing itself causes (onInput updating the empty state, the caret moving
updating the format bar), so a keystroke appeared and was immediately replaced by
the text as it stood before the edit.

The value is written into the element by hand instead, before paint, only when it
differs, and never while the field has focus — which also means an edit arriving
from the agent or the presenter window cannot delete a half-typed sentence.
The skill opened by calling data/deck.json "one file of truth — or the project's
cloud backend", then branched on which storage the project was using. That
sentence is where the two-truths problem came from: an agent following it can
author a deck into a file that the app no longer reads.

There is no branch now. Step 0 is applying the schema and deploying the function,
authoring is one import whose return value is the verification, and reading a
deck back before editing it has a path again now that the CLI is gone.

Three rules are new because nothing else would supply them. Never write a deck to
a file, since a second copy is a second answer rather than a backup. Never add a
row level security policy, which Bolt's own database guidance would otherwise
have the agent do — the anon key ships inside the published deck, so a policy for
anon hands the audience the speaker notes and a way to rewrite the deck. And when
there is no database, say so instead of improvising somewhere to put the slides.

.bolt/prompt is new, carrying the same rules for prompts that never load the
skill, plus the @/ alias the re-modernization added.
The suite tested the architecture that was just deleted: a snapshot in dist, the
draft applied by predev, and the CLI's round trip. What replaces it runs the real
route handlers against a real Postgres — pglite, in the browser — so the rules
are checked where they are enforced. They cannot be checked from the app: the
anon key ships inside every published deck, so anyone holding it can call the
function with whatever headers they like, and a rule that lives in the editor's
UI is not a rule.

That covers the schema (re-runnable, one deck row, slide identity across an
import, the version trigger, and row level security with no policies leaving anon
with nothing while holding every privilege) and the function (each mode's
permissions, notes never reaching the audience, passwords, throttling, rotation,
slide ordering, publish-URL capture, and the preflight).

Two smaller suites sit either side: what the client does with no database,
placeholder credentials, or a function that does not answer; and a WebContainer
check that the template builds, typechecks, carries no owner key into the bundle,
and still ships the two files the agent's tools look for by path.
The README described a portable JSON file, a CLI, comments, a generated OG image
and a publish step that could not work, and cloud-setup.md was a guide for a port
that has now happened — a document whose whole premise is that the reader must
decide something already decided.

The README is cloud-first, and the port guide becomes docs/architecture.md: where
a deck is kept, the route contract, who may do what, and why each rule sits where
it does rather than somewhere more convenient. The owner question it used to pose
has an answer, so it records that answer instead of the options.
@bolt-new-by-stackblitz

Copy link
Copy Markdown

Review PR in StackBlitz Codeflow Run & review this pull request in StackBlitz Codeflow.

Step 0 had the agent apply the schema and deploy the function, and stopped
there. The key the migration generates was never read out of the deck row or
written into .env, so the app held no credential the function recognised: it
answered every request as an ordinary visitor, and the first prompt returned a
deck that could only be presented.
Both the database and the deck's key are written into .env after the dev server
is already running, so a value captured when it booted is stale exactly when it
matters. Vite restarts itself when it sees .env change and would pick them up
that way, but that is a file watch event inside WebContainer, and neither Bolt
nor the agent can restart the server if it never arrives. The dev server now
answers for them on a route it reads .env for per request, which a build has no
trace of — the published deck stays keyless by construction, and the build test
now proves it with a key planted in .env rather than by the absence of a name.
The editor sent anyone it could not identify to /present, which is right for a
share link that says "present" and a dead end for the project's own editor: /
redirected straight back, so a deck whose key was missing looked like an app
stuck in present mode with no way out and nothing to act on. It now says which
piece is missing, offers the deck to present meanwhile, and watches for the key
arriving — as the no-database screen now retries a database being created.
@geotrev geotrev closed this Aug 21, 2026
@geotrev
geotrev deleted the geotrev/bou-2538-slides-v2-cloud branch August 21, 2026 16:58
@geotrev
geotrev restored the geotrev/bou-2538-slides-v2-cloud branch August 21, 2026 17:15
@geotrev
geotrev deleted the geotrev/bou-2538-slides-v2-cloud branch August 21, 2026 17:15
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