@ai-matrx/detail
v0.1.170
Published
THE Detail primitive: ONE registration per record type — header, source health strip, fields, associations, history — wrapped once so it shows as a window (the default), a docked side panel, or a page, with the same content in all three. Owns the presenta
Maintainers
Readme
@ai-matrx/detail — the Detail primitive
Arman, 2026-09-17: "a core component that then shows up as a Page, a flexible drawer, and a window panel. The default is the window. … you just make a core component with some core rules, then wrap it in this reusable thing from the package, which then instantly gives you all 3 for free."
ONE registration per record type → three presentations for free, with the same content in all
three. Keyboard: Escape closes, the arrows move between records ([ / ] are aliases), and
Cmd/Ctrl+Enter saves.
| Presentation | What it is | What the host binds |
|---|---|---|
| window (the default) | Floating, draggable, minimizable; a bottom sheet on phones | shells.Window — the host's window manager |
| docked | A resizable side panel docked to the right edge, no backdrop; a bottom sheet on phones | shells.Docked — the host's side-panel surface |
| page | A full route body under the app header — the only presentation that changes the URL; offers the window back | shells.Page — the host's route header + scroll body |
Champions: Notion (side peek / center peek / full page, per-database "open pages in") and Linear (peek, keyboard-first). Bones matched, skin ours.
pnpm add @ai-matrx/detail # every Matrx repo declares it "latest" (THE LATEST LAW)Adoption status
matrx-frontend installs this package from npm at latest (adopted 2026-09-30); its former
in-repo copy and the byte-parity guard that kept the two even are deleted. A fix to the primitive
is made here, released, and reaches the frontend by version. The not-found state renders
design-system's ErrorActions slot, so a host's error actions (the frontend's Alchemy menu)
appear there without a host-specific body.
| Entry | Ships | "use client" |
|---|---|---|
| @ai-matrx/detail | the record-type contract, the presentation setting shape and its per-type override, the list-context trim with the final-URL byte budget, the deep-link spelling of every presentation, the row → fields formatter | no — pure, legal in a Server Component |
| @ai-matrx/detail/react | the ports and provider, the core, the keyboard model, the loader and the health resolver, the one opener, the header halves, the body, the setting pane, the three presentations, the header geometry constants | yes |
| @ai-matrx/detail/testing | the stub-port seat: makePortsWith(fn), mount, instance, clickByLabel, three stub shells, FILE_TYPE — the seat the package proves itself with, for a host to prove its binding with | yes |
The contract — one registration per record type
import type { DetailRecordType } from "@ai-matrx/detail";
const task: DetailRecordType = {
type: "task", label: "Task", icon: TaskIcon, accent: null,
entityToken: "task", // doors · associations · history key off this; null = none
load: (id, signal) => …, // null = no single source → renders from the seed, and says so
title: (row, seed) => …,
fields: (row) => […],
health?: (row, ctx) => …, // the source health strip for a SYNCED record (sync or async)
associationTokens?: […], // null hides the section; omitted → the host default minus self
history?: true, // default: when entityToken is set
extraSections?: (row, ctx) => […],
Frame?: ({ ctx, children }) => …, // wraps the body: surface runtime, right-click menu, …
};Fixed sections, in order: source health strip (synced records only) · about · fields ·
associations (AssociationCardGrid under a PrimaryEntityProvider, from
@ai-matrx/associations) · history (row versions) · extras · the "how details open" pane.
A section that does not apply is absent, never an empty box.
The one opener: useOpenDetail()({ type, id, presentation?, seed?, list? }). Without
presentation it asks the host's resolvePresentation(type) — the person's
ui.detail.default_presentation, with ui.detail.presentation_by_type read first for that
type. A resolver that raises is announced (the host's notify.error, naming the key) and the
window opens. Nothing navigates away except page.
Binding the ports
Everything app-shaped arrives through DetailHostPorts (@ai-matrx/detail/react). Providers
nest and merge: bind the light ports ONCE at boot so useOpenDetail works everywhere, and let
each presentation's lazily loaded entry add its own shell and the record-type map right above
the presentation, so the window shell never reaches a boot bundle. A missing port throws,
naming itself.
| Port | What the host injects | Required |
|---|---|---|
| resolveType(type) | THE record-type map — one registry, never a second | per presentation entry |
| usePresentationSetting(type) · resolvePresentation(type) · warmPresentation(type) | the settings ladder's answer for ui.detail.default_presentation and ui.detail.presentation_by_type (React face, awaited face, cache warmer) | yes |
| reReadPresentation(type) | a FRESH ladder read after a write, naming the rung that answers now | optional |
| savePresentation({ presentation, forType, clear? }) | the ONE writer of the setting and its per-type map entry; a refusal comes back as { ok: false, reason } | optional — absent ⇒ the pane is absent |
| open · close | the in-place presentations (window / docked) on the host's overlay system | yes |
| navigate.pageHref · toPage · back · canGoBack · toRecordHome | the page route; canGoBack answers "did THIS tab push the page" and toRecordHome replaces the page with the record's home — never a raw back() into an empty history | yes |
| shells.Window · Docked · Page | the chrome; the package fills title, titleNode, actions, children, onClose (+ onBack for the page, + list for the window's deep link) | per presentation entry |
| doors.RecordDoors · RefCell · tokenFromColumnName · isUuidValue · hasDoor | the host's entity doors (open / new tab / peek); hasDoor decides whether the absent state may claim "the controls above still open it" | yes |
| associations.defaultTokens · canAnchor | which tokens the associations section shows and which may anchor one | yes |
| history.list(token, id, signal) | row versions | yes |
| notify.error · success · copyText | the host's toast and clipboard | yes |
| reconnectSource(ref, source) | the same Reconnect the connector rows offer, for a synced record's strip | optional |
| remedy.typeMap | where the developer goes to register a type — the console remedy names it | optional |
The knobs the package READS THROUGH PORTS and never resolves itself: ui.detail.default_presentation
(enum window|docked|page, organization → user), ui.detail.presentation_by_type (json map,
same ladder), ui.detail.list_context_max_ids (organization; the host resolves it and passes
the number into encodeListQuery / detailListToUrlArgs, which clamp it to
DETAIL_LIST_CONTEXT_MAX_IDS_CEILING). Their rows are seeded by matrx-frontend's migrations.
Deep links
Window and docked deep links ride the host's ONE panel-URL mechanism under the type key
detail, instance type.id, arg as: ?panels=detail:task.<uuid>:as-window / :as-docked,
carrying the list the record was opened from (l / i / lt). The page is the host's route
(/detail/<type>/<id> in matrx-frontend) with ?l=type.id,…&i=<n><=<total>. Both spellings
are cut by ONE trim (trimListContext) against ONE budget — 8,000 characters of the FINAL
serialized URL, with what the rest of the address costs reserved — and the record says when the
list was trimmed. . : , inside a type token or an id are escaped, so an external id such
as sc-domain:example.com round-trips as the same record.
Keyboard
useDetailKeyboard, bound on every slot the presentation fills (header and body): Escape closes
from any control except an open menu / select / combobox and a text field with uncommitted
edits; ArrowUp/Down/Left/Right move between records only when the body is not the scroll target,
[ / ] always move; Cmd/Ctrl+Enter saves what a section registered — and says there is nothing
to save when nothing did.
Testing a host binding
import { makePortsWith, mount, instance, StubPageShell } from "@ai-matrx/detail/testing";
const makePorts = makePortsWith(jest.fn); // or vi.fn — the seat takes the runner's factory
const ports = makePorts({ shells: { Page: StubPageShell } });
const m = mount(<DetailPagePresentation data={instance()} />, ports);Every port is a mock, so a test asserts what the core ASKED THE HOST TO DO — which is where every recorded defect of this primitive lived.
What is deliberately NOT here
- The host's window manager, side panel and route header — they are shells, bound as ports.
- The settings ladder, the toast, the clipboard, the router, the entity registry — ports.
- An icon library: the seventeen glyphs the primitive draws are inlined SVGs (Lucide-derived, ISC). A record type's own icon is host data on its registration.
- Tailwind CSS generation: the components are styled with utility classes. Register the built
package with the consumer's Tailwind scan (
@source "../node_modules/@ai-matrx/detail/dist";in matrx-frontend'sglobals.css, the design-system precedent).
Implementation truth, file map and change history: FEATURE.md beside this file. Packaging
law: common-docs/policies/typescript-package-standard.md.
