@firna/ui
v2.0.1
Published
Shared React Native and React Native Web UI primitives for Firna apps.
Maintainers
Readme
ui
Shared UI component library for Firna React Native and React Native Web surfaces. The first consumers are the accounting app and the Juno app.
Key Features
- Shared dropdown menu, selector, combobox, drag-select, segmented control, radio card, switch, spinner, loaders (six indeterminate shapes plus determinate progress bar and ring), button, labelled input/textarea, data table, editable data grid (Airtable/Notion-style), a from-scratch interactive chart family (bar, line, area, sparkline, stat tile, donut, gauge, bullet, funnel, matrix heatmap, scatter, histogram, waterfall, small multiples) on a colourblind-validated palette, cross-platform block rich-text editor with canonical markdown and a collaboration layer (live carets, tracked changes, comment threads), modal, toast provider/controller, avatar, status badge, animated comet-trail border, calendar heatmap, full event-calendar (month/week/day/agenda, recurring events, drag-to-create), branching workflow-builder step-graph primitives, a multi-track editing timeline with pro clip edits, and the video-editor panels that surround it (program monitor, transport, level meter, media bin, property inspector, effects rack, keyframe editor, export dialog).
- A shared
sm/md/lgsize scale (ControlSize) across the interactive controls — buttons, inputs, dropdown selectors, date fields, segmented controls, and switches. - Themeable visual tokens so consumers can use their own brand primary color, with four shipped presets — the accounting default and Juno, each in a light and a dark variant. Every component reads its colors from the tokens, so dark mode is a preset swap rather than a per-component opt-in.
- A shared, calm focus glow across every control. On web it follows
:focus-visible, so keyboard focus is clear without painting a keyboard-style ring after a pointer click; native keeps its platform focus behavior. Disable it globally via the theme'sfocusRing: falseflag or per instance with adisableFocusRingprop (both restore the browser's default focus outline). - Portaled, anchored web date/dropdown/popover overlays with viewport-aware, content-sized selector menus and z-index escape hatches, plus touch-friendly native date sheets.
- Expo and React Native Web compatible platform files.
- Focused unit tests, browser interaction tests, and package export checks.
- Storybook previews for visual review on same-repository non-release PRs.
- Release-please release PRs and npm trusted publishing for
@firna/ui.
User-Facing Interface
The package name is @firna/ui. Public exports are available from:
@firna/uifor all public components and helpers.@firna/ui/animated-borderfor the animated comet-trail border that traces a rounded-rectangle perimeter to highlight an element, in a single color or a two-color brand gradient.@firna/ui/avatarfor the themed initials avatar (circle or rounded square).@firna/ui/badgefor the themed status badge pill with tone, variant, and size variants.@firna/ui/buttonfor the themed button with tone, size, and block variants, and arolethat re-points it at another single-activation role (checkbox,menuitem,radio,switch,tab) with the state that role must carry — so a tab or checkbox keeps the shared focus glow instead of being hand-rolled.@firna/ui/calendarfor the full event calendar (month, week, day, and agenda views, recurring events, and drag-to-create).@firna/ui/chartfor the interactive chart family — bar, line, area, sparkline, stat tile, donut, gauge, bullet, funnel, matrix heatmap, scatter, bubble, histogram, waterfall and small multiples — built from this library's own primitives andreact-native-svg, with no charting dependency. Every chart ships a keyboard-navigable hit layer, a hover/scrub readout and an accessible data-table twin.@firna/ui/data-gridfor the editable Airtable/Notion-style data grid (cell-range selection, keyboard nav, virtualized infinite scroll, typed editable cells, column menus, and a responsive card stack).@firna/ui/datefor single-date and date-range fields.@firna/ui/drag-selectfor web drag-selection providers, target hooks, and geometry helpers.@firna/ui/dropdownfor dropdown menu, selector, combobox, and layer helpers.@firna/ui/heatmapfor the calendar contribution heatmap and its pure layout and color-scale helpers.@firna/ui/inputfor the labelled text input, textarea, and bare input frame.@firna/ui/listfor the vertical list with between-item separators, optional clickable items, and theListItemrow.@firna/ui/loaderfor the loading indicator family:Loaderwith six interchangeable indeterminate shapes (ring,dot-grid,dots,bars,blades,pulse), plusProgressBarandProgressRingfor work whose total is known.@firna/ui/modalfor web modal frame, portal, model, and layer helpers.@firna/ui/popoverfor generic anchored popovers.@firna/ui/radiofor themed titled radio-option cards.@firna/ui/rich-textfor the Notion-style block editor with markdown in/out, inline formatting, lists/checklists, native keyboard toolbars, and the collaboration layer (live carets, tracked changes, comment threads) with itsRichTextPresenceBarandRichTextCollabRailsurfaces.@firna/ui/segmentedfor themed single-select segmented controls.@firna/ui/sortable-listfor the drag-and-drop sortable list (pointer + keyboard reordering, an optional start/end grab handle, and vertical or horizontal flow), plus theSortableGroupscoordinator that lets several lists exchange items — stacked as sections or laid out as a board — reporting each move with its source and destination group.@firna/ui/spinnerfor the themed indeterminate spinning loading indicator.@firna/ui/switchfor themed binary on/off switches.@firna/ui/tablefor the data table with optional headers and clickable rows.@firna/ui/themeforSharedUiThemeProvider, default accounting-style tokens, the Juno token preset, thedarkSharedUiThemeandjunoDarkSharedUiThemedark presets, theSharedUiSchemetype,createSharedUiTheme(overrides, base), and the globalfocusRingswitch (SharedUiThemeProvider theme={{ focusRing: false }}disables every control's focus glow at once). See Theming for the dark-mode contract.@firna/ui/focusRingforuseFocusRingandfocusRingStyleFor— the shared focus-glow primitive every control uses. The hook exposesfocusedfor actual focus andfocusVisiblefor deciding when to paint the ring; on web the latter follows:focus-visibleand is cleared by the target's native blur event even when React misses a blur during a disabled-state transition. PassdisableFocusRingto a single control to drop only that instance's glow; both disable paths restore the browser's default focus outline (WCAG 2.4.7).@firna/ui/toastfor the toast provider, theuseToasthook, thetoastControllermethod API, and transient notification toasts including card and solid variants with optional custom leading icons.@firna/ui/workflowfor the branching workflow builder — a step-graph canvas (color-coded nodes, tinted edge labels, forks, legend, add-step) for constructing automation workflows.
Installation
npm install @firna/uiConsumers must provide the peer dependencies listed in package.json: React,
React DOM, React Native, React Native Web, React Native SVG, and
lucide-react-native.
Theming
Four presets ship, all built from the same semantic token set:
defaultSharedUiTheme and junoSharedUiTheme (light), darkSharedUiTheme and
junoDarkSharedUiTheme (dark). Pass one to SharedUiThemeProvider, or brand it
first with createSharedUiTheme(overrides, base) — the second argument picks
the preset to extend, so a dark brand tint stays dark:
const brandDark = createSharedUiTheme(
{ colors: { primary: "#8fb3ff" } },
darkSharedUiTheme,
);Two token-level rules make dark mode work without per-component branching:
colors.onSolidis the text/icon color on solid accent fills (the solid badge, the primary button, the dropdown's active row, the calendar "today" disc, the switch knob at the on-position…). It is white in the light themes and the near-black page ink-well in the dark ones.- Solid fills invert. In the dark presets the
*Deeptokens become light accents andonSoliddarkens, so every existing token relationship (deep-on-soft, deep-as-fill, the heatmap ramp's ordering) keeps working unchanged. Every documented WCAG 2.1 — 1.4.3/1.4.11 pair is pinned by unit tests across all four presets, and every dark Storybook story is swept by axe.
theme.scheme ("light" | "dark") is available for the rare physical-metaphor
case, but components should read colors from tokens rather than branch on it.
Chart colors
theme.charts carries the data-visualization scales used by @firna/ui/chart,
each encoding exactly one job: series (identity — 8 slots, assigned in order
and never cycled), sequential (magnitude), ordinal (ordered marks),
diverging (polarity, with a neutral grey midpoint) and status (reserved
state, never handed out as a series color). The chart furniture — grid,
axis, label, surface, deemphasis — derives from the theme's own
neutrals, so all four presets stay in sync with nothing maintained by hand.
createSharedUiTheme resolves charts from scheme and colors, so a theme
built through it never supplies one. Values you set explicitly are carried
forward when that theme is extended.
scheme and colors must agree. createSharedUiTheme({ scheme: "dark" })
alone yields a dark-schemed theme still wearing the light palette — the dark
series steps then paint on a white surface and several drop below their
contrast floor. Extend a dark preset instead:
createSharedUiTheme(overrides, darkSharedUiTheme). Before charts, scheme
only affected a few physical-metaphor sites; it now selects whole color scales,
so the mismatch matters much more than it used to.
The slot order is the colorblind-safety mechanism, not a cosmetic choice — it was picked by enumerating all 40,320 orderings and keeping only those that clear the gates on every shipped surface. Re-order it and you must re-validate:
node scripts/validate-chart-palette.mjs # report the shipped palette
node scripts/validate-chart-palette.mjs --derive # re-run the enumerationtests/unit/chartPalette.test.ts pins every measured number, so a token edit
that regresses the palette fails the suite rather than shipping quietly.
The library does not detect the OS setting — the provider stays free of a
react-native import so it can be loaded by the node test runner and the
package-smoke stubs. Consumers own that wiring:
import { useColorScheme } from "react-native";
import {
darkSharedUiTheme,
defaultSharedUiTheme,
SharedUiThemeProvider,
} from "@firna/ui/theme";
function App() {
const scheme = useColorScheme();
return (
<SharedUiThemeProvider
theme={scheme === "dark" ? darkSharedUiTheme : defaultSharedUiTheme}
>
{/* … */}
</SharedUiThemeProvider>
);
}Developer Get Started
npm ci
npm test
npm run typecheck
npm run build
npm run test:package
npm run storybook
npm run storybook:build
npm run test:browserRun the full JavaScript verification suite with:
cargo xtask checkRun the same read-only AI review wrapper used by the accounting repo with:
cargo xtask reviewBrowser interaction tests start Storybook automatically through Playwright.
Storybook is built to storybook-static. npm run test:package builds a
packed tarball, installs it into temporary consumers, imports every public
package subpath with Node's native ESM resolver, typechecks those subpaths with
TypeScript's NodeNext resolver, and then verifies the same subpaths through a
Vite build.
The axe accessibility gate discovers every Storybook story at runtime and
splits the sorted story list into four deterministic shards. Playwright runs
those shards across its workers so the complete sweep does not depend on one
long-running test. UPDATE_A11Y_BASELINE=1 npm run test:browser -- a11y.spec.ts
uses one serial sweep instead, ensuring axe-baseline.json has a single writer.
Playwright uses STORYBOOK_PORT when set, then Conductor's workspace-specific
CONDUCTOR_PORT, and otherwise port 6006. This lets browser checks run safely
alongside previews from parallel workspaces.
The package export map intentionally separates runtime targets:
- The standard
importcondition points atdist/node/**, where relative ESM specifiers include explicit.jsfiles and web platform files are selected when they exist. - Type declarations also point at
dist/node/**, where relative declaration specifiers use NodeNext-compatible.jspaths. - The
react-nativecondition points atdist/**, preserving extensionless specifiers so Metro and React Native platform resolution can choose native or web files.
Package Releases
- Release-please opens and updates the release PR for
@firna/uifrom Conventional Commits. - The release PR updates
CHANGELOG.md,package.json, andpackage-lock.jsonthrough release-please'snoderelease type. - When release-please creates or updates a release PR, the release workflow
checks out that generated PR branch, runs
npm run format, and pushes achore: format release PRcommit only if the generated files need Prettier cleanup. - Merging the release PR lets release-please create the
vX.Y.Ztag and GitHub release. Ordinary non-release pushes tomainonly update the release PR. - npm publishing runs in the same
.github/workflows/release-plz.ymlinvocation that creates the GitHub release, using npm trusted publishing. The npm package must configure this repository andrelease-plz.ymlas the trusted publisher, with allowed actionnpm publish. - The workflow file keeps the historical
release-plz.ymlfilename because npm trusted publishing validates the workflow filename configured on npmjs.com. The workflow implementation itself uses release-please. - The workflow falls back to
GITHUB_TOKENfor release-please, but a repository secret namedRELEASE_PLEASE_TOKENcan be added if release PRs need to trigger normal PR checks. - Before publishing, the release workflow installs dependencies, installs the
Playwright browser, verifies the release tag matches
package.json, runscargo xtask check, and skips publishing if the version already exists on npm. - If publish fails after the GitHub release was created, manually dispatch the
release workflow with
publish_refset to the existingvX.Y.Ztag. The retry path checks out that tag and runs the same verification and publish steps. - Scoped npm packages default to private, so
publishConfig.accessis set topublic.
Storybook Deployments
- Main branch Storybook deploys to Cloudflare Pages project
futex-ui-storybook. - Main URL:
https://futex-ui-storybook.pages.dev. - Same-repository non-release PR previews deploy to Cloudflare branch
pr-<number>. - PR preview URL shape:
https://pr-<number>.futex-ui-storybook.pages.dev. - PR previews are posted through a sticky comment marked
<!-- futex-ui-storybook-preview -->. - Release Please PRs are skipped by the Storybook preview deploy job; their component changes were already previewed in the source PRs.
- Closing a same-repository PR marks the sticky comment inactive and attempts to delete aliased preview deployments for that PR branch; if Cloudflare cleanup cannot complete safely, the comment reports the retained reason.
- Storybook examples are grouped under one top-level folder per family:
Avatar/Examples,Badge/Examples,Button/Examples,Calendar/Examples,Date/Examples,Drag Select/Examples,Dropdown/Examples,Heatmap/Examples,Input/Examples,Kanban/Examples,List/Examples,Loader/Examples,Modal/Examples,Popover/Examples,Radio/Examples,RichText/Examples,RichText/Collaboration,Segmented/Examples,SortableList/Examples,Spinner/Examples,Status dot/Examples,Switch/Examples,Table/Examples,Theme/Examples,Timeline/Examples,Toast/Examples, andVideo editor/Examples(whoseFull editorstory assembles the whole family into a working editor, in light, dark, and compact). - Required repository variable:
CLOUDFLARE_ACCOUNT_ID. - Required repository secret:
CLOUDFLARE_PAGES_API_TOKENorCLOUDFLARE_API_TOKEN.
Key Code Jumping Points
- Shared theme boundary: src/theme.tsx
- Animated border component: src/animated-border/README.md
- Avatar component: src/avatar/README.md
- Badge component: src/badge/README.md
- Shared control-size scale: src/controlSize.ts
- Shared focus-glow primitive: src/focusRing.ts
- Button component: src/button/README.md
- Calendar component: src/calendar/README.md
- Input and textarea components: src/input/README.md
- Kanban component: src/kanban/README.md
- List component: src/list/README.md
- Loader components: src/loader/README.md
- SortableList component: src/sortable-list/README.md
- Dropdown components: src/dropdown/README.md
- Drag-select components: src/drag-select/README.md
- Heatmap component: src/heatmap/README.md
- Modal components: src/modal/README.md
- Radio card component: src/radio/README.md
- Rich-text editor: src/rich-text/README.md
- Segmented control component: src/segmented/README.md
- Spinner component: src/spinner/README.md
- Status dot component: src/status-dot/README.md
- Switch component: src/switch/README.md
- Table component: src/table/README.md
- Timeline component: src/timeline/README.md
- Toast component: src/toast/README.md
- Video-editor panels: src/video-editor/README.md
- Workflow builder component: src/workflow/README.md
- Browser tests: tests/browser/storybook.spec.ts
- Repository automation: xtask/README.md
- Shared component protocol: docs/protocol/shared-ui-components.md
- Rich-text collaboration protocol: docs/protocol/rich-text-collaboration.md
- Consumer migration handoff: docs/consumer-migration.md
- Active and completed implementation plans: plans/README.md
Related Repositories
- Accounting consumer/source components:
/Users/calummoore/projects/futex/accounting - Juno consumer:
/Users/calummoore/projects/futex/juno
