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
16 changes: 16 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Sanity CMS — required for the app to load decks.
# Get these from https://sanity.io/manage after creating your project.

# Public (exposed to the client; safe to commit values for non-prod):
NEXT_PUBLIC_SANITY_PROJECT_ID=
NEXT_PUBLIC_SANITY_DATASET=production
NEXT_PUBLIC_SANITY_API_VERSION=2024-10-01

# Server-only secrets — do NOT commit real values.
# Optional. Only needed if you want to preview unpublished drafts.
SANITY_READ_TOKEN=

# Required for the /api/revalidate webhook to verify Sanity's signature.
# Generate any random string and paste the same value into the Sanity
# webhook configuration's "Secret" field.
SANITY_WEBHOOK_SECRET=
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ out
.DS_Store
.env
.env.*
!.env.example
*.tsbuildinfo
next-env.d.ts
coverage
Expand All @@ -13,3 +14,5 @@ coverage
.turbo
.temp
.vercel
.claude
slides
51 changes: 33 additions & 18 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,52 +2,67 @@ Never run build or tests until I ask manually.

## What this app is

Internal viewer for Octify case studies. **Bring your own HTML.** Each case study is a folder of hand-authored HTML files (one per slide) under `case-studies/<slug>/`. The app renders each slide inside a sandboxed iframe at a fixed 1920×1080 canvas, scaled to fit the viewport. There is no editor, no markdown, no theming, no presets.
Internal viewer for Octify decks. **Bring your own HTML.** Each deck is either a Sanity document with a `slides[]` array, or a folder under `slides/<slug>/` with `meta.json` and `<n>.html` files. The app renders each slide inside a sandboxed iframe at a fixed 1920x1080 canvas, scaled to fit the viewport. There is no in-app editor, no markdown, no theming, no presets.

The job of this app is narrow: discover case studies, list them, render them as a deck (viewer + present mode), serve their assets. Nothing else.
The job of this app is narrow: list decks, render them as a slide deck (viewer + present mode), generate per-deck PDFs and OG images. Nothing else.

## The mental model
## The two authoring sources

- **Author** designs slides as HTML somewhere (Astro, hand-coded, whatever). Each slide is a self-contained 1920×1080 document with inline CSS and system fonts.
- **Drop** the folder into `case-studies/<slug>/` with a `meta.json`.
- **Ship.** The app picks it up, no code change needed.
Both work simultaneously. The loader merges results, with Sanity winning on slug collisions.

**Sanity (preferred for non-developers):** run `pnpm studio` (starts Sanity Studio standalone), create a `deck` document, paste each slide's HTML, publish. The Sanity webhook hits `/api/revalidate` and the new deck is live in seconds with no deploy. Requires `NEXT_PUBLIC_SANITY_PROJECT_ID` env var; without it the app silently skips Sanity and serves filesystem decks only.

**Filesystem (preferred for developers):** drop a folder into `slides/<slug>/` with a `meta.json` and one HTML file per slide. Commit, push, deploy. Same model the app shipped with originally; works without any external service.

## Authoring contract (the only real rule)

Every slide HTML file must:
Every slide HTML must:

1. Be a complete `<!doctype html>` document.
2. Render against a 1920×1080 canvas. `body { margin: 0; width: 1920px; height: 1080px; overflow: hidden; }`.
2. Render against a 1920x1080 canvas. `body { margin: 0; width: 1920px; height: 1080px; overflow: hidden; }`.
3. Inline its CSS. No external CSS, no Google Fonts, no external scripts, no fetches. System font stacks only.
4. Have a `<title>` tag.

Slide assets go under `case-studies/<slug>/assets/` and are referenced as `assets/<file>` (resolved at runtime by `/c/<slug>/assets/<path>`).
Images and other assets are uploaded to Sanity directly and referenced by their `cdn.sanity.io` URL inside the slide HTML's `<img>` tags. The app does not proxy assets; it does not host any.

