Mixins / foldkit-mixins

Style: appearance as data

Style is pure data. It never touches the DOM.

Every piece takes Declarations: camelCase CSS properties (typed by csstype) and custom properties, each a string. A misspelled or kebab-case property in an object literal is a type error; the compiler writes the kebab-case name. Values are not checked, so var(...) and light-dark(...) pass through.

Wherever a slot's piece is taken (forSlots, slots, forCapability, a recipeFor definition) and in compose, when, whenInput and perItem, a piece may also be written short: a declarations object means Style.self(declarations), a list means Style.compose(...list), and {} is Style.empty. So [Style.class('card'), { padding: '1rem' }] is Style.compose(Style.class('card'), Style.self({ padding: '1rem' })). A StyleValue carries a brand, so a declarations object is never mistaken for one.

PrimitiveMeaning
Style.class(...)class tokens
Style.inline({...})inline declarations
Style.compose(...)concatenate classes; later declarations win per property
Style.when(condition, piece)a boolean known at authoring time
Style.whenInput(predicate, piece)a condition read from the view input at render time
Style.recipe({ base, variants, defaults, compound })one slot: returns selection => StyleValue; every field is optional, so a recipe may be only its base
Style.recipeFor(Slots)({ base, variants, defaults, compound })every slot: returns selection => StylePieces; null unsets a defaulted axis; .extend(patch) merges per slot and refuses a slot the contract lacks
Style.perItem(item => piece) / Style.stagger({ stepMs })a piece from the item the slot is rendered for (attrs(base, item)); stagger writes --fk-index inline and the calc delay as a rule
Style.forCapability(Slots)(capability, piece)one piece for every public slot whose capability satisfies it
Style.slots(pieces, options?) / Style.slot(slotOptions, piece)an application's own Slots declared by their style: { slots, style } (see An application's own look)
Style.install(css)in a browser, one <style> in the head holding css, unless one already holds exactly that text; returns the element
Style.self / pseudo / media / supports / container / nestrule-based appearance; self is a rule on the element's own class (&{…}), for declarations a layer must hold
Style.keyframes / globalclass-independent CSS
Theme.define / Theme.ref(theme) / variablestyped tokens; every token as a typed var(--fk-group-name) reference (Theme.ref(theme).surface.base), built once per theme; the tokens as inline custom properties
Theme.lightDark(light, dark) / Theme.compose(base, over)a token that follows the color scheme with CSS light-dark(); themes merged at definition time
Theme.root(theme, { omit?, colorScheme? }) / Theme.scoped(theme, selector, overrides)from foldkit-mixins/theme: tokens as :root custom properties, or as overrides under a selector; unlayered global pieces for Style.stylesheet
Theme.schemeScript(key?) / Theme.chooseScheme(scheme, key?) / Theme.currentScheme()from foldkit-mixins/theme: a head script applying a kept light or dark before the first paint; a Command's Effect that applies and keeps a choice; the scheme applied now
Theme.tokens / Theme.oklch(knobs) / Theme.breakpointWidths(theme)from foldkit-mixins/theme: the shipped spacing, type, radius, motion, border, shadow and breakpoint scales; a whole palette derived from an accent and a few knobs (including the shadow color, denser in a dark scheme); the breakpoints as pixel widths for foldkit-primitives/media
Layers.define(names) / Layers.standardcascade layers as a value: names, declare (the @layer …; statement as a global piece), in(name, piece) (a piece's unlayered rules and global CSS inside that layer; what is already layered keeps its layer; a piece wholly in another layer is style:relayered) and layer(name) (the placement Style.forSlots(S)(pieces, { layer }) takes, so a slot style is compiled in that layer). A name outside the order is a type error. standard is reset, tokens, theme, defaults, components, layouts, variants, utilities, app; also under foldkit-mixins/layers
Style.stylesheet(...)one <style> block from NamedStyles and bare StyleValues: the layer order hoisted first (two different orders is style:conflicting-layer-order), then global chunks, then scoped classes, deduplicated. Once an order is declared, a rule outside it is style:unlayered-rule, since it would beat every layer; keyframes, font faces and @property pass

Rule-based Style compiles to one deterministic class (an FNV-1a hash of canonical rule text) plus CSS as data:

ts
const CardStyle = Style.forSlots(FieldSlots)({
  root: Style.compose(
    Style.class('card'),
    Style.pseudo(':hover', { boxShadow: '0 1px 2px' }),
    Style.media('(min-width: 40rem)', { gridTemplateColumns: '1fr 1fr' }),
  ),
})

CardStyle.css
Style.stylesheet(FieldStyle, CardStyle)

Equal rules share a class, so server and client derive the same class and CSS. A rule piece inside Style.whenInput compiles to its class like any other; the class is static and only its presence follows the input, and its CSS is in Style.stylesheet whether or not the condition ever holds. Drawn, it is injected like any other class.

Prefer rule pieces for a state. An inline shorthand beside a conditional inline longhand of it (an inline border and a whenInput borderColor, either way round) is refused when the style is defined, with mixins:inline-shorthand-conflict: the renderer drops a property by clearing it, so clearing borderColor also cleared the color border set, and the element kept a currentColor border after the state ended.

Beyond self, pseudo, media, supports, container and nest, the rule pieces are:

  • Style.states({ open: { opacity: '1' } }) compiles to &[data-state="open"], for Behaviors that write data-state; whenInput when the view knows, states when the DOM does;
  • Style.responsive(breakpoints, { md: { display: 'flex' } }), named breakpoints from the record you pass, so a misspelled one is a type error; a breakpoint is a media query or a whole at-rule, such as '@container page (min-width: 48rem)';
  • Style.at('@container builder (max-width: 40rem)', Style.pseudo('[data-open="false"]', { … })) puts a piece's rules inside an at-rule, where no one constructor does; a class, a piece chosen when drawn, global CSS or a rule already inside an at-rule throws there;
  • Style.enter({ opacity: '0' }), a @starting-style rule the element animates from, with Style.allowDiscrete when display takes part;
  • Style.vars({ '--gap': '1rem' }) and Style.viewTransitionName('hero'), declarations;
  • Style.grid({ areas: [['header', 'header'], ['nav', 'main']], columns, rows, gap }), a grid template with typed areas: .style for the container and .area('main') for a child, where a name the template lacks is a type error and a ragged template raises mixins:ragged-grid-areas. Both are rules, not inline declarations, so a later layer can override the template;
  • Selector.attr, not, is, child, descendant, sibling, siblings build the selector strings pseudo and nest take. Each takes a selector list: every top-level selector is scoped to the class (:is(a, b) is not split), and a selector that writes & places the class itself (nest('[data-open] &')).

Theme from a few knobs

foldkit-mixins/theme extends the root Theme with the pieces a page ships. Theme.oklch takes an accent color and a few knobs (hue shifts, a neutral hue, surface saturation and contrast, a contrast factor, feedback hues) and returns typed tokens for surfaces, text, outlines, and the accent, secondary, tertiary and feedback families. Only the knob group holds literals; every other value is a CSS expression over other tokens (oklch(from …), color-mix(), light-dark()), so the browser does the derivation and one knob override recolors everything below it. Each family (accent, secondary, tertiary, success, warning, error, info) is one shape: a fill, default, lighter in a dark scheme; hover and active; subtle, a tint of it on the base; outline, a line in it that stands off the base; and two text colors named for where they go: on-fill is text on that fill (near white on a mid accent), and ink is text in the family's color on the base surface, dark enough to read in a light scheme and light enough in a dark one. Colored text is ink, never default. The surfaces are the base at a few lightnesses with its chroma, so a panel and a sidebar read as the page's own color, in the accent's hue unless neutral: { h } gives them one of their own (a base color: a warm stone under a blue accent); bedrock is the deepest of them in both schemes, and the focus ring, outline.focus, is the accent. The syntax group colors code by the kinds a tokenizer names (syntax-keyword is --fk-syntax-keyword, as foldkit-richtext-code and its Shiki adapter name them): keyword, function and type in the accent, secondary and tertiary hues, string, number and property in the success, warning and info hues, all at one lightness per scheme and the syntaxChroma knob's chroma, and operator, punctuation and comment in the text's own grey. The popup group is a menu's panel (surface, ink, blur) and its lit row (highlight, highlight-ink): menu: { accent: 'bold' } lights the row in the accent's fill, and menu: { color } draws the panel inverted (the other scheme's surface and text) or translucent over a blur, as shadcn's menu options do. Each is a 0-or-1 knob (popup-bold, popup-invert) blended by color-mix, so an override switches it live. The state group is what a state lays over the surface under it, a mix of the element's own text color (hover; tint and tint-hover, a see-through fill; edge, a hairline inside an image), so it reads at any elevation and on any band; its amounts are knobs (state-hover, state-tint, state-tint-dark, image-edge). Theme.nested(outer, inset, border?) is the radius of a surface inset in another, so their corners are concentric. Theme.raise(offset) lifts an element offset levels above the raised element holding it (or the page, level 1), capped at 8, and Theme.raisedSurface() is its background at that level: a popover opened in a dialog lands above the dialog with nothing passed. In a light scheme the first two levels step toward white and the rest stay there, so shadow carries elevation; in a dark scheme each level is an even step lighter. Theme.tokens is the non-color scales, with space and radius multiplied by the density and radius-factor knobs. Its motion group is three tiers, fast, moderate and slow (80, 160 and 240ms, for hover and fades, popups and tabs, dialogs), each with a quicker -exit for dismissals, all multiplied by the motion knob. A duration of something that moves or resizes also multiplies by knob.travel, which Defaults.reset sets to 0 under prefers-reduced-motion: the thing stops travelling, and its fade still plays.

