From 10a4499245eee9ecc9126dfc4517de3b7f698895 Mon Sep 17 00:00:00 2001 From: developeranku Date: Thu, 7 May 2026 14:24:44 +0530 Subject: [PATCH 1/6] feat: rewrite app as BYO HTML case study viewer --- CLAUDE.md | 190 +- README.md | 212 +- case-studies/acme-churn/01.html | 60 + case-studies/acme-churn/02.html | 81 + case-studies/acme-churn/03.html | 78 + case-studies/acme-churn/04.html | 87 + case-studies/acme-churn/05.html | 67 + case-studies/acme-churn/06.html | 94 + case-studies/acme-churn/07.html | 66 + case-studies/acme-churn/meta.json | 18 + case-studies/linen-rebrand/01.html | 126 + case-studies/linen-rebrand/02.html | 190 ++ case-studies/linen-rebrand/03.html | 121 + case-studies/linen-rebrand/04.html | 165 ++ case-studies/linen-rebrand/05.html | 208 ++ case-studies/linen-rebrand/06.html | 146 ++ case-studies/linen-rebrand/meta.json | 17 + case-studies/vector-onboarding/01.html | 139 ++ case-studies/vector-onboarding/02.html | 195 ++ case-studies/vector-onboarding/03.html | 173 ++ case-studies/vector-onboarding/04.html | 217 ++ case-studies/vector-onboarding/05.html | 219 ++ case-studies/vector-onboarding/06.html | 157 ++ case-studies/vector-onboarding/meta.json | 17 + docs/ARCHITECTURE.md | 332 --- docs/GRAMMAR.md | 558 ----- docs/ROADMAP.md | 44 - eslint.config.mjs | 19 +- knip.json | 12 - next.config.mjs | 2 +- package.json | 23 +- src/app/apple-icon.tsx | 10 +- src/app/c/[slug]/assets/[...path]/route.ts | 21 + src/app/c/[slug]/not-found.css | 111 + src/app/c/[slug]/not-found.tsx | 38 + src/app/c/[slug]/page.tsx | 33 + src/app/c/[slug]/print/PrintDoc.tsx | 74 + src/app/c/[slug]/print/page.tsx | 11 + src/app/c/[slug]/print/print.css | 177 ++ src/app/c/[slug]/slides/[file]/route.ts | 21 + src/app/d/[id]/edit/page.tsx | 15 - src/app/d/[id]/page.tsx | 8 - src/app/d/[id]/present/page.tsx | 15 - src/app/globals.css | 213 +- src/app/home.css | 670 ++++++ src/app/icon.tsx | 6 +- src/app/layout.tsx | 135 +- src/app/new/NewDeckGallery.tsx | 301 --- src/app/new/page.tsx | 15 - src/app/opengraph-image.tsx | 47 +- src/app/page.tsx | 225 +- src/app/presets/PresetsGallery.tsx | 116 - src/app/presets/page.tsx | 15 - src/app/presets/presets.css | 132 -- src/app/presets/presets.ts | 30 - src/app/sitemap.ts | 14 +- src/app/templates/TemplatesGallery.tsx | 128 - src/app/templates/page.tsx | 15 - src/app/templates/seeds/dossier-case-study.ts | 155 -- src/app/templates/templates.ts | 33 - src/blocks/BlockRenderer.tsx | 71 - src/blocks/InlineText.tsx | 10 - src/blocks/default/Box.tsx | 13 - src/blocks/default/Cell.tsx | 21 - src/blocks/default/Chart.tsx | 182 -- src/blocks/default/Code.tsx | 9 - src/blocks/default/Columns.tsx | 17 - src/blocks/default/Grid.tsx | 13 - src/blocks/default/Heading.tsx | 12 - src/blocks/default/Image.tsx | 79 - src/blocks/default/List.tsx | 32 - src/blocks/default/Quote.tsx | 16 - src/blocks/default/Stat.tsx | 15 - src/blocks/default/Table.tsx | 30 - src/blocks/default/Text.tsx | 11 - src/blocks/index.ts | 1 - src/blocks/registry.ts | 50 - src/components/AppTopbar.css | 58 - src/components/AppTopbar.tsx | 63 - src/components/Present.css | 198 ++ src/components/Present.tsx | 194 ++ src/components/SlideFrame.css | 55 + src/components/SlideFrame.tsx | 103 + src/components/StackdeckMark.tsx | 21 + src/components/Viewer.css | 721 ++++++ src/components/Viewer.tsx | 307 +++ src/components/index.ts | 9 - src/components/layout/BackLink.tsx | 31 - src/components/layout/GalleryGrid.tsx | 11 - src/components/layout/PageShell.tsx | 15 - src/components/layout/PageWorkbar.tsx | 48 - src/components/layout/page.css | 195 -- src/components/primitives/Button.tsx | 121 - src/components/primitives/Text.tsx | 119 - src/components/primitives/button.css | 163 -- src/components/primitives/typography.css | 87 - src/editor/AssetsDrawer.tsx | 144 -- src/editor/ColorPicker.tsx | 80 - src/editor/Editor.tsx | 733 ------ src/editor/InsertMenu.tsx | 80 - src/editor/SourceEditor.tsx | 129 - src/editor/ThemeDrawer.tsx | 304 --- src/editor/cm-directive-highlight.ts | 74 - src/editor/cm-slash-command.ts | 34 - src/editor/cm-theme.ts | 115 - src/editor/editor.css | 1699 -------------- src/editor/insert-items.ts | 105 - src/editor/sample-deck.ts | 111 - src/ir/parse.ts | 924 -------- src/ir/plan.ts | 48 - src/ir/schema.ts | 470 ---- src/ir/source-edit.ts | 60 - src/layouts/index.ts | 72 - src/lib/case-studies.ts | 150 ++ src/lib/color.ts | 148 -- src/library/DeckLibrary.tsx | 419 ---- src/library/library.css | 486 ---- src/present/PresentMode.tsx | 138 -- src/present/present.css | 116 - src/presets/dossier/CONTRACT.md | 127 - src/presets/dossier/atoms/DottedLeader.tsx | 23 - src/presets/dossier/atoms/Furniture.tsx | 29 - src/presets/dossier/atoms/Masthead.tsx | 22 - src/presets/dossier/atoms/Monogram.tsx | 30 - src/presets/dossier/atoms/RunningHead.tsx | 26 - src/presets/dossier/atoms/ScaleBar.tsx | 20 - src/presets/dossier/atoms/Sparkline.tsx | 45 - src/presets/dossier/atoms/Spine.tsx | 22 - src/presets/dossier/atoms/Stamp.tsx | 35 - src/presets/dossier/atoms/Watermark.tsx | 44 - src/presets/dossier/compose.tsx | 282 --- src/presets/dossier/extract.ts | 145 -- src/presets/dossier/slides/BeforeAfter.tsx | 96 - src/presets/dossier/slides/Chart.tsx | 192 -- src/presets/dossier/slides/Closer.tsx | 83 - src/presets/dossier/slides/Cover.tsx | 47 - src/presets/dossier/slides/HeroStat.tsx | 89 - src/presets/dossier/slides/KpiGrid.tsx | 98 - src/presets/dossier/slides/PullQuote.tsx | 57 - src/presets/dossier/slides/SectionDivider.tsx | 59 - src/presets/dossier/slides/TearSheet.tsx | 44 - src/presets/registry.tsx | 23 - src/render/DeckRenderer.tsx | 67 - src/render/ExportPdf.tsx | 95 - src/render/SlideLogo.tsx | 36 - src/render/SlideRenderer.tsx | 62 - src/render/ThemeProvider.tsx | 44 - src/render/contrast.ts | 72 - src/render/lint.ts | 67 - src/render/theme-resolver.ts | 128 - src/storage/asset-store.ts | 75 - src/storage/db.ts | 100 - src/storage/deck-store.ts | 132 -- src/styles/app-shell.css | 151 -- src/styles/blocks.css | 971 -------- src/styles/deck.css | 201 -- src/styles/dossier.css | 2091 ----------------- src/styles/layouts.css | 375 --- src/themes/fonts.ts | 62 - src/themes/palettes/dossier.ts | 24 - src/themes/registry.ts | 18 - tests/blocks/registry.test.ts | 60 - tests/ir/parse.test.ts | 644 ----- tests/ir/plan.test.ts | 90 - tests/ir/schema.test.ts | 222 -- tests/ir/source-edit.test.ts | 71 - tests/lib/color.test.ts | 110 - tests/render/contrast.test.ts | 34 - tests/render/lint.test.ts | 34 - tests/render/theme-resolver.test.ts | 87 - tests/storage/deck-store.test.ts | 207 -- vitest.config.ts | 27 - 172 files changed, 6120 insertions(+), 18432 deletions(-) create mode 100644 case-studies/acme-churn/01.html create mode 100644 case-studies/acme-churn/02.html create mode 100644 case-studies/acme-churn/03.html create mode 100644 case-studies/acme-churn/04.html create mode 100644 case-studies/acme-churn/05.html create mode 100644 case-studies/acme-churn/06.html create mode 100644 case-studies/acme-churn/07.html create mode 100644 case-studies/acme-churn/meta.json create mode 100644 case-studies/linen-rebrand/01.html create mode 100644 case-studies/linen-rebrand/02.html create mode 100644 case-studies/linen-rebrand/03.html create mode 100644 case-studies/linen-rebrand/04.html create mode 100644 case-studies/linen-rebrand/05.html create mode 100644 case-studies/linen-rebrand/06.html create mode 100644 case-studies/linen-rebrand/meta.json create mode 100644 case-studies/vector-onboarding/01.html create mode 100644 case-studies/vector-onboarding/02.html create mode 100644 case-studies/vector-onboarding/03.html create mode 100644 case-studies/vector-onboarding/04.html create mode 100644 case-studies/vector-onboarding/05.html create mode 100644 case-studies/vector-onboarding/06.html create mode 100644 case-studies/vector-onboarding/meta.json delete mode 100644 docs/ARCHITECTURE.md delete mode 100644 docs/GRAMMAR.md delete mode 100644 docs/ROADMAP.md delete mode 100644 knip.json create mode 100644 src/app/c/[slug]/assets/[...path]/route.ts create mode 100644 src/app/c/[slug]/not-found.css create mode 100644 src/app/c/[slug]/not-found.tsx create mode 100644 src/app/c/[slug]/page.tsx create mode 100644 src/app/c/[slug]/print/PrintDoc.tsx create mode 100644 src/app/c/[slug]/print/page.tsx create mode 100644 src/app/c/[slug]/print/print.css create mode 100644 src/app/c/[slug]/slides/[file]/route.ts delete mode 100644 src/app/d/[id]/edit/page.tsx delete mode 100644 src/app/d/[id]/page.tsx delete mode 100644 src/app/d/[id]/present/page.tsx create mode 100644 src/app/home.css delete mode 100644 src/app/new/NewDeckGallery.tsx delete mode 100644 src/app/new/page.tsx delete mode 100644 src/app/presets/PresetsGallery.tsx delete mode 100644 src/app/presets/page.tsx delete mode 100644 src/app/presets/presets.css delete mode 100644 src/app/presets/presets.ts delete mode 100644 src/app/templates/TemplatesGallery.tsx delete mode 100644 src/app/templates/page.tsx delete mode 100644 src/app/templates/seeds/dossier-case-study.ts delete mode 100644 src/app/templates/templates.ts delete mode 100644 src/blocks/BlockRenderer.tsx delete mode 100644 src/blocks/InlineText.tsx delete mode 100644 src/blocks/default/Box.tsx delete mode 100644 src/blocks/default/Cell.tsx delete mode 100644 src/blocks/default/Chart.tsx delete mode 100644 src/blocks/default/Code.tsx delete mode 100644 src/blocks/default/Columns.tsx delete mode 100644 src/blocks/default/Grid.tsx delete mode 100644 src/blocks/default/Heading.tsx delete mode 100644 src/blocks/default/Image.tsx delete mode 100644 src/blocks/default/List.tsx delete mode 100644 src/blocks/default/Quote.tsx delete mode 100644 src/blocks/default/Stat.tsx delete mode 100644 src/blocks/default/Table.tsx delete mode 100644 src/blocks/default/Text.tsx delete mode 100644 src/blocks/index.ts delete mode 100644 src/blocks/registry.ts delete mode 100644 src/components/AppTopbar.css delete mode 100644 src/components/AppTopbar.tsx create mode 100644 src/components/Present.css create mode 100644 src/components/Present.tsx create mode 100644 src/components/SlideFrame.css create mode 100644 src/components/SlideFrame.tsx create mode 100644 src/components/StackdeckMark.tsx create mode 100644 src/components/Viewer.css create mode 100644 src/components/Viewer.tsx delete mode 100644 src/components/index.ts delete mode 100644 src/components/layout/BackLink.tsx delete mode 100644 src/components/layout/GalleryGrid.tsx delete mode 100644 src/components/layout/PageShell.tsx delete mode 100644 src/components/layout/PageWorkbar.tsx delete mode 100644 src/components/layout/page.css delete mode 100644 src/components/primitives/Button.tsx delete mode 100644 src/components/primitives/Text.tsx delete mode 100644 src/components/primitives/button.css delete mode 100644 src/components/primitives/typography.css delete mode 100644 src/editor/AssetsDrawer.tsx delete mode 100644 src/editor/ColorPicker.tsx delete mode 100644 src/editor/Editor.tsx delete mode 100644 src/editor/InsertMenu.tsx delete mode 100644 src/editor/SourceEditor.tsx delete mode 100644 src/editor/ThemeDrawer.tsx delete mode 100644 src/editor/cm-directive-highlight.ts delete mode 100644 src/editor/cm-slash-command.ts delete mode 100644 src/editor/cm-theme.ts delete mode 100644 src/editor/editor.css delete mode 100644 src/editor/insert-items.ts delete mode 100644 src/editor/sample-deck.ts delete mode 100644 src/ir/parse.ts delete mode 100644 src/ir/plan.ts delete mode 100644 src/ir/schema.ts delete mode 100644 src/ir/source-edit.ts delete mode 100644 src/layouts/index.ts create mode 100644 src/lib/case-studies.ts delete mode 100644 src/lib/color.ts delete mode 100644 src/library/DeckLibrary.tsx delete mode 100644 src/library/library.css delete mode 100644 src/present/PresentMode.tsx delete mode 100644 src/present/present.css delete mode 100644 src/presets/dossier/CONTRACT.md delete mode 100644 src/presets/dossier/atoms/DottedLeader.tsx delete mode 100644 src/presets/dossier/atoms/Furniture.tsx delete mode 100644 src/presets/dossier/atoms/Masthead.tsx delete mode 100644 src/presets/dossier/atoms/Monogram.tsx delete mode 100644 src/presets/dossier/atoms/RunningHead.tsx delete mode 100644 src/presets/dossier/atoms/ScaleBar.tsx delete mode 100644 src/presets/dossier/atoms/Sparkline.tsx delete mode 100644 src/presets/dossier/atoms/Spine.tsx delete mode 100644 src/presets/dossier/atoms/Stamp.tsx delete mode 100644 src/presets/dossier/atoms/Watermark.tsx delete mode 100644 src/presets/dossier/compose.tsx delete mode 100644 src/presets/dossier/extract.ts delete mode 100644 src/presets/dossier/slides/BeforeAfter.tsx delete mode 100644 src/presets/dossier/slides/Chart.tsx delete mode 100644 src/presets/dossier/slides/Closer.tsx delete mode 100644 src/presets/dossier/slides/Cover.tsx delete mode 100644 src/presets/dossier/slides/HeroStat.tsx delete mode 100644 src/presets/dossier/slides/KpiGrid.tsx delete mode 100644 src/presets/dossier/slides/PullQuote.tsx delete mode 100644 src/presets/dossier/slides/SectionDivider.tsx delete mode 100644 src/presets/dossier/slides/TearSheet.tsx delete mode 100644 src/presets/registry.tsx delete mode 100644 src/render/DeckRenderer.tsx delete mode 100644 src/render/ExportPdf.tsx delete mode 100644 src/render/SlideLogo.tsx delete mode 100644 src/render/SlideRenderer.tsx delete mode 100644 src/render/ThemeProvider.tsx delete mode 100644 src/render/contrast.ts delete mode 100644 src/render/lint.ts delete mode 100644 src/render/theme-resolver.ts delete mode 100644 src/storage/asset-store.ts delete mode 100644 src/storage/db.ts delete mode 100644 src/storage/deck-store.ts delete mode 100644 src/styles/app-shell.css delete mode 100644 src/styles/blocks.css delete mode 100644 src/styles/deck.css delete mode 100644 src/styles/dossier.css delete mode 100644 src/styles/layouts.css delete mode 100644 src/themes/fonts.ts delete mode 100644 src/themes/palettes/dossier.ts delete mode 100644 src/themes/registry.ts delete mode 100644 tests/blocks/registry.test.ts delete mode 100644 tests/ir/parse.test.ts delete mode 100644 tests/ir/plan.test.ts delete mode 100644 tests/ir/schema.test.ts delete mode 100644 tests/ir/source-edit.test.ts delete mode 100644 tests/lib/color.test.ts delete mode 100644 tests/render/contrast.test.ts delete mode 100644 tests/render/lint.test.ts delete mode 100644 tests/render/theme-resolver.test.ts delete mode 100644 tests/storage/deck-store.test.ts delete mode 100644 vitest.config.ts diff --git a/CLAUDE.md b/CLAUDE.md index 846f085..4f3f9e0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,173 +1,57 @@ Never run build or tests until I ask manually. -## The mental model (read this first) +## What this app is -**A preset is a hand-designed deck that has been embedded into this app.** Not a theme. Not a CSS skin over a generic template. A complete, intentional, designer-grade slide deck whose compositions live in this repo as JSX and scoped CSS. The only difference from a one-off, hand-coded deck is that the _content_ is variable: it comes in as markdown directives instead of being baked into the JSX. +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//`. 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. -The bar is the same as the bar for a designer-built deck delivered to a client. Bloomberg Businessweek case studies. Stripe annual reports. Apple keynotes. That level. If a preset's output looks like a "templated theme", the preset is failing its job. +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 reason this app exists is reuse: design a deck once, render it as many times as needed for as many clients as needed by swapping the markdown content. The design quality is fixed at preset-design time. The content is fluid. +## The mental model -## The product +- **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//` with a `meta.json`. +- **Ship.** The app picks it up, no code change needed. -The user experience is: pick a preset, drop your content in, it looks great, done. No customization panel, no grid editor, no layout options. The design quality is the product. The target user is content-rich and time-poor: consultants, agencies, founders. They should never have to make a visual decision. If you find yourself suggesting a customization option or a user-facing setting, stop. The preset should make that decision for them. +## Authoring contract (the only real rule) -Every preset is its own different design. A future "Bauhaus" preset is not Dossier with different colors. It is its own designed deck with its own grammar, its own type personality, its own decorative atoms, its own bespoke compositions. Two completely different decks that both happen to consume the same markdown directive vocabulary. +Every slide HTML file must: -## What a great preset must deliver +1. Be a complete `` document. +2. Render against a 1920×1080 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 `` tag. -A real client case study has three tiers of slide content. A preset must hand-design Tier 1 and opinionate Tier 2 and 3. +Slide assets go under `case-studies/<slug>/assets/` and are referenced as `assets/<file>` (resolved at runtime by `/c/<slug>/assets/<path>`). -### Tier 1, signature moments (where presets win or lose) +## File map -Roughly 9 slide types. Each is a bespoke composition: hand-tuned JSX with absolute positioning, exact type sizes, decorative atoms, art direction. Generic CSS does not produce these. Bespoke components do. +- `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. +- [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/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. -1. Cover. Project title, subhead, client, date, author. First impression. -2. Tear sheet. Client, industry, engagement, duration, team, outcome headline. -3. Section divider. Chapter number, italic oversized chapter title. Used 3 to 5 times in the deck. -4. Hero stat. The one number that matters. Fills 50 to 70 percent of the canvas. -5. KPI grid. 3 to 6 stats with deltas and trend arrows. Optional source caption. -6. Pull quote. Quote, attribution, optional photo. Full bleed. Decorative open-quote glyph. -7. Before / after. Two columns, transformation in one frame. -8. Chart slide. Title, chart, context line, source caption. -9. Closer. Thank you. Contact. Brand mark. Mirrors the cover. +## What this app must NOT grow into -### Tier 2, workhorse layouts (60 to 70 percent of slide count) +- No editor. +- 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. -Body slides that carry the narrative between signature moments. CSS scoped to the active preset is enough. They should look professionally consistent, not generic. - -Body (heading plus paragraph plus list). Two-column compare. Three-column principles. Process steps. Timeline. Deliverables. Image with caption. Annotated image. Logo strip. Data table. Agenda. - -### Tier 3, inline blocks (atoms) - -Headings (H1 to H4), paragraph, lead, caption, bulleted list, numbered list, inline stat, inline quote, code, callout (info / warn / success / neutral), inline table, inline chart, image (plain / framed / inline), hairline rule. Generic React components, preset CSS for tone. - -## Vocabulary (lock these meanings) - -A **deck** is the final artifact: a stored slide deck the user edits, presents, and exports. A deck is created by combining a **preset** with an optional **template**. - -### Preset = the hand-designed deck - -A Preset is the design surface. A complete deck design embedded as code in this repo. To deliver one, you ship: - -- 9 signature compositions as bespoke JSX components, each typically 80 to 200 lines. -- Scoped CSS for the workhorse layouts and inline blocks, written under `[data-preset='<id>']` (or for a single-design app, in a single locked CSS file like `src/styles/dossier.css`). -- Decorative atoms the design uses (SVG monograms, hairlines, glyphs). -- A curated demo template chosen to flatter the preset's specific compositions. -- A palette and a default font. - -Adding a new design means designing a new deck end to end. There is no shortcut where generic CSS produces editorial results. The cost of a serious preset is roughly a designer-week. - -The current app ships one design (`src/styles/dossier.css`) and exposes it as multiple `Preset` records that vary palette and font over that locked design. That is a legitimate cheap-reuse path for _variations of the same designed deck_ (Dossier Noir vs Dossier Midnight). It is not a substitute for adding a _new design_. A new design = a new CSS file (e.g. `src/styles/bauhaus.css`), possibly new bespoke JSX components, a new curated demo template. - -Type and registry: [src/app/presets/presets.ts](src/app/presets/presets.ts). Color tokens: [src/themes/palettes/](src/themes/palettes/). Fonts: [src/themes/fonts.ts](src/themes/fonts.ts). Resolver: [src/render/theme-resolver.ts](src/render/theme-resolver.ts). - -### Palette = colors - -A Palette is one full set of dark-mode color tokens (`brand`, `accent`, `surface`, `surfaceMuted`, `text`, `textMuted`, `border`, `success`, `warn`, `danger`). The deck is dark-only by design, so palettes have a single token set, no light/dark split. - -### Template = content - -A Template is content data: the markdown directive body of a starter deck plus metadata (`category`, `slideCount`, `recommendedPresetId`). A template has no design. The same template can be spawned with any preset. - -Type and registry: [src/app/templates/templates.ts](src/app/templates/templates.ts). Markdown bodies live in [src/app/templates/seeds/](src/app/templates/seeds/). - -### Directive = author intent - -The markdown directive vocabulary is what authors learn once and use across all presets. It carries author intent (e.g., "this slide is the hero stat"), and the active preset interprets that intent into its own bespoke composition. - -Directives are tokens like `::cover`, `::section`, `::stat`, `::kpi-grid`, `::quote.big`, `::testimonial`, `::tear-sheet`, `::process-steps`, `::timeline`, `::chart`, `::table`. Authors do not pick layouts or compositions, they pick the _kind of moment_. The preset owns the rest. - -## Current schema state (subject to extension when adding a real second design) - -`ThemeRef`: - -```ts -{ presetId: string; paletteId?: string; fontId?: string; } -``` - -That is the entire per-deck design override. No mode (dark only). No density (one airy scale baked into the resolver). No font slot per role (one font drives display and body, mono is fixed). - -`Preset`: - -```ts -{ id; name; vibe; paletteId; fontId; previewTemplateId; } -``` - -A preset is a named starting combination of palette + font over the existing design. To add a new _design_, you will need to extend this: the renderer's `data-preset` scoping must come back, and the preset record must point at its own CSS file and (where used) its own bespoke React components. - -Block IR is unchanged: heading, text, list, quote, stat, code, box, columns, grid, cell, chart, table, image. The directives expand into block trees in [src/ir/parse.ts](src/ir/parse.ts). - -## Authoring contract - -The author writes markdown. The directive vocabulary is small and stable across presets. The same `::stat{value="38m"}` produces a Hero Stat composition in Dossier and a Hero Stat composition in Bauhaus, both bespoke to that preset's design. The author never picks a "layout style". The author picks intent (cover, section, hero stat, pull quote) and the preset renders. - -## When to write JSX vs CSS - -- **Signature moments (Tier 1)**: bespoke React components per preset. Hand-tuned positioning, exact type sizes, decorative SVG, art direction. Generic CSS will not deliver this tier. -- **Workhorse layouts (Tier 2)**: scoped CSS under `[data-preset='<id>']` is fine. The preset's voice (color, type, hairlines, spacing) applied to shared structural blocks. -- **Inline blocks (Tier 3)**: scoped CSS overrides on top of the default block components. Typography, color, small spacing tweaks. - -If you find yourself trying to make a generic block render a hero-stat by tweaking CSS, stop. Build a bespoke component for the hero stat instead. Generic abstraction at the block level cannot win Tier 1. - -### Layered JSX pattern for Tier 1 components - -Every Tier 1 component has three layers: - -1. **Background layer**: full bleed, decorative atoms, color fills, SVG shapes. This is where the preset's visual personality lives. -2. **Layout shell**: fixed insets from all four sides, baked into the component as design decisions, not exposed as props or configuration. -3. **Content layer**: the variable markdown content positioned within the shell. - -The insets are part of the design. A cover slide for a given preset has specific padding because the designer chose it. That number does not change per deck and is not user-configurable. - -## Anti-patterns to avoid - -- Treating a Preset as a CSS skin over one generic template. It isn't. A preset is a designed deck. -- Trying to deliver Tier 1 with generic CSS. It will look templated. Use bespoke JSX. This has been tried and failed: CSS over shared generic components always produces generic-looking output regardless of how much the CSS is tuned. Bespoke JSX per slide type per preset is not optional for Tier 1. -- Adding "more flexible directives" or "more configuration" to compensate for design that is missing. Configuration cannot capture composition. Hand-design the composition instead. -- Assembling Tier 1 slides from shared component libraries. This produces UI-kit output, not editorial output. A hero stat component shared across presets will look identical across presets with just color and font differences. That is a failure. -- Bundling content (`seed`) on `Preset` or design (`paletteId` / `fontId`) on `Template`. The split is intentional and load-bearing. -- Calling the directive vocabulary "templates" in code or copy. Templates use directives; they aren't directives. -- Combining `Template` and `Preset` into one type. They are different axes (content vs design). -- Reintroducing a `mode` (light/dark) field. The system is dark-only. -- Reintroducing a `density` field on `ThemeRef`. One scale (airy, multiplier 1.35) is the system constant. -- Producing a kitchen-sink demo as the preset preview. The preview should be a curated handful of slides chosen to flatter that preset's specific compositions. - -## File map (current) - -- [src/app/presets/presets.ts](src/app/presets/presets.ts), preset registry. -- [src/app/templates/templates.ts](src/app/templates/templates.ts), template registry. -- [src/app/templates/seeds/](src/app/templates/seeds/), markdown bodies. -- [src/themes/palettes/](src/themes/palettes/), color token sets. -- [src/themes/fonts.ts](src/themes/fonts.ts), font catalog (matches `next/font` imports in [src/app/layout.tsx](src/app/layout.tsx)). -- [src/styles/dossier.css](src/styles/dossier.css), the locked design rules for the current (and only) deck design. Edit here to change the design itself. -- [src/styles/blocks.css](src/styles/blocks.css), [src/styles/layouts.css](src/styles/layouts.css), [src/styles/deck.css](src/styles/deck.css), structural defaults shared across all presets. -- [src/render/theme-resolver.ts](src/render/theme-resolver.ts), turns Preset + Palette + Brand into CSS variables. Spacing, radius, shadow, mono font are fixed system constants here. -- [src/render/ThemeProvider.tsx](src/render/ThemeProvider.tsx), wraps the deck and emits the CSS variables. -- [src/blocks/](src/blocks/), default block React components. -- [src/ir/parse.ts](src/ir/parse.ts), markdown directives to block IR. -- [src/ir/schema.ts](src/ir/schema.ts), IR types and zod schemas. - -## Routes - -- `/presets` lists presets. -- `/templates` lists templates. -- `/new` is the two-step deck creation flow: pick a template (or "Blank deck"), then a preset. -- `/d/<id>/edit` opens the editor. -- `/d/<id>/present` opens presentation mode. - -## Quick mental check before changes - -- Are you about to make a generic abstraction that has to work across all presets? If yes, stop. Bespoke per preset is almost always the right answer. -- Are you adding a directive that is really a layout option in disguise? If yes, the preset should own that composition, not the directive. -- Are you about to write CSS that renders the cover, section divider, hero stat, big quote, or closer? If yes, that is Tier 1. Bespoke JSX is the right tool, not CSS. -- Are you adding a "feature" to compensate for a preset feeling weak? Strengthen the preset's design instead. +If something needs to be different per deck, it lives in the deck's HTML. Not in the app. ## Workflow rules -- Brainstorm before building. For Tier 1 work this is non-negotiable: you must have a slide-by-slide visual description signed off before writing any JSX. Not vague ("clean", "editorial") but specific: exact type sizes, what decorative atoms appear, how space is used, what the slide does NOT include. If you do not have this, stop and ask. Writing Tier 1 code without a concrete design brief always produces generic output. -- One genuinely great preset is worth more than five mediocre ones. Do not register a preset until all 9 Tier 1 slides are solid. A weak cover or a generic hero stat means the preset is not ready to ship. -- Never auto-commit or auto-push. A prior "go ahead" does not carry over. +- Brainstorm before building. Don't auto-implement non-trivial changes. +- Never auto-commit, never auto-push. A prior "go ahead" does not carry over to git. - Commit messages: one line, under 72 characters, imperative voice. No body. No `Co-Authored-By: Claude`. -- Never use an em dash in any output (sentences, lists, headers, code comments). Use a comma, colon, or rephrase instead. +- Never use an em dash anywhere (sentences, lists, headers, code comments). Use a comma, colon, or rephrase. - For tasks that can be parallelized, run agents in parallel. diff --git a/README.md b/README.md index ed64096..eab1f4e 100644 --- a/README.md +++ b/README.md @@ -1,186 +1,76 @@ -<div align="center"> - # stackdeck -**Turn a markdown file into a beautiful slide deck.** -**Switch themes instantly. Export to PDF. No backend, no accounts, no lock-in.** - -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) -[![CI](https://github.com/Octify-Technologies/stackdeck/actions/workflows/ci.yml/badge.svg)](https://github.com/Octify-Technologies/stackdeck/actions/workflows/ci.yml) -[![Made with Next.js](https://img.shields.io/badge/Next.js-15-black?logo=next.js)](https://nextjs.org) -[![TypeScript](https://img.shields.io/badge/TypeScript-strict-3178C6?logo=typescript&logoColor=white)](https://www.typescriptlang.org) -[![Deployed on Vercel](https://img.shields.io/badge/Deployed-Vercel-black?logo=vercel)](https://stackdeck-seven.vercel.app) -[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](#contributing) - -[Live demo](https://stackdeck-seven.vercel.app) · [Grammar reference](docs/GRAMMAR.md) · [Architecture](docs/ARCHITECTURE.md) · [Changelog](CHANGELOG.md) +Internal viewer for Octify case study decks. Bring your own HTML, render as a deck. -</div> +## 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. -## Why stackdeck +There is no editor, no theming layer, no markdown directives. Slides are authored elsewhere (Astro, hand-coded HTML, whatever) and dropped in. -You write in markdown. The slides should follow. +## Authoring contract -Most slide tools force you into a visual editor, lock your content into proprietary formats, or hide your content behind a SaaS account. **stackdeck flips it.** Markdown is the source of truth. The same `.md` file renders under any theme, exports to PDF, and lives in your repo or on your disk forever. +Every slide HTML file must: -<!-- prettier-ignore --> -```md -::cover -# Q4 Review -A look at the year that was. -:: +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. -::slide +Static assets (images, fonts) live in `case-studies/<slug>/assets/` and are served at `/c/<slug>/assets/<path>`. -::stats -::stat{value="$3M" label="ARR" delta="+47%" trend="up"} -::stat{value="71" label="NPS" delta="+9" trend="up"} -::stat{value="12" label="Markets" delta="+5" trend="up"} -:: +## `meta.json` -::slide - -::quote.big -> The future is already here, it is just not evenly distributed. -> -- William Gibson -:: +```json +{ + "slug": "acme-churn", + "title": "How Acme cut churn by 38%", + "client": "Acme Corp", + "industry": "B2B SaaS", + "date": "2026-03-12", + "summary": "One-line summary shown on the index card.", + "tags": ["churn", "lifecycle"], + "cover": "01.html", + "slides": [ + { "file": "01.html", "title": "Cover" }, + { "file": "02.html", "title": "Tear sheet" } + ], + "visibility": "public" +} ``` -That markdown becomes a 3-slide deck with a cover, a 3-stat grid, and a takeover quote. Switch the theme and it reflows in milliseconds. +`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. + +## Routes -## Features +- `/` — 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 -- **Markdown-first.** Plain markdown plus a small set of semantic directives. No new programming language to learn. -- **Theme = Style × Density × Palette × Mode.** Tens of thousands of theme combinations from a small curated atom set. -- **Instant theme switching.** No re-render, no flicker. CSS variable swap on a deck root, sub-frame fast. -- **PDF export via the browser.** `window.print()` plus a careful print stylesheet. Vector text, custom fonts, exact 16:9 page geometry. -- **No backend.** Pure static deploy on Vercel, Cloudflare Pages, GitHub Pages, or anywhere static. Your content lives in your browser (or in a `.md` file you control). -- **Open standards.** TypeScript-strict, Zod-validated IR, semantic versioned grammar, no lock-in. -- **Single source of truth.** One markdown file, every theme, every render path. -- **Production-grade primitives.** 9 atomic blocks, 10 pattern directives, 8 layouts. Tree-shaped IR, exhaustive renderer. +## Keyboard -## Quickstart +| 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 | -Requires Node 22 (see [.nvmrc](.nvmrc)) and pnpm 10+. +## Development ```bash -git clone https://github.com/Octify-Technologies/stackdeck.git -cd stackdeck pnpm install pnpm dev ``` -Open `http://localhost:3000`. Edit the markdown on the left, watch the deck update on the right, switch themes from the toolbar, click **Export PDF** when you're ready. - -## What it looks like - -``` -┌──────────────────────┬──────────────────────────────────┐ -│ Markdown source │ Live slide preview │ -│ │ │ -│ ::cover │ ┌──────────────────────────┐ │ -│ # Q4 Review │ │ │ │ -│ :: │ │ Q4 Review │ │ -│ │ │ A look at the year │ │ -│ ::slide │ │ that was. │ │ -│ │ │ │ │ -│ # Highlights │ └──────────────────────────┘ │ -│ │ │ -│ - Revenue up 47% │ ┌──────────────────────────┐ │ -│ - NPS at 71 │ │ Highlights │ │ -│ │ │ • Revenue up 47% │ │ -│ │ │ • NPS at 71 │ │ -│ │ └──────────────────────────┘ │ -└──────────────────────┴──────────────────────────────────┘ - Toolbar: Style ▾ Palette ▾ Density ▾ Mode ▾ Export PDF -``` - -## How it compares - -| | stackdeck | Slidev | Marp | Pitch | -| --------------------------- | --------- | ---------- | ---------- | ----- | -| Markdown-first | ✅ | ✅ | ✅ | ❌ | -| Instant theme switching | ✅ | ⚠️ rebuild | ⚠️ rebuild | ✅ | -| Multi-theme from one source | ✅ | ⚠️ partial | ⚠️ partial | ❌ | -| Self-hosted | ✅ | ✅ | ✅ | ❌ | -| No backend required | ✅ | ✅ | ✅ | ❌ | -| Browser PDF export | ✅ | ⚠️ CLI | ⚠️ CLI | ✅ | -| TypeScript-strict source | ✅ | ✅ | ⚠️ | n/a | - -## Architecture in one paragraph - -A markdown file is parsed into a versioned IR (`Slide = { layout, blocks[] }`). Pattern directives like `::callout` and `::compare` compile to trees of 9 atomic block primitives. Inference picks a layout when the user does not specify one; a deck-level planner enforces position rules (cover at slide 0, no repeated section breaks). The renderer reads CSS variables emitted by the active theme (Style + Palette + Density + Mode), so theme switching is a CSS variable swap, not a React re-render. PDF export uses native `window.print()` with a 1280×720 `@page` rule. - -Full deep-dive in [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md). Grammar spec in [docs/GRAMMAR.md](docs/GRAMMAR.md). - -## Project layout - -``` -src/ - ir/ markdown parser, deck planner, Zod schemas (the IR contract) - blocks/ 9 atomic React primitives, one file each, all token-driven - layouts/ 8 layout definitions (CSS grid + metadata) - themes/ curated Styles + Palettes + registry - render/ ThemeProvider, SlideRenderer, DeckRenderer, PDF export - editor/ the live markdown editor with theme controls - styles/ global CSS that consumes the theme tokens - app/ Next.js routes -docs/ - GRAMMAR.md authoritative markdown grammar spec - ARCHITECTURE.md system overview -tests/ - ir/ deterministic tests for schema, parse, plan - render/ theme resolver tests -``` - -## Develop - -```bash -pnpm dev # start the editor on localhost:3000 -pnpm typecheck # tsc --noEmit -pnpm format # prettier --write -pnpm format:check # prettier --check (used by CI) -pnpm lint # eslint -pnpm knip # detect unused files, exports, dependencies -pnpm test # vitest, single run -pnpm test:watch # vitest, watch mode -pnpm test:coverage # vitest with v8 coverage -pnpm build # next build -``` - -CI runs typecheck, format-check, lint, knip, test, and build on every PR across Node 20 and 22. - -## Roadmap - -- [x] v0.1 — IR, parser, planner, theme system, 9 atomic blocks, 8 layouts, editor, PDF export -- [x] v0.2 — Brand kit, three-pane editor, templates gallery, Editorial + Brutalist Styles -- [x] v1.0 — Deck library at `/`, IndexedDB persistence with auto-save, `/d/[id]/edit` editor, Insert menu for directives, Soft Style, premium per-Style cover treatments -- [ ] v1.1 — CodeMirror editor with `/` directive palette and live syntax highlighting -- [ ] v1.2 — Image support (compressed-on-import, blob storage in IndexedDB) -- [ ] v1.3 — Charts and tables as native primitives -- [ ] v1.4 — Folders / collections; named brand profiles with logo + color presets -- [ ] v1.5 — Theme marketplace via static JSON registry, public stable grammar - -## Contributing - -PRs welcome. Please: - -1. Open an issue describing the change before sending a large PR. -2. Keep IR changes covered by tests in [tests/ir/](tests/ir/). Adding a directive or layout means adding a test. -3. Theme additions: one new file in [src/themes/styles/](src/themes/styles/) plus a registry entry. No code changes elsewhere. -4. Run `pnpm typecheck && pnpm test && pnpm lint` before pushing. - -The architectural boundary is firm: the renderer never sees pattern directives. They compile to atomic block trees in the parser. If your change wants to bend that rule, open an issue first. - -## License - -[MIT](LICENSE). Use it, fork it, ship it. - ---- - -<div align="center"> +## Publishing a case study -If stackdeck saved you time, [drop a star ⭐](https://github.com/Octify-Technologies/stackdeck) — it helps others find the project. +1. Create `case-studies/<slug>/` with a `meta.json` and your HTML files. +2. Open a PR. Merge. +3. Deploy. -</div> +That's it. diff --git a/case-studies/acme-churn/01.html b/case-studies/acme-churn/01.html new file mode 100644 index 0000000..108c810 --- /dev/null +++ b/case-studies/acme-churn/01.html @@ -0,0 +1,60 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<title>Cover + + + +
+
Octify
+
Case Study No. 014
+ +