## File map

- `case-studies/<slug>/meta.json`, deck metadata.
- `case-studies/<slug>/*.html`, one file per slide.
- `case-studies/<slug>/assets/*`, optional static assets.
- [src/lib/case-studies.ts](src/lib/case-studies.ts), manifest loader, slide reader, asset reader. Server-only.
- `studio/sanity.config.ts`, Sanity Studio configuration.
- `studio/schemas/deck.ts`, Sanity schema for a deck (metadata + slides[]).
- [src/lib/sanity.ts](src/lib/sanity.ts), Sanity client and cache-tag identifiers.
- [src/lib/decks.ts](src/lib/decks.ts), GROQ-backed loader (`listDecks`, `getDeck`, `readSlide`).
- [src/components/SlideFrame.tsx](src/components/SlideFrame.tsx), fixed-canvas iframe with CSS scaling.
- [src/components/Viewer.tsx](src/components/Viewer.tsx), main viewer (chrome + stage + thumb strip).
- [src/components/Present.tsx](src/components/Present.tsx), fullscreen present mode.
- [src/app/page.tsx](src/app/page.tsx), index of case studies.
- [src/app/page.tsx](src/app/page.tsx), index of decks.
- [src/app/c/[slug]/page.tsx](src/app/c/[slug]/page.tsx), viewer route.
- [src/app/c/[slug]/present/page.tsx](src/app/c/[slug]/present/page.tsx), present mode route.
- [src/app/c/[slug]/slides/[file]/route.ts](src/app/c/[slug]/slides/[file]/route.ts), slide HTML serving.
- [src/app/c/[slug]/assets/[...path]/route.ts](src/app/c/[slug]/assets/[...path]/route.ts), slide asset serving.
- [src/app/c/[slug]/slides/[file]/route.ts](src/app/c/[slug]/slides/[file]/route.ts), slide HTML serving (file = stringified slide index).
- [src/app/c/[slug]/opengraph-image.tsx](src/app/c/[slug]/opengraph-image.tsx), per-deck social card.
- [src/app/api/revalidate/route.ts](src/app/api/revalidate/route.ts), Sanity webhook receiver.
- [studio/sanity.cli.ts](studio/sanity.cli.ts), CLI config for `pnpm studio`.

## What this app must NOT grow into

- No editor.
- No in-app editor (use Sanity Studio).
- No theming, no presets, no palettes.
- No markdown, no IR, no block components.
- No customization UI of any kind.
- No "easier authoring" shortcuts that let slides skip the BYO HTML contract.

If something needs to be different per deck, it lives in the deck's HTML. Not in the app.

## Env vars (all optional)

The app boots without any of these. Without them, only filesystem decks under `slides/` are served.

- `NEXT_PUBLIC_SANITY_PROJECT_ID`, the Sanity project ID. Setting this enables the Sanity loader path.
- `NEXT_PUBLIC_SANITY_DATASET`, defaults to `production`.
- `NEXT_PUBLIC_SANITY_API_VERSION`, ISO date, defaults to `2024-10-01`.
- `SANITY_READ_TOKEN`, required only if previewing unpublished drafts.
- `SANITY_WEBHOOK_SECRET`, required for the `/api/revalidate` webhook to verify signatures. Configure the same value in the Sanity webhook settings.

See `.env.example`.

## Workflow rules

- Brainstorm before building. Don't auto-implement non-trivial changes.
Expand Down
220 changes: 185 additions & 35 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,76 +1,226 @@
# stackdeck

Internal viewer for Octify case study decks. Bring your own HTML, render as a deck.
A bring-your-own-HTML deck viewer. Drop a folder of self-contained HTML files into `slides/<slug>/`, get a polished web viewer with present mode, browser-side PDF export, and per-deck OG images. Optional Sanity CMS for non-developer authoring.

No editor, no theming layer, no markdown directives. Each slide is exactly the HTML you wrote, scaled to fit the viewport.

