Concepts
Product-neutral React primitives and blocks for Fungi interfaces
Caps is a React component library: accessible primitives such as buttons,
fields, dialogs, menus and comboboxes, plus presentation blocks such as a chat
composer, an app bar, window frames, status trays, game HUD pieces and editorial
print furniture. It is built on daisyUI and themed by
Stipe, so every component picks up its look from the
active data-theme instead of from component props.
Caps renders and handles interaction. It does not fetch, navigate, check out, pick a model or know your data model. Your app owns state, effects, copy, routes and policy, and passes them in as props, slots and callbacks.
Core concepts
Section titled “Core concepts”Hosts own behaviour
Section titled “Hosts own behaviour”Caps owns rendering plus the interaction, controlled or uncontrolled state, focus, keyboard handling, teardown and accessibility that belong to a UI primitive. Your app owns product data and state, authority, routes, persistence, fetching, effects, commands, policy and copy. Blocks receive props, slots and callbacks. They never fetch.
Components keep their native contracts. Button forwards native props and its
ref, adds variant, size and loading, and merges your className last.
Checkbox, Switch and Progress keep their native elements.
Explicit subpaths
Section titled “Explicit subpaths”Every component, block, helper and stylesheet has its own subpath. There is no barrel, alias or hidden provider, so you import exactly what you render and nothing else ends up in your bundle.
Overlay destinations
Section titled “Overlay destinations”Menus, Combobox popups, dialogs, tooltips, notices and the AppBar dial portal
into the document body by default. A shell can provide OverlayRoot from
@fungi.computer/caps/components/overlay-root with containers for popover,
dialog, toast and tooltip. The shell owns their layout and paint order;
Caps owns interaction and chooses the semantic destination. A null destination
waits for the shell root to mount, including during server rendering. Base UI
menu, Combobox and dialog portals still accept explicit container overrides.
In body mode, isolate the App root so page controls cannot paint over portaled
dialogs. Agar’s mountApp does this. Dialogs and menus rank by portal order;
tooltips and toasts use their Stipe layers.
A portaled menu opened from a dialog inside Tourtois is not covered by this body-mode policy: its isolated popover layer ranks below the dialog layer. No current consumer uses that combination. Such a consumer needs a Caps portal-into-dialog composition, with its collision boundary kept separate from its portal container.
A shell can also supply subscribeDismiss to OverlayRoot. When the host
reports blur or child-surface focus, menus, Combobox popups, App dials and
tooltips close together. Dialogs and notices stay mounted. The subscription
retires with the component; standalone components retain their ordinary
dismissal behavior.
Themes and CSS
Section titled “Themes and CSS”Stipe ships a curated theme list, including Daisy light and dark. Choose a
palette or finish with data-theme; hosts can add their own presets. latte,
mocha, field-guide, field-guide-night, embed, embed-dark, phosphor,
codec, pirate-radio and pirate-radio-night are Stipe’s maintained presets.
<html data-theme="mocha"> ...</html>Your CSS can add a preset or local treatment by overriding daisyUI or Stipe variables. Keep it CSS-only:
[data-theme="workbench"] { --color-primary: #8b5cf6; --color-primary-content: #ffffff; --stipe-surface: var(--color-base-200); --stipe-fg-muted: #4c4f69;}Card variants describe structure and interaction (surface, outline,
interactive, game, fit). Use featured to emphasize an item or
state="empty" for absent content. The nested Stipe vishnu finish supplies
Vishnu’s gradient faces, stage frames, hard shadows and hover motion while
retaining the host palette. An ordinary surface card inside that finish keeps
its normal face, so play controls remain independent of the stage.
@fungi.computer/caps/blocks/game-share-card composes a published game’s
heading, optional eyebrow and cover, body children, and a primary action
element. The host supplies the link, cover alt text, data and copy behavior; the
block owns their layout. Set data-theme="vishnu" on the block for the Vishnu
finish.
Caps reads its material from the theme vocabulary below. Choose the look with
data-theme. Do not layer component CSS over Caps.
| Token | Caps material role |
|---|---|
daisyUI --depth |
Scales molded highlights, bevels, recesses, and elevation shadows. 1 preserves the molded latte/mocha material. 0 removes dimensional effects for flat linework. |
daisyUI --border |
Structural border and linework thickness. |
daisyUI --radius-field, --radius-selector, --radius-box |
Caps control, selection, and container corner geometry. |
daisyUI --color-base-*, --color-base-content, --color-neutral, semantic colors |
Surface faces, ink, neutral controls, and state accents. |
Stipe --stipe-linework |
Structural line color. The default follows --color-base-300, while field-guide uses ink. |
Stipe --card-paper, --card-rule, --card-ink, --card-rule-width |
Collector card faces, ruled borders, text, and frame weight. |
Stipe --card-frame, --card-frame-ink, --card-accent, --card-window, --card-window-ink, --card-texture, --card-shadow |
Card frame band and its ink, the accent rule and series mark, the art window stock and its ink, paper grain, and the single drop shadow. |
Stipe --font-display |
Fraunces, the self-hosted display serif for headlines, decks, card titles and flavor text. |
Stipe --font-nameplate |
Grenze Gotisch, the self-hosted blackletter for a Masthead’s nameplate. |
Stipe --newsprint-column-rule, --marginalia-ink |
Issue column separators and notes in the margin. |
Stipe --growth-spore, --growth-mycelium, --growth-fruiting |
Growth-stage badge colors. |
Caps adds no parallel material vocabulary, because daisyUI already supplies
depth, border weight, radii and semantic colors. field-guide sets
--depth: 0, thin borders, small radii, cream paper colors and muted naturalist
accents. field-guide-night keeps the same material on dark blue-green paper
with cream ink.
Screen supplies display material and keeps the inherited interface font. Use
font-mono on machine values, identifiers and code, not on a whole screen. Use
opaque semantic tokens for text hierarchy and ordinary surfaces. Keep opacity
for real compositing such as scrims, overlays and visual effects.
Filled and outline buttons read finish tokens: --caps-button-border-width
(length), --caps-button-weight (font weight), --caps-button-press (active
travel), --caps-button-drop and --caps-button-drop-active (complete shadow
lists), and --caps-button-cut (a CSS clip path, none keeps the uncut
silhouette). A disabled filled button reads --caps-button-disabled-opacity,
--caps-button-disabled-face, --caps-button-disabled-ink,
--caps-button-disabled-edge, and --caps-button-disabled-drop. Unset tokens
keep the default rendering. Roles still choose the colour. Joined input actions
keep their shared seam.
Class merging
Section titled “Class merging”@fungi.computer/caps/lib/cn merges classes with cn@0.2.6 and Stipe’s
named spacing scale.
It accepts conditional strings, arrays and objects and keeps caller-last
Tailwind overrides, so cn("text-xs px-xs py-sm", "px-md") gives
"text-xs py-sm px-md". The helper keeps typography and spacing names apart.
Every product form is built from the Caps form layer; Apps never hand-roll
labels, help text, errors, spacing or action rows. All parts live in
@fungi.computer/caps/components/field (plus RadioGroupCard in
components/radio-group). Validation stays with the caller (TanStack Form or
local state); Caps only presents it.
| Part | Job |
|---|---|
Form |
The <form> with the field rhythm (16px between fields). busy disables every control through one fieldset and sets aria-busy. |
FormSection |
A titled group (title, description, level). Consecutive sections sit 32px apart with one quiet rule. |
Field |
Label, then help, then control, then error. Wires id, htmlFor, aria-describedby (keeping the caller’s own ids), aria-invalid and required. orientation="horizontal" puts a switch opposite its copy (settings rows); orientation="inline" puts a checkbox before its copy. icon leads the copy with a decorative Sigil or App icon (hidden from assistive tech). A horizontal Field bounds a Select to 16rem, and at 480px and under drops a Select, Input or Textarea under its label. readout names a row whose child is a read-only Status rather than a control. |
FieldSet |
A legend-named group of related choices (a list of inline checkbox Fields). disabled disables every choice. Use it, not a hand-rolled <fieldset><legend>. |
Label |
required adds the asterisk; optional adds a quiet mono “Optional” readout. Mark whichever is rarer in the form. |
FieldError |
The announced, collapsing error line. Field and FormFooter already render one. |
FormFooter |
Exactly one primary Button, last; every other action is ghost or outline. error shows the submit error above the actions, status seats a quiet readout (a Status LED plus “Saved”) opposite them, layout="stretch" stacks full-width actions for single-column cards, and divider={false} drops the rule on short inline forms. Under 24rem the actions share the row equally. |
NumberField |
A spinbutton: a native input type="number" between − and + keys (which stay on touch), with the value as a mono readout and an optional unit. value/onValueChange (committed values are clamped to min/max), step, wrap. Arrows step, Page Up/Down move ten steps, Home/End jump to min/max; typing commits on blur or Enter. Field wires label, description and error to the input. Stable parts (caps-number-field, -decrement, -readout, -input, -unit, -increment, plus data-at-min/data-at-max/data-disabled) let a context such as a Screen redraw it as a dial. |
RadioGroupCard |
A radio choice that needs a sentence or a readout (title, description, meta). Selection is ink, never the accent. |
IconCheckbox (components/icon-checkbox) and IconRadio
(components/icon-radio, inside RadioGroup) accept an icon slot, a required
full label, and an optional short caption. Their native inputs retain refs,
Field descriptions/errors, keyboard selection, form submission and reset.
Selection shows the icon in colour and adds an ink lamp; off is a grey
silhouette. Caps does not depend on an icon library. Use the standard LIST, READ
(book-eye), WRITE (pencil), and RUN Sigils for Computer rights.
Rules:
- Controls share the field height (Daisy
--size-field: 40px by default), so inputs, selects and the buttons beside them line up. - Show a field’s error inline once the person has left it or submitted; show
server and network failures in
FormFooter error. - While submitting, set
Form busyand the primaryButton loading; never swap its label for “Please wait…”.
<Form aria-label="Computer settings" busy={saving} onSubmit={submit}> <FormSection title="Computer" description="How your Team sees it."> <Field label="Name" required error={nameError}> <Input value={name} onChange={onName} /> </Field> <Field label="Notes" optional> <Textarea rows={3} /> </Field> </FormSection> <FormFooter error={saveError} status={ saved ? <Status tone="success" size="compact" label="Saved" /> : null } > <Button type="reset" variant="ghost"> Cancel </Button> <Button type="submit" loading={saving}> Save changes </Button> </FormFooter></Form>TanStack Form bindings
Section titled “TanStack Form bindings”The opt-in @fungi.computer/caps/form subpath uses
@tanstack/react-form (1.33.2 in this workspace) and Effect 4 as optional
peers. Install both when using this subpath. The presentational components keep
their existing API.
FormField accepts TanStack’s field options (form, typed nested name,
validators, listeners) plus Caps label, description, required,
optional, orientation and className. Its child receives the native field
API: bind the control’s value, change and blur handlers there. Caps connects the
label, description and first readable error to that control. Errors appear after
blur or a submission attempt; TanStack retains validation timing.
fieldError(meta) reads the first nonempty string or Standard Schema issue
message, returning undefined when there is none. effectValidator(schema)
uses Effect’s maintained Schema.toStandardSchemaV1, preserving nested issue
paths and decoded values. TanStack validates with that schema; submission values
remain TanStack’s input values, so decode separately when saving transformed
data.
SubmitFooter subscribes to canSubmit and isSubmitting, renders a native
submit button, and keeps it disabled and busy until the submit promise settles.
It accepts FormFooter presentation props, a label, an additional disabled
condition and buttonProps for appearance. Keep mutation errors in its error
prop and await the actual mutation in onSubmit.
import { useForm } from "@tanstack/react-form";import { Schema } from "effect";import { Form } from "@fungi.computer/caps/components/field";import { Input } from "@fungi.computer/caps/components/input";import { FormField, SubmitFooter, effectValidator,} from "@fungi.computer/caps/form";
function Settings({ save }: { save: (name: string) => Promise<void> }) { const validator = effectValidator( Schema.Struct({ name: Schema.String.check(Schema.isMinLength(1)) }), ); const form = useForm({ defaultValues: { name: "" }, validators: { onBlur: validator, onSubmit: validator }, onSubmit: async ({ value }) => save(value.name), }); return ( <form.Subscribe selector={(state) => state.isSubmitting}> {(busy) => ( <Form busy={busy} noValidate onSubmit={(event) => { event.preventDefault(); if (!form.state.isSubmitting) void form.handleSubmit(); }} > <FormField form={form} name="name" label="Name" required> {(field) => ( <Input name={field.name} value={field.state.value} onBlur={field.handleBlur} onChange={(event) => field.handleChange(event.currentTarget.value) } /> )} </FormField> <SubmitFooter form={form} label="Save" /> </Form> )} </form.Subscribe> );}Workbench: forms--form-anatomy, forms--number-field, forms--form-states,
forms--choice-cards, forms--form-footers, forms--field, and
forms--settings-rows (settings as compact DataList rows of icon Fields).
Schema forms
Section titled “Schema forms”Forms are schema-first. The schema is the single source: it validates the raw
input, parses what the submit receives, and its annotations give each field’s
label, kind, help, placeholder, options, order and section.
<SchemaForm schema={…} /> renders the whole form; there is no field list.
import { Schema } from "effect";import { SchemaForm, Secret, field, sections } from "@fungi.computer/caps/form";
const computer = Schema.Struct({ name: Schema.Trim.check(Schema.isMinLength(1)).annotate( field({ required: true, section: "identity" }), ), token: Secret.annotate(field({ label: "Deploy token", section: "access" })), region: Schema.Literals(["eu", "us"]).annotate( field({ options: { eu: "Europe (Helsinki)", us: "United States" } }), ), internet: Schema.Boolean.annotate(field({ label: "Internet access" })),}).annotate( sections({ identity: { title: "Identity" }, access: { title: "Access" } }),);
<SchemaForm schema={computer} defaultValues={{ name: "", token: "", region: "eu", internet: true }} onSubmit={async (value) => (await save(value)) ? undefined : { kind: "refused", fields: { name: "Taken." } } } discardLabel="Discard" aria-label="Computer"/>;- Schemas: an Effect
Schema.Struct(house path; annotate with.annotate(field({ … }))) or a Zod object (annotate with.meta(field({ … }))). The form holds the schema’s encoded (raw) values;onSubmitgets only the parsed value and returns nothing (saved),{ kind: "saved", values?, message? }or{ kind: "refused", error?, fields? }. Field refusals land on their fields and clear when the field changes. - Kinds are inferred, annotations win: String → text; Number (or
NullOr(Number)) → number; Boolean → switch; a literal union → radio (≤ 3 options, or options with adescription/meta→ choice cards), select (4–12), or searchablecombobox(more than 12); options with anicon→ icon keys; an array of literals → checkboxes (icon keys with icons);Secret→ secret.kindoverrides:email,url,tel,textarea,secret,money(currency),date,select,combobox,radio,checkbox,switch,file, and the icon variants.comboboxuses literal choices or annotatedoptionsand keeps a string value. - Files: annotate
Schema.Array(Schema.instanceOf(File))(orz.array(z.file())) withkind: "file",accept,multipleandmaxBytes. The raw value isreadonly File[]; a Caps “Choose files” button opens the visually hidden, accessible native picker and feeds UploadQueue rows. The schema enforces size (Effect.check()or Zod.max()on each file), using the same byte limit asmaxBytes;acceptis a picker hint. Caps reads metadata only; the submit handler receives the File objects. - Annotations:
label(else the humanized key),help,placeholder,section+order,layout: "row"(settings row),required/optional,icon,options(labels, descriptions, meta, icons, captions), numbermin/max/step/unit,readOnly,when(values)for conditional fields.sections({ … })on the struct titles the groups, in order. - The bailout is
render:field<number>({ render: (control) => … })draws the control yourself and getsvalue,onChange,onBlur,error,disabled,idandaria-*; Caps still owns the label, help, error, validation and save bar. Precedence:render>kind> inferred kind. - Page state, not schema:
SchemaFormtakessubmitLabel,discardLabel,allowClean,disabled(lock every field),footer(replace the SaveBar;nulldrops it) andchildren(a custom layout: place fields with<FormFields model names={["…"]} />). UseuseSchemaForm(options)and passmodelwhen the page needsuseSchemaFormStatus(model)(dirty, submitting) for a leave guard.
FormField / SubmitFooter remain the hand-wired path for forms that are not a
struct.
Controls on a screen. Inside a Screen/Display the same controls take the
screen context (tokens only, no new props): labels become tiny mono legends,
Switch/Checkbox/Slider go flat and calm, icon keys get a hairline and faint ink
tint so their colour Sigil is the only colour, and NumberField becomes an
encoder dial between hairline − / + keys. Lay an instrument out with
ScreenHeader, ScreenCells and ScreenCell (components/surface), and show
read-only rights with IconLamp / IconLamps (components/icon-lamp). Keep
the Save key under the glass. Workbench: forms--declared-form,
forms--declared-form-states, forms--screen-instrument, forms--icon-lamps.
Destructive confirmations
Section titled “Destructive confirmations”ConfirmAction (components/confirm-action) owns confirmation, pending and
failure feedback. Use mode="hold" with label, confirmLabel (the spoken
hold instruction), and onConfirm: () => Promise<void>; use mode="type" with
a nonempty phrase and a destructive confirmLabel. Type mode opens a Dialog;
only an exact match enables submission. Escape or Cancel clears the phrase and
returns focus to the trigger. Pending actions cannot be dismissed or submitted
again. Resolve to finish (and close the Dialog); reject to keep it available for
retry. Optional error supplies safe failure copy; rejection details are never
rendered. The host still owns the request, idempotency and resource removal.
Workbench: forms--confirm-actions (choose Complete or Refuse).
Button finish tokens
Section titled “Button finish tokens”Filled and outline buttons inherit --caps-button-border-width (length),
--caps-button-weight (font weight), --caps-button-press (active travel),
--caps-button-drop and --caps-button-drop-active (complete shadow lists),
and --caps-button-cut (a CSS clip path; none keeps the uncut silhouette). A
disabled filled button also reads --caps-button-disabled-opacity,
--caps-button-disabled-face, --caps-button-disabled-ink,
--caps-button-disabled-edge, and --caps-button-disabled-drop, so a theme can
show a flat disabled plate instead of a dimmed key. Unset tokens keep the
existing button rendering and native state behaviour. Roles still choose the
colour; a nested Stipe finish chooses these material values. Joined input
actions retain their stationary shared seam.
Dialogs and menus
Section titled “Dialogs and menus”Dialog and DropdownMenu use Base UI’s event details, focus management and
open state. Their styled parts accept Base UI’s render composition and
state-based className callbacks. You can place the portal inside a window and
choose focus targets. When the container mounts with the overlay, pass the
mounted element through a React callback ref. Passing null keeps the portal
waiting for that element.
DialogContent is the convenient portal, backdrop and popup composition. It
keeps child state across closes by default. Its backdrop follows Base UI’s full
modal mode. modal={false} and modal="trap-focus" have no backdrop. For a
different composition, use DialogPortal, DialogBackdrop and DialogPopup
directly. The raw portal keeps Base UI’s default mount lifetime. You can retain
closing content with details.preventUnmountOnClose() and finish its animation
with the native actionsRef.current.unmount().
DropdownMenuContent exposes placement and portal-container options. Use
DropdownMenuPortal, DropdownMenuPositioner and DropdownMenuPopup directly
for custom anchors or collision boundaries. Both overlays support
interactionOwner="host": your app receives Escape and owns focus restoration.
Local overlays use Base UI’s normal closing behaviour.
CSS inherits through the DOM, not through React portals. For a nested theme, render popups into an unclipped container inside that theme.
Selection contracts
Section titled “Selection contracts”Use rootRef to receive the root div of a generic Combobox or Listbox.
Combobox also exposes inputRef and triggerRef for its input and popup
trigger.
Import ComboboxProps<T> from @fungi.computer/caps/components/combobox and
ResourceSelectProps from @fungi.computer/caps/blocks/resource-select. Use
Extract or Exclude to select a mode, and indexed access to declare reusable
callbacks or native trigger metadata. The public props keep the inline or popup,
controlled or uncontrolled, and single or multiple constraints, so you need no
private aliases. The selection contract notes have
worked examples.
Windows
Section titled “Windows”WindowFrame, WindowHeader, WindowActions and WindowBody draw a rounded
application window. Caps owns presentation, not a window manager. Your app
supplies positioning, active and floating state, drag-handle events and
lifecycle commands, and owns tiling, floating geometry and layout focus.
GameWindowContent adds padded reading rhythm for a game’s headings, readouts
and action keys inside WindowBody.
Only the callbacks you pass produce action buttons. Omit handleProps for a
static title. Otherwise the title is a native button, separate from the actions.
Name the frame with aria-label or aria-labelledby. A window is a non-modal
section, not a dialog or focus trap. WindowFrame data-theme="paper" draws it
in an ink line with a hard offset shadow, for windows over a game world.
Editorial and print components
Section titled “Editorial and print components”Some components serve long-form, magazine-style pages. They read Stipe’s card,
marginalia, growth and type tokens, so they look right in field-guide,
field-guide-night and the other editorial themes.
@fungi.computer/caps/components/field-guide:Keycap(a native keyboard key),CartridgeLabel(a ruled cartridge face) andSpecimenLabel(plate annotation, catalogue metadata, title and summary). Status lights are the gooeyStatusbead from@fungi.computer/caps/components/status.@fungi.computer/caps/components/technonomicon:CigaretteCard,Masthead,SectionBar,Folio,IssueMasthead,Spreadand related editorial pieces.
CigaretteCard is a framed collector card at the playing-card ratio 63:88. Its
content scales and clamps to fit instead of stretching the card: a display-serif
title and type line, a hexagonal series mark, an inset art window, a stat plate
on an accent rule, a flavor line and a collector line with an optional set
symbol. The reverse keeps the same frame around a ruled measurement ledger with
sources. finish="full-art" runs the drawing under paper panels inside an
accent border. A null stat value prints “not measured”. Caps never invents a
number. Give a card href to make it a preview: a link to the card’s own page
that shows only its face. The card on its own page is a pressed button that
turns over.
Masthead sets a nameplate in --font-nameplate blackletter with an optional
printed mark, a tagline, an edition line between a hairline and a double
rule, and two ears (startEar, endEar) that flank the nameplate on wide pages
and sit under it on phones. size="front" prints the large front-page head and
size="running" the smaller inner-page head. SectionBar is its section
navigation: small capitals between rules, the current section printed in
reverse, an optional letter marker, a shortcut announced through
aria-keyshortcuts, and tools at the end. It scrolls sideways on narrow pages
instead of wrapping. Folio is the running foot. IssueMasthead names its
publication in the kicker. Spread layout="lead" opens its first teaser
paragraph on a drop cap.
Server-rendered pages without React hydration can opt in to two small browser
enhancements. enhanceCigaretteCards() from
@fungi.computer/caps/enhance/cigarette-card makes click, tap, Enter and Space
toggle a card’s aria-pressed, and ignores clicks a hydrated card already
handled. sketch() from @fungi.computer/caps/enhance/sketch draws rough.js
pencil marks inside every [data-sketch-root]. Mark inline text with
data-sketch="circle" or data-sketch="underline", and a note with
data-sketch-leader-x/y to draw a leader to the data-sketch-target inside the
same data-sketch-item. The layout turns leaders on with
--caps-sketch-leader: on. Strokes use currentColor, so theme switches need
no redraw. SpecimenLabel sketch marks its plate and detail, and its ruled CSS
marks stay until the pencil layer draws.
Static sites (for example Astro) can use plain-HTML class contracts from the stylesheet without React:
- Journal:
caps-meta(catalogue line),caps-entry-row(contents row,data-thumb="none"without a plate),caps-toc,caps-backlinks,caps-tool-call,caps-collage(specimen sketchbook with--art-*and--note-*placement variables),caps-note-teaser,caps-inset(data-align="end"floats a card beside prose),caps-nav-link,caps-shortcuts,caps-skip-linkandcaps-pencil-reveal. - Print:
caps-headline(data-size="banner|lead|story|brief"),caps-deck,caps-byline,caps-jump(the “continued on” line),caps-story-head,caps-section-head(data-size="front"for a section front),caps-edition(childrendata-edition-slot="lead|aside|wide", the aside ruled beside the lead),caps-column-grid(one ruled column per topic,--caps-columns),caps-column-head,caps-column-empty,caps-brief,caps-box,caps-numeral,caps-print-prose(display headings, pull-quote blockquotes, an ornamental break and, withdata-dropcap, a drop cap) andcaps-live-portrait(a live avatar traced by a host SVG filter whosecaps-live-portrait-inkflood reads--card-ink).caps-note-teaser[data-size="lead"]sets a teaser as a front-page lead. - Page turns: keyframes
caps-page-out,caps-page-in,caps-page-back-outandcaps-page-back-inare for your view transitions. Drop them under reduced motion.