A theme reaches the page as pieces, and the page chooses the layers:

ts
import { Layers, Slot, Slots, Capability, Style } from 'foldkit-mixins'
import { Theme } from 'foldkit-mixins/theme'

const L = Layers.standard
const theme = Theme.compose(Theme.tokens, Theme.oklch({ accent: { h: 280, c: 0.15, l: '60%' } }))
const t = Theme.ref(theme) // t.surface.base is 'var(--fk-surface-base)'; a missing name is a type error

const PageSlots = Slots.define({ root: Slot.make({ capability: Capability.Container }) })
// Born in the app layer: the view attaches this same value, so its classes are the sheet's.
const PageStyle = Style.forSlots(PageSlots)(
  { root: Style.self({ background: t.surface.base, color: t.text.default }) },
  { layer: L.layer('app') },
)

export const sheet = Style.stylesheet(
  L.declare,
  L.in('tokens', Theme.root(Theme.tokens)),
  L.in('theme', Theme.root(theme, { omit: Theme.tokens })),
  L.in('theme', Theme.scoped(theme, ':root[data-theme="ocean"]', { knob: { 'accent-h': '215' } })),
  PageStyle,
)

Once the sheet declares an order, every rule in it must sit in one of its layers: an unlayered rule would beat all of them, app included, so Style.stylesheet refuses it with style:unlayered-rule. A slot style takes its layer when it is compiled, with the layer option; a layout or recipe it composes keeps the layer it was already given. L.in takes only bare pieces: a layer is part of a rule's identity, so layering a finished style would copy it under new class names that no view attaches.

