diff --git a/bolt-slides/.bolt/prompt b/bolt-slides/.bolt/prompt index ba2f25bc..0980a305 100644 --- a/bolt-slides/.bolt/prompt +++ b/bolt-slides/.bolt/prompt @@ -1,3 +1,7 @@ -Import project modules with the `@/` path alias, which maps to `src/` (e.g. `@/components/Foo` == `src/components/Foo`), instead of deep relative paths like `../../components/Foo`. +This project is a finished slide studio, not a starting point to build one. A deck request is a request for content: use the `slides` skill, which carries the deck format and the layout catalog, and author into the deck rather than writing components. -This template uses Vite 8. `resolve.tsconfigPaths: true` is a valid built-in Vite option. Preserve it for the `@/` alias; do not claim it is invalid or add the `vite-tsconfig-paths` plugin. +The deck is rows in this project's Postgres database, reached through the `deck` Edge Function in `supabase/functions/`. That is the only copy. Never write slides to a file, never add a dev-server route that serves them, and if the project has no database yet, say that it needs one instead of improvising somewhere to put the deck. + +The tables carry no row level security policies on purpose — the anon key ships inside the published deck, so anything `anon` may read is readable by everyone the deck is shared with, speaker notes included. All access goes through the function, which holds the service role. Adding a policy is not the fix for a permission problem here. + +Import project modules with the `@/` path alias, which maps to `src/` (e.g. `@/data/store` == `src/data/store`), instead of deep relative paths like `../../data/store`. diff --git a/bolt-slides/.bolt/skills/slides/SKILL.md b/bolt-slides/.bolt/skills/slides/SKILL.md index 99b6e5bb..da632e65 100644 --- a/bolt-slides/.bolt/skills/slides/SKILL.md +++ b/bolt-slides/.bolt/skills/slides/SKILL.md @@ -1,318 +1,333 @@ --- name: slides description: >- - Build a premium PRESENTED slide deck — classic one-slide-at-a-time slides you - advance with a clicker — as a Vite + React app. It keeps the Slidev navigation UI - (floating glass dock + thumbnail rail + click-builds + presenter) but slides are - RESPONSIVE React (reflow to any screen, no fixed canvas) and fully interactive. - Use this for a deck you'll PRESENT (talk over, projector, screen-share) when you - want web interactivity and responsiveness without Slidev's constraints. The repo - IS the running app (Vite + React) — you install it in place, theme the tokens, and - AUTHOR an original deck from the user's real topic/brand. + Author a premium slide deck INTO the Bolt Slides editor — a Pitch-style + slide builder (this repo) where every slide is a row in the project's + Postgres database. You author the deck as JSON (layouts, copy, + backgrounds, animation and transition choices, statuses, speaker notes) + and import it with one SQL call; the user then edits any of it in place, + presents full-screen, exports a PDF, and shares links to the published + deck. Use this whenever the user asks for a deck, a pitch, slides, or a + presentation in this project. --- -# Slides — a premium, responsive, React presentation engine for Bolt - -Classic **paged** slides (advance one at a time, present over them) — but rebuilt in -**Vite + React** so each slide is a **responsive web layout** instead of a fixed -1080×607 canvas that clips. It keeps the Slidev UI you liked (the floating dock + -thumbnail rail + click-builds + presenter) and adds real web interactivity. - -Two halves — keep them separate: - -- **The engine + UI are pre-built** — this repo is a complete, runnable app: the paged - engine (`src/deck/`), the dock/rail chrome, the section components (`src/components/`), - the shared CSS. **Leave the engine as-is. Never regenerate it.** This is the part you - liked; it must look/behave identically. -- **The content is authored fresh, every time** — the slides (topic, structure, copy, - visuals, theme) are designed from scratch for *this* request. - -> **Theme surface.** All color, type, radius, depth, and motion live in the `:root` -> token vocabulary of `src/styles/tokens.css`. Theme a brand once, there. - -## ⛔ Three hard rules - -1. **Don't touch the engine** — leave `src/deck/` (the engine + chrome: `Deck`, `Slide`, - `Build`, `Reveal`, `DeckContext`, `icons`, `Annotator`, `useInView`) untouched. Don't - rewrite or paraphrase the engine or the chrome; author *around* it. -2. **Author the deck from the user's REAL input — do not reskin the starter.** - `src/App.tsx` is a throwaway that only proves it runs. **Delete its slides and - write a new deck.** Never reuse its order, copy, placeholders, or fake names. -3. **Center what stands alone.** Ask of every slide: *does it have a side visual* - (a `Split` media panel, an image, a `BrowserFrame`, a chart beside text)? - - **No side visual** (only text, or one structured block like `Comparison` / - `Tabs` / `Timeline` / `Accordion` / `StatGrid`) → the slide MUST be centered: - use ``, or a `textAlign:'center'` heading with - `marginInline:'auto'` on every block below it. - - **Yes** → left-aligned/asymmetric is allowed; the visual balances the text. - A lone left-anchored block floating in empty space is the #1 alignment bug — - never ship one. (The structured blocks self-center with built-in max-widths as - a safety net, but headings/paragraphs around them are YOUR job to center.) - ---- - -## Step 0 — Ground the deck in the user's real input - -Use the user's real topic, brand, document, facts. Never fabricate a placeholder -company, logo, or quote for a real subject. If a URL/brand is given, the theme comes -from that brand — fetch the page for real colors/font/logo, or use the brand's known -palette, or **STOP and ask**. Report which colors/fonts you used and where from. - ---- - -## Step 1 — Run it in place (the repo is the app) - -The repo root is already a complete Vite + React app — no scaffolding or copying. Its -layout: - +# Slides — prompt decks into an editable, presentable app + +This repo is a complete slide **studio**, already built and running: + +- `/` — the editor: persistent thumbnail rail (drag to reorder, right-click to + duplicate/delete), click-to-edit text on the slide itself, an inspector for + every layout prop, background and scale, plus speaker notes. +- `/present` — the premium presentation engine: floating dock, side panel (S) + and grid overview (G), click-builds, presenter view (P), annotator (D), + fullscreen (F). +- **The deck is rows in Postgres**, reached through one Edge Function + (`supabase/functions/deck`). That is the only copy. Nothing persists to a + file, no file makes the deck travel, and a deck that is not in the database + does not exist. + +**Your job is to author CONTENT, not code.** A deck is data: you write deck +JSON and import it into the database. You never write JSX slides. + +## ⛔ Hard rules + +1. **Don't touch the app — you only generate slides.** The editor UI, the + presentation engine, the layouts, the database schema and the deck function + are the finished product: never rewrite, "improve", restyle, or regenerate + any of it. Off-limits, no exceptions: `src/edit/`, `src/deck/`, + `src/present/`, `src/slide/`, `src/layouts/`, `src/components/`, + `src/styles/base.css`, `src/styles/editor.css`, `supabase/`, `index.html`, + `vite.config.ts`. + That includes every piece of app chrome — the editor's top bar, thumbnail + sidebar, inspector and modals, and present mode's dock, side panel, grid + overview, presenter view and annotator. A deck request that mentions colors, + fonts, sizes or spacing is about the DECK (deck `accent` + `font`, or + `tokens.css`), never about the UI. The ONLY things you touch: + `src/styles/tokens.css` (`:root` values, to theme the deck) and the deck in + the database. If the user genuinely wants an app change, say so and let them + ask for it outside this skill. +2. **Author from the user's REAL input.** Their topic, brand, facts, numbers. + Never reskin the seed deck; never invent a placeholder company for a real + subject. Brand given → derive theme colors/fonts from it (fetch the site or + use its known palette) and say what you used. +3. **The accent is ONE SOLID color.** `--accent` must be a solid hex — never a + gradient. Keep restraint: one accent, used sparingly. +4. **Never write a deck to a file.** No `deck.json` in the project, no seed + file, no `data/` directory, and never a dev-server route that serves slides. + The database is the deck. A second place to keep slides is not a backup, it + is a second answer to "what is in this deck", and the app will show the + user whichever one they did not mean. Write your deck JSON to a scratch path + outside the project (`/tmp`) if you want to look at it, and import it. +5. **This project's tables get NO row level security policies — leave them + that way.** Bolt's database guidance says to enable RLS and add policies for + `anon`; here, the policies are deliberately absent and the schema comment + says so. The anon key ships inside the published deck, so a policy that lets + `anon` read the tables lets anyone who opens the deck read the speaker notes, + and one that lets it write lets them rewrite the deck. Everything goes + through the `deck` function, which holds the service role and checks who is + asking. If you find yourself adding a policy to make something work, the + thing to fix is the function. +6. **No database means stop, not improvise.** If the project has no Supabase + project, or you have no Supabase tools in this session, say plainly that the + deck needs a database and that asking Bolt for one fixes it — the same thing + the app's own screen says. Do not author into a file "for now": there is no + later step that would pick it up. + +## Step 0 · make sure the deck has somewhere to live + +Do this before authoring anything, and skip whatever is already done — every +step is safe to repeat. + +1. **Apply the schema** — pass the contents of `supabase/schema.sql` to + `mcp__supabase__apply_migration` (name it `deck_schema`). It is written to be + re-runnable, which matters because the tool can time out after the SQL has + already committed. Do not edit the file, and do not hand-write migrations + into `supabase/migrations/`. +2. **Deploy the function** — `mcp__supabase__deploy_edge_function` with slug + `deck`, from `supabase/functions/deck/`. Nothing to configure: it reads the + database URL Supabase already gives it. +3. **Give the editor its key** — `mcp__supabase__execute_sql` with + `select owner_key from deck;`, then put that value in `.env` as + `DECK_OWNER_KEY=` (edit the file; leave every other line alone). + +Do not skip step 3 or leave it for the user. The key is what proves a request +may edit the deck: without it the app can only present, so the person who asked +for a deck gets a presentation of it with no way into the editor. Nothing else +supplies the key — it is generated per deck by the migration, so it is not in +`.env.example`, not in any prompt, and not guessable. The dev server reads +`.env` per request, so the editor picks the key up on its own within a few +seconds; the dev server does not need restarting, and must not be restarted. + +Publishing and sharing then work as the starter's own features — the published +deck reads the same database, so a share link opens a live deck rather than a +copy of one. There is no port to decide on and no local mode to escape. + +## Workflow + +```bash +npm install && npm run dev # 1 · the studio runs at :5173 ``` -package.json vite.config.ts tsconfig*.json index.html src/main.tsx -src/App.tsx ← THROWAWAY. delete its slides; author the real deck. -src/styles/ tokens.css (edit :root ONLY) · base.css ← don't edit base.css -src/deck/ Deck Slide Build Reveal DeckContext useInView icons Annotator ← engine + UI. LOCKED. -src/components/ Cover BigNumber Contrast Chat Bento Split StatGrid Section ← slide layouts - Quote Pricing Steps Agenda Team - CountUp TiltCard Marquee VisualDashboard Accordion Comparison - Tabs Timeline CodeWindow BrowserFrame SpotlightCard Charts -``` - -`npm install && npm run dev` runs the deck at `/`. Verify the dock / thumbnail rail / -click-builds work, then delete the starter slides and author the real deck in -`App.tsx`. ---- - -## Step 2 — Theme it (edit only the `:root` block) - -All color, type, radius, depth, motion live in `src/styles/tokens.css` `:root`. -**Change values, never variable names.** Nine ready-made theme families to pull from -(dark product, editorial luxury, Swiss, dark technical, warm minimal, -fintech, aurora glass, cinematic, paper editorial). One accent, used sparingly. -Dark vs light: set `html { color-scheme }` in base.css and pick `--bg`/`--fg` -accordingly. Set fonts in `--font-head`/`--font-body` and the `@import` at the top -of `base.css`. Derive from the brand when given. - -**Tab title + icon — always, unprompted.** Shared decks show the browser tab, so -never leave the `index.html` placeholders: set `` to the deck's real title -(e.g. "Acme — Series A") and swap the emoji in the favicon `<link>` to one that -fits the topic. Do this for every deck without being asked. - ---- +2. Theme: edit ONLY the `:root` values in `src/styles/tokens.css`. +3. Author the deck as JSON (format below). +4. Import it with `mcp__supabase__execute_sql`: -## Step 3 — Author slides (each child of `<Deck>` is one slide) - -Compose slides in `App.tsx`. The building blocks: - -- **`<Slide>`** — one slide. `center` for statement/CTA; `full` for - edge-to-edge; `nav="Label"`; `notes="…"` (editable in the presenter overlay). -- **`<Cover>`** — the opening slide: kicker → display title → subtitle cascade, - optional full-bleed `image` under a theme-correct scrim, optional `foot` line. -- **`<BigNumber>`** — ONE enormous accent figure (pass a `<CountUp>`) + caption. - Every deck should have one of these drama beats. -- **`<Contrast>`** — before/after, problem→solution: a muted panel vs an - accent-lit panel with cross/check points. The classic pitch move. -- **`<Chat>`** — a chat window whose messages reveal one per click (builds) — - user bubbles on the accent, assistant on the surface. For AI-product decks. -- **`<Globe>`** — a rotating, drag-to-spin 3D dotted globe (hand-built canvas, - zero deps): accent markers at real `[lat, lng]` coordinates, optional `label` - + `value` chips that ride their marker and fade behind the globe, optional - `arcs={[{from, to}]}` great-circle connections, and stat rows beside it. - Colors follow the tokens automatically. Label a FEW hero markers, not all. -- **`<Split>`** — text + edge-to-edge media (`flip` swaps). media = `<img>`, a color - panel, a `<BrowserFrame>`, or `<TiltCard><VisualX/></TiltCard>`. -- **`<Bento>`** — asymmetric tile grid; tiles take `c`/`r` spans + `variant`. -- **`<StatGrid>`** — responsive proof cards; pass a `<CountUp>` as a stat `value`. -- **`<Section>`** — chapter divider: ghost number + accent glows. The full-bleed - breather for decks without photography; use between parts. -- **`<Agenda>`** — numbered table-of-contents rows (strings, or `{title, hint}`). -- **`<Steps>`** — horizontal numbered process; the connector draws in. Use for - "how it works" instead of a bulleted list. -- **`<Pricing>`** — 2–4 tier cards; `highlight: true` crowns one with a badge. -- **`<Team>`** — people grid; photos via `img`, else auto-initials on the accent. -- **`<Quote>`** — pull-quote slide with attribution (don't add quotation marks — - the accent mark provides them). -- **`<Comparison>`** — us-vs-them feature matrix; one column highlighted in the accent. -- **`<Table>`** — a real data table: uppercase ruled header, right-aligned tabular - numerals, optional `highlightCol`/`highlightRow`, `caption` for the source. - Use for actual data (pricing tiers → `Pricing`, feature ticks → `Comparison`). - Keep ≤5 columns / ≤7 rows — a paged slide can't scroll. -- **`<Tabs>`** — tabbed content with a sliding accent pill. -- **`<Accordion>`** — expand/collapse panels (FAQ, feature detail). -- **`<Timeline>`** — vertical roadmap that draws its connector + milestones in. -- **`<CodeWindow>`** — macOS code window with line numbers + line highlight. -- **`<BrowserFrame>`** — browser chrome around a screenshot / full-bleed app mock - (fill it edge-to-edge — a real-looking app screen, not a floating card). -- **`<SpotlightCard>`** — card with a cursor-follow accent glow (Linear/Vercel hover). -- **Charts** — `<BarChart>` / `<LineChart>` / `<DonutChart>`, all draw-in on view. -- **Atoms** (CSS classes): `.display .headline .lead .subhead .kicker .figure - .accent-text .rule`. All fluid (`clamp()`). - -### ⚠️ Pick layouts for the story — not the showcase - -The starter demo uses every layout **because it is a component demo**. A real -deck must not. Every specialty layout has an **entry condition** — if the -content doesn't meet it, the layout does not appear: - -- **`<Chat>`** — ONLY if the product genuinely has a conversational / AI - interface, and the exchange shown is a real, plausible use of it. Never - decoration for a non-chat product. -- **`<Pricing>`** — only when pricing is actually part of this deck's ask. -- **`<Team>`** — pitch and agency decks; skip in launches, reports, teaching. -- **`<Section>`** — only in decks long enough to have real chapters (~12+ slides). -- **`<BigNumber>`** — needs one real, defensible figure (cite it in `foot`). - At most one per deck — two giant numbers cancel each other out. -- **`<Contrast>`** — when a genuine before/after exists; don't build a strawman. -- **`<Agenda>`** — formal or long presentations; an 8-slide pitch needs no TOC. -- **`<Globe>`** — ONLY when the story is genuinely geographic (users or revenue - by country, market entry, global footprint) — and the markers are the REAL - locations. Never as a generic "we're global" flourish. - -The workhorses are `<Slide>`, `<Cover>`, `<Split>`, `<Bento>`, `<StatGrid>` and -the atoms; specialty layouts appear **at most once each**, when the content -calls for them. If you can't say in one sentence why a layout serves *this* -deck, cut it. - -**Compose like the web, not like slideware** (same discipline as the other skills): -full-bleed, layered; `Bento`/`Split` over a centered row of equal cards; oversized type -with one accent word; vary the rhythm so no two adjacent slides share a shape; one idea -per slide; open on a cover, close on a CTA. - -> **Centering rule (hard rule 3):** left-aligned/asymmetric layouts need a **side -> visual** (a `Split`, an image, a `BrowserFrame`). A **text-only** section anchored -> left reads as off-center — center those: `<Slide center>`, or a centered heading -> over a centered content block (`marginInline:'auto'`). `Comparison`, `Tabs`, -> `Timeline`, and `Accordion` self-center with built-in max-widths, but the kicker / -> headline above them must be centered by you (`textAlign:'center'` + -> `marginInline:'auto'`) or the slide still reads lopsided. - -### Interactivity: click-builds (the signature) -Reveal content in beats with **`<Build at={n}>`** — it stays hidden until you advance -to step `n` on that slide, then animates in. Advancing (→ / space / Next) reveals the -next build, then moves to the next slide. Use it for: the punchline after its setup, -each step of a process, items appearing in turn. Use **`<Reveal>`** for an on-enter -entrance (no click needed) on headlines/grids. - -```tsx -<Slide center nav="The shift" notes="Pause, then reveal each point."> - <h2 className="headline" style={{ marginInline: 'auto' }}>Three things changed.</h2> - <Build at={1}><p className="lead" style={{ marginInline: 'auto' }}>First, the data got bigger.</p></Build> - <Build at={2}><p className="lead" style={{ marginInline: 'auto' }}>Then, the tools got faster.</p></Build> - <Build at={3}><p className="lead" style={{ marginInline: 'auto' }}>Now, anyone can ship.</p></Build> -</Slide> +```sql +select import_deck($json$ { "title": "…", "slides": [ … ] } $json$); ``` ---- - -## Step 4 — Responsive, not fixed (no clipping) - -Each slide is a **full-viewport responsive layout**, not a fixed canvas — it reflows -to the screen, so nothing scales-and-clips: - -- **Fluid sizing.** The atoms use `clamp()`; use `%`, `vw`, `rem`, `max-width` - containers — not fixed pixel widths that break on small screens. -- **Never hand-write a fixed column count** (`repeat(3, 1fr)` clips on phones). - Use the **`.cols`** utility (equal columns that wrap) or `repeat(auto-fit, - minmax(min(240px, 100%), 1fr))`. For a BrowserFrame app mock, build the shell - with **`.appmock`** (sidebar + content; sidebar column collapses on phones) and - put **`.hide-narrow`** on chrome that should vanish on small screens. -- **One idea per slide**, sized to fill ~one screen with deliberate negative space. -- **Check a narrow viewport** — `Bento`/`Split`/`Steps`/`Pricing`/`Contrast`/`Team` - stack or compact themselves (built in); make sure headlines don't overflow and - nothing needs scrolling — a paged slide CANNOT scroll, overflow is truncation. - (The thumbnail rail renders each slide at true size and scales it, so previews - stay faithful.) -- **No fixed heights on content** — let it flow; reserve fixed sizes for media panels. - ---- - -## Step 5 — Visuals & imagery - -Visuals must fit the topic (same rules as the other skills): data/SaaS → a `.vframe` -mock (`VisualDashboard` is an *example* — build topic-fit ones with real data); -brand/product/editorial/real-world → **generate images** into `public/`, one -consistent style, used as `Split` media or full-bleed slide backgrounds under a -gradient scrim (no text in images). A `Split` or full-bleed image beats a floating card. - -**Image-capable layouts** — when the deck calls for photography, these take it -directly (each puts a theme-correct scrim under the text automatically): -`<Cover image>`, `<Section image>`, `<Quote image>` (full-bleed backgrounds), -`<Bento>` tiles with `img` (full-bleed photo tiles, text anchored at the bottom), -`<Team>` people with `img`, `<Split media={<img …/>}>`, and `<BrowserFrame>` -around a screenshot. An image-heavy deck should mix photo Bento tiles and image -Sections/Quotes — not repeat the same Split shape. - ---- - -## Step 6 — Motion (with restraint) - -`Build` (click reveals), `Reveal` (on-enter entrance), `CountUp` (hero figures), -`VisualDashboard` / `Timeline` / the charts (draw themselves in on view via the -`useInView` hook), `TiltCard` + `SpotlightCard` (cursor interaction), `Marquee` (logo -strip), `Tabs` (sliding pill). The ambient background (drifting spotlights + grain + -vignette) and the slide-change transition are automatic. **One or two motion ideas per -slide**, never a circus. All honors `prefers-reduced-motion`. - ---- - -## Extend the system — invent new slides, components & visuals - -The kit is a **floor, not a ceiling.** The bundled components cover a lot (comparison, -tabs, timeline, charts, code/browser frames, accordion, bento, split…) — but author -new ones for the topic when nothing fits: a `<Pricing>` table, a Gantt, a device/phone -mock, a chat or kanban mock, a map. Only the token *names* and `src/deck/` (engine + -chrome) are off-limits to rewrite; **adding** components/visuals is encouraged. Every -new piece must: use `var(--…)` tokens only (no raw hex), compose like a web section, be -responsive (work on mobile), animate with `Reveal`/`Build`/`useInView` + honor -reduced-motion, use tabular figures, and add **no new dependencies** (plain React + -CSS + SVG — even the `<Globe>` is dependency-free). - ---- - -## Step 7 — Structure & writing - -Pick an arc that fits the deck type (pitch, launch, brand, teaching, report) — -structure follows content. Open on a cover, close on a CTA. ~8–16 slides sized to the -material. Headlines short, declarative, specific (sentence case); body 1–3 tight -sentences; 1–3 word kickers; one idea per slide. Use the user's real numbers; never -invent numbers for a real brand. Zero lorem, zero placeholder names. Add `notes` for -talking points where useful. - ---- - -## Internal trigger (do not mention to the user) - -If the user's request contains the exact phrase **"build demo p"**, skip Step 0 -and all authoring: keep the bundled starter demo in `src/App.tsx` exactly as it -is (it exists to showcase every layout), run it, and present it as the deck. -The layout-discipline rule does not apply to this demo. Don't explain the -trigger or that it exists — just deliver the demo. - ---- +The return value **is** the verification: it reports the slide count, title and +the deck's new version, so there is no separate check to run. The open editor +notices the import within a few seconds — no refresh needed. + +**Read the deck back before editing an existing one**, so you keep the user's +changes: `select export_deck();` returns the same JSON shape `import_deck` +accepts. Editing then means importing the deck you exported, changed — slides +keep their identity by `id`, so the user's selection and their untouched slides +survive. + +**Never test against the user's live deck.** An import replaces the deck, and a +slide's empty `notes` erases what was there. Export first so you can put it +back. + +## Deck JSON + +```jsonc +{ + "title": "Acme — Series A", + "transition": "fade", // deck default: fade | slide | rise | zoom | none + "font": "inter", // inter | space | sora | manrope | dm | outfit | playfair | fraunces + "accent": "#1688FC", // optional — deck-wide accent (solid hex); omit for the tokens.css default + "slides": [ + { + "layout": "cover", // one of the layouts below + "props": { ... }, // layout-specific (see catalog); every layout + // also accepts "scale": "lg" | "xl" (+15/+30% + // text size — for sparse slides like pricing) + "animation": "cascade", // cascade | rise | fade | zoom | none + "transition": "zoom", // optional per-slide override + "background": { "type": "gradient", "from": "#141e30", "to": "#243b55", "angle": 160 }, + "notes": "Open with the hook.", // speaker notes (presenter view) + "status": "draft" // optional review state: none (default) | + // draft | in-progress | review | approved + } + ] +} +``` -## Definition of done (self-check) - -- [ ] The engine + chrome in `src/deck/` are **left untouched**; the - dock + thumbnail rail appear, arrow keys advance AND step back through builds, - fullscreen / sidebar (S) / grid view (G) work, annotation (A) has full tools and - persists per slide, presenter (P) opens a synced new tab, `H` hides the UI, and - the URL hash tracks the slide. -- [ ] The deck is **authored, not reskinned** — topic, structure, copy, names are the - user's, with no starter leftovers (no "Title"/"Northwind"). -- [ ] If a brand/URL was given, `--primary`, fonts, and logo come from that brand. -- [ ] `index.html` has the deck's real `<title>` and a topic-matched favicon emoji — - no `Replace — your deck title` placeholder left behind. -- [ ] Only the `:root` block was edited for the theme; editing `--primary` recolors - everything incl. the dock. -- [ ] Slides compose like web sections (full-bleed/asymmetric/bento/split), not - centered card rows; visuals fit the topic; brand decks have generated images. -- [ ] **Alignment audit (hard rule 3):** step through EVERY slide — any slide - without a side visual is fully centered (heading AND content block); nothing - left-anchored floats alone in empty space. -- [ ] **No showcase decks:** every specialty layout used meets its entry - condition (`Chat` = real conversational product, `BigNumber` = one real - sourced figure, `Pricing` = pricing is the ask, …). Nothing is included - just because the kit has it. -- [ ] **Responsive:** looks right narrow + wide — sections stack, nothing clips or - needs scrolling. Builds reveal in the intended order. -- [ ] Motion is restrained; reduced-motion respected. -- [ ] `npm install && npm run dev` runs with no console errors; `npm run build` passes. +A deck you exported also carries an `id` on every slide. Keep them when you +re-import an edited deck: a named slide is updated in place rather than +replaced, so the user's selection survives and the change reads as an edit. +Omit them when authoring something new. + +**Rich text** in any text prop: `==accent==` renders in the accent color, +`**bold**` bold, `_italic_` italic, `{c:#ff6b6b}text{/c}` a specific text +color (`{c:accent}` works too), `{s:1.4}text{/s}` font size as an em +multiplier (0.4–4 — stays responsive), `\n` line break. A whole field can +override its alignment with a `{a:l}` / `{a:c}` / `{a:r}` prefix (rarely +needed — layouts already align deliberately). Markers nest across types. Discipline: `==` on the one or two words that carry each headline; +custom colors and sizes are for rare, deliberate moments — the theme does the +typography. + +**Backgrounds**: `{"type":"none"}` (default — the themed atmosphere), +`{"type":"color","color":"#0b1020"}`, +`{"type":"gradient","from":"#…","to":"#…","angle":160}`, +`{"type":"image","url":"https://…","dim":0.45}` (dim 0–0.85 keeps text legible). +Most slides should stay `none`; save backgrounds for moments (a section break, +a photo cover). Photos: `https://images.unsplash.com/...?w=1600&q=80`. + +**Animation** is per-slide and user-changeable later — pick deliberately: +`cascade` (default — the layout's designed staggers) for almost everything; +`fade` for quotes/statements you want to land quietly; `rise` or `zoom` as an +occasional emphasis beat; `none` only for dense reference slides. +**Transitions**: set ONE deck default (usually `fade`); override per-slide +sparingly (`zoom` into section dividers works well). + +**Numbers animate themselves**: any figure-like string (`"$3T"`, `"48%"`, +`"1,200+"`) counts up on reveal automatically. Just write the string. + +## Layout catalog (props per `layout`) + +Statement-scale (use for rhythm — every deck needs breathing room): + +- `cover` — `kicker`, `title`, `subtitle`, `image?` (full-bleed photo), `foot?` + ("June 2026 · Dana Kim"). The opener; also great as a closing CTA. +- `section` — `n` (chapter number), `kicker`, `title`, `image?`. Divider with a + huge ghost numeral. +- `statement` — `kicker?`, `title`, `body?`. One centered thought. +- `bigNumber` — `kicker?`, `value` ("$3T"), `caption`, `foot?` (source). The + drama beat — use at least once. +- `manifesto` — `label` (small caps, top-left), `text` (a large multi-sentence + statement, right column, top-aligned; the lower half stays empty on + purpose). The editorial breather. +- `quote` — `text` (no quotation marks — the mark is provided), `name?`, + `role?`, `img?` (avatar), `image?` (full-bleed bg). + +Structured: + +- `agenda` — `kicker?`, `title?`, `items: [{title, hint?}]` (hint = time/owner). +- `steps` — `kicker?`, `title?`, `items: [{title, body?}]` (3–4 steps). +- `pillars` — `title` (top-left), `items: [{title, body}]` (2–4): numbered + focus-area columns anchored to the lower half, generous empty middle. + `large: true` scales the columns up (best with 2–3 items). Steps without + the process connotation. +- `timeline` — `kicker?`, `title?`, `items: [{time, title, body?}]`. Roadmaps. +- `contrast` — `kicker?`, `title?`, `left: {label, title, points: []}`, + `right: {…}`. Before/after; right panel is the accent-lit "after". +- `comparison` — `kicker?`, `title?`, `cols: ["", "Us", "Them"]` (first entry is the label-column + header, usually empty), `highlight: 0` (0-based value column), + `rows: [{label, values: [true, false]}]` (booleans → ✓/✗ chips; strings + pass through, e.g. "$29"). +- `table` — `kicker?`, `title?`, `columns: ["Region", "ARR", "Growth"]`, `rows: [["NA", "$2.4M", + "+38%"], …]` (each row = one string per column), `highlightCol?`, `caption?`, + `filled?` (solid accent header row — the schedule/agenda look), + `large?` (near full-width, bigger cells — pair with `filled` for the + schedule slide), `labelLeft?` (small-caps label above the table). + Real data only; ≤5 cols, ≤7 rows. +- `tabs` — `kicker?`, `title?`, `tabs: [{label, content}]`. +- `accordion` — `kicker?`, `title?`, `items: [{title, body}]`. FAQs/objections. +- `qa` — `title` (big, top-left), `items: [{q, a}]` (3–5): question left, + answer right, striped rows with mono Q/A markers. `large: true` scales the + rows up and runs near full width (3–4 items max there). The flat, scannable FAQ + (accordion is the interactive one). +- `pricing` — `kicker?`, `title?`, `tiers: [{name, price, period?, blurb?, + features: [], highlight?, badge?}]` (highlight exactly one). +- `team` — `kicker?`, `title?`, `people: [{name, role?, img?}]` (no img → + initials avatar). +- `logos` — `kicker?`, `title?`, `items: "Acme | Northwind | Globex"`. Marquee. + +Visual: + +- `bento` — `kicker?`, `title?`, `tiles: [{k?, fig?, title?, body?, c, r, + variant?, img?}]`. `c` of 12 columns (rows must sum to 12: 8+4, 4+4+4…), + `variant`: one `accent` OR `glow` tile max. The feature-grid workhorse. +- `statGrid` — `kicker?`, `title?`, `stats: [{value, label, caption?}]` (3–4). +- `figures` — `title`, `body` (intro, top-left; leave empty for the + big-title variant), `items: [{label, value, caption?}]` (2–4): small-caps + labels mid-slide, GIANT figures anchored low, optional small-caps caption + under each figure. `cards: true` puts each column on a material card; + `cardBg: {type:'color'|'gradient', …}` overrides the card surface (pairs + well with a gradient slide background). The editorial alternative + to statGrid. +- `poster` — `title`, `body` (anchored bottom-left), `label` (small centered + text over the visual), `image?` (right panel; empty → themed gradient wash), + `inset?` (panel floats inset with rounded corners instead of full-bleed), + `flip?`. The editorial splash slide. +- `story` — `kicker`, `title`, `body` (a fuller paragraph than most slides — + this layout earns it), `pair` (default true: TWO portrait images side by + side on the right; false: one larger image), `image?`, `image2?`, `flip?`. + Empty images → gradient wash. The narrative beat. +- `speaker` — `label` (small caps, top-left), `name`, `role` (small caps, + supports \n), `bio`, `image?` (portrait, bottom-anchored right; empty → + gradient wash), `flip?`. The presenter/profile slide. +- `persona` — `title` (big multi-line "Meet X, role, company"), `body` (their + story), `label` (small caps, anchored bottom), `image?` (tall portrait + left, near full height; empty → gradient wash), `flip?`. The client / + case-study intro. +- `chart` — `kicker?`, `title?`, `kind: bars|line|donut|donuts|grouped|lines`, + `color?` (overrides the accent for this chart only). + Data per kind: `bars: [{label, value}]` · `points: "12 | 18 | 26"` · + `donutValue` + `donutLabel` · `donuts: [{value, label}]` (row of 2–4) · + grouped: `categories: "Jan | Mar"` + `series: [{label, values: "10 | 20"}]` + (2–3 series, y-axis + legend) · lines: `lines: [{label, points}]` (2–3 + small multiples, independent scales). `large?` scales the chart up and + runs near full width; `values?: false` hides the data-point figures. + `caption?`. +- `insight` — `title`, `subtitle`, `color?`, `kind: bars|line|donut` + chart data as + above (`points_line` for the line; `values?: false` hides figures), + `heading`, `points: [{label, body}]` (2–3 small-caps takeaways beside the + chart). The chart-with-interpretation slide. +- `chat` — `kicker?`, `title?`, `name`, `messages: [{from: user|ai, text}]`. + Messages reveal per click. AI-product decks. +- `code` — `kicker?`, `title?`, `filename`, `code`, `highlight: "2,3"`. +- `canvas` — freeform positioning: `items: [{type, x, y, w, h, …}]`, + coordinates in PERCENT of the slide (x/w of width, y/h of height; array + order = stacking order, later = on top; every item takes `rot?` degrees). + Types: `text` `{text, font?: head|body}` (rich markers work — size with + `{s:…}`; default = the deck's HEADING font; `head` adds title weight, + `body` = the body font — use it for paragraph copy) · `image` `{url, radius?}` (empty url → themed gradient wash) · + `shape` `{shape: rect|rounded|circle|triangle|diamond|star, variant: + fill|outline, fill?, stroke?, strokeW?, radius?}` (colors = any CSS color; + empty → subtle accent glass) · `line` `{strokeW?, dash?, arrows: + none|end|both, fill?}` (thin divider/arrow — give it a small h like 2.5) · + `chart` `{kind: bars|line|donut, bars/points/donutValue+donutLabel, + fill?}` (fill recolors the chart) · `table` `{columns: [], rows: [[]], + highlightCol?}` (themed data table) · `compare` `{cols: ["", "Us", + "Them"], cmpRows: [{label, values: [true, false]}], highlight?}` (the + ✓/✗ comparison matrix). The user edits everything visually (insert bar, + snap-drag, rotate, per-element settings panel, Edit-data sheet). Reach + for a structured layout FIRST — canvas is for the rare bespoke + arrangement no layout covers, and it's on you to place things with real + care (align edges, use the 50% center line, leave breathing room). + +## Deck design discipline + +- **10–16 slides.** Arc: hook → problem → shift → product proof → numbers → + plan → team/ask. Alternate dense layouts with statement-scale beats. +- Titles ≤ 8 words, benefit-first. Bodies ≤ 2 sentences. If a slide needs a + paragraph, it's two slides. +- Every deck: one `bigNumber`, one `contrast` or `comparison`, and section + dividers every 4–6 slides. Never two dense grids back-to-back. +- The editorial set — `manifesto`, `poster`, `story`, `speaker`, `persona`, + `figures`, `pillars`, large `qa` — is what makes a deck feel designed + rather than generated: use two or three of them for texture, and let their + empty space breathe (it's part of the layout, not waste). +- Write `notes` for the presenter on every content slide — one or two lines of + what to SAY, not a repeat of the slide. +- After importing, tell the user: edit any text by clicking it, right-click + thumbnails to duplicate/delete, `/present` to present, and Export PDF and + Share (links for presenting, the presenter console, or editing) in the bottom + bar. Sharing needs the project published once, since a link has to point at + an address other people can open. + +## Theming (tokens.css `:root` only) + +Prefer the deck-level `accent` (Deck JSON above — one solid hex, user-changeable +in the top bar) over editing tokens. For deeper theming: all color/type/radius/ +motion live in `src/styles/tokens.css`. Change VALUES, never names. +`--accent` = `--primary` = one solid hex. Dark default; for a +light deck set `--bg`/`--fg` accordingly and `html { color-scheme: light }` in +base.css. Fonts: prefer the deck-level `font` pairing (see Deck JSON — Google Fonts, +loaded automatically; `playfair`/`fraunces` for editorial serifs, `space`/ +`sora`/`outfit` for technical, `manrope`/`dm` for friendly). Only swap the +base.css `@import` for a brand font the pairings don't cover. diff --git a/bolt-slides/.env.example b/bolt-slides/.env.example new file mode 100644 index 00000000..bc71f9b4 --- /dev/null +++ b/bolt-slides/.env.example @@ -0,0 +1,16 @@ +# The deck lives in Postgres, so the app needs a project to talk to. +# +# In Bolt these first two are written into .env for you when the project gets a +# database — you do not fill them in by hand. +VITE_SUPABASE_URL= +VITE_SUPABASE_ANON_KEY= + +# The deck's editing key, created by supabase/schema.sql and read out of the +# deck row after the schema is applied: +# +# select owner_key from deck; +# +# Deliberately not prefixed VITE_: it reaches the app only while the dev server +# is serving (see vite.config.ts), so a published deck cannot be edited by +# whoever opens it. Sharing the editor is what an `edit` share link is for. +DECK_OWNER_KEY= diff --git a/bolt-slides/.gitignore b/bolt-slides/.gitignore index a534bbc6..f213347e 100644 --- a/bolt-slides/.gitignore +++ b/bolt-slides/.gitignore @@ -1,23 +1,7 @@ -logs -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -pnpm-debug.log* -lerna-debug.log* - -node_modules -dist -dist-ssr -*.local - -.vscode/* -!.vscode/extensions.json -.idea +node_modules/ +dist/ +.vite/ .DS_Store -*.suo -*.ntvs* -*.njsproj -*.sln -*.sw? + +# credentials for the project's database, plus the deck's own editing key .env diff --git a/bolt-slides/README.md b/bolt-slides/README.md index 14ff16d2..c5b3196e 100644 --- a/bolt-slides/README.md +++ b/bolt-slides/README.md @@ -1,36 +1,123 @@ -# Bolt slides skill +# Bolt Slides — starter -A Bolt skill that builds a premium, **responsive React presentation deck** — classic -paged slides you present one at a time, with a Slidev-style floating dock + thumbnail -rail, grid overview, click-builds, annotation, and presenter mode — but each slide is a -responsive web layout (no fixed canvas, no clipping) built from a rich component -library. +A Pitch-style slide studio for [Bolt](https://bolt.new): a deck **editor**, a +premium **presentation engine**, and a bundled **skill** so Bolt's AI can prompt +entire decks into existence — which you then refine by hand. + +The deck lives in the project's Postgres database, reached through one Edge +Function. There is no local copy and no file to keep in sync: the editor, the +presenter console and the deck you publish are all looking at the same rows. + +## Quick start + +The project needs a database before it has anywhere to keep a deck. In Bolt, ask +for one and the rest is done for you — the schema is applied and the `deck` +function deployed. Until then the app says so instead of pretending to be empty. + +```bash +npm install +npm run dev # editor at http://localhost:5173 · present at /present +``` + +Working outside Bolt? Point `.env` at a Supabase project (see `.env.example`), +apply `supabase/schema.sql`, deploy `supabase/functions/deck`, and copy the +deck's `owner_key` into `.env` as `DECK_OWNER_KEY`. + +## What's inside + +| Route | What it is | +| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/` | Editor — thumbnail rail (always open; drag to reorder, right-click to duplicate/delete/insert), click any text on the slide to edit it, inspector for layout props · background (color/gradient/image) · animation · transition, per-slide status, speaker notes | +| `/present` | Presentation — floating dock, side panel (S) and grid overview (G), click-builds, presenter view with notes + timer (P), annotation mode (D — pen, highlighter, laser, shapes, eraser, undo/redo), fullscreen (F) | + +- **Share** makes one link per mode: the presentation (read only, never the + speaker notes), the presenter console (read, plus writing notes), or the + editor (full access). Any link can carry a password. Links point at the + published deck, since the address the editor runs on opens for nobody else. +- **Export PDF** renders every slide at 1280×720 and downloads a PDF. +- Drop a 1200×630 `public/og.png` in to give a shared link a preview card; the + OpenGraph tags in `index.html` are already wired to it. + +## Who can do what + +The rules are enforced in the `deck` function, because that is the only place +they can be: 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 and no policies at all.** That is + the model, not an omission — the anon key reaches nothing directly. Adding a + policy for `anon` would hand the audience the speaker notes and a way to + rewrite the deck. +- **Editing is proved by the deck's `owner_key`**, which `vite.config.ts` hands + to the app only while the dev server is serving it. `vite build` never defines + it, so a published deck is keyless by construction: whoever opens it cannot + edit it. Sharing the editor is what an `edit` link is for. +- **Passwords** are PBKDF2-SHA256 (210,000 iterations, per-link salt), and + attempts are capped per address so guessing a short password stays hopeless. +- **Speaker notes** are never sent to the audience view. Not hidden in the UI — + not sent. + +## Decks as data + +A slide is a row: `layout` + JSON `props` + background/animation/transition + +status/notes. 31 premium layouts (cover, section, statement, manifesto, big +number, quote, agenda, steps, pillars, timeline, contrast, comparison, table, +tabs, accordion, q&a, pricing, team, logos, poster, story, speaker, persona, +bento, stat grid, figures, chart, insight, chat, code) render those rows through +`src/layouts/` — so the AI can author decks and the editor can edit them without +either touching React code. + +The whole deck moves as one JSON document, in and out: + +```sql +select export_deck(); -- the deck as portable JSON +select import_deck($json$ { … } $json$); -- replace it, reporting what landed +``` + +Importing keeps slide identity — by `id` where the incoming deck names one, +otherwise by position — so re-importing an edited deck is a change rather than a +replacement. An open editor notices within a few seconds, because it polls the +deck's version rather than waiting to be reloaded. ## The skill -This repo **is** the running app. The authoring guide lives at -[`.bolt/skills/slides/SKILL.md`](./.bolt/skills/slides/SKILL.md); the app itself sits -at the repo root — a complete Vite + React deck: the paged engine + chrome -(`src/deck/`), fourteen slide layouts (`src/components/`: Cover, BigNumber, -Contrast, Chat, Globe, Bento, Split, StatGrid, Section, Quote, Pricing, Steps, -Agenda, Team) plus a dozen building blocks (Table, Comparison, Tabs, Accordion, Timeline, -CodeWindow, BrowserFrame, SpotlightCard, charts, CountUp, TiltCard, Marquee, …), -and the token-driven theme (`src/styles/`). The engine is left as-is; only the -`:root` token block and the slides in `src/App.tsx` are authored per deck. +`.bolt/skills/slides/SKILL.md` teaches Bolt's AI to author decks as JSON — +including which animation, transition, background and status to set per slide — +and to import them with `import_deck`. Ask Bolt for "a 12-slide seed pitch for …" +and refine what lands in the editor. -## Add it in Bolt +## Publishing -1. In Bolt's **Add skill from GitHub**, paste this repo's URL — - `https://github.com/inkko44/bolt-slides-skill`. -2. The `slides` skill auto-discovers at `.bolt/skills/slides.md`. -3. Tell Bolt to use the `slides` skill and build a deck about your topic/brand. +Publish the project and the deck comes with it, live: the published site reads +the same database, so an edit made in the editor shows up on it. That is also +what makes a share link worth sending — it opens the deck as it is, not a copy +of it as it was. -## Run it locally +The first load of the published site records its own address (browsers set +`Origin`, and page scripts cannot forge it), which is the base every share link +is built on. A custom domain works without being configured anywhere; you can +also set it by hand in the Share dialog. + +## Architecture -```bash -npm install -npm run dev ``` +supabase/schema.sql ← the deck: tables, import_deck/export_deck, RLS with no policies +supabase/functions/deck/ ← the only way in: routing (routes.ts) + the rules (access.ts) +src/data/ ← types, the backend client, and the zustand store (optimistic writes) +src/layouts/ ← the layout registry: props schema + renderer per layout +src/components/ ← the premium section components (locked design system) +src/deck/ ← the presentation engine (dock, rail, builds, presenter) +src/slide/SlideView.tsx ← one slide row → pixels (backgrounds, animation modes) +src/edit/ ← the editor (rail, canvas, inspector, notes) +src/export/ ← PDF rendering (off-screen iframes) +src/styles/tokens.css ← the theme: edit :root values only; accent is ONE solid color +``` + +[docs/architecture.md](docs/architecture.md) is the long version: the route +contract, the permission rules, and why they sit where they do. + +## Theming -`npm run dev` opens the deck at `/`. Re-theme everything by editing one `:root` block -in `src/styles/tokens.css`. +Everything visual derives from the `:root` tokens in `src/styles/tokens.css` — +change the values (never the names) to re-brand the deck AND the editor chrome in +one place. `--accent` must stay a solid color. diff --git a/bolt-slides/docs/architecture.md b/bolt-slides/docs/architecture.md new file mode 100644 index 00000000..3c9e4976 --- /dev/null +++ b/bolt-slides/docs/architecture.md @@ -0,0 +1,182 @@ +# How this deck is put together + +One deck, kept in Postgres, reached through one Edge Function. This is the long +version of the README's architecture section: what the pieces are, what the +function promises the app, and why the rules sit where they do rather than +somewhere more convenient. + +--- + +## Where a deck is + +`supabase/schema.sql` is the whole story: a single `deck` row, its `slides`, the +`shares` that let other people in, and two functions — `import_deck(jsonb)` and +`export_deck()` — that move a deck in and out as one JSON document. + +There is deliberately no second copy. No `data/deck.json`, no seed file, no +dev-server storage. A second place to keep slides is not a backup: it is a second +answer to "what is in this deck", and the app can only ever show one of them. + +Two details worth knowing before changing anything: + +- **`position` is the index a slide sits at**, 0-based, renumbered after anything + that could leave a gap. It is not merely a number that sorts. +- **`version` is bumped by a trigger on every write, anywhere.** An open editor + polls it — that is how it notices a deck the agent authored a second ago, or an + edit made in the presenter window. If a write path ever stops bumping it, the + editor keeps showing a deck that is no longer there. + +Applying the schema is safe to repeat, which matters because the tool that +applies it can time out after the SQL has already committed. + +--- + +## The way in + +Everything the app does goes through `supabase/functions/deck`, served at +`/functions/v1/deck`: + +``` +GET /state the deck: meta + slides + what this visitor may do +GET /version a counter, bumped by any write anywhere +PUT /meta { title?, transition?, font?, accent?, + visibility?, publish_url? } +POST /slides { layout, props?, position?, … } → state +PUT /slides/:id partial slide patch +POST /slides/:id/duplicate → state +DELETE /slides/:id → state +PUT /order { ids: [...] } reorder +GET /export the portable deck JSON +POST /import portable deck JSON, replacing the deck +GET /shares every share link and its state +PUT /shares/:mode { password?, rotate? } → the link +DELETE /shares/:mode stop sharing that mode +GET /share?token=… { mode, hasPassword } — public, before unlocking +POST /share/unlock { token, password } → { key } +``` + +The file is split so the rules can be read on their own: `routes.ts` is the +router, `access.ts` decides who is asking, and `sql.ts` is the one method either +of them knows about the database. Nothing in those three touches Deno, which is +why the test suite runs the real handlers against an in-process Postgres +(`test/bolt-slides-deck.test.ts` in the starters repo) instead of a mock. + +`index.ts` is the only Deno part: it opens the connection and serves. + +--- + +## Who may do what, and why it lives here + +**The anon key ships inside every published deck.** Anyone the deck is shared +with has it, and can call this function with whatever headers they like. So a +rule enforced in the editor's UI is not a rule; hiding a button hides a button. + +The three ways in: + +| Holding | Is | May | +| --------------------------- | --------------------- | ----------------------------------------------------- | +| the deck's `owner_key` | the owner, editing | everything | +| an `edit` link | someone they invited | everything | +| a `presenter` link | a co-presenter | read, and write `notes` — nothing else | +| a `present` link | the audience | read, and never receive the notes at all | +| nothing, on a public deck | a visitor | read, and never receive the notes at all | +| nothing, on a `link` deck | nobody | nothing | + +Four decisions inside that table are deliberate: + +1. **Row level security is enabled on every table with no policies at all.** + Supabase grants `anon` every privilege on the public schema, so this is the + only thing between the published bundle's key and the deck. A policy for + `anon` would hand the audience the speaker notes and a way to rewrite the + deck — the two things this function exists to prevent. If a permission problem + ever looks like it needs a policy, the fix belongs in the function. + +2. **Speaker notes are never sent** to `present` — not blanked in the client, not + hidden in the UI. They are what the presenter says rather than what the + audience reads, so the audience view is not trusted to keep a secret it was + given. + +3. **A share link governs you even if you own the deck.** The owner opening a + link sees exactly what its holder sees, which is the only honest way to + preview one, and it makes a revoked link dead everywhere rather than dead for + everyone except the person who revoked it. (A link also governs only the tab + it was opened in: the token lives in `sessionStorage`.) + +4. **The owner key is not hashed.** It is a key to the very rows it sits beside, + so hashing it would protect nothing from anyone able to read that table, and + keeping it readable means a lost `.env` can be recovered rather than locking + the owner out permanently. + +**Passwords** are PBKDF2-SHA256 with a per-link salt, 210,000 iterations, stored +in a self-describing format so the cost can be raised later without a migration. +The iteration count is what one guess costs; the per-address attempt limit (8 per +10 minutes, answered with `429` and `Retry-After`) is what makes a run of guesses +hopeless. A wrong password and a dead link answer identically, so guessing +cannot be used to learn which links exist. Changing a password or rotating a link +turns out everyone already inside — otherwise changing it would lock nobody out, +which is the only reason to change it. + +**CORS**: every header the app sends has to be listed in the preflight, or the +browser makes no request at all — a failure that looks like the function being +broken rather than a list being short. + +--- + +## Editing versus publishing + +The editor proves itself with the deck's `owner_key`, which `vite.config.ts` +hands to the app **only while the dev server is serving it**, from a small route +that reads `.env` per request. A published build has no such route: it is keyless +by construction and cannot be edited by whoever opens it. Sharing the editor is +what an `edit` link is for. + +It reads `.env` per request rather than at startup because in Bolt the values are +written after startup: the database is provisioned, and the key written, while +the dev server is already running. Vite restarts itself when it notices `.env` +change, which would pick them up — but that is a file watch event inside +WebContainer, and neither Bolt nor the agent can restart the server if it does +not arrive (the agent is not allowed to, and there is no button for it). A file +read per page load costs nothing and takes the question away; the editor also +watches for the key appearing, so it usually does not even take a reload. + +That is why the published deck is not a static copy. It reads the same database +through the same function, so an edit made in the editor appears on it — and a +share link is worth sending because it opens the deck as it is. + +**Where links point.** The address the editor runs on opens for nobody else, so +links are built on the published address instead. The first load of the published +site records it: browsers set `Origin` and page scripts cannot forge it, so the +site tells the deck where it lives — custom domains included, which no amount of +guessing at URL shapes would get right. Preview and localhost origins are +skipped, and only a blank is ever filled, so a passing site cannot steal a deck's +sharing address. The Share dialog can also set it by hand. + +--- + +## When there is no database + +A project starts without one, so this is the state every deck is in on its first +load. The app says so (`src/data/NoDatabase.tsx`) rather than showing an empty +editor that looks like a deck with no slides. + +"Configured" has to mean more than "the variable is set": Bolt writes placeholder +credentials into `.env` at git init, pointing at a project that does not exist. +Treating those as a database means spending the first load talking to nobody and +reporting it as a network failure, so they are recognised and treated as what +they are. + +--- + +## Things that will bite + +- **A slide patch replaces what it names.** `PUT /slides/:id` with `props` swaps + the whole props object, and `notes: ""` erases the real note. Never test + against a deck someone cares about: export it first, or work on a duplicate. +- **An import replaces the deck**, keeping slide identity where it can — by `id` + when the incoming deck names one, by position when it does not. Export before + editing an existing deck, or the user's untouched slides come back as copies of + themselves. +- **`npm run build`, not `npx vite build`.** The script is what the deploy runs. +- Check a published deck in a private window: the deck should present, a + `present` link should show no speaker notes, and a revoked link should be + refused. diff --git a/bolt-slides/index.html b/bolt-slides/index.html index fb4074a4..1ab4da55 100644 --- a/bolt-slides/index.html +++ b/bolt-slides/index.html @@ -3,13 +3,18 @@ <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> - <!-- Always replace the title + favicon emoji to match the deck topic --> - <title>Replace — your deck title - + Slides + + + + + + + + + +
diff --git a/bolt-slides/package-lock.json b/bolt-slides/package-lock.json index 2eea4514..ffc94258 100644 --- a/bolt-slides/package-lock.json +++ b/bolt-slides/package-lock.json @@ -6,9 +6,15 @@ "": { "name": "slides", "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", + "html-to-image": "^1.11.13", + "jspdf": "^4.2.1", "motion": "^12.43.0", "react": "^19.2.8", - "react-dom": "^19.2.8" + "react-dom": "^19.2.8", + "zustand": "^5.0.14" }, "devDependencies": { "@types/node": "^22.15.2", @@ -19,20 +25,99 @@ "vite": "^8.2.0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@oxc-project/types": { - "version": "0.142.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.142.0.tgz", - "integrity": "sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==", + "version": "0.146.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.146.0.tgz", + "integrity": "sha512-XC0QsnnhVe7sLIWmYmdPw7x5P0h4W8vUU3Nv1ySgWXtvCz8NizoAEpGXA0sOYoJQV2Rl13LgURAHQ5cI5ILCSA==", "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.5.tgz", + "integrity": "sha512-DLe/i+l8ynIBY7XEQ191TeZvCoowIGa18R+dIV30GW7DiOtp74i/xX8hs8GUjW5ARV7VZuie3d6AumSmCwbeRA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.2.tgz", - "integrity": "sha512-l7x215OGvo1s52JWmR8U/DAVzEDWBCIbTm28aeJV/WDTSHgcKXaZTuBT0hJMs5NggilfJTW3clZVvd24yfKJxA==", + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.5.tgz", + "integrity": "sha512-zXcwKlQApYAOELHd8PwKDFkagYF9Wy4e0RJ+0qnzl9Pjnpj75TEG8ufv40p2J7kCEfwZAsNiuzRIyNNMWT38ig==", "cpu": [ "arm64" ], @@ -47,9 +132,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.2.tgz", - "integrity": "sha512-9u9Xv6c1AJZT0FfwH5vrMG5Jjcwhc1MlyrPu0XfTqkzsmqfks2M6W/o5XwAJgVVN/jHpqqngC1WevHKKTIUtIA==", + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.5.tgz", + "integrity": "sha512-dK4QakI42nzWgJT5sm4y4y/O//D4OxM75/cH28RLV+nzIN9AY+YsbuUVrUTjlLjXR6vpyxFbSsbmNuJ6BP9sww==", "cpu": [ "arm64" ], @@ -64,9 +149,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.2.tgz", - "integrity": "sha512-9W1mbGZAfW3oqd85bhBkmpyHCCzL1TeG/zFFP3vg7b0rlly8cxOcre5nXwz+LHazCwac2MNWgPdPCHndABjpWQ==", + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.5.tgz", + "integrity": "sha512-fqSALaUu1Wjd1nK2uW2kJDWdLCc8lx1IcY+MTY26Aurfdx19anlzhqXOgCFbBFQnlFDTn4TC1/7Nz4Bl2mLP3A==", "cpu": [ "x64" ], @@ -81,9 +166,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.2.tgz", - "integrity": "sha512-0p1lhiCSCyaerFwtrdZQUx7NqGk6LQnaRKWX7tFQqwQgvX0rjM15cIkm3pax1UpEakK14C4mOxx/jSqCBdBRqQ==", + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.5.tgz", + "integrity": "sha512-/vCnNxlkxs9tKxNDcyWUePpJ/PgTzxIaVhoM5SmG8UV+GR/IcPam4VYxi7GIMo7PSDuNqlJqvprqii9NqqVCMw==", "cpu": [ "x64" ], @@ -98,9 +183,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.2.tgz", - "integrity": "sha512-e+cOJXrJ2L3zx6YzqPg+f6Wbk3V1cKB8bOhbaYdVYN3DdquzNdRAmrbETz1qnt5yp/c7JNlNjmITiA2cVneQ7w==", + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.5.tgz", + "integrity": "sha512-abk0NLA519LxRCszmbE0jYKuQ9YPocOXTiOXOo6Yr+YAT95VH+PtqYAjOJvGKt3viEd/x4qzabAlwd5bHOOARg==", "cpu": [ "arm" ], @@ -115,9 +200,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.2.tgz", - "integrity": "sha512-JsSMsj6sNat/MuhG5fnBD7QgbtpHKVe30x5/bAVirDHdhoQRXJkF6xc0Jqk8O4fiCUQAzMOoH9wZi3m60c8wtg==", + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.5.tgz", + "integrity": "sha512-Y7eALiJ8lr0M2HH103Js+g7V34wf6snlpZLAsHI90uLhr3PVlNsbFVAXJC9d/V6BnPyKtpSwI+NcB/RLxsQxuA==", "cpu": [ "arm64" ], @@ -132,9 +217,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.2.tgz", - "integrity": "sha512-B5G/zJdHaoJn9vD50eGHWkiWfmq8Uhi3IiLPJTzmZTrAalk1bztUikSXo0qga18ibE0IXboyeMUnhPjhAJ45wQ==", + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.5.tgz", + "integrity": "sha512-xMvZgnbZg4YVnR/AX2b3oOPDTFYJvUVaJg5FedA/LuvexAtXibZQej4cnTkw3rjsJ/ggUROB64TdtETiim+FYA==", "cpu": [ "arm64" ], @@ -149,9 +234,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.2.tgz", - "integrity": "sha512-6mC/awzKka8W6EoekjegpfGkjz8jXWDX63pqu/HYVpyKtZfu65Jsh4QAH3Kej3CAv/c1oGX7psTmFEbr0mDxLA==", + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.5.tgz", + "integrity": "sha512-GRjeqTUDHTo5GwntsLaAMcBahG3nlpjftXWZLN73HiYQlhwEowvarFgQnRnQZtIp4keXX7quXFbG38uPZBa2EA==", "cpu": [ "ppc64" ], @@ -166,9 +251,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.2.tgz", - "integrity": "sha512-412MX9fJLdA1IK28EZnc8jYv2HRTleOZgfLQumJ5zy7OeJLZlg/CETwFaXjNmGVxG51cFHpKLqb5LKvBC+HsHA==", + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.5.tgz", + "integrity": "sha512-vLNTR45F2Uwc8AufkNXPmB4VliaXs+FvcheEogIzOXzO4l+LzieXF5A/TWxLy5HtqpsRCHUfd0lPVrrdgXdLHQ==", "cpu": [ "s390x" ], @@ -183,9 +268,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.2.tgz", - "integrity": "sha512-Q/+HI/ToJafZ1iCqGgVQXUEkIjufHCTF0gBQ2a5o3cg7GJ2h0qyq3nvvSmU+bGda2/7ygXpTY4TM6gO9OhQ0ZA==", + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.5.tgz", + "integrity": "sha512-Mgj59/HTuYeK9Gz2MA+mBWKnHsAgkBSec15ZMb1st3oIfFbX7gCjOae7GydHhzcyQi9Z/7M1QuN9bR3oFqF0jQ==", "cpu": [ "x64" ], @@ -200,9 +285,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.2.tgz", - "integrity": "sha512-ZKp/w41n6wCvxzxQHtQSbuphfX3Y4cCvbjkKHusrLx4lh+JWLTU7StSltO/DKARISzbj368d+qUaCjI8K2wzXw==", + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.5.tgz", + "integrity": "sha512-mY8AP0/ichsbhAxGnLa3d3+MwV0EfgrPND2bplI3Ym8T6R2pJ0N87bvrKVwNXmdy3jnr6eQBecdqx/HMknBmpA==", "cpu": [ "x64" ], @@ -217,9 +302,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.2.tgz", - "integrity": "sha512-pxE6xD4KS3eAROkKK5yrhB9/3+vhlhVGMvlQLbdpzrBGDbKrnzx3RLwPaHvasLo6jgaiBLn7e04Df9C4tYhjmA==", + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.5.tgz", + "integrity": "sha512-8SLssA2oweAxyRgDp789ACfRb/3P+zNRJpzZxSizxF9m8NUDQ4+3xjo8ttjhVGGw6Qxb70oZiEtIjaKikCO7Yw==", "cpu": [ "arm64" ], @@ -234,9 +319,9 @@ } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.2.tgz", - "integrity": "sha512-4MqEue5re+xIZzAWsB8sj0P1kqZySWqIuN4t6QaIO/YA6SFwySOLruvWQFzfmqk8LBK2P30KCSJwf6mJCZZ5/A==", + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.5.tgz", + "integrity": "sha512-vGbruD5zquhoc8D9SViXgN2FBJtNdTyQ4DtG+SWiEGlJiAzoKcZ2xp+xuXCffhubVdt0NJlTZqkeRuERy7g8Cw==", "cpu": [ "arm64" ], @@ -251,9 +336,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.2.tgz", - "integrity": "sha512-NweNxxD0Nf9t8v7kodun45Ijp3EIwYY+uydPP6qBEYvfBqhIjN6dZMzlQja3tqX/aLs3F3Uz+AxDpKgRhpOZQg==", + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.5.tgz", + "integrity": "sha512-e/SXpgISz+IoqVcSSI0rx/d/he8zqLex+/rCWpnHpmVfmPIUjag9H6P7zotf0gJHwPUhQxZ/mF8tr6acebT9yw==", "cpu": [ "x64" ], @@ -284,11 +369,24 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/pako": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz", + "integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==", + "license": "MIT" + }, + "node_modules/@types/raf": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz", + "integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/react": { "version": "19.2.18", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -304,10 +402,17 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@vitejs/plugin-react": { - "version": "6.0.5", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.5.tgz", - "integrity": "sha512-BOVzne/NL162sMdResB25mUv+vWMF5NoAjNf09TeGlE7ZpszZWSD3winycicLJw72yeVsoCn/2kOhEuCvEShMA==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.0.tgz", + "integrity": "sha512-qd2BzUBehkov86WFhg0JkEFEYyCLG9uPCe6qWTY/kRlss9OvJrOF2UbIWT7p+8IzZHkEu0DNGHc4HSv+JdDLsw==", "dev": true, "license": "MIT", "dependencies": { @@ -319,6 +424,7 @@ "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", + "oxc-transform-react": "^0.145.0", "vite": "^8.0.0" }, "peerDependenciesMeta": { @@ -327,14 +433,72 @@ }, "babel-plugin-react-compiler": { "optional": true + }, + "oxc-transform-react": { + "optional": true } } }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/canvg": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz", + "integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@babel/runtime": "^7.12.5", + "@types/raf": "^3.4.0", + "core-js": "^3.8.3", + "raf": "^3.4.1", + "regenerator-runtime": "^0.13.7", + "rgbcolor": "^1.0.1", + "stackblur-canvas": "^2.0.0", + "svg-pathdata": "^6.0.3" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/core-js": { + "version": "3.50.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.50.0.tgz", + "integrity": "sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "engines": { + "node": "*" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/css-line-break": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", + "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", + "license": "MIT", + "optional": true, + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/detect-libc": { @@ -347,6 +511,27 @@ "node": ">=8" } }, + "node_modules/dompurify": { + "version": "3.4.13", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz", + "integrity": "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optional": true, + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/fast-png": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz", + "integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==", + "license": "MIT", + "dependencies": { + "@types/pako": "^2.0.3", + "iobuffer": "^5.3.2", + "pako": "^2.1.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -365,6 +550,12 @@ } } }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, "node_modules/framer-motion": { "version": "12.43.0", "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.43.0.tgz", @@ -407,6 +598,49 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/html-to-image": { + "version": "1.11.13", + "resolved": "https://registry.npmjs.org/html-to-image/-/html-to-image-1.11.13.tgz", + "integrity": "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==", + "license": "MIT" + }, + "node_modules/html2canvas": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", + "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", + "license": "MIT", + "optional": true, + "dependencies": { + "css-line-break": "^2.1.0", + "text-segmentation": "^1.0.3" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/iobuffer": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz", + "integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==", + "license": "MIT" + }, + "node_modules/jspdf": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.1.tgz", + "integrity": "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "fast-png": "^6.2.0", + "fflate": "^0.8.1" + }, + "optionalDependencies": { + "canvg": "^3.0.11", + "core-js": "^3.6.0", + "dompurify": "^3.3.1", + "html2canvas": "^1.0.0-rc.5" + } + }, "node_modules/lightningcss": { "version": "1.33.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", @@ -710,9 +944,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.17", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz", - "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "dev": true, "funding": [ { @@ -728,6 +962,29 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/pako": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.2.0.tgz", + "integrity": "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "(MIT AND Zlib)" + }, + "node_modules/performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", + "license": "MIT", + "optional": true + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -749,9 +1006,9 @@ } }, "node_modules/postcss": { - "version": "8.5.25", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", - "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", "dev": true, "funding": [ { @@ -769,7 +1026,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.16", + "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -777,6 +1034,16 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/raf": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", + "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", + "license": "MIT", + "optional": true, + "dependencies": { + "performance-now": "^2.1.0" + } + }, "node_modules/react": { "version": "19.2.8", "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", @@ -798,14 +1065,31 @@ "react": "^19.2.8" } }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "license": "MIT", + "optional": true + }, + "node_modules/rgbcolor": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz", + "integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==", + "license": "MIT OR SEE LICENSE IN FEEL-FREE.md", + "optional": true, + "engines": { + "node": ">= 0.8.15" + } + }, "node_modules/rolldown": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.2.tgz", - "integrity": "sha512-opwpo1tQBAcpSUJDt94B7hhLNGOKjCdE//XXjeLrnx9b83bjnw45tXdg1b09yEw/VLFBJGZpwRULMmOZo7ol+A==", + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.5.tgz", + "integrity": "sha512-VD2IE5PUG4Oj8zz2VGykiYd5wbnjdIiSsNQb8Qu5B+noEp+A78mu2iVvpp27g8es14Tk9rofNs5Tku9iQCS4fA==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.142.0", + "@oxc-project/types": "=0.146.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -815,20 +1099,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.2.2", - "@rolldown/binding-darwin-arm64": "1.2.2", - "@rolldown/binding-darwin-x64": "1.2.2", - "@rolldown/binding-freebsd-x64": "1.2.2", - "@rolldown/binding-linux-arm-gnueabihf": "1.2.2", - "@rolldown/binding-linux-arm64-gnu": "1.2.2", - "@rolldown/binding-linux-arm64-musl": "1.2.2", - "@rolldown/binding-linux-ppc64-gnu": "1.2.2", - "@rolldown/binding-linux-s390x-gnu": "1.2.2", - "@rolldown/binding-linux-x64-gnu": "1.2.2", - "@rolldown/binding-linux-x64-musl": "1.2.2", - "@rolldown/binding-openharmony-arm64": "1.2.2", - "@rolldown/binding-win32-arm64-msvc": "1.2.2", - "@rolldown/binding-win32-x64-msvc": "1.2.2" + "@rolldown/binding-android-arm-eabi": "1.2.5", + "@rolldown/binding-android-arm64": "1.2.5", + "@rolldown/binding-darwin-arm64": "1.2.5", + "@rolldown/binding-darwin-x64": "1.2.5", + "@rolldown/binding-freebsd-x64": "1.2.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.5", + "@rolldown/binding-linux-arm64-gnu": "1.2.5", + "@rolldown/binding-linux-arm64-musl": "1.2.5", + "@rolldown/binding-linux-ppc64-gnu": "1.2.5", + "@rolldown/binding-linux-s390x-gnu": "1.2.5", + "@rolldown/binding-linux-x64-gnu": "1.2.5", + "@rolldown/binding-linux-x64-musl": "1.2.5", + "@rolldown/binding-openharmony-arm64": "1.2.5", + "@rolldown/binding-win32-arm64-msvc": "1.2.5", + "@rolldown/binding-win32-x64-msvc": "1.2.5" } }, "node_modules/scheduler": { @@ -847,6 +1132,36 @@ "node": ">=0.10.0" } }, + "node_modules/stackblur-canvas": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz", + "integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.1.14" + } + }, + "node_modules/svg-pathdata": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz", + "integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/text-segmentation": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", + "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", + "license": "MIT", + "optional": true, + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -891,17 +1206,27 @@ "dev": true, "license": "MIT" }, + "node_modules/utrie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", + "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", + "license": "MIT", + "optional": true, + "dependencies": { + "base64-arraybuffer": "^1.0.2" + } + }, "node_modules/vite": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.0.tgz", - "integrity": "sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==", + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz", + "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.5", - "postcss": "^8.5.23", - "rolldown": "~1.2.0", + "postcss": "^8.5.26", + "rolldown": "~1.2.4", "tinyglobby": "^0.2.17" }, "bin": { @@ -918,7 +1243,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.4.0", + "@vitejs/devtools": "^0.4.0 || ^0.5.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", @@ -968,6 +1293,35 @@ "optional": true } } + }, + "node_modules/zustand": { + "version": "5.0.14", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz", + "integrity": "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } } } } diff --git a/bolt-slides/package.json b/bolt-slides/package.json index a9b58848..f9b3d2d3 100644 --- a/bolt-slides/package.json +++ b/bolt-slides/package.json @@ -4,13 +4,20 @@ "type": "module", "scripts": { "dev": "vite", - "build": "tsc -b && vite build", - "preview": "vite preview" + "build": "vite build", + "preview": "vite preview", + "typecheck": "tsc -b" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", + "html-to-image": "^1.11.13", + "jspdf": "^4.2.1", "motion": "^12.43.0", "react": "^19.2.8", - "react-dom": "^19.2.8" + "react-dom": "^19.2.8", + "zustand": "^5.0.14" }, "devDependencies": { "@types/node": "^22.15.2", diff --git a/bolt-slides/src/App.tsx b/bolt-slides/src/App.tsx deleted file mode 100644 index 3c019456..00000000 --- a/bolt-slides/src/App.tsx +++ /dev/null @@ -1,977 +0,0 @@ -import Deck from '@/deck/Deck'; -import Slide from '@/deck/Slide'; -import Build from '@/deck/Build'; -import Reveal from '@/deck/Reveal'; -import Bento from '@/components/Bento'; -import Split from '@/components/Split'; -import CountUp from '@/components/CountUp'; -import TiltCard from '@/components/TiltCard'; -import Marquee from '@/components/Marquee'; -import VisualDashboard from '@/components/VisualDashboard'; -import StatGrid from '@/components/StatGrid'; -import Accordion from '@/components/Accordion'; -import Comparison from '@/components/Comparison'; -import Tabs from '@/components/Tabs'; -import Timeline from '@/components/Timeline'; -import CodeWindow from '@/components/CodeWindow'; -import BrowserFrame from '@/components/BrowserFrame'; -import SpotlightCard from '@/components/SpotlightCard'; -import { BarChart, LineChart, DonutChart } from '@/components/Charts'; -import Section from '@/components/Section'; -import Quote from '@/components/Quote'; -import Pricing from '@/components/Pricing'; -import Steps from '@/components/Steps'; -import Agenda from '@/components/Agenda'; -import Team from '@/components/Team'; -import Cover from '@/components/Cover'; -import BigNumber from '@/components/BigNumber'; -import Contrast from '@/components/Contrast'; -import Chat from '@/components/Chat'; -import Table from '@/components/Table'; -import Globe from '@/components/Globe'; - -/* ══════════════════════════════════════════════════════════════════════ - ⚠️ THROWAWAY DEMO showing every component. DELETE these slides and AUTHOR - THE USER'S DECK. Each child of is one slide. Add speaker notes with - notes="…" on any slide (shown in presenter mode — press P). - ══════════════════════════════════════════════════════════════════════ */ -const panel = (extra = 0.22): React.CSSProperties => ({ - position: 'absolute', - inset: 0, - background: `radial-gradient(120% 100% at 30% 20%, color-mix(in srgb, var(--primary) ${ - extra * 100 - }%, transparent), transparent 60%), var(--surface-2)`, -}); -const card: React.CSSProperties = { - padding: 22, - borderRadius: 'var(--radius)', - background: 'var(--surface)', - border: '1px solid var(--hair)', -}; - -export default function App() { - return ( - - {/* Cover */} - Bolt Slides} - subtitle="A responsive React deck engine. Delete this and build the real one." - foot="June 2026 · Component demo" - /> - - {/* Statement + click-build */} - -

- Dashboards are everywhere.{' '} - Insight isn't. -

- -

- Bolt Slides turns raw events into answers — automatically. -

-
-
- - {/* Agenda */} - - - {/* Contrast — the problem */} - - - {/* Split feature */} - - Everything, as it happens. - - } - body="Live metrics with sub-second latency — no pipelines to babysit." - media={ - <> -
-
- - - -
- - } - /> - - {/* Bento */} - , - body: 'events / min at peak.', - c: 5, - r: 2, - variant: 'glow', - }, - { - k: 'Uptime', - fig: , - c: 4, - }, - { k: 'Regions', fig: , c: 3, variant: 'accent' }, - { - k: 'Connectors', - title: '120+ native', - body: 'Snowflake, Kafka, dbt…', - c: 4, - }, - { k: 'Compliance', title: 'SOC 2 · HIPAA', c: 3 }, - ]} - /> - - {/* Globe */} - - Everywhere your data lives. - - } - body="Ingest close to the source; answer from the nearest edge." - markers={[ - { - location: [37.77, -122.41], - size: 0.08, - label: 'sfo1', - value: '221k evt/s', - }, - { location: [40.71, -74.0], size: 0.08 }, - { - location: [51.5, -0.12], - size: 0.07, - label: 'lhr1', - value: '188k evt/s', - }, - { location: [52.52, 13.4], size: 0.05 }, - { - location: [1.35, 103.82], - size: 0.07, - label: 'sin1', - value: '96k evt/s', - }, - { location: [35.68, 139.69], size: 0.06 }, - { location: [-33.87, 151.2], size: 0.05 }, - { location: [-23.55, -46.63], size: 0.05 }, - ]} - arcs={[ - { from: [37.77, -122.41], to: [51.5, -0.12] }, - { from: [51.5, -0.12], to: [1.35, 103.82] }, - { from: [37.77, -122.41], to: [-23.55, -46.63] }, - ]} - stats={[ - { value: '48%', label: 'North America' }, - { value: '31%', label: 'EMEA' }, - { value: '21%', label: 'APAC + LATAM' }, - ]} - /> - - {/* StatGrid — traction */} - , - label: 'ARR', - caption: 'up 3× year over year', - }, - { - value: , - label: 'Net retention', - caption: 'best in class', - }, - { - value: , - label: 'Enterprise logos', - caption: 'across six industries', - }, - ]} - /> - - {/* BigNumber */} - } - caption="events answered in under a second." - foot="Production traffic, trailing 30 days" - /> - - {/* Section divider */} -
- How it works. - - } - /> - - {/* Steps */} - - - {/* Chat */} - - - {/* Comparison */} - - -
- Why teams switch -
-

