@miadi/capture-ui
v0.1.1
Published
Reusable recording UI for the capture domain — a lifecycle hook and headless, minimally-styled React components (record button, recorder panel, take list, inbox, assign-to-episode picker) over @miadi/capture-client. The record button is written once; ever
Downloads
276
Readme
@miadi/capture-ui
Reusable recording UI for the capture domain — the record button written once, so every surface (forgewright, the studio app, the Miadi app) uses the same one and restyles it freely.
Headless-first over @miadi/capture-client: one
lifecycle hook owns the state; minimally-styled components render over it with
capture-* classnames and data-* attributes as styling hooks. Vocabulary
follows the law in rispecs/capture-vocabulary.spec.md:
a capture produces takes; a take carries 0, 1, or 2 belongings
(episode_path, composition — a musical-composition slug); a take with
neither lives in the inbox, whole and complete, awaiting the human's
later choice.
The two rules every component keeps
- No component fetches episode lists. Episodes arrive as props, assembled by the host from whatever it already knows.
- No component holds a service URL. The host constructs its own
CaptureApiClient(base URL and dialect are the host's choices) and passes the instance in; results come back out through callbacks.
Client instance in, callbacks out — that is the whole contract.
What exists
| Export | What it is |
|---|---|
| useCaptureLifecycle | The lifecycle as a hook: idle → recording ⇄ paused → idle, elapsed time, error surface, belonging carried per the timing ruling |
| RecordButton | One <button>: Record when idle, Stop when rolling |
| RecorderPanel | Minimal panel: state, elapsed, take in flight, pause/resume/stop, error alert |
| TakeList | Renders takes: filename, kind badge (audio/video/midi/other), origin, duration, belonging or INBOX marker, transcription marker |
| InboxView | TakeList filtered to takes with no declared belonging, each row offering assignment |
| AssignToEpisodePicker | Assigns one take to a host-provided episode via client.register() — always carrying the take's ORIGINAL id |
| isInbox, mergeBelonging, isInboxTake, defaultRegistrationOf, formatSeconds | The belonging algebra and small helpers, exported for hosts |
| TakeBelonging, TakeView, EpisodeChoice, StoppedTake, … | The prop types |
Usage
import { CaptureApiClient } from "@miadi/capture-client"
import {
AssignToEpisodePicker,
InboxView,
RecorderPanel,
TakeList,
useCaptureLifecycle,
} from "@miadi/capture-ui"
// The HOST owns the URL and the dialect. No component ever does.
const client = new CaptureApiClient({
baseUrl: process.env.NEXT_PUBLIC_CAPTURE_URL!,
dialect: "pixel",
})
function Recorder({ episodePath }: { episodePath?: string }) {
const lifecycle = useCaptureLifecycle({
client,
onStop: (take) => {
// take.belonging is the effective declaration; take.inbox says whether
// the human deferred. Registering it — or not — is the host's move.
},
})
// Belonging is optional at every point: omit it and the take goes to
// the inbox; pass it and it freezes at start / declares at stop.
return (
<RecorderPanel
lifecycle={lifecycle}
belonging={episodePath ? { episode_path: episodePath } : undefined}
/>
)
}
function Inbox({ takes, episodes }: { takes: TakeView[]; episodes: EpisodeChoice[] }) {
// takes and episodes are assembled BY THE HOST — a device listing, a
// registry answer, a chronicle walk. This package only renders and assigns.
return (
<InboxView
takes={takes}
episodes={episodes}
client={client}
onAssigned={(take, result) => {
/* refresh the host's lists */
}}
/>
)
}What each component owes its host — and what it demands
useCaptureLifecycle({ client, tickMs?, now?, onStop? })
Demands a client with the lifecycle methods (status/start/pause/resume/stop
— CaptureApiClient fits; any mock with the same shape fits too). Owes:
statemirroring the core lifecycle,filename/startedAtfor the take in flight,elapsedSeconds(pauses excluded; holds after stop),pending, anderrorin the service's own words;start(belonging?)— the belonging (if any) is frozen before the first await, the Songbird rule, so mid-flight selection changes never attach to the wrong take;stop(belonging?)— declared fields win over frozen ones, field-wise; nothing anywhere yields an inbox take. Returns aStoppedTake(also onlastTakeand viaonStop) carrying the effective belonging;refresh()— adoptsclient.status(); the hook never fetches on mount.
The hook does not register the stopped take — it cannot know uri,
device, or origin better than the host does. It hands the host the
finished take; registering is the host's move.
RecordButton { lifecycle, belonging?, labels?, className?, disabled? }
Owes one accessible <button> (aria-pressed, data-capture-state,
disabled while pending). Demands the host's lifecycle — sharing one
lifecycle between button and panel is the point.
RecorderPanel { lifecycle, belonging?, labels?, className? }
Owes state copy, a running clock, the take's filename, pause/resume buttons
that only appear when meaningful, and a role="alert" error surface. All
copy overridable via labels.
TakeList { takes, emptyLabel?, renderActions?, onSelect?, className? }
Owes one row per TakeView: filename, kind badge (including video),
origin when known (never invented), duration as a clock, belonging spans
(data-belonging="episode" | "musical-composition"), the INBOX marker
when neither is declared, and the transcribed marker. renderActions is
the per-row slot InboxView builds on. Demands nothing but the array.
InboxView { takes, episodes, client, onAssigned?, onError?, renderAssign?, … }
Owes the filter (only takes with no declared belonging) and an assign
affordance per row. Demands host-assembled takes and episodes, and a
client with register().
AssignToEpisodePicker { take, episodes, client, onAssigned?, onError?, toRegistration?, … }
Owes a <select> of exactly the episodes passed in, an Assign button inert
until a choice exists, and the id law: the upsert always carries the
take's ORIGINAL id when one exists — assignment never mints a second name
for the same take. Wire-required fields the view lacks are filled honestly
by defaultRegistrationOf (uri ← filename, device ← "unknown"); hosts
holding fuller records override via toRegistration.
Styling
Nothing ships beyond structure. Every element carries a stable classname
(capture-record-button, capture-recorder-panel, capture-take-list,
capture-take, capture-inbox, capture-assign-picker, …) and state rides
on data-capture-state, data-take-kind, data-inbox, data-belonging —
style them, or replace the classnames via className props.
Tests
npm test — builds, then node --test test/*.test.mjs against dist/ with
jsdom + React 19, a mocked client, and no network anywhere: the same bench as
packages/ava8-react. The belonging algebra (0/1/2, never xor), the timing
ruling (freeze at start / declare at stop / defer to inbox), and the id law
are asserted on the exact records that would travel.