## Why

Most deck tools force you into their templates and editors. This one stays out of the way. If you can ship a `1920×1080` HTML document, you can ship a slide. The viewer adds chrome (sidebar, navigation, present mode, PDF export) without touching what's inside the slide.

Use it for sales decks shared as URLs, internal show-and-tells, or any context where you want the reach of a web link plus the precision of hand-authored HTML.

## Quick start

```bash
git clone https://github.com/Octify-Technologies/stackdeck
cd stackdeck
pnpm install
pnpm dev
```

Open `http://localhost:3000`. The index will be empty until you add a deck.

### Add your first deck

```bash
mkdir -p slides/hello/assets
cat > slides/hello/meta.json <<'EOF'
{
"slug": "hello",
"title": "Hello, deck",
"summary": "First slide deck.",
"visibility": "public"
}
EOF
cat > slides/hello/01.html <<'EOF'
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Hello</title>
<style>
body { margin: 0; width: 1920px; height: 1080px; overflow: hidden;
display: grid; place-items: center; background: #0a0a0a; color: #fafafa;
font: 600 200px/1 -apple-system, sans-serif; letter-spacing: -0.04em; }
</style>
</head>
<body>Hello.</body>
</html>
EOF
```

Reload `http://localhost:3000`. Your deck appears. Click in to view, press `F` for present mode, click "Download PDF" to export.

## How it works

Each case study lives in `case-studies/<slug>/` as a folder of self-contained HTML files (one per slide) plus a `meta.json` describing the deck. The viewer renders each slide inside a sandboxed iframe at a fixed **1920×1080** canvas, scaled to fit the viewport.
Each deck is a folder under `slides/<slug>/`:

- `meta.json` describes the deck (title, client, slug, etc.).
- `01.html`, `02.html`, ... are individual slide documents.
- `assets/` holds any images or fonts referenced from the slide HTML.

There is no editor, no theming layer, no markdown directives. Slides are authored elsewhere (Astro, hand-coded HTML, whatever) and dropped in.
Each slide HTML is loaded into a sandboxed iframe at a fixed `1920×1080` canvas, scaled with CSS `transform: scale(...)` to whatever space the viewer has. The viewer chrome lives outside the iframe and never touches your slide content.

## Authoring contract

Every slide HTML file must:
Every slide HTML must:

1. Be a complete `<!doctype html>` document.
2. Render against a **1920×1080** canvas. Set `body { margin: 0; width: 1920px; height: 1080px; overflow: hidden; }`.
3. Inline its CSS (no external CSS, no Google Fonts, no external scripts). Use system font stacks.
4. Include a `<title>` tag, used as the slide name.
2. Render against a `1920×1080` canvas. Set `body { margin: 0; width: 1920px; height: 1080px; overflow: hidden; }`.
3. Inline its CSS. No external stylesheets, no Google Fonts, no external scripts. Use system font stacks.
4. Include a `<title>` tag, used as the slide name in the viewer's Contents sidebar.

Static assets go in `slides/<slug>/assets/` and are referenced relatively from the slide:

```html
<img src="./assets/logo.png" alt="Logo">
```

Static assets (images, fonts) live in `case-studies/<slug>/assets/` and are served at `/c/<slug>/assets/<path>`.
The viewer injects a `<base href="/c/<slug>/">` tag when serving the slide so relative paths resolve correctly inside the iframe.

## `meta.json`
## `meta.json` reference

```json
{
"slug": "acme-churn",
"title": "How Acme cut churn by 38%",
"slug": "acme-launch",
"title": "How Acme launched their public API",
"client": "Acme Corp",
"industry": "B2B SaaS",
"date": "2026-03-12",
"summary": "One-line summary shown on the index card.",
"tags": ["churn", "lifecycle"],
"summary": "One-line summary shown on the index card and in social previews.",
"tags": ["api", "launch"],
"cover": "01.html",
"slides": [
{ "file": "01.html", "title": "Cover" },
{ "file": "02.html", "title": "Tear sheet" }
{ "file": "02.html", "title": "The brief" }
],
"visibility": "public"
}
```

