Kortix Presentations
Load kortix-brand first. It owns color, type, layout (deck column), motion, voice and claims. This file owns the slide engine and the procedure.
A Kortix deck is a Next.js route. Every presentation lives at
/presentations/<slug> in apps/web, styled like the marketing site, driven by
the keyboard, and built to be screen recorded.
There is no file format. Do not produce .pptx, Keynote, Google Slides, or a
Markdown "slides" file — that output gets rejected on sight, and nothing in this
skill will help you make one. A deck is a React component: it themes correctly,
stays sharp at any projection size, is reviewed in a PR, and cannot drift from
the product copy because it imports the same content modules the marketing pages
do.
Where everything is
apps/web/src/app/[locale]/presentations/
layout.tsx full-bleed shell, noindex
page.tsx the index — every deck, one card each
index-client.tsx the index UI (client: reads each deck's slides for counts)
registry.ts the ONE file you edit to add a deck
[deck]/
page.tsx server route: generateStaticParams + generateMetadata
deck-client.tsx resolves slug → deck, mounts the engine
decks/<slug>.tsx a deck: `useSlides(): SlideDef[]`
engine/
deck.tsx keyboard engine, build steps, presenter notes, overview
parts.tsx Slide, SectionHead, Spine, Panel, RowList, Shot, Rise…
diagram.tsx build-aware mechanism diagrams
README.md the authoring guide this skill summarises
Shipped decks: security (diagram-led — the reference implementation, read it
first), platform, sales. Legacy /presentation paths 307 to their new
homes via next.config.ts. The tree is allowlisted public in src/middleware.ts
— decks are link-shared and noindex, never behind a login.
Using a deck
Open /presentations, pick one, press F.
| Key | Does |
|---|
→ Space J | next build step, or next slide |
← K | back one step, or previous slide fully built |
Home End | first / last slide |
1–9 | jump to slide |
G Esc | overview grid (every slide, fully built) |
F | fullscreen |
N | presenter notes — the spoken script |
Recording: open the deck, press F, record the tab. Read the script first
with N → Full script, then close the drawer — notes never render on the
stage, so the recording stays clean. Prefer light theme on decks that embed
product screenshots, since the screenshots are light. Reload before you record:
after a hot reload in dev, accumulated keydown listeners make one press jump
several steps.
Films — launch and product videos
A video is not a screen recording of a deck. It is a film: a route at
/presentations/film/<slug> where every frame is a pure function of the frame
number. The same React tree plays live (it is the hero of /launch) and renders
to MP4 through headless Chrome and ffmpeg, with a synthesized score and sound
effects locked to the bar grid (60 fps, 120 BPM, one bar = 120 frames).
open http://localhost:<web>/presentations/film/launch # live, scrubbable
open "http://localhost:<web>/presentations/film/launch?frame=1800" # one frame
bun apps/web/scripts/film/render.ts launch --url http://localhost:<web> # 1080p60 MP4
bun apps/web/scripts/film/render.ts launch --url http://localhost:<web> --audio-only
The MP4 lands in the gitignored output/film/<slug>/. Never edit a film file
while a render runs: hot reload reaches the render pages. The engine, the
storyboard, the motion rules, the sound pipeline and the verification bar:
references/films.md. Read it before touching a film.
Adding a deck — two edits
1. decks/<slug>.tsx — a client component exporting useSlides():
'use client';
import type { SlideDef } from '../engine/deck';
import { Slide, Rise, Spine, Panel, Dim } from '../engine/parts';
export function useSlides(): SlideDef[] {
return [
{
id: 'title',
label: 'Chapter name',
notes: 'What you say while this is on screen.',
node: (
<Slide>
<Rise i={0}>
<h1 className="text-foreground text-5xl font-medium tracking-tight">
The claim
</h1>
</Rise>
</Slide>
),
},
];
}
2. A row in DECKS in registry.ts:
{
slug: 'connectors',
title: 'Connectors walkthrough',
description: 'One line for the index card and the route metadata.',
kind: 'Product',
tags: ['Diagram-led'],
useSlides: useConnectorsSlides,
}
There is no step 3. The route, the index card, the slide and build counts, the
length estimate and the page metadata are all derived from those two edits.
The slide API
type SlideDef = {
id: string; // stable, unique within the deck
label: string; // shown bottom-left on the stage
node: ReactNode | ((step: number) => ReactNode);
steps?: number; // extra build steps. Total → presses = steps + 1
notes?: string | readonly string[]; // the spoken script
};
Builds — the format Kortix decks use
steps: 3 means → is pressed four times on that slide, and node(step) lights
up one more part of the picture each time. Advancing past the last step moves on;
reversing off step 0 lands on the previous slide at its last step, so ←
always undoes exactly what → just did.
This is the point of the format: a diagram assembles while it is explained,
instead of landing all at once. Reach for it whenever you are explaining a
mechanism — a request crossing a boundary, a branch becoming a merge, a call
being held and released.
The hard rule: never mount or unmount on a build step. Every element is in
the DOM from the first frame, ghosted, and a step raises its opacity. If parts
appeared instead, every press would reflow the slide and the viewer would lose
the thread.
// ✅ opacity — layout is stable, the eye tracks what changed
<div className={cn('transition-opacity duration-500', step >= 1 ? 'opacity-100' : 'opacity-15')}>
// ❌ conditional render — the slide jumps on every press
{step >= 1 ? <Panel /> : null}
engine/diagram.tsx does this for you via its internal Reveal. The one
deliberate exception is a travelling packet (Link fire), which is genuinely
transient and mounts for its step only.
Presenter notes
notes is what you say, not what is on screen. Pass an array to give each
build step its own line; the drawer follows the step you are on. Write spoken
sentences — contractions are fine, numerals read aloud ("SOC 2 Type One"), no
bullet fragments.
Diagrams
engine/diagram.tsx provides Stage (framed card with a caption rail), Box,
Chip, Link (rail + arrowhead + optional travelling packet), Wall, Row.
Shipped machines: IsolationDiagram, BrokerDiagram, ChangeRequestDiagram,
PrincipalDiagram, LedgerDiagram.
- The caption carries the sentence that changes per step. The slide title
should not move.
- Connector rails never fully fade — the wiring of a system stays readable even
where traffic is not flowing yet. Only ghosted nodes drop to
0.12.
- Color is monochrome plus
kortix-* tokens for verdicts only: green = allowed
or merged, orange = held, red = blocked or refused. Never raw Tailwind palette.
- Declare sub-components at module scope, never inside a diagram's render —
a component created during render is a new type every step, so React remounts
it and the animation restarts instead of continuing.
react-hooks/static-components
catches this.
Structure discipline — the rule that matters most
Say how many parts there are, then have exactly that many. The security deck
promised "four answers" and originally ran seven chapters; rebuilding it to four,
with a Spine on every chapter slide, did more for it than any visual work.
- Name the chapters in a
const at the top of the deck file and render them
through <Spine chapters={CHAPTERS} active={n} />.
- One diagram per chapter, one supporting slide at most.
- A fifth idea belongs on the marketing page, not the deck. State the rule in the
deck's file header so the next edit does not quietly grow one.
- Chapter slides get a title and the machine — no lead paragraph. On a build
slide a second block of prose competes with the narration.
- Roughly 20 seconds of narration per build step. 25–30 builds is a comfortable
eight-to-ten-minute video.
Copy accuracy — non-negotiable
Import copy from the marketing content modules
(apps/web/src/features/marketing/*/content.ts), never retype it. Those files
carry accuracy gates in their headers: the claims verified against shipped code,
and the corrections that must not be "restored". A deck that retypes a claim is a
deck still saying it a year after the product stopped doing it.
The standing traps (microVM, secret scope, "the key never sits in the sandbox",
who can merge, certifications, license names) now live in
claims.md, section 3, with the
code each one cites. Read it before you write on-slide text. A claim that is not
in that file is not a claim you may make.
Read voice-and-tone.md
(section 5.8, deck notes) for voice and the banned phrases.
Visual language
Values, type, color and the deck column live in
layout.md,
typography.md and
color.md. Do not restate them
here. Compose from engine/parts.tsx before you invent chrome: SectionHead,
Panel, RowList, KortixAsterisk bullets and real product screenshots in
Shot. A deck may set a mono-uppercase eyebrow (decision D4e in
decisions.md). App chrome may not.
Theme-following always: semantic tokens and kortix-* accents, never a forced
palette and never a dark: hack.
A slide is one viewport and must never scroll. Cap screenshots in vh:
<Shot src="/media/…" alt="…" imgClassName="max-h-[48vh] object-cover object-top" />
object-top keeps the top of the screen, where the product is, and crops the
empty bottom rather than letterboxing it.
QA before delivery — do not skip
cd apps/web
npx tsc --noEmit # must be clean in src/app/[locale]/presentations
npx eslint src/app/[locale]/presentations # 0 errors
tsc --noEmit reports ~15 pre-existing @types/bun test.each errors elsewhere
in the app — those are the documented baseline, not yours. Nothing under
presentations may appear.
Then drive it in the browser (chrome-devtools MCP):
- Every slide renders — press
G for the overview, which shows all slides
fully built, and check it in one screenshot.
- Step at least one build slide by hand; confirm each press adds exactly one
stage and the layout does not jump.
- Both themes. A light screenshot inside a dark deck reads as a bright panel —
look at it, don't assume.
- Console clean.
Reload the page before each check: after a hot reload, one press can jump several
steps. That is a dev artifact of accumulated listeners, not a bug in the engine.