omit leaves out the tokens another theme already declared, so the scales go out once in tokens and the palette once in theme. Which theme is active is a Model fact: the view writes data-theme on the root, and Theme.scoped only says what the value means. A scheme the browser picks needs nothing, because the light-dark() values follow it. A scheme the user picks is a color-scheme override on the root, which must be in place before the first paint or a reader who chose dark sees one light frame: Theme.schemeScript(key?) is the text of a head <script> that applies the one kept in localStorage, Theme.chooseScheme(scheme, key?) ('light', 'dark' or 'system') is a Command's Effect that applies and keeps a choice, and Theme.currentScheme() reads back what is applied, for the Model once the browser takes the page over (the docs site does it in its SSR plan's boot). The palette needs relative color syntax and light-dark() (Chrome 123, Firefox 128, Safari 17.5).

Style.responsive(Theme.tokens.breakpoint, { md: { … } }) and Theme.breakpointWidths(Theme.tokens) (for the Breakpoints bundle) name the same breakpoints; a query that is not a plain min-width raises theme:unparseable-breakpoint. Theme.inContainer('page', Theme.tokens.breakpoint) is the same breakpoints measured on the container named page rather than the window: an editor's preview frame, narrow in a wide window, then draws a look as a narrow screen would. An element must be that container (containerType: 'inline-size', containerName: 'page').