`slides` is optional. If omitted, all `*.html` files in the folder are picked up in lexicographic order, and each slide title is read from its `<title>` tag.
| Field | Required | Notes |
| ------------ | -------- | ----------------------------------------------------------------------------------------------------------- |
| `slug` | yes | Must match folder name. Kebab-case, `[a-z0-9-]+`. |
| `title` | yes | Deck title shown in the viewer's chrome. |
| `client` | no | Optional client name shown in breadcrumbs. |
| `industry` | no | Free-form string. |
| `date` | no | ISO date, used to sort the index. |
| `summary` | no | Up to ~280 chars. Shown on the homepage card and OG image. |
| `tags` | no | Array of strings, rendered as colored chips on the index. |
| `cover` | no | Filename of the slide used as the cover thumbnail. Defaults to first slide. |
| `slides` | no | Explicit slide order. If omitted, all `*.html` files in the folder are picked up in lexicographic order. |
| `visibility` | no | `"public"` (default) or `"private"`. Private decks are reachable via direct link but hidden from the index. |

## Routes

- `/` — case studies index
- `/c/<slug>` — viewer (thumbnail strip + main slide + chrome)
- `/c/<slug>/present` — fullscreen present mode
- `/c/<slug>/slides/<file>` — raw slide HTML (iframe source, also openable directly for debugging)
- `/c/<slug>/assets/<path>` — slide static assets
| URL | What it serves |
| ------------------------- | ------------------------------------------------- |
| `/` | Index of public decks |
| `/c/<slug>` | Viewer (sidebar + main slide + chrome) |
| `/c/<slug>/slides/<file>` | Raw slide HTML, used as the iframe source |
| `/c/<slug>/assets/<path>` | Slide static assets |
| `/api/revalidate` | Sanity webhook receiver (no-op without Sanity) |
| `http://localhost:3333` | Sanity Studio (standalone, run via `pnpm studio`) |

## Keyboard
## Keyboard shortcuts

| Key | Viewer | Present |
| ------------------ | ------------- | ------------- |
| `→` `Space` `PgDn` | Next | Next |
| `←` `PgUp` | Prev | Prev |
| `Home` / `End` | First / Last | First / Last |
| `1`–`9` | — | Jump to slide |
| `F` | Enter present | — |
| `Esc` | — | Exit |
| Key | Viewer | Present |
| ------------------ | ------------------ | ----------------- |
| `→` `Space` `PgDn` | Next slide | Next slide |
| `←` `PgUp` | Previous slide | Previous slide |
| `Home` / `End` | First / last | First / last |
| `1`–`9` | Jump to slide | Jump to slide |
| `F` | Enter present mode | — |
| `Esc` | — | Exit present mode |

## URL parameters

| Parameter | Effect |
| ----------------- | ---------------------------------------------------------------------------------------------- |
| `?to=<recipient>` | Shows a "for `<recipient>`" chip in the breadcrumb and personalizes mailto / PDF contact card. |
| `#<n>` | Open the deck on slide `<n>` (1-indexed). The hash updates as you navigate. |

## PDF export

Click "Download PDF" in the viewer. The browser renders each slide into a JPEG via `html-to-image`, assembles a one-slide-per-page PDF via `jsPDF`, and triggers a Blob download. An Octify-branded contact card is appended as the last page.