How Acme cut churn by 38% in two quarters.

+ +
+
ClientAcme Corp
+
DateMarch 2026
+
AuthorOctify Technologies
+
EngagementQ4 2025 to Q1 2026
+
+ +
01
+
+
Octify Technologies / Confidential
+
+ + diff --git a/case-studies/acme-churn/02.html b/case-studies/acme-churn/02.html new file mode 100644 index 0000000..bf1f78a --- /dev/null +++ b/case-studies/acme-churn/02.html @@ -0,0 +1,81 @@ + + + + +Tear sheet + + + +
+
+
Octify
+
Case Study No. 014 / 02
+
+ + +

The engagement at a glance.

+ +
+
+
Client
Acme Corp
+
Industry
B2B SaaS, mid-market
+
Engagement
Retention overhaul
+
Duration
Q4 2025 to Q1 2026
+
Team
3 strategists, 2 engineers
+
Outcome
Churn down 38%
+
+ +
+

Acme was bleeding revenue at the edges of its lifecycle, with annual gross churn pushing 23% and a save flow that converted barely one in eight cancellations.

+

Over two quarters we re-architected the customer lifecycle, rebuilt the in-product save flow against fresh qualitative work, and stood up a dormant-account win-back program. By the close of Q1, gross churn sat at 14.3%, net revenue retention had crossed 118%, and the save rate climbed from 12% to 41%. The work is documented here, slide by slide, with the numbers that closed it out.

