@charcuterie/logic
The five Charcuterie state kinds, as framework-free cores with React 19 and Preact
bindings. No components — those are @charcuterie/ui (M3).
@charcuterie/logic React 19 binding (default entry)
@charcuterie/logic/core framework-free factories, zero dependencies
@charcuterie/logic/preact Preact binding (no preact/compat)
@charcuterie/logic/jotai optional store adapter
@charcuterie/logic/signals optional store adapter
@charcuterie/logic/query request/response data layer (TanStack Query)
@charcuterie/logic/openapi the typed HTTP seam, for a backend with an OpenAPI spec
@charcuterie/logic/query — the data layer
The fleet's one way to fetch: TanStack Query for caching, and the defaults two apps had already tuned before this existed.
import { QueryProvider } from "@charcuterie/logic/query"
import {
createApiClient,
createApiHooks,
} from "@charcuterie/logic/openapi"
import type { paths } from "./__generated__/api.gen.ts"
const fetchClient = createApiClient<paths>({ baseUrl: "/" })
export const api = createApiHooks(fetchClient)
const App = () => (
<QueryProvider>
<Jobs />
</QueryProvider>
)
const Jobs = () => {
// path, params, and response are all typed off the OpenAPI spec:
const { data } = api.useQuery("get", "/jobs")
return <JobList jobs={data ?? []} />
}
createQueryClient keeps react-query's own defaults — retries stay on, so
the layer recovers from a transient blip — and deep-merges any override. A
polling app that wants a failed request to not retry (because backoff would
keep stale data on screen) opts out explicitly:
createQueryClient({ defaultOptions: { queries: { retry: false } } }) — the call
rip-deck and board-games make. All three libraries are optional peers, and
the two subpaths opt into different ones: ./query needs only
@tanstack/react-query, and ./openapi adds openapi-fetch +
openapi-react-query. An app with no OpenAPI document imports ./query alone
and installs neither — why they are separate
subpaths.
The paths type is generated from the backend's OpenAPI document by
openapi-typescript and committed as a .gen.ts file that Biome and ESLint
ignore — see the generated-schemas
decision.
This is the request/response layer; the RxJS push layer (SSE/WebSocket)
is the separate, future @charcuterie/streams.
The five kinds
| Kind | Question it answers | Used by |
|---|---|---|
createVisibility | Is this shown? | Modal, Drawer, Popover, Tooltip, Disclosure |
createVisibilityGroup | Which one is shown? | Tabs, Accordion, a Menu's submenus |
createSinglePicker | Which one is chosen? | Radio, Select, segmented control |
createMultiplePicker | Which set is chosen? | Checkbox group, multi-select filter |
createRovingFocus | Which one is tabbable? | Listbox, Menu, Toolbar, Tab list |
createStatus | Where in a lifecycle? | Badge, LiveStatusIndicator, ProgressBar |
The last two are new in v2; see
the five-state-kinds decision.
createLinkedIds sits alongside them as shared aria-controls / aria-labelledby
bookkeeping — it holds no user-facing state, so it is not a sixth kind.
useFlipList is not a kind either
It holds no state at all. It reads layout before a commit, reads it again after, and plays the difference back as a transform — so there is no core, no store, and no Preact twin to keep in step. It lives in the React entry because that is where its two halves (a render-phase measurement, a layout effect) have to sit.
const listRef = useFlipList({ signature: ids.join(",") })
<ul ref={listRef}>
{ids.map((id) => <li data-flip-key={id} key={id}>…</li>)}
</ul>
Two apps had grown this independently — queuepilot's poster grid and Docket's phase
list — which is the threshold for it belonging here rather than in either of them.
Duration and easing come from --duration-normal / --easing-standard, so a re-order
moves at the same speed as everything around it and prefers-reduced-motion is already
handled: @charcuterie/tokens collapses every duration to 0ms inside that query, and
the hook returns early on a zero.
Shape
Every core is { getState, subscribe, ...commands } plus pure selectors:
import {
createVisibilityGroup,
selectIsKeyVisible,
} from "@charcuterie/logic/core"
const tabs = createVisibilityGroup()
const release = tabs.register("overview")
tabs.show("overview")
selectIsKeyVisible(tabs.getState(), "overview") // true
getState() returns a frozen object whose identity changes only when the state does.
That is load-bearing, not housekeeping: useSyncExternalStore re-renders on identity, so
a core that rebuilt its derived arrays on every read would re-render a whole listbox for
nothing — and, in the Preact binding, loop.
The React and Preact hooks flatten that into one object:
const { register, show, visibleKey } = useVisibilityGroup()
They are uncontrolled: visibleKey is an initial value, and onChange is the
observation escape hatch. See
the uncontrolled-hooks decision.
Intent, registration, and what falls out
The four member-having kinds store what the consumer asked for and derive the public answer from that plus who is currently mounted. Three properties come free:
- A value set before its member mounts is not lost. It parks in
pendingKey/pendingValue(s)and is promoted on registration — which is what makes a form's initial value survive its options mounting a tick later. - A remount round-trips. StrictMode's double mount, a route change, a virtualised list scrolling a row out and back — the intent was never discarded, so it comes back selected.
- The invariants are true by representation. "At most one visible" is one field.
"
selectedValueis always a registered option ornull" is a derivation, not a rule six commands have to remember.
Registrations are a multiset, not a set. Two things register the same key more often than you would guess, and with a plain set the first unmount unregisters it out from under the survivor.
createRovingFocus breaks the symmetry in one direction on purpose: unregistering the
focused member moves focus to its neighbour rather than parking it, because a keyboard
user whose row disappeared expects the next row.
Store injection
import { createStoreFromJotai } from "@charcuterie/logic/jotai"
useVisibility({ createStore: createStoreFromJotai(jotaiStore) })
The seam has exactly three members — get, set, subscribe — and the default is a
20-line observable ref with no dependencies. Jotai is not a dependency; see
the store-injection decision.
Testing
pnpm vitest run --project logic # cores, node
pnpm vitest run --project logic-dom # React + Preact bindings, chromium
src/conformance/ holds one model-based suite that runs against five adapters: the
core, the core over a Jotai store, the core over a @preact/signals-core store, the React
19 binding, and the Preact binding. fast-check generates random command sequences, applies
each to both a naive reference model and the real thing, and asserts after every step.
The DOM adapters mount a host component that renders null and rebuild getState() from
the last committed render — so a binding that failed to re-render reads stale and the
model catches it on the next assertion.
Mutation-checked, because a green suite that cannot fail proves nothing:
| Deliberate regression | Result |
|---|---|
multiset → plain Set in registrations.ts | 15 core properties fail |
Preact useStoreValue stops subscribing | 10 Preact properties fail, React unaffected |
Sandbox note. The
logic-domproject needs a chromium build. The agent container ships browsers for its own globally-installed Playwright at a root-owned/opt/pw-browsersand pointsPLAYWRIGHT_BROWSERS_PATHthere. As of 2026-08-24 that directory holds chromium 1234 and this repo's Playwright wants 1234, so the run works with no override — but the two agree by coincidence, not by design. Each side moves on its own schedule, and two repos in the fleet already disagree.When the run dies naming a build number that is not in
/opt/pw-browsers, install this repo's build somewhere writable rather than changing the repo:PLAYWRIGHT_BROWSERS_PATH=/tmp/pw-browsers pnpm playwright install chromium-headless-shell PLAYWRIGHT_BROWSERS_PATH=/tmp/pw-browsers pnpm vitest run --project logic-dom
/tmp, not$HOME/.cache/ms-playwright— M2 recommended that path and it was wrong even then.--dry-runon the install prints the exact revision without downloading.⚠️ Never bump this package's Playwright to match the container, and never edit
vitest.browser.config.tsfor it. The pinned version is what CI installs and what the conformance suite is measured against. ⚠️ Never report the browser suite as unrunnable — it runs fine under the override; say that you used it. Long version:docs/runbooks/agent-sandbox-runtime.mdin theagenticworkspace.
What is deliberately not here
Floating positioning, dismiss layers, focus traps, typeahead, virtualisation, and date
logic. Those arrive in M4 on @floating-ui/react, which is controlled by construction —
you pass state in, it never stores it — so VisibilityGroup stays the sole owner. Radix,
Base UI, and Ark UI all own open themselves, which is the conflict this package exists to
avoid.
Priority layouts
selectPriorityLayout from @charcuterie/logic/core compares measured layout
candidates. Each section declares a numeric priority, allocated width and
height, optional minimum dimensions, and an optional media aspectRatio.
Required content fits first; useful area is then maximized in descending
priority order. idealArea caps a section once it has enough space. Candidate
order breaks ties. The function is framework independent; apps own their
measurements, content, and rendering.
Optional sections declare a separate numeric visibilityPriority and a candidate
may mark them isHidden. Visible content must fit its minimum dimensions first.
Among fitting candidates, higher visibility priorities survive before lower ones;
focus priority then spends the remaining space. Hidden optional sections consume
no minimum dimensions and contribute no useful area. Omit visibilityPriority
for required sections such as controls. Apps enumerate measured visibility states;
the selector never changes the candidate or silently clips content.
Charts
@charcuterie/logic/charts exposes the shared TanStack chart definition and standalone SVG renderer. /charts/react and /charts/preact expose ChartPlot for native framework surfaces, with common ChartOptions, hover/touch/keyboard interaction, and optional reduced-motion-aware transitions. TanStack is pinned to 0.18.0 while alpha. Importing the core or existing bindings does not load this engine.
The existing renderChartSvg in /core remains the portable fallback for constrained clients. Both engines accept grouped bars, signed stacks (barLayout: "stacked"), lines, and null samples. Null is unavailable, never zero. Apps supply series and labels; no engine performs requests or calculates report data.
Compact portable chart previews
renderChartSvg accepts isSparkline: true for small, always-visible trends in
constrained displays. Pass the preview's width and height; the SVG stretches
into its container without axis labels or dense point markers. Missing values
still break lines, isolated observations remain visible, negative values remain
in the domain, and the title and description remain accessible. Full charts keep
their existing default presentation. Interactive chart facades continue to render
full detail; isSparkline applies to the portable renderer.