@charcuterie/logic
v2.3.0
Published
The five Charcuterie state kinds — Visibility, VisibilityGroup, SinglePicker, MultiplePicker, RovingFocus, and Status — as framework-free cores with React and Preact bindings.
Downloads
7,080
Readme
@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") // truegetState() 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
yarn vitest run --project logic # cores, node
yarn vitest run --project logic-dom # React + Preact bindings, chromiumsrc/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 yarn playwright install chromium-headless-shell PLAYWRIGHT_BROWSERS_PATH=/tmp/pw-browsers yarn 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.
