From e866afd40f8abf5e823ca81ac18e8ae7f08ec668 Mon Sep 17 00:00:00 2001 From: developeranku Date: Wed, 6 May 2026 20:20:12 +0530 Subject: [PATCH 1/6] refactor: split preset (design) and template (content); empty registries --- CLAUDE.md | 52 ++++ src/app/new/NewDeckGallery.tsx | 259 ++++++++++++++---- src/app/new/page.tsx | 6 +- src/app/presets/PresetsGallery.tsx | 163 +++++++++++ src/app/presets/page.tsx | 15 + .../templates.css => presets/presets.css} | 85 ++++-- src/app/presets/presets.ts | 24 ++ src/app/sitemap.ts | 1 + src/app/templates/TemplatesGallery.tsx | 84 +++--- src/app/templates/page.tsx | 4 +- .../templates/seeds/case-study-editorial.ts | 100 ------- src/app/templates/seeds/case-study-pro.ts | 97 ------- src/app/templates/template-presets.ts | 46 ---- src/app/templates/templates.ts | 23 ++ src/blocks/BlockRenderer.tsx | 4 + src/blocks/index.ts | 2 +- src/components/AppTopbar.css | 2 +- src/components/AppTopbar.tsx | 1 + src/editor/Editor.tsx | 4 +- src/ir/parse.ts | 99 ++++++- src/styles/app-shell.css | 12 +- tests/ir/parse.test.ts | 75 +++++ 22 files changed, 777 insertions(+), 381 deletions(-) create mode 100644 CLAUDE.md create mode 100644 src/app/presets/PresetsGallery.tsx create mode 100644 src/app/presets/page.tsx rename src/app/{templates/templates.css => presets/presets.css} (61%) create mode 100644 src/app/presets/presets.ts delete mode 100644 src/app/templates/seeds/case-study-editorial.ts delete mode 100644 src/app/templates/seeds/case-study-pro.ts delete mode 100644 src/app/templates/template-presets.ts create mode 100644 src/app/templates/templates.ts diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..b861b68 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,52 @@ +Never run build or tests until I ask manually. + +## 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 = design + +A **Preset** is a visual design and nothing else. It owns: + +- `styleId` (typography system, radii, shadows, motion) +- `paletteId` (brand and accent colors) +- `density` (spacing scale) +- `defaultMode` (light or dark) +- `previewTemplateId` (which template the gallery uses to render the card preview) + +A preset has NO content. It is the "house style" of a deck. Multiple presets coexist (`octify-magazine`, plus future ones for brutalist, swiss-modernist, cinematic-dark, etc.). + +Type and registry: [src/app/presets/presets.ts](src/app/presets/presets.ts). Visual rules live in [src/themes/styles/](src/themes/styles/) and [src/themes/palettes/](src/themes/palettes/), with per-preset CSS scoped under `.deck-root[data-style='']` (see [src/styles/magazine.css](src/styles/magazine.css) for the pattern). + +### Template = content + +A **Template** is content data for a starter deck. It owns: + +- `seed` (markdown with directives) +- `category` (case-study, pitch, sales, internal) +- `slideCount` +- `recommendedPresetId` (the preset the gallery uses for preview render and spawns by default) + +A template has NO design. The same template can be spawned with any preset and re-skinned without touching its markdown. + +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/). + +### Quick mental check + +- Adding a new design → new **preset**. +- Adding a new starter deck (different content shape) → new **template**. +- "Light vs dark" is a property of a preset, NOT a template. +- The directive vocabulary itself (`::cover`, `::kpi-grid`, `::testimonial`, `::process-steps`, etc.) is part of the directive system, not a "template". Templates USE directives; they aren't directives. + +### Routes + +- `/presets` lists presets (designs). +- `/templates` lists templates (content). +- `/new` is the two-step deck creation flow: step 1 picks a template (or "Blank deck"), step 2 picks a preset. + +### Anti-patterns to avoid + +- Do NOT bundle a `seed` field on `Preset`. +- Do NOT bundle `styleId`/`paletteId` on `Template`. +- Do NOT add a single combined "TemplatePreset" type. The split is intentional and load-bearing. +- Do NOT call the directive vocabulary "templates" in code or copy. diff --git a/src/app/new/NewDeckGallery.tsx b/src/app/new/NewDeckGallery.tsx index db44fb4..4c94665 100644 --- a/src/app/new/NewDeckGallery.tsx +++ b/src/app/new/NewDeckGallery.tsx @@ -1,8 +1,9 @@ 'use client'; import { useRouter } from 'next/navigation'; -import { useMemo } from 'react'; +import { useMemo, useState } from 'react'; +import type { Mode } from '@/ir/schema'; import { ParseError, parseDeck } from '@/ir/parse'; import { planDeck } from '@/ir/plan'; import { createDeck } from '@/storage/deck-store'; @@ -18,7 +19,8 @@ import { PageWorkbar, } from '@/components'; -import { TEMPLATE_PRESETS, type TemplatePreset } from '@/app/templates/template-presets'; +import { TEMPLATES, type Template } from '@/app/templates/templates'; +import { PRESETS, getPreset, type Preset } from '@/app/presets/presets'; const STARTER_MARKDOWN = `--- title: New deck @@ -52,33 +54,23 @@ Replace this with your own title. :: `; -const BLANK_TEMPLATE: TemplatePreset = { - id: 'blank', - name: 'Blank', - vibe: 'Start from scratch', - category: 'case-study', - styleId: 'modern', - paletteId: 'electric', - density: 'comfortable', - mode: 'light', - seed: STARTER_MARKDOWN, - slideCount: 3, -}; +type Picked = { template: Template | null; preset: Preset | null }; export function NewDeckGallery() { const router = useRouter(); + const [picked, setPicked] = useState(null); - const create = async (preset: TemplatePreset) => { + const create = async (template: Template | null, preset: Preset, mode: Mode) => { try { const deck = await createDeck({ - source: preset.seed, + source: template?.seed ?? STARTER_MARKDOWN, theme: { styleId: preset.styleId, paletteId: preset.paletteId, density: preset.density, - mode: preset.mode, + mode, }, - templateName: preset.id === 'blank' ? undefined : preset.name, + templateName: template?.name, }); router.push(`/d/${deck.id}/edit`); } catch (err) { @@ -87,24 +79,42 @@ export function NewDeckGallery() { } }; - const totalChoices = TEMPLATE_PRESETS.length + 1; + if (picked) { + return ( + setPicked(null)} + onConfirm={(preset, mode) => create(picked.template, preset, mode)} + /> + ); + } return ( - + - create(BLANK_TEMPLATE)} /> - {TEMPLATE_PRESETS.map((preset) => ( - create(preset)} /> + { + const defaultPreset = PRESETS[0]; + if (!defaultPreset) return; + setPicked({ template: null, preset: null }); + }} + /> + {TEMPLATES.map((template) => ( + setPicked({ template, preset: null })} + /> ))} @@ -116,31 +126,34 @@ function BlankCard({ onClick }: { onClick: () => void }) { return ( ); } -function TemplateCard({ preset, onClick }: { preset: TemplatePreset; onClick: () => void }) { +function TemplateCard({ template, onClick }: { template: Template; onClick: () => void }) { + const preset = getPreset(template.recommendedPresetId); + const previewDeck = useMemo(() => { + if (!preset) return { ok: false as const, error: 'Preset missing' }; try { - const parsed = parseDeck(preset.seed, { + const parsed = parseDeck(template.seed, { theme: { styleId: preset.styleId, paletteId: preset.paletteId, density: preset.density, - mode: preset.mode, + mode: preset.defaultMode, }, }); const planned = planDeck(parsed); @@ -149,43 +162,183 @@ function TemplateCard({ preset, onClick }: { preset: TemplatePreset; onClick: () const message = e instanceof ParseError ? e.message : (e as Error).message; return { ok: false as const, error: message }; } - }, [preset]); - - const templateAttr = templateSlug(preset.id); + }, [template, preset]); return ( ); } -function templateSlug(id: string): 'case-study-pro' | 'case-study-editorial' | 'other' { - if (id === 'case-study-pro') return 'case-study-pro'; - if (id === 'case-study-editorial') return 'case-study-editorial'; - return 'other'; +function PresetPicker({ + picked, + onCancel, + onConfirm, +}: { + picked: Picked; + onCancel: () => void; + onConfirm: (preset: Preset, mode: Mode) => void; +}) { + return ( + + + + + + + + {PRESETS.map((preset) => ( + onConfirm(preset, mode)} + /> + ))} + + + + + ); +} + +function PresetChoiceCard({ + preset, + template, + onConfirm, +}: { + preset: Preset; + template: Template | null; + onConfirm: (mode: Mode) => void; +}) { + const [previewMode, setPreviewMode] = useState(preset.defaultMode); + const seed = template?.seed ?? STARTER_MARKDOWN; + + const previewDeck = useMemo(() => { + try { + const parsed = parseDeck(seed, { + theme: { + styleId: preset.styleId, + paletteId: preset.paletteId, + density: preset.density, + mode: previewMode, + }, + }); + const planned = planDeck(parsed); + return { ok: true as const, deck: { ...planned, slides: planned.slides.slice(0, 3) } }; + } catch (e) { + const message = e instanceof ParseError ? e.message : (e as Error).message; + return { ok: false as const, error: message }; + } + }, [preset, previewMode, seed]); + + const stop = (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + }; + + return ( + + + + + design + + +
+ + {preset.name} + + {preset.vibe} +
+ + +
+
+ + ); } diff --git a/src/app/new/page.tsx b/src/app/new/page.tsx index 0095b6f..7004427 100644 --- a/src/app/new/page.tsx +++ b/src/app/new/page.tsx @@ -1,12 +1,12 @@ import type { Metadata } from 'next'; import { NewDeckGallery } from './NewDeckGallery'; -import '@/app/templates/templates.css'; +import '@/app/presets/presets.css'; export const metadata: Metadata = { - title: 'New deck, pick a template', + title: 'New deck, pick a preset', description: - 'Start a new slide deck from a curated template. Pitch decks, editorials, brutalist manifestos, and more.', + 'Start a new slide deck from a curated preset. Pitch decks, editorials, brutalist manifestos, and more.', alternates: { canonical: '/new' }, }; diff --git a/src/app/presets/PresetsGallery.tsx b/src/app/presets/PresetsGallery.tsx new file mode 100644 index 0000000..46b211f --- /dev/null +++ b/src/app/presets/PresetsGallery.tsx @@ -0,0 +1,163 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import { useMemo, useState } from 'react'; + +import type { Mode } from '@/ir/schema'; +import { ParseError, parseDeck } from '@/ir/parse'; +import { planDeck } from '@/ir/plan'; +import { createDeck } from '@/storage/deck-store'; +import { DeckRenderer } from '@/render/DeckRenderer'; +import { + AppTopbar, + Caption, + GalleryGrid, + Heading, + Label, + PageMain, + PageWorkbar, + PageShell, +} from '@/components'; + +import { PRESETS, type Preset } from './presets'; +import { getTemplate } from '@/app/templates/templates'; + +export function PresetsGallery() { + const router = useRouter(); + + const applyPreset = async (preset: Preset, mode: Mode) => { + const tpl = getTemplate(preset.previewTemplateId); + try { + const deck = await createDeck({ + source: tpl?.seed ?? '# Blank deck\n', + theme: { + styleId: preset.styleId, + paletteId: preset.paletteId, + density: preset.density, + mode, + }, + templateName: preset.name, + }); + router.push(`/d/${deck.id}/edit`); + } catch (err) { + const message = err instanceof Error ? err.message : 'Could not create deck'; + window.alert(`Could not create the deck: ${message}`); + } + }; + + return ( + + + + + + + + {PRESETS.map((preset) => ( + applyPreset(preset, mode)} + /> + ))} + + + + ); +} + +function PresetCard({ preset, onApply }: { preset: Preset; onApply: (mode: Mode) => void }) { + const [previewMode, setPreviewMode] = useState(preset.defaultMode); + const previewTemplate = getTemplate(preset.previewTemplateId); + + const previewDeck = useMemo(() => { + if (!previewTemplate) return { ok: false as const, error: 'Preview template missing' }; + try { + const parsed = parseDeck(previewTemplate.seed, { + theme: { + styleId: preset.styleId, + paletteId: preset.paletteId, + density: preset.density, + mode: previewMode, + }, + }); + const planned = planDeck(parsed); + return { ok: true as const, deck: { ...planned, slides: planned.slides.slice(0, 3) } }; + } catch (e) { + const message = e instanceof ParseError ? e.message : (e as Error).message; + return { ok: false as const, error: message }; + } + }, [preset, previewMode, previewTemplate]); + + const presetAttr = preset.id; + const stop = (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + }; + + return ( + + + + + design + + +
+ + {preset.name} + + {preset.vibe} +
+ + + + +
+
+ + ); +} diff --git a/src/app/presets/page.tsx b/src/app/presets/page.tsx new file mode 100644 index 0000000..a324196 --- /dev/null +++ b/src/app/presets/page.tsx @@ -0,0 +1,15 @@ +import type { Metadata } from 'next'; + +import { PresetsGallery } from './PresetsGallery'; +import './presets.css'; + +export const metadata: Metadata = { + title: 'Presets', + description: + 'Visual designs for your slide decks. Each preset is a typography and color system you can pair with any template.', + alternates: { canonical: '/presets' }, +}; + +export default function PresetsPage() { + return ; +} diff --git a/src/app/templates/templates.css b/src/app/presets/presets.css similarity index 61% rename from src/app/templates/templates.css rename to src/app/presets/presets.css index a322dbf..5029f7d 100644 --- a/src/app/templates/templates.css +++ b/src/app/presets/presets.css @@ -1,25 +1,18 @@ /* ========================================================================== - templates / new-deck gallery — page-specific styles + presets / new-deck gallery — page-specific styles Page shell, workbar, grid, and surface card live in shared primitives (components/layout/page.css and primitives/*). This file owns ONLY - template-card unique parts: preview frame + scaler, blank-card plus + preset-card unique parts: preview frame + scaler, blank-card plus icon, and per-template accent + tag chip. ========================================================================== */ -/* Per-template accent colors used by .surface-card[data-template=*]. */ - -.template-card[data-template='case-study-pro'] { - --accent: 56, 189, 248; -} - -.template-card[data-template='case-study-editorial'] { - --accent: 217, 119, 87; -} +/* Per-preset accent colors used by .surface-card[data-preset=*]. Add a + selector here for each new preset id to color its tag chips. */ /* Preview window --------------------------------------------------------------------------*/ -.template-card__preview { +.preset-card__preview { position: relative; width: 100%; aspect-ratio: 16 / 9; @@ -29,21 +22,21 @@ } /* Multi-slide preview shows 3 stacked 16:9 slides. */ -.template-card__preview--multi { +.preset-card__preview--multi { aspect-ratio: 16 / 11; } -.template-card--blank .template-card__preview { +.preset-card--blank .preset-card__preview { background: transparent; } -.template-card__preview--blank { +.preset-card__preview--blank { display: grid; place-items: center; background: transparent; } -.template-card__preview--blank span { +.preset-card__preview--blank span { width: 40px; height: 40px; display: grid; @@ -59,7 +52,7 @@ /* The 1280×720 deck preview is rendered at full size and scaled down to fit the card width. */ -.template-card__scaler { +.preset-card__scaler { position: absolute; top: 0; left: 0; @@ -70,23 +63,23 @@ pointer-events: none; } -.template-card__scaler--multi { +.preset-card__scaler--multi { height: auto; } -.template-card__scaler .deck { +.preset-card__scaler .deck { padding: 0; gap: 0; align-items: stretch; } -.template-card__scaler--multi .deck { +.preset-card__scaler--multi .deck { display: flex; flex-direction: column; gap: 4px; } -.template-card__scaler .slide-frame { +.preset-card__scaler .slide-frame { width: 1280px; max-width: 1280px; height: 720px; @@ -95,8 +88,50 @@ box-shadow: none; } +/* Light/Dark mode toggle in the preview corner (top-left). + Sits above the scaled deck preview and lets users flip the + preview palette without opening the deck. */ +.preset-card__mode-toggle { + position: absolute; + top: 10px; + left: 10px; + display: inline-flex; + gap: 2px; + padding: 2px; + background: rgba(0, 0, 0, 0.55); + backdrop-filter: blur(8px); + border-radius: 999px; + z-index: 2; +} + +.preset-card__mode-btn { + appearance: none; + border: 0; + background: transparent; + color: rgba(255, 255, 255, 0.65); + font-family: inherit; + font-size: 11px; + font-weight: 500; + letter-spacing: 0.02em; + padding: 3px 9px; + border-radius: 999px; + cursor: pointer; + transition: + background 120ms ease, + color 120ms ease; +} + +.preset-card__mode-btn:hover { + color: #fff; +} + +.preset-card__mode-btn[data-active='true'] { + background: #fff; + color: #0a0a0a; +} + /* Slide-count badge in the preview corner */ -.template-card__count { +.preset-card__count { position: absolute; top: 10px; right: 10px; @@ -114,14 +149,14 @@ /* Meta block under the preview --------------------------------------------------------------------------*/ -.template-card__meta { +.preset-card__meta { padding: 14px 16px 16px; display: flex; flex-direction: column; gap: 6px; } -.template-card__tags { +.preset-card__tags { display: flex; flex-wrap: wrap; gap: 6px; @@ -130,7 +165,7 @@ /* Tag chip — applied alongside .t-label, keeps mono uppercase typography from the primitive and adds the accent-colored pill shape. */ -.template-card__tag { +.preset-card__tag { padding: 2px 8px; background: rgba(var(--accent), 0.06); color: rgba(var(--accent), 0.85); diff --git a/src/app/presets/presets.ts b/src/app/presets/presets.ts new file mode 100644 index 0000000..c16872a --- /dev/null +++ b/src/app/presets/presets.ts @@ -0,0 +1,24 @@ +import type { Density, Mode } from '@/ir/schema'; + +/** + * A Preset is a visual design only. Style + palette + density + mode + the + * default mode the gallery should render in. NO content. Combine with a + * Template (or the blank starter) to spawn a deck. + */ +export type Preset = { + id: string; + name: string; + vibe: string; + styleId: string; + paletteId: string; + density: Density; + defaultMode: Mode; + /** Template the preset uses for its gallery preview render. */ + previewTemplateId: string; +}; + +export const PRESETS: Preset[] = []; + +export function getPreset(id: string): Preset | undefined { + return PRESETS.find((p) => p.id === id); +} diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts index ee62a09..9707b18 100644 --- a/src/app/sitemap.ts +++ b/src/app/sitemap.ts @@ -7,6 +7,7 @@ export default function sitemap(): MetadataRoute.Sitemap { return [ { url: `${base}/`, lastModified, changeFrequency: 'weekly', priority: 1.0 }, { url: `${base}/new`, lastModified, changeFrequency: 'monthly', priority: 0.8 }, + { url: `${base}/presets`, lastModified, changeFrequency: 'weekly', priority: 0.9 }, { url: `${base}/templates`, lastModified, changeFrequency: 'weekly', priority: 0.9 }, ]; } diff --git a/src/app/templates/TemplatesGallery.tsx b/src/app/templates/TemplatesGallery.tsx index f7df2d9..b993b60 100644 --- a/src/app/templates/TemplatesGallery.tsx +++ b/src/app/templates/TemplatesGallery.tsx @@ -14,26 +14,32 @@ import { Heading, Label, PageMain, - PageShell, PageWorkbar, + PageShell, } from '@/components'; -import { TEMPLATE_PRESETS, type TemplatePreset } from './template-presets'; +import { TEMPLATES, type Template } from './templates'; +import { getPreset } from '@/app/presets/presets'; export function TemplatesGallery() { const router = useRouter(); - const applyTemplate = async (preset: TemplatePreset) => { + const applyTemplate = async (template: Template) => { + const preset = getPreset(template.recommendedPresetId); + if (!preset) { + window.alert(`Recommended preset "${template.recommendedPresetId}" not found.`); + return; + } try { const deck = await createDeck({ - source: preset.seed, + source: template.seed, theme: { styleId: preset.styleId, paletteId: preset.paletteId, density: preset.density, - mode: preset.mode, + mode: preset.defaultMode, }, - templateName: preset.name, + templateName: template.name, }); router.push(`/d/${deck.id}/edit`); } catch (err) { @@ -43,20 +49,24 @@ export function TemplatesGallery() { }; return ( - + - {TEMPLATE_PRESETS.map((preset) => ( - applyTemplate(preset)} /> + {TEMPLATES.map((template) => ( + applyTemplate(template)} + /> ))} @@ -64,15 +74,18 @@ export function TemplatesGallery() { ); } -function TemplateCard({ preset, onClick }: { preset: TemplatePreset; onClick: () => void }) { +function TemplateCard({ template, onApply }: { template: Template; onApply: () => void }) { + const preset = getPreset(template.recommendedPresetId); + const previewDeck = useMemo(() => { + if (!preset) return { ok: false as const, error: 'Preset missing' }; try { - const parsed = parseDeck(preset.seed, { + const parsed = parseDeck(template.seed, { theme: { styleId: preset.styleId, paletteId: preset.paletteId, density: preset.density, - mode: preset.mode, + mode: preset.defaultMode, }, }); const planned = planDeck(parsed); @@ -81,46 +94,37 @@ function TemplateCard({ preset, onClick }: { preset: TemplatePreset; onClick: () const message = e instanceof ParseError ? e.message : (e as Error).message; return { ok: false as const, error: message }; } - }, [preset]); - - const templateAttr = templateSlug(preset.id); + }, [template, preset]); return ( ); } - -function templateSlug(id: string): 'case-study-pro' | 'case-study-editorial' | 'other' { - if (id === 'case-study-pro') return 'case-study-pro'; - if (id === 'case-study-editorial') return 'case-study-editorial'; - return 'other'; -} diff --git a/src/app/templates/page.tsx b/src/app/templates/page.tsx index 564d7b1..ce426a0 100644 --- a/src/app/templates/page.tsx +++ b/src/app/templates/page.tsx @@ -1,12 +1,12 @@ import type { Metadata } from 'next'; import { TemplatesGallery } from './TemplatesGallery'; -import './templates.css'; +import '../presets/presets.css'; export const metadata: Metadata = { title: 'Templates', description: - 'Browse curated theme combinations for stackdeck. Pitch decks, editorials, brutalist manifestos, and more.', + 'Content scaffolds for case studies, pitches, and sales decks. Pick a template and pair it with a preset design.', alternates: { canonical: '/templates' }, }; diff --git a/src/app/templates/seeds/case-study-editorial.ts b/src/app/templates/seeds/case-study-editorial.ts deleted file mode 100644 index a0449a9..0000000 --- a/src/app/templates/seeds/case-study-editorial.ts +++ /dev/null @@ -1,100 +0,0 @@ -export const CASE_STUDY_EDITORIAL_MARKDOWN = `--- -title: A quieter signal -footer: Selene & Co · Editorial case study · 2026 ---- - -::cover -# A quieter signal. - -The story of how a 47-year-old publishing house rebuilt its newsroom, one workflow at a time. - -Selene & Co · Editorial case study -:: - -::slide - -::section -# The brief. -:: - -::slide - -::pull-quote -> We did not need a new tool. We needed a newsroom that trusted itself again. -> -- Editor in chief, Selene Press -:: - -::slide - -::scope-strip{industry="Independent publishing" region="London and New York" timeframe="11 months, 2025–2026"} -:: - -::slide - -::section -# The work. -:: - -::slide - -::approach -We spent the first six weeks not writing code. We sat in editorial standups, watched four issues ship, and counted the number of times the same document was re-typed into a different system. The number was twenty-three. That number became the brief. - -The redesign collapsed the chain to a single document, with branches for print, web, and the audio edition. Editors stayed in prose. Producers picked up the rest downstream. -:: - -::slide - -::before-after -**Before** - -Reporters wrote in Word. Sub-editors re-typed into a CMS. Designers pulled from a third tool. The audio team rebuilt every script from scratch. Every issue was four versions of the same words. - -::: - -**After** - -A single document is the source of truth. Print, web, and audio each pull a designed view. Reporters watch their words flow into every channel without lifting a finger. -:: - -::slide - -::kpi-grid{source="Selene & Co internal time-tracking, average over the last quarter of engagement"} -::stat{value="63%" label="Less time per issue" delta="-9.2 hr" trend="down"} -::stat{value="3.4x" label="Audio editions shipped" delta="+2.4x" trend="up"} -::stat{value="91" label="Editor NPS" delta="+38" trend="up"} -:: - -::slide - -::testimonial{name="Anouk Devereux" role="Editor in chief" company="Selene Press"} -> We did not become a tech company. We became a newsroom that knew where its time was going. The difference is in the work, not the tools. -:: - -::slide - -::big-number{value="20,000 hrs" label="Editorial time returned annually" delta="+20k" trend="up" source="Calculated from time-tracking before and after launch, full editorial staff"} -:: - -::slide - -::section -# The takeaway. -:: - -::slide - -::pull-quote -> Software at its best disappears. The byline stays. -:: - -::slide - -::tear-sheet{client="Selene & Co" engagement="Newsroom systems redesign" outcome="63% time reclaimed, single-source workflow across print, web, audio" date="2025–2026"} -:: - -::slide - -::contact{name="Marin Aalto" role="Practice lead, editorial systems" email="marin@stackdeck.studio" url="stackdeck.studio"} -:: -`; diff --git a/src/app/templates/seeds/case-study-pro.ts b/src/app/templates/seeds/case-study-pro.ts deleted file mode 100644 index c9bb4d1..0000000 --- a/src/app/templates/seeds/case-study-pro.ts +++ /dev/null @@ -1,97 +0,0 @@ -export const CASE_STUDY_PRO_MARKDOWN = `--- -title: How Northwind cut deploy time from 12 minutes to 90 seconds -footer: Northwind × stackdeck · Confidential · Q2 2026 ---- - -::cover -# Northwind ships ten times a day. - -A 9-week engagement that turned a brittle release pipeline into a competitive advantage. -:: - -::slide - -::scope-strip{industry="B2B SaaS, logistics" region="North America" timeframe="9 weeks, Q2 2026"} -:: - -::slide - -::big-number{value="92%" label="Reduction in time-to-deploy" delta="11min" trend="down" source="Northwind internal CI metrics, 2026"} -:: - -::slide - -::section -# The brief. -:: - -::slide - -::problem -A 12-minute pipeline ran on every push, failed 8% of runs, and turned every release into a small ceremony. Engineers were batching changes to avoid the wait, which made each merge bigger and riskier than the last. -:: - -::slide - -::approach -We re-architected the pipeline around three ideas: parallel test sharding, layer-aware Docker caching, and a lightweight gatekeeper that blocks only on what actually broke. We left the unit-test contract alone so the team did not have to relearn anything. -:: - -::slide - -::section -# What changed. -:: - -::slide - -::kpi-grid{source="Pipeline observability, weekly avg over the last 4 weeks of engagement"} -::stat{value="90s" label="Median pipeline" delta="-11m" trend="down"} -::stat{value="0.4%" label="Failure rate" delta="-7.6pp" trend="down"} -::stat{value="14x" label="Releases per week" delta="+12.6" trend="up"} -::stat{value="$1.8M" label="Recovered eng time / yr" delta="+$1.8M" trend="up"} -:: - -::slide - -::before-after -**Before** - -A single linear job. 12 minutes. 8% failure rate. Engineers waited, batched, pushed bigger PRs. Friday afternoon deploys felt like a coin flip. - -::: - -**After** - -Sharded jobs run in parallel with smart caching. 90 seconds median. 0.4% failure. Engineers ship as they finish, multiple times a day, without thinking about it. -:: - -::slide - -::testimonial{name="Priya Mehta" role="VP Engineering" company="Northwind"} -> Stackdeck took the part of our day we hated and made it disappear. Our team's mood is different. Our release calendar is different. The product moves faster. -:: - -::slide - -::pull-quote -> The pipeline used to be the meeting. Now nobody mentions it. -> -- Engineering all-hands, week 9 -:: - -::slide - -::tear-sheet{client="Northwind Logistics" engagement="Pipeline overhaul" outcome="92% faster deploys, 14x release frequency" date="Q2 2026"} -:: - -::slide - -::section -# What's next. -:: - -::slide - -::contact{name="Riley Chen" role="Principal, stackdeck" email="riley@stackdeck.studio" phone="+1 415 555 0188" url="stackdeck.studio"} -:: -`; diff --git a/src/app/templates/template-presets.ts b/src/app/templates/template-presets.ts deleted file mode 100644 index 2a03a4e..0000000 --- a/src/app/templates/template-presets.ts +++ /dev/null @@ -1,46 +0,0 @@ -import type { Density, Mode } from '@/ir/schema'; - -import { CASE_STUDY_PRO_MARKDOWN } from './seeds/case-study-pro'; -import { CASE_STUDY_EDITORIAL_MARKDOWN } from './seeds/case-study-editorial'; - -export type TemplateCategory = 'case-study'; - -export type TemplatePreset = { - id: string; - name: string; - vibe: string; - category: TemplateCategory; - styleId: string; - paletteId: string; - density: Density; - mode: Mode; - seed: string; - slideCount: number; -}; - -export const TEMPLATE_PRESETS: TemplatePreset[] = [ - { - id: 'case-study-pro', - name: 'Case Study Pro', - vibe: 'Sales-call optimized. Punchy, stat-forward, built to close', - category: 'case-study', - styleId: 'modern', - paletteId: 'electric', - density: 'comfortable', - mode: 'light', - seed: CASE_STUDY_PRO_MARKDOWN, - slideCount: 14, - }, - { - id: 'case-study-editorial', - name: 'Case Study Editorial', - vibe: 'Sendable PDF. Magazine-grade narrative, calm and considered', - category: 'case-study', - styleId: 'editorial', - paletteId: 'mono', - density: 'airy', - mode: 'light', - seed: CASE_STUDY_EDITORIAL_MARKDOWN, - slideCount: 14, - }, -]; diff --git a/src/app/templates/templates.ts b/src/app/templates/templates.ts new file mode 100644 index 0000000..215d11a --- /dev/null +++ b/src/app/templates/templates.ts @@ -0,0 +1,23 @@ +export type TemplateCategory = 'case-study' | 'pitch' | 'sales' | 'internal'; + +/** + * A Template is content data: the markdown directives for a starter deck. It + * is independent of the visual design (Preset). Pair a Template with a Preset + * at deck-creation time. recommendedPresetId is the preset the gallery uses + * to render the preview thumbnail and the default when spawning a deck. + */ +export type Template = { + id: string; + name: string; + vibe: string; + category: TemplateCategory; + seed: string; + slideCount: number; + recommendedPresetId: string; +}; + +export const TEMPLATES: Template[] = []; + +export function getTemplate(id: string): Template | undefined { + return TEMPLATES.find((t) => t.id === id); +} diff --git a/src/blocks/BlockRenderer.tsx b/src/blocks/BlockRenderer.tsx index d53ec4c..9fd5fad 100644 --- a/src/blocks/BlockRenderer.tsx +++ b/src/blocks/BlockRenderer.tsx @@ -12,6 +12,10 @@ export function StyleIdProvider({ styleId, children }: { styleId: string; childr return {children}; } +export function useStyleId(): string { + return useContext(StyleIdContext); +} + /** * Dispatches an IR Block to the right atomic component, looking up per-Style * overrides via the registry. The renderer never sees pattern directives, and diff --git a/src/blocks/index.ts b/src/blocks/index.ts index 78c0b89..29ead9e 100644 --- a/src/blocks/index.ts +++ b/src/blocks/index.ts @@ -1 +1 @@ -export { BlockRenderer, StyleIdProvider } from './BlockRenderer'; +export { BlockRenderer, StyleIdProvider, useStyleId } from './BlockRenderer'; diff --git a/src/components/AppTopbar.css b/src/components/AppTopbar.css index 372fc8e..5e9cc0e 100644 --- a/src/components/AppTopbar.css +++ b/src/components/AppTopbar.css @@ -1,6 +1,6 @@ /* ========================================================================== app topbar - Single source of truth for the global top bar shared by /, /templates, + Single source of truth for the global top bar shared by /, /presets, and /new. Inner rail and bar shape live in layout/page.css; nav buttons come from primitives/button.css. Only chrome and brand mark live here. ========================================================================== */ diff --git a/src/components/AppTopbar.tsx b/src/components/AppTopbar.tsx index 670d38d..0a2cb28 100644 --- a/src/components/AppTopbar.tsx +++ b/src/components/AppTopbar.tsx @@ -9,6 +9,7 @@ import './AppTopbar.css'; const NAV_ITEMS: ReadonlyArray<{ href: string; label: string; match: (p: string) => boolean }> = [ { href: '/', label: 'Library', match: (p) => p === '/' }, + { href: '/presets', label: 'Presets', match: (p) => p.startsWith('/presets') }, { href: '/templates', label: 'Templates', match: (p) => p.startsWith('/templates') }, ]; diff --git a/src/editor/Editor.tsx b/src/editor/Editor.tsx index 32e6116..ebbbdad 100644 --- a/src/editor/Editor.tsx +++ b/src/editor/Editor.tsx @@ -275,8 +275,8 @@ export function Editor({ deckId }: Props) {
- - Templates + + Presets