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.
| Primitive | Meaning |
|---|---|
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 / nest | rule-based appearance; self is a rule on the element's own class (&{…}), for declarations a layer must hold |
Style.keyframes / global | class-independent CSS |
Theme.define / Theme.ref(theme) / variables | typed 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.standard | cascade 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:
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 writedata-state;whenInputwhen the view knows,stateswhen 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-stylerule the element animates from, withStyle.allowDiscretewhendisplaytakes part;Style.vars({ '--gap': '1rem' })andStyle.viewTransitionName('hero'), declarations;Style.grid({ areas: [['header', 'header'], ['nav', 'main']], columns, rows, gap }), a grid template with typed areas:.stylefor the container and.area('main')for a child, where a name the template lacks is a type error and a ragged template raisesmixins:ragged-grid-areas. Both are rules, not inline declarations, so a later layer can override the template;Selector.attr,not,is,child,descendant,sibling,siblingsbuild the selector stringspseudoandnesttake. 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:
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:
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:
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 published | Style.forSlots(ButtonSlots)(pieces, options) |
| declaring your own Slots and their look | Style.slots(pieces, options) |
fixing theme, app layer and page sheet for one application | AppStyle.make from foldkit-mixins/app |
| writing a token-backed declaration in a word | Utilities from foldkit-mixins/utilities |
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.makecomputes, once:themeisTheme.compose(Theme.tokens, palette),tisTheme.ref(theme),LisLayers.standard, andstylesheetis the text of the layer order,Defaults.reset, the tokens, the palette andDefaults.body(plus anyglobalpieces, in thedefaultslayer). 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)isStyle.slots(pieces, { layer: L.layer('app') }), andforSlotsisStyle.forSlotswith the same layer: the options take aname, never a layer. Both compile once, where they are defined.Style.slotsreturns{ slots, style }:slotsis the contract (Slots.defineof aContainerper key, or theStyle.slotoptions, which default toContainertoo and are neverhidden), andstyletheNamedStylea 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.