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 <noreply@anthropic.com>
This commit is contained in:
jules
2026-06-11 18:42:51 +10:00
commit b5ccdbe903
2 changed files with 692 additions and 0 deletions

45
README.md Normal file
View File

@@ -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
<RecordGroupSection label="Identity" locked>
<RecordCard record={summary} icon="passport" onClick={open} />
</RecordGroupSection>
// Read view of fields (registry order, blanks skipped)
<FieldTable fields={kind.fields} data={record.data} />
// Spec-driven add/edit inputs; KeyValueEditor for free-form `other`
<FieldInputs fields={kind.fields} values={draft} onChange={set} />
// 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).

647
src/index.tsx Normal file
View File

@@ -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<string, FC<{ className?: string }>> = {
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 <Icon className={className} aria-hidden />;
}
/* ------------------------------------------------------------------ */
/* 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 (
<span
data-slot="tier-badge"
data-tier="private"
title="Only you can open this — it's locked with your passkey"
className={cn(
"inline-flex shrink-0 items-center gap-1 rounded-full bg-foreground/8 px-2 py-0.5 text-[0.7rem] font-medium text-foreground/70",
className,
)}
>
<Lock className="size-3" /> Private
</span>
);
}
return (
<span
data-slot="tier-badge"
data-tier="open"
title="Your assistant can use this record"
className={cn(
"inline-flex shrink-0 items-center gap-1 rounded-full bg-primary/10 px-2 py-0.5 text-[0.7rem] font-medium text-primary",
className,
)}
>
<Sparkles className="size-3" /> Assistant
</span>
);
}
/* ------------------------------------------------------------------ */
/* 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 (
<button
type="button"
data-slot="record-card"
onClick={onClick}
className={cn(
"group flex w-full items-center gap-3 rounded-xl border bg-card p-3 text-left transition-all",
"hover:border-ring/50 hover:shadow-sm",
"focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none",
className,
)}
>
<span
className={cn(
"grid size-11 shrink-0 place-items-center rounded-lg",
record.tier === "private"
? "bg-foreground/[0.05] text-foreground/70"
: "bg-primary/10 text-primary",
)}
>
<RecordKindIcon icon={icon} className="size-5" />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-foreground">
{record.title || record.label || record.kind}
</span>
<span className="mt-0.5 flex items-center gap-2 text-xs text-muted-foreground">
<span className="truncate">{record.label ?? record.kind}</span>
{record.attachment_count ? (
<span className="inline-flex shrink-0 items-center gap-0.5">
<Paperclip className="size-3" />
{record.attachment_count}
</span>
) : null}
</span>
</span>
<TierBadge tier={record.tier} />
</button>
);
}
/* ------------------------------------------------------------------ */
/* 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 (
<section data-slot="record-group" className={cn("space-y-2", className)}>
<div className="flex items-baseline gap-2 px-1">
<h3 className="text-xs font-semibold tracking-wide text-muted-foreground uppercase">
{label}
</h3>
{locked ? (
<span className="inline-flex items-center gap-1 text-[0.7rem] text-muted-foreground/80">
<Lock className="size-3" /> only you can open these
</span>
) : null}
</div>
<div className="grid gap-2 sm:grid-cols-2">{children}</div>
</section>
);
}
/* ------------------------------------------------------------------ */
/* 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<string, unknown>;
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 (
<p className={cn("text-sm text-muted-foreground", className)}>
No details recorded yet.
</p>
);
}
return (
<dl
data-slot="field-table"
className={cn("divide-y divide-border", className)}
>
{rows.map((r) => (
<div key={r.label} className="flex items-baseline gap-4 py-2">
<dt className="w-2/5 shrink-0 text-xs text-muted-foreground">
{r.label}
</dt>
<dd className="num min-w-0 flex-1 text-sm break-words text-foreground">
{r.value}
</dd>
</div>
))}
</dl>
);
}
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<string, string>;
onChange: (name: string, value: string) => void;
className?: string;
}) {
return (
<div
data-slot="field-inputs"
className={cn("grid gap-3 sm:grid-cols-2", className)}
>
{fields.map((f) => (
<label
key={f.name}
className={cn(
"block space-y-1",
f.type === "longtext" && "sm:col-span-2",
)}
>
<span className="text-xs font-medium text-muted-foreground">
{f.label}
</span>
{f.type === "longtext" ? (
<textarea
rows={3}
className={inputBase}
value={values[f.name] ?? ""}
onChange={(e) => onChange(f.name, e.target.value)}
/>
) : (
<input
type={
f.type === "date"
? "date"
: f.type === "number"
? "number"
: "text"
}
className={inputBase}
value={values[f.name] ?? ""}
onChange={(e) => onChange(f.name, e.target.value)}
/>
)}
</label>
))}
</div>
);
}
/** Free-form key/value editor for kinds with no fixed spec (`other`). */
export function KeyValueEditor({
entries,
onChange,
className,
}: {
entries: { key: string; value: string }[];
onChange: (entries: { key: string; value: string }[]) => void;
className?: string;
}) {
const set = (i: number, patch: Partial<{ key: string; value: string }>) =>
onChange(entries.map((e, j) => (j === i ? { ...e, ...patch } : e)));
return (
<div data-slot="key-value-editor" className={cn("space-y-2", className)}>
{entries.map((e, i) => (
<div key={i} className="flex items-center gap-2">
<input
placeholder="Field"
className={cn(inputBase, "w-2/5")}
value={e.key}
onChange={(ev) => set(i, { key: ev.target.value })}
/>
<input
placeholder="Value"
className={cn(inputBase, "flex-1")}
value={e.value}
onChange={(ev) => set(i, { value: ev.target.value })}
/>
<button
type="button"
aria-label="Remove field"
onClick={() => onChange(entries.filter((_, j) => j !== i))}
className="grid size-8 shrink-0 place-items-center rounded-lg text-muted-foreground hover:bg-accent hover:text-foreground"
>
<Trash2 className="size-4" />
</button>
</div>
))}
<button
type="button"
onClick={() => onChange([...entries, { key: "", value: "" }])}
className="inline-flex items-center gap-1.5 rounded-lg px-2 py-1 text-xs font-medium text-primary hover:bg-primary/10"
>
<Plus className="size-3.5" /> Add field
</button>
</div>
);
}
/* ------------------------------------------------------------------ */
/* RecordsEmpty — the marquee empty state */
/* ------------------------------------------------------------------ */
export function RecordsEmpty({
onAdd,
className,
}: {
onAdd?: () => void;
className?: string;
}) {
return (
<div
data-slot="records-empty"
className={cn(
"flex flex-col items-center gap-3 px-6 py-14 text-center",
className,
)}
>
<span className="grid size-14 place-items-center rounded-2xl bg-primary/10 text-primary">
<ShieldCheck className="size-7" />
</span>
<div className="max-w-md space-y-1.5">
<h3 className="text-base font-semibold text-foreground">
Keep your important records in one place
</h3>
<p className="text-sm leading-relaxed text-muted-foreground">
Passport, licence, Medicare, memberships saved as real details, not
just photos. Identity and medical records are locked with your
passkey: not your apps, not your assistant, not even us can open them
without you.
</p>
</div>
{onAdd ? (
<button
type="button"
onClick={onAdd}
className="mt-1 inline-flex items-center gap-1.5 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
<Plus className="size-4" /> Add your first record
</button>
) : null}
</div>
);
}
/* ------------------------------------------------------------------ */
/* renderRecordMd — client mirror of the server's Markdown renderer */
/* ------------------------------------------------------------------ */
/** Renders record fields as the Markdown form the assistant consumes —
* the same generic layout the server produces for open records, used
* client-side when presenting a decrypted private record. */
export function renderRecordMd(
kindLabel: string,
title: string,
fields: FieldSpec[],
data: Record<string, unknown>,
): string {
const heading = `# ${title.trim() || kindLabel}\n\n*${kindLabel}*\n`;
const rows: [string, string][] =
fields.length > 0
? fields.flatMap((f) => {
const v = data[f.name];
return isBlankValue(v)
? []
: [[f.label, mdValue(v)] as [string, string]];
})
: Object.keys(data)
.sort()
.flatMap((k) => {
const v = data[k];
return isBlankValue(v)
? []
: [[humanize(k), mdValue(v)] as [string, string]];
});
if (rows.length === 0) return heading;
const table = [
"| Field | Value |",
"| --- | --- |",
...rows.map(([l, v]) => `| ${l} | ${v} |`),
];
return `${heading}\n${table.join("\n")}\n`;
}
function mdValue(v: unknown): string {
if (typeof v === "boolean") return v ? "yes" : "no";
return String(v).replace(/\|/g, "\\|").replace(/\n/g, " ");
}
/* ------------------------------------------------------------------ */
/* parseMrzTd3 — passport machine-readable zone (2 × 44 chars) */
/* ------------------------------------------------------------------ */
export interface MrzResult {
valid: boolean;
/** Per-field check-digit failures (empty when valid). */
errors: string[];
/** Extracted fields keyed by the passport kind's field names. */
fields: {
passport_number?: string;
full_name?: string;
nationality?: string;
date_of_birth?: string;
sex?: string;
expiry_date?: string;
issuing_authority?: string;
};
}
const MRZ_VALUES: Record<string, number> = (() => {
const m: Record<string, number> = { "<": 0 };
for (let i = 0; i <= 9; i++) m[String(i)] = i;
for (let i = 0; i < 26; i++) m[String.fromCharCode(65 + i)] = 10 + i;
return m;
})();
function mrzCheckDigit(s: string): number {
const weights = [7, 3, 1];
let sum = 0;
for (let i = 0; i < s.length; i++) {
sum += (MRZ_VALUES[s[i]] ?? 0) * weights[i % 3];
}
return sum % 10;
}
function mrzDate(yymmdd: string, kind: "dob" | "expiry"): string | undefined {
if (!/^\d{6}$/.test(yymmdd)) return undefined;
const yy = Number(yymmdd.slice(0, 2));
// Expiry dates are always in the future-ish window (passports run ≤10
// years); birth dates older than "this year" belong to the 1900s.
const nowYY = new Date().getFullYear() % 100;
const century = kind === "expiry" ? 2000 : yy > nowYY ? 1900 : 2000;
return `${century + yy}-${yymmdd.slice(2, 4)}-${yymmdd.slice(4, 6)}`;
}
/** Parses a TD3 (passport) MRZ — the two 44-character lines at the bottom
* of the photo page. Pure text parsing, fully local: nothing leaves the
* device. Verifies the per-field check digits; `valid` is true only when
* they all pass (fields are still returned for review when they don't). */
export function parseMrzTd3(input: string): MrzResult | null {
const lines = input
.toUpperCase()
.replace(/ /g, "")
.split(/[\r\n]+/)
.map((l) => l.trim())
.filter(Boolean);
if (lines.length < 2) return null;
// Tolerate slightly short lines (missed trailing fillers) by padding.
const l1 = lines[0].padEnd(44, "<").slice(0, 44);
const l2 = lines[1].padEnd(44, "<").slice(0, 44);
if (!l1.startsWith("P")) return null;
const errors: string[] = [];
const issuer = l1.slice(2, 5).replace(/</g, "");
const nameField = l1.slice(5);
const [surnamePart, givenPart] = nameField.split("<<");
const surname = (surnamePart ?? "").replace(/</g, " ").trim();
const given = (givenPart ?? "").replace(/</g, " ").trim();
const fullName = [given, surname].filter(Boolean).join(" ");
const number = l2.slice(0, 9);
const numberCheck = l2[9];
const nationality = l2.slice(10, 13).replace(/</g, "");
const dob = l2.slice(13, 19);
const dobCheck = l2[19];
const sex = l2[20];
const expiry = l2.slice(21, 27);
const expiryCheck = l2[27];
if (String(mrzCheckDigit(number)) !== numberCheck)
errors.push("passport number");
if (String(mrzCheckDigit(dob)) !== dobCheck) errors.push("date of birth");
if (String(mrzCheckDigit(expiry)) !== expiryCheck) errors.push("expiry date");
return {
valid: errors.length === 0,
errors,
fields: {
passport_number: number.replace(/</g, "") || undefined,
full_name: fullName || undefined,
nationality: nationality || undefined,
date_of_birth: mrzDate(dob, "dob"),
sex: sex === "M" || sex === "F" ? sex : undefined,
expiry_date: mrzDate(expiry, "expiry"),
issuing_authority: issuer || undefined,
},
};
}