diff --git a/src/_internal.tsx b/src/_internal.tsx index e6af34a..a7bc05b 100644 --- a/src/_internal.tsx +++ b/src/_internal.tsx @@ -4,8 +4,8 @@ // dep) so a fresh consumer needs only this lib's alias. // =========================================================================== import type { FC, ReactNode } from "react"; -import { Lock, ShieldAlert, Globe, Sparkles, UserCheck, FileText } from "lucide-react"; -import type { AssertionTier, Curation, ObjectStatus, Sensitivity } from "./types"; +import { Lock, ShieldAlert, Globe, Bot, UserCheck, FileText } from "lucide-react"; +import type { AssertionTier, Curation, ObjectStatus, PrincipalRef, Sensitivity } from "./types"; export function cn(...parts: (string | false | null | undefined)[]): string { return parts.filter(Boolean).join(" "); @@ -58,6 +58,14 @@ function rel(n: number, unit: string): string { return n <= 0 ? `${abs} ${u} ago` : `in ${abs} ${u}`; } +/** Render a principal (`"account:you"` or the service's `{type, id}` ref) as display text. */ +export function formatPrincipal(p?: string | PrincipalRef | null): string | null { + if (p == null) return null; + if (typeof p === "string") return p; + const id = typeof p.id === "string" && p.id.length > 12 ? `${p.id.slice(0, 8)}…` : String(p.id ?? ""); + return id ? `${p.type} · ${id}` : p.type; +} + /** Render a claim/JSON value compactly for cards and one-liners. */ export function renderValue(value: unknown): string { if (value == null) return "—"; @@ -82,15 +90,33 @@ export function isImageMime(mime?: string | null): boolean { const badgeBase = "inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium leading-none"; -export const Badge: FC<{ tone?: string; icon?: ReactNode; children: ReactNode; className?: string; title?: string }> = ({ - tone, - icon, - children, - className, - title, -}) => ( - - {icon} +// Semantic badge tones. The text mixes the semantic colour toward --foreground +// so it clears WCAG AA (4.5:1) on the subtle tint in BOTH light and dark mode: +// in light mode --foreground is near-black (darkens the text), in dark mode it's +// near-white (lightens it). A static black mix would fail dark mode. +export const successTone = + "bg-[color-mix(in_oklab,var(--success)_16%,transparent)] text-[color-mix(in_oklab,var(--success),var(--foreground)_22%)]"; +export const warningTone = + "bg-[color-mix(in_oklab,var(--warning)_18%,transparent)] text-[color-mix(in_oklab,var(--warning),var(--foreground)_34%)]"; +export const infoTone = + "bg-[color-mix(in_oklab,var(--info,var(--primary))_16%,transparent)] text-[color-mix(in_oklab,var(--info,var(--primary)),var(--foreground)_16%)]"; +export const dangerTone = + "bg-[color-mix(in_oklab,var(--destructive)_15%,transparent)] text-[color-mix(in_oklab,var(--destructive),var(--foreground)_16%)]"; + +export const Badge: FC<{ + tone?: string; + icon?: ReactNode; + children: ReactNode; + className?: string; + /** Mouse-hover explanation. */ + title?: string; + /** Accessible name for assistive tech; falls back to `title`. Set this so a + * screen reader announces the meaning ("Open — may go to any AI model"), + * not just the terse visible label. */ + ariaLabel?: string; +}> = ({ tone, icon, children, className, title, ariaLabel }) => ( + + {icon != null && } {children} ); @@ -104,31 +130,27 @@ const SENSITIVITY: Record, title: "Restricted — amounts and personal detail; approved AI destinations only, via redaction.", }, vault: { label: "Vault", - tone: "bg-[color-mix(in_oklab,var(--destructive)_15%,transparent)] text-destructive", + tone: dangerTone, icon: , title: "Vault — identity/medical grade; never sent to any AI model.", }, }; export const SensitivityBadge: FC<{ level: Sensitivity; className?: string }> = ({ level, className }) => { - // A row can arrive without a sensitivity (e.g. a claim/note, or older data); - // fall back to a neutral badge rather than crash — and never default an - // unknown value to "Open", which would misstate its exposure. - const s = - SENSITIVITY[level] ?? { - label: level ?? "—", - tone: "bg-muted text-muted-foreground", - icon: null, - title: "Sensitivity not set.", - }; + // A row can arrive without a sensitivity: objects inherit it from their + // collection, and claims/notes carry none. Render NOTHING in that case — a + // "—" pill reads as broken data. Never default an unknown value to "Open", + // which would misstate its exposure. + const s = SENSITIVITY[level]; + if (!s) return null; return ( - + {s.label} ); @@ -136,11 +158,15 @@ export const SensitivityBadge: FC<{ level: Sensitivity; className?: string }> = export const CurationBadge: FC<{ curation: Curation; className?: string }> = ({ curation, className }) => { const gated = curation === "gated"; + const title = gated + ? "Gated — an agent's claims stay hidden until you confirm them." + : "Live — an agent's claims appear immediately, labelled by tier."; return ( @@ -148,12 +174,12 @@ export const CurationBadge: FC<{ curation: Curation; className?: string }> = ({ }; const STATUS: Record = { - active: { label: "Active", tone: "bg-[color-mix(in_oklab,var(--success)_16%,transparent)] text-[var(--success)]" }, + active: { label: "Active", tone: successTone }, ingesting: { label: "Processing", tone: "bg-muted text-muted-foreground" }, - proposed: { label: "Proposed", tone: "bg-[color-mix(in_oklab,var(--warning)_18%,transparent)] text-[var(--warning)]" }, + proposed: { label: "Proposed", tone: warningTone }, superseded: { label: "Superseded", tone: "bg-muted text-muted-foreground" }, archived: { label: "Archived", tone: "bg-muted text-muted-foreground" }, - failed: { label: "Failed", tone: "bg-[color-mix(in_oklab,var(--destructive)_15%,transparent)] text-destructive" }, + failed: { label: "Failed", tone: dangerTone }, }; export const StatusBadge: FC<{ status: ObjectStatus; className?: string }> = ({ status, className }) => { @@ -174,13 +200,13 @@ const TIER: Record, + tone: infoTone, + icon: , title: "Asserted by an agent — not yet confirmed by a person.", }, human_confirmed: { label: "You confirmed", - tone: "bg-[color-mix(in_oklab,var(--success)_16%,transparent)] text-[var(--success)]", + tone: successTone, icon: , title: "Confirmed by a person.", }, @@ -188,9 +214,9 @@ const TIER: Record = ({ tier, className }) => { - const t = TIER[tier]; + const t = TIER[tier] ?? TIER.extracted; return ( - + {t.label} ); diff --git a/src/components-claims.tsx b/src/components-claims.tsx index 8ecfaf6..bfa92e8 100644 --- a/src/components-claims.tsx +++ b/src/components-claims.tsx @@ -4,7 +4,7 @@ // via the general claims list. Props in, callbacks out. // =========================================================================== import { type FC, type ReactNode } from "react"; -import { AlertTriangle, ArrowUpRight, Check, RotateCcw, Sparkles, X } from "lucide-react"; +import { AlertTriangle, ArrowUpRight, Check, Inbox, RotateCcw, X } from "lucide-react"; import type { Claim } from "./types"; import { Badge, TierBadge, cn, formatDate, renderValue } from "./_internal"; @@ -114,7 +114,7 @@ export const ConflictPair: FC = ({ claims, busy, onKeep, onDi
{claims.map((c, i) => (
-

v === values[i]).length === 1 && "text-[var(--warning)]")}> +

