+
+
{previewDeck.ok ? : null}
- {preset.name}
+ {preset.category}
+
+
+ {preset.slideCount} slides
@@ -113,8 +114,8 @@ function TemplateCard({ preset, onClick }: { preset: TemplatePreset; onClick: ()
);
}
-function templateSlug(id: string): 'pitch' | 'editorial' | 'other' {
- if (id.includes('pitch')) return 'pitch';
- if (id.includes('editorial')) return 'editorial';
+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/seeds/case-study-editorial.ts b/src/app/templates/seeds/case-study-editorial.ts
new file mode 100644
index 0000000..a0449a9
--- /dev/null
+++ b/src/app/templates/seeds/case-study-editorial.ts
@@ -0,0 +1,100 @@
+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
new file mode 100644
index 0000000..c9bb4d1
--- /dev/null
+++ b/src/app/templates/seeds/case-study-pro.ts
@@ -0,0 +1,97 @@
+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
index 824d769..2a03a4e 100644
--- a/src/app/templates/template-presets.ts
+++ b/src/app/templates/template-presets.ts
@@ -1,41 +1,46 @@
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;
};
-/**
- * Two solid, distinctly opinionated starting points. Different aesthetics so
- * they cover the most common use cases without overwhelming the gallery.
- *
- * - Pitch Deck (Modern + Electric, light, comfortable): sharp, professional,
- * investor-ready. The default for product, sales, fundraising.
- * - Editorial (Editorial + Mono, light, airy): magazine-grade narrative,
- * long-form storytelling, year-in-review, retrospective.
- */
export const TEMPLATE_PRESETS: TemplatePreset[] = [
{
- id: 'pitch-deck',
- name: 'Pitch Deck',
- vibe: 'Sharp, professional, investor-ready',
+ 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: 'editorial',
- name: 'Editorial',
- vibe: 'Magazine-grade narrative, calm and considered',
+ 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.css b/src/app/templates/templates.css
index 7ee45d1..a322dbf 100644
--- a/src/app/templates/templates.css
+++ b/src/app/templates/templates.css
@@ -8,12 +8,12 @@
/* Per-template accent colors used by .surface-card[data-template=*]. */
-.template-card[data-template='pitch'] {
+.template-card[data-template='case-study-pro'] {
--accent: 56, 189, 248;
}
-.template-card[data-template='editorial'] {
- --accent: 244, 114, 182;
+.template-card[data-template='case-study-editorial'] {
+ --accent: 217, 119, 87;
}
/* Preview window
@@ -28,6 +28,11 @@
border-bottom: 1px solid var(--line);
}
+/* Multi-slide preview shows 3 stacked 16:9 slides. */
+.template-card__preview--multi {
+ aspect-ratio: 16 / 11;
+}
+
.template-card--blank .template-card__preview {
background: transparent;
}
@@ -65,12 +70,22 @@
pointer-events: none;
}
+.template-card__scaler--multi {
+ height: auto;
+}
+
.template-card__scaler .deck {
padding: 0;
gap: 0;
align-items: stretch;
}
+.template-card__scaler--multi .deck {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
.template-card__scaler .slide-frame {
width: 1280px;
max-width: 1280px;
@@ -80,6 +95,22 @@
box-shadow: none;
}
+/* Slide-count badge in the preview corner */
+.template-card__count {
+ position: absolute;
+ top: 10px;
+ right: 10px;
+ padding: 4px 10px;
+ border-radius: 999px;
+ background: rgba(0, 0, 0, 0.55);
+ backdrop-filter: blur(8px);
+ color: #fff;
+ font-size: 11px;
+ font-weight: 500;
+ letter-spacing: 0.02em;
+ z-index: 2;
+}
+
/* Meta block under the preview
--------------------------------------------------------------------------*/
diff --git a/src/ir/parse.ts b/src/ir/parse.ts
index 63e25dc..ae72dc2 100644
--- a/src/ir/parse.ts
+++ b/src/ir/parse.ts
@@ -45,7 +45,7 @@ type LineToken =
| { kind: 'colsep' }
| { kind: 'void'; name: string; options: Record };
-const DIRECTIVE_OPEN_RE = /^::([a-zA-Z][a-zA-Z0-9.]*)(\{[^}]*\})?\s*$/;
+const DIRECTIVE_OPEN_RE = /^::([a-zA-Z][a-zA-Z0-9.-]*)(\{[^}]*\})?\s*$/;
const DIRECTIVE_CLOSE_RE = /^::\s*$/;
const COLUMN_SEP_RE = /^:::\s*$/;
@@ -253,19 +253,27 @@ function collectColumnGroups(tokens: LineToken[], cursor: Cursor, count: number)
return cols.slice(0, count);
}
+function inferTrend(options: Record): Stat['trend'] {
+ if (options.trend === 'up' || options.trend === 'down' || options.trend === 'flat') {
+ return options.trend as Stat['trend'];
+ }
+ if (options.delta) {
+ const d = options.delta.trim();
+ if (d.startsWith('+')) return 'up';
+ if (d.startsWith('-') || d.startsWith('−')) return 'down';
+ }
+ return undefined;
+}
+
function expandVoidDirective(name: string, options: Record): Block | null {
if (name === 'stat') {
if (!options.value) return null;
- const trend =
- options.trend === 'up' || options.trend === 'down' || options.trend === 'flat'
- ? (options.trend as Stat['trend'])
- : undefined;
return {
type: 'stat',
value: options.value,
label: options.label,
delta: options.delta,
- trend,
+ trend: inferTrend(options),
};
}
return null;
@@ -371,6 +379,143 @@ function expandBlockDirective(
return [heading, ...children];
}
+ if (name === 'scope-strip') {
+ parseChildren(tokens, cursor, true);
+ const cells: Block[][] = [];
+ const cell = (label: string, value: string | undefined): Block[] =>
+ value
+ ? [
+ { type: 'text', text: label, emphasis: 'caption' } satisfies Text,
+ { type: 'text', text: value, emphasis: 'normal' } satisfies Text,
+ ]
+ : [];
+ if (options.industry) cells.push(cell('Industry', options.industry));
+ if (options.region) cells.push(cell('Region', options.region));
+ if (options.timeframe) cells.push(cell('Timeframe', options.timeframe));
+ while (cells.length < 3) cells.push([]);
+ const cols = cells.slice(0, 3);
+ return [{ type: 'columns', count: 3, columns: cols }];
+ }
+
+ if (name === 'big-number') {
+ parseChildren(tokens, cursor, true);
+ if (!options.value) return [];
+ const stat: Stat = {
+ type: 'stat',
+ value: options.value,
+ label: options.label,
+ delta: options.delta,
+ trend: inferTrend(options),
+ };
+ const children: Block[] = [stat];
+ if (options.source) {
+ children.push({
+ type: 'text',
+ text: `Source: ${options.source}`,
+ emphasis: 'caption',
+ } satisfies Text);
+ }
+ return [{ type: 'box', tone: 'neutral', children }];
+ }
+
+ if (name === 'kpi-grid') {
+ const children = parseChildren(tokens, cursor, true);
+ const stats = children.filter((b): b is Stat => b.type === 'stat');
+ const cols = pickGridCols('kpis', stats.length);
+ const rows = pickGridRows('kpis', stats.length, cols);
+ const grid: Block = { type: 'grid', cols, rows, children: stats };
+ if (options.source) {
+ const caption: Text = {
+ type: 'text',
+ text: `Source: ${options.source}`,
+ emphasis: 'caption',
+ };
+ return [grid, caption];
+ }
+ return [grid];
+ }
+
+ if (name === 'problem' || name === 'approach') {
+ const children = parseChildren(tokens, cursor, true);
+ const hasHeading = children.some((b) => b.type === 'heading');
+ const tone: Tone = name === 'problem' ? 'warn' : 'info';
+ const finalChildren: Block[] = hasHeading
+ ? children
+ : [
+ {
+ type: 'heading',
+ level: 2,
+ text: name === 'problem' ? 'Problem' : 'Approach',
+ } satisfies Heading,
+ ...children,
+ ];
+ return [{ type: 'box', tone, children: finalChildren }];
+ }
+
+ if (name === 'before-after') {
+ const groups = collectColumnGroups(tokens, cursor, 2);
+ const wrap = (blocks: Block[], tone: Tone): Block[] => [
+ { type: 'box', tone, children: blocks },
+ ];
+ const cols: Block[][] = [wrap(groups[0], 'warn'), wrap(groups[1], 'success')];
+ return [{ type: 'columns', count: 2, columns: cols }];
+ }
+
+ if (name === 'testimonial') {
+ const children = parseChildren(tokens, cursor, true);
+ const quote = children.find((b): b is Quote => b.type === 'quote');
+ const attributionParts = [options.name, options.role, options.company].filter(Boolean);
+ const attribution = attributionParts.length > 0 ? attributionParts.join(', ') : undefined;
+ const finalQuote: Quote = quote
+ ? { ...quote, attribution: attribution ?? quote.attribution }
+ : { type: 'quote', text: '', emphasis: 'normal', attribution };
+ const inner: Block[] = [finalQuote];
+ return [{ type: 'box', tone: 'neutral', children: inner }];
+ }
+
+ if (name === 'pull-quote') {
+ const children = parseChildren(tokens, cursor, true);
+ const quote = children.find((b): b is Quote => b.type === 'quote');
+ if (quote) return [{ ...quote, emphasis: 'big' }];
+ return [];
+ }
+
+ if (name === 'tear-sheet') {
+ parseChildren(tokens, cursor, true);
+ const cell = (label: string, value: string): Block[] => [
+ { type: 'text', text: label, emphasis: 'caption' } satisfies Text,
+ { type: 'text', text: value, emphasis: 'normal' } satisfies Text,
+ ];
+ const items: Block[] = [];
+ if (options.client) items.push(...cell('Client', options.client));
+ if (options.engagement) items.push(...cell('Engagement', options.engagement));
+ if (options.outcome) items.push(...cell('Outcome', options.outcome));
+ if (options.date) items.push(...cell('Date', options.date));
+ return [{ type: 'grid', cols: 2, rows: 2, children: items }];
+ }
+
+ if (name === 'contact') {
+ parseChildren(tokens, cursor, true);
+ const left: Block[] = [];
+ const right: Block[] = [];
+ if (options.name) {
+ left.push({ type: 'heading', level: 3, text: options.name } satisfies Heading);
+ }
+ if (options.role) {
+ left.push({ type: 'text', text: options.role, emphasis: 'caption' } satisfies Text);
+ }
+ if (options.email) {
+ right.push({ type: 'text', text: options.email, emphasis: 'normal' } satisfies Text);
+ }
+ if (options.phone) {
+ right.push({ type: 'text', text: options.phone, emphasis: 'normal' } satisfies Text);
+ }
+ if (options.url) {
+ right.push({ type: 'text', text: options.url, emphasis: 'normal' } satisfies Text);
+ }
+ return [{ type: 'columns', count: 2, columns: [left, right] }];
+ }
+
const children = parseChildren(tokens, cursor, true);
return children;
}
@@ -577,6 +722,8 @@ export function parseDeck(source: string, options: ParseOptions = {}): Deck {
headingTitle ??
'Untitled Deck';
+ const fmFooter = typeof fm.data?.footer === 'string' ? (fm.data.footer as string) : undefined;
+
const deck: Deck = {
version: IR_VERSION,
id: options.deckId ?? ulid(),
@@ -584,6 +731,7 @@ export function parseDeck(source: string, options: ParseOptions = {}): Deck {
aspectRatio: '16:9',
theme,
...(brand ? { brand } : {}),
+ ...(fmFooter ? { footer: fmFooter } : {}),
slides,
createdAt: now,
updatedAt: now,
diff --git a/src/ir/schema.ts b/src/ir/schema.ts
index b75880f..9cdd861 100644
--- a/src/ir/schema.ts
+++ b/src/ir/schema.ts
@@ -408,6 +408,7 @@ export type Deck = {
aspectRatio: AspectRatio;
theme: ThemeRef;
brand?: Brand;
+ footer?: string;
slides: Slide[];
createdAt: string;
updatedAt: string;
@@ -420,6 +421,7 @@ const DeckSchema: z.ZodType = z.object({
aspectRatio: z.enum(ASPECT_RATIOS),
theme: ThemeRefSchema,
brand: BrandSchema.optional(),
+ footer: z.string().optional(),
slides: z.array(SlideSchema),
createdAt: z.string().datetime(),
updatedAt: z.string().datetime(),
diff --git a/src/render/DeckRenderer.tsx b/src/render/DeckRenderer.tsx
index 4deb7a1..edaf387 100644
--- a/src/render/DeckRenderer.tsx
+++ b/src/render/DeckRenderer.tsx
@@ -1,6 +1,6 @@
'use client';
-import type { Deck } from '@/ir/schema';
+import type { Deck, Heading, Slide } from '@/ir/schema';
import { getPalette, getStyle } from '@/themes/registry';
import { SlideRenderer } from './SlideRenderer';
@@ -12,9 +12,25 @@ type Props = {
className?: string;
};
+function computeSectionContext(slides: Slide[]): (string | undefined)[] {
+ const out: (string | undefined)[] = [];
+ let current: string | undefined;
+ for (const slide of slides) {
+ if (slide.layout === 'section' || slide.layout === 'cover') {
+ const h = slide.blocks.find((b): b is Heading => b.type === 'heading' && b.level === 1);
+ current = h?.text ?? current;
+ out.push(undefined);
+ } else {
+ out.push(current);
+ }
+ }
+ return out;
+}
+
export function DeckRenderer({ deck, className }: Props) {
const style = getStyle(deck.theme.styleId);
const palette = getPalette(deck.theme.paletteId);
+ const sectionContext = computeSectionContext(deck.slides);
return (
@@ -27,6 +43,8 @@ export function DeckRenderer({ deck, className }: Props) {
totalSlides={deck.slides.length}
brand={deck.brand}
mode={deck.theme.mode}
+ footer={deck.footer}
+ section={sectionContext[i]}
/>
))}
diff --git a/src/render/SlideRenderer.tsx b/src/render/SlideRenderer.tsx
index f0f3474..f562920 100644
--- a/src/render/SlideRenderer.tsx
+++ b/src/render/SlideRenderer.tsx
@@ -10,11 +10,16 @@ type Props = {
totalSlides: number;
brand?: Brand;
mode: Mode;
+ footer?: string;
+ section?: string;
};
-export function SlideRenderer({ slide, index, totalSlides, brand, mode }: Props) {
+const FURNITURE_HIDDEN = new Set(['cover', 'fullBleed', 'section']);
+
+export function SlideRenderer({ slide, index, totalSlides, brand, mode, footer, section }: Props) {
const layout = getLayout(slide.layout);
const pageNumber = `${String(index + 1).padStart(2, '0')} / ${String(totalSlides).padStart(2, '0')}`;
+ const showFurniture = !FURNITURE_HIDDEN.has(slide.layout);
return (
+ {showFurniture && section ? {section}
: null}
{slide.blocks.map((block, i) => (
))}
+ {showFurniture && footer ? {footer}
: null}
);
}
diff --git a/src/styles/blocks.css b/src/styles/blocks.css
index c0adaa4..7e392a4 100644
--- a/src/styles/blocks.css
+++ b/src/styles/blocks.css
@@ -220,7 +220,11 @@
line-height: 1;
color: var(--color-brand);
letter-spacing: -0.03em;
- font-variant-numeric: tabular-nums;
+ font-variant-numeric: tabular-nums lining-nums;
+ font-feature-settings:
+ 'tnum' 1,
+ 'lnum' 1,
+ 'ss01' 1;
}
.block-stat-label {
@@ -241,6 +245,10 @@
padding: 2px 8px;
border-radius: var(--radius-sm);
width: fit-content;
+ font-variant-numeric: tabular-nums lining-nums;
+ font-feature-settings:
+ 'tnum' 1,
+ 'lnum' 1;
}
.block-stat-delta-up {
@@ -1045,3 +1053,76 @@
[data-style='soft'] .block-table tbody td {
border-bottom: 1px solid color-mix(in oklab, var(--color-brand) 10%, var(--color-border));
}
+
+/* ─── Premium directive polish ───────────────────────────────────────────── */
+
+/* Big-number: a Box containing exactly one Stat plus a single caption text
+ gets a takeover treatment: massive value, caption rendered as a small
+ sourced footnote sitting under the stat. */
+.block-box.block-box-neutral:has(> .block-stat:only-of-type):has(
+ > .block-text-caption:only-of-type
+ ):not(:has(> .block-heading)):not(:has(> .block-list)) {
+ align-items: center;
+ justify-content: center;
+ text-align: center;
+ padding: var(--space-2xl, 48px);
+ gap: var(--space-md);
+ background: transparent;
+ border: none;
+}
+
+.block-box.block-box-neutral:has(> .block-stat:only-of-type):has(
+ > .block-text-caption:only-of-type
+ ):not(:has(> .block-heading)):not(:has(> .block-list))
+ > .block-stat {
+ font-feature-settings:
+ 'tnum' 1,
+ 'lnum' 1;
+}
+
+.block-box.block-box-neutral:has(> .block-stat:only-of-type):has(
+ > .block-text-caption:only-of-type
+ ):not(:has(> .block-heading)):not(:has(> .block-list))
+ > .block-stat
+ .block-stat-value {
+ font-size: clamp(72px, 10vw, 168px);
+ line-height: 0.95;
+ letter-spacing: -0.04em;
+ color: var(--color-brand);
+}
+
+.block-box.block-box-neutral:has(> .block-stat:only-of-type):has(
+ > .block-text-caption:only-of-type
+ ):not(:has(> .block-heading)):not(:has(> .block-list))
+ > .block-text-caption {
+ margin-top: var(--space-md);
+ max-width: 56ch;
+ opacity: 0.75;
+}
+
+/* Tear-sheet treatment: a 2x2 grid of paired caption + normal text reads as
+ a fact sheet — caption is the label, normal is the value. */
+.block-grid[data-cols='2'][data-rows='2']
+ > .block-text-caption
+ + .block-text:not(.block-text-caption) {
+ font-size: 1.15em;
+ font-weight: 500;
+ letter-spacing: -0.01em;
+ margin-top: 4px;
+ margin-bottom: var(--space-md);
+}
+
+/* Scope-strip treatment: a 3-column row of caption+text pairs reads as
+ a header strip across the top of a slide. */
+.block-columns[data-cols='3']:has(> .block-columns-col > .block-text-caption:first-child) {
+ padding-bottom: var(--space-md);
+ border-bottom: 1px solid var(--color-border);
+}
+
+.block-columns[data-cols='3']
+ > .block-columns-col
+ > .block-text-caption
+ + .block-text:not(.block-text-caption) {
+ font-weight: 500;
+ letter-spacing: -0.005em;
+}
diff --git a/src/styles/layouts.css b/src/styles/layouts.css
index 6367a8b..069afca 100644
--- a/src/styles/layouts.css
+++ b/src/styles/layouts.css
@@ -34,10 +34,57 @@
}
.slide.layout-cover::after,
-.slide.layout-fullbleed::after {
+.slide.layout-fullbleed::after,
+.slide.layout-section::after {
display: none;
}
+/* Section kicker — small label that names the current section on body slides. */
+.slide-kicker {
+ position: absolute;
+ top: var(--space-md);
+ left: var(--space-md);
+ font-family: var(--font-mono);
+ font-size: 11px;
+ font-weight: 600;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+ color: var(--color-text-muted);
+ opacity: 0.65;
+ z-index: 0;
+ pointer-events: none;
+}
+
+.slide-kicker::before {
+ content: '';
+ display: inline-block;
+ width: 18px;
+ height: 1px;
+ background: currentColor;
+ vertical-align: middle;
+ margin-right: 8px;
+ opacity: 0.6;
+}
+
+/* Deck footer — small line at the bottom-left of body slides. */
+.slide-footer {
+ position: absolute;
+ bottom: var(--space-sm);
+ left: var(--space-md);
+ font-family: var(--font-mono);
+ font-size: 11px;
+ font-weight: 500;
+ letter-spacing: 0.04em;
+ color: var(--color-text-muted);
+ opacity: 0.5;
+ z-index: 0;
+ pointer-events: none;
+ max-width: 60%;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
/* ─── flow ──────────────────────────────────────────────────────────────── */
.layout-flow {
diff --git a/tests/ir/parse.test.ts b/tests/ir/parse.test.ts
index 12eec03..25bf2f3 100644
--- a/tests/ir/parse.test.ts
+++ b/tests/ir/parse.test.ts
@@ -1,7 +1,18 @@
import { describe, expect, it } from 'vitest';
import { parseDeck } from '@/ir/parse';
-import type { Box, Chart, Columns, Grid, Heading, Quote, Stat, Table } from '@/ir/schema';
+import type {
+ Block,
+ Box,
+ Chart,
+ Columns,
+ Grid,
+ Heading,
+ Quote,
+ Stat,
+ Table,
+ Text,
+} from '@/ir/schema';
describe('parseDeck', () => {
it('parses a minimal deck with one slide', () => {
@@ -222,4 +233,217 @@ Q4: 220
expect(table.headers).toEqual(['H1', 'H2']);
expect(table.rows).toEqual([['a', 'b']]);
});
+
+ it('compiles ::scope-strip into a 3-column Columns block', () => {
+ const md = '::scope-strip{industry="SaaS" region="EU" timeframe="Q3 2026"}\n::';
+ const deck = parseDeck(md);
+ const cols = deck.slides[0].blocks[0] as Columns;
+ expect(cols.type).toBe('columns');
+ expect(cols.count).toBe(3);
+ expect(cols.columns[0][0]).toMatchObject({
+ type: 'text',
+ emphasis: 'caption',
+ text: 'Industry',
+ });
+ expect(cols.columns[0][1]).toMatchObject({ type: 'text', text: 'SaaS' });
+ });
+
+ it('compiles ::big-number into a Box with one Stat plus optional source caption', () => {
+ const md =
+ '::big-number{value="92%" label="Faster" delta="-11m" trend="down" source="Internal CI"}\n::';
+ const deck = parseDeck(md);
+ const box = deck.slides[0].blocks[0] as Box;
+ expect(box.type).toBe('box');
+ expect(box.tone).toBe('neutral');
+ expect(box.children).toHaveLength(2);
+ const stat = box.children[0] as Stat;
+ expect(stat.type).toBe('stat');
+ expect(stat.value).toBe('92%');
+ expect(stat.trend).toBe('down');
+ const cap = box.children[1] as Text;
+ expect(cap.emphasis).toBe('caption');
+ expect(cap.text).toContain('Internal CI');
+ });
+
+ it('compiles ::kpi-grid with a source into Grid plus a caption sibling', () => {
+ const md = `::kpi-grid{source="2026 numbers"}
+::stat{value="1" label="A"}
+::stat{value="2" label="B"}
+::stat{value="3" label="C"}
+::`;
+ const deck = parseDeck(md);
+ const blocks = deck.slides[0].blocks;
+ const grid = blocks[0] as Grid;
+ expect(grid.type).toBe('grid');
+ expect(grid.children).toHaveLength(3);
+ const cap = blocks[1] as Text;
+ expect(cap.type).toBe('text');
+ expect(cap.emphasis).toBe('caption');
+ expect(cap.text).toContain('2026 numbers');
+ });
+
+ it('compiles ::problem and ::approach into toned Box with default heading', () => {
+ const probDeck = parseDeck('::problem\nThings broke.\n::');
+ const prob = probDeck.slides[0].blocks[0] as Box;
+ expect(prob.type).toBe('box');
+ expect(prob.tone).toBe('warn');
+ expect((prob.children[0] as Heading).type).toBe('heading');
+ expect((prob.children[0] as Heading).text).toBe('Problem');
+
+ const apprDeck = parseDeck('::approach\nWe fixed it.\n::');
+ const appr = apprDeck.slides[0].blocks[0] as Box;
+ expect(appr.tone).toBe('info');
+ expect((appr.children[0] as Heading).text).toBe('Approach');
+ });
+
+ it('compiles ::before-after into 2-column Columns of toned Boxes', () => {
+ const md = '::before-after\nOld.\n:::\nNew.\n::';
+ const deck = parseDeck(md);
+ const cols = deck.slides[0].blocks[0] as Columns;
+ expect(cols.type).toBe('columns');
+ expect(cols.count).toBe(2);
+ const left = cols.columns[0][0] as Box;
+ const right = cols.columns[1][0] as Box;
+ expect(left.tone).toBe('warn');
+ expect(right.tone).toBe('success');
+ });
+
+ it('compiles ::testimonial into a neutral Box wrapping a Quote with attribution', () => {
+ const md = '::testimonial{name="Priya" role="VP Eng" company="Northwind"}\n> Great work.\n::';
+ const deck = parseDeck(md);
+ const box = deck.slides[0].blocks[0] as Box;
+ expect(box.type).toBe('box');
+ expect(box.tone).toBe('neutral');
+ const q = box.children[0] as Quote;
+ expect(q.type).toBe('quote');
+ expect(q.attribution).toBe('Priya, VP Eng, Northwind');
+ });
+
+ it('compiles ::pull-quote into a big-emphasis Quote', () => {
+ const md = '::pull-quote\n> A quieter signal.\n> -- A.D.\n::';
+ const deck = parseDeck(md);
+ const q = deck.slides[0].blocks[0] as Quote;
+ expect(q.type).toBe('quote');
+ expect(q.emphasis).toBe('big');
+ });
+
+ it('compiles ::tear-sheet into a 2x2 Grid of label+value text pairs', () => {
+ const md =
+ '::tear-sheet{client="Northwind" engagement="Pipeline" outcome="Faster" date="Q2 2026"}\n::';
+ const deck = parseDeck(md);
+ const grid = deck.slides[0].blocks[0] as Grid;
+ expect(grid.type).toBe('grid');
+ expect(grid.cols).toBe(2);
+ expect(grid.rows).toBe(2);
+ expect(grid.children).toHaveLength(8);
+ expect((grid.children[0] as Text).emphasis).toBe('caption');
+ expect((grid.children[1] as Text).text).toBe('Northwind');
+ });
+
+ it('compiles ::contact into a 2-column Columns', () => {
+ const md = '::contact{name="Riley" role="Principal" email="riley@x.com" url="x.com"}\n::';
+ const deck = parseDeck(md);
+ const cols = deck.slides[0].blocks[0] as Columns;
+ expect(cols.type).toBe('columns');
+ expect(cols.count).toBe(2);
+ expect((cols.columns[0][0] as Heading).text).toBe('Riley');
+ expect((cols.columns[1][0] as Text).text).toBe('riley@x.com');
+ });
+
+ it('infers stat trend up from a delta with leading +', () => {
+ const deck = parseDeck('::stat{value="$3M" label="ARR" delta="+47%"}');
+ const stat = deck.slides[0].blocks[0] as Stat;
+ expect(stat.trend).toBe('up');
+ });
+
+ it('infers stat trend down from a delta with leading -', () => {
+ const deck = parseDeck('::stat{value="2m" label="Latency" delta="-58%"}');
+ const stat = deck.slides[0].blocks[0] as Stat;
+ expect(stat.trend).toBe('down');
+ });
+
+ it('keeps explicit trend over inferred one', () => {
+ const deck = parseDeck('::stat{value="x" delta="-1%" trend="up"}');
+ const stat = deck.slides[0].blocks[0] as Stat;
+ expect(stat.trend).toBe('up');
+ });
+
+ it('reads deck footer from frontmatter', () => {
+ const md = `---\nfooter: ACME · Confidential\n---\n\n# Hello`;
+ const deck = parseDeck(md);
+ expect(deck.footer).toBe('ACME · Confidential');
+ });
+
+ it('does not set deck.footer when frontmatter omits it', () => {
+ const deck = parseDeck('# Hello');
+ expect(deck.footer).toBeUndefined();
+ });
+
+ it('premium directives compile to atomic IR only (no leftover directive types)', () => {
+ const md = `::cover
+# Title
+::
+
+::slide
+
+::scope-strip{industry="SaaS" region="EU" timeframe="2026"}
+::
+
+::slide
+
+::big-number{value="92%" label="Faster"}
+::
+
+::slide
+
+::problem
+Bad.
+::
+
+::slide
+
+::before-after
+A
+:::
+B
+::
+
+::slide
+
+::testimonial{name="X" company="Y"}
+> Good.
+::
+
+::slide
+
+::tear-sheet{client="A" engagement="B" outcome="C" date="D"}
+::
+
+::slide
+
+::contact{name="N" email="e@x.com"}
+::
+`;
+ const deck = parseDeck(md);
+ const ATOMIC_TYPES = new Set([
+ 'heading',
+ 'text',
+ 'list',
+ 'quote',
+ 'stat',
+ 'code',
+ 'box',
+ 'columns',
+ 'grid',
+ 'chart',
+ 'table',
+ ]);
+ const walk = (b: Block): void => {
+ expect(ATOMIC_TYPES.has(b.type)).toBe(true);
+ if (b.type === 'box') b.children.forEach(walk);
+ if (b.type === 'columns') b.columns.forEach((col) => col.forEach(walk));
+ if (b.type === 'grid') b.children.forEach(walk);
+ };
+ deck.slides.forEach((s) => s.blocks.forEach(walk));
+ });
});