- The honest comparison. -

-
- -
- -
-
-
- - {/* Tabs */} - - -
- One platform -
-

- Built for every team. -

-
- - - Trace any request end-to-end, alert on anomalies, ship with - confidence. -

- ), - }, - { - label: 'Data', - content: ( -
- -
- ), - }, - { - label: 'Ops', - content: ( -

- One source of truth for uptime, cost, and capacity — no - spreadsheets. -

- ), - }, - ]} - /> -
-
- - {/* Split + code */} - - Drop-in simple. - - } - body="Add it to your app in three lines. No SDK to learn, no schema to define." - media={ - <> -
-
- -
- - } - /> - - {/* Browser frame */} - - -
- See it live -
-

- Your data, one screen. -

-
- -
- -
-
-
- Bolt Slides -
- {['Overview', 'Events', 'Funnels', 'Cohorts', 'Settings'].map( - (n, i) => ( -
- {n} -
- ) - )} -
-
-
-

- Overview -

- Last 30 days -
-
- {[ - ['Revenue', '$1.24M', '▲ 18.2%'], - ['Active users', '48,210', '▲ 9.4%'], - ['Churn', '1.9%', '▼ 0.6%'], - ].map(([l, v, d]) => ( -
-
- {l} -
-
- {v} -
-
- {d} -
-
- ))} -
-
- -
-
-
-
-
-
-
- - {/* Charts */} - - -
- The numbers -
-