Layout

foldkit-mixins/layout ships the common arrangements as pieces: Layout.stack, cluster, split, sidebar, switcher, reel, center, frame, pad, and autoGrid, plus Layout.intrinsic (a stack child that keeps its own width) and Layout.aside (the sidebar child). Each compiles to rule text that is the same for every caller and reads --fk-l-* variables with token fallbacks; the options only write those variables inline, so ten stacks with ten gaps are one rule and ten inline declarations. The pieces are unlayered; put them in layouts:

ts
import { Layers, Style } from 'foldkit-mixins'
import { Layout } from 'foldkit-mixins/layout'

const L = Layers.standard

const CardStyle = Style.forSlots(CardSlots)({
  root: L.in('layouts', Layout.stack({ gap: 'var(--fk-space-sm)' })),
  actions: L.in('layouts', Layout.cluster({ justify: 'end' })),
})

split adapts to its container by default (container-type: inline-size and a @container query); { contain: false } uses @media on the viewport instead. Its breakpoint and stack's split index are in the rule text, because a query cannot read a variable, so each distinct value is its own class. sidebar and switcher are flex math and need no query.

Defaults and prose

foldkit-mixins/defaults is the baseline plain HTML gets before any slot is styled: Defaults.reset (box model, media, form-control fonts, reduced motion) and body, headings, links, code, controls, composed as Defaults.all (everything except reset). Native checkboxes, radios and sliders take the accent (accent-color), selected text is the accent's tint, kbd is a raised key and hr one quiet line. Each is element-selector CSS under :where() over --fk-* tokens with a fallback, so a class rule always beats it and it reads with or without a theme. Headings are text-overt unless a container sets --fk-heading: a band drawn in its own color writes Style.vars({ '--fk-heading': 'currentColor' }) and the headings inside take its color, with no rule on the heading. foldkit-mixins-ui's unfilled buttons read --fk-ink the same way. foldkit-mixins/prose is the longform contract: Prose.style({ measure?, leading?, rhythm? }) is one class for every caller (the rhythm between unlike elements: heading to paragraph, list to paragraph, around figures) whose options are --fk-prose-* variables on the element. Its measure and line height are a rule reading those variables, so a later layer can override them. Both are unlayered; the page places them:

ts
import { Layers, Style } from 'foldkit-mixins'
import { Defaults } from 'foldkit-mixins/defaults'
import { Prose } from 'foldkit-mixins/prose'

const L = Layers.standard
const Article = Style.forSlots(ArticleSlots)({ body: L.in('components', Prose.style({ measure: '60ch' })) })

export const sheet = Style.stylesheet(
  L.declare,
  L.in('reset', Defaults.reset),
  L.in('defaults', Defaults.all),
  Article,
)

An application's own look

