Mixins / foldkit-mixins-ui

Recipes

The adapters only name slots. Recipes gives those slots, and the vocabulary above, a look: one Style.recipeFor per contract, built on the tokens of foldkit-mixins/theme. Select variants, hand the pieces to Style.forSlots, and attach the result like any other Style:

ts
import { Layers, Style } from 'foldkit-mixins'
import { Theme } from 'foldkit-mixins/theme'
import { ButtonSlots, Recipes } from 'foldkit-mixins-ui'

const DeleteStyle = Style.forSlots(ButtonSlots)(
  Recipes.Button({ tone: 'danger', variant: 'outline', size: 'sm' }),
)

const L = Layers.standard
const palette = Theme.oklch({ accent: { h: 280, c: 0.15, l: '60%' } })

export const sheet = Style.stylesheet(
  L.declare,
  L.in('tokens', Theme.root(Theme.tokens)),
  L.in('theme', Theme.root(palette)),
  DeleteStyle,
)

DeleteStyle.mixin then goes to Button.toView as in the first example.

What a recipe assumes and does:

  • The page ships the tokens. Every value is a var(--fk-…) reference to Theme.tokens or a Theme.oklch palette, so a scoped theme or a knob override restyles every recipe with no new CSS.
  • Bases sit in components, variants in variants of Layers.standard, so an application's app layer overrides both without specificity fights. Every declaration is a layered rule and none is inline style, since inline style beats every layer and could not be overridden this way:
    ts
    const Red = Style.forSlots(ButtonSlots)(
      { button: Style.self({ background: 'red' }) },
      { layer: L.layer('app') },
    )
    // Style.stylesheet(L.declare, …, DeleteStyle, Red): Red's background wins.
  • Controls stand on one size ladder. A button, a field, a select, a tab, a segmented control, a table row and the rows of a menu, listbox or combobox are all one rung tall: 36px by default and 28px compact, their padding, gap and text stepping with them (Theme.ladder, read on the element). --fk-knob-step: 1 on any element makes everything inside it compact, the popups it opens included when they stay in the page (an anchor's portal: false, as the gallery's do); upstream portals a panel out of the page by default, where no region reaches it. A recipe's size (sm compact, md default, lg a half step up at 40px) sets the step on its own element and beats the region, and a popup's sets it on its panel too, so it holds portaled or not; unset, which is the default, follows the region. An icon, checkbox, radio or switch with no size stands on the ladder's icon rung, 16px or 14px compact.
  • Text comes in roles. Theme.role(name) is a size and its leading, stepping with the ladder: display, title, subtitle, body (a control's text, 14px on 20), caption (badges, tooltips, a table's headings) and micro (keycaps). A tab's and a segmented option's label is trimmed to its capitals (text-box), centering it optically; a pressed segmented option and a chosen card title turn semibold without moving anything (the card writes its title in data-label; give an option its text there, h.DataAttribute('label', 'Week')); and a field's text is 16px on a coarse pointer, so iOS Safari does not zoom the page into it.
  • A state is a tint, not a surface. A hover lays the palette's state.hover, a mix of the control's own text color, over whatever is under it, so it reads on a muted page, a colored band or an inverted panel; the neutral button is see-through (state.tint), and images carry a hairline inside their edge (state.edge). A popup's rows round concentrically inside its panel (Theme.nested), as a segmented option does inside its tray.
  • Popups and dialogs enter and leave when drawn with @foldkit/ui's isAnimated (Menu.init({ id, isAnimated: true })): a popup fades and grows on the fast tier, from the side its anchor placed it on, and a dialog on the slow one, each leaving on the quicker exit.
  • A press shrinks a button by 1px a side. A solid or primary button's border carries its fill and clears while held, so the fill stands exactly 1px in at any width; chosen rows next to each other in a multiple listbox or combobox merge into one block.
  • Raised surfaces nest. Popups, popovers, date picker panels and toasts sit two levels above what holds them and dialogs four (Theme.raise), each painted for its level, so a popover in a dialog lands above the dialog: a step lighter in a dark scheme, the same flat surface with its own shadow in a light one.
  • State comes from the component's own attributes. Checked, selected, and disabled looks read aria-checked, aria-selected, and aria-disabled, which @foldkit/ui already writes; no whenInput is needed.
  • Tone and variant are independent. A tone sets a few private custom properties that solid, outline, and ghost read — except primary, which is ink by definition, and icon, a square one rung of the size ladder on a side. A tone also carries the line an outline draws: the fill, or for neutral, whose fill is a quiet grey, the page's default line. A neutral Checkbox or Switch that is on is in the page's ink, not the neutral grey, so it stands out without a color.
  • On a colored band, unfilled buttons take the band's color. outline and ghost draw their text in the tone's ink, which reads on the page's surface and not on a band of the accent. A band sets Style.vars({ '--fk-ink': 'currentColor' }), beside --fk-heading for its headings, and their text is the band's.

Adjust a recipe with extend instead of forking it. Base pieces compose per slot, a variant's pieces compose over the shipped ones, and compounds append:

ts
const BrandButton = Recipes.Button.extend({
  base: { button: Style.class('brand-button') },
  variants: { size: { lg: { button: Style.class('brand-button-lg') } } },
})

Recipes.Badge is a function rather than a variant selection: one style serves badges in every state, so every tone is present at once and there is no axis to select. It takes the attribute carrying the value and the value-to-tone map; values the map leaves out keep the base pill:

ts
const EntryStyle = Style.forSlots(EntrySlots)(
  {
    badge: Recipes.Badge({
      attribute: 'data-state',
      tones: { Published: 'success', Changed: 'warning' },
    }).badge,
  },
)

Like the variant recipes it reads Theme.tokens and Theme.oklch tokens and keeps the base in components with each tone in variants. A toned pill is grounded on its tone's subtle, which keeps the tone's hue over a tinted page.

Recipes.InputGroup draws a text field with a prefix or suffix, such as an address's /, as one box: group carries the border, its hover and the focus ring (while the control inside has focus), affix sits inside it, muted, and control makes the input inside borderless. They are pieces for your own group, affix and control slots, as foldkit-mixins-form's field publishes them:

ts
const AddressStyle = Style.forSlots(FieldSlots)({
  group: Recipes.InputGroup.group,
  affix: Recipes.InputGroup.affix,
  text: Style.compose(Recipes.Input({}).input ?? Style.empty, Recipes.InputGroup.control),
})

control is in variants, so it wins over a components look on the same slot; over a look of your own in a later layer, say the same in that layer.

Recipes.Calendar styles CalendarSlots in every mode: days in seven columns, months and years in three, each button reading its state off the cell (data-today, data-selected, data-focused, data-outside-month, data-disabled). Each cell is a rung of the size ladder, so size (sm, md) or the region steps it. It draws no card, so the same style serves a calendar on the page and one in a date picker's panel.

Tabs glide when drawn with their indicator and highlight and given Tabs.fluid beside the style. Tabs.fluid measures the chosen tab on the tablist (Measure) and moves one highlight to the tab nearest the pointer (FluidHover), born at the chosen tab and returning to it:

ts
Tabs.toView([TabsStyle.mixin, ...Tabs.fluid], { h }, ({ tablist, indicator, highlight, tabs }) =>
  h.div(tablist, [h.div(indicator), h.div(highlight), ...tabs.map(tab => h.button(tab.tab, [tab.value]))]),
)

The recipe draws the line variant's rule or the pill variant's pill on the indicator, which springs to a new choice, and dims the pill while another tab is hovered. Until Tabs.fluid runs, as before a page hydrates, the indicator and highlight show nothing and the chosen tab marks itself: the recipe reads the data-fluid-hover attribute FluidHover sets while it runs. (Tabs.fluid is opt-in because a Scene test resolves every Mount with a Message, and these send none.)

Menus, listboxes and comboboxes draw their panels in the palette's popup tokens, so a theme's menu: { accent, color } (a bold accent row, an inverted or translucent panel) restyles all three. They take the same opt-in: Menu.fluid, Listbox.fluid and Combobox.fluid, beside the style in toView. The open panel's rows sit in a rows box of this package's (the panel carries upstream's anchor, and an element holds one Mount), with a highlight first in it that follows the pointer, and the keys, which mark a row data-active; a listbox or combobox adds an indicator that glides to the chosen row. The default draw places all three, and the rows drop their own hover and chosen fill while the highlight runs. A press in a gap presses the lit row, except in a combobox, whose field keeps focus.

ts
Menu.toView([MenuStyle.mixin, ...Menu.fluid], { h })

Recipes.Tabs({ variant: 'pill' })'s chosen tab is the accent's tint and ink, as a pressed Segmented option is. To look at every recipe in every variant and state at once, in both schemes, write the specimen page: pnpm --filter foldkit-mixins-ui specimen <out.html> [hue].

Recipes.Segmented is a tray of toggle buttons where pressing selects — plain buttons, not Tabs (the group is role="group", each option aria-pressed). The pressed option takes the accent's tint, flat: that rule lives on the group, in variants, so icon tiles take the group piece alone, and an icon button's own background, a variant too, does not hide it. tray is a muted tray or a plain row (which sets no display: the Builder owns its narrow tabs' visibility); size steps the tray and its options on the ladder (xs a half step below compact, 24px), the tray's inset counted in, so a tray is as tall as a button beside it. Its indicator and highlight slots, drawn first in the group and given Segmented.fluid, slide the pressed option's pill to a new press and move a faint pill to the option the pointer is on; until it runs the pressed option fills itself:

ts
const SegmentedStyle = Style.forSlots(SegmentedSlots)(
  {
    group: Recipes.Segmented({ tray: 'plain', size: 'sm' }).group ?? Style.empty,
    option: Recipes.Segmented({ tray: 'plain', size: 'sm' }).option ?? Style.empty,
  },
  { layer: L.layer('app') },
)

Segmented.view draws the options in one call from the application's pressed values, and Segmented.toggled(pressed, value, { multiple, optional }) is the transition update applies. With multiple it is a toggle group, shadcn's and Zag's: any number pressed, each filling itself (draw it without Segmented.fluid, whose pill marks one):

ts
Segmented.view(
  {
    label: 'Text style',
    options: [
      { value: 'bold', label: 'Bold' },
      { value: 'italic', label: 'Italic' },
    ],
    pressed: model.marks,
    onPress: mark => Message.ToggledMark({ mark }),
    style: SegmentedStyle,
  },
  h,
)
// in update: modifyFields(model, { marks: () => Segmented.toggled(model.marks, mark, { multiple: true }) })