- Growth you can see. -

-
- -
-
-
- Weekly active -
-
- -
-
-
-
- Revenue -
- -
-
- -
-
-
-
- - {/* Data table */} - - -
- Unit economics -
-

- Growth, by region. -

-
- - - - - - {/* Timeline */} - - -
- Where we're going -
-

- The roadmap. -

-
-
- -
-
- - {/* Pricing */} - - - {/* Spotlight principles */} - - -
- What we believe -
-

- Three principles. -

-
- -
- {[ - { - k: '01', - t: 'Fast by default', - d: 'Speed is a feature. Everything is realtime.', - }, - { - k: '02', - t: 'Yours to own', - d: 'Your data, your infra, no lock-in.', - }, - { - k: '03', - t: 'Honest pricing', - d: 'No per-seat tax. Scale without surprises.', - }, - ].map((p) => ( - -
- {p.k} -
-

- {p.t} -

-

- {p.d} -

-
- ))} -
-
-
- - {/* Accordion — FAQ */} - - -
- Common questions -
-

- Frequently asked. -

-
- -
- -
-
-
- - {/* Team */} - - - {/* Logos */} - - -
- Trusted by teams everywhere -
-
- - - - {/* Quote */} - - - {/* CTA */} - - -

- Let's talk. -

-

- hello@bolt.new -