Everything above serves two kinds of author. A component or design system publishes a contract (ButtonSlots) that others style with Style.forSlots. An application mostly styles its own markup: Slots nobody else attaches to, one theme, one layer, one page sheet. For that case the contract, the style and the setup can each be written once:

You are...Use
styling a contract someone publishedStyle.forSlots(ButtonSlots)(pieces, options)
declaring your own Slots and their lookStyle.slots(pieces, options)
fixing theme, app layer and page sheet for one applicationAppStyle.make from foldkit-mixins/app
writing a token-backed declaration in a wordUtilities from foldkit-mixins/utilities
ts
import { Event, Style, SlotView } from 'foldkit-mixins'
import { AppStyle } from 'foldkit-mixins/app'
import { Theme } from 'foldkit-mixins/theme'
import { Utilities as U } from 'foldkit-mixins/utilities'

// The theme over Theme.tokens, its references, the app layer and the page sheet.
const { t, slots, forSlots, stylesheet } = AppStyle.make({
  palette: Theme.oklch({ accent: { h: 260, c: 0.21, l: '62%' } }),
  colorScheme: 'light',
})

// Each key is a Container slot with that piece; Style.slot gives a slot options.
export const Page = slots({
  root: [U.p('lg'), U.bg('surface.base')],
  count: [U.textCenter, U.font('bold'), { fontSize: '3.75rem', color: t.text.default }],
  form: Style.slot({ events: [Event.Submit] }, [U.flex, U.gap('sm')]),
})

export const Counter = SlotView.forMessages<never>()
  .define(Page.slots, (count: number, slots, h) =>
    h.main(slots.root.attrs(), [h.p(slots.count.attrs(), [String(count)])]),
  )
  .pipe(Style.attach(Page.style))

// In the browser entry, before the application runs:
Style.install(stylesheet)

A published contract takes the same layer through the kit's forSlots, as forSlots(ButtonSlots)(Recipes.Button()) with foldkit-mixins-ui.

What each call does, and does not do:

  • AppStyle.make computes, once: theme is Theme.compose(Theme.tokens, palette), t is Theme.ref(theme), L is Layers.standard, and stylesheet is the text of the layer order, Defaults.reset, the tokens, the palette and Defaults.body (plus any global pieces, in the defaults layer). It installs nothing and owns no state; a scheme or theme the user picks is still a Model fact (see Theme from a few knobs).
  • slots(pieces) is Style.slots(pieces, { layer: L.layer('app') }), and forSlots is Style.forSlots with the same layer: the options take a name, never a layer. Both compile once, where they are defined.
  • Style.slots returns { slots, style }: slots is the contract (Slots.define of a Container per key, or the Style.slot options, which default to Container too and are never hidden), and style the NamedStyle a view attaches. A slot with no look takes {}. The view still decides the markup; the contract only names what it may draw.
  • Style.install(stylesheet) is the one call that touches the DOM. The sheet holds no slot class: a Slot's CSS is injected when it first draws.

Utilities are one-idea pieces whose steps are the shipped scales' token names, so an invalid step is a type error and the value still follows the density knob and theme overrides: p, px, py, m, mx, my, gap (a space step or '0'; margins also 'auto'), text (size), font (weight), leading, rounded (radius), color and bg (a Theme.oklch color as 'group.name'), items and justify ('between' is space-between), and the constants flex, column, grid, block, hidden, wFull, textCenter, uppercase, truncate, pointer and selectNone, and scrollFade(axis), a scroller's content dissolving toward an edge with more to scroll (a scroll-driven mask: the true ends stay crisp until something is past them, and a browser without scroll timelines gets no fade), and steady(weight), room for a label at a heavier weight, read from the element's data-label, so turning bold moves nothing beside it. Each is a Style.self rule, unlayered, so it takes the layer of the style it is composed into, and a plain declaration after it wins per property. A value off the scale is a plain declaration ({ fontSize: '3.75rem' }); a color outside Theme.oklch's groups is read through t.