diff --git a/bolt-slides/.bolt/skills/slides/SKILL.md b/bolt-slides/.bolt/skills/slides/SKILL.md index 5acdd496..29a0328f 100644 --- a/bolt-slides/.bolt/skills/slides/SKILL.md +++ b/bolt-slides/.bolt/skills/slides/SKILL.md @@ -30,7 +30,7 @@ Two halves — keep them separate: > **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. -## ⛔ Two hard rules +## ⛔ 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 @@ -38,6 +38,16 @@ Two halves — keep them separate: 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.) --- @@ -60,8 +70,10 @@ 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/ CountUp TiltCard Marquee Bento Split StatGrid VisualDashboard Accordion - Comparison Tabs Timeline CodeWindow BrowserFrame SpotlightCard Charts +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 / @@ -86,13 +98,39 @@ of `base.css`. Derive from the brand when given. Compose slides in `App.tsx`. The building blocks: -- **``** — one slide. `center` for cover/statement/quote/CTA; `full` for +- **``** — one slide. `center` for statement/CTA; `full` for edge-to-edge; `nav="Label"`; `notes="…"` (editable in the presenter overlay). +- **``** — the opening slide: kicker → display title → subtitle cascade, + optional full-bleed `image` under a theme-correct scrim, optional `foot` line. +- **``** — ONE enormous accent figure (pass a ``) + caption. + Every deck should have one of these drama beats. +- **``** — before/after, problem→solution: a muted panel vs an + accent-lit panel with cross/check points. The classic pitch move. +- **``** — a chat window whose messages reveal one per click (builds) — + user bubbles on the accent, assistant on the surface. For AI-product decks. +- **``** — 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. - **``** — text + edge-to-edge media (`flip` swaps). media = ``, a color panel, a ``, or ``. - **``** — asymmetric tile grid; tiles take `c`/`r` spans + `variant`. - **``** — responsive proof cards; pass a `` as a stat `value`. +- **`
`** — chapter divider: ghost number + accent glows. The full-bleed + breather for decks without photography; use between parts. +- **``** — numbered table-of-contents rows (strings, or `{title, hint}`). +- **``** — horizontal numbered process; the connector draws in. Use for + "how it works" instead of a bulleted list. +- **``** — 2–4 tier cards; `highlight: true` crowns one with a badge. +- **``** — people grid; photos via `img`, else auto-initials on the accent. +- **``** — pull-quote slide with attribution (don't add quotation marks — + the accent mark provides them). - **``** — us-vs-them feature matrix; one column highlighted in the accent. +- **``** — 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. - **``** — tabbed content with a sliding accent pill. - **``** — expand/collapse panels (FAQ, feature detail). - **``** — vertical roadmap that draws its connector + milestones in. @@ -104,16 +142,43 @@ Compose slides in `App.tsx`. The building blocks: - **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: + +- **``** — 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. +- **``** — only when pricing is actually part of this deck's ask. +- **``** — pitch and agency decks; skip in launches, reports, teaching. +- **`
`** — only in decks long enough to have real chapters (~12+ slides). +- **``** — needs one real, defensible figure (cite it in `foot`). + At most one per deck — two giant numbers cancel each other out. +- **``** — when a genuine before/after exists; don't build a strawman. +- **``** — formal or long presentations; an 8-slide pitch needs no TOC. +- **``** — 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 ``, ``, ``, ``, `` 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:** 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: a centered heading over a centered content block (e.g. -> `marginInline:'auto'`). `Comparison`, `Tabs`, `Timeline`, `Accordion`, `StatGrid` look -> best centered unless paired with a side visual. +> **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: ``, 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 **``** — it stays hidden until you advance @@ -122,12 +187,6 @@ next build, then moves to the next slide. Use it for: the punchline after its se each step of a process, items appearing in turn. Use **``** for an on-enter entrance (no click needed) on headlines/grids. -**Auto-play mode.** The same ``s can instead reveal **automatically, staggered, -on slide load** — no clicking. Toggle it live with the dock's auto button or the `A` -key, or start the deck in auto mode with **``** (tune the gap with -``, seconds). Ideal for a self-running deck or mobile viewing; -click-mode stays the default. - ```tsx

Three things changed.

@@ -146,10 +205,17 @@ 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` stack (built in); make sure headlines - don't overflow and nothing needs scrolling. (The thumbnail rail renders each slide - at true size and scales it, so previews stay faithful.) +- **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. --- @@ -162,6 +228,14 @@ 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): +``, `
`, `` (full-bleed backgrounds), +`` tiles with `img` (full-bleed photo tiles, text anchored at the bottom), +`` people with `img`, `}>`, and `` +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) @@ -185,7 +259,7 @@ chrome) are off-limits to rewrite; **adding** components/visuals is encouraged. 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). +CSS + SVG — even the `` is dependency-free). --- @@ -200,11 +274,21 @@ 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. + +--- + ## 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, - auto-play / fullscreen / overview work, annotation (D) has full tools and + fullscreen / overview work, annotation (D) 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 @@ -214,6 +298,13 @@ talking points where useful. 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. diff --git a/bolt-slides/README.md b/bolt-slides/README.md index 5cd949f4..3d42c45c 100644 --- a/bolt-slides/README.md +++ b/bolt-slides/README.md @@ -2,19 +2,21 @@ 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, click-builds, auto-play, annotation, and presenter mode — but each slide is a +rail, 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. ## The skill This repo **is** the running app. The authoring guide lives at -[`.bolt/skills/slides.md`](./.bolt/skills/slides.md); the app itself sits at the repo -root — a complete Vite + React deck: the paged engine + chrome (`src/deck/`), ~14 -components (`src/components/`: Bento, Split, StatGrid, 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`](./.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. ## Add it in Bolt diff --git a/bolt-slides/src/App.tsx b/bolt-slides/src/App.tsx index e7769f2f..f209b6db 100644 --- a/bolt-slides/src/App.tsx +++ b/bolt-slides/src/App.tsx @@ -17,11 +17,23 @@ 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). (A = auto-play.) + notes="…" on any slide (shown in presenter mode — press P). ══════════════════════════════════════════════════════════════════════ */ const panel = (extra = 0.22): React.CSSProperties => ({ position: 'absolute', @@ -41,23 +53,14 @@ export default function App() { return ( {/* Cover */} - - -
- Helix · Component demo -
-

- Helix -

-

- A responsive React deck engine. Delete this and build the real one. -

-
-
+ kicker="Bolt Slides · Component demo" + title={Bolt Slides} + subtitle="A responsive React deck engine. Delete this and build the real one." + foot="June 2026 · Component demo" + /> {/* Statement + click-build */}

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

+ {/* Agenda */} + + + {/* Contrast — the problem */} + + {/* Split feature */}
-
+
@@ -133,6 +184,54 @@ export default function App() { ]} /> + {/* 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 */} + {/* BigNumber */} + } + caption="events answered in under a second." + foot="Production traffic, trailing 30 days" + /> + + {/* Section divider */} +
+ How it works. + + } + /> + + {/* Steps */} + + + {/* Chat */} + + {/* Comparison */}
+

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

@@ -257,7 +422,7 @@ export default function App() { { label: 'Ops', content: ( -

+

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

@@ -286,7 +451,7 @@ export default function App() {
- +
- Helix + Bolt Slides
{['Overview', 'Events', 'Funnels', 'Cohorts', 'Settings'].map( (n, i) => ( @@ -381,7 +544,8 @@ track('signup', {
-
+
Weekly active @@ -505,6 +662,44 @@ track('signup', { + {/* Data table */} + + +
+ Unit economics +
+

+ Growth, by region. +

+
+ +
+ + + {/* Timeline */} + {/* Pricing */} + + {/* Spotlight principles */} -
+
{[ { k: '01', @@ -660,7 +894,7 @@ track('signup', { items={[ { title: 'How long does setup take?', - body: 'Five minutes — point Helix at your warehouse and you are live.', + body: 'Five minutes — point Bolt Slides at your warehouse and you are live.', }, { title: 'Can we self-host?', @@ -676,6 +910,20 @@ track('signup', { + {/* Team */} + + {/* Logos */} {/* Quote */} - - -

- “We replaced four tools with Helix and never looked back.” -

-
- — Dana Kim, VP Engineering -
-
-
+ text="We replaced four tools with Bolt Slides and never looked back." + name="Dana Kim" + role="VP Engineering, Acme" + /> {/* CTA */} Let's talk.

- helix.io · hello@helix.io + hello@bolt.new

diff --git a/bolt-slides/src/components/Accordion.tsx b/bolt-slides/src/components/Accordion.tsx index 9e4897cd..52abca78 100644 --- a/bolt-slides/src/components/Accordion.tsx +++ b/bolt-slides/src/components/Accordion.tsx @@ -1,25 +1,14 @@ import { useState, type ReactNode } from 'react'; -import { AnimatePresence, motion } from 'framer-motion'; +import { AnimatePresence, motion, useReducedMotion } from 'framer-motion'; -/* An expand/collapse accordion. Data-driven like Bento/StatGrid. +/* An expand/collapse accordion. Data-driven like Bento/StatGrid. Mono index, + a +/× morph affordance, and an accent-lit open state. Self-centers when it + stands alone on a slide. - single (default true): only one panel open at a time. - defaultOpen: index open on mount (null = all closed). */ export type AccordionItem = { title: string; body: ReactNode }; -const Chevron = () => ( - - - -); - export default function Accordion({ items, single = true, @@ -29,6 +18,7 @@ export default function Accordion({ single?: boolean; defaultOpen?: number | null; }) { + const reduce = useReducedMotion(); const [open, setOpen] = useState( defaultOpen == null ? [] : [defaultOpen] ); @@ -43,7 +33,7 @@ export default function Accordion({ return (
{items.map((it, i) => ( -
+
{isOpen(i) && ( @@ -62,7 +50,10 @@ export default function Accordion({ initial={{ height: 0, opacity: 0 }} animate={{ height: 'auto', opacity: 1 }} exit={{ height: 0, opacity: 0 }} - transition={{ duration: 0.32, ease: [0.16, 1, 0.3, 1] }} + transition={{ + duration: reduce ? 0 : 0.34, + ease: [0.16, 1, 0.3, 1], + }} >
{it.body}
diff --git a/bolt-slides/src/components/Agenda.tsx b/bolt-slides/src/components/Agenda.tsx new file mode 100644 index 00000000..9d4f71de --- /dev/null +++ b/bolt-slides/src/components/Agenda.tsx @@ -0,0 +1,78 @@ +import { motion, useReducedMotion } from 'framer-motion'; +import Reveal from '../deck/Reveal'; +import { useDeck } from '../deck/DeckContext'; + +/* A table-of-contents slide: hairline-ruled rows with mono accent numbers. + Pass strings, or { title, hint } for a right-aligned hint (a time, a slide + count, an owner). Rows cascade in; the block self-centers. + */ +export type AgendaItem = string | { title: string; hint?: string }; + +export default function Agenda({ + kicker, + title, + items, +}: { + kicker?: string; + title?: string; + items: AgendaItem[]; + nav?: string; + notes?: string; +}) { + const { isStatic } = useDeck(); + const reduce = useReducedMotion(); + const animate = !isStatic && !reduce; + return ( +
+
+ + {kicker && ( +
+ {kicker} +
+ )} + {title && ( +

+ {title} +

+ )} +
+
+ {items.map((it, i) => { + const item = typeof it === 'string' ? { title: it } : it; + return ( + + + {String(i + 1).padStart(2, '0')} + + {item.title} + {item.hint && {item.hint}} + + ); + })} +
+
+
+ ); +} diff --git a/bolt-slides/src/components/Bento.tsx b/bolt-slides/src/components/Bento.tsx index b9984966..faa52e84 100644 --- a/bolt-slides/src/components/Bento.tsx +++ b/bolt-slides/src/components/Bento.tsx @@ -1,8 +1,11 @@ import type { CSSProperties, ReactNode } from 'react'; +import { motion, useReducedMotion } from 'framer-motion'; import Reveal from '../deck/Reveal'; +import { useDeck } from '../deck/DeckContext'; /* A full-viewport slide laid out as an asymmetric bento grid. Spans via c - (columns of 12) and r (rows). Collapses to one column on narrow screens. + (columns of 12) and r (rows). Tiles rise in one after another; metric tiles + get an accent tick. Collapses to one column on narrow screens. nav/notes are read by the engine (rail label / presenter notes). */ export type BentoTile = { k?: string; @@ -12,6 +15,9 @@ export type BentoTile = { c?: number; r?: number; variant?: 'accent' | 'glow'; + /** full-bleed photo tile: the image covers the tile under a bottom scrim, + text anchors to the bottom. Great for brand/product decks. */ + img?: string; }; export default function Bento({ @@ -25,6 +31,9 @@ export default function Bento({ nav?: string; notes?: string; }) { + const { isStatic } = useDeck(); + const reduce = useReducedMotion(); + const animate = !isStatic && !reduce; return (
@@ -43,30 +52,61 @@ export default function Bento({ )} - +
{tiles.map((t, i) => ( -
- {t.k &&
{t.k}
} -
- {t.fig != null && ( -
- {t.fig} +
+ {t.img && ( + <> + + + + )} + {(t.k || t.fig != null) && !t.img && ( +
+ {t.fig != null && } + {t.k &&
{t.k}
}
)} - {t.title &&

{t.title}

} - {t.body &&

{t.body}

} +
+ {t.img && t.k && ( +
+ {t.k} +
+ )} + {t.fig != null && ( +
+ {t.fig} +
+ )} + {t.title &&

{t.title}

} + {t.body &&

{t.body}

} +
-
+ ))} - +
); diff --git a/bolt-slides/src/components/BigNumber.tsx b/bolt-slides/src/components/BigNumber.tsx new file mode 100644 index 00000000..f6e6d6aa --- /dev/null +++ b/bolt-slides/src/components/BigNumber.tsx @@ -0,0 +1,47 @@ +import type { ReactNode } from 'react'; +import Reveal from '../deck/Reveal'; + +/* The giant-figure drama slide — one enormous accent number, a caption, and + an optional source line. Pass a as the value so it animates in. + } + caption="moves through supply chains every year" foot="Source: McKinsey, 2025" /> */ +export default function BigNumber({ + kicker, + value, + caption, + foot, +}: { + kicker?: string; + value: ReactNode; + caption?: ReactNode; + foot?: string; + nav?: string; + notes?: string; +}) { + return ( +
+ + {kicker && ( +
+ {kicker} +
+ )} +
+ +
{value}
+
+ {caption && ( + +

{caption}

+
+ )} + {foot && ( + +
+ {foot} +
+
+ )} +
+ ); +} diff --git a/bolt-slides/src/components/BrowserFrame.tsx b/bolt-slides/src/components/BrowserFrame.tsx index ccb9ba44..12c2f413 100644 --- a/bolt-slides/src/components/BrowserFrame.tsx +++ b/bolt-slides/src/components/BrowserFrame.tsx @@ -1,7 +1,36 @@ import type { ReactNode } from 'react'; -/* Wrap a screenshot or UI mock in browser chrome. children fill the body — - an , a , anything. */ +/* Wrap a screenshot or UI mock in browser chrome — traffic dots, back/forward + arrows, and a padlocked URL pill. children fill the body: an , a + , anything. + */ +const Arrow = ({ back }: { back?: boolean }) => ( + + {back ? : } + +); + +const Lock = () => ( + + + + +); + export default function BrowserFrame({ url = 'app.acme.com', children, @@ -10,14 +39,22 @@ export default function BrowserFrame({ children: ReactNode; }) { return ( -
+
- {url} + + + + + + + {url} + +
{children}
diff --git a/bolt-slides/src/components/Charts.tsx b/bolt-slides/src/components/Charts.tsx index 9cc1fa99..d7b597dc 100644 --- a/bolt-slides/src/components/Charts.tsx +++ b/bolt-slides/src/components/Charts.tsx @@ -1,30 +1,46 @@ -import { motion } from 'framer-motion'; -import type { CSSProperties } from 'react'; +import { useEffect, useId, useState, type CSSProperties } from 'react'; +import { motion, useReducedMotion } from 'framer-motion'; import { useInView } from '../deck/useInView'; +import { useDeck } from '../deck/DeckContext'; -/* A small hand-built chart kit — bar, line/area, and donut. Each draws itself in - when scrolled into view. All token-driven (one accent), no chart library. - - - */ +/* A small hand-built chart kit — bar, line/area, and donut. Each draws itself + in when scrolled into view. All token-driven (one accent), no chart library. + (values label the bars) + (gridlines + live end-dot) + (the number counts up) */ export function BarChart({ data, height = 200, + showValues = true, }: { data: { label: string; value: 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(...data.map((d) => d.value)) || 1; return (
{data.map((d, i) => (
+ {showValues && ( + + {d.value.toLocaleString('en-US')} + + )}
(0.3); + const { isStatic } = useDeck(); + const reduce = useReducedMotion(); + const { ref, inView } = useInView(0.3); + const gid = useId(); + const animate = !isStatic && !reduce; const w = 300, h = 120; const max = Math.max(...points), @@ -63,39 +83,72 @@ export function LineChart({ ) .join(' '); const area = `${line} L${w},${h} L0,${h} Z`; + const [ex, ey] = coords[coords.length - 1]; return ( - - - - - - - - - + + + + + + + + {/* horizontal gridlines — crisp regardless of the non-uniform scale */} + {[0.25, 0.5, 0.75].map((f) => ( + + ))} + + + {/* no vectorEffect here: non-scaling-stroke breaks the pathLength + draw-in under a stretched viewBox (the dash ends before the path + does and the stroke stops short of its end-point) */} + + + {/* the live end-point — a glowing dot with a radar pulse */} + - +
); } @@ -108,9 +161,31 @@ export function DonutChart({ label?: string; size?: number; }) { + const { isStatic } = useDeck(); + const reduce = useReducedMotion(); const { ref, inView } = useInView(0.4); + const [shown, setShown] = useState(isStatic ? value : 0); const r = 54; const circ = 2 * Math.PI * r; + + useEffect(() => { + if (isStatic || !inView) return; + if (reduce) { + setShown(value); + return; + } + let raf = 0; + const t0 = performance.now(); + const dur = 1100; + const tick = (now: number) => { + const p = Math.min(1, (now - t0) / dur); + setShown(value * (1 - Math.pow(1 - p, 3))); + if (p < 1) raf = requestAnimationFrame(tick); + }; + raf = requestAnimationFrame(tick); + return () => cancelAnimationFrame(raf); + }, [isStatic, inView, value, reduce]); + return (
- {value}% + {Math.round(shown)}% {label &&
{label}
} diff --git a/bolt-slides/src/components/Chat.tsx b/bolt-slides/src/components/Chat.tsx new file mode 100644 index 00000000..ccd035a1 --- /dev/null +++ b/bolt-slides/src/components/Chat.tsx @@ -0,0 +1,71 @@ +import Build from '../deck/Build'; +import Reveal from '../deck/Reveal'; + +/* A chat-conversation slide — user bubbles on the accent, assistant bubbles on + the surface, inside a titled window. Each message is a click-build beat: + advance the deck (→ / space) to reveal the exchange line by line. Ideal for + AI-product decks. + */ +export type ChatMessage = { from: 'user' | 'ai'; text: string }; + +export default function Chat({ + kicker, + title, + name = 'Assistant', + messages, +}: { + kicker?: string; + title?: string; + name?: string; + messages: ChatMessage[]; + nav?: string; + notes?: string; +}) { + return ( +
+
+ + {kicker && ( +
+ {kicker} +
+ )} + {title && ( +

+ {title} +

+ )} +
+ +
+
+ + {name} +
+
+ {messages.map((m, i) => ( + + {m.text} + + ))} +
+
+
+
+
+ ); +} diff --git a/bolt-slides/src/components/CodeWindow.tsx b/bolt-slides/src/components/CodeWindow.tsx index fa8a20be..e1864327 100644 --- a/bolt-slides/src/components/CodeWindow.tsx +++ b/bolt-slides/src/components/CodeWindow.tsx @@ -1,18 +1,37 @@ import { Fragment, type ReactNode } from 'react'; -/* A macOS-style code window with line numbers, restrained syntax tinting, and - optional line highlighting. Highlighting is intentionally minimal (keywords, - strings, comments) to stay on-brand — one accent, no rainbow. +/* A macOS-style code window with a gutter-ruled line-number column and + restrained syntax tinting. Highlighting is intentionally minimal (keywords, + strings, numbers, comments) to stay on-brand — one accent, no rainbow. */ const KW = /\b(const|let|var|function|return|if|else|for|while|import|from|export|default|class|new|await|async|type|interface|extends|implements|public|private|true|false|null|undefined)\b/g; +const NUM = /(\b\d+(?:\.\d+)?\b)/g; + +function tintNumbers(s: string, key: number): ReactNode { + const parts = s.split(NUM); + if (parts.length === 1) return s; + return ( + + {parts.map((p, k) => + k % 2 === 1 ? ( + + {p} + + ) : ( + p + ) + )} + + ); +} function hl(line: string): ReactNode { // split off a trailing line comment const ci = line.indexOf('//'); const code = ci >= 0 ? line.slice(0, ci) : line; const comment = ci >= 0 ? line.slice(ci) : ''; - // strings first (odd indices), then keywords inside the rest + // strings first (odd indices), then keywords, then numeric literals const segs = code.split( /("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`)/g ); @@ -31,7 +50,7 @@ function hl(line: string): ReactNode { {s} ) : ( - s + tintNumbers(s, j) ) )} @@ -53,7 +72,7 @@ export default function CodeWindow({ }) { const lines = code.replace(/\n$/, '').split('\n'); return ( -
+
@@ -70,7 +89,7 @@ export default function CodeWindow({ className={'cw-line' + (highlight.includes(i + 1) ? ' hl' : '')} > {i + 1} - {ln.length ? hl(ln) : ' '} + {ln.length ? hl(ln) : ' '}
))} diff --git a/bolt-slides/src/components/Comparison.tsx b/bolt-slides/src/components/Comparison.tsx index 28c420a2..8f606c3a 100644 --- a/bolt-slides/src/components/Comparison.tsx +++ b/bolt-slides/src/components/Comparison.tsx @@ -1,19 +1,22 @@ -/* A "us vs. them" feature matrix. One value column is highlighted (your column). +/* A "us vs. them" feature matrix. One value column is highlighted (your + column) and reads as a framed accent strip through the table; booleans + render as circled check/cross chips; rows cascade in on view. values are booleans (→ ✓/✗) or strings; `highlight` indexes the value columns. */ import type { CSSProperties } from 'react'; +import { motion, useReducedMotion } from 'framer-motion'; +import { useInView } from '../deck/useInView'; export type CompRow = { label: string; values: (boolean | string)[] }; const Check = () => ( @@ -22,11 +25,10 @@ const Check = () => ( ); const Cross = () => ( @@ -43,9 +45,15 @@ export default function Comparison({ rows: CompRow[]; highlight?: number; }) { + const { ref, inView } = useInView(0.25); + const reduce = useReducedMotion(); const vcols = cols.length - 1; return ( -
+
{cols.map((c, i) => (
{rows.map((r, ri) => ( -
+
{r.label}
{r.values.map((v, vi) => (
{typeof v === 'boolean' ? ( - v ? ( - - ) : ( - - ) + + {v ? : } + ) : ( {v} )}
))} -
+ ))}
); diff --git a/bolt-slides/src/components/Contrast.tsx b/bolt-slides/src/components/Contrast.tsx new file mode 100644 index 00000000..6285a09f --- /dev/null +++ b/bolt-slides/src/components/Contrast.tsx @@ -0,0 +1,116 @@ +import type { ReactNode } from 'react'; +import { motion, useReducedMotion } from 'framer-motion'; +import Reveal from '../deck/Reveal'; +import { useDeck } from '../deck/DeckContext'; + +/* The before/after (problem → solution) slide: a muted panel against an + accent-lit panel. Left points get faint crosses, right points get accent + checks; the lit panel lands second, with the glow. + */ +export type ContrastPanel = { + label?: string; + title?: string; + points: ReactNode[]; +}; + +const Cross = () => ( + + + +); +const Check = () => ( + + + +); + +export default function Contrast({ + kicker, + title, + left, + right, +}: { + kicker?: string; + title?: string; + left: ContrastPanel; + right: ContrastPanel; + nav?: string; + notes?: string; +}) { + const { isStatic } = useDeck(); + const reduce = useReducedMotion(); + const animate = !isStatic && !reduce; + const panel = (p: ContrastPanel, lit: boolean, i: number) => ( + + {p.label && ( + {p.label} + )} + {p.title &&

{p.title}

} +
+ {p.points.map((pt, pi) => ( +
+ {lit ? : } + {pt} +
+ ))} +
+
+ ); + return ( +
+
+ + {kicker && ( +
+ {kicker} +
+ )} + {title && ( +

+ {title} +

+ )} +
+
+ {panel(left, false, 0)} + {panel(right, true, 1)} +
+
+
+ ); +} diff --git a/bolt-slides/src/components/CountUp.tsx b/bolt-slides/src/components/CountUp.tsx index 8362a4df..98677a28 100644 --- a/bolt-slides/src/components/CountUp.tsx +++ b/bolt-slides/src/components/CountUp.tsx @@ -1,9 +1,10 @@ import { useEffect, useRef, useState } from 'react'; import { useDeck } from '../deck/DeckContext'; -/* A number that animates 0 → to when its slide becomes active. Inherits color, - so inside a .figure / .stat-value it picks up the accent gradient. Renders - the final value instantly in thumbnails. */ +/* A number that animates 0 → to when its slide becomes active, with a + quintic ease-out so the last digits settle gently. Inherits color, so + inside a .figure / .stat-value it picks up the accent gradient. Renders + the final value instantly in thumbnails and under reduced motion. */ export default function CountUp({ to, from = 0, @@ -40,7 +41,7 @@ export default function CountUp({ const t0 = performance.now(); const tick = (now: number) => { const p = Math.min(1, (now - t0) / duration); - setVal(from + (to - from) * (1 - Math.pow(1 - p, 3))); + setVal(from + (to - from) * (1 - Math.pow(1 - p, 5))); if (p < 1) raf = requestAnimationFrame(tick); }; raf = requestAnimationFrame(tick); diff --git a/bolt-slides/src/components/Cover.tsx b/bolt-slides/src/components/Cover.tsx new file mode 100644 index 00000000..e050cd19 --- /dev/null +++ b/bolt-slides/src/components/Cover.tsx @@ -0,0 +1,56 @@ +import type { ReactNode } from 'react'; +import Reveal from '../deck/Reveal'; + +/* The standardized opening slide: kicker → display title → subtitle cascade, + an optional full-bleed background image under a theme-correct scrim, and an + optional foot line (date · presenter · confidential). + Acme} + subtitle="Answers, not dashboards." image="/cover.webp" foot="June 2026 · Dana Kim" /> */ +export default function Cover({ + kicker, + title, + subtitle, + image, + foot, +}: { + kicker?: string; + title: ReactNode; + subtitle?: ReactNode; + image?: string; + foot?: string; + nav?: string; + notes?: string; +}) { + return ( +
+ {image && ( + <> + +
+ + )} + + {kicker && ( +
+ {kicker} +
+ )} +
+ +

{title}

+
+ {subtitle && ( + +

+ {subtitle} +

+
+ )} + {foot && ( + +
{foot}
+
+ )} +
+ ); +} diff --git a/bolt-slides/src/components/Globe.tsx b/bolt-slides/src/components/Globe.tsx new file mode 100644 index 00000000..6ec45f5e --- /dev/null +++ b/bolt-slides/src/components/Globe.tsx @@ -0,0 +1,435 @@ +import { useEffect, useRef, type ReactNode } from 'react'; +import { useReducedMotion } from 'framer-motion'; +import Reveal from '../deck/Reveal'; +import { useDeck } from '../deck/DeckContext'; + +/* A 3D-globe slide, hand-built on Canvas2D (no WebGL, no dependencies): a + slowly-rotating, drag-to-spin dotted earth with accent markers at real + coordinates, DOM-bound label chips that ride the globe (and fade behind + it), great-circle arcs between locations, and stat rows beside it. Colors + are probed from the live tokens, so re-theming recolors the globe too. + ONLY for a genuinely geographic story — users or revenue by country, + market entry, presence. + + Thumbnails render a static disc; reduced motion stops the auto-spin. */ +export type GlobeMarker = { + location: [number, number]; + size?: number; + /** chip anchored to the marker (e.g. a city or region code) */ + label?: string; + /** accent badge beside the label (e.g. a share or throughput) */ + value?: string; +}; +export type GlobeArc = { from: [number, number]; to: [number, number] }; +export type GlobeStat = { value: ReactNode; label: string }; + +/* 256×128 equirectangular land mask (white = land). From the cobe project + (MIT, shuding/cobe), map data via Wikipedia's blank world map. */ +const LAND_MASK = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAQAAAACAAQAAAADMzoqnAAAAAXNSR0IArs4c6QAABA5JREFUeNrV179uHEUAx/Hf3JpbF+E2VASBsmVKTBcpKJs3SMEDcDwBiVJAAewYEBUivIHT0uUBIt0YCovKD0CRjUC4QfHYh8hYXu+P25vZ2Zm9c66gMd/GJ/tz82d3bk8GN4SrByYF2366FNTACIAkivVAAazQdnf3MvAlbNUQfOPAdQDvSAimMWhwy4I2g4SU+Kp04ISLpPBAKLxPyic3O/CCi+Y7rUJbiodcpDOFY7CgxCEXmdYD2EYK2s5lApOx5pEDDYCUwM1XdJUwBV11QQMg59kePSCaPAASQMEL2hwo6TJFgxpg+TgC2ymXPbuvc40awr3D1QCFfbH9kcoqAOkZozpQo0aqAGQRKCog/+tjkgbNFEtg2FffBvBGlSxHoAaAa1u6X4PBAwDiR8FFsrQgeUhfJTSALaB9jy5NCybJPn1SVFiWk7ywN+KzhH1aKAuydhGkbEF4lWohLXDXavlyFgHY7LBnLRdlAP6BS5Cc8RfVDXbkwN/oIvmY+6obbNeBP0JwTuMGu9gTzy1Q4RS/cWpfzszeYwd+CAFrtBW/Hur0gLbJGlD+/OjVwe/drfBxkbbg63dndEDfiEBlAd7ac0BPe1D6Jd8dfbLH+RI0OzseFB5s01/M+gMdAeluLOCAuaUA9Lezo/vSgXoCX9rtEiXnp7Q1W/CNyWcd8DXoS6jH/YZ5vAJEWY2dXFQe2TUgaFaNejCzJ98g6HnlVrsE58sDcYqg+9XY75fPqdoh/kRQWiXKg8MWlJQxUFMPjqnyujhFBE7UxIMjyszk0QwQlFsezImsyvUYYYVED2pk6m0Tg8T04Fwjk2kdAwSACqlM6gRRt3vQYAFGX0Ah7Ebx1H+MDRI5ui0QldH4j7FGcm90XdxD2Jg1AOEAVAKhEFXSn4cKUELurIAKwJ3MArypPscQaLhJFICJ0ohjDySAdH8AhDtCiTuMycH8CXzhH9jUACAO5uMhoAwA5i+T6WAKmmAqnLy80wxHqIPFYpqCwxGaYLt4Dyievg5kEoVEUAhs6pqKgFtDQYOuaXypaWKQfIuwwoGSZgfLsu/XAtI8cGN+h7Cc1A5oLOMhwlIPXuhu48AIvsSBkvtV9wsJRKCyYLfq5lTrQMFd1a262oqBck9K1V0YjQg0iEYYgpS1A9GlXQV5cykwm4A7BzVsxQqo7E+zCegO7Ma7yKgsuOcfKbMBwLC8wvVNYDsANYalEpOAa6zpWjTeMKGwEwC1CiQewJc5EKfgy7GmRAZA4vUVGwE2dPM/g0xuAInE/yG5aZ8ISxWGfYigUVbdyBElTHh2uCwGdfCkOLGgQVBh3Ewp+/QK4CDlR5Ws/Zf7yhCf8pH7vinWAvoVCQ6zz0NX5V/6GkAVV+2/5qsJ/gU8bsxpM8IeAQAAAABJRU5ErkJggg=='; + +/* resolve a CSS custom property to 0–255 rgb by letting the browser compute it */ +function probe( + cssVar: string, + prop: 'color' | 'backgroundColor' = 'color' +): [number, number, number] { + const el = document.createElement('div'); + el.style.cssText = `position:absolute;visibility:hidden;${ + prop === 'color' ? 'color' : 'background-color' + }:var(${cssVar})`; + document.body.appendChild(el); + const m = getComputedStyle(el)[prop].match(/[\d.]+/g); + el.remove(); + if (!m || m.length < 3) return [128, 128, 128]; + return [+m[0], +m[1], +m[2]]; +} +const mix = (a: number[], b: number[], t: number) => + [0, 1, 2].map((i) => Math.round(a[i] * (1 - t) + b[i] * t)) as [ + number, + number, + number + ]; +const rgba = (c: number[], a: number) => `rgba(${c[0]},${c[1]},${c[2]},${a})`; +const clamp01 = (v: number) => Math.min(1, Math.max(0, v)); + +/* land points on a Fibonacci sphere, filtered by the mask — computed once */ +let landCache: Promise | null = null; +function landPoints(): Promise { + if (landCache) return landCache; + landCache = new Promise((resolve) => { + const img = new Image(); + img.onload = () => { + const W = 256, + H = 128; + const c = document.createElement('canvas'); + c.width = W; + c.height = H; + const g = c.getContext('2d')!; + g.drawImage(img, 0, 0); + const data = g.getImageData(0, 0, W, H).data; + const N = 12000, + GA = Math.PI * (3 - Math.sqrt(5)); + const pts: number[] = []; + for (let i = 0; i < N; i++) { + const y = 1 - (2 * i) / (N - 1); + const rad = Math.sqrt(1 - y * y); + const lng = (i * GA) % (2 * Math.PI); + const x = Math.cos(lng) * rad; + const z = Math.sin(lng) * rad; + const lat = Math.asin(y); + const l = Math.atan2(z, x); // -π..π + const u = Math.min(W - 1, Math.floor((l / (2 * Math.PI) + 0.5) * W)); + const v = Math.min(H - 1, Math.floor((0.5 - lat / Math.PI) * H)); + if (data[(v * W + u) * 4] > 128) + pts.push( + Math.cos(lat) * Math.sin(l), + Math.sin(lat), + Math.cos(lat) * Math.cos(l) + ); + } + resolve(new Float32Array(pts)); + }; + img.src = LAND_MASK; + }); + return landCache; +} + +const toVec = ([lat, lng]: [number, number]): [number, number, number] => { + const p = (lat * Math.PI) / 180, + l = (lng * Math.PI) / 180; + return [Math.cos(p) * Math.sin(l), Math.sin(p), Math.cos(p) * Math.cos(l)]; +}; + +function GlobeCanvas({ + markers, + arcs = [], +}: { + markers: GlobeMarker[]; + arcs?: GlobeArc[]; +}) { + const wrapRef = useRef(null); + const canvasRef = useRef(null); + const labelRefs = useRef<(HTMLDivElement | null)[]>([]); + const reduce = useReducedMotion(); + const drag = useRef({ down: false, startX: 0, r: 0, rBase: 0 }); + + useEffect(() => { + const canvas = canvasRef.current; + const wrap = wrapRef.current; + if (!canvas || !wrap) return; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + const fg = probe('--fg'); + const bg = probe('--bg', 'backgroundColor'); + const primary = probe('--primary'); + // carry the theme: land dots and the atmosphere lean toward the accent + const dot = mix(mix(bg, fg, 0.6), primary, 0.24); + const atmo = mix(fg, primary, 0.45); + const theta = 0.28; + const dpr = Math.min(window.devicePixelRatio || 1, 2); + let phi = 1.9; // open on the Americas + let raf = 0; + let size = 0; + const marks = markers.map((m) => ({ + v: toVec(m.location), + s: m.size ?? 0.06, + })); + // arcs: lifted great-circle samples (slerp + radial lift), precomputed once + const K = 44; + const arcPts = arcs.map((a) => { + const A = toVec(a.from), + B = toVec(a.to); + const d = + clamp01((A[0] * B[0] + A[1] * B[1] + A[2] * B[2] + 1) / 2) * 2 - 1; + const ang = Math.acos(Math.min(1, Math.max(-1, d))); + const sinAng = Math.sin(ang) || 1e-4; + const lift = 0.05 + 0.11 * (ang / Math.PI); + const p = new Float32Array((K + 1) * 3); + for (let i = 0; i <= K; i++) { + const t = i / K; + const w0 = Math.sin((1 - t) * ang) / sinAng; + const w1 = Math.sin(t * ang) / sinAng; + const r = 1 + lift * Math.sin(Math.PI * t); + p[i * 3] = (A[0] * w0 + B[0] * w1) * r; + p[i * 3 + 1] = (A[1] * w0 + B[1] * w1) * r; + p[i * 3 + 2] = (A[2] * w0 + B[2] * w1) * r; + } + return p; + }); + let pts: Float32Array | null = null; + landPoints().then((p) => { + pts = p; + }); + + const resize = () => { + size = wrap.offsetWidth; + canvas.width = size * dpr; + canvas.height = size * dpr; + }; + resize(); + const ro = new ResizeObserver(resize); + ro.observe(wrap); + + const cosT = Math.cos(theta), + sinT = Math.sin(theta); + // smooth horizon fade: 0 at the limb, 1 once safely on the visible side + const smooth = (zr: number) => { + const t = clamp01(zr / 0.22); + return t * t * (3 - 2 * t); + }; + // arc-point visibility: in front of the globe, or outside its silhouette + const arcVis = (xr: number, yr: number, zr: number) => + clamp01(Math.max(zr / 0.08, (Math.hypot(xr, yr) - 1) / 0.04)); + + const render = () => { + const s = size * dpr, + cx = s / 2, + cy = s / 2, + R = s * 0.4; + ctx.clearRect(0, 0, s, s); + // atmosphere — limb brightening, then a soft falloff that reaches ZERO + // exactly at the canvas half-size so nothing ever clips square + const G = s * 0.5, + inner = R * 0.8; + const at = (r: number) => clamp01((r - inner) / (G - inner)); + const glow = ctx.createRadialGradient(cx, cy, inner, cx, cy, G); + glow.addColorStop(0, rgba(atmo, 0)); + glow.addColorStop(at(R * 0.97), rgba(atmo, 0.05)); + glow.addColorStop(at(R * 0.995), rgba(atmo, 0.2)); + glow.addColorStop(at(R * 1.01), rgba(atmo, 0.22)); + glow.addColorStop(at(R * 1.07), rgba(atmo, 0.1)); + glow.addColorStop(at(R * 1.15), rgba(atmo, 0.035)); + glow.addColorStop(1, rgba(atmo, 0)); + ctx.fillStyle = glow; + ctx.beginPath(); + ctx.arc(cx, cy, G, 0, 7); + ctx.fill(); + const body = ctx.createRadialGradient( + cx - R * 0.3, + cy - R * 0.35, + R * 0.1, + cx, + cy, + R + ); + body.addColorStop(0, rgba(mix(fg, primary, 0.2), 0.09)); + body.addColorStop(1, rgba(fg, 0.02)); + ctx.fillStyle = body; + ctx.beginPath(); + ctx.arc(cx, cy, R, 0, 7); + ctx.fill(); + const a = phi + drag.current.r; + const cosP = Math.cos(a), + sinP = Math.sin(a); + const proj = ( + x: number, + y: number, + z: number + ): [number, number, number] => { + const xr = x * cosP + z * sinP; + const z1 = z * cosP - x * sinP; + return [xr, y * cosT - z1 * sinT, y * sinT + z1 * cosT]; + }; + // land dots — round, sized and faded by depth, easing out at the limb + if (pts) { + ctx.fillStyle = rgba(dot, 1); + for (let i = 0; i < pts.length; i += 3) { + const [xr, yr, zr] = proj(pts[i], pts[i + 1], pts[i + 2]); + if (zr <= 0) continue; + const e = smooth(zr); + const d = (0.5 + 0.75 * zr) * dpr * (0.6 + 0.4 * e); + ctx.globalAlpha = (0.22 + 0.62 * zr) * e; + ctx.beginPath(); + ctx.arc(cx + xr * R, cy - yr * R, d, 0, 7); + ctx.fill(); + } + } + // arcs — great-circle connections, hidden where they pass behind + ctx.lineCap = 'round'; + ctx.lineWidth = 1.3 * dpr; + for (const p of arcPts) { + let prev = proj(p[0], p[1], p[2]); + let prevV = arcVis(prev[0], prev[1], prev[2]); + for (let i = 1; i <= K; i++) { + const cur = proj(p[i * 3], p[i * 3 + 1], p[i * 3 + 2]); + const curV = arcVis(cur[0], cur[1], cur[2]); + const alpha = Math.min(prevV, curV) * 0.8; + if (alpha > 0.01) { + ctx.globalAlpha = alpha; + ctx.strokeStyle = rgba(primary, 1); + ctx.beginPath(); + ctx.moveTo(cx + prev[0] * R, cy - prev[1] * R); + ctx.lineTo(cx + cur[0] * R, cy - cur[1] * R); + ctx.stroke(); + } + prev = cur; + prevV = curV; + } + } + // markers — accent dots with a soft halo, easing over the limb too + for (let mi = 0; mi < marks.length; mi++) { + const m = marks[mi]; + const [xr, yr, zr] = proj(m.v[0], m.v[1], m.v[2]); + const e = smooth(zr); + // ride the DOM label chip on this marker (CSS pixels) + const el = labelRefs.current[mi]; + if (el) { + const lx = size * (0.5 + 0.4 * xr); + const ly = size * (0.5 - 0.4 * yr); + el.style.transform = `translate(${lx.toFixed(1)}px, ${ly.toFixed( + 1 + )}px) translate(-50%, calc(-100% - 12px))`; + el.style.opacity = String(e * e); + } + if (zr <= 0) continue; + const mx = cx + xr * R, + my = cy - yr * R; + const mr = (2.4 + m.s * 42) * dpr * (0.7 + 0.3 * zr); + ctx.globalAlpha = e; + const halo = ctx.createRadialGradient(mx, my, 0, mx, my, mr * 3); + halo.addColorStop(0, rgba(primary, 0.5)); + halo.addColorStop(1, rgba(primary, 0)); + ctx.fillStyle = halo; + ctx.beginPath(); + ctx.arc(mx, my, mr * 3, 0, 7); + ctx.fill(); + ctx.fillStyle = rgba(primary, 1); + ctx.beginPath(); + ctx.arc(mx, my, mr, 0, 7); + ctx.fill(); + } + ctx.globalAlpha = 1; + }; + + let last = 0; + const loop = (t: number) => { + if (last && !reduce && !drag.current.down) + phi += ((t - last) / 1000) * 0.22; // rad/s, frame-rate independent + last = t; + render(); + raf = requestAnimationFrame(loop); + }; + raf = requestAnimationFrame(loop); + requestAnimationFrame(() => { + canvas.style.opacity = '1'; + }); + + const down = (e: PointerEvent) => { + drag.current.down = true; + drag.current.startX = e.clientX; + canvas.style.cursor = 'grabbing'; + }; + const move = (e: PointerEvent) => { + if (drag.current.down) + drag.current.r = + drag.current.rBase + (e.clientX - drag.current.startX) / 140; + }; + const up = () => { + drag.current.down = false; + drag.current.rBase = drag.current.r; + canvas.style.cursor = 'grab'; + }; + canvas.addEventListener('pointerdown', down); + window.addEventListener('pointermove', move); + window.addEventListener('pointerup', up); + return () => { + cancelAnimationFrame(raf); + ro.disconnect(); + canvas.removeEventListener('pointerdown', down); + window.removeEventListener('pointermove', move); + window.removeEventListener('pointerup', up); + }; + }, [reduce, markers, arcs]); + + return ( +
+ + {markers.map((m, i) => + m.label ? ( +
{ + labelRefs.current[i] = el; + }} + > + {m.label} + {m.value && {m.value}} +
+ ) : null + )} +
+ ); +} + +export default function Globe({ + kicker, + title, + body, + markers, + arcs, + stats, + flip, +}: { + kicker?: string; + title: ReactNode; + body?: ReactNode; + markers: GlobeMarker[]; + arcs?: GlobeArc[]; + stats?: GlobeStat[]; + flip?: boolean; + nav?: string; + notes?: string; +}) { + const { isStatic } = useDeck(); + return ( +
+
+
+ {kicker && ( + +
{kicker}
+
+ )} + +

+ {title} +

+
+ {body && ( + +
{body}
+
+ )} + {stats && stats.length > 0 && ( + +
+ {stats.map((s, i) => ( +
+ {s.label} + {s.value} +
+ ))} +
+
+ )} +
+
+ {isStatic ? ( +
+
+
+ ) : ( + + )} +
+
+
+ ); +} diff --git a/bolt-slides/src/components/Marquee.tsx b/bolt-slides/src/components/Marquee.tsx index 8a1141c2..8440fd32 100644 --- a/bolt-slides/src/components/Marquee.tsx +++ b/bolt-slides/src/components/Marquee.tsx @@ -1,4 +1,7 @@ -/* A continuously-scrolling strip — logo wall, value props, tech stack. */ +import { Fragment } from 'react'; + +/* A continuously-scrolling strip — logo wall, value props, tech stack. + Items are separated by small accent diamonds; hovering pauses the strip. */ export default function Marquee({ items, duration = 26, @@ -6,17 +9,26 @@ export default function Marquee({ items: string[]; duration?: number; }) { + const run = (copy: number) => ( + + {items.map((it, i) => ( + + + {it} + + + + ))} + + ); return (
- {[...items, ...items].map((it, i) => ( - - {it} - - ))} + {run(0)} + {run(1)}
); diff --git a/bolt-slides/src/components/Pricing.tsx b/bolt-slides/src/components/Pricing.tsx new file mode 100644 index 00000000..47cf7770 --- /dev/null +++ b/bolt-slides/src/components/Pricing.tsx @@ -0,0 +1,115 @@ +import type { CSSProperties } from 'react'; +import { motion, useReducedMotion } from 'framer-motion'; +import Reveal from '../deck/Reveal'; +import { useDeck } from '../deck/DeckContext'; + +/* A pricing slide: 2–4 tier cards, one highlighted with an accent badge. + Feature lists get accent checks; cards rise in staggered. + */ +export type Tier = { + name: string; + price: string; + period?: string; + blurb?: string; + features?: string[]; + highlight?: boolean; + badge?: string; +}; + +const Check = () => ( + + + +); + +export default function Pricing({ + kicker, + title, + tiers, +}: { + kicker?: string; + title?: string; + tiers: Tier[]; + nav?: string; + notes?: string; +}) { + const { isStatic } = useDeck(); + const reduce = useReducedMotion(); + const animate = !isStatic && !reduce; + return ( +
+
+ + {kicker && ( +
+ {kicker} +
+ )} + {title && ( +

+ {title} +

+ )} +
+
+ {tiers.map((t, i) => ( + + {t.highlight && ( + {t.badge ?? 'Most popular'} + )} +
{t.name}
+
+ {t.price} + {t.period && {t.period}} +
+ {t.blurb &&
{t.blurb}
} + {t.features && t.features.length > 0 && ( +
+ {t.features.map((f, fi) => ( +
+ + {f} +
+ ))} +
+ )} +
+ ))} +
+
+
+ ); +} diff --git a/bolt-slides/src/components/Quote.tsx b/bolt-slides/src/components/Quote.tsx new file mode 100644 index 00000000..b013da75 --- /dev/null +++ b/bolt-slides/src/components/Quote.tsx @@ -0,0 +1,67 @@ +import Reveal from '../deck/Reveal'; + +/* A pull-quote slide: an accent quotation mark, the quote at display scale, + and an attribution row with a photo or auto-initials avatar (`img`). Pass + `image` for a full-bleed background photo under a scrim (brand decks). + Don't wrap the text in quotation marks — the mark provides them. + */ +export default function Quote({ + text, + name, + role, + img, + image, +}: { + text: string; + name?: string; + role?: string; + img?: string; + image?: string; + nav?: string; + notes?: string; +}) { + const initials = name + ? name + .split(/\s+/) + .map((w) => w[0]) + .slice(0, 2) + .join('') + .toUpperCase() + : ''; + return ( +
+ {image && ( + <> + +
+ + )} + +
+ “ +
+
+ +

+ {text} +

+
+ {name && ( + +
+ + {img ? {name} : initials} + + +
{name}
+ {role &&
{role}
} +
+
+
+ )} +
+ ); +} diff --git a/bolt-slides/src/components/Section.tsx b/bolt-slides/src/components/Section.tsx new file mode 100644 index 00000000..c3454f48 --- /dev/null +++ b/bolt-slides/src/components/Section.tsx @@ -0,0 +1,47 @@ +import type { ReactNode } from 'react'; +import Reveal from '../deck/Reveal'; + +/* A chapter divider slide — a full-bleed breather between parts of the deck. + An enormous outlined "ghost" number sits behind a centered kicker + display + title, over stronger accent corner glows. Works with no image (ideal for + data/B2B decks) — or pass `image` for a full-bleed photo under a scrim + (brand decks): the ghost number and title sit on top. +
How it works.} /> */ +export default function Section({ + n, + kicker, + title, + image, +}: { + n?: number | string; + kicker?: string; + title: ReactNode; + image?: string; + nav?: string; + notes?: string; +}) { + return ( +
+ {image && ( + <> + +
+ + )} +
+ {n != null && ( +
+ {String(n).padStart(2, '0')} +
+ )} + + {kicker && ( +
+ {kicker} +
+ )} +

{title}

+
+
+ ); +} diff --git a/bolt-slides/src/components/Split.tsx b/bolt-slides/src/components/Split.tsx index a5d08afd..c9c94b2f 100644 --- a/bolt-slides/src/components/Split.tsx +++ b/bolt-slides/src/components/Split.tsx @@ -1,7 +1,8 @@ import type { ReactNode } from 'react'; import Reveal from '../deck/Reveal'; -/* A full-bleed feature slide: text one side, edge-to-edge media the other. +/* A full-bleed feature slide: text one side, edge-to-edge media the other, + separated by a hairline. The text cascades in (kicker → title → body); `flip` swaps sides. Stacks vertically on narrow screens. */ export default function Split({ kicker, @@ -22,24 +23,24 @@ export default function Split({
- - {kicker && ( -
- {kicker} -
- )} + {kicker && ( + +
{kicker}
+
+ )} +

{title}

- {body && ( -
- {body} -
- )}
+ {body && ( + +
{body}
+
+ )}
{media}
diff --git a/bolt-slides/src/components/SpotlightCard.tsx b/bolt-slides/src/components/SpotlightCard.tsx index 129627c1..18230789 100644 --- a/bolt-slides/src/components/SpotlightCard.tsx +++ b/bolt-slides/src/components/SpotlightCard.tsx @@ -1,7 +1,8 @@ import { useRef, type ReactNode } from 'react'; -/* A card with a soft accent glow that follows the cursor (the Linear/Vercel - hover). Wrap content; it provides the card shell + padding. +/* A card with a cursor-following accent treatment (the Linear/Vercel hover): + a soft interior bloom AND a border ring that lights up around the pointer. + Wrap content; it provides the card shell + padding.
…

…

…

*/ export default function SpotlightCard({ children }: { children: ReactNode }) { const ref = useRef(null); @@ -13,8 +14,9 @@ export default function SpotlightCard({ children }: { children: ReactNode }) { el.style.setProperty('--my', `${e.clientY - r.top}px`); } return ( -
+
{children} +
); } diff --git a/bolt-slides/src/components/StatGrid.tsx b/bolt-slides/src/components/StatGrid.tsx index a6cc3d3b..a5aaae1d 100644 --- a/bolt-slides/src/components/StatGrid.tsx +++ b/bolt-slides/src/components/StatGrid.tsx @@ -1,8 +1,11 @@ import type { ReactNode } from 'react'; +import { motion, useReducedMotion } from 'framer-motion'; import Reveal from '../deck/Reveal'; +import { useDeck } from '../deck/DeckContext'; -/* A full-viewport proof slide: responsive auto-fit stat cards. Pass a - as a stat `value` so figures animate in. */ +/* A full-viewport proof slide: responsive auto-fit stat cards, each with an + accent tick and a staggered rise-in. Pass a as a stat `value` + so figures animate in. */ export type Stat = { value?: ReactNode; label: string; caption?: string }; export default function StatGrid({ @@ -16,6 +19,9 @@ export default function StatGrid({ nav?: string; notes?: string; }) { + const { isStatic } = useDeck(); + const reduce = useReducedMotion(); + const animate = !isStatic && !reduce; return (
@@ -41,15 +47,28 @@ export default function StatGrid({ )} - +
{stats.map((s, i) => ( -
-
{s.value}
-
{s.label}
- {s.caption &&
{s.caption}
} -
+ +
+ +
{s.value}
+
{s.label}
+ {s.caption &&
{s.caption}
} +
+
))} - +
); diff --git a/bolt-slides/src/components/Steps.tsx b/bolt-slides/src/components/Steps.tsx new file mode 100644 index 00000000..ebcca357 --- /dev/null +++ b/bolt-slides/src/components/Steps.tsx @@ -0,0 +1,93 @@ +import type { CSSProperties, ReactNode } from 'react'; +import { motion, useReducedMotion } from 'framer-motion'; +import Reveal from '../deck/Reveal'; +import { useDeck } from '../deck/DeckContext'; + +/* A horizontal numbered process (the "how it works" slide): mono number + rings joined by a connector that draws in left-to-right as each step + reveals. Stacks vertically on narrow screens (connectors hide). + */ +export type Step = { title: string; body?: ReactNode }; + +export default function Steps({ + kicker, + title, + items, +}: { + kicker?: string; + title?: string; + items: Step[]; + nav?: string; + notes?: string; +}) { + const { isStatic } = useDeck(); + const reduce = useReducedMotion(); + const animate = !isStatic && !reduce; + return ( +
+
+ + {kicker && ( +
+ {kicker} +
+ )} + {title && ( +

+ {title} +

+ )} +
+
+ {items.map((s, i) => ( + +
+ + {String(i + 1).padStart(2, '0')} + + + + +
+

{s.title}

+ {s.body &&

{s.body}

} +
+ ))} +
+
+
+ ); +} diff --git a/bolt-slides/src/components/Table.tsx b/bolt-slides/src/components/Table.tsx new file mode 100644 index 00000000..35bbfb3c --- /dev/null +++ b/bolt-slides/src/components/Table.tsx @@ -0,0 +1,96 @@ +import type { ReactNode } from 'react'; +import { motion, useReducedMotion } from 'framer-motion'; +import { useInView } from '../deck/useInView'; + +/* A data-rich table done properly: uppercase mono-ruled header, hairline rows, + right-aligned tabular numerals (first column left for labels), an optional + accent column or row, staggered row reveal, and a source caption. For real + data — a feature matrix belongs in . Self-centers standalone. +
+ Keep it ≤5 columns and ≤7 rows — a paged slide can't scroll. */ +export type TableColumn = + | string + | { label: string; align?: 'left' | 'right' | 'center' }; +export type TableCell = string | number | ReactNode; + +export default function Table({ + columns, + rows, + highlightCol, + highlightRow, + caption, +}: { + columns: TableColumn[]; + rows: TableCell[][]; + highlightCol?: number; + highlightRow?: number; + caption?: string; +}) { + const { ref, inView } = useInView(0.25); + const reduce = useReducedMotion(); + const align = (c: TableColumn) => + typeof c === 'string' ? undefined : c.align; + const alignClass = (i: number) => { + const a = align(columns[i]); + return a ? ` al-${a[0]}` : ''; + }; + return ( +
+
+
+ + + {columns.map((c, i) => ( + + ))} + + + + {rows.map((r, ri) => ( + + {r.map((cell, ci) => ( + + ))} + + ))} + +
+ {typeof c === 'string' ? c : c.label} +
+ {cell} +
+ + {caption && ( +
+ {caption} +
+ )} + + ); +} diff --git a/bolt-slides/src/components/Tabs.tsx b/bolt-slides/src/components/Tabs.tsx index 309da5fa..015cfaa9 100644 --- a/bolt-slides/src/components/Tabs.tsx +++ b/bolt-slides/src/components/Tabs.tsx @@ -1,8 +1,10 @@ -import { useState, type ReactNode } from 'react'; -import { AnimatePresence, motion } from 'framer-motion'; +import { useId, useState, type ReactNode } from 'react'; +import { AnimatePresence, motion, useReducedMotion } from 'framer-motion'; -/* Tabbed content with a sliding accent pill (framer layoutId) and a cross-fade - between panels. }, …]} /> */ +/* 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. + }, …]} /> */ export type Tab = { label: string; content: ReactNode }; export default function Tabs({ @@ -12,37 +14,64 @@ export default function Tabs({ tabs: Tab[]; defaultTab?: number; }) { + const pillId = useId(); + const reduce = useReducedMotion(); const [active, setActive] = useState(defaultTab); + + function onKeyDown(e: React.KeyboardEvent) { + if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return; + e.preventDefault(); + e.stopPropagation(); // don't page the deck while switching tabs + const next = + e.key === 'ArrowRight' + ? (active + 1) % tabs.length + : (active - 1 + tabs.length) % tabs.length; + setActive(next); + const bar = e.currentTarget as HTMLElement; + (bar.children[next] as HTMLElement | undefined)?.focus(); + } + return (
-
- {tabs.map((t, i) => ( - - ))} +
+
+ {tabs.map((t, i) => ( + + ))} +
{tabs[active].content} diff --git a/bolt-slides/src/components/Team.tsx b/bolt-slides/src/components/Team.tsx new file mode 100644 index 00000000..70124176 --- /dev/null +++ b/bolt-slides/src/components/Team.tsx @@ -0,0 +1,83 @@ +import { motion, useReducedMotion } from 'framer-motion'; +import Reveal from '../deck/Reveal'; +import { useDeck } from '../deck/DeckContext'; + +/* A people-grid slide: photo avatars (or auto-initials on the accent) with + name + role. Cards rise in staggered; the grid wraps responsively. + */ +export type Person = { name: string; role?: string; img?: string }; + +export default function Team({ + kicker, + title, + people, +}: { + kicker?: string; + title?: string; + people: Person[]; + nav?: string; + notes?: string; +}) { + const { isStatic } = useDeck(); + const reduce = useReducedMotion(); + const animate = !isStatic && !reduce; + return ( +
+
+ + {kicker && ( +
+ {kicker} +
+ )} + {title && ( +

+ {title} +

+ )} +
+
+ {people.map((p, i) => { + const initials = p.name + .split(/\s+/) + .map((w) => w[0]) + .slice(0, 2) + .join('') + .toUpperCase(); + return ( + + + {p.img ? {p.name} : initials} + +
{p.name}
+ {p.role &&
{p.role}
} +
+ ); + })} +
+
+
+ ); +} diff --git a/bolt-slides/src/components/TiltCard.tsx b/bolt-slides/src/components/TiltCard.tsx index 29681e72..669a6071 100644 --- a/bolt-slides/src/components/TiltCard.tsx +++ b/bolt-slides/src/components/TiltCard.tsx @@ -1,33 +1,63 @@ -import { useRef, useState, type CSSProperties, type ReactNode } from 'react'; +import { useRef, type ReactNode } from 'react'; +import { + motion, + useMotionValue, + useSpring, + useReducedMotion, +} from 'framer-motion'; +import { useDeck } from '../deck/DeckContext'; -/* Mouse-tracked 3D tilt + glare. Wrap one hero visual per slide. */ +/* Mouse-tracked 3D tilt + glare, smoothed with springs. The pointer is + measured against a STATIC outer frame — never the tilting element itself — + so the tilt can't feed back into its own hit-box and wobble. Wrap ONE hero + visual per slide. Flat in thumbnails and under reduced motion. */ export default function TiltCard({ children }: { children: ReactNode }) { - const ref = useRef(null); - const [style, setStyle] = useState({}); + const { isStatic } = useDeck(); + const reduce = useReducedMotion(); + const frameRef = useRef(null); + const rx = useMotionValue(0); + const ry = useMotionValue(0); + const srx = useSpring(rx, { stiffness: 260, damping: 26, mass: 0.5 }); + const sry = useSpring(ry, { stiffness: 260, damping: 26, mass: 0.5 }); + + if (isStatic || reduce) { + return ( +
+
+ {children} + +
+
+ ); + } function move(e: React.MouseEvent) { - const r = ref.current!.getBoundingClientRect(); + const el = frameRef.current; + if (!el) return; + const r = el.getBoundingClientRect(); const px = (e.clientX - r.left) / r.width - 0.5; const py = (e.clientY - r.top) / r.height - 0.5; - setStyle({ - transform: `perspective(1200px) rotateX(${(-py * 6).toFixed( - 2 - )}deg) rotateY(${(px * 9).toFixed(2)}deg)`, - ['--gx' as string]: `${((px + 0.5) * 100).toFixed(1)}%`, - ['--gy' as string]: `${((py + 0.5) * 100).toFixed(1)}%`, - } as CSSProperties); + rx.set(-py * 7); + ry.set(px * 10); + el.style.setProperty('--gx', `${((px + 0.5) * 100).toFixed(1)}%`); + el.style.setProperty('--gy', `${((py + 0.5) * 100).toFixed(1)}%`); + } + function leave() { + rx.set(0); + ry.set(0); } return (
setStyle({})} + onMouseLeave={leave} > - {children} - + + {children} + +
); } diff --git a/bolt-slides/src/components/Timeline.tsx b/bolt-slides/src/components/Timeline.tsx index 05b825e6..78ee2c01 100644 --- a/bolt-slides/src/components/Timeline.tsx +++ b/bolt-slides/src/components/Timeline.tsx @@ -1,22 +1,24 @@ -import { motion } from 'framer-motion'; +import { motion, useReducedMotion } from 'framer-motion'; import type { ReactNode } from 'react'; import { useInView } from '../deck/useInView'; -/* A vertical timeline / roadmap. The connector line draws in and milestones - reveal in sequence when scrolled into view. +/* A vertical timeline / roadmap. The connector draws in, milestones are + glowing rings with accent cores, and time labels are mono chips. Reveals + in sequence when scrolled into view; self-centers when standalone. */ export type TimelineItem = { time: string; title: string; body?: ReactNode }; export default function Timeline({ items }: { items: TimelineItem[] }) { const { ref, inView } = useInView(0.2); + const reduce = useReducedMotion(); return (
@@ -24,17 +26,17 @@ export default function Timeline({ items }: { items: TimelineItem[] }) {
-
{it.time}
+ {it.time}

{it.title}

{it.body &&

{it.body}

}
diff --git a/bolt-slides/src/components/VisualDashboard.tsx b/bolt-slides/src/components/VisualDashboard.tsx index 8295ca7f..35089ae3 100644 --- a/bolt-slides/src/components/VisualDashboard.tsx +++ b/bolt-slides/src/components/VisualDashboard.tsx @@ -2,12 +2,34 @@ import { useEffect, useRef, useState } from 'react'; import CountUp from './CountUp'; import { useDeck } from '../deck/DeckContext'; -/* Example analytics visual (a self-drawing dashboard mock). ONLY use it when - the deck is genuinely about data — otherwise build a topic-fit visual in the - same .vframe shell. Draws in when its slide becomes active. */ +/* Example analytics visual (a self-drawing dashboard mock): KPI cards with + live sparklines, a gridded area chart, and a weekly bar row. ONLY use it + when the deck is genuinely about data — otherwise build a topic-fit visual + in the same .vframe shell. Draws in when its slide becomes active. */ const bars = [44, 62, 55, 78, 96, 70, 58]; const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; +const kpis = [ + { + l: 'Revenue', + v: , + d: '▲ 18.2%', + spark: 'M1,14 L9,11 L17,12.5 L25,8 L33,9.5 L41,5 L49,3', + }, + { + l: 'Active users', + v: , + d: '▲ 9.4%', + spark: 'M1,15 L9,13 L17,14 L25,10 L33,11.5 L41,7 L49,4.5', + }, + { + l: 'Churn', + v: , + d: '▼ 0.6%', + spark: 'M1,4 L9,6 L17,5 L25,9 L33,8 L41,12 L49,14', + }, +]; + export default function VisualDashboard() { const { isStatic } = useDeck(); const ref = useRef(null); @@ -34,7 +56,7 @@ export default function VisualDashboard() { return (
@@ -51,32 +73,31 @@ export default function VisualDashboard() {
-
-
Revenue
-
- -
-
▲ 18.2%
-
-
-
Active users
-
- -
-
▲ 9.4%
-
-
-
Churn
-
- + {kpis.map((k) => ( +
+
{k.l}
+
{k.v}
+
+
{k.d}
+ + + +
-
▼ 0.6%
-
+ ))}
@@ -84,11 +105,23 @@ export default function VisualDashboard() { + {[38, 76, 114].map((y) => ( + + ))} + {/* no vectorEffect: it breaks the dash-based draw-in (stroke stops short) */} ( ); +/* ── content anchoring ─────────────────────────────────────────────── */ +const stage = () => document.querySelector('.slide-stage'); +const viewportBox = (): Box => ({ + left: 0, + top: 0, + width: window.innerWidth, + height: window.innerHeight, +}); + +/* child-index path from the stage root, e.g. "0.2.1" ("" = the stage itself) */ +function pathOf(el: Element): string { + const root = stage(); + const parts: number[] = []; + let cur: Element | null = el; + while (cur && cur !== root) { + const parent: Element | null = cur.parentElement; + if (!parent) return ''; + parts.unshift(Array.prototype.indexOf.call(parent.children, cur)); + cur = parent; + } + return cur === root ? parts.join('.') : ''; +} +function resolveAnchor(path?: string): Box | null { + if (path === undefined) return null; + const root = stage(); + if (!root) return null; + let cur: Element = root; + if (path !== '') { + for (const i of path.split('.').map(Number)) { + const next = cur.children[i]; + if (!next) return null; + cur = next; + } + } + const r = cur.getBoundingClientRect(); + return r.width > 4 && r.height > 4 ? r : null; +} +/* the block element under a client point (skipping the canvas + chrome) */ +function anchorAt(cx: number, cy: number): string { + for (const el of document.elementsFromPoint(cx, cy)) { + if (!el.closest('.slide-stage')) continue; + let block: Element | null = el; + while ( + block && + block !== stage() && + getComputedStyle(block).display === 'inline' + ) + block = block.parentElement; + return block ? pathOf(block) : ''; + } + return ''; +} +/* a stroke's points mapped to client pixels at the CURRENT layout */ +function clientPts(s: Stroke): Pt[] { + const r = resolveAnchor(s.anchor) ?? viewportBox(); + return s.points.map((p) => ({ + x: r.left + p.x * r.width, + y: r.top + p.y * r.height, + })); +} + function distToSeg( px: number, py: number, @@ -83,13 +159,12 @@ function distToSeg( t = Math.max(0, Math.min(1, t)); return Math.hypot(px - (ax + t * dx), py - (ay + t * dy)); } -function outline(s: Stroke): Pt[] { - if (s.points.length < 2) return s.points; - const a = s.points[0], - b = s.points[s.points.length - 1]; - if (s.tool === 'rect') - return [a, { x: b.x, y: a.y }, b, { x: a.x, y: b.y }, a]; - if (s.tool === 'ellipse') { +function outline(tool: Tool, pts: Pt[]): Pt[] { + if (pts.length < 2) return pts; + const a = pts[0], + b = pts[pts.length - 1]; + if (tool === 'rect') return [a, { x: b.x, y: a.y }, b, { x: a.x, y: b.y }, a]; + if (tool === 'ellipse') { const cx = (a.x + b.x) / 2, cy = (a.y + b.y) / 2, rx = Math.abs(b.x - a.x) / 2, @@ -99,10 +174,10 @@ function outline(s: Stroke): Pt[] { y: cy + ry * Math.sin((i / 24) * 2 * Math.PI), })); } - return s.points; + return pts; } function hits(s: Stroke, x: number, y: number, r: number) { - const pts = outline(s); + 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; for (let i = 0; i < pts.length - 1; i++) @@ -126,7 +201,7 @@ export default function Annotator({ const canvasRef = useRef(null); if (!store[slide]) store[slide] = []; const strokes = useRef(store[slide]); - const draft = useRef(null); + const draft = useRef(null); // draft points are raw client px const [tool, setTool] = useState('pen'); const [color, setColor] = useState(COLORS[0]); const [size, setSize] = useState(SIZES[1]); @@ -144,8 +219,8 @@ export default function Annotator({ .trim() || '#4fe5b0' : c; - function paint(ctx: CanvasRenderingContext2D, s: Stroke) { - if (!s.points.length) return; + function paint(ctx: CanvasRenderingContext2D, s: Stroke, p: Pt[]) { + if (!p.length) return; ctx.save(); ctx.strokeStyle = resolve(s.color); ctx.lineCap = 'round'; @@ -155,8 +230,7 @@ export default function Annotator({ ctx.globalAlpha = 0.32; ctx.lineWidth = s.size * 3.2; } - const p = s.points, - a = p[0], + const a = p[0], b = p[p.length - 1]; ctx.beginPath(); if (s.tool === 'pen' || s.tool === 'highlighter') { @@ -212,8 +286,8 @@ 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); - if (draft.current) paint(ctx, draft.current); + for (const s of strokes.current) paint(ctx, s, clientPts(s)); + if (draft.current) paint(ctx, draft.current, draft.current.points); } function commit() { store[slide] = strokes.current; @@ -238,15 +312,32 @@ export default function Annotator({ }; fit(); const onUp = () => { - if (draft.current) { - strokes.current.push(draft.current); - draft.current = null; - commit(); - } + 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(); }; + // re-render after the slide's entrance motion settles (anchor boxes move) + const t1 = window.setTimeout(redraw, 400); + const t2 = window.setTimeout(redraw, 1100); window.addEventListener('resize', fit); window.addEventListener('pointerup', onUp); return () => { + clearTimeout(t1); + clearTimeout(t2); window.removeEventListener('resize', fit); window.removeEventListener('pointerup', onUp); }; @@ -254,31 +345,28 @@ export default function Annotator({ }, []); function down(e: React.PointerEvent) { - const x = e.clientX, - y = e.clientY; if (toolRef.current === 'eraser') { - erase(x, y); + erase(e.clientX, e.clientY); return; } draft.current = { tool: toolRef.current, color: colorRef.current, size: sizeRef.current, - points: [{ x, y }], + points: [{ x: e.clientX, y: e.clientY }], }; redraw(); } function move(e: React.PointerEvent) { - const x = e.clientX, - y = e.clientY; if (toolRef.current === 'eraser') { - if (e.buttons) erase(x, y); + 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, y }); - else d.points = [d.points[0], { x, y }]; + 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(); } diff --git a/bolt-slides/src/deck/Build.tsx b/bolt-slides/src/deck/Build.tsx index f93fdc87..3c2ea3b4 100644 --- a/bolt-slides/src/deck/Build.tsx +++ b/bolt-slides/src/deck/Build.tsx @@ -6,10 +6,7 @@ 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 animates in. Reveal content in beats: …. The deck - advances builds before moving to the next slide. - - In AUTO mode (Deck autoplay / the dock auto button / "A"), every build - instead animates in automatically on slide load, staggered by `at`. */ + advances builds before moving to the next slide. */ export default function Build({ at = 1, y = 16, @@ -23,7 +20,7 @@ export default function Build({ className?: string; style?: CSSProperties; }) { - const { clicks, isStatic, auto, stagger = 0.16, registerMax } = useDeck(); + const { clicks, isStatic, registerMax } = useDeck(); useEffect(() => { registerMax?.(at); }, [at, registerMax]); @@ -35,24 +32,6 @@ export default function Build({
); - if (auto) { - return ( - - {children} - - ); - } - const shown = clicks >= at; return ( , then the next slide) ← / ↑ previous O overview F fullscreen Home / End first / last D draw P presenter (new tab) - A auto-play H hide/show the UI - starts in auto mode; tunes the gap. + H hide/show the UI Copy verbatim; theme only via the :root tokens. ───────────────────────── */ const fmt = (s: number) => @@ -76,15 +74,7 @@ function Thumb({ children }: { children: ReactNode }) { ); } -export default function Deck({ - children, - autoplay = false, - stagger = 0.16, -}: { - children: ReactNode; - autoplay?: boolean; - stagger?: number; -}) { +export default function Deck({ children }: { children: ReactNode }) { const slides = useMemo( () => Children.toArray(children) as ReactElement[], [children] @@ -101,7 +91,6 @@ export default function Deck({ }); const [clicks, setClicks] = useState(0); const [curMax, setCurMax] = useState(0); - const [auto, setAuto] = useState(autoplay); const [railOpen, setRailOpen] = useState(false); const [drawing, setDrawing] = useState(false); const [elapsed, setElapsed] = useState(0); @@ -142,7 +131,7 @@ export default function Deck({ [total] ); const next = useCallback(() => { - if (!auto && clicks < curMax) { + if (clicks < curMax) { setClicks(clicks + 1); return; } @@ -152,26 +141,25 @@ export default function Deck({ setClicks(0); setCurMax(maxMap.current[n] || 0); } - }, [auto, clicks, curMax, slide, total]); + }, [clicks, curMax, slide, total]); const prev = useCallback(() => { - if (!auto && clicks > 0) { + if (clicks > 0) { setClicks(clicks - 1); return; } if (slide > 0) { const n = slide - 1; - const m = auto ? 0 : maxMap.current[n] || 0; + const m = maxMap.current[n] || 0; setSlide(n); setClicks(m); setCurMax(m); } - }, [auto, clicks, slide]); + }, [clicks, slide]); const toggleFs = useCallback(() => { if (document.fullscreenElement) document.exitFullscreen(); else document.documentElement.requestFullscreen?.(); }, []); - const toggleAuto = useCallback(() => setAuto((v) => !v), []); const setNote = useCallback((text: string) => { setNoteOverrides((prev) => { const nextO = { ...prev, [slideRef.current]: text }; @@ -240,10 +228,6 @@ export default function Deck({ case 'P': openPresenter(); break; - case 'a': - case 'A': - toggleAuto(); - break; case 'h': case 'H': setUiHidden((v) => !v); @@ -257,7 +241,7 @@ export default function Deck({ }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); - }, [next, prev, go, total, toggleFs, toggleAuto, openPresenter]); + }, [next, prev, go, total, toggleFs, openPresenter]); // URL hash sync (initial slide comes from the hash via useState above) useEffect(() => { @@ -285,7 +269,6 @@ export default function Deck({ applyingRemote.current = true; setSlide(e.data.slide); setClicks(e.data.clicks); - setAuto(e.data.auto); } }; return () => c.close(); @@ -295,8 +278,8 @@ export default function Deck({ applyingRemote.current = false; return; } - chan.current?.postMessage({ type: 'state', slide, clicks, auto }); - }, [slide, clicks, auto]); + chan.current?.postMessage({ type: 'state', slide, clicks }); + }, [slide, clicks]); // fullscreen flag, presenter timer, idle auto-hide useEffect(() => { @@ -328,13 +311,11 @@ export default function Deck({ }, []); const liveCtx = useMemo( - () => ({ clicks, isStatic: false, auto, stagger, registerMax }), - [clicks, auto, stagger, registerMax] + () => ({ clicks, isStatic: false, registerMax }), + [clicks, registerMax] ); - const hasPrev = auto ? slide > 0 : slide > 0 || clicks > 0; - const hasNext = auto - ? slide < total - 1 - : slide < total - 1 || clicks < curMax; + 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]; @@ -446,13 +427,6 @@ export default function Deck({ -