v === values[i]).length === 1 && "text-[color-mix(in_oklab,var(--warning),var(--foreground)_34%)]")}> {values[i]}

@@ -277,7 +277,7 @@ const Section: FC<{ title: string; count: number; hint: string; children: ReactN const Empty: FC = () => (
- +
diff --git a/src/components-collections.tsx b/src/components-collections.tsx index a1e7dfe..724618e 100644 --- a/src/components-collections.tsx +++ b/src/components-collections.tsx @@ -2,10 +2,10 @@ // owner — "Your corpuses" (account) then each organisation (tenant). // Props in, callbacks out; the app owns fetching + routing. // =========================================================================== -import { useState, type FC, type ReactNode } from "react"; +import { cloneElement, isValidElement, useId, useState, type FC, type ReactElement, type ReactNode } from "react"; import { Plus, Users, User, ChevronRight, Loader2 } from "lucide-react"; import type { Collection, CollectionInput, CollectionPatch, Curation, Sensitivity } from "./types"; -import { Badge, CurationBadge, SensitivityBadge, cn } from "./_internal"; +import { Badge, CurationBadge, SensitivityBadge, cn, warningTone } from "./_internal"; const SENS_ORDER: Sensitivity[] = ["open", "restricted", "vault"]; @@ -42,7 +42,11 @@ export const CollectionCard: FC = ({ collection, pendingCou {collection.claim_extraction === "auto" && ( - + Auto-claims )} @@ -50,7 +54,11 @@ export const CollectionCard: FC = ({ collection, pendingCou {collection.object_count ?? 0} {collection.object_count === 1 ? "item" : "items"} {pendingCount != null && pendingCount > 0 && ( - + {pendingCount} to review )} @@ -211,8 +219,12 @@ export const CollectionForm: FC = ({ const readOnly = isEdit && initial?.owner_type === "tenant" && !canManageTenant; const [slug, setSlug] = useState(initial?.slug ?? ""); + const [slugTouched, setSlugTouched] = useState(false); const [name, setName] = useState(initial?.name ?? ""); const [description, setDescription] = useState(initial?.description ?? ""); + + const slugify = (s: string) => + s.toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""); const [sensitivity, setSensitivity] = useState(initial?.sensitivity ?? "open"); const [curation, setCuration] = useState(initial?.curation ?? "live"); const [claimAuto, setClaimAuto] = useState((initial?.claim_extraction ?? "off") === "auto"); @@ -262,23 +274,41 @@ export const CollectionForm: FC = ({ )} + + { + setName(e.target.value); + // Auto-fill the identifier from the name until the user edits it by hand. + if (!isEdit && !slugTouched) setSlug(slugify(e.target.value)); + }} + disabled={readOnly} + placeholder="Supplier documents" + autoFocus={!isEdit} + required + /> + + {!isEdit && ( - + setSlug(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, "-"))} - placeholder="supplier-docs" + onChange={(e) => { + setSlugTouched(true); + setSlug(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, "-")); + }} + placeholder="supplier-documents" required /> )} - - setName(e.target.value)} disabled={readOnly} required /> - -