Output is rasterized (text isn't selectable in the resulting PDF) but every glyph is preserved exactly as your browser painted it, so font fidelity is guaranteed regardless of what's installed on the recipient's machine.

To customize the PDF contact card branding, edit `renderContactCardToJpeg` in [src/lib/generate-pdf.ts](src/lib/generate-pdf.ts).

## Optional: Sanity CMS

If you want non-developers to author decks, you can connect Sanity. Decks created in Sanity Studio appear in the index alongside filesystem decks. When both sources contain the same slug, Sanity wins.

1. Create a Sanity project at https://sanity.io/manage.
2. Set env vars in `.env.local` (see `.env.example`):
```
NEXT_PUBLIC_SANITY_PROJECT_ID=<your project id>
NEXT_PUBLIC_SANITY_DATASET=production
SANITY_WEBHOOK_SECRET=<random string>
```
3. Add CORS origins for `http://localhost:3000` and your production URL in the Sanity dashboard.
4. Run `pnpm studio` to start the Sanity Studio at `http://localhost:3333`. Sign in, create a `deck` document, paste the slide HTML into each slide entry. Publish.
5. Configure a webhook (Project → API → Webhooks) pointing at `<your-domain>/api/revalidate`:
- Filter (GROQ): `_type == "deck"`
- Projection: `{ "_type": _type, "slug": slug.current }`
- Secret: matches `SANITY_WEBHOOK_SECRET`

Without these env vars the app silently uses the filesystem only.

## Configuration

All env vars are optional. App boots with none set; only filesystem decks are served.

| Var | Purpose |
| -------------------------------- | ------------------------------------------------------------ |
| `NEXT_PUBLIC_SANITY_PROJECT_ID` | Enables the Sanity loader path. |
| `NEXT_PUBLIC_SANITY_DATASET` | Defaults to `production`. |
| `NEXT_PUBLIC_SANITY_API_VERSION` | ISO date, defaults to `2024-10-01`. |
| `SANITY_READ_TOKEN` | Required only for previewing unpublished drafts. |
| `SANITY_WEBHOOK_SECRET` | Required for `/api/revalidate` to verify webhook signatures. |

## Development

```bash
pnpm install
pnpm dev
pnpm dev # http://localhost:3000
pnpm typecheck
pnpm lint
pnpm build
```

## Publishing a case study
## Deploying

Vercel is the simplest target.

1. Connect the GitHub repo to Vercel.
2. Add the Sanity env vars from `.env.example` to the Vercel project (or skip them entirely to run filesystem-only).
3. Push to `main`.

Per-deck OG images are statically generated at build time. The PDF generation runs entirely in the visitor's browser, so it has no serverless cold-start cost.

## File map

- `slides/<slug>/` — filesystem decks (gitignored is fine if Sanity is your source of truth).
- `studio/` — Sanity schema and config.
- [src/lib/decks.ts](src/lib/decks.ts) — hybrid loader (Sanity + filesystem).
- [src/lib/sanity.ts](src/lib/sanity.ts) — Sanity client (returns `null` if env vars missing).
- [src/lib/generate-pdf.ts](src/lib/generate-pdf.ts) — browser-side PDF generator.
- [src/components/Viewer.tsx](src/components/Viewer.tsx) — main viewer.
- [src/components/Present.tsx](src/components/Present.tsx) — fullscreen present mode.
- [src/components/SlideFrame.tsx](src/components/SlideFrame.tsx) — fixed-canvas iframe.
- [src/app/c/[slug]/](src/app/c/[slug]/) — viewer routes (page, slide HTML, assets, OG image).
- [src/app/api/revalidate/](src/app/api/revalidate/) — Sanity webhook receiver.

## Contributing

Issues and PRs welcome. Two guardrails worth knowing:

- The app is intentionally narrow. No in-app editor, no theming layer, no markdown. If you want a different visual per deck, put it in the deck's HTML.
- Keep the BYO HTML contract intact. Any feature that lets slides skip "complete `<!doctype html>` document at 1920×1080 with inlined CSS" is a non-starter.

1. Create `case-studies/<slug>/` with a `meta.json` and your HTML files.
2. Open a PR. Merge.
3. Deploy.
## License

That's it.
MIT.
Loading
Loading