FiestaUI is the FiestaBoard design system: React components built on Base UI with Tailwind v4 design tokens, published as @fiestaboard/ui.
Component catalog: the Storybook is deployed to GitHub Pages on every push to main.
npm install @fiestaboard/uiNo token, no .npmrc, no registry configuration — the package is public on
registry.npmjs.org and installs anonymously,
in CI and Docker builds as much as locally.
Before 1.6.3 this package lived on the GitHub Packages npm registry, which requires authentication for every read even when the package is public. If you still have a
@fiestaboard:registry=https://npm.pkg.github.comline in an.npmrcanywhere, delete it — it will pin you to the old registry and to versions no longer published there.
Peer dependencies: react / react-dom ^19, lucide-react, and tailwindcss ^4 in the consuming app.
FiestaUI ships no compiled utility CSS. The consuming app runs Tailwind v4 and generates utilities for the class strings inside this package. In your Tailwind entry CSS:
@import "tailwindcss";
@import "@fiestaboard/ui/fonts.css";
@import "@fiestaboard/ui/theme.css";
@source "../node_modules/@fiestaboard/ui/dist";theme.csscarries the design tokens (@theme inline,:root/.dark, [data-theme="dark"]custom properties), the base layer, the component animation keyframes, and thedarkcustom variant.fonts.cssis the opt-in font registration (@fontsource-variable/archivo+@fontsource-variable/spline-sans-mono@font-facerules, ~70 KB fetched for a Latin-only page). Import it alongsidetheme.cssunless your app supplies the faces itself — e.g. vianext/font, a CDN, or a self-hosted subset. If you self-host, skipfonts.cssand register faces named"Archivo Variable"/"Spline Sans Mono Variable"(the namestheme.css's--font-sans-stack/--font-mono-stacktokens reference); without either, the tokens degrade gracefully to the system font stack.- The
@sourceline is mandatory — Tailwind v4 does not scannode_modulesby default, and without it component styles silently vanish. Adjust the relative path to wherever your CSS file lives. - Dark mode responds to either signal, and they are equivalent: the
darkclass or adata-theme="dark"attribute. Put whichever your host already toggles on<html>— or on any ancestor, to theme just that subtree. FiestaUI only defines the variant; your app owns the toggle. Hosts that stamp the attribute natively (Docusaurus, Astro, Nuxt Color Mode) therefore need no adapter. The pair is left unqualified, at specificity 0-1-0, so a pipeline that appends its own:rootblocks aftertheme.css(Docusaurus's wide-gamut P3 pass does) still has to raise specificity on its side.
The sans face is Archivo as of 5.0.0, replacing Geist. Archivo is drawn from American grotesque signage — the same world the split-flap hardware lives in — and carries a wdth 62–125 axis, so headings can run expanded into departure-board territory while UI text stays at normal width, from one family.
fonts.css registers the weight-only Archivo file (wght 100–900, ~34 KB Latin). The width axis lives in the larger @fontsource-variable/archivo/wdth.css entrypoint (~88 KB Latin) and is not imported, because nothing in this package sets font-stretch yet. Swap the import when expanded display type ships — the family name is identical, so no token or consumer change is needed.
The token names --font-sans-stack / --font-mono-stack name the role, not the vendor. The former --font-geist-sans / --font-geist-mono names are aliased to them for one minor and will be removed in the next major; nothing in this package references the old names.
Archivo's vertical metrics are meaningfully tighter than Geist's — a 1.089em natural line box against Geist's 1.30em — so text set without an explicit line-height renders more compactly. Tailwind's text-* utilities set line-height, so most surfaces are unaffected; heading.stories.tsx documents where the difference does bite.
The mono face is Spline Sans Mono, also as of 5.0.0. It is not a code-editor setting: every split-flap glyph the product draws is font-mono font-semibold (board-display.tsx, static-board-display.tsx), and the template editor that previews the board follows the same token. It was picked on the two metrics that govern a fixed character cell read from a distance — cap height 72.7% of em against Geist Mono's 71.0%, at the same 60.0% glyph advance. Its weight range is 300–700; the only weights paired with font-mono in this package are 500 and 600, so no weight synthesizes.
theme.css inlines only the live season (Pride) — it activates automatically when the app shell stamps .pride-month on <html>. The other seasons are design drafts and are shipped as opt-in stylesheets rather than bundled into every consumer, so you only pay for the ones you use:
@import "@fiestaboard/ui/seasons/christmas.css";Import the file for a season only once you've promoted it (moved its entry into SEASONS and taught the app shell to stamp its htmlClass). All drafts are previewable in Storybook's Season toolbar without any consumer setup.
Migration (breaking): the draft season data (
DRAFT_SEASONS,ALL_SEASONS) is no longer exported from"@fiestaboard/ui"— it's Storybook-only data and now tree-shakes out of production bundles. If you previewed drafts, import them from the subpath instead:import { ALL_SEASONS } from "@fiestaboard/ui/lib/seasons-drafts". The live-season API (SEASONS,getActiveSeason,useActiveSeason, …) is unchanged.
import { Button, Card, CardContent, cn } from "@fiestaboard/ui";
export function Example() {
return (
<Card>
<CardContent className={cn("flex gap-2 p-4")}>
<Button variant="brand">Save</Button>
<Button variant="outline">Cancel</Button>
</CardContent>
</Card>
);
}The board renderers take either a message in board markup or the grid of cells a caller has already parsed. DisplayPreview is the one entry point: give it a device model and it picks the renderer the model calls for.
import { DisplayPreview, LedMatrixDisplay, parseLine } from "@fiestaboard/ui";
// A built-in model by id. A split-flap model renders through StaticBoardDisplay
// (or the animated BoardDisplay when `transition` is set); an LED matrix through
// LedMatrixDisplay. An unknown id throws — it is never silently a Flagship.
<DisplayPreview model="vestaboard_flagship" message="72° SUNNY" code62Glyph="heart" />
<DisplayPreview model="hub75_64x32" message="72° SUNNY" transition="flip" />
// The Pixoo 64 streams single frames at 5 fps (its hardware labs), so its
// default transition is a coarse flip: one frame per step, no half-flaps.
<DisplayPreview model="divoom_pixoo64" message="72° SUNNY" />
// A plugin's model, passed as the object its manifest declares.
<DisplayPreview model={pluginModel} message="€12 {icon:up}" />
// Board-level settings are honoured only where the model's `appearance.options`
// lists the key and the value — here the Vestaboard models offer `board_color`.
<DisplayPreview model="vestaboard_note" message="HELLO" appearance={{ board_color: "white" }} />Extended markup is on by default, on every board. Colour spans {red:HOT}, block spans {black/white:OPEN} and icons {icon:sun} parse on every renderer: an LED draws them as written, and a split-flap board draws the degradation — the span's letters without the colour, the icon's fallback tile ({icon:sun} is a yellow tile, {icon:check} a green one). That has been the split-flap default since the coordinated major that shipped with FiestaBoard's Python parser parity; a board driven by an older FiestaBoard, whose renderer draws those markers literally, opts out with extendedMarkup={false} on BoardDisplay, ScaledBoardDisplay, StaticBoardDisplay, BoardTeaser or DisplayPreview — and on ScaledBoardTeaser, PluginCard, BoardBackdrop and BoardShowcase, which pass the prop through and otherwise inherit the renderer's default.
The TemplateEditor is the one exception. It reads the extended grammar only when it is given a charset or a deviceModel: without a set it cannot offer the forms in its toolbar or say which cells a board cannot draw, and the templates it edits are stored text whose grammar the host decides, so {{red:HOT}} without a set stays the variable it always was and round-trips untouched. FiestaBoard threads the board's deviceModel into the editor, which turns the grammar on; a host that wants it without a device passes charset="vestaboard_v1" (or the board's set). The pure parser (parseLine, messageToGrid, messageToText) keeps the option off unless asked, so a bare call is an explicit parse of the base grammar.
Cells in. FiestaBoard core parses markup once into rich cells (BoardToken[][]: characters, colour tiles and icons, with their span colours) and hands every output the same grid. Each renderer accepts that grid as cells in place of message; cells wins when both are given, and the two paths draw byte-identical frames for the same content (golden-tested).
const cells = message.split("\n").map((line) => parseLine(line, cols, { extendedMarkup: true }));
<LedMatrixDisplay model="hub75_64x32" cells={cells} transition="flip" />
<DisplayPreview model="vestaboard_note" cells={cells} />The cell grid should be the device grid's size (ledGridLayout(spec) for an LED matrix). A grid that is not is clipped and padded, as a long or short message is, and the LED housing reports it on data-cells-mismatch; a development build also logs a console.error. The pure functions are layoutLedMessage / layoutLedCellGrid (LED) and messageToGrid / cellsToGrid (split-flap).
FiestaPanel's television. FiestaPanel shows a board on a TV, so its previews sit on one: frame="tv" wraps whichever renderer the model gets in TvFrame, an OLED set (thin bezel, chin with a standby LED, centre stand or wall mount, true-black screen) that fills the width it is given and scales the board to fit its screen. The set is described by tv, from what the viewer knows: diagonalInches (screen_diagonal_inches, 3–200, default 55) sizes the bezel and the stand relative to the screen; aspect (screen_aspect_w / _h, a number or { w, h }, default 16/9) shapes the screen; dimmed (0–1, the viewer's auto-dim level) veils the screen only; offline (a frame-fetch failure) turns the screen off, lights the standby LED and announces a status in place of the board. The viewer's calibration_scale is a physical-size calibration and does not reach the frame. TvFrame takes any children, and frame="tv" is allowed for any model.
A split-flap board on the TV draws no housing of its own — only its flaps on the screen's black, as FiestaPanel's Apple TV app shows them — and the flaps are what is fitted to the screen. That is the renderers' bezel prop (StaticBoardDisplay, BoardDisplay, ScaledBoardDisplay, LedMatrixDisplay; default true, the board as before; false is the tile grid alone, transparent, still role="img" with its name, marked data-bezel="false"), which DisplayPreview frame="tv" sets for a split-flap model and exposes as bezel for an explicit choice. An LED board on the TV keeps its housing until that is decided.
<DisplayPreview model={fiestaPanelModel} message="DEPARTURES" frame="tv" tv={{ diagonalInches: 65, dimmed: 0.2 }} />
<TvFrame aspect={{ w: 9, h: 16 }} stand={false} offline>
<LedMatrixDisplay model="hub75_128x64" message="HELLO" />
</TvFrame>
<TvFrame>
<BoardDisplay deviceType="panel" gridRows={12} gridCols={29} message="DEPARTURES" bezel={false} />
</TvFrame>Validators and schemas. validateDeviceModel(json) and validateCharacterSet(json) return { ok, errors[] } for any value. The same contract as JSON Schema lives in scripts/ci/tests/fixtures/device-model.schema.json and character-set.schema.json, and the two are tested to agree. The fixtures beside them are generated by node scripts/ci/led-fixtures.mjs and drift-tested: the built-in sets and models (character-sets.json, device-models.json), the fonts and icons, the golden layout and transition frames (led-golden.json), the character-set rules (charset-golden.json), and example plugin declarations (plugin-models.json).
An output plugin declares its devices as data, never as React: a DeviceModel per device in its output/device-models.json, and a CharacterSet if the device draws characters the built-in sets do not. The shapes, the rules and the worked example are in the spec, docs/superpowers/specs/2026-10-03-led-matrix-display-design.md §6 ("Device taxonomy and plugin-declared devices"). The short version:
- A model names its
technology(split_flap|led_matrix),geometry(cells/note_array/panelfor flaps,pixelsfor an LED),color,charset(a built-in id or an embedded set),animation(how fast it can show frames, and amaxFramesbudget for a sequence player), an LEDfont, and — preview-only —appearance, withoptionsfor the fields a board may override. - Pass the object, not an id, to every API:
DisplayPreview,LedMatrixDisplay,CharacterGlyph,LedTransitionPicker,transitionsForModel, … - Validate it with
validateDeviceModel(or the JSON Schema) in your tests.scripts/ci/tests/fixtures/plugin-models.jsonholds three declarations to copy from: a 48×12 amber sign with its own€glyph, and FiestaPanel's two render styles.
npm install
npm run storybook # component workbench on :6006
npm run build # dist/ — ESM + d.ts + theme.css
npm run lint && npm run typecheck && npm run format:check
npm test # unit tests (Vitest + jsdom), single run
npm run test:watch # the same suite, watching
npm run build-storybook && npm run test-storybook # axe a11y sweep (needs the static build served on :6006)Vitest + Testing Library on jsdom, configured in vitest.config.ts (deliberately a separate file from the library build's vite.config.ts) with globals bootstrapped in src/test/setup.ts.
Tests are colocated: the test for src/components/forms/button.tsx is src/components/forms/button.test.tsx. Only src/**/*.test.{ts,tsx} is collected — the harness suites under scripts/ keep their own runners (npm run perf:test, npm run release:test).
Tailwind does not run in jsdom. No stylesheet is loaded, so class strings are inert and getComputedStyle reports UA defaults for every component in this package; an assertion on a colour or a size there passes for the wrong reason. Assert on what jsdom actually models — roles, accessible names, ARIA state, focus, keyboard, events and data-* props. Appearance is covered twice already, by VRT (docs/VISUAL_REGRESSION.md) and by the Storybook a11y sweep, both of which run a real browser.
Three files are maintained as exemplars of the house pattern; copy the nearest one:
| Exemplar | Pattern it models |
|---|---|
src/components/chrome/breadcrumb.test.tsx |
presentational — landmark, list semantics, aria-current, asChild |
src/components/forms/button.test.tsx |
interactive — userEvent, keyboard activation, busy/disabled semantics |
src/components/forms/toggle.test.tsx |
ARIA state and selection — aria-pressed, roving tabindex, arrow keys |
This package was extracted from FiestaBoard's web/src/components/ui with a pixel-parity guarantee. Token values in src/styles/theme.css and component class strings are contract, not implementation detail — changes to them are visible in every consumer. Change intentionally and version accordingly (patch: fixes, minor: additive components/variants, major: breaking API or visual changes).
Don't point FiestaBoard's Docker build at an unpublished version. For local iteration:
npm run build && npm pack # produces fiestaboard-ui-<version>.tgz
# in the consumer: npm install /path/to/fiestaboard-ui-<version>.tgzReleases are continuous: every merge to main that changes shipped code bumps the version, tags v<version>, publishes to registry.npmjs.org, and creates a GitHub Release. scripts/release/gate.mjs decides whether a merge earns a release and how big a bump; CI-only changes (workflows, scripts/ci, VRT baselines) mint nothing. Actions → Release → Run workflow remains the manual override.
Publishing uses npm Trusted Publishing (OIDC). The workflow's id-token: write permission mints a short-lived credential that npm exchanges for publish rights, so there is no npm token anywhere — nothing to store, leak, or rotate. Provenance is attested from the OIDC claims, linking each tarball to the workflow run that built it.
This requires a trusted publisher configured on the npmjs package — repository Fiestaboard/FiestaUI, workflow release.yml. Without it the publish step fails outright; it does not fall back to a token.
npm run release:npmjs builds and publishes from a local checkout. It exists as an escape hatch for when the workflow is broken — normal releases should go through CI.
npm login # once — web-based, completes 2FA in the browser
npm run release:npmjs -- --dry-run # inspect the tarball contents first
npm run release:npmjs # publish; completes 2FA interactivelyThe account's 2FA mode is auth-and-writes (check with npm profile get), so the publish needs a second factor. Do not pass --otp unless you have a TOTP authenticator app — with a passkey or hardware security key there is no code to type. With auth-type=web (npm's default) the CLI prints a https://www.npmjs.com/login/<uuid> URL; open it, satisfy the passkey, and the publish continues.
The script rebuilds first because files ships only dist and there is no prepack hook, so a stale dist/ would otherwise publish silently-wrong contents — unrecoverable, since npm forbids republishing a version. Publish from an up-to-date main: the version comes from package.json, so a stale checkout would point npmjs's latest tag at a superseded release.
Every release triggers .github/workflows/downstream-upgrade.yml, which keeps
a single evergreen PR open on Fiestaboard/FiestaBoard (branch
fiestaui-upgrade) pinning @fiestaboard/ui to the newest version. If the
bump breaks FiestaBoard, Claude (Opus) TDD-fixes it (max 3 attempts) and
FiestaBoard CI must pass before the PR is labeled upgrade-green; otherwise
it's labeled upgrade-blocked and the maintainer is pinged. A human on
FiestaBoard always does the merge.
Manual run / backfill: Actions → Downstream Upgrade → Run workflow
(optionally set version; dry_run computes the bump without pushing).
Required repo secrets: CLAUDE_BOT_APP_ID, CLAUDE_BOT_APP_PRIVATE_KEY
(GitHub App with write access to FiestaBoard), CLAUDE_CODE_OAUTH_TOKEN.
Design: docs/superpowers/specs/2026-08-01-downstream-upgrade-design.md.
MIT