@charcuterie/logic

中文文档
2.12.0发布于 2026/11/02MITTypeScriptES Module构建来源已验证近 30 天下载 5835

The five Charcuterie state kinds — Visibility, VisibilityGroup, SinglePicker, MultiplePicker, RovingFocus, and Status — as framework-free cores with React and Preact bindings.

原始使用文档
文档目录11 节
  1. @charcuterie/logic/query — the data layer
  2. The five kinds
  3. useFlipList is not a kind either
  4. Shape
  5. Intent, registration, and what falls out
  6. Store injection
  7. Testing
  8. What is deliberately not here
  9. Priority layouts
  10. Charts
  11. Compact portable chart previews

@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

KindQuestion it answersUsed by
createVisibilityIs this shown?Modal, Drawer, Popover, Tooltip, Disclosure
createVisibilityGroupWhich one is shown?Tabs, Accordion, a Menu's submenus
createSinglePickerWhich one is chosen?Radio, Select, segmented control
createMultiplePickerWhich set is chosen?Checkbox group, multi-select filter
createRovingFocusWhich one is tabbable?Listbox, Menu, Toolbar, Tab list
createStatusWhere 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. "selectedValue is always a registered option or null" 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 regressionResult
multiset → plain Set in registrations.ts15 core properties fail
Preact useStoreValue stops subscribing10 Preact properties fail, React unaffected

Sandbox note. The logic-dom project needs a chromium build. The agent container ships browsers for its own globally-installed Playwright at a root-owned /opt/pw-browsers and points PLAYWRIGHT_BROWSERS_PATH there. 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-run on the install prints the exact revision without downloading.

⚠️ Never bump this package's Playwright to match the container, and never edit vitest.browser.config.ts for 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.md in the agentic workspace.

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.

版本与发布记录

@charcuterie/logic 共发布 22 个版本,首次发布于 2026/07/31;过去 12 个月发布了 22 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
2.12.0latest2026/11/027 天前
2.11.02026/10/1624 天前
2.10.02026/10/051 个月前
2.9.02026/10/021 个月前
2.8.02026/10/021 个月前
2.7.02026/10/021 个月前

关于 @charcuterie/logic 的常见问题

@charcuterie/logic 是什么?

The five Charcuterie state kinds — Visibility, VisibilityGroup, SinglePicker, MultiplePicker, RovingFocus, and Status — as framework-free cores with React and Preact bindings.

如何安装 @charcuterie/logic?

在项目目录运行 npm install @charcuterie/logic,或使用 pnpm add / yarn add / bun add。

@charcuterie/logic 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@charcuterie/logic 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@charcuterie/logic 可以商用吗?

@charcuterie/logic 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@charcuterie/logic 的下载量有多少?

根据 npm 官方下载统计,@charcuterie/logic 在 2026/10/10 – 2026/11/08 这 30 天内被下载 5,835 次,日均约 195 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@charcuterie/logic 还在维护吗?

最新版本 2.12.0 发布于 2026/11/02(7 天前);至今共发布 22 个版本,其中过去 12 个月发布了 22 个;首次发布于 2026/07/31。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。