Theme
export interface SlideFrameTheme {
text: string;
}
- CSS color for body text
SlideFrameTheme is the minimum this pack places on a frame. One field means any richer host palette satisfies it structurally. Primitives read none of it — they reach the snapshot surface through their react renderers and this pack's stylesheet — so it exists for a frame drawing its own surround.
slidePaletteForMode(mode: 'light' | 'dark'): SlidePalette returns the named colors for one scheme as literals, resolved from the lightDark pairs in SLIDE_THEME.color.
export const slideThemes: ThemePair<SlideFrameTheme> = {
light: { text: slidePaletteForMode('light').text },
dark: { text: slidePaletteForMode('dark').text },
};
These are the values slideDeckFrame.theme holds. A host can override them — or override the whole frame — to use a custom palette.
The tree passed to createDistillery holds every value the pack renders. src/theme/base.ts holds the scales; src/theme/components/ holds one group per primitive, composed from them; src/theme/theme.ts assembles the single tree:
export const SLIDE_THEME = {
// Scales (`base.ts`).
color: { bgPage: lightDark(…), text: lightDark(…), line: lightDark(…), primary: lightDark(…), … },
inverse: { bg: lightDark(…), text: lightDark(…), rule: lightDark(…), connector: lightDark(…), … },
space: { px4: px(4), …, px128: px(128) },
radius: { chipSmall: px(8), chip: px(10), panel: px(12) },
stroke: { panel: px(1), chip: px(2), hairline: px(2), rail: px(3), bar: px(4) },
font: { family: { sans, mono }, size: { px24: px(24), …, px280: px(280) }, weight, tracking, lineHeight },
type: { display, heading, lede, body, label, mono, chrome, … },
// Shared groups (`components/shared.ts`, `components/marks.ts`).
glyph: { arrow, separator, dash, termJoiner }, link: { decoration }, label: { … }, connector: { … }, tone: { … }, placeholder: { … }, marks: { … },
// One group per primitive (`components/<group>.ts`).
frame: { … }, heading: { title: type.heading, lede: type.lede, … }, split: { … }, …
} as const;
Leaf kind is the decision, not the group name:
| Leaf | Emission | Used for |
|---|---|---|
lightDark(light, dark) |
--slide-<group>-* holding light-dark(…) |
Everything under color and inverse, the only groups that vary by scheme. |
ScaleToken (px, literal, paddingXy) |
Inlined literal, no custom property | space, radius, stroke, font, type, glyph, and every component group. |
| Plain string | Custom property with identical light/dark values | Unused here; reach for it when a host should override a non-color value. |
space and font.size are keyed by pixel value (space.px48, font.size.px28) because the canvas is a fixed 1920×1080 and the design is specified in pixels. type holds the design's type roles — size, weight, tracking, and leading together — and a module emits one with typeRole(tokens.type.heading). A value that is off both ramps still lives in its component group, with a comment saying why.
Nothing outside this tree holds a rendered value. A primitive's styles.ts reads slideDistillery.tokens.<group>, and its text, markdown, and slack renderers read the same tokens for any glyph they print, so there is one place a value can be wrong.
Sizes are ScaleToken on purpose: inlining matches the frame contract, where a host wanting different geometry replaces the frame rather than overriding a token.
Slides are read embedded at roughly half scale, so no rule sets a font-size under 24px. src/theme/modules.test.ts scans the whole stylesheet for it. An inline `code` mark keeps its run's size rather than an em fraction, which would drop 26px body copy under the floor.
Length-sensitive primitives take an optional size: l, m, or s, each a set of theme tokens (heading.titleSizes, title.displaySizes), none below 24px. Left out, the renderer picks the step from the node's own text, so every surface draws the same size; the snapshot surface cannot measure, and does not support container units, min(), or clamp(). An explicit size always wins.
Every primitive that picks a step does it in one function: its fit.ts exports <name>Step(node, layout) (headingStep, tableStep, annotatedRenderStep), which returns the node's own size or the step its content takes in layout, the StepLayout that slideLayout(context) returns, and its renderer calls it and sizes nothing itself. Each criterion is a predicate on a step, and stepWhere(fits) in src/primitives/size.ts is the largest step at which it holds, s when none does. The criteria below are named helpers built on it: sizeForLoad against a LoadBudget, scaled by crowding; sizeForWidthLoad for a load set across a layout narrower than its measure, and sizeForProse for one marked passage; sizeForWidth, sizeForWords, sizeForMarkedWords, and sizeForLines across a width; and a height estimate compared with the layout's height in a stepWhere of its own. smallerStep takes the smaller of two criteria.
- Load:
slideHeading,statementSlide, andquoteSlidecompare their text's character count withheadingFit,statementFit, andquoteFit, a wide East Asian glyph or an emoji counting as two (src/render/mono.ts).slideDefinitionscomparesrowLoad, its longest column's characters times the column count, withdefinitionsFit, and its longer column's rows withdefinitionsRowFit, and takes the smaller step; pastdefinitionsSingleColumnMaxterms it splits into two columns.slideBarscounts two per label line, wrapped at its label column, and one per detail line, wrapped across its track, againstbarsFit, and takes no larger step than the one at which each value fits beside its bar across the layout's width. Its label column islabelWidth, orlabelMaxShareof a layout too narrow for it; where a value still would not fit beside its bar atbarMaxShare, every bar scales down together, and a positive bar draws at leastbarMinWidth.slidePipelinecomparesrowLoad, its longest step's characters times the step count, withpipelineFit, scaled by how many times over its step columns, after anystartandendchips, fall short of the frame body, and skips a step at which a title's widest word outgrows its column. In a narrower layout than their measure, such as aslideSplitLayoutpane, statement, quote, and definitions scale their load by how many times over it falls short (narrowing).slideTimelineandslideRoadmapcomparerowLoadover their items or columns, a channel or status counted uppercase as it prints (styledText), withtimelineFitandroadmapFit, andslideGraphits node rows times the longest node, plus the caption, withgraphFit;sizeForWidthLoadscales each by how many times its layout's width falls short of the frame's content width, less the gutters between its columns. - Count:
agendaSlidecounts its title lines, one a section and more where a title wraps across its layout's width, againstagendaFit.slideSequencecounts its messages, plus the message rows its actor chips and labels add where they wrap at each step's size across their spans, againstsequenceFit; each run of a label is measured in the face it renders in, acoderun between its chip's sides, and each line holding a chip adds its padding and border (marksHeight).slideMatrixsteps by its row count againstmatrixFit, andslideQuadrantby its fullest top cell's items plus its fullest bottom cell's againstquadrantFit, scaled bynarrowingin a layout narrower than a frame body. - Width:
titleSlide,sectionSlide, andclosingSlidetake the largest step at which their title's longest word fits its column of the layout's width and the title holds two lines, estimated fromextraboldAdvanceglyph widths and the role's tracking. Every other estimate measures text throughmeasureText, as its role sets it (Measuring text as it is styled).slideCode,slideCodeTrace, andslideDiff, which draw in one panel style, take their dense size when a line would clip at the regular size across their layout's width, as well as pastcodeDenseAfterlines.slideCommandtakes the largest step (command.textSizes) at which its prompt and command fit its layout's width beside the Copy chip.slideStatstakes the largest of the sharedstatValueSizesat which every value fits its column of the layout's width, so values in one row share a size.slideDeltatakes the largest at which its row holds both sides, each the wider of its label and its value or placeholder, the arrow, and the note's floor: its measure, padding, and rule. When no step holds the note beside the pair, it takes the largest at which the pair fits and the note wraps under it.slideGraph,slideTimeline, andslideRoadmaptake the smaller of their load step and the largest step at which no word of a term, heading, title, body, or caption is wider than its node, column, or span (sizeForWords, andsizeForMarkedWordsfor a body, caption, timeline heading, or item, measured as its marks draw); a timeline's label is set on one line, so it is measured whole, and steps with the rest. A graph's caption is measured in the tracks it spans beside the upper node, or the whole row with none, up tocaptionMaxWidth. AslideRenderGridtile sets its caption beside the surface name whilecaptionMinWidthis left there, and on its own lines under the name otherwise. - Height:
slideTabletakes the largest step whose estimated height fits its layout: its caption and group labels wrapped across the layout's width, and its headings and each row as tall as their longest text wraps across a column of it, against the layout's height (primitives/slide_table/fit.ts). Each is measured throughmeasureTextin its own role, so a row header is measured in its bold face. Cell type, padding, and group spacing step together.slideLanestakes the largest step at which its widest lane, from its label column to its join, fits the layout's width and its rows, each as tall as its name wraps to in capitals, and notes, wrapped at that step's note column, each line holding acodechip grown by its padding and border (marksHeight), fit the layout's height; with no budget, it reads the room in pixels.slideLayersdoes the same down the slide: it takes the largest step at which its bands, each as tall as its name, its body's lines, or its chips' rows wrap to across the layout's width, a body line holding acodechip grown as a note's is, fit the layout's height, and skips a step at which a chip outgrows its column. - Layout: every container sets one
layouton the render context for its children,{ width, height }in pixels, and every auto-sizing primitive reads it throughslideLayout(context)(src/primitives/layout.ts).slideFramegives its openingslideHeading, and every node of a body with no heading, the whole body; below a heading it gives the height the heading leaves.slideSplitLayoutgives each pane its width, never more than the split has, and the height left after the pane's label and the split's footnote, each wrapped at its width, clamped at zero, so nested splits narrow and shorten in turn.titleSlidegives its aside the aside column.slideWindowLayoutgives its body what its border, title bar, and padding leave, and, like a frame, the nodes below a leadingslideHeadingthe height the heading and the body's gap leave.slideStackLayoutpasses its own on. A render's embedded slide gets no layout from around it: it lays out on a fresh full slide, a bare body as a frame body would, and only the drawn result is scaled, to the largest that fits the render's layout under its caption.crowdingis derived, not stored: the height below a two-line title and lede atl, which load budgets are set against, over the layout's height. It is 1 there, below 1 with more room, as on a slide with no heading, and above 1 with less.statementSlide,quoteSlide,agendaSlide,slideDefinitions,slideColumns,slideBars,slideMatrix,slideQuadrant,slidePipeline,slideSequence,slideGraph,slideTimeline, andslideRoadmapscale their load by it;slideTable,slideLanes, andslideLayersread the height itself; every load-, count-, and width-sized primitive butslideMatrix, whose row labels take a fixed column, andslideCode,slideCodeTrace,slideDiff, andslideCommandmeasure against the width. A layout nested splits leave no width measures as zero, so its content takes the smallest step. With no container, a node reads a frame body with nothing above it.
Past the smallest step, nothing on a field says how much it holds. No surface checks overflow at render time. checkLayout measures a frame's nodes against its body and a slideSplitLayout pane's nodes against the pane, and src/examples/fit.test.ts measures every example with takumi and fails on any checkLayout finding. slidePipeline and slideSequence each have a fit.test.ts that measures content at the edge of each step's budget under the tallest heading. slideLayers' checks its height estimate is never less than takumi draws, and measures stacks at the step each takes under the tallest heading and one step larger, overflowing. slideLanes' measures the longest notes each step takes under the tallest heading, fourNotesExample at the step it takes there and in half a split, each one step larger overflowing, and reports lanes wider than half a split at every step. slideGraph, slideTimeline, and slideRoadmap each have one that measures a code word at the step it takes, one line inside its node or column, and one step larger, past it or onto a second line. slideList, slideFanout, slideTree, and slideStat have no size step, and the slideStats and slideDelta steps answer their values' width only, so past what the body holds they are reported, not shrunk.
A number that is not measured yet is left out, never invented. slideStat, slideStats, and slideDelta then draw placeholderModule: a striped box captioned placeholder.caption, one line of the value's type tall, so the row keeps its shape. Text, Markdown, and Slack print the same caption.
slideTones is ['primary', 'accent'], named for theme roles rather than hues. primary marks the product, the runtime, or whatever is in focus; accent marks the host or another party. Everything else is neutral: text, and line for rails and arrows, or the quieter textSoft, textSubtle, and border. Authoring copy names tones and never colors, so a theme can change what each role looks like.
A tone is never color alone. A toned title puts ToneCue (src/render/tone_cue.tsx) before its text: a filled dot for primary and a ring for accent, in the tone's color, which assistive technology announces as tone.label ("Primary", "Accent"), or as the primitive's own names, such as territoryGroup.toneLabel ("Yours", "Another party"), passed as labels. Its text, markdown, and slack renderers prefix toneCueText(tone), the tone's tone.glyph (● or ○). A neutral title gets neither. A primitive that takes a tone uses both, so every surface carries it.
A module's rules reach the stylesheet sorted by name, and a variant that overrides a base rule wins only by coming later at the same specificity. Name the override so it sorts after its base: titleHighlighted after title, not highlightedTitle.
slideFrame's tone: 'inverse' gives the title slide the dark background. The frame redeclares every color token for its subtree from the inverse group, so a primitive reads the same tokens on either tone and never branches on it; a stylesheet test fails when a color token has no inverse. Light-mode inverse is the dark page palette. In dark mode the inverse background is lifted above the page (#12213A over #07101F) so those slides still stand apart.
The snapshot surface's CSS goes to takumi, which lays out a subset of CSS. The pack relies on custom properties (including redeclaring them in a subtree), grid with minmax(0, …) tracks, border-drawn triangles, text-wrap: balance, and baseline alignment, all of which it renders. These limits shape the modules:
- A block element with padding and a border around text renders taller than its content. Chips and markers are
display: flex. @containerqueries,min(),clamp(), and:has()do not apply, andcqiresolves against the canvas, not the container. Type that must fit is sized by size steps instead.display: inline-flexinside running text breaks the line. Inline marks stay plain inline elements.- An inline element's border is not drawn, only its background, so an inline code chip stands out by
codeFillalone. text-wrap: prettyis not applied, so a paragraph can end on one word. Short copy that wraps, such as a footnote or a tagline, usesbalanceinstead.flex: noneis ignored, so an item meant to keep its size shrinks with its row. Writeflex: 0 0 auto; a stylesheet test rejectsflex: none.- A
calc()nested inside another is not evaluated; the same test rejects one. display: contentsis laid out as a block, so a table's rows cannot share one grid.slideTableandslideMatrixmake each row its own grid with the same tracks, and give the table elements explicit roles, since some browsers drop table semantics oncedisplaychanges.- A
flex: 1child of a column with no set height collapses to nothing. The layoutfillrole usesflex: 1 1 auto, and centers with auto margins, so content taller than its space runs down rather than up over the heading.
Named colors per mode, derived from the lightDark pairs in SLIDE_THEME.color. CSS interpolates slideDistillery.tokens.color; slidePaletteForMode resolves the same group to literals through slideDistillery.resolveValues(scheme).color, which is what a frame reads and what the snapshot surface flattens light-dark(…) into.
font.family.sans is 'Inter, system-ui, sans-serif' for the slide body and font.family.mono is 'Roboto Mono', ui-monospace, monospace for code. Code needing the raw string reads .value. The snapshot surface needs Inter 400–800, Inter 400 italic (the title slide's definition line), and Roboto Mono 400–700 registered with the backend; slideFontFaces lists them.