+
Prepared for the Acme board, March 2026
+
+
+ +
+
+ Octify Technologies / Confidential + 02 / 07 +
+
+ + diff --git a/case-studies/acme-churn/03.html b/case-studies/acme-churn/03.html new file mode 100644 index 0000000..0899053 --- /dev/null +++ b/case-studies/acme-churn/03.html @@ -0,0 +1,78 @@ + + + + +The problem + + + +
+
+
Octify
+
Case Study No. 014 / 03
+
+ + + +
23%
+ +
+
Diagnosis, October 2025
+

Annual gross churn at the close of FY24.

+

Roughly one in four customers leaving every year, the save flow converting at 12%, and a CAC payback period stretching past 19 months. The math no longer worked.

+ + + + + + + + + + + +
Quarterly gross churn, FY23 to FY24
+
+ +
+
+ Source: Acme RevOps, Q4 2025 + 03 / 07 +
+
+ + diff --git a/case-studies/acme-churn/04.html b/case-studies/acme-churn/04.html new file mode 100644 index 0000000..f4b6c1f --- /dev/null +++ b/case-studies/acme-churn/04.html @@ -0,0 +1,87 @@ + + + + +What we did + + + +
+
+
Octify
+
Case Study No. 014 / 04
+
+ + +

Four moves, in sequence.

