commit b5ccdbe903dbaab5edaf3be685460a9a332ac290 Author: jules Date: Thu Jun 11 18:42:51 2026 +1000 Initial release: personal-records primitives RecordCard / RecordGroupSection / TierBadge / RecordKindIcon, FieldTable + spec-driven FieldInputs + KeyValueEditor, the marquee RecordsEmpty, renderRecordMd (mirror of the personal cloud's server renderer), and parseMrzTd3 — a fully-local TD3 passport MRZ parser with check-digit verification (validated against the ICAO Doc 9303 specimen). Token-themed; lucide-react is the only dep (Crema shared-dep aliasing). Co-Authored-By: Claude Fable 5 diff --git a/README.md b/README.md new file mode 100644 index 0000000..3bc4e1d --- /dev/null +++ b/README.md @@ -0,0 +1,45 @@ +# @crema/records-ui + +Personal-records primitives for the personal cloud's Records surface +(arcadia-personal-cloud `docs/RECORDS.md`): kind-typed record cards, group +sections with privacy framing, field tables and spec-driven field inputs, +tier badges, the record→Markdown renderer, and a TD3 passport MRZ parser. + +This lib ships **shapes and pure logic**, not data fetching. The app owns +the API client, the passkey doc-vault crypto, and flow orchestration; this +lib renders records and parses/renders their field structures. + +## Public API + +```tsx +import { + RecordCard, RecordGroupSection, TierBadge, RecordKindIcon, + FieldTable, FieldInputs, KeyValueEditor, RecordsEmpty, + renderRecordMd, parseMrzTd3, + type RecordKindInfo, type FieldSpec, type RecordTier, +} from "@crema/records-ui" + +// Group section with the locked-group privacy framing + + + + +// Read view of fields (registry order, blanks skipped) + + +// Spec-driven add/edit inputs; KeyValueEditor for free-form `other` + + +// The Markdown the assistant consumes (mirror of the server renderer) +const md = renderRecordMd("Passport", "My passport", kind.fields, data) + +// Fully-local passport MRZ parse (two 44-char lines, check digits verified) +const mrz = parseMrzTd3(pastedText) +``` + +Field specs come from the backend registry (`GET /me/records/kinds`) — +this lib defines the types and rendering, never the vocabulary. + +Token-themed throughout (`bg-card`, `text-muted-foreground`, `--primary`); +no hex values. `lucide-react` is the only dependency (a Crema shared dep — +consumers alias it to their installed copy). diff --git a/src/index.tsx b/src/index.tsx new file mode 100644 index 0000000..a3aef18 --- /dev/null +++ b/src/index.tsx @@ -0,0 +1,647 @@ +// PURPOSE: Personal-records primitives — kind-typed record cards, group +// sections with privacy framing, field tables and spec-driven +// field inputs, tier badges, the record→Markdown renderer, and a +// TD3 passport MRZ parser. Domain UI for the personal cloud's +// Records surface (arcadia-personal-cloud docs/RECORDS.md); shapes +// here, data fetching in the app. +// =========================================================================== +// EXPORTS +// Components: RecordKindIcon, TierBadge, RecordCard, RecordGroupSection, +// FieldTable, FieldInputs, KeyValueEditor, RecordsEmpty +// Functions: renderRecordMd, parseMrzTd3, isBlankValue +// Types: RecordTier, FieldSpec, RecordKindInfo, RecordGroupInfo, +// RecordSummaryLike, MrzResult +// =========================================================================== +"use client"; + +import type { FC, ReactNode } from "react"; +import { + Award, + Book, + BookUser, + Car, + Dumbbell, + File as FileIcon, + GraduationCap, + HeartPulse, + IdCard, + Lock, + Paperclip, + Pill, + Plane, + Plus, + Receipt, + Repeat, + Scroll, + Shield, + ShieldCheck, + Sparkles, + Stethoscope, + Syringe, + Trash2, + Wrench, +} from "lucide-react"; + +/* ------------------------------------------------------------------ */ +/* Utility */ +/* ------------------------------------------------------------------ */ + +function cn(...classes: (string | false | null | undefined)[]): string { + return classes.filter(Boolean).join(" "); +} + +/* ------------------------------------------------------------------ */ +/* Types — mirror the backend registry (GET /me/records/kinds) */ +/* ------------------------------------------------------------------ */ + +export type RecordTier = "private" | "open"; + +export interface FieldSpec { + name: string; + label: string; + type: "text" | "longtext" | "date" | "number"; +} + +export interface RecordKindInfo { + kind: string; + group: string; + label: string; + icon: string; + floor: RecordTier; + default_tier: RecordTier; + locked: boolean; + schema_version: number; + fields: FieldSpec[]; +} + +export interface RecordGroupInfo { + key: string; + label: string; + floor: RecordTier; +} + +/** The slice of a record listing entry these components need. */ +export interface RecordSummaryLike { + id: string; + kind: string; + label?: string | null; + tier: RecordTier; + locked?: boolean; + title?: string | null; + attachment_count?: number; + updated_at?: string; +} + +/* ------------------------------------------------------------------ */ +/* RecordKindIcon — registry icon string → SVG */ +/* ------------------------------------------------------------------ */ + +const iconMap: Record> = { + passport: BookUser, + "id-card": IdCard, + plane: Plane, + scroll: Scroll, + "heart-pulse": HeartPulse, + stethoscope: Stethoscope, + syringe: Syringe, + pill: Pill, + shield: Shield, + badge: Award, + dumbbell: Dumbbell, + repeat: Repeat, + car: Car, + wrench: Wrench, + receipt: Receipt, + book: Book, + "graduation-cap": GraduationCap, + file: FileIcon, +}; + +export function RecordKindIcon({ + icon, + className, +}: { + icon: string | undefined; + className?: string; +}) { + const Icon = (icon && iconMap[icon]) || FileIcon; + return ; +} + +/* ------------------------------------------------------------------ */ +/* TierBadge */ +/* ------------------------------------------------------------------ */ + +/** Privacy posture at a glance. Private = lock ("only you can open + * this"); open = the assistant can use it ambiently. */ +export function TierBadge({ + tier, + className, +}: { + tier: RecordTier; + className?: string; +}) { + if (tier === "private") { + return ( + + Private + + ); + } + return ( + + Assistant + + ); +} + +/* ------------------------------------------------------------------ */ +/* RecordCard */ +/* ------------------------------------------------------------------ */ + +export function RecordCard({ + record, + icon, + onClick, + className, +}: { + record: RecordSummaryLike; + /** Registry icon string for the record's kind. */ + icon?: string; + onClick?: () => void; + className?: string; +}) { + return ( + + ); +} + +/* ------------------------------------------------------------------ */ +/* RecordGroupSection */ +/* ------------------------------------------------------------------ */ + +/** A kind-group section. Locked groups (identity/medical) carry the + * privacy framing in the header — the product promise, stated where the + * records live. */ +export function RecordGroupSection({ + label, + locked, + children, + className, +}: { + label: string; + locked?: boolean; + children: ReactNode; + className?: string; +}) { + return ( +
+
+

+ {label} +

+ {locked ? ( + + only you can open these + + ) : null} +
+
{children}
+
+ ); +} + +/* ------------------------------------------------------------------ */ +/* FieldTable — read view of a record's fields */ +/* ------------------------------------------------------------------ */ + +export function isBlankValue(v: unknown): boolean { + return v == null || (typeof v === "string" && v.trim() === ""); +} + +/** Definition-list rendering of record fields in registry order; blank + * fields are skipped. For free-form data (no spec), keys are humanised + * and sorted. */ +export function FieldTable({ + fields, + data, + className, +}: { + fields: FieldSpec[]; + data: Record; + className?: string; +}) { + const rows: { label: string; value: string }[] = + fields.length > 0 + ? fields.flatMap((f) => { + const v = data[f.name]; + return isBlankValue(v) + ? [] + : [{ label: f.label, value: formatValue(v) }]; + }) + : Object.keys(data) + .sort() + .flatMap((k) => { + const v = data[k]; + return isBlankValue(v) + ? [] + : [{ label: humanize(k), value: formatValue(v) }]; + }); + + if (rows.length === 0) { + return ( +

+ No details recorded yet. +

+ ); + } + + return ( +
+ {rows.map((r) => ( +
+
+ {r.label} +
+
+ {r.value} +
+
+ ))} +
+ ); +} + +function formatValue(v: unknown): string { + if (typeof v === "boolean") return v ? "yes" : "no"; + return String(v); +} + +function humanize(s: string): string { + const t = s.replace(/[_-]+/g, " ").trim(); + return t.charAt(0).toUpperCase() + t.slice(1); +} + +/* ------------------------------------------------------------------ */ +/* FieldInputs — spec-driven add/edit form fields */ +/* ------------------------------------------------------------------ */ + +const inputBase = + "w-full rounded-lg border border-input bg-transparent px-3 py-2 text-sm text-foreground " + + "placeholder:text-muted-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none"; + +/** Renders inputs for a kind's field spec. Values are kept as strings + * (numbers coerced by the caller on save if desired); all fields are + * optional — partial records are normal. */ +export function FieldInputs({ + fields, + values, + onChange, + className, +}: { + fields: FieldSpec[]; + values: Record; + onChange: (name: string, value: string) => void; + className?: string; +}) { + return ( +
+ {fields.map((f) => ( +