feat(bolt-slides): rebuild the slides starter on Postgres - #125
Closed
geotrev wants to merge 30 commits into
Closed
Conversation
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.
|
|
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Replaces
bolt-slideswith 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.jsonbehind 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 prefixedVITE_, sovite buildnever defines it. A published deck is keyless by construction; sharing the editor is what aneditlink 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 —dangerouslySetInnerHTMLis 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
anonpolicy — 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 Firefoxvite build+ preview): presents the live deck, an edit shows up on it, the published editor cannot edit and its write is refused, aneditlink makes it editable, a locked link refuses the wrong password, alink-only deck shows a stranger nothingnpm run typecheck,npm run format:check,npm run package:check.envBolt rewrites mid-session;DECK_OWNER_KEYsurvives those rewrites; the deploy build inlinesVITE_SUPABASE_*so the published deck reaches the functionNot user-visible until the pin moves
Nothing ships until
templates.jsoninboltpoints at a commit from this branch, so rollback is the current pin.