+
No single intervention would have shifted the number. The work was a chain: a clean diagnosis fed a lifecycle redesign, which fed a new save flow, which fed a smarter win-back.
+ +
+
+
01
+

Diagnosis

+

Cohort decomposition across 14 months of billing data. Twelve customer interviews. We isolated three churn modes that accounted for 71% of all losses.

+
Weeks 1 to 3
+
+
+
02
+

Lifecycle re-architecture

+

Replaced a single onboarding flow with three role-tuned tracks. Activation milestones tied to billing events, with health scoring rebuilt against actual usage signals.

+
Weeks 4 to 9
+
+
+
03
+

Save-flow rebuild

+

Replaced a static cancel page with a dynamic offer engine. Five branches keyed off the cancel reason, with offers calibrated to LTV not gut feel.

+
Weeks 10 to 16
+
+
+
04
+

Win-back sequencing

+

A six-touch program reaching dormant accounts at 30, 60, and 90 days post-cancel. Two reactivation offers, both tied to specific product changes shipped after their exit.

+
Weeks 17 to 24
+
+
+ +
+
+ Octify Technologies / Confidential + 04 / 07 +
+
+ + diff --git a/case-studies/acme-churn/05.html b/case-studies/acme-churn/05.html new file mode 100644 index 0000000..9e45a6b --- /dev/null +++ b/case-studies/acme-churn/05.html @@ -0,0 +1,67 @@ + + + + +Voice of the client + + + +
+
+
Octify
+
Case Study No. 014 / 05
+
+ +
+ +
+

Octify did not hand us a deck and disappear. They sat in our cancel queue for a week, then told us, in numbers, exactly which churn we were causing ourselves. The save flow they built is now the single highest-leverage surface in our product.

+
+ +
+
+
Helena Vargas
+
Chief Revenue Officer, Acme Corp
+
+ +
+
+ Interview transcript, February 2026 + 05 / 07 +
+
+ + diff --git a/case-studies/acme-churn/06.html b/case-studies/acme-churn/06.html new file mode 100644 index 0000000..38fab3b --- /dev/null +++ b/case-studies/acme-churn/06.html @@ -0,0 +1,94 @@ + + + + +Outcomes + + + +
+
+
Octify
+
Case Study No. 014 / 06
+
+ + +

Two quarters, three numbers.

+
Measured at the close of Q1 2026, against the FY24 baseline. All figures audited by Acme RevOps and reviewed by the Acme board on March 12.
+ +
+
+
Gross churn
+
14.3%
+
+ + 8.7 points + from 23.0% +
+
+
+
Net revenue retention
+
118%
+
+ + 24 points + from 94% +
+
+
+
Save rate
+
41%
+
+ + 29 points + from 12% +
+
+
+ +
+
+ Source: Acme RevOps, March 2026 + 06 / 07 +
+
+ + diff --git a/case-studies/acme-churn/07.html b/case-studies/acme-churn/07.html new file mode 100644 index 0000000..7dded49 --- /dev/null +++ b/case-studies/acme-churn/07.html @@ -0,0 +1,66 @@ + + + + +Thank you + + + +
+
End / Case Study No. 014
+
Fin.
+ +
+
+ + Octify Technologies +
+

Thank you.

+
+
Emailhello@octify.tech
+
Weboctify.tech
+
AuthorOctify Studio, 2026
+
+
+ +
07
+
+
Octify Technologies / Confidential
+
07 / 07
+
+ + diff --git a/case-studies/acme-churn/meta.json b/case-studies/acme-churn/meta.json new file mode 100644 index 0000000..6ac369a --- /dev/null +++ b/case-studies/acme-churn/meta.json @@ -0,0 +1,18 @@ +{ + "slug": "acme-churn", + "title": "How Acme cut churn by 38%", + "client": "Acme Corp", + "industry": "B2B SaaS", + "date": "2026-03-12", + "summary": "A two-quarter intervention on lifecycle, save flow, and win-back. Gross churn from 23% to 14.3%.", + "tags": ["churn", "lifecycle", "saas"], + "slides": [ + { "file": "01.html", "title": "Cover" }, + { "file": "02.html", "title": "Tear sheet" }, + { "file": "03.html", "title": "The problem" }, + { "file": "04.html", "title": "What we did" }, + { "file": "05.html", "title": "Voice of the client" }, + { "file": "06.html", "title": "Outcomes" }, + { "file": "07.html", "title": "Thank you" } + ] +} diff --git a/case-studies/linen-rebrand/01.html b/case-studies/linen-rebrand/01.html new file mode 100644 index 0000000..447c29b --- /dev/null +++ b/case-studies/linen-rebrand/01.html @@ -0,0 +1,126 @@ + + + + +Linen, redrawn. Cover + + + +
+
OCTIFY MONOGRAPHS/NO. 09/CASE STUDY
+
+ +

linen,
redrawn.

+ +

+ Notes on a year of rebuilding a hospitality brand from the ground type up. A study in restraint, in the discipline of subtraction, and in the quiet authority of a single well set page. +

+ +
+
april mmxxvi
+
i
+
+ + diff --git a/case-studies/linen-rebrand/02.html b/case-studies/linen-rebrand/02.html new file mode 100644 index 0000000..88e3f1f --- /dev/null +++ b/case-studies/linen-rebrand/02.html @@ -0,0 +1,190 @@ + + + + +Linen, redrawn. Tear sheet + + + +
II/TEAR SHEET/THE FACTS
+ +
+ +
+
+
Client
Linen Hospitality, Lda.
+
Sector
Independent hotels, six properties
+
Engagement
Identity, voice, and print system
+
Timeline
March 2025 to February 2026
+
Team
Two designers, one writer
+
Outcome
One typeface. Two weights. Six properties.
+
+ +
+

+ Linen arrived loud. Eleven typefaces across the printed collateral, four logo lockups in active use, and a colour palette that had been added to, never edited from. The brief was deceptively simple: make it quiet again. Restore the authority the founder had built in the first two years and lost in the next ten. Strip the visual identity back to its load bearing components, define a system small enough to be remembered without a manual, and let the photography of the properties do the talking they had always been able to do. We agreed on three principles before drawing a single line. +

+
a deliberate constraint, see principle II overleaf
+
+
+ +
02 / 06
+
ii
+ + diff --git a/case-studies/linen-rebrand/03.html b/case-studies/linen-rebrand/03.html new file mode 100644 index 0000000..a65a60d --- /dev/null +++ b/case-studies/linen-rebrand/03.html @@ -0,0 +1,121 @@ + + + + +Linen, redrawn. Pull quote + + + +
+
III / VOICE
+ +
+
+

We had spent a decade adding. They taught us, with great patience, the discipline of taking away.

+
+
Marta Vieira·Founder·Linen Hospitality
+
+ +
03 / 06
+
iii
+ + diff --git a/case-studies/linen-rebrand/04.html b/case-studies/linen-rebrand/04.html new file mode 100644 index 0000000..a9a21b8 --- /dev/null +++ b/case-studies/linen-rebrand/04.html @@ -0,0 +1,165 @@ + + + + +Linen, redrawn. Three principles + + + +
+ THE SYSTEM/III PRINCIPLES +
+
A small set of rules, held to without exception.
+ +
+
+
+
I.
+

Restraint over expression.

+

Every choice subtracts before it adds. If a mark, a colour, or a flourish is not earning its place on the page, it is removed. The brand is what is left when nothing else is.

+
+
+
+
II.
+

One typeface, two weights.

+

A single serif carries the system, set at regular and at italic. No display cuts, no second family for headings. Hierarchy is built from size and space, not from variety.

+
+
+
+
III.
+

Asymmetry as honesty.

+

Centred composition flatters the page; asymmetry tells the truth about what matters. The system favours generous left margins, ragged right edges, and breath where most brands would push a logo.

+
+
+ +
04 / 06
+
iv
+ + diff --git a/case-studies/linen-rebrand/05.html b/case-studies/linen-rebrand/05.html new file mode 100644 index 0000000..44a975b --- /dev/null +++ b/case-studies/linen-rebrand/05.html @@ -0,0 +1,208 @@ + + + + +Linen, redrawn. Before and after + + + +
V/THE RESULT/BEFORE AND AFTER
+ +
+ +
+
BEFORE
+

Eleven typefaces, FOUR LOGOS

+

A palette that never edited.

+

Loud where it should have been QUIET

+
+ +
+
AFTER
+

One serif, set in two weights.

+

A wordmark, drawn once.

+

Quiet where it had been loud.

+
+ +
noise · visual debt · brand sprawl
+
restraint · system · authority
+ +
05 / 06
+
v
+ + diff --git a/case-studies/linen-rebrand/06.html b/case-studies/linen-rebrand/06.html new file mode 100644 index 0000000..daee406 --- /dev/null +++ b/case-studies/linen-rebrand/06.html @@ -0,0 +1,146 @@ + + + + +Linen, redrawn. Colophon + + + +
COLOPHON
+
+ +
+ +
+

linen, redrawn.

+

+ This monograph was set in Iowan Old Style, a transitional serif chosen for its quiet authority on long measure, and in Helvetica Neue at small sizes for running heads and folios. It was composed at 1920 by 1080, dark ink on warm cream stock, with hairlines drawn at one pixel. Art direction, writing, and typesetting by Octify Technologies, in collaboration with the founder and the front of house team at Linen Hospitality. Printed in a private edition of one, for the client; this digital impression is the ninth in the Octify Monographs series. +

+
+ + + +
OCTIFY TECHNOLOGIES/MONOGRAPH 09/2026
+ +
06 / 06
+
vi
+ + diff --git a/case-studies/linen-rebrand/meta.json b/case-studies/linen-rebrand/meta.json new file mode 100644 index 0000000..da9aab9 --- /dev/null +++ b/case-studies/linen-rebrand/meta.json @@ -0,0 +1,17 @@ +{ + "slug": "linen-rebrand", + "title": "linen, redrawn.", + "client": "Linen Hospitality", + "industry": "Hospitality", + "date": "2026-02-04", + "summary": "A year-long rebrand grounded in restraint, asymmetry, and a single typeface in two weights.", + "tags": ["brand", "editorial", "hospitality"], + "slides": [ + { "file": "01.html", "title": "Cover" }, + { "file": "02.html", "title": "The brief" }, + { "file": "03.html", "title": "Voice of the client" }, + { "file": "04.html", "title": "Three principles" }, + { "file": "05.html", "title": "Before / After" }, + { "file": "06.html", "title": "Colophon" } + ] +} diff --git a/case-studies/vector-onboarding/01.html b/case-studies/vector-onboarding/01.html new file mode 100644 index 0000000..99a87de --- /dev/null +++ b/case-studies/vector-onboarding/01.html @@ -0,0 +1,139 @@ + + + + +Vector / Cover + + + +
+
+ +
[ CASE • 022 ]
+
VECTOR / 2026
OCTIFY TECHNOLOGIES
+ +

+ Activation,
+ retooled. +

+ +
+ Vector/Q1, Q2 2026/Onboarding rebuild +
+ + + + +
+ + + + +
+ + diff --git a/case-studies/vector-onboarding/02.html b/case-studies/vector-onboarding/02.html new file mode 100644 index 0000000..f681e8b --- /dev/null +++ b/case-studies/vector-onboarding/02.html @@ -0,0 +1,195 @@ + + + + +Vector / Tear sheet + + + +
[ CASE • 022 / 02 ]
+
02 / 06
+

Tear sheet.

+
readout • instrument panel
+ +
+
+
Client
+
01
+
Vector
+
vector.dev
+
+
+
Industry
+
02
+
Developer tools
+
infra / CLI
+
+
+
Engagement
+
03
+
Onboarding rebuild
+
scope, design, ship
+
+
+
Timeline
+
04
+
11 wks
+
2026.01.20, 2026.04.06
+
+
+
Stack
+
05
+
Next.js, tRPC, Postgres
+
+ Segment, PostHog
+
+
+
Team
+
06
+
04
+
PM, design, 2 eng
+
+
+
Blocker
+
07
+
9-step signup, no SSO
+
drop-off at step 4
+
+
+
Outcome
+
08
+
2.4×
+
activation, 12.3%, 30.1%
+
+
+ + + + diff --git a/case-studies/vector-onboarding/03.html b/case-studies/vector-onboarding/03.html new file mode 100644 index 0000000..2f68b33 --- /dev/null +++ b/case-studies/vector-onboarding/03.html @@ -0,0 +1,173 @@ + + + + +Vector / The problem + + + +
[ CASE • 022 / 03 ]
+
03 / 06
+ +
+

Activation flatlined at 12%.

+
+
+
2026-01-14T09:12:33Z signup.completed step=4/9 abandoned=true reason=auth_friction
+
2026-01-14T09:18:02Z workspace.created empty=true first_action=null
+
2026-01-14T09:21:47Z session.dropped tta=18m40s retry=0
+
2026-01-14T09:33:11Z signup.started fields=14 submit_err=email_in_use
+
2026-01-14T09:41:05Z invite.required blocked=true reason=team_gate
+
2026-01-14T09:52:29Z activation.checked cohort=2026w02 rate=0.123
+
+
SOURCE / POSTHOG, INTERNAL LOGS, N=18,402
+
+ +
+
[ ACTIVATION / BASELINE ]
+
12.3%
+
+
Activation rate, baseline (Q4 2025)
+
+ + + + diff --git a/case-studies/vector-onboarding/04.html b/case-studies/vector-onboarding/04.html new file mode 100644 index 0000000..c9cc3a5 --- /dev/null +++ b/case-studies/vector-onboarding/04.html @@ -0,0 +1,217 @@ + + + + +Vector / The redesign + + + +
[ CASE • 022 / 04 ]
+
04 / 06
+ +

Five steps. Three optional.

+
flow rebuild • reduced fields, deferred friction, prefilled state
+ +
+ + + + + + + + + + + + 01 + REQ + Sign up + email + pwd + + + + + + 02 + OPT + SSO + google, github + + + + + + 03 + REQ + Workspace + name only + + + + + + 04 + OPT + Sample data + prefilled + + + + + + 05 + REQ + First action + deploy / query + + + + + + + + + + < 200ms + optional + auto-prefilled + deferred + + + + + + + REMOVED 3 FIELDS + + + + + + NEW + + + + + + SEEDED PER ROLE + + + + + + DEFERRED TO STEP 5 + + + + + + 14 → 4 INPUTS + + +
+ + + + diff --git a/case-studies/vector-onboarding/05.html b/case-studies/vector-onboarding/05.html new file mode 100644 index 0000000..ab4c779 --- /dev/null +++ b/case-studies/vector-onboarding/05.html @@ -0,0 +1,219 @@ + + + + +Vector / The result + + + +
[ CASE • 022 / 05 ]
+
05 / 06
+ +

The result.

+
measured 30 days post-launch • n=24,118
+ +
+
+
01 / Activation rate
+
30.1%
+
+ + +17.8 pts + / from 12.3% +
+ + + + + +
baseline / Q4 2025
+
+ +
+
02 / Time to first value
+
4m 12s
+
+ + -77% + / from 18m 40s +
+ + + + + +
median, signed-in users
+
+ +
+
03 / 30-day retention
+
64%
+
+ + +33 pts + / from 31% +
+ + + + + +
cohort 2026w14
+
+
+ + + + diff --git a/case-studies/vector-onboarding/06.html b/case-studies/vector-onboarding/06.html new file mode 100644 index 0000000..218ed09 --- /dev/null +++ b/case-studies/vector-onboarding/06.html @@ -0,0 +1,157 @@ + + + + +Vector / End of file + + + +
+
+ +
[ CASE • 022 / EOF ]
+
VECTOR / 2026
OCTIFY TECHNOLOGIES
+ +

End of file.

+ +
$ octify --status complete
+ +
+ Octify Technologies
+ case-022
+ 2026.04.18
+ octifytechnologies.com +
+ + + + +
+ + + +
+ + diff --git a/case-studies/vector-onboarding/meta.json b/case-studies/vector-onboarding/meta.json new file mode 100644 index 0000000..4b5fa5b --- /dev/null +++ b/case-studies/vector-onboarding/meta.json @@ -0,0 +1,17 @@ +{ + "slug": "vector-onboarding", + "title": "Activation, retooled.", + "client": "Vector", + "industry": "Developer tools", + "date": "2026-04-18", + "summary": "An onboarding rebuild that took activation from 12.3% to 30.1% in one quarter.", + "tags": ["onboarding", "activation", "devtools"], + "slides": [ + { "file": "01.html", "title": "Cover" }, + { "file": "02.html", "title": "Tear sheet" }, + { "file": "03.html", "title": "The problem" }, + { "file": "04.html", "title": "The redesign" }, + { "file": "05.html", "title": "The result" }, + { "file": "06.html", "title": "End of file" } + ] +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md deleted file mode 100644 index 673a89f..0000000 --- a/docs/ARCHITECTURE.md +++ /dev/null @@ -1,332 +0,0 @@ -# Architecture - -This document explains how stackdeck turns a markdown file into a rendered slide deck. It is the design contract; if you change something here, the code should follow, and vice versa. - -## Top-level data flow - -``` -┌──────────────┐ parse ┌──────────────┐ plan ┌──────────────┐ -│ │ ───────────▶ │ │ ──────────▶ │ │ -│ Markdown │ │ Deck IR │ │ Planned IR │ -│ (.md file) │ │ (validated) │ │ (validated) │ -│ │ │ │ │ │ -└──────────────┘ └──────────────┘ └──────────────┘ - │ - │ render - ▼ - ┌──────────────────────────────┐ - │ ThemeProvider │ - │ ┌────────────────────────┐ │ - │ │ DeckRenderer │ │ - │ │ ┌──────────────────┐ │ │ - │ │ │ SlideRenderer │ │ │ - │ │ │ ┌────────────┐ │ │ │ - │ │ │ │ BlockRender│ │ │ │ - │ │ │ └────────────┘ │ │ │ - │ │ └──────────────────┘ │ │ - │ └────────────────────────┘ │ - └──────────────────────────────┘ - │ - │ window.print() - ▼ - ┌──────────────┐ - │ PDF file │ - └──────────────┘ -``` - -Five stages, four data shapes. Each stage is pure: same input, same output. Only the editor and the browser print pipeline have side effects. - -## The IR - -The IR (intermediate representation) is the contract between every part of the system. Defined in [src/ir/schema.ts](../src/ir/schema.ts), validated by Zod, type-inferred for TypeScript. - -``` -Deck -├── version "2.0" -├── id ULID -├── title -├── aspectRatio "16:9" -├── theme ThemeRef -├── slides[] Slide -├── createdAt -└── updatedAt - -Slide -├── id ULID -├── layout LayoutId (one of 8) -├── blocks[] Block (one of 9 atomic types) -└── notes? string - -Block (discriminated union, 9 variants) -├── heading { level: 1..4, text } -├── text { emphasis: normal|lead|caption, text } -├── list { ordered, items[] } (recursive) -├── quote { emphasis: normal|big, text, attribution? } -├── stat { value, label?, delta?, trend? } -├── code { language?, content } -├── box { tone?, children: Block[] } (recursive) -├── columns { count: 2|3, columns: Block[][] } (recursive) -└── grid { cols, rows, children: Block[] } (recursive) -``` - -The recursive container blocks (Box, Columns, Grid) hold child blocks, making the IR a tree, not a flat list. This is what makes "put a box inside a column inside a slide" work cleanly. - -## The grammar layer (parser) - -[src/ir/parse.ts](../src/ir/parse.ts) is a single-pass line-tokenizer plus a recursive descent parser. - -``` -markdown source - │ - ▼ -┌─────────────────────────┐ -│ 1. Frontmatter split │ gray-matter splits YAML frontmatter from body -└─────────────────────────┘ - │ - ▼ -┌─────────────────────────┐ -│ 2. Slide section split │ split body on `::slide` lines into N sections -└─────────────────────────┘ - │ - ▼ -┌─────────────────────────┐ -│ 3. Per-slide tokenize │ classify each line: open / close / colsep / void / text -└─────────────────────────┘ - │ - ▼ -┌─────────────────────────┐ -│ 4. Recursive parse │ expand directives, accumulate text into markdown chunks -└─────────────────────────┘ - │ - ▼ -┌─────────────────────────┐ -│ 5. Pattern expansion │ ::callout → Box{tone}, ::compare → Columns{2}, etc. -└─────────────────────────┘ - │ - ▼ -┌─────────────────────────┐ -│ 6. Markdown → blocks │ marked.lexer for plain-md regions, mapped to Heading/Text/List/Quote/Code -└─────────────────────────┘ - │ - ▼ -┌─────────────────────────┐ -│ 7. Layout selection │ explicit > pattern > inferred -└─────────────────────────┘ - │ - ▼ -┌─────────────────────────┐ -│ 8. Schema validation │ Zod safeParse on the whole Deck -└─────────────────────────┘ - │ - ▼ - Deck IR -``` - -**The discipline:** the renderer never sees pattern names like `callout` or `compare`. They are compiled away in stage 5. The IR contains only the 9 atomic block types. Adding a new pattern is a parser-only change with zero impact on the renderer or themes. - -## The planner (deck-level pass) - -[src/ir/plan.ts](../src/ir/plan.ts) runs after the parser produces a per-slide IR. It enforces deck-level rules that local parsing cannot: - -| Rule | Effect | -| ------------------------------------------------------------------------ | ------------------------------------ | -| Slide 0 with single H1 and sparse content gets `cover` layout | Even if user did not write `::cover` | -| Slide 0 tagged `cover` but content is too dense gets demoted to `flow` | Honest fallback | -| Mid-deck `cover` layout becomes `section` if it is heading-only | Cover only belongs at slide 0 | -| Two adjacent slides with the same uncommon layout: second becomes `flow` | Avoid two `fullBleed` in a row | - -Inference is local; planning is global. Both produce the same `Deck` shape so the renderer never knows which path made each decision. - -## The theme system - -A theme is composed at runtime from four orthogonal axes: - -``` -Theme = Style × Density × Palette × Mode - -Style typography, radius, shadow, motion, base spacing, color sets (light + dark) -Density multiplier on the spacing scale: dense 0.75, comfortable 1.0, airy 1.35, spacious 1.7 -Palette brand + accent + optional surface/text overrides -Mode light or dark -``` - -The theme resolver ([src/render/theme-resolver.ts](../src/render/theme-resolver.ts)) is a pure function: - -``` -resolveTheme(themeRef, style, palette) -> - { - colors: { brand, accent, surface, surface-muted, text, text-muted, border, success, warn, danger } - cssVars: { --color-brand, --space-md, --radius-lg, --font-display, --shadow-md, ... } - } -``` - -The `cssVars` map is applied as inline `style` on a `.deck-root` element by `ThemeProvider`. Switching theme is a single CSS variable swap, ~40 properties. No React reconciliation. Sub-frame on a 100-slide deck. - -``` -┌──────────────────────────────────────────────────────────────┐ -│ │ -│ │ -│
│ -│
│ -│
│ -│
│ -│

│ -│ Title │ -│

│ -│ │ -│
│ -│
│ -│ ... │ -│
│ -│
│ -│ │ -│ │ -└──────────────────────────────────────────────────────────────┘ -``` - -**The discipline:** atomic block components consume CSS variables only. They never reference hardcoded hex colors, pixel spacing, or font families. This is what makes adding a new theme a token-file change with zero component edits. - -## The renderer - -``` -DeckRenderer - └── ThemeProvider (injects CSS vars) - └── div.deck - └── for each Slide: - └── div.slide-frame (16:9 box) - └── SlideRenderer - └── section.slide.layout-{id} - └── for each Block: - └── BlockRenderer (dispatches by type) - └── Heading | Text | List | Quote | Stat | Code | Box | Columns | Grid -``` - -The dispatcher in [src/blocks/BlockRenderer.tsx](../src/blocks/BlockRenderer.tsx) is exhaustive: TypeScript enforces a `case` for every block type. Adding a new atomic block is a compile-time error until you handle it in the dispatcher. - -## The storage layer - -v0.1 stores the source markdown and theme reference in React state only. It does not persist across reloads. v0.2 will add IndexedDB persistence with auto-save and version history. The persistence module will be `src/storage/`, isolated from the IR and rendering layers. - -## PDF export - -A single button calls `window.print()`. The print stylesheet (in [src/styles/deck.css](../src/styles/deck.css)) does the work: - -```css -@page { - size: 1280px 720px; /* Exact 16:9 */ - margin: 0; -} - -@media print { - .slide-frame { - width: 1280px; - height: 720px; - page-break-after: always; - } - * { - animation: none !important; - transition: none !important; - } -} -``` - -Modern Chrome's print pipeline produces vector text, embeds custom fonts, and respects CSS gradients. The output is a real PDF, not a screenshot. No serverless function needed. - -The honest tradeoffs: - -- The user sees the browser's native print dialog, not a stackdeck-branded one. -- Some browsers (older Safari) handle web fonts differently in print. -- Animations and `position: sticky` are disabled in print by the global rules above. - -## Module boundaries - -``` - ┌─────────────────┐ - │ src/ir/ │ pure logic, no React, no DOM - │ schema.ts │ - │ parse.ts │ - │ plan.ts │ - └────────┬────────┘ - │ (Block, Deck, Slide types) - │ - ┌───────────────────┼───────────────────┐ - │ │ │ - ▼ ▼ ▼ - ┌──────────┐ ┌──────────────┐ ┌─────────────┐ - │ blocks/ │ │ layouts/ │ │ themes/ │ - │ 9 React │ │ 8 grid defs │ │ Style+Pal │ - │ comp. │ │ + CSS │ │ registry │ - └─────┬────┘ └──────┬───────┘ └──────┬──────┘ - │ │ │ - └─────────┬─────────┴───────────────────┘ - │ - ▼ - ┌──────────┐ - │ render/ │ ThemeProvider, DeckRenderer, SlideRenderer, ExportPdf - └─────┬────┘ - │ - ▼ - ┌──────────┐ - │ editor/ │ the UI shell - └─────┬────┘ - │ - ▼ - ┌──────────┐ - │ app/ │ Next.js routes - └──────────┘ -``` - -Dependencies only flow downward. `ir/` knows nothing about React. `blocks/` and `themes/` only depend on IR types. `render/` orchestrates. `editor/` is the only place state lives. `app/` is just the Next.js mounting points. - -## Testing strategy - -Deterministic logic gets unit tests. UI gets reviewed by humans. - -| Layer | Tested? | How | -| ---------------- | ------- | ----------------------------------------------- | -| `ir/schema.ts` | Yes | Validators accept good shapes, reject bad ones | -| `ir/parse.ts` | Yes | Each directive, each pattern, full 7-slide e2e | -| `ir/plan.ts` | Yes | Each rule in isolation | -| `theme-resolver` | Yes | Color overrides, density scaling, mode swap | -| Block components | No | Visual review | -| Editor UI | No | Visual review | -| Print stylesheet | No | Manual PDF export check before each Style ships | - -Coverage thresholds (in [vitest.config.ts](../vitest.config.ts)) are enforced on `src/ir/` and `src/render/theme-resolver.ts`: 75% lines, 75% functions, 70% branches, 75% statements. - -## What this architecture buys you - -- **One source, many themes.** Same markdown file, switch theme, get a different look without touching the source. -- **Cheap theme authoring.** A new Style is one TypeScript file in [src/themes/styles/](../src/themes/styles/). Zero changes elsewhere. -- **Cheap pattern authoring.** A new directive is one branch in the parser's pattern expander. Zero changes elsewhere. -- **Print fidelity.** PDF is a real PDF, not a screenshot. -- **No backend.** Pure static deploy. -- **No lock-in.** Markdown is forever readable, the IR schema is documented, the grammar is versioned. - -## What this architecture deliberately defers - -- Images (planned v0.5) -- Charts and tables (planned v0.6) -- Multiple aspect ratios (16:9 only in v0.x) -- Real-time multiplayer (out of scope; single-user product) -- A backend (out of scope; the no-backend constraint is a feature) - -## Where to look first - -| You want to | Start here | -| -------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | -| Understand the IR | [src/ir/schema.ts](../src/ir/schema.ts) | -| Add a markdown directive | [src/ir/parse.ts](../src/ir/parse.ts) `expandBlockDirective` | -| Add a layout | [src/layouts/index.ts](../src/layouts/index.ts) + [src/styles/layouts.css](../src/styles/layouts.css) | -| Add a Style or Palette | [src/themes/styles/](../src/themes/styles/) + [src/themes/registry.ts](../src/themes/registry.ts) | -| Tune block visuals | [src/blocks/](../src/blocks/) + [src/styles/blocks.css](../src/styles/blocks.css) | -| Change theme switching mechanics | [src/render/theme-resolver.ts](../src/render/theme-resolver.ts) + [src/render/ThemeProvider.tsx](../src/render/ThemeProvider.tsx) | -| Improve PDF output | [src/styles/deck.css](../src/styles/deck.css) `@media print` | diff --git a/docs/GRAMMAR.md b/docs/GRAMMAR.md deleted file mode 100644 index 5b51ff3..0000000 --- a/docs/GRAMMAR.md +++ /dev/null @@ -1,558 +0,0 @@ -# stackdeck Markdown Grammar (v2.0) - -This document is the authoritative spec for the markdown syntax that produces a stackdeck deck. The parser, the inference engine, the editor's `/` palette, and the documentation site all derive from this file. - -The mental model is small: - -1. A deck is one markdown file plus a theme reference. -2. The file is split into slides by `::slide`. -3. Inside each slide, plain markdown is allowed and gets inferred into atomic blocks. -4. Pattern directives like `::callout` give explicit semantic intent. -5. Layout directives like `::columns` arrange blocks into shapes. -6. Directives describe **intent**, never form. Visual treatment is owned by the theme. - ---- - -## 1. Document structure - -``` ---- -title: Q4 Review ---- - -::cover -# Q4 Review -A look at the year that was. - -::slide - -# Highlights - -- Revenue up 47% -- 12 new markets -- NPS at 71 - -::slide - -::stats -::stat{value="$3M" label="ARR"} -::stat{value="47%" label="MoM Growth"} -::stat{value="71" label="NPS"} -:: - -::slide - -::callout -This was a transformational quarter for the team. -:: -``` - -### 1.1 Frontmatter - -Optional YAML at the start of the file, fenced by `---`. Recognized fields: - -| Field | Type | Purpose | -| ------------- | ------ | -------------------------------------------- | -| `title` | string | Deck title. Defaults to first H1 if missing. | -| `description` | string | Optional summary, used for sharing meta. | -| `theme` | object | Inline theme override (see section 6.2). | - -Frontmatter is parsed but never rendered as a slide. - -### 1.2 Slide separator - -`::slide` on its own line marks the start of a new slide. The first slide does not require a preceding `::slide`. - -``` -# This is slide 1 - -::slide - -# This is slide 2 -``` - -A `::slide` directive may carry slide-level options: - -``` -::slide{layout=hero} -::slide{notes="Speaker notes here"} -::slide{nosplit} # forbid auto-split on overflow -``` - -Recognized options: - -| Option | Value | Effect | -| --------- | --------------- | --------------------------------------------------------------------------- | -| `layout` | a LayoutId | Forces a specific layout for this slide, overriding inference. | -| `notes` | quoted string | Speaker notes, never rendered on the slide itself. | -| `nosplit` | flag (no value) | Forbids auto-split if content overflows. Slide may clip; user accepts that. | - ---- - -## 2. Atomic primitives (9) - -These are the only blocks the renderer understands. Pattern directives compile into trees of these. - -### 2.1 Heading - -``` -# Title -> { type: "heading", level: 1, text: "Title" } -## Subtitle -> { type: "heading", level: 2, text: "Subtitle" } -### Section -> level: 3 -#### Detail -> level: 4 -``` - -Levels 5 and 6 are downgraded to level 4. Inline markdown (`**bold**`, `*italic*`, `` `code` ``, `[link](url)`) is preserved in the `text` field and rendered by the inline renderer. - -### 2.2 Text - -A paragraph in markdown becomes a `text` block. - -``` -Lorem ipsum dolor sit amet. -> { type: "text", text: "...", emphasis: "normal" } -``` - -Emphasis variants: - -``` -::lead -The opening promise of this section. -:: - -::caption -A small caption. -:: -``` - -Compile to `{ type: "text", emphasis: "lead" | "caption", text: "..." }`. - -### 2.3 List - -Standard markdown lists. - -``` -- One -- Two - - Two-A - - Two-B -- Three - -1. First -2. Second -``` - -Compiles to a `list` block with `ordered` reflecting `1.` vs `-`. Nesting is preserved. - -### 2.4 Quote - -Markdown blockquote, optionally with attribution after a `--` separator on its own line. - -``` -> Make it work, make it right, make it fast. -> -- Kent Beck -``` - -Compiles to `{ type: "quote", text: "...", attribution: "Kent Beck", emphasis: "normal" }`. See `::quote.big` (section 3.7) for the takeover variant. - -### 2.5 Stat - -A single big-number block, used inside `::stats` / `::kpis` or as a standalone slide. - -``` -::stat{value="$3M" label="ARR"} -::stat{value="47%" label="MoM" delta="+12%" trend="up"} -``` - -Compiles to `{ type: "stat", value, label?, delta?, trend? }`. - -### 2.6 Box - -Generic container with optional semantic tone. Holds children blocks. - -``` -::box{tone=info} -Important secondary note. -:: - -::box{tone=warn} -Heads up about a risk. -:: -``` - -Tones: `info`, `warn`, `success`, `neutral`. Theme decides visual treatment. - -### 2.7 Columns - -Explicit horizontal arrangement. - -``` -::columns{count=2} -::: -First column content. -::: -Second column content. -::: -:: -``` - -Each `:::` starts a new column. Compiles to `{ type: "columns", count: 2|3, columns: [Block[], Block[]] }`. - -### 2.8 Grid - -``` -::grid{cols=2 rows=2} -Top-left content. - -Top-right content. - -Bottom-left content. - -Bottom-right content. -:: -``` - -Children are placed left-to-right, top-to-bottom into the grid. Compiles to `{ type: "grid", cols, rows, children: Block[] }`. - -### 2.9 Code - -Standard markdown fenced code block. - -```` -```ts -const x: number = 1; -``` -```` - -Compiles to `{ type: "code", language: "ts", content: "..." }`. - ---- - -## 3. Pattern directives (10) - -Patterns are sugar. The parser expands them into trees of atomic primitives. The renderer never sees the pattern name. Adding a pattern is a parser-only change. - -### 3.1 `::cover` - -Deck cover. Only meaningful on slide 1; ignored elsewhere by the deck planner. - -``` -::cover -# Big Title -A subtitle that frames the deck. -:: -``` - -Expands to a `cover` layout containing `Heading.h1` + `Text.lead`. - -### 3.2 `::section` - -Section break, used between major parts of the deck. - -``` -::section -# Part Two: Where We're Going -:: -``` - -Expands to a `section` layout containing `Heading.h1`. - -### 3.3 `::callout` - -Aside or important note. - -``` -::callout{tone=info} -This is the most important takeaway. -:: -``` - -Expands to `Box{tone}` containing the inner blocks. Tone defaults to `neutral`. - -### 3.4 `::compare` - -Two-sided comparison: before/after, this/that, problem/solution. - -``` -::compare -::: -**Before** -The old way was slow and error-prone. -::: -**After** -The new way is faster and safer. -::: -:: -``` - -Expands to a `split` layout containing two columns, each with the content given. - -### 3.5 `::stats` - -Row of 2 to 6 stats. Auto-arranges into the right grid. - -``` -::stats -::stat{value="42%" label="Lift"} -::stat{value="$3M" label="ARR"} -::stat{value="71" label="NPS"} -:: -``` - -Expands to a `grid` layout sized to fit (e.g. 3 stats -> grid 3x1). - -### 3.6 `::kpis` - -Larger grid (4 to 8 stats), arranged 2x2, 3x2, or 2x3. - -``` -::kpis -::stat{value="$3M" label="ARR"} -::stat{value="47%" label="MoM"} -::stat{value="71" label="NPS"} -::stat{value="12" label="Markets"} -:: -``` - -Expands to a `grid` layout. Parser picks rows/cols based on count. - -### 3.7 `::quote.big` - -Full-bleed takeover quote. - -``` -::quote.big -> The future is already here, it's just not evenly distributed. -> -- William Gibson -:: -``` - -Expands to a `fullBleed` layout containing `Quote{emphasis: "big"}`. - -### 3.8 `::steps` - -Ordered procedural list with step formatting. - -``` -::steps -1. Define the problem. -2. Sketch a solution. -3. Build the smallest version. -4. Ship and learn. -:: -``` - -Expands to `List{ordered: true}` inside a `flow` layout, with the renderer treating items as steps via theme. - -### 3.9 `::timeline` - -Time-anchored sequence. - -``` -::timeline -- **2021** -- Founded. -- **2022** -- First product launch. -- **2023** -- Series A. -- **2024** -- Profitability. -:: -``` - -Each item is parsed as `**when** -- body`. Expands to a `flow` layout containing a structured List the theme renders as a timeline. - -### 3.10 `::agenda` - -Deck table of contents. - -``` -::agenda -- Why we're here -- What we shipped -- What's next -- Q&A -:: -``` - -Expands to a `flow` layout with `Heading.h2 ("Agenda")` + `List` styled by the theme. - ---- - -## 4. Layouts (8) - -Layouts are JSON files at `src/layouts/.layout.json`. Each defines a CSS grid with named slots that blocks fill. - -| LayoutId | Purpose | -| ----------- | ---------------------------------------------------------------- | -| `flow` | Top-to-bottom stack. Default fallback when inference is unsure. | -| `hero` | One dominant block, optional supporting content beneath. | -| `cover` | Deck cover treatment. Big title, subtitle, generous spacing. | -| `section` | Section break. Sparse, big, transition slide between deck parts. | -| `split` | Two-column 50/50. Used by `::compare`. | -| `columns` | Explicit N-column grid (2 or 3). Used by `::columns`. | -| `grid` | Explicit N x M grid. Used by `::stats`, `::kpis`, `::grid`. | -| `fullBleed` | Single dominant element edge-to-edge. Used by `::quote.big`. | - -A layout may declare `supportedRatios`. v1 only ships `16:9`, so this is informational. - ---- - -## 5. Inference - -When a slide has no `layout=` option and no pattern directive, the inference engine picks a layout and arranges blocks. Three passes run in order. - -### 5.1 Local pass - -Score each candidate layout against the slide's blocks independently. - -| Heuristic | Suggests | -| ------------------------------------------------ | ----------- | -| Single Heading.h1, sparse content, position 0 | `cover` | -| Single Heading.h1 or h2, no body, not position 0 | `section` | -| One Stat block alone | `hero` | -| 2 to 6 Stat blocks | `grid` | -| One Quote block alone | `fullBleed` | -| Heading + List or Heading + Text | `flow` | -| Anything else | `flow` | - -### 5.2 Deck pass (planner) - -After every slide has a candidate, the planner adjusts based on deck-level rules: - -- Slide 0 must use `cover` if any cover-shaped candidate exists. -- No two adjacent slides may share the same uncommon layout (avoid two `fullBleed` in a row). -- A slide tagged `::section` keeps `section` regardless of content. -- A slide carrying a pattern directive keeps its directive's layout. - -### 5.3 Theme pass - -The active Style may declare layouts it cannot render well. The planner substitutes a fallback. v1 themes all support all layouts; this pass is reserved for future theme-specific overrides. - ---- - -## 6. Theme reference - -A deck carries a `theme` object referring to a Style + Palette + Density + Mode. None of these affect the markdown source. Switching theme re-renders the same IR with different tokens. - -### 6.1 Theme on a deck - -``` -deck.theme = { - styleId: "editorial", - paletteId: "electric-blue", - density: "comfortable", // dense | comfortable | airy | spacious - mode: "light" // light | dark -} -``` - -### 6.2 Inline theme override (frontmatter) - -``` ---- -title: My Deck -theme: - styleId: brutalist - paletteId: monochrome - density: dense - mode: dark ---- -``` - -Inline overrides are convenience for authors. The persisted deck record is the source of truth. - ---- - -## 7. Overflow behavior - -A slide whose blocks exceed the available 16:9 area under the active theme + density gets auto-split into two slides at a sensible boundary (between top-level blocks). The editor surfaces a chip: - -> "Slide 4 overflowed under Airy density, split into 4a and 4b. Switch to Comfortable to keep as one." - -Authors can opt out per slide with `::slide{nosplit}`. The slide may clip; the author accepts that. - ---- - -## 8. Print - -Every slide must render correctly when the user invokes "Save as PDF" via the browser print dialog. The print stylesheet is part of the theme contract. Authors of new themes are responsible for testing print output across all 10 atomic primitives, both modes, and all 4 densities. - -Animations, transitions, and `position: sticky` are disabled in print. Custom fonts are preloaded with `font-display: block` before print fires. The `@page` rule sets a 1280x720 page size with zero margins, producing exact 16:9 PDF pages. - ---- - -## 9. Examples - -### 9.1 Minimal deck - -``` -::cover -# Hello - -::slide - -# What we'll cover -- The problem -- The solution -- The result - -::slide - -::stats -::stat{value="$3M" label="ARR"} -::stat{value="47%" label="Growth"} -::stat{value="71" label="NPS"} -:: - -::slide - -::quote.big -> Beautiful is better than ugly. -> -- Tim Peters -``` - -### 9.2 Compare slide - -``` -::slide - -::compare -::: -**Before** - -The pipeline took 12 minutes and failed 8% of runs. -::: -**After** - -The new pipeline runs in 2 minutes with a 0.4% failure rate. -::: -:: -``` - -### 9.3 Mixed columns - -``` -::slide - -# Why now - -::columns{count=2} -::: -::callout{tone=info} -The cost of waiting compounds. -:: -::: -::stat{value="$1M" label="Annual cost of inaction"} -::: -:: -``` - ---- - -## 10. What's NOT in v1 - -The following are deliberate omissions, listed so authors and theme designers know what to expect. - -- **Images.** No image upload, no image directive. v1 is text-only. -- **Charts.** Deferred. Will arrive as a separate atomic primitive in v1.5+. -- **Tables.** Use `::columns` or `::grid` for v1; native table primitive comes later. -- **Math.** No LaTeX rendering in v1. -- **Multiple aspect ratios.** Only 16:9. -- **User-uploaded fonts.** Top 10 Google Fonts, self-hosted, are the v1 set. -- **Live share links.** Sharing in v1 is via .deck file export or PDF. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md deleted file mode 100644 index 7a78b72..0000000 --- a/docs/ROADMAP.md +++ /dev/null @@ -1,44 +0,0 @@ -# Roadmap - -Wave 1: Ship a real case study - -- Two premium templates only: `case-study-pro` (sales-call optimized) and `case-study-editorial` (sendable PDF optimized) -- Bespoke React composition per template, IR as the shared contract -- Real `::grid` and `::cell` primitives with span control and asymmetric splits -- Image support with bleed, focal-point crop, captions, aspect-ratio control -- Drag-drop and paste image directly into the preview pane -- Asset library per project: logos, photos, screenshots reusable across every deck in the project -- Brand kit per project: logo, colors, fonts, footer -- Projects and folders to organize decks -- Premium directives: `::cover`, `::section`, `::scope-strip`, `::problem`, `::approach`, `::kpi-grid`, `::big-number`, `::before-after`, `::testimonial-card`, `::pull-quote`, `::asset-frame`, `::annotated-image`, `::tear-sheet`, `::contact` -- Smart number formatting and auto trend arrows in `::kpi-grid` and `::big-number` (`$1.2M`, `+47%↑` with OpenType numerals) -- Source citations on stats rendered as designed footnotes -- Designed page furniture: page numbers, kickers, footers, section markers -- PDF export: 16:9 landscape true bleed, vector-only, font subsetting, hyperlinks, bookmarks, auto TOC -- Fullscreen present mode with arrow-key nav (no notes, no timer) - -Wave 2: Live in it day to day - -- In-preview editing: click any element in the preview to edit in place -- Drag-to-reorder slides in the thumbnail panel -- Undo and redo -- Duplicate slide -- Slide library: save any slide as a reusable block -- Outline view -- Keyboard shortcuts (cmd+/, cmd+d, cmd+1..9) -- Version history via auto-snapshots -- Full-text search across all stored decks -- Click-to-zoom on images during present -- Cursor highlight / spotlight mode during present -- PDF watermark modes at export: DRAFT, CONFIDENTIAL, FOR REVIEW, INTERNAL ONLY -- Workspace export and import as a single `.zip` (decks, assets, templates, brand kits) - -Wave 3: Premium finish - -- Background system per template: gradients, grain, decorative shapes -- Image treatments per template: duotone, B&W, polaroid, hard-frame, mask -- Auto-contrast on every palette swap -- Smart layout selection (3 stats horizontal, 4 stats 2x2, 6 stats 3x2) -- Color contrast linter -- Required alt text on images -- Custom font upload (woff2) diff --git a/eslint.config.mjs b/eslint.config.mjs index 05dca62..ed4698a 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -6,18 +6,7 @@ import prettier from 'eslint-config-prettier'; export default tseslint.config( { - ignores: [ - '.next/**', - 'node_modules/**', - 'coverage/**', - 'next-env.d.ts', - 'src/lib/**', - 'src/templates/**', - 'src/components/**', - 'src/primitives/**', - 'src/hooks/**', - 'src/data/**', - ], + ignores: ['.next/**', 'node_modules/**', 'next-env.d.ts', 'case-studies/**'], }, js.configs.recommended, ...tseslint.configs.recommended, @@ -60,11 +49,5 @@ export default tseslint.config( 'react-hooks/exhaustive-deps': 'warn', }, }, - { - files: ['tests/**/*.ts'], - rules: { - '@typescript-eslint/no-explicit-any': 'off', - }, - }, prettier, ); diff --git a/knip.json b/knip.json deleted file mode 100644 index de16bce..0000000 --- a/knip.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "$schema": "https://unpkg.com/knip@6/schema.json", - "entry": ["src/app/**/page.tsx", "src/app/**/layout.tsx"], - "project": ["src/**/*.{ts,tsx}", "tests/**/*.ts"], - "next": { - "entry": ["src/app/**/page.tsx", "src/app/**/layout.tsx", "next.config.{mjs,js,ts}"] - }, - "vitest": { - "config": ["vitest.config.ts"], - "entry": ["tests/**/*.test.ts"] - } -} diff --git a/next.config.mjs b/next.config.mjs index 549de6b..bbb2c14 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -8,7 +8,7 @@ const nextConfig = { source: '/:path*', headers: [ { key: 'X-Content-Type-Options', value: 'nosniff' }, - { key: 'X-Frame-Options', value: 'DENY' }, + { key: 'X-Frame-Options', value: 'SAMEORIGIN' }, { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' }, { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' }, ], diff --git a/package.json b/package.json index 20da4f2..b332026 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "stackdeck", - "version": "0.1.0", + "version": "0.2.0", "private": true, "scripts": { "dev": "next dev --turbopack", @@ -9,11 +9,7 @@ "lint": "eslint .", "format": "prettier --write .", "format:check": "prettier --check .", - "knip": "knip", "typecheck": "tsc --noEmit -p tsconfig.json", - "test": "vitest run", - "test:watch": "vitest", - "test:coverage": "vitest run --coverage", "prepare": "husky" }, "lint-staged": { @@ -26,20 +22,9 @@ ] }, "dependencies": { - "@codemirror/autocomplete": "^6.20.2", - "@codemirror/commands": "^6.10.3", - "@codemirror/lang-markdown": "^6.5.0", - "@codemirror/language": "^6.12.3", - "@codemirror/search": "^6.7.0", - "@codemirror/state": "^6.6.0", - "@codemirror/view": "^6.42.0", - "@lezer/highlight": "^1.2.3", - "gray-matter": "^4.0.3", - "marked": "^14.1.3", "next": "^15.0.4", "react": "^19.0.0", "react-dom": "^19.0.0", - "ulid": "^2.3.0", "zod": "^3.23.8" }, "devDependencies": { @@ -47,18 +32,14 @@ "@types/node": "^22.10.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", - "@vitejs/plugin-react": "^6.0.1", - "@vitest/coverage-v8": "^4.1.5", "eslint": "^10.3.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^7.1.1", "husky": "^9.1.7", - "knip": "^6.11.0", "lint-staged": "^17.0.2", "prettier": "^3.8.3", "typescript": "^5.6.3", - "typescript-eslint": "^8.59.2", - "vitest": "^4.1.5" + "typescript-eslint": "^8.59.2" } } diff --git a/src/app/apple-icon.tsx b/src/app/apple-icon.tsx index 5c999f5..754482c 100644 --- a/src/app/apple-icon.tsx +++ b/src/app/apple-icon.tsx @@ -9,17 +9,17 @@ export default function AppleIcon() { style={{ width: '100%', height: '100%', - background: '#0b0b0f', + background: '#0a0a0a', display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: 36, }} > - - - - + + + + , { ...size }, diff --git a/src/app/c/[slug]/assets/[...path]/route.ts b/src/app/c/[slug]/assets/[...path]/route.ts new file mode 100644 index 0000000..755f330 --- /dev/null +++ b/src/app/c/[slug]/assets/[...path]/route.ts @@ -0,0 +1,21 @@ +import { NextResponse } from 'next/server'; +import { readAsset } from '@/lib/case-studies'; + +export async function GET( + _req: Request, + { params }: { params: Promise<{ slug: string; path: string[] }> }, +) { + const { slug, path: parts } = await params; + const asset = `assets/${parts.join('/')}`; + const result = await readAsset(slug, asset); + if (!result) { + return new NextResponse('Not found', { status: 404 }); + } + return new NextResponse(new Uint8Array(result.buf), { + status: 200, + headers: { + 'Content-Type': result.type, + 'Cache-Control': 'public, max-age=3600, s-maxage=86400', + }, + }); +} diff --git a/src/app/c/[slug]/not-found.css b/src/app/c/[slug]/not-found.css new file mode 100644 index 0000000..534d533 --- /dev/null +++ b/src/app/c/[slug]/not-found.css @@ -0,0 +1,111 @@ +.nf { + min-height: 100vh; + display: flex; + flex-direction: column; + background: var(--bg); + position: relative; + z-index: 2; +} + +.nf-bar { + display: flex; + align-items: center; + height: 68px; + padding: 0 32px; + border-bottom: 1px solid var(--line); +} + +.nf-brand { + display: inline-flex; + align-items: center; + gap: 10px; + color: var(--fg); + font-size: 16px; + font-weight: 600; + letter-spacing: -0.012em; +} + +.nf-main { + flex: 1; + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: center; + padding: 0 64px; + max-width: 880px; + margin: 0 auto; + width: 100%; +} + +.nf-eyebrow { + display: inline-flex; + align-items: center; + padding: 6px 12px; + background: var(--accent-bg); + border: 1px solid var(--accent-border); + color: var(--accent-soft); + border-radius: 999px; + font-family: var(--mono); + font-size: 11px; + letter-spacing: 0.06em; + text-transform: uppercase; + margin-bottom: 24px; +} + +.nf-title { + margin: 0 0 24px; + font-size: clamp(40px, 6vw, 72px); + font-weight: 500; + letter-spacing: -0.035em; + line-height: 1; + color: var(--fg); +} + +.nf-title-accent { + color: var(--accent); +} + +.nf-sub { + margin: 0 0 36px; + font-size: 17px; + line-height: 1.55; + color: var(--fg-soft); + max-width: 540px; +} + +.nf-cta { + display: inline-flex; + align-items: center; + gap: 10px; + height: 44px; + padding: 0 22px; + background: var(--accent); + color: var(--bg); + border-radius: var(--rad); + font-size: 15px; + font-weight: 600; + transition: + background 0.15s var(--ease), + transform 0.06s var(--ease); +} + +.nf-cta:hover { + background: var(--accent-soft); + transform: translateY(-1px); +} + +.nf-cta svg { + transition: transform 0.2s var(--ease); +} + +.nf-cta:hover svg { + transform: translateX(3px); +} + +@media (max-width: 720px) { + .nf-bar, + .nf-main { + padding-left: 24px; + padding-right: 24px; + } +} diff --git a/src/app/c/[slug]/not-found.tsx b/src/app/c/[slug]/not-found.tsx new file mode 100644 index 0000000..590d9b6 --- /dev/null +++ b/src/app/c/[slug]/not-found.tsx @@ -0,0 +1,38 @@ +import Link from 'next/link'; +import { StackdeckMark } from '@/components/StackdeckMark'; +import './not-found.css'; + +export default function CaseStudyNotFound() { + return ( +
+
+ + + stackdeck + +
+
+ 404 — case study not found +

+ We could not find that deck. +

+

+ The case study you were looking for has been moved or never existed. Head back to the + index and pick another one. +

+ + Back to all decks + + + + +
+
+ ); +} diff --git a/src/app/c/[slug]/page.tsx b/src/app/c/[slug]/page.tsx new file mode 100644 index 0000000..fb3f534 --- /dev/null +++ b/src/app/c/[slug]/page.tsx @@ -0,0 +1,33 @@ +import { notFound } from 'next/navigation'; +import { getCaseStudy, listCaseStudies } from '@/lib/case-studies'; +import { Viewer } from '@/components/Viewer'; +import type { Metadata } from 'next'; + +export async function generateStaticParams() { + const studies = await listCaseStudies(); + return studies.map((s) => ({ slug: s.slug })); +} + +export async function generateMetadata({ + params, +}: { + params: Promise<{ slug: string }>; +}): Promise { + const { slug } = await params; + const study = await getCaseStudy(slug); + if (!study) return {}; + return { + title: study.title, + description: study.summary ?? `Octify case study, ${study.client ?? ''}`.trim(), + }; +} + +export default async function CaseStudyPage({ params }: { params: Promise<{ slug: string }> }) { + const { slug } = await params; + const study = await getCaseStudy(slug); + if (!study) notFound(); + + return ( + + ); +} diff --git a/src/app/c/[slug]/print/PrintDoc.tsx b/src/app/c/[slug]/print/PrintDoc.tsx new file mode 100644 index 0000000..90eb5b7 --- /dev/null +++ b/src/app/c/[slug]/print/PrintDoc.tsx @@ -0,0 +1,74 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; + +type SlideRef = { file: string; title?: string }; + +type Props = { + slug: string; + title: string; + slides: SlideRef[]; +}; + +export function PrintDoc({ slug, title, slides }: Props) { + const [loaded, setLoaded] = useState(0); + const total = slides.length; + const printedRef = useRef(false); + + // Auto-trigger print once all iframes have loaded. + useEffect(() => { + if (loaded >= total && !printedRef.current) { + printedRef.current = true; + // Tiny delay so the browser settles layout + const t = setTimeout(() => window.print(), 400); + return () => clearTimeout(t); + } + }, [loaded, total]); + + const ready = loaded >= total; + + return ( +
+
+
+ {title} + + {total} {total === 1 ? 'slide' : 'slides'} + +
+
+ + {ready ? 'Ready to save as PDF' : `Loading slides… ${loaded}/${total}`} + + +
+
+ +
+ Tip: in the print dialog, set destination to Save as PDF, layout to{' '} + Landscape, and margins to None for a clean export. +
+ +
+ {slides.map((s, i) => ( +
+