Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion bolt-slides/.bolt/skills/slides/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,11 @@ 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 `<title>` 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.

---

## Step 3 — Author slides (each child of `<Deck>` is one slide)
Expand Down Expand Up @@ -288,12 +293,14 @@ trigger or that it exists — just deliver the demo.

- [ ] The engine + chrome in `src/deck/` are **left untouched**; the
dock + thumbnail rail appear, arrow keys advance AND step back through builds,
fullscreen / overview work, annotation (D) has full tools and
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
Expand Down
2 changes: 1 addition & 1 deletion bolt-slides/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

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, annotation, and presenter mode — but each slide is a
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.

Expand Down
5 changes: 5 additions & 0 deletions bolt-slides/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@
<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</title>
<link
rel="icon"
href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🎞️</text></svg>"
/>
<meta name="theme-color" content="#05070a" />
</head>
<body>
Expand Down
43 changes: 42 additions & 1 deletion bolt-slides/src/deck/Annotator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,39 @@ export default function Annotator({
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<HTMLDivElement>(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;
}
}

return (
<>
<canvas
Expand All @@ -383,7 +416,15 @@ export default function Annotator({
onPointerMove={move}
/>
{active && (
<div className="ann-bar">
<div
className="ann-bar"
ref={barRef}
onPointerDown={barDown}
onPointerMove={barMove}
onPointerUp={barUp}
onPointerLeave={barUp}
onClickCapture={barClickCapture}
>
{TOOLS.map((t) => (
<button
key={t.id}
Expand Down
146 changes: 112 additions & 34 deletions bolt-slides/src/deck/Deck.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
Children,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
Expand All @@ -11,6 +12,7 @@ import { MotionConfig } from 'framer-motion';
import { DeckCtx } from './DeckContext';
import Annotator, { type Stroke } from './Annotator';
import {
IconSidebar,
IconGrid,
IconLeft,
IconRight,
Expand All @@ -24,9 +26,9 @@ import {
/* ── The paged presentation engine + the Slidev-style chrome (dock + rail).
Wrap your <Slide>/<Bento>/… in <Deck>. Each top-level child is one slide.
→ / ↓ / Space next (reveals the next <Build>, then the next slide)
← / ↑ previous O overview F fullscreen
Home / End first / last D draw P presenter (new tab)
H hide/show the UI
← / ↑ previous S sidebar G grid view
Home / End first / last A annotate P presenter (new tab)
F fullscreen H hide/show the UI
Copy verbatim; theme only via the :root tokens. ───────────────────────── */

const fmt = (s: number) =>
Expand All @@ -38,7 +40,10 @@ const fmt = (s: number) =>
function Thumb({ children }: { children: ReactNode }) {
const frameRef = useRef<HTMLDivElement>(null);
const [d, setD] = useState({ vw: 1280, vh: 720, scale: 0.15 });
useEffect(() => {
// 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 = () =>
Expand Down Expand Up @@ -92,6 +97,7 @@ 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);
const [drawing, setDrawing] = useState(false);
const [elapsed, setElapsed] = useState(0);
const [fs, setFs] = useState(false);
Expand Down Expand Up @@ -160,6 +166,15 @@ 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 };
Expand Down Expand Up @@ -212,16 +227,20 @@ export default function Deck({ children }: { children: ReactNode }) {
e.preventDefault();
go(total - 1);
break;
case 'o':
case 'O':
setRailOpen((v) => !v);
case 's':
case 'S':
toggleRail();
break;
case 'g':
case 'G':
toggleGrid();
break;
case 'f':
case 'F':
toggleFs();
break;
case 'd':
case 'D':
case 'a':
case 'A':
setDrawing((v) => !v);
break;
case 'p':
Expand All @@ -234,14 +253,26 @@ export default function Deck({ children }: { children: ReactNode }) {
break;
case 'Escape':
setRailOpen(false);
setGridOpen(false);
setDrawing(false);
setUiHidden(false);
break;
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [next, prev, go, total, toggleFs, openPresenter]);
}, [next, prev, go, total, toggleRail, toggleGrid, toggleFs, openPresenter]);

// safety net: if the authored deck kept the placeholder tab title, derive
// one from the current slide's heading so shared links look right.
useEffect(() => {
if (!document.title.startsWith('Replace')) return;
const h = document.querySelector<HTMLElement>(
'.slide-stage h1, .slide-stage h2'
);
const t = h?.innerText.replace(/\s+/g, ' ').trim();
if (t) document.title = t;
}, []);

// URL hash sync (initial slide comes from the hash via useState above)
useEffect(() => {
Expand Down Expand Up @@ -323,6 +354,33 @@ export default function Deck({ children }: { children: ReactNode }) {
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 = (
<>
<button
className="noir-icon-btn"
data-tip="Previous"
disabled={!hasPrev}
onClick={prev}
>
<IconLeft />
</button>
<div className="noir-counter">
<span className="noir-counter-now">{slide + 1}</span>
<span className="noir-counter-tot">/ {total}</span>
</div>
<button
className="noir-icon-btn"
data-tip="Next"
disabled={!hasNext}
onClick={next}
>
<IconRight />
</button>
</>
);

return (
<MotionConfig reducedMotion="user">
<div className={'deck' + (cursorHidden ? ' nocursor' : '')}>
Expand Down Expand Up @@ -370,6 +428,36 @@ export default function Deck({ children }: { children: ReactNode }) {
</div>
</aside>

{gridOpen && (
<div className="noir-grid">
<div className="noir-grid-head">
<span className="noir-rail-title">All slides</span>
<button
className="noir-icon-btn sm"
data-tip="Close"
onClick={() => setGridOpen(false)}
>
<IconClose />
</button>
</div>
<div className="noir-grid-list">
{slides.map((s, i) => (
<button
key={i}
className={'noir-thumb' + (i === slide ? ' active' : '')}
onClick={() => {
go(i);
setGridOpen(false);
}}
>
<span className="noir-thumb-no">{i + 1}</span>
<Thumb>{s}</Thumb>
</button>
))}
</div>
</div>
)}

{isPresenter && (
<div className="noir-presenter">
<div className="noir-presenter-row">
Expand Down Expand Up @@ -397,39 +485,29 @@ export default function Deck({ children }: { children: ReactNode }) {
)}

<div className={'noir-dock' + (hideUI ? ' hidden' : '')}>
{/* phones: nav floats bare above the tools pill (see base.css) */}
<div className="noir-bar noir-nav-bar">{navCluster}</div>
<div className="noir-bar">
<button
className={'noir-icon-btn' + (railOpen ? ' on' : '')}
data-tip="Overview (O)"
onClick={() => setRailOpen((v) => !v)}
>
<IconGrid />
</button>
<span className="noir-sep" />
<button
className="noir-icon-btn"
data-tip="Previous"
disabled={!hasPrev}
onClick={prev}
data-tip="Sidebar (S)"
onClick={toggleRail}
>
<IconLeft />
<IconSidebar />
</button>
<div className="noir-counter">
<span className="noir-counter-now">{slide + 1}</span>
<span className="noir-counter-tot">/ {total}</span>
</div>
<button
className="noir-icon-btn"
data-tip="Next"
disabled={!hasNext}
onClick={next}
className={'noir-icon-btn' + (gridOpen ? ' on' : '')}
data-tip="Grid view (G)"
onClick={toggleGrid}
>
<IconRight />
<IconGrid />
</button>
<span className="noir-sep" />
<div className="noir-nav-inline">{navCluster}</div>
<span className="noir-sep" />
<button
className={'noir-icon-btn noir-optional' + (drawing ? ' on' : '')}
data-tip="Annotate (D)"
className={'noir-icon-btn' + (drawing ? ' on' : '')}
data-tip="Annotate (A)"
onClick={() => setDrawing((v) => !v)}
>
<IconPencil />
Expand All @@ -442,7 +520,7 @@ export default function Deck({ children }: { children: ReactNode }) {
{fs ? <IconShrink /> : <IconExpand />}
</button>
<button
className="noir-icon-btn noir-optional"
className="noir-icon-btn"
data-tip="Presenter — new tab (P)"
onClick={openPresenter}
>
Expand Down
7 changes: 7 additions & 0 deletions bolt-slides/src/deck/icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@ const base = {
strokeLinejoin: 'round' as const,
};

export const IconSidebar = () => (
<svg {...base}>
<rect x="2.5" y="3.5" width="19" height="17" rx="2" />
<path d="M10 3.5v17" />
<path d="M5.6 8.5h1.4M5.6 12h1.4" />
</svg>
);
export const IconGrid = () => (
<svg {...base}>
<rect x="3" y="3" width="7" height="7" rx="1.5" />
Expand Down
Loading