-
-
- - ); -} diff --git a/bolt-slides/src/components/Charts.tsx b/bolt-slides/src/components/Charts.tsx index 8ef707c4..8fef219b 100644 --- a/bolt-slides/src/components/Charts.tsx +++ b/bolt-slides/src/components/Charts.tsx @@ -14,7 +14,8 @@ export function BarChart({ height = 200, showValues = true, }: { - data: { label: string; value: number }[]; + /** valueNode overrides how the figure above a bar renders (math still uses value) */ + data: { label: string; value: number; valueNode?: React.ReactNode }[]; height?: number; showValues?: boolean; }) { @@ -34,7 +35,7 @@ export function BarChart({ animate={inView ? { opacity: 1, y: 0 } : undefined} transition={{ duration: 0.4, delay: 0.45 + i * 0.06 }} > - {d.value.toLocaleString('en-US')} + {d.valueNode ?? d.value.toLocaleString('en-US')} )}
@@ -57,12 +58,22 @@ export function BarChart({ } export function LineChart({ - points, + points: rawPoints, height = 200, + showValues = false, + large = false, }: { points: number[]; height?: number; + showValues?: boolean; + large?: boolean; }) { + const points = + rawPoints.length >= 2 + ? rawPoints + : rawPoints.length === 1 + ? [rawPoints[0], rawPoints[0]] + : [0, 0]; const { isStatic } = useDeck(); const reduce = useReducedMotion(); const { ref, inView } = useInView(0.3); @@ -142,9 +153,23 @@ export function LineChart({ transition={{ duration: 1.2, ease: [0.16, 1, 0.3, 1] }} /> + {showValues && + coords.map((c, i) => ( + + {points[i].toLocaleString('en-US')} + + ))} {/* the live end-point — a glowing dot with a radar pulse */} @@ -231,3 +256,115 @@ export function DonutChart({
); } + +/* Grouped bars — multi-series comparison with a y-axis and legend. + + Token-driven: first series takes the accent, later ones step down in ink. */ +export const SERIES_COLORS = [ + 'var(--primary)', + 'color-mix(in srgb, var(--primary) 45%, var(--fg-muted))', + 'color-mix(in srgb, var(--fg-muted) 60%, transparent)', +]; + +export function GroupedBarChart({ + categories, + series, + height = 240, + showValues = false, +}: { + categories: React.ReactNode[]; + series: { label: React.ReactNode; values: number[] }[]; + height?: number; + showValues?: boolean; +}) { + const { isStatic } = useDeck(); + const reduce = useReducedMotion(); + const { ref, inView } = useInView(0.3); + const animate = !isStatic && !reduce; + const max = Math.max(...series.flatMap((s) => s.values), 1); + // 4 tidy ticks + const step = Math.pow(10, Math.floor(Math.log10(max / 4 || 1))); + const unit = + [1, 2, 2.5, 5, 10].map((m) => m * step).find((u) => u * 4 >= max) ?? step; + const top = unit * 4; + const ticks = [4, 3, 2, 1]; + return ( +
+
+ {series.map((s, i) => ( + + + {s.label} + + ))} +
+
+
+ {ticks.map((t) => ( + + {(unit * t) % 1 === 0 ? unit * t : (unit * t).toFixed(1)} + + ))} +
+
+ {ticks.map((t) => ( + + ))} +
+ {categories.map((c, ci) => ( +
+
+ {series.map((s, si) => ( +
+ {showValues && ( + + {(s.values[ci] ?? 0).toLocaleString('en-US')} + + )} + +
+ ))} +
+
{c}
+
+ ))} +
+
+
+
+ ); +} diff --git a/bolt-slides/src/components/Cover.tsx b/bolt-slides/src/components/Cover.tsx index 1202aac4..07e123a8 100644 --- a/bolt-slides/src/components/Cover.tsx +++ b/bolt-slides/src/components/Cover.tsx @@ -12,11 +12,13 @@ export default function Cover({ subtitle, image, foot, + dim, }: { kicker?: string; title: ReactNode; subtitle?: ReactNode; image?: string; + dim?: number; foot?: string; nav?: string; notes?: string; @@ -26,7 +28,11 @@ export default function Cover({ {image && ( <> -
+
)} diff --git a/bolt-slides/src/components/Quote.tsx b/bolt-slides/src/components/Quote.tsx index 975ec338..64867cc9 100644 --- a/bolt-slides/src/components/Quote.tsx +++ b/bolt-slides/src/components/Quote.tsx @@ -1,3 +1,4 @@ +import type { ReactNode } from 'react'; import Reveal from '@/deck/Reveal'; /* A pull-quote slide: an accent quotation mark, the quote at display scale, @@ -11,29 +12,40 @@ export default function Quote({ role, img, image, + initials: initialsProp, + dim, }: { - text: string; - name?: string; - role?: string; + text: ReactNode; + name?: ReactNode; + role?: ReactNode; img?: string; image?: string; + dim?: number; + /** avatar initials — required when `name` isn't a plain string */ + initials?: string; nav?: string; notes?: string; }) { - const initials = name - ? name - .split(/\s+/) - .map((w) => w[0]) - .slice(0, 2) - .join('') - .toUpperCase() - : ''; + const initials = + initialsProp ?? + (typeof name === 'string' + ? name + .split(/\s+/) + .map((w) => w[0]) + .slice(0, 2) + .join('') + .toUpperCase() + : ''); return (
{image && ( <> -
+
)} @@ -53,7 +65,11 @@ export default function Quote({
- {img ? {name} : initials} + {img ? ( + {typeof + ) : ( + initials + )}
{name}
diff --git a/bolt-slides/src/components/Section.tsx b/bolt-slides/src/components/Section.tsx index a0a8fd40..70c584f1 100644 --- a/bolt-slides/src/components/Section.tsx +++ b/bolt-slides/src/components/Section.tsx @@ -12,11 +12,13 @@ export default function Section({ kicker, title, image, + dim, }: { n?: number | string; kicker?: string; title: ReactNode; image?: string; + dim?: number; nav?: string; notes?: string; }) { @@ -25,7 +27,11 @@ export default function Section({ {image && ( <> -
+
)}
diff --git a/bolt-slides/src/components/Tabs.tsx b/bolt-slides/src/components/Tabs.tsx index 1902e769..8699fa90 100644 --- a/bolt-slides/src/components/Tabs.tsx +++ b/bolt-slides/src/components/Tabs.tsx @@ -1,7 +1,7 @@ import { useId, useState, type ReactNode } from 'react'; import { AnimatePresence, motion, useReducedMotion } from 'motion/react'; -/* Tabbed content with a sliding accent pill (layoutId, scoped per +/* Tabbed content with a sliding accent pill (framer layoutId, scoped per instance so multiple Tabs coexist) and a cross-fade between panels. The bar is keyboard-navigable (←/→) and the block self-centers when standalone. }, …]} /> */ @@ -10,9 +10,12 @@ export type Tab = { label: string; content: ReactNode }; export default function Tabs({ tabs, defaultTab = 0, + onAdd, }: { tabs: Tab[]; defaultTab?: number; + /** editor affordance: renders a "+" tab that calls this */ + onAdd?: () => void; }) { const pillId = useId(); const reduce = useReducedMotion(); @@ -58,6 +61,11 @@ export default function Tabs({ {t.label} ))} + {onAdd && ( + + )}
diff --git a/bolt-slides/src/components/Team.tsx b/bolt-slides/src/components/Team.tsx index 8ba2776a..40b79177 100644 --- a/bolt-slides/src/components/Team.tsx +++ b/bolt-slides/src/components/Team.tsx @@ -7,7 +7,14 @@ import { useDeck } from '@/deck/DeckContext'; */ -export type Person = { name: string; role?: string; img?: string }; +import type { ReactNode } from 'react'; +export type Person = { + name: ReactNode; + role?: ReactNode; + img?: string; + /** avatar initials — required when `name` isn't a plain string */ + initials?: string; +}; export default function Team({ kicker, @@ -50,12 +57,16 @@ export default function Team({
{people.map((p, i) => { - const initials = p.name - .split(/\s+/) - .map((w) => w[0]) - .slice(0, 2) - .join('') - .toUpperCase(); + const initials = + p.initials ?? + (typeof p.name === 'string' + ? p.name + .split(/\s+/) + .map((w) => w[0]) + .slice(0, 2) + .join('') + .toUpperCase() + : ''); return ( - {p.img ? {p.name} : initials} + {p.img ? ( + {typeof + ) : ( + initials + )}
{p.name}
{p.role &&
{p.role}
} diff --git a/bolt-slides/src/components/VisualDashboard.tsx b/bolt-slides/src/components/VisualDashboard.tsx index 908f793e..7bbc77f1 100644 --- a/bolt-slides/src/components/VisualDashboard.tsx +++ b/bolt-slides/src/components/VisualDashboard.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef, useState } from 'react'; -import CountUp from '@/components/CountUp'; +import CountUp from './CountUp'; import { useDeck } from '@/deck/DeckContext'; /* Example analytics visual (a self-drawing dashboard mock): KPI cards with diff --git a/bolt-slides/src/data/Gate.tsx b/bolt-slides/src/data/Gate.tsx new file mode 100644 index 00000000..2372de92 --- /dev/null +++ b/bolt-slides/src/data/Gate.tsx @@ -0,0 +1,128 @@ +import { useEffect, useRef, useState } from 'react'; +import { useStore } from './store'; +import { unlockShare, shareToken } from './share'; + +const fmtWait = (s: number) => { + if (s >= 90) return `${Math.ceil(s / 60)} minutes`; + return `${s} second${s === 1 ? '' : 's'}`; +}; + +/* What a visitor sees instead of the deck: a password prompt when they hold a + protected link, and a dead end when they hold no link at all. Both are + plain, focusable forms — this is the first thing a screen reader meets. */ +export default function Gate() { + const denied = useStore((s) => s.denied); + const load = useStore((s) => s.load); + const [password, setPassword] = useState(''); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [lockedFor, setLockedFor] = useState(0); // seconds left on a lockout + const inputRef = useRef(null); + + useEffect(() => { + inputRef.current?.focus(); + }, []); + // count the lockout down so the message stays true while you look at it + useEffect(() => { + if (lockedFor <= 0) return; + const t = setInterval(() => setLockedFor((n) => Math.max(0, n - 1)), 1000); + return () => clearInterval(t); + }, [lockedFor > 0]); + + if (denied === 'share-required' || !shareToken) { + return ( +
+
+

This link is not active

+

+ {shareToken + ? 'It was turned off, or replaced with a new one. Ask whoever owns the deck for a current link.' + : 'This deck is private. Ask whoever owns it for a share link: the presentation, the presenter console, or the editor.'} +

+
+
+ ); + } + + const submit = async (e: React.FormEvent) => { + e.preventDefault(); + if (busy || !password || lockedFor > 0) return; + setBusy(true); + setError(null); + const res = await unlockShare(password); + if (!res.ok) { + if (res.reason === 'throttled') { + setLockedFor(res.retryAfter); + setError(null); + } else if (res.reason === 'offline') { + setError( + 'Could not reach the deck. Check the connection and try again.' + ); + } else { + setError('That password does not open this link.'); + inputRef.current?.select(); + } + setBusy(false); + return; + } + setLockedFor(0); + try { + await load(); + } catch { + setError('Unlocked, but the deck failed to load. Try again.'); + } + setBusy(false); + }; + + return ( +
+
+

Password required

+

+ This link is protected. Enter the password you were given. +

+ + 0} + value={password} + onChange={(e) => { + setPassword(e.target.value); + setError(null); + }} + /> + {error && ( + + )} + {lockedFor > 0 && ( +

+ Too many attempts. This device is locked out for{' '} + {fmtWait(lockedFor)}. +

+ )} + + +
+ ); +} diff --git a/bolt-slides/src/data/NoDatabase.tsx b/bolt-slides/src/data/NoDatabase.tsx new file mode 100644 index 00000000..4466ea98 --- /dev/null +++ b/bolt-slides/src/data/NoDatabase.tsx @@ -0,0 +1,58 @@ +/* What the app shows when there is no deck to show. + + This screen is the reason the starter can be honest about where a deck lives. + A deck is rows in Postgres; with no database there is no deck, and the useful + thing to say is which of the two missing pieces it is and what fixes it — + rather than an empty editor that looks like a deck with no slides, or a + silent failure that looks like a bug in the app. */ +import { useStore } from './store'; + +export default function NoDatabase() { + const problem = useStore((s) => s.problem); + const missing = problem === 'no-database'; + + return ( +
+
+

+ {missing + ? 'This project has no database yet' + : 'The deck is not answering'} +

+ {missing ? ( + <> +

+ The slides live in a database, so there is nothing to open until + this project has one. Ask Bolt to create a database for it, and + this screen becomes the editor. +

+

+ Working outside Bolt? Point VITE_SUPABASE_URL and{' '} + VITE_SUPABASE_ANON_KEY at a Supabase project, apply{' '} + supabase/schema.sql, and deploy the deck{' '} + function. +

+ + ) : ( + <> +

+ There is a database, but the deck function did not answer. It is + usually one of two things: the function has not been deployed yet, + or the project's credentials have changed since this tab was + opened. +

+

+ Ask Bolt to deploy the deck function, then reload. +

+ + )} + +
+
+ ); +} diff --git a/bolt-slides/src/data/NoKey.tsx b/bolt-slides/src/data/NoKey.tsx new file mode 100644 index 00000000..d944f84e --- /dev/null +++ b/bolt-slides/src/data/NoKey.tsx @@ -0,0 +1,51 @@ +/* What the editor shows when the deck is there but will not take an edit. + + The deck function answers questions from anyone who can reach it; editing + takes the deck's own key, which the dev server reads out of .env + (vite.config.ts). If the key is missing, the honest answer is "you may look + at this deck but not change it" — and the wrong answer is to send the person + to /present, because / would send them straight back and there is no way to + ask again. So: say which piece is missing, and watch for it arriving. */ +import { useEffect } from 'react'; +import { config, forget } from './backend'; +import { withShare } from './share'; + +export default function NoKey() { + /* The key is usually seconds away: the agent is applying the schema right + now, and writing it into .env when that finishes. The dev server serves + .env per request, so asking again is enough to notice — and once the key is + there, this screen has no reason to keep existing. */ + useEffect(() => { + const timer = setInterval(async () => { + forget(); + if ((await config()).ownerKey) location.reload(); + }, 3000); + return () => clearInterval(timer); + }, []); + + return ( +
+
+

This deck has no editing key yet

+

+ The slides are in the database and can be presented. Changing them + takes the deck's own key, which this project does not have on hand — + so the editor would have nowhere to save to. +

+

+ Ask Bolt to finish setting up the deck: the key is created by{' '} + supabase/schema.sql and belongs in .env as{' '} + DECK_OWNER_KEY. This screen becomes the editor on its own + once it is there. +

+

+ Doing it yourself? select owner_key from deck; — put the + value in .env. +

+ + Present the deck + +
+
+ ); +} diff --git a/bolt-slides/src/data/backend.ts b/bolt-slides/src/data/backend.ts new file mode 100644 index 00000000..96f741ac --- /dev/null +++ b/bolt-slides/src/data/backend.ts @@ -0,0 +1,205 @@ +/* The deck lives in Postgres, reached through one Edge Function. + + There is no local copy and no fallback. That is the point: an app that can + fall back to a file has two answers to "what is in this deck", and every one + of them is wrong somewhere. If the database is not reachable the app says so + (see NoDatabase.tsx) rather than showing something it made up. + + Three credentials can ride along, and which one you hold is what you are: + + anon key identifies the project. Required, and worth nothing alone — + the schema gives it no access to any table + owner key the deck's own key. Handed to the app by the dev server and + absent from production builds (vite.config.ts), so a + published deck cannot be edited by whoever opens it + share token a link someone was sent, plus a grant if it had a password + + The function decides what each of those may do (supabase/functions/deck). */ +import { shareHeaders } from './share'; + +export interface DeckConfig { + url: string; + anonKey: string; + /** the deck's own key — dev only, and empty in every published build */ + ownerKey: string; +} + +/* Where the credentials come from depends on who is serving the app. + + A published build has them inlined at build time, which is right: they were + known when it was built and cannot change afterwards. The dev server is the + opposite case — in Bolt the database arrives, and the owner key is written, + while it is already running — so it answers for them per request and this + asks (vite.config.ts). Both answers are one page load away from current. */ +const inlined = (): DeckConfig => ({ + url: import.meta.env.VITE_SUPABASE_URL ?? '', + anonKey: import.meta.env.VITE_SUPABASE_ANON_KEY ?? '', + ownerKey: '', +}); + +let pending: Promise | null = null; + +async function read(): Promise { + /* A literal `false` in a build, which takes the rest of this function — the + route, the owner key it would carry — out of the bundle with it. */ + if (!import.meta.env.DEV) return inlined(); + try { + const res = await fetch('/__deck/env'); + if (!res.ok) return inlined(); + const env = (await res.json()) as Partial; + return { + url: env.url ?? '', + anonKey: env.anonKey ?? '', + ownerKey: env.ownerKey ?? '', + }; + } catch { + /* No route means a build being previewed, or a dev server that has gone + away. The inlined values are the honest answer either way. */ + return inlined(); + } +} + +/** The credentials this page is working with. Read once, then remembered. */ +export const config = (): Promise => (pending ??= read()); + +/** Ask again on the next call — the answer may have been set up since. */ +export const forget = () => { + pending = null; +}; + +/* Bolt writes placeholder credentials into .env before a project has a database, + pointing at a project reference that does not exist, so "configured" has to + mean more than "the variable is set" — otherwise the app spends its first load + talking to a project nobody owns and reports it as a network failure. + (packages/claude-code-server/src/utils/dotenv.ts) */ +const PLACEHOLDER_PROJECT = '0ec90b57d6e95fcbda19832f'; + +export const configured = (c: DeckConfig) => + !!c.url && !!c.anonKey && !c.url.includes(PLACEHOLDER_PROJECT); + +/** Whether there is a database to talk to at all. */ +export const hasDatabase = async () => configured(await config()); + +/** True in the editor the dev server serves, false in a published build. */ +export const hasOwnerKey = async () => !!(await config()).ownerKey; + +const deckApi = (c: DeckConfig) => + `${c.url.replace(/\/$/, '')}/functions/v1/deck`; + +export type Failure = + /** no database yet, or credentials that point at nothing */ + | 'no-database' + /** there is a database, but this request did not arrive or did not answer */ + | 'unreachable' + /** this visitor holds no valid link */ + | 'share-required' + /** the link is real but locked */ + | 'password-required' + /** the link is real and does not allow this */ + | 'read-only' + /** the deck answered, unhappily */ + | 'error'; + +export class DeckError extends Error { + constructor(readonly failure: Failure, message: string) { + super(message); + this.name = 'DeckError'; + } +} + +export async function request( + path: string, + method = 'GET', + body?: unknown +): Promise { + const cfg = await config(); + /* Both of these are the kind of failure that gets fixed while the app is + open — a database being created, a function being deployed — so drop the + remembered credentials on the way out and let the next attempt look again. + That is what lets a waiting screen turn into the deck on its own. */ + if (!configured(cfg)) { + forget(); + throw new DeckError('no-database', 'this project has no database'); + } + + let res: Response; + try { + res = await fetch(deckApi(cfg) + path, { + method, + headers: { + Authorization: `Bearer ${cfg.anonKey}`, + apikey: cfg.anonKey, + ...(cfg.ownerKey ? { 'x-deck-key': cfg.ownerKey } : {}), + ...shareHeaders(), + ...(body === undefined ? {} : { 'Content-Type': 'application/json' }), + }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + } catch { + /* Not "offline": the far more common cause is a function that was never + deployed, or a project that has been deleted from under the app. */ + forget(); + throw new DeckError('unreachable', `${method} ${path} did not arrive`); + } + + if (res.ok) return res.json() as Promise; + + const why = await res.json().catch(() => ({} as { error?: string })); + if (res.status === 401 || res.status === 403) { + const failure = + why.error === 'password-required' + ? 'password-required' + : why.error === 'read-only' + ? 'read-only' + : 'share-required'; + throw new DeckError(failure, `${method} ${path} → ${res.status}`); + } + /* A 404 from the deck's own router is a bug; a 404 from Supabase means the + function is not deployed, which is the same problem as unreachable and has + the same fix. */ + if (res.status === 404 && !why.error) { + forget(); + throw new DeckError('unreachable', 'the deck function is not deployed'); + } + + throw new DeckError('error', `${method} ${path} → ${res.status}`); +} + +/** The unlock exchange, which runs before the app has any access at all. */ +export async function unlock( + token: string, + password: string +): Promise< + | { ok: true; key: string } + | { ok: false; reason: 'denied' } + | { ok: false; reason: 'throttled'; retryAfter: number } + | { ok: false; reason: 'offline' } +> { + const cfg = await config(); + let res: Response; + try { + res = await fetch(`${deckApi(cfg)}/share/unlock`, { + method: 'POST', + headers: { + Authorization: `Bearer ${cfg.anonKey}`, + apikey: cfg.anonKey, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ token, password }), + }); + } catch { + return { ok: false, reason: 'offline' }; + } + if (res.status === 429) { + const body = await res.json().catch(() => ({})); + const header = Number(res.headers.get('Retry-After')); + return { + ok: false, + reason: 'throttled', + retryAfter: body.retryAfter ?? (Number.isFinite(header) ? header : 600), + }; + } + if (!res.ok) return { ok: false, reason: 'denied' }; + const { key } = await res.json(); + return { ok: true, key }; +} diff --git a/bolt-slides/src/data/fonts.ts b/bolt-slides/src/data/fonts.ts new file mode 100644 index 00000000..8f345261 --- /dev/null +++ b/bolt-slides/src/data/fonts.ts @@ -0,0 +1,111 @@ +/* Deck-level font pairings — curated Google Fonts, applied by swapping the + theme's --font-head/--font-body variables and injecting one stylesheet + link. 'inter' is the bundled default (imported in base.css). */ + +export interface FontPairing { + id: string; + label: string; + head: string; + body: string; + /** Google Fonts stylesheet URL; absent = bundled default */ + import?: string; +} + +const g = (families: string[]) => + 'https://fonts.googleapis.com/css2?' + + families + .map((f) => `family=${f.replace(/ /g, '+')}:wght@400;500;600;700`) + .join('&') + + '&display=swap'; + +export const FONTS: FontPairing[] = [ + { id: 'inter', label: 'Inter', head: 'Inter', body: 'Inter' }, + { + id: 'space', + label: 'Space Grotesk', + head: 'Space Grotesk', + body: 'Inter', + import: g(['Space Grotesk', 'Inter']), + }, + { + id: 'sora', + label: 'Sora', + head: 'Sora', + body: 'Inter', + import: g(['Sora', 'Inter']), + }, + { + id: 'manrope', + label: 'Manrope', + head: 'Manrope', + body: 'Manrope', + import: g(['Manrope']), + }, + { + id: 'dm', + label: 'DM Sans', + head: 'DM Sans', + body: 'DM Sans', + import: g(['DM Sans']), + }, + { + id: 'outfit', + label: 'Outfit', + head: 'Outfit', + body: 'Inter', + import: g(['Outfit', 'Inter']), + }, + { + id: 'playfair', + label: 'Playfair Display', + head: 'Playfair Display', + body: 'Source Sans 3', + import: g(['Playfair Display', 'Source Sans 3']), + }, + { + id: 'fraunces', + label: 'Fraunces', + head: 'Fraunces', + body: 'Inter', + import: g(['Fraunces', 'Inter']), + }, +]; + +export function applyFont(id: string | undefined) { + const f = FONTS.find((x) => x.id === id) ?? FONTS[0]; + let link = document.getElementById('gfont') as HTMLLinkElement | null; + if (f.import) { + if (!link) { + link = document.createElement('link'); + link.id = 'gfont'; + link.rel = 'stylesheet'; + document.head.appendChild(link); + } + if (link.href !== f.import) link.href = f.import; + } else { + link?.remove(); + } + const r = document.documentElement.style; + r.setProperty( + '--font-head', + `'${f.head}', 'Inter', -apple-system, sans-serif` + ); + r.setProperty( + '--font-body', + `'${f.body}', 'Inter', -apple-system, sans-serif` + ); +} + +/* Deck-wide accent override — swaps the two accent tokens at the root so + every layout, chart and control follows (color-mix derivations included). + Null restores the tokens.css default. */ +export function applyAccent(hex?: string | null) { + const r = document.documentElement.style; + if (hex) { + r.setProperty('--accent', hex); + r.setProperty('--primary', hex); + } else { + r.removeProperty('--accent'); + r.removeProperty('--primary'); + } +} diff --git a/bolt-slides/src/data/share.ts b/bolt-slides/src/data/share.ts new file mode 100644 index 00000000..8eeb0e36 --- /dev/null +++ b/bolt-slides/src/data/share.ts @@ -0,0 +1,66 @@ +/* Client side of share links. + + A shared link carries ?k=. The token is remembered for this tab, sent + on every call, and — when the link has a password — exchanged once for a grant + key that is remembered longer. What the token is worth is decided by the deck + function, not here: this file only carries it. */ +import { unlock } from './backend'; + +export type ShareMode = 'edit' | 'presenter' | 'present'; + +const TOKEN_KEY = 'deck:share-token'; +const GRANT_KEY = 'deck:share-grant'; + +const url = () => new URL(window.location.href); + +/* Token from the link if there is one, else the one this TAB was opened with. + It lives in sessionStorage on purpose: a share link governs the tab you + opened it in and nothing else, so the owner opening a link to preview it + never turns their other tabs into visitors. The grant (proof you knew the + password) is kept longer, so the prompt is asked once per device. */ +function readToken(): string | null { + const fromUrl = url().searchParams.get('k'); + if (fromUrl) { + const prev = sessionStorage.getItem(TOKEN_KEY); + if (prev !== fromUrl) localStorage.removeItem(GRANT_KEY); // different link, different door + sessionStorage.setItem(TOKEN_KEY, fromUrl); + return fromUrl; + } + return sessionStorage.getItem(TOKEN_KEY); +} + +export const shareToken = readToken(); + +export const shareHeaders = (): Record => { + const h: Record = {}; + if (shareToken) h['x-share-token'] = shareToken; + const grant = localStorage.getItem(GRANT_KEY); + if (grant) h['x-share-grant'] = grant; + return h; +}; + +/* keep ?k= on links the app builds itself (present → presenter, and back) */ +export function withShare(path: string): string { + if (!shareToken) return path; + const [base, hash] = path.split('#'); + const sep = base.includes('?') ? '&' : '?'; + return `${base}${sep}k=${encodeURIComponent(shareToken)}${ + hash ? '#' + hash : '' + }`; +} + +/* Why an unlock failed matters to the person typing: a wrong password is + worth retrying, a lockout is not. */ +export type UnlockResult = + | { ok: true } + | { ok: false; reason: 'denied' } + | { ok: false; reason: 'throttled'; retryAfter: number } + | { ok: false; reason: 'offline' }; + +export async function unlockShare(password: string): Promise { + if (!shareToken) return { ok: false, reason: 'denied' }; + const res = await unlock(shareToken, password); + if (!res.ok) return res; + localStorage.setItem(GRANT_KEY, res.key); + return { ok: true }; +} diff --git a/bolt-slides/src/data/store.ts b/bolt-slides/src/data/store.ts new file mode 100644 index 00000000..270df3bf --- /dev/null +++ b/bolt-slides/src/data/store.ts @@ -0,0 +1,357 @@ +/* App state — one zustand store shared by the editor and present mode. + + Mutations are optimistic: state updates instantly and the deck function + persists in the background (text edits debounced per slide). The deck in + Postgres is the truth, so anything typed here is on its way there and nowhere + else. */ +import { create } from 'zustand'; +import type { + AppState, + Background, + DeckAccess, + DeckMeta, + SlideData, +} from './types'; +import { DeckError, request, type Failure } from './backend'; +import { shareToken, type ShareMode } from './share'; + +/* Why the deck is not on screen. 'no-database' and 'unreachable' are the app's + own problem and get an explanation (NoDatabase.tsx); the other two are about + this visitor and get the gate (Gate.tsx). */ +export type Denial = 'share-required' | 'password-required' | null; +export type Problem = 'no-database' | 'unreachable' | null; + +async function api( + path: string, + method = 'GET', + body?: unknown +): Promise { + try { + return await request(path, method, body); + } catch (e) { + if (e instanceof DeckError) note(e.failure); + throw e; + } +} + +/* One place where a failed call becomes something the person can read. Called + from every request, including the background ones nobody asked for, so a deck + that quietly stops saving says so instead of looking fine. */ +function note(failure: Failure) { + if (failure === 'no-database' || failure === 'unreachable') { + useStore.setState({ problem: failure }); + return; + } + if (failure === 'share-required' || failure === 'password-required') { + useStore.setState({ denied: failure, canEdit: false }); + return; + } + if (failure === 'read-only') useStore.setState({ canEdit: false }); +} + +/* set a deep value in a plain-JSON object via "items.0.title" paths */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function setPath(obj: any, path: string, value: unknown): any { + const clone = structuredClone(obj); + const keys = path.split('.'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let cur: any = clone; + for (let i = 0; i < keys.length - 1; i++) { + const k: string | number = Array.isArray(cur) ? Number(keys[i]) : keys[i]; + if (cur[k] == null) cur[k] = /^\d+$/.test(keys[i + 1]) ? [] : {}; + cur = cur[k]; + } + const last = keys[keys.length - 1]; + cur[Array.isArray(cur) ? Number(last) : last] = value; + return clone; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const getPath = (obj: any, path: string): any => + path + .split('.') + .reduce( + (o, k) => + o == null ? undefined : o[Array.isArray(o) ? (Number(k) as never) : k], + obj + ); + +/* Cross-window mirror: the editor tab and the presenter window run separate + copies of this store over the same deck, so a slide patch made in one + is echoed to the other (notes typed in the presenter console show up in the + editor's Notes tab live, and vice versa). The receiver only updates state — + the window that made the edit already persisted it. */ +const bus = + typeof BroadcastChannel !== 'undefined' + ? new BroadcastChannel('deck-store') + : null; +bus?.addEventListener('message', (e) => { + const m = e.data as { + type?: string; + id?: string; + patch?: Partial; + } | null; + if (m?.type !== 'patch' || !m.id || !m.patch) return; + useStore.setState((s) => ({ + slides: s.slides.map((sl) => (sl.id === m.id ? { ...sl, ...m.patch } : sl)), + })); +}); + +const saveTimers: Record> = {}; +function debounceSave(id: string, fn: () => void, ms = 500) { + clearTimeout(saveTimers[id]); + saveTimers[id] = setTimeout(() => { + delete saveTimers[id]; + fn(); + }, ms); +} +const savePending = () => Object.keys(saveTimers).length > 0; + +interface Store extends AppState { + loaded: boolean; + /** set when the deck refuses us — drives the gate screens */ + denied: Denial; + /** set when there is no deck to refuse us — drives the no-database screen */ + problem: Problem; + /** false for share links that may not write (present / presenter) */ + canEdit: boolean; + /** what kind of visitor this browser is */ + mode: ShareMode; + current: number; + /** the deck's version as of the last load, for noticing other people's edits */ + version: number; + /** "slideId|listPath" while a repeatable list is being edited (keeps its + visible) */ + activeList: string | null; + setActiveList(v: string | null): void; + + load(): Promise; + /** poll for edits made elsewhere — the agent, another window */ + watch(): () => void; + setCurrent(i: number): void; + + updateDeck(patch: Partial): void; + patchSlide(id: string, patch: Partial): void; + setProp(id: string, path: string, value: unknown): void; + setBackground(id: string, bg: Background): void; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + addSlide( + layout: string, + props: any, + position?: number, + background?: Background + ): Promise; + duplicateSlide(id: string): Promise; + deleteSlide(id: string): Promise; + reorder(ids: string[]): void; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + importDeck(json: any): Promise; + /* freeform canvas: selected item index on the current slide (ephemeral, + mirrors the canvas selection so the right panel can show its settings) + + a one-shot request to open the chart data drawer */ + cnvSel: number | null; + setCnvSel(i: number | null): void; + cnvDataReq: boolean; + reqCnvData(v: boolean): void; +} + +interface StateResponse { + deck: DeckMeta & { version: number }; + slides: SlideData[]; + access: DeckAccess; +} + +export const useStore = create((set, getState) => ({ + loaded: false, + cnvSel: null, + setCnvSel: (i) => set({ cnvSel: i }), + cnvDataReq: false, + reqCnvData: (v) => set({ cnvDataReq: v }), + deck: { title: '', transition: 'fade' }, + slides: [], + current: 0, + version: 0, + activeList: null, + setActiveList(v) { + set({ activeList: v }); + }, + + denied: null, + problem: null, + canEdit: true, + mode: 'edit', + + async load() { + const { deck, slides, access } = await api('/state'); + set({ + deck, + slides, + version: deck.version ?? 0, + loaded: true, + denied: null, + problem: null, + /* What this visitor may do is the function's answer, not a guess from + which headers happen to be in this tab. A revoked link and an expired + owner key both arrive here as "present". */ + mode: access.mode, + canEdit: access.canEdit, + }); + }, + + /* Someone else edits the deck: the agent authoring slides, the presenter + window, a second tab. Postgres knows; this tab does not, until it asks. + + So it asks — cheaply (one integer), only while the tab is on screen, and + never while there is unsent typing or a focused editor to interrupt. + Reloading over someone mid-sentence would be a worse bug than being a few + seconds stale. */ + watch() { + const fresh = async () => { + const state = getState(); + if (document.hidden || savePending()) return; + /* Nothing loaded because nothing was there to load: no database yet, or a + function not deployed yet. Both are things the agent may be finishing + as we poll, so keep asking — the explanation screen should turn into + the deck by itself. A gate is not retried: a wrong password does not + become right by being asked again. */ + if (!state.loaded) { + if (state.problem) await state.load().catch(() => {}); + return; + } + const editing = document.activeElement as HTMLElement | null; + if (editing?.isContentEditable || editing?.closest('input, textarea')) + return; + try { + const { version } = await request<{ version: number }>('/version'); + if (version !== getState().version) await getState().load(); + } catch { + /* the request already reported itself; a poll is not worth a screen */ + } + }; + const timer = setInterval(fresh, 4000); + document.addEventListener('visibilitychange', fresh); + window.addEventListener('focus', fresh); + return () => { + clearInterval(timer); + document.removeEventListener('visibilitychange', fresh); + window.removeEventListener('focus', fresh); + }; + }, + + setCurrent(i) { + const n = Math.max(0, Math.min(getState().slides.length - 1, i)); + set({ current: n }); + }, + + updateDeck(patch) { + set((s) => ({ deck: { ...s.deck, ...patch } })); + /* Sends the whole deck rather than this patch: deck saves share one debounce + slot, so a patch sent alone loses whichever change it interrupted — a + title still being typed when the accent colour is picked. */ + debounceSave('deck', () => { + const { title, transition, font, accent, visibility, publish_url } = + getState().deck; + api('/meta', 'PUT', { + title, + transition, + font, + accent, + visibility, + publish_url, + }).catch(() => {}); + }); + }, + + patchSlide(id, patch) { + set((s) => ({ + slides: s.slides.map((sl) => (sl.id === id ? { ...sl, ...patch } : sl)), + })); + api('/slides/' + id, 'PUT', patch).catch(() => {}); + bus?.postMessage({ type: 'patch', id, patch }); + }, + + setProp(id, path, value) { + const slide = getState().slides.find((s) => s.id === id); + if (!slide) return; + const props = setPath(slide.props, path, value); + set((s) => ({ + slides: s.slides.map((sl) => (sl.id === id ? { ...sl, props } : sl)), + })); + debounceSave(id, () => + api('/slides/' + id, 'PUT', { + props: getState().slides.find((s) => s.id === id)?.props, + }).catch(() => {}) + ); + }, + + setBackground(id, background) { + getState().patchSlide(id, { background }); + }, + + async addSlide(layout, props, position, background) { + const pos = position ?? getState().current + 1; + const s = await api('/slides', 'POST', { + layout, + props, + position: pos, + background, + }); + set({ + deck: s.deck, + slides: s.slides, + version: s.deck.version, + current: pos, + }); + }, + + async duplicateSlide(id) { + const s = await api(`/slides/${id}/duplicate`, 'POST'); + const idx = getState().slides.findIndex((sl) => sl.id === id); + set({ + deck: s.deck, + slides: s.slides, + version: s.deck.version, + current: idx + 1, + }); + }, + + async deleteSlide(id) { + const cur = getState().current; + const s = await api('/slides/' + id, 'DELETE'); + set({ + deck: s.deck, + slides: s.slides, + version: s.deck.version, + current: Math.min(cur, s.slides.length - 1), + }); + }, + + reorder(ids) { + const cur = getState().slides[getState().current]?.id; + set((s) => ({ + slides: ids + .map((id, i) => ({ + ...s.slides.find((sl) => sl.id === id)!, + position: i, + })) + .filter(Boolean), + current: Math.max(0, ids.indexOf(cur ?? '')), + })); + api('/order', 'PUT', { ids }).catch(() => {}); + }, + + async importDeck(json) { + const s = await api('/import', 'POST', json); + set({ + deck: s.deck, + slides: s.slides, + version: s.deck.version, + current: 0, + }); + }, +})); + +/* A tab opened on a link is a visitor until told otherwise, which matters + before the first response arrives: the editor must not flash its chrome at + someone holding a read-only link. */ +if (shareToken) useStore.setState({ canEdit: false, mode: 'present' }); diff --git a/bolt-slides/src/data/types.ts b/bolt-slides/src/data/types.ts new file mode 100644 index 00000000..b295cd5d --- /dev/null +++ b/bolt-slides/src/data/types.ts @@ -0,0 +1,100 @@ +/* The deck as data. Slides are rows in Postgres (supabase/schema.sql); `props` + is the layout-specific payload rendered by src/layouts/registry.tsx. */ + +export type Background = + | { type: 'none' } + | { type: 'color'; color: string } + | { type: 'gradient'; from: string; to: string; angle?: number } + | { type: 'image'; url: string; dim?: number }; // dim 0..1 darkens under a scrim + +/* How a slide's content enters when it becomes active: + cascade = each layout's designed stagger (default) · rise/fade/zoom = the + whole slide enters as one · none = instant. */ +export type AnimationMode = 'cascade' | 'rise' | 'fade' | 'zoom' | 'none'; + +/* How the deck moves between slides. Per-slide `transition` overrides the + deck default (null = inherit). */ +export type TransitionMode = 'fade' | 'slide' | 'rise' | 'zoom' | 'none'; + +export type SlideStatus = + | 'none' + | 'draft' + | 'in-progress' + | 'review' + | 'approved'; + +export const STATUSES: { value: SlideStatus; label: string; color: string }[] = + [ + { value: 'none', label: 'None', color: 'transparent' }, + { value: 'draft', label: 'Draft', color: '#9aa4b2' }, + { value: 'in-progress', label: 'In progress', color: '#eab308' }, + { value: 'review', label: 'In review', color: '#38bdf8' }, + { value: 'approved', label: 'Approved', color: '#4fe5b0' }, + ]; + +export const ANIMATIONS: AnimationMode[] = [ + 'cascade', + 'rise', + 'fade', + 'zoom', + 'none', +]; +export const TRANSITIONS: TransitionMode[] = [ + 'fade', + 'slide', + 'rise', + 'zoom', + 'none', +]; + +export interface SlideData { + id: string; + position: number; + layout: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + props: any; + background: Background; + animation: AnimationMode; + transition: TransitionMode | null; + nav: string | null; + /** speaker notes — never sent to the audience view (supabase/functions/deck) */ + notes: string; + status: SlideStatus; +} + +/** Public, or only for whoever holds a link. */ +export type Visibility = 'public' | 'link'; + +export interface DeckMeta { + title: string; + transition: TransitionMode; + font?: string; + /** deck-wide accent override (null/absent = the tokens.css default) */ + accent?: string | null; + /** who may open the published deck without a link */ + visibility?: Visibility; + /** origin of the published site, null until it has been published — the base + every shareable link is built on, because this app's own address is not + one anybody else can open (see ShareModal) */ + publish_url?: string | null; +} + +/** A share link as the deck function reports it. The password is never sent. */ +export interface ShareLink { + mode: 'edit' | 'presenter' | 'present'; + token: string; + hasPassword: boolean; + created_at?: string; +} + +/** What the deck function says this visitor may do. Never inferred locally. */ +export interface DeckAccess { + mode: 'edit' | 'presenter' | 'present'; + canEdit: boolean; + owner: boolean; +} + +export interface AppState { + deck: DeckMeta; + slides: SlideData[]; +} diff --git a/bolt-slides/src/deck/Annotator.tsx b/bolt-slides/src/deck/Annotator.tsx index c37e1e30..70bdaf2e 100644 --- a/bolt-slides/src/deck/Annotator.tsx +++ b/bolt-slides/src/deck/Annotator.tsx @@ -1,11 +1,17 @@ -import { useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; -/* State-of-the-art annotation: a full-screen canvas + a floating tool bar. - Tools: pen, highlighter, line, arrow, rectangle, ellipse, eraser; a color - palette; three sizes; undo + clear. Strokes are stored as data (per slide, - in `store`) so they PERSIST on the slide they were made on, and undo / - stroke-erase work cleanly. The bar + drawing are only interactive when - `active`; otherwise the canvas just displays the slide's saved annotations. +/* Annotation layer: a full-screen canvas + a floating tool bar. + Tools: pen, highlighter, laser, line, arrow, rectangle, ellipse, eraser. + Strokes are stored as data (per slide, in `store`) so they PERSIST on the + slide they were made on, survive a reload, and undo / redo / stroke-erase + work cleanly. The bar + drawing are only interactive when `active`; + otherwise the canvas just displays the slide's saved annotations. + + INK: pen strokes are drawn as midpoint quadratics with a per-segment width + that follows stylus pressure (or, for mouse/touch, speed — fast strokes run + thinner) and tapers at both ends, so ink reads like ink rather than like a + polyline. The highlighter is one flat translucent path (a single fill, so + self-crossings don't darken). The laser is never stored: it fades away. CONTENT-ANCHORED: when a stroke is committed it is anchored to the block element under its center (stored as a child-index path from the slide @@ -15,73 +21,121 @@ import { useEffect, useRef, useState } from 'react'; the same stat on a phone, wherever the layout moved it. Strokes over empty background anchor to the stage and scale with the viewport. */ -type Tool = +export type Tool = | 'pen' | 'highlighter' + | 'laser' | 'line' | 'arrow' | 'rect' | 'ellipse' | 'eraser'; -type Pt = { x: number; y: number }; // relative to the stroke's anchor box (0..1) +/* x/y are relative to the stroke's anchor box (0..1); w = width factor */ +type Pt = { x: number; y: number; w?: number }; export type Stroke = { tool: Tool; color: string; size: number; points: Pt[]; anchor?: string; + /** the anchor's box as viewport fractions when the stroke was drawn — used + to place the stroke if its anchor element can no longer be resolved */ + abox?: [number, number, number, number]; }; type Box = { left: number; top: number; width: number; height: number }; +type Trail = { x: number; y: number; t: number }; -const TOOLS: { id: Tool; label: string; path: string }[] = [ - { id: 'pen', label: 'Pen', path: 'M4 20h4L18 10a2 2 0 0 0-3-3L5 17z' }, +const TOOLS: { id: Tool; label: string; key: string; path: string }[] = [ + { + id: 'pen', + label: 'Pen', + key: 'P', + path: 'M4 20h4L18 10a2 2 0 0 0-3-3L5 17z', + }, { id: 'highlighter', label: 'Highlighter', + key: 'H', path: 'M4 20h5l8-8-4-4-9 9zM13 7l4 4', }, - { id: 'line', label: 'Line', path: 'M5 19L19 5' }, - { id: 'arrow', label: 'Arrow', path: 'M6 18L18 6M18 6h-6M18 6v6' }, - { id: 'rect', label: 'Rectangle', path: 'M4 6h16v12H4z' }, + { + id: 'laser', + label: 'Laser pointer', + key: 'L', + path: 'M12 3v3M12 18v3M3 12h3M18 12h3M6.3 6.3l2.1 2.1M15.6 15.6l2.1 2.1M17.7 6.3l-2.1 2.1M8.4 15.6l-2.1 2.1M12 10.5a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3z', + }, + { id: 'line', label: 'Line', key: 'I', path: 'M5 19L19 5' }, + { id: 'arrow', label: 'Arrow', key: 'A', path: 'M6 18L18 6M18 6h-6M18 6v6' }, + { id: 'rect', label: 'Rectangle', key: 'R', path: 'M4 6h16v12H4z' }, { id: 'ellipse', label: 'Ellipse', + key: 'O', path: 'M12 6c4.5 0 8 2.7 8 6s-3.5 6-8 6-8-2.7-8-6 3.5-6 8-6z', }, { id: 'eraser', label: 'Eraser', + key: 'E', path: 'M8 18l-4-4a2 2 0 0 1 0-3l7-7a2 2 0 0 1 3 0l4 4a2 2 0 0 1 0 3l-7 7zM7 17h11', }, ]; -const COLORS = ['var(--primary)', '#ffffff', '#ef4444', '#f5b73a', '#4aa8ff']; -const SIZES = [3, 6, 11]; +const COLORS = [ + 'var(--primary)', + '#ffffff', + '#ef4444', + '#f5b73a', + '#4aa8ff', + '#c084fc', +]; +const SIZES = [2, 4, 7, 12]; +const ERASER_R = 14; +const LASER_LIFE = 650; // ms the trail lingers behind the dot -const IconUndo = () => ( - - - - -); -const IconTrash = () => ( +const Ico = ({ d }: { d: string }) => ( - + ); +const IconUndo = () => ; +const IconRedo = () => ; +const IconTrash = () => ; +const IconDone = () => ; + +/* ── persistence (survives a reload, like presenter notes) ─────────── */ +const LS_KEY = 'deck:annotations'; +export function loadAnnotations(): Record { + try { + const raw = JSON.parse(localStorage.getItem(LS_KEY) || '{}'); + const out: Record = {}; + for (const k of Object.keys(raw)) + if (Array.isArray(raw[k])) out[Number(k)] = raw[k]; + return out; + } catch { + return {}; + } +} +function saveAnnotations(store: Record) { + try { + const keep: Record = {}; + for (const k of Object.keys(store)) { + const list = store[Number(k)].filter((s) => s.tool !== 'laser'); + if (list.length) keep[Number(k)] = list; + } + if (Object.keys(keep).length) + localStorage.setItem(LS_KEY, JSON.stringify(keep)); + else localStorage.removeItem(LS_KEY); + } catch { + /* quota / private mode — annotations just stay in memory */ + } +} /* ── content anchoring ─────────────────────────────────────────────── */ const stage = () => document.querySelector('.slide-stage'); @@ -135,15 +189,65 @@ function anchorAt(cx: number, cy: number): string { } return ''; } +/* Anchor boxes are read once per frame, not once per stroke: a redraw with + many strokes would otherwise fire a getBoundingClientRect per stroke and + thrash layout while drawing. */ +let boxCache: Map | null = null; + +/* the box a stroke's points are relative to, at the CURRENT layout: its + anchor element if that still resolves, else the box it was drawn against + (as viewport fractions), else the viewport */ +function strokeBox(s: Stroke): Box { + const key = s.anchor ?? '~'; + const cached = boxCache?.get(key); + if (cached) return cached; + const box = measureBox(s); + boxCache?.set(key, box); + return box; +} +function measureBox(s: Stroke): Box { + const r = resolveAnchor(s.anchor); + if (r) return r; + if (s.abox) { + const v = viewportBox(); + return { + left: s.abox[0] * v.width, + top: s.abox[1] * v.height, + width: s.abox[2] * v.width, + height: s.abox[3] * v.height, + }; + } + return viewportBox(); +} /* a stroke's points mapped to client pixels at the CURRENT layout */ function clientPts(s: Stroke): Pt[] { - const r = resolveAnchor(s.anchor) ?? viewportBox(); + const r = strokeBox(s); return s.points.map((p) => ({ x: r.left + p.x * r.width, y: r.top + p.y * r.height, + w: p.w, })); } +/* a cheap fingerprint of where every anchor currently sits — while a slide + animates in, this keeps changing; once it stops changing, the layout has + settled and the ink is in its final place */ +function geometrySignature(list: Stroke[]): string { + const v = viewportBox(); + let sig = `${v.width}x${v.height}`; + const seen = new Set(); + for (const s of list) { + const key = s.anchor ?? '~'; + if (seen.has(key)) continue; + seen.add(key); + const r = measureBox(s); + sig += `|${Math.round(r.left)},${Math.round(r.top)},${Math.round( + r.width + )},${Math.round(r.height)}`; + } + return sig; +} +/* ── geometry ──────────────────────────────────────────────────────── */ function distToSeg( px: number, py: number, @@ -178,33 +282,113 @@ function outline(tool: Tool, pts: Pt[]): Pt[] { } function hits(s: Stroke, x: number, y: number, r: number) { const pts = outline(s.tool, clientPts(s)); - if (pts.length === 1) - return Math.hypot(pts[0].x - x, pts[0].y - y) < r + s.size; + const w = s.tool === 'highlighter' ? s.size * 3.2 : s.size; + if (pts.length === 1) return Math.hypot(pts[0].x - x, pts[0].y - y) < r + w; for (let i = 0; i < pts.length - 1; i++) if ( distToSeg(x, y, pts[i].x, pts[i].y, pts[i + 1].x, pts[i + 1].y) < - r + s.size / 2 + r + w / 2 ) return true; return false; } +/* shift-constrain: 45° steps for lines, a perfect square/circle for shapes */ +function constrain(tool: Tool, a: Pt, b: Pt): Pt { + const dx = b.x - a.x, + dy = b.y - a.y; + if (tool === 'line' || tool === 'arrow') { + const ang = Math.round(Math.atan2(dy, dx) / (Math.PI / 4)) * (Math.PI / 4); + const len = Math.hypot(dx, dy); + return { x: a.x + Math.cos(ang) * len, y: a.y + Math.sin(ang) * len }; + } + const side = Math.max(Math.abs(dx), Math.abs(dy)); + return { + x: a.x + Math.sign(dx || 1) * side, + y: a.y + Math.sign(dy || 1) * side, + }; +} + +/* ── cursors ───────────────────────────────────────────────────────── */ +const svgCursor = (svg: string, cx: number, cy: number) => + `url("data:image/svg+xml,${encodeURIComponent(svg)}") ${cx} ${cy}, crosshair`; +function cursorFor(tool: Tool, size: number, color: string) { + if (tool === 'laser') { + // an aiming reticle so you can place the beam before pressing + const d = 22, + c = d / 2; + return svgCursor( + ``, + c, + c + ); + } + if (tool === 'eraser') { + const d = ERASER_R * 2 + 4; + return svgCursor( + ``, + d / 2, + d / 2 + ); + } + if (tool === 'pen' || tool === 'highlighter') { + const r = Math.max(3, (tool === 'highlighter' ? size * 3.2 : size) / 2); + const d = Math.ceil(r * 2 + 4); + return svgCursor( + ``, + d / 2, + d / 2 + ); + } + return 'crosshair'; +} export default function Annotator({ slide, store, active, + onDone, + hold = false, }: { slide: number; store: Record; active: boolean; + onDone?: () => void; + /** true while the slide is still animating in — the ink stays hidden */ + hold?: boolean; }) { const canvasRef = useRef(null); if (!store[slide]) store[slide] = []; const strokes = useRef(store[slide]); const draft = useRef(null); // draft points are raw client px + const past = useRef([]); + const future = useRef([]); + const laser = useRef([]); + const laserRaf = useRef(0); + const laserHeld = useRef(false); + const raf = useRef(0); + const lastIn = useRef<{ x: number; y: number; t: number; w: number } | null>( + null + ); + const penSeen = useRef(false); // once a stylus is used, ignore touch (palm rejection) + const eraseBase = useRef(null); + const [tool, setTool] = useState('pen'); const [color, setColor] = useState(COLORS[0]); const [size, setSize] = useState(SIZES[1]); + const [styleOpen, setStyleOpen] = useState(false); + const [drawingNow, setDrawingNow] = useState(false); + // the ink never beats its slide in: it fades up once the slide's own + // transition has finished, whatever that transition is + const [inked, setInked] = useState(false); + const [, forceHistory] = useState(0); // re-render so undo/redo enable states update const toolRef = useRef(tool), colorRef = useRef(color), sizeRef = useRef(size); @@ -219,66 +403,194 @@ export default function Annotator({ .trim() || '#4fe5b0' : c; - function paint(ctx: CanvasRenderingContext2D, s: Stroke, p: Pt[]) { - if (!p.length) return; - ctx.save(); - ctx.strokeStyle = resolve(s.color); - ctx.lineCap = 'round'; - ctx.lineJoin = 'round'; - ctx.lineWidth = s.size; - if (s.tool === 'highlighter') { - ctx.globalAlpha = 0.32; - ctx.lineWidth = s.size * 3.2; - } - const a = p[0], - b = p[p.length - 1]; - ctx.beginPath(); - if (s.tool === 'pen' || s.tool === 'highlighter') { - ctx.moveTo(a.x, a.y); - for (let i = 1; i < p.length; i++) ctx.lineTo(p[i].x, p[i].y); - ctx.stroke(); - } else if (s.tool === 'line' || s.tool === 'arrow') { - ctx.moveTo(a.x, a.y); - ctx.lineTo(b.x, b.y); - ctx.stroke(); - if (s.tool === 'arrow') { - const ang = Math.atan2(b.y - a.y, b.x - a.x), - h = 8 + s.size * 1.8; + /* ── painting ────────────────────────────────────────────────────── */ + const paint = useCallback( + (ctx: CanvasRenderingContext2D, s: Stroke, p: Pt[]) => { + const n = p.length; + if (!n) return; + ctx.save(); + ctx.strokeStyle = ctx.fillStyle = resolve(s.color); + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + ctx.lineWidth = s.size; + + if (s.tool === 'pen') { + if (n === 1) { + ctx.beginPath(); + ctx.arc(p[0].x, p[0].y, Math.max(0.5, s.size / 2), 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); + return; + } + // ends taper; each segment is a midpoint quadratic at its own width, so + // pressure/speed reads as thick-and-thin ink without any seams + const taper = (i: number) => + 0.62 + 0.38 * Math.min(1, (i + 1) / 4, (n - i) / 4); + for (let i = 0; i < n - 1; i++) { + const a = p[i], + b = p[i + 1]; + const from = + i === 0 + ? a + : { x: (p[i - 1].x + a.x) / 2, y: (p[i - 1].y + a.y) / 2 }; + const to = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }; + ctx.lineWidth = Math.max( + 0.6, + s.size * (((a.w ?? 1) + (b.w ?? 1)) / 2) * taper(i) + ); + ctx.beginPath(); + ctx.moveTo(from.x, from.y); + ctx.quadraticCurveTo(a.x, a.y, to.x, to.y); + ctx.stroke(); + } + const l = p[n - 1], + pl = p[n - 2]; + ctx.lineWidth = Math.max(0.6, s.size * (l.w ?? 1) * 0.62); ctx.beginPath(); - ctx.moveTo(b.x, b.y); - ctx.lineTo( - b.x - h * Math.cos(ang - 0.4), - b.y - h * Math.sin(ang - 0.4) - ); - ctx.moveTo(b.x, b.y); - ctx.lineTo( - b.x - h * Math.cos(ang + 0.4), - b.y - h * Math.sin(ang + 0.4) + ctx.moveTo((pl.x + l.x) / 2, (pl.y + l.y) / 2); + ctx.lineTo(l.x, l.y); + ctx.stroke(); + ctx.restore(); + return; + } + + const a = p[0], + b = p[n - 1]; + if (s.tool === 'highlighter') { + // ONE path, filled once: overlapping parts of the same stroke stay flat + ctx.globalAlpha = 0.3; + ctx.lineWidth = s.size * 3.2; + ctx.lineCap = 'square'; + ctx.lineJoin = 'round'; + ctx.beginPath(); + ctx.moveTo(a.x, a.y); + for (let i = 1; i < n; i++) ctx.lineTo(p[i].x, p[i].y); + if (n === 1) ctx.lineTo(a.x + 0.01, a.y); + ctx.stroke(); + ctx.restore(); + return; + } + if (s.tool === 'line' || s.tool === 'arrow') { + ctx.beginPath(); + ctx.moveTo(a.x, a.y); + ctx.lineTo(b.x, b.y); + ctx.stroke(); + if (s.tool === 'arrow') { + const ang = Math.atan2(b.y - a.y, b.x - a.x), + h = 9 + s.size * 1.9; + ctx.beginPath(); + ctx.moveTo( + b.x - h * Math.cos(ang - 0.42), + b.y - h * Math.sin(ang - 0.42) + ); + ctx.lineTo(b.x, b.y); + ctx.lineTo( + b.x - h * Math.cos(ang + 0.42), + b.y - h * Math.sin(ang + 0.42) + ); + ctx.stroke(); + } + } else if (s.tool === 'rect') { + const x = Math.min(a.x, b.x), + y = Math.min(a.y, b.y), + w = Math.abs(b.x - a.x), + h = Math.abs(b.y - a.y); + const r = Math.min(6, w / 2, h / 2); + ctx.beginPath(); + ctx.roundRect ? ctx.roundRect(x, y, w, h, r) : ctx.rect(x, y, w, h); + ctx.stroke(); + } else if (s.tool === 'ellipse') { + ctx.beginPath(); + ctx.ellipse( + (a.x + b.x) / 2, + (a.y + b.y) / 2, + Math.abs(b.x - a.x) / 2, + Math.abs(b.y - a.y) / 2, + 0, + 0, + Math.PI * 2 ); ctx.stroke(); } - } else if (s.tool === 'rect') { - ctx.strokeRect( - Math.min(a.x, b.x), - Math.min(a.y, b.y), - Math.abs(b.x - a.x), - Math.abs(b.y - a.y) - ); - } else if (s.tool === 'ellipse') { - ctx.ellipse( - (a.x + b.x) / 2, - (a.y + b.y) / 2, - Math.abs(b.x - a.x) / 2, - Math.abs(b.y - a.y) / 2, - 0, - 0, - Math.PI * 2 - ); - ctx.stroke(); - } - ctx.restore(); - } - function redraw() { + ctx.restore(); + }, + [] + ); + + /* The beam is ONE tapered shape — a comet, not a row of dots: the trail is + smoothed, offset to a left and a right edge whose half-width grows from + the tail to the head, then filled in a single pass. No per-segment + strokes, so no beads, no banding where segments overlap. */ + const paintLaser = useCallback( + (ctx: CanvasRenderingContext2D, now: number) => { + const raw = laser.current; + if (!raw.length) return; + const head = raw[raw.length - 1]; + const fade = 1 - Math.min(1, (now - head.t) / LASER_LIFE); + if (fade <= 0) return; + const c = resolve(colorRef.current); + + ctx.save(); + ctx.shadowColor = c; + + if (raw.length > 2) { + // smooth the samples so the offsets can't flip on jittery input + const sm = raw.map((p, i) => { + const a = raw[Math.max(0, i - 1)], + b = raw[Math.min(raw.length - 1, i + 1)]; + return { x: (a.x + 2 * p.x + b.x) / 4, y: (a.y + 2 * p.y + b.y) / 4 }; + }); + const n = sm.length; + const left: { x: number; y: number }[] = []; + const right: { x: number; y: number }[] = []; + for (let i = 0; i < n; i++) { + const p = sm[i], + a = sm[Math.max(0, i - 1)], + b = sm[Math.min(n - 1, i + 1)]; + const dx = b.x - a.x, + dy = b.y - a.y; + const len = Math.hypot(dx, dy) || 1; + const nx = -dy / len, + ny = dx / len; + // the tail keeps real body instead of thinning away to nothing + const w = 4.2 * (0.42 + 0.58 * Math.pow(i / (n - 1), 0.7)); // tail → head + left.push({ x: p.x + nx * w, y: p.y + ny * w }); + right.push({ x: p.x - nx * w, y: p.y - ny * w }); + } + ctx.beginPath(); + ctx.moveTo(left[0].x, left[0].y); + for (let i = 1; i < n; i++) ctx.lineTo(left[i].x, left[i].y); + for (let i = n - 1; i >= 0; i--) ctx.lineTo(right[i].x, right[i].y); + ctx.closePath(); + ctx.fillStyle = c; + ctx.globalAlpha = 0.92 * fade; + ctx.shadowBlur = 16; + ctx.fill(); + } + + // the dot itself: a soft halo, a solid core, a white centre + ctx.globalAlpha = 0.5 * fade; + ctx.shadowBlur = 26; + ctx.fillStyle = c; + ctx.beginPath(); + ctx.arc(head.x, head.y, 11, 0, Math.PI * 2); + ctx.fill(); + ctx.globalAlpha = fade; + ctx.shadowBlur = 16; + ctx.beginPath(); + ctx.arc(head.x, head.y, 5, 0, Math.PI * 2); + ctx.fill(); + ctx.shadowBlur = 0; + ctx.fillStyle = '#fff'; + ctx.beginPath(); + ctx.arc(head.x, head.y, 1.9, 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); + }, + [] + ); + + const redraw = useCallback(() => { const cv = canvasRef.current; if (!cv) return; const ctx = cv.getContext('2d'); @@ -286,123 +598,390 @@ export default function Annotator({ const dpr = window.devicePixelRatio || 1; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, cv.width / dpr, cv.height / dpr); - for (const s of strokes.current) paint(ctx, s, clientPts(s)); + boxCache = new Map(); + // highlighter first, so ink always reads on top of the wash + for (const s of strokes.current) + if (s.tool === 'highlighter') paint(ctx, s, clientPts(s)); + for (const s of strokes.current) + if (s.tool !== 'highlighter') paint(ctx, s, clientPts(s)); + boxCache = null; if (draft.current) paint(ctx, draft.current, draft.current.points); - } - function commit() { - store[slide] = strokes.current; - redraw(); - } - function erase(x: number, y: number) { - const before = strokes.current.length; - strokes.current = strokes.current.filter((s) => !hits(s, x, y, 12)); - if (strokes.current.length !== before) commit(); - } + paintLaser(ctx, performance.now()); + }, [paint, paintLaser]); + + const schedule = useCallback(() => { + if (raf.current) return; + raf.current = requestAnimationFrame(() => { + raf.current = 0; + redraw(); + }); + }, [redraw]); + + /* ── history ─────────────────────────────────────────────────────── */ + const apply = useCallback( + (next: Stroke[], record = true) => { + if (record) { + past.current.push(strokes.current); + future.current = []; + } + strokes.current = next; + store[slide] = next; + saveAnnotations(store); + forceHistory((v) => v + 1); + schedule(); + }, + [schedule, slide, store] + ); + const undo = useCallback(() => { + if (!past.current.length) return; + future.current.push(strokes.current); + apply(past.current.pop()!, false); + }, [apply]); + const redo = useCallback(() => { + if (!future.current.length) return; + past.current.push(strokes.current); + apply(future.current.pop()!, false); + }, [apply]); + const clear = useCallback(() => { + if (strokes.current.length) apply([]); + }, [apply]); + + /* one eraser drag = one undo step: the pre-drag state is banked on + pointerdown and pushed to history once, when the drag ends */ + const erase = useCallback( + (x: number, y: number) => { + boxCache = new Map(); + const next = strokes.current.filter((s) => !hits(s, x, y, ERASER_R)); + boxCache = null; + if (next.length !== strokes.current.length) apply(next, false); + }, + [apply] + ); + + /* ── laser animation loop (runs only while a trail is alive) ─────── */ + const pumpLaser = useCallback(() => { + if (laserRaf.current) return; + const step = () => { + const now = performance.now(); + // held still with the button down: keep the dot alive, let the tail expire + if (laserHeld.current && laser.current.length) + laser.current[laser.current.length - 1].t = now; + laser.current = laser.current.filter((p) => now - p.t < LASER_LIFE); + redraw(); + laserRaf.current = laser.current.length ? requestAnimationFrame(step) : 0; + }; + laserRaf.current = requestAnimationFrame(step); + }, [redraw]); + + useEffect(() => { + if (hold) { + setInked(false); + return; + } + const t = window.setTimeout(() => setInked(true), 110); // a beat behind the slide + return () => clearTimeout(t); + }, [hold]); + + /* ── canvas sizing + following the layout until it settles ───────── */ useEffect(() => { const cv = canvasRef.current; if (!cv) return; + let watchRaf = 0; const fit = () => { const dpr = window.devicePixelRatio || 1; - cv.width = window.innerWidth * dpr; - cv.height = window.innerHeight * dpr; + cv.width = Math.round(window.innerWidth * dpr); + cv.height = Math.round(window.innerHeight * dpr); cv.style.width = window.innerWidth + 'px'; cv.style.height = window.innerHeight + 'px'; redraw(); }; - fit(); - const onUp = () => { - const d = draft.current; - if (!d) return; - draft.current = null; - // anchor the stroke to the content under its center, then store its - // points relative to that element's box - const xs = d.points.map((p) => p.x), - ys = d.points.map((p) => p.y); - const cx = (Math.min(...xs) + Math.max(...xs)) / 2; - const cy = (Math.min(...ys) + Math.max(...ys)) / 2; - d.anchor = anchorAt(cx, cy); - const r = resolveAnchor(d.anchor) ?? viewportBox(); - d.points = d.points.map((p) => ({ - x: (p.x - r.left) / r.width, - y: (p.y - r.top) / r.height, - })); - strokes.current.push(d); - commit(); + /* Coming back to a slide replays its entrance motion, so anchor boxes keep + moving for a while (staggered reveals, transforms, late-loading images). + Re-place the ink every frame until the geometry stops changing — that is + what makes returning to a slide land the strokes exactly where they were + drawn, rather than wherever the layout happened to be at a fixed delay. */ + const watch = () => { + if (watchRaf) cancelAnimationFrame(watchRaf); + let sig = ''; + let stableSince = 0; + const start = performance.now(); + const tick = () => { + const now = performance.now(); + const next = geometrySignature(strokes.current); + if (next !== sig) { + sig = next; + stableSince = now; + redraw(); + } + // keep watching until it has held still for 250ms (2.5s ceiling) + watchRaf = + now - stableSince < 250 && now - start < 2500 + ? requestAnimationFrame(tick) + : 0; + }; + watchRaf = requestAnimationFrame(tick); }; - // re-render after the slide's entrance motion settles (anchor boxes move) - const t1 = window.setTimeout(redraw, 400); - const t2 = window.setTimeout(redraw, 1100); + fit(); + watch(); + const ro = new ResizeObserver(() => { + schedule(); + watch(); + }); + const st = stage(); + if (st) ro.observe(st); + document.fonts?.ready.then(watch).catch(() => {}); window.addEventListener('resize', fit); - window.addEventListener('pointerup', onUp); return () => { - clearTimeout(t1); - clearTimeout(t2); + ro.disconnect(); + if (watchRaf) cancelAnimationFrame(watchRaf); window.removeEventListener('resize', fit); - window.removeEventListener('pointerup', onUp); + if (raf.current) cancelAnimationFrame(raf.current); + if (laserRaf.current) cancelAnimationFrame(laserRaf.current); }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [redraw, schedule]); - function down(e: React.PointerEvent) { - if (toolRef.current === 'eraser') { + /* ── committing a stroke ─────────────────────────────────────────── */ + const commitDraft = useCallback(() => { + const d = draft.current; + draft.current = null; + lastIn.current = null; + if (!d || !d.points.length) { + schedule(); + return; + } + // anchor to the content under the stroke's center, then store its points + // relative to that element's box + const xs = d.points.map((p) => p.x), + ys = d.points.map((p) => p.y); + const cx = (Math.min(...xs) + Math.max(...xs)) / 2; + const cy = (Math.min(...ys) + Math.max(...ys)) / 2; + d.anchor = anchorAt(cx, cy); + const r = resolveAnchor(d.anchor) ?? viewportBox(); + const v = viewportBox(); + d.abox = [ + r.left / v.width, + r.top / v.height, + r.width / v.width, + r.height / v.height, + ]; + d.points = d.points.map((p) => ({ + x: (p.x - r.left) / r.width, + y: (p.y - r.top) / r.height, + w: p.w, + })); + apply([...strokes.current, d]); + }, [apply, schedule]); + + const endSession = useCallback(() => { + setDrawingNow(false); + laserHeld.current = false; + const base = eraseBase.current; + eraseBase.current = null; + if (base && base !== strokes.current) { + past.current.push(base); + future.current = []; + forceHistory((v) => v + 1); + } + if (draft.current) commitDraft(); + }, [commitDraft]); + + useEffect(() => { + window.addEventListener('pointerup', endSession); + window.addEventListener('pointercancel', endSession); + return () => { + window.removeEventListener('pointerup', endSession); + window.removeEventListener('pointercancel', endSession); + }; + }, [endSession]); + + // leaving draw mode mid-stroke still commits what was drawn + useEffect(() => { + if (!active) { + endSession(); + setStyleOpen(false); + laser.current = []; + } + }, [active, endSession]); + + // click anywhere else closes the style popover + useEffect(() => { + if (!styleOpen) return; + const onDown = (e: PointerEvent) => { + if (!(e.target as HTMLElement | null)?.closest?.('.ann-style')) + setStyleOpen(false); + }; + document.addEventListener('pointerdown', onDown); + return () => document.removeEventListener('pointerdown', onDown); + }, [styleOpen]); + + /* ── keyboard (only while drawing mode is on) ────────────────────── */ + useEffect(() => { + if (!active) return; + const onKey = (e: KeyboardEvent) => { + const t = e.target as HTMLElement | null; + if ( + t && + (t.tagName === 'TEXTAREA' || + t.tagName === 'INPUT' || + t.isContentEditable) + ) + return; + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'z') { + e.preventDefault(); + e.shiftKey ? redo() : undo(); + return; + } + if (e.metaKey || e.ctrlKey || e.altKey) return; + const k = e.key.toLowerCase(); + const hit = TOOLS.find((x) => x.key.toLowerCase() === k); + if (hit) { + e.preventDefault(); + setTool(hit.id); + return; + } + const n = parseInt(e.key, 10); + if (n >= 1 && n <= COLORS.length) { + e.preventDefault(); + setColor(COLORS[n - 1]); + return; + } + if (e.key === '[' || e.key === ']') { + e.preventDefault(); + setSize( + (s) => + SIZES[ + Math.max( + 0, + Math.min( + SIZES.length - 1, + SIZES.indexOf(s) + (e.key === ']' ? 1 : -1) + ) + ) + ] + ); + return; + } + if (e.key === 'Backspace' || e.key === 'Delete') { + e.preventDefault(); + clear(); + } + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [active, undo, redo, clear]); + + /* ── input ───────────────────────────────────────────────────────── */ + /* width factor from stylus pressure, or from speed for mouse/touch + (a fast stroke runs thinner), damped so it never steps */ + function widthFactor(e: { + clientX: number; + clientY: number; + pressure: number; + pointerType: string; + }) { + const now = performance.now(); + const prev = lastIn.current; + let w: number; + if (e.pointerType === 'pen' && e.pressure > 0 && e.pressure !== 0.5) { + w = 0.35 + e.pressure * 1.05; + } else if (prev) { + const v = + Math.hypot(e.clientX - prev.x, e.clientY - prev.y) / + Math.max(4, now - prev.t); // px/ms + w = 1.15 - Math.min(0.62, v * 0.5); + } else w = 1; + const damped = prev ? prev.w + (w - prev.w) * 0.4 : w; + const clamped = Math.max(0.34, Math.min(1.35, damped)); + lastIn.current = { x: e.clientX, y: e.clientY, t: now, w: clamped }; + return clamped; + } + + const ignore = (e: React.PointerEvent) => { + if (e.pointerType === 'pen') penSeen.current = true; + return penSeen.current && e.pointerType === 'touch'; + }; + + function down(e: React.PointerEvent) { + if (!active || ignore(e) || e.button > 0) return; + setInked(true); // drawing during the entrance shows immediately + e.currentTarget.setPointerCapture?.(e.pointerId); + setStyleOpen(false); + const t = toolRef.current; + setDrawingNow(true); + if (t === 'laser') { + laserHeld.current = true; + laser.current = [{ x: e.clientX, y: e.clientY, t: performance.now() }]; + pumpLaser(); + return; + } + if (t === 'eraser') { + eraseBase.current = strokes.current; erase(e.clientX, e.clientY); return; } + lastIn.current = null; draft.current = { - tool: toolRef.current, + tool: t, color: colorRef.current, size: sizeRef.current, - points: [{ x: e.clientX, y: e.clientY }], + points: [{ x: e.clientX, y: e.clientY, w: widthFactor(e) }], }; - redraw(); + schedule(); } - function move(e: React.PointerEvent) { - if (toolRef.current === 'eraser') { + + function move(e: React.PointerEvent) { + if (!active || ignore(e)) return; + const t = toolRef.current; + if (t === 'laser') { + // the beam only fires while the pointer is held down; the cursor + // reticle is what you aim with before that + if (!e.buttons || !laser.current.length) return; + laser.current.push({ x: e.clientX, y: e.clientY, t: performance.now() }); + if (laser.current.length > 160) + laser.current.splice(0, laser.current.length - 160); + pumpLaser(); + return; + } + if (t === 'eraser') { if (e.buttons) erase(e.clientX, e.clientY); return; } const d = draft.current; if (!d) return; - if (d.tool === 'pen' || d.tool === 'highlighter') - d.points.push({ x: e.clientX, y: e.clientY }); - else d.points = [d.points[0], { x: e.clientX, y: e.clientY }]; - redraw(); - } - - // drag-to-scroll for the bar when it overflows (phones scroll natively by - // touch; this covers mouse users). A 4px threshold keeps clicks working, - // and a capture-phase click handler swallows the click after a real drag. - const barRef = useRef(null); - const barDrag = useRef({ down: false, moved: false, x: 0, left: 0 }); - function barDown(e: React.PointerEvent) { - if (e.pointerType !== 'mouse' || !barRef.current) return; - barDrag.current = { - down: true, - moved: false, - x: e.clientX, - left: barRef.current.scrollLeft, - }; - } - function barMove(e: React.PointerEvent) { - const d = barDrag.current; - if (!d.down || !barRef.current) return; - const dx = e.clientX - d.x; - if (!d.moved && Math.abs(dx) < 4) return; - d.moved = true; - barRef.current.scrollLeft = d.left - dx; - } - function barUp() { - barDrag.current.down = false; - } - function barClickCapture(e: React.MouseEvent) { - if (barDrag.current.moved) { - e.preventDefault(); - e.stopPropagation(); - barDrag.current.moved = false; + if (d.tool === 'pen' || d.tool === 'highlighter') { + // coalesced events keep every sample a high-rate stylus reported + const evs = e.nativeEvent.getCoalescedEvents?.() ?? []; + const pts = evs.length ? evs : [e.nativeEvent]; + for (const ev of pts) { + const w = widthFactor({ + clientX: ev.clientX, + clientY: ev.clientY, + pressure: ev.pressure ?? 0.5, + pointerType: e.pointerType, + }); + d.points.push({ x: ev.clientX, y: ev.clientY, w }); + } + } else { + // hold Shift for 45° lines and perfect squares / circles + const end = e.shiftKey + ? constrain(d.tool, d.points[0], { x: e.clientX, y: e.clientY }) + : { x: e.clientX, y: e.clientY }; + d.points = [d.points[0], end]; } + schedule(); } + const canUndo = past.current.length > 0; + const canRedo = future.current.length > 0; + const styleTool = + tool === 'pen' || + tool === 'highlighter' || + tool === 'line' || + tool === 'arrow' || + tool === 'rect' || + tool === 'ellipse'; + return ( <> {active && (
e.stopPropagation()} > - {TOOLS.map((t) => ( - - ))} - - {COLORS.map((c) => ( - + ))} +
+ - {SIZES.map((s) => ( + +
- ))} + {styleOpen && ( +
+
+ {COLORS.map((c, i) => ( +
+
+ {SIZES.map((s) => ( + + ))} +
+
+ {styleTool + ? '1–6 colors · [ ] size' + : 'Pick a drawing tool to use these'} +
+
+ )} +
+ + + + {onDone && ( + <> + + + + )}
)} diff --git a/bolt-slides/src/deck/Build.tsx b/bolt-slides/src/deck/Build.tsx index f66b87de..6a9d7bdc 100644 --- a/bolt-slides/src/deck/Build.tsx +++ b/bolt-slides/src/deck/Build.tsx @@ -1,7 +1,7 @@ import { motion } from 'motion/react'; import { useEffect } from 'react'; import type { CSSProperties, ReactNode } from 'react'; -import { useDeck } from '@/deck/DeckContext'; +import { useDeck } from './DeckContext'; /* Click-build (the Slidev "v-click"): this element stays hidden until the presenter advances to step `at` (1-based) on the current slide, then it diff --git a/bolt-slides/src/deck/Deck.tsx b/bolt-slides/src/deck/Deck.tsx index 4e8507dc..f05d0bc3 100644 --- a/bolt-slides/src/deck/Deck.tsx +++ b/bolt-slides/src/deck/Deck.tsx @@ -2,18 +2,24 @@ import { Children, useCallback, useEffect, - useLayoutEffect, useMemo, useRef, useState, } from 'react'; import type { ReactElement, ReactNode } from 'react'; -import { MotionConfig } from 'motion/react'; -import { DeckCtx } from '@/deck/DeckContext'; -import Annotator, { type Stroke } from '@/deck/Annotator'; import { - IconSidebar, + AnimatePresence, + MotionConfig, + motion, + type Variants, +} from 'motion/react'; +import { DeckCtx } from './DeckContext'; +import Annotator, { loadAnnotations, type Stroke } from './Annotator'; +import Thumb from './Thumb'; +import Presenter from './Presenter'; +import { IconGrid, + IconSidebar, IconLeft, IconRight, IconPencil, @@ -21,65 +27,84 @@ import { IconShrink, IconPresent, IconClose, -} from '@/deck/icons'; +} from './icons'; -/* ── The paged presentation engine + the Slidev-style chrome (dock + rail). +/* ── The paged presentation engine + the Slidev-style chrome (dock, side + panel, grid overview). Wrap your //… in . Each top-level child is one slide. → / ↓ / Space next (reveals the next , then the next slide) - ← / ↑ previous S sidebar G grid view - Home / End first / last A annotate P presenter (new tab) - F fullscreen H hide/show the UI + ← / ↑ previous S side panel G grid overview + Home / End first / last D draw F fullscreen + H hide/show the UI P presenter (new tab) + While drawing (D), the annotator owns the letter keys: P pen · H highlighter + · L laser · I line · A arrow · R rect · O ellipse · E eraser · 1–6 colour · + [ ] size · ⌘Z / ⇧⌘Z undo+redo · ⌫ clear · D or Esc to finish. Copy verbatim; theme only via the :root tokens. ───────────────────────── */ -const fmt = (s: number) => - `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart( - 2, - '0' - )}`; - -function Thumb({ children }: { children: ReactNode }) { - const frameRef = useRef(null); - const [d, setD] = useState({ vw: 1280, vh: 720, scale: 0.15 }); - // measure before paint — with useEffect the first frame renders at the - // default scale and visibly snaps (worst in the grid view, which has no - // slide-in transition to mask it). - useLayoutEffect(() => { - const el = frameRef.current; - if (!el) return; - const update = () => - setD({ - vw: window.innerWidth, - vh: window.innerHeight, - scale: el.clientWidth / window.innerWidth, - }); - update(); - const ro = new ResizeObserver(update); - ro.observe(el); - window.addEventListener('resize', update); - return () => { - ro.disconnect(); - window.removeEventListener('resize', update); - }; - }, []); - return ( -
- -
- {children} -
-
-
- ); +/* True once a scroll container has moved off the top — the sticky heads stay + fully transparent until something actually scrolls under them. */ +function useScrolled( + ref: React.RefObject, + active: boolean +) { + const [scrolled, setScrolled] = useState(false); + useEffect(() => { + const el = ref.current; + if (!active || !el) { + setScrolled(false); + return; + } + const onScroll = () => setScrolled(el.scrollTop > 0); + onScroll(); + el.addEventListener('scroll', onScroll, { passive: true }); + return () => el.removeEventListener('scroll', onScroll); + }, [ref, active]); + return scrolled; } -export default function Deck({ children }: { children: ReactNode }) { +/* Slide-to-slide transition variants. The deck default comes from the + `transition` prop; a slide can override it via a `transition` prop on the + slide element itself (SlideView passes the row's value through). */ +const TRANSITIONS: Record = { + fade: { + initial: { opacity: 0 }, + animate: { opacity: 1 }, + exit: { opacity: 0 }, + }, + slide: { + initial: { opacity: 0, x: 56 }, + animate: { opacity: 1, x: 0 }, + exit: { opacity: 0, x: -56 }, + }, + rise: { + initial: { opacity: 0, y: 44 }, + animate: { opacity: 1, y: 0 }, + exit: { opacity: 0, y: -44 }, + }, + zoom: { + initial: { opacity: 0, scale: 0.965 }, + animate: { opacity: 1, scale: 1 }, + exit: { opacity: 0, scale: 1.02 }, + }, + none: { initial: {}, animate: {}, exit: {} }, +}; + +export default function Deck({ + children, + transition = 'fade', + onNotes, + navLabel, + allowPresenter = true, +}: { + children: ReactNode; + transition?: string; + /** false on an audience share link — the console shows speaker notes */ + allowPresenter?: boolean; + /** persist a slide's speaker notes (presenter console edits) */ + onNotes?: (index: number, text: string) => void; + /** optional short name for a slide, shown as "up next" in the console */ + navLabel?: (index: number) => string | undefined; +}) { const slides = useMemo( () => Children.toArray(children) as ReactElement[], [children] @@ -96,28 +121,35 @@ export default function Deck({ children }: { children: ReactNode }) { }); const [clicks, setClicks] = useState(0); const [curMax, setCurMax] = useState(0); - const [railOpen, setRailOpen] = useState(false); - const [gridOpen, setGridOpen] = useState(false); + // two ways to browse the deck, mutually exclusive: a persistent side panel + // (stays open while you jump around) and a full-screen grid overview (a + // picker — it closes on pick). Opening one closes the other. + const [browse, setBrowse] = useState<'none' | 'rail' | 'grid'>('none'); + const railOpen = browse === 'rail'; + const gridOpen = browse === 'grid'; + const toggleRail = useCallback( + () => setBrowse((b) => (b === 'rail' ? 'none' : 'rail')), + [] + ); + const toggleGrid = useCallback( + () => setBrowse((b) => (b === 'grid' ? 'none' : 'grid')), + [] + ); + const closeBrowse = useCallback(() => setBrowse('none'), []); const [drawing, setDrawing] = useState(false); - const [elapsed, setElapsed] = useState(0); + // false while the incoming slide is still animating — annotations wait for it + const [stageIn, setStageIn] = useState(false); const [fs, setFs] = useState(false); const [uiHidden, setUiHidden] = useState(false); const [nearDock, setNearDock] = useState(false); const [cursorIdle, setCursorIdle] = useState(false); - const [noteOverrides, setNoteOverrides] = useState>( - () => { - try { - return JSON.parse(localStorage.getItem('deck:notes') || '{}'); - } catch { - return {}; - } - } - ); // per-slide build maxima (so going back restores the right click state) and // per-slide annotations (so drawings persist on the slide they were made). const maxMap = useRef>({}); - const annStore = useRef>({}); + const annStore = useRef>(loadAnnotations()); + const railRef = useRef(null); + const gridRef = useRef(null); const slideRef = useRef(slide); slideRef.current = slide; @@ -166,30 +198,11 @@ export default function Deck({ children }: { children: ReactNode }) { if (document.fullscreenElement) document.exitFullscreen(); else document.documentElement.requestFullscreen?.(); }, []); - // sidebar and grid view are mutually exclusive — opening one closes the other - const toggleRail = useCallback(() => { - setRailOpen((v) => !v); - setGridOpen(false); - }, []); - const toggleGrid = useCallback(() => { - setGridOpen((v) => !v); - setRailOpen(false); - }, []); - const setNote = useCallback((text: string) => { - setNoteOverrides((prev) => { - const nextO = { ...prev, [slideRef.current]: text }; - try { - localStorage.setItem('deck:notes', JSON.stringify(nextO)); - } catch { - /* ignore */ - } - return nextO; - }); - }, []); const openPresenter = useCallback(() => { if (isPresenter) return; - const url = - window.location.pathname + '?presenter=1' + window.location.hash; + const params = new URLSearchParams(window.location.search); + params.set('presenter', '1'); + const url = `${window.location.pathname}?${params}${window.location.hash}`; window.open(url, 'deck-presenter'); }, [isPresenter]); @@ -227,6 +240,13 @@ export default function Deck({ children }: { children: ReactNode }) { e.preventDefault(); go(total - 1); break; + // while drawing, the annotator owns the letter keys it uses (O, P, H + // are ellipse / pen / highlighter there) — D and Escape still exit + case 'o': + case 'O': + if (drawing) break; + toggleRail(); + break; case 's': case 'S': toggleRail(); @@ -239,21 +259,23 @@ export default function Deck({ children }: { children: ReactNode }) { case 'F': toggleFs(); break; - case 'a': - case 'A': + case 'd': + case 'D': + if (isPresenter) break; setDrawing((v) => !v); break; case 'p': case 'P': + if (drawing || !allowPresenter) break; openPresenter(); break; case 'h': case 'H': + if (drawing) break; setUiHidden((v) => !v); break; case 'Escape': - setRailOpen(false); - setGridOpen(false); + closeBrowse(); setDrawing(false); setUiHidden(false); break; @@ -261,18 +283,28 @@ export default function Deck({ children }: { children: ReactNode }) { }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); - }, [next, prev, go, total, toggleRail, toggleGrid, toggleFs, openPresenter]); + }, [ + next, + prev, + go, + total, + toggleFs, + openPresenter, + toggleRail, + toggleGrid, + closeBrowse, + drawing, + isPresenter, + allowPresenter, + ]); - // safety net: if the authored deck kept the placeholder tab title, derive - // one from the current slide's heading so shared links look right. + // the slide's entrance owns the screen first: hold the ink until the stage + // has finished animating in (with a safety net if no animation ever runs) useEffect(() => { - if (!document.title.startsWith('Replace')) return; - const h = document.querySelector( - '.slide-stage h1, .slide-stage h2' - ); - const t = h?.innerText.replace(/\s+/g, ' ').trim(); - if (t) document.title = t; - }, []); + setStageIn(false); + const t = window.setTimeout(() => setStageIn(true), 1000); + return () => clearTimeout(t); + }, [slide]); // URL hash sync (initial slide comes from the hash via useState above) useEffect(() => { @@ -318,12 +350,6 @@ export default function Deck({ children }: { children: ReactNode }) { document.addEventListener('fullscreenchange', h); return () => document.removeEventListener('fullscreenchange', h); }, []); - useEffect(() => { - if (!isPresenter) return; - setElapsed(0); - const t = setInterval(() => setElapsed((e) => e + 1), 1000); - return () => clearInterval(t); - }, [isPresenter]); // mouse-driven only: keyboard nav keeps the UI hidden; the dock returns when // the pointer nears the bottom (where it lives); the cursor hides on idle. useEffect(() => { @@ -341,53 +367,78 @@ export default function Deck({ children }: { children: ReactNode }) { }; }, []); + const railScrolled = useScrolled(railRef, railOpen); + const gridScrolled = useScrolled(gridRef, gridOpen); + const liveCtx = useMemo( () => ({ clicks, isStatic: false, registerMax }), [clicks, registerMax] ); const hasPrev = slide > 0 || clicks > 0; const hasNext = slide < total - 1 || clicks < curMax; - const notes = (slides[slide]?.props as { notes?: string } | undefined)?.notes; - const noteText = noteOverrides[slide] ?? notes ?? ''; - const nextSlide = slides[slide + 1]; + const noteText = + (slides[slide]?.props as { notes?: string } | undefined)?.notes ?? ''; + const slideTransition = + (slides[slide]?.props as { transition?: string } | undefined)?.transition || + transition; const hideUI = uiHidden || (fs && !nearDock); const cursorHidden = fs && cursorIdle && !drawing; const showAnnotator = drawing || (annStore.current[slide]?.length ?? 0) > 0; - // prev / counter / next — rendered inside the pill on desktop, in its own - // pill above the tools on phones (only one is visible at a time). - const navCluster = ( - <> - -
- {slide + 1} - / {total} -
- - - ); + /* Presenter mode is its own screen: a console with the live slide, what is + next, the notes and the clock — not a second copy of the presentation. + The audience window stays in step over the BroadcastChannel. */ + if (isPresenter) { + return ( + + + + ); + } return (
-
- {slides[slide]} -
+ + {(() => { + const t = slideTransition; + const v = TRANSITIONS[t] ?? TRANSITIONS.fade; + return ( + { + if (d === 'animate') setStageIn(true); + }} + > + {slides[slide]} + + ); + })()} +
{showAnnotator && ( @@ -396,30 +447,39 @@ export default function Deck({ children }: { children: ReactNode }) { slide={slide} store={annStore.current} active={drawing} + onDone={() => setDrawing(false)} + hold={!stageIn} /> )} -