@ai-matrx/records-ui
v0.116.17
Published
The thirty-one canonical screens of the AI Matrx record store: grid, generated record form, field editor, table settings, relation picker, peek, views, import/export, custom fields, action inbox, history, comments, forms, charts, documents, record chat, A
Maintainers
Readme
@ai-matrx/records-ui
The platform's primary record UI components. Every component here reads and
writes ONLY through @ai-matrx/records, renders with
@ai-matrx/design-system's own primitives and data table, and carries
@ai-matrx/alchemy's menu where a person reaches for one.
There is no bespoke per-feature record UI in this platform. A feature that needs a grid, a form, a picker or a peek mounts one of these — and any one usage is a few lines.
Bind once
import { RecordsProvider } from "@ai-matrx/records/react";
import { RecordsUiProvider } from "@ai-matrx/records-ui";
import "@ai-matrx/design-system/styles.css";
<RecordsProvider config={{ dataSource: supabase, organizationId, actor: { actor: "user", user_id } }}>
<RecordsUiProvider value={{ rights: (table) => myRights(table), density: "condensed" }}>
{children}
</RecordsUiProvider>
</RecordsProvider>;rights is the port that decides whether a person sees the administering
controls. Unbound, it answers "no" WITH A REASON, and every such control is
ABSENT — this package ships no disabled variant of any of them.
Then, a few lines each
import {
Grid, RecordForm, FieldEditor, TableSettings, RelationPicker,
Peek, ImportWizard, ExportMenu, CustomFieldsSection,
} from "@ai-matrx/records-ui";
<Grid tableId={id} onOpenRecord={setPeeking} onAddField={openSettings} /> // records as rows, fields as columns
<RecordForm tableId={id} /> // create, generated from the Field records
<RecordForm tableId={id} recordId={recordId} /> // edit, with the conflict shown when someone wins
<Peek tableId={id} recordId={recordId} onClose={close} /> // any custom table's record, no per-table code
<TableSettings tableId={id} /> // rename · reorder · retype · delete
<FieldEditor tableId={id} field={field} /> // one Field's type, rules and sensitivity
<ImportWizard tableId={id} onProposeField={propose} /> // map columns, preview rows, propose the rest
<ExportMenu tableId={id} label="Deals" /> // CSV · XLSX · Alchemy
<CustomFieldsSection tableId={entityTableId} recordId={record.id} /> // THE one line a standard entity page addsRelationPicker is used by RecordForm automatically for any relation field;
mount it directly when a screen needs just the picker.
Views, the queue, history and comments
<ViewBar tableId={id} onActiveView={setView} seed={[{ name: "By stage", subject: id, layout: "kanban", groupField: "stage" }]} />
<ViewSwitcher view={view} /> // grid · kanban · calendar · gallery, one membership Rule
<ActionInbox tableId={id} seed={[...]} /> // approvals, assignments and agent proposals, one queue
<HistoryPanel tableId={id} recordId={recordId} /> // the timeline IS the record; undo is the store's soft delete
<CommentThread tableId={id} recordId={recordId} /> // a comment is a record
<FormBuilder tableId={id} seed={[form]} /> <FormRunner form={form} /> // a form is a view on a real Table
<DashboardCanvas tableId={id} seed={[dashboard]} /> <ChartBlock spec={chart} /> // every block is one `record_aggregate`
<NotifyRuleEditor tableId={id} /> // a subscription is a Rule record over a saved view
<DocTemplate tableId={id} seed={[tpl]} /> <DocRender templateId={t} recordId={r} /> // tokens resolved by the store
<SignBlock tableId={id} recordId={r} render={render} /> // the signature is checked against the exact bytesTalking to a record, and letting an agent keep it filled
<RecordChat tableId={id} recordId={recordId} /> // the HOST's chat surface, this record as its context
<EnrichPanel tableId={id} recordId={recordId} /> // per-field AI fill, freshness and provenanceRecordChat ships no chat UI. AI Matrx has exactly one — matrx-frontend's
AgentConversationColumn — and it reads that app's Redux store, so a package
that imported it would stop being a package and a second one here would drift
within a month. RecordChat builds the part only the record store can build:
the record read through the read door, each value carrying its Field's unit and
format (the agent is given $48,000, never a bare 48000), the fields the
store MASKED named out loud, and the secret and on_demand ones held back
with the reason. Then it hands that to the chat port:
<RecordsUiProvider value={{
chat: (ctx) => {
const { conversationId } = useAgentLauncher(agentId, {
surfaceKey: ctx.surfaceKey,
runtime: { context: Object.fromEntries(ctx.entries.map((e) => [e.key, e.value])) },
});
return <AgentConversationColumn conversationId={conversationId} surfaceKey={ctx.surfaceKey} />;
},
}}>Resolve the agent through a Mandate, never a pinned UUID. Unbound, the panel is absent with its reason and draws no composer.
EnrichPanel reads which Fields an agent keeps filled from the Fields
themselves (source = agent) and how often from review_interval_days, and
dates each value off its own envelope — never off the row's updated_at, which
would call every field stale because one of them changed. The fill itself goes
through the server's records tool, because a write stamped actor agent
on behalf of a person can only honestly be made by the server:
<RecordsUiProvider value={{
enrich: (ask) => fetch("/api/ai/tools/execute", { method: "POST", body: JSON.stringify({
tool_name: "records", surface: "records-ui:enrich",
arguments: { action: "record_write", record_id: ask.recordId, values: {/* the agent's answers */} },
})}).then((r) => r.json()).then((r) => ({ ok: r.ok, message: r.output })),
}}>Two live switches sit in front of that call today and the panel names both
rather than spinning: the knob custom/code_paths_enabled resolves false, and
the records row in tool.binding is is_active = false.
Work: checklists, bookings and a phone with no signal
<ChecklistRunner tableId={id} seed={[{ name: "Onboarding", subject: id, steps: [{ title: "Confirm the address" }] }]} />
<BookingSlots tableId={id} form={bookingForm} availability={{ fromHour: 9, toHour: 17, everyMinutes: 60, days: 5 }} />
<CaptureSheet tableId={id} readingField="amount" noteField="title" />ChecklistRunner makes every step a real record — assignable, commentable,
exportable, historied — and the whole graph is created in ONE act
(custom.work_template_instantiate judges it before writing any of it), so a
half-made checklist is not a state this store can be in. The states and the
moves offered come from custom.work_states(); a move the store does not list
as next is not shown greyed out, it is not there.
BookingSlots holds the slot first and takes the details second — taking
details first is how two people fill in a form and one of them finds out at the
end. One hold per slot is a UNIQUE INDEX in the database, not a check this
screen makes, so the loser of a race reads the database's own sentence naming
the index that refused it. Holds live in a slots Table the component declares
through custom.work_slots_declare; if the store cannot build that index it
refuses, and this screen shows the refusal rather than a picker that would sell
the same hour twice.
CaptureSheet is the offline capture contract, and both halves matter: the
client mints the id the moment a person hits Capture — offline, before the
first attempt, never re-minted on retry — and the store dedupes on it, so a
reconnecting phone that fires the same capture thirty times writes one record
and gets the same id back every time. Bind captureQueue to keep the queue
across a reload; unbound, it is in memory for the life of the page and says so.
Outsiders: a portal, a public page, an embed
<PortalShell tableId={id} form={intakeForm} /> // their list, their record, their form
<PublicViewPage slug={params.slug} /> // a signed-out visitor's page
<EmbedFrame tableId={id} formId={formId} embedUrl="https://app.example/embed" /> // issue the token
const { binding, error } = useEmbedHandshake({ secret, requiredMode: "read" }); // and verify it, inside the iframePortalShell asks iam who the person is rather than deciding for itself:
"external" means no membership of a non-personal organization, because
signup mints everyone a personal one and the other reading makes nobody
external. What they reach is iam.external_principal_reach — exactly the
resource ids Visibility handed them, one at a time, never "their
organization's records".
PublicViewPage renders a publish binding, and a share is not a publish: an
address nobody made, one that was revoked, and one whose lane closed all resolve
to the same nothing, because an address that answered differently for a revoked
binding would be an existence oracle. Every read carries the store's
"not scanned" note (D-15) — one function, so the page, the API and the admission
row cannot drift into three different admissions. Reaching the published happy
path needs a claimed namespace and a verified publisher, which is a person
standing behind what goes on the open internet, not something a screen may forge.
EmbedFrame mints a token through custom.anon_token_issue, shows the secret
once (only its digest is stored), and states the rules before the refusal: a
token carries one decision — read or write, never both — and its origin list is
required and matched exactly, so example.com.evil.test fails a check a suffix
match would pass.
What every one of them does the same way
- The store's own sentence. A refusal is never re-worded. "Someone else changed this record while you were working on it" is what the person reads, because it is what the store said, and the conflict comes with it field by field.
- Absent, never dead. A control a person may not use is not rendered. There is no disabled "+" and no greyed settings panel anywhere in this package.
- A Field decides its editor. The thirteen parity types come from the store
(
custom.parity_field_types()), generated into@ai-matrx/records. The unit and the format are on the Field because they change what the value MEANS. - Nothing is dropped quietly. An unmapped import column is shown and offered as a field proposal; a list that is not live says so; a door that does not exist yet is named, with the lane that owes it.
- Dense, one-row headers, light and dark, iOS-safe, through the design system's own tokens — this package sets no colours of its own.
The demo
pnpm demo runs every component against the one live store, headless, on
this lane's own port (127.0.0.1:3005), and stands its own table, fields and
records up the first time it runs. Its data seam is the server lane the platform
uses today, in one transaction per request that opens the product switch, acts
as the test identity ([email protected]) through the store's own doors, and sets
every switch back before committing — so no other session ever observes one
open. pnpm test renders those same pages in jsdom against that server and
asserts what a person would see.
It reads the live store deliberately: custom.anon_*, the embed token, the
external principal and the publish binding exist only there, and a demo pointed
at a rehearsal copy would answer door_absent for a third of this package and
call it a finding.
The thirty-one components
| | | |
|---|---|---|
| Grid | RecordForm | FieldEditor |
| TableSettings | RelationPicker | Peek |
| ViewSwitcher | ViewBar | ImportWizard |
| ExportMenu | CustomFieldsSection | ActionInbox |
| ProposalRow | HistoryPanel | CommentThread |
| FormBuilder | FormRunner | ChartBlock |
| DashboardCanvas | NotifyRuleEditor | DocTemplate |
| DocRender | SignBlock | RecordChat |
| EnrichPanel | ChecklistRunner | BookingSlots |
| CaptureSheet | PortalShell | PublicViewPage |
| EmbedFrame | | |
Commands
| Command | What it does |
|---|---|
| pnpm demo | Every component, live on the rehearsal branch, headless |
| pnpm test | The demo pages asserted headlessly |
| pnpm build | tsup → dist (esm + cjs + d.ts), then the client boundary stamp |
| pnpm check:package | build · exports agreement · publint · tarball canary |
