listkit
A list as a query: facets, a URL you can share, sorting that puts missing values last. No markup.
Why it exists
Directories — foundations, products, people — kept reinventing filters, and each one encoded the filter in a slightly different URL, so a view could not be sent to someone else.
How it works
A closed set of facet kinds, a URL codec that copies the query instead of rebuilding it, a comparator that sorts missing values last, and page math that cannot produce a negative offset. No React and no HTTP client.
Where it fits
Hirnli's foundation lists and Loki's project lists are the same idea. A list you can link is part of how the studio's work stays checkable.
API and examples
Documentation from source commit f64939c9ac77 · 2026-11-06. Source documentation can include work beyond the registry release.
Read the package documentation
A list as a query. Filtering, sorting, searching and paging are the same problem everywhere, and they were solved separately everywhere.
A survey of twelve repositories in this fleet on 2026-09-11 found:
| what | count |
|---|---|
| incompatible filter-state shapes | 12, no two alike |
| hand-built URL builders | ~34 |
| copies of "toggle a value in a set" | ~25, in two encodings |
| debounces, at five different delays | ~11 |
| copies of `Math.ceil(total / pageSize)` | ~11, three in one repo |
| repos that escape `%` and `_` before a SQL `LIKE` | 3 of 7 that need to |
Two of the toggle copies were character-for-character identical in repos that share no code. One repo wrote the same three-line body eight times for eight facets, plus eleven more for its boolean flags — about a hundred and twenty lines expressing one idea.
Four live bugs were among the URL builders, and this codec exists so they cannot be written again:
- one built
new URLSearchParams()from scratch, so applying a preset silently
wiped the reader's active search and five other facets;
- one changed a date range without clearing
page, landing the reader on page 4
of a two-page result;
- one preserved exactly three params by name, so every param added later had to
be remembered in six separate template strings;
- one used
pushrather thanreplacefor a debounced search box, so the back
button walked backwards through every intermediate spelling of the word.
What it ships
The decisions, and nothing else.
import { parseQuery, writeQuery, applyQuery, type ListSpec } from "@bitbaum/listkit";
const spec: ListSpec<Project> = {
facets: [
{ key: "kind", kind: "one", value: (p) => p.kind, options: ["product", "client", "demo"] },
{ key: "status", kind: "many", value: (p) => p.status, options: ["live", "prospect"] },
{ key: "nosite", kind: "flag", value: (p) => !p.site },
],
search: { text: (p) => [p.name, p.description, p.host] },
sorts: [
{ key: "name", by: [(p) => p.name] },
{ key: "newest", by: [(p) => p.since, (p) => p.name] },
],
defaultSort: "name",
defaultPageSize: 25,
};
const query = parseQuery(searchParams, spec); // validated, clamped
const { rows, matched, page, counts } = applyQuery(projects, spec, query);
const url = `?${writeQuery(searchParams, next, spec, query)}`;
Five facet kinds, and no magic sentinel
one · many · flag · range · ordinal. The value for "no filter" is the empty selection, never a magic string. One surveyed repo used the translated label ("Alle") as its all-value, which made filter identity depend on the reader's language.
Rules the tests pin
- An empty selection filters nothing, not everything.
- Missing values sort last in both directions. A record with no date is not
the oldest record.
?page=0can never become a negative offset, and an empty list still has
one page rather than "page 1 of 0".
- Facet counts are computed with that facet's own selection lifted, so while
filtering by kind=product the count beside kind=demo still says how many demos exist, instead of zero.
- Writing copies the params you were given. A locale or a referrer survives.
pageis dropped when the result set changes, kept when only the page moves.- A superseded async search resolves to
undefined, so a slow "ab" cannot
overwrite a fast "abc".
- Every search word must match, folded, and a non-match scores 0.
- A reader's chosen sort is never overridden by relevance.
Search that finds what people type
- Every word, any order.
bike sellfinds "Sell my old bike". - Accents and ß fold.
zurichfinds "Zürich",strassefinds "Straße". - Best match first. While a search is active and the reader has not picked
a sort, results rank by where a word matched — search.text lists fields most important first — and how well: the whole field, its start, a word's start, anywhere inside. A phrase match earns a bonus; ties keep the default order. A sort the reader chose wins. ?sort=relevance asks for it explicitly; search: { rank: false } opts a list out.
- One rule for SQL too.
searchTerms(q)gives the folded words; apply one
likeContains per term, ANDed, and the database agrees with the array. (Fold the column the same way — unaccent() in Postgres.)
No stemming, no typo tolerance, no index: that is a search engine's job.
What it deliberately does not ship
Markup, tokens, React, a search engine, or an HTTP client.
Nine token vocabularies and six chip treatments were found among those same repos, several different on purpose — one marketplace is monochrome by design, one product reserves an accent colour for Bitcoin UI, one is the only RTL-aware surface in the fleet. The fleet has already run the experiment where a shared renderer owns the markup: it reached two consumers out of twenty and shipped the same two defects to both, unfixable downstream because a consumer cannot patch markup it does not own.
Centralize the rule; render it locally.
The execution engine is also yours. applyQuery is the in-memory engine, for bounded collections. The same ListSpec describes what a SQL builder should do with a large one — escapeLike and likeContains are exported for exactly that. Forcing one execution model would serve one repo and strand the rest.
Install
pnpm add @bitbaum/listkit
Develop
pnpm install
pnpm run verify # format, lint, typecheck, build, test
Tests import the package by name, so a broken exports or files map fails here rather than at the first consumer's install.
MIT.
Part of bitbaum — AI-native products on open infrastructure, built in Zürich. Every package here lists the apps that use it: which apps use listkit.
Git releases include the compiled dist/ artifact and require no consumer-side prepare build. Package CI rebuilds it and refuses a mismatch with committed output, so consumers can retain pnpm's default build-script restrictions. The same public exports work for Git and npm installs.
Changelog and releases
Read the repository changelog
This retrospective record was added on 2026-10-27 from the immutable sources linked below. Git commit dates are source dates, not inferred npm publication dates. A GitHub release does not itself establish npm publication. Application adoption and deployment evidence are tracked separately.
0.2.1
- Tagged source
v0.2.1 - Source commit, dated 2026-10-25.
- GitHub release, published 2026-10-25.
Recorded release notes
What's Changed
* fix(release): ship compiled Git artifacts without prepare builds by @catomean in https://github.com/bitbaum/listkit/pull/8
Full Changelog: https://github.com/bitbaum/listkit/compare/v0.2.0...v0.2.1
Release notes
What's Changed
* chore: add the fleet package template files this repo is missing (AGENTS.md, CLAUDE.md) by @catomean in https://github.com/bitbaum/listkit/pull/9 * chore: add the fleet package template files this repo is missing (release-notify.yml) by @catomean in https://github.com/bitbaum/listkit/pull/10 * docs: record verified package release history by @catomean in https://github.com/bitbaum/listkit/pull/11 * docs(readme): install from npm, the way the registry says by @catomean in https://github.com/bitbaum/listkit/pull/12 * ci(publish): the fleet's one publish.yml, with a no-publish check of the npm link by @catomean in https://github.com/bitbaum/listkit/pull/13 * docs(agents): the org rules block, generated from bitbaum/fleet by @catomean in https://github.com/bitbaum/listkit/pull/14 * chore(deps): dependabot leaves TypeScript 7 alone until typescript-eslint supports it by @catomean in https://github.com/bitbaum/listkit/pull/15 * chore(deps-dev): bump the dev group with 5 updates by @dependabot[bot] in https://github.com/bitbaum/listkit/pull/16
New Contributors
* @dependabot[bot] made their first contribution in https://github.com/bitbaum/listkit/pull/16
Full Changelog: https://github.com/bitbaum/listkit/compare/v0.2.1...v0.2.2
Release notes
What's Changed
* fix(release): ship compiled Git artifacts without prepare builds by @catomean in https://github.com/bitbaum/listkit/pull/8
Full Changelog: https://github.com/bitbaum/listkit/compare/v0.2.0...v0.2.1
Release notes
What's Changed
* docs: link back to bitbaum, and to who uses this package by @catomean in https://github.com/bitbaum/listkit/pull/1 * feat(ci): listkit had a complete verify and nothing to run it by @catomean in https://github.com/bitbaum/listkit/pull/2 * feat(search): every word in any order, accents folded, best match first (v0.2.0) by @catomean in https://github.com/bitbaum/listkit/pull/3 * feat!: publish as @bitbaum/listkit — the bare name is taken on npm by @catomean in https://github.com/bitbaum/listkit/pull/4 * chore: add the fleet package template files this repo is missing (dependabot.yml, git-release.yml, release.yml) by @catomean in https://github.com/bitbaum/listkit/pull/5
New Contributors
* @catomean made their first contribution in https://github.com/bitbaum/listkit/pull/1
Full Changelog: https://github.com/bitbaum/listkit/compare/v0.1.0...v0.2.0
Recent source changes
Repository activity can include unreleased work; it is not a list of deployed changes.
- docs(agents): the org rules block, generated from bitbaum/fleet (#20)
- chore(deps-dev): bump the dev group with 2 updates (#19)
- chore(deps): clear dev-toolchain audit findings inside existing ranges (#18)
- docs(agents): the org rules block, generated from bitbaum/fleet (#17)
- release v0.2.2
- chore(deps-dev): bump the dev group with 5 updates (#16)
- chore(deps): dependabot leaves TypeScript 7 alone until typescript-eslint supports it (#15)
- docs(agents): the org rules block, generated from bitbaum/fleet (#14)
Where it is declared
Linked products declare this package in their manifests. A declaration alone does not verify every feature, version or deployed integration.
Sources and verification
Install commands and declared consumers come from Fleet’s generated registry. Documentation and release notes come from the package repository. The catalogue does not certify production journey coverage.
Documentation from source commit f64939c9ac77 · 2026-11-06. Source documentation can include work beyond the registry release.


