fix(kb-ui): P0 provenance crash + AA badges + a11y + first-run polish

Provenance/claims carried created_by/verified_by as {type,id} principal
refs from the service, but the types said string|null and ProvenancePanel
rendered them raw → React #31 crash on every object view. Widen to
string|PrincipalRef|null and add formatPrincipal() (barrel-exported).

Badges: mode-aware semantic tones (mix toward --foreground) so success/
warning/info/danger text clears WCAG AA on the subtle tint in BOTH light
and dark; Active badge 2.81→5.25/5.67:1. SensitivityBadge renders nothing
for an unset level (objects inherit from their collection) instead of a
misleading "—" pill. TierBadge tolerates an unknown tier.

Accessibility: Badge gains ariaLabel (screen readers announce the meaning,
not just the terse label); Field links label↔control via htmlFor + useId
and wires aria-describedby to the hint; OwnerToggle gets aria-pressed;
icons aria-hidden.

First-run: CollectionForm leads with Name and auto-derives the Identifier
(was slug-first); placeholder text uses --muted-foreground. Drop the
Sparkles "AI-magic" icons from the review empty state and the agent tier
badge (Inbox / Bot instead), per the no-magic-framing principle.

Verified live on dev-knowledge.sky-ai.com.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jules
2026-07-11 08:40:24 +10:00
parent 2c921a911c
commit ac7c294753
6 changed files with 147 additions and 64 deletions

View File

@@ -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,
}) => (
<span className={cn(badgeBase, tone, className)} title={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 }) => (
<span className={cn(badgeBase, tone, className)} title={title} aria-label={ariaLabel ?? title}>
{icon != null && <span aria-hidden="true" className="inline-flex">{icon}</span>}
{children}
</span>
);
@@ -104,31 +130,27 @@ const SENSITIVITY: Record<Sensitivity, { label: string; tone: string; icon: Reac
},
restricted: {
label: "Restricted",
tone: "bg-[color-mix(in_oklab,var(--warning)_18%,transparent)] text-[var(--warning)]",
tone: warningTone,
icon: <ShieldAlert className="size-3" />,
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: <Lock className="size-3" />,
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 (
<Badge tone={s.tone} icon={s.icon} className={className} title={s.title}>
<Badge tone={s.tone} icon={s.icon} className={className} title={s.title} ariaLabel={`Sensitivity: ${s.title}`}>
{s.label}
</Badge>
);
@@ -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 (
<Badge
tone={gated ? "bg-[color-mix(in_oklab,var(--info,var(--primary))_16%,transparent)] text-[var(--info,var(--primary))]" : "bg-muted text-muted-foreground"}
tone={gated ? infoTone : "bg-muted text-muted-foreground"}
className={className}
title={gated ? "Gated — an agent's claims stay hidden until you confirm them." : "Live — an agent's claims appear immediately, labelled by tier."}
title={title}
ariaLabel={`Claim curation: ${title}`}
>
{gated ? "Gated" : "Live"}
</Badge>
@@ -148,12 +174,12 @@ export const CurationBadge: FC<{ curation: Curation; className?: string }> = ({
};
const STATUS: Record<ObjectStatus, { label: string; tone: string }> = {
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<AssertionTier, { label: string; tone: string; icon: ReactNode
},
agent_asserted: {
label: "Agent-asserted",
tone: "bg-[color-mix(in_oklab,var(--info,var(--primary))_16%,transparent)] text-[var(--info,var(--primary))]",
icon: <Sparkles className="size-3" />,
tone: infoTone,
icon: <Bot className="size-3" />,
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: <UserCheck className="size-3" />,
title: "Confirmed by a person.",
},
@@ -188,9 +214,9 @@ const TIER: Record<AssertionTier, { label: string; tone: string; icon: ReactNode
/** The trust surface — categorical, never a score ([[feedback_trust_is_human]]). */
export const TierBadge: FC<{ tier: AssertionTier; className?: string }> = ({ tier, className }) => {
const t = TIER[tier];
const t = TIER[tier] ?? TIER.extracted;
return (
<Badge tone={t.tone} icon={t.icon} className={className} title={t.title}>
<Badge tone={t.tone} icon={t.icon} className={className} title={t.title} ariaLabel={`Trust: ${t.title}`}>
{t.label}
</Badge>
);

View File

@@ -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<ConflictPairProps> = ({ claims, busy, onKeep, onDi
<div className="grid gap-3 sm:grid-cols-2">
{claims.map((c, i) => (
<div key={c.claim_id} className="flex flex-col gap-2 rounded-lg border border-border bg-card p-3">
<p className={cn("text-sm font-medium", values.filter((v) => v === values[i]).length === 1 && "text-[var(--warning)]")}>
<p className={cn("text-sm font-medium", values.filter((v) => v === values[i]).length === 1 && "text-[color-mix(in_oklab,var(--warning),var(--foreground)_34%)]")}>
{values[i]}
</p>
<div className="flex flex-wrap items-center gap-1.5">
@@ -277,7 +277,7 @@ const Section: FC<{ title: string; count: number; hint: string; children: ReactN
const Empty: FC = () => (
<div className="flex flex-col items-center gap-2 rounded-xl border border-dashed border-border py-12 text-center">
<Sparkles className="size-6 text-muted-foreground" />
<Inbox className="size-6 text-muted-foreground" aria-hidden="true" />
<p className="text-sm font-medium text-foreground">Nothing to review</p>
<p className="text-xs text-muted-foreground">Your agents' claims will appear here when there's something to confirm.</p>
</div>

View File

@@ -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<CollectionCardProps> = ({ collection, pendingCou
<SensitivityBadge level={collection.sensitivity} />
<CurationBadge curation={collection.curation} />
{collection.claim_extraction === "auto" && (
<Badge tone="bg-muted text-muted-foreground" title="New documents are scanned for claims automatically.">
<Badge
tone="bg-muted text-muted-foreground"
title="New documents are scanned for claims automatically."
ariaLabel="Auto-claims: new documents are scanned for claims automatically."
>
Auto-claims
</Badge>
)}
@@ -50,7 +54,11 @@ export const CollectionCard: FC<CollectionCardProps> = ({ collection, pendingCou
{collection.object_count ?? 0} {collection.object_count === 1 ? "item" : "items"}
</span>
{pendingCount != null && pendingCount > 0 && (
<Badge tone="bg-[color-mix(in_oklab,var(--warning)_18%,transparent)] text-[var(--warning)]" title="Claims awaiting your review.">
<Badge
tone={warningTone}
title="Claims awaiting your review."
ariaLabel={`${pendingCount} claim${pendingCount === 1 ? "" : "s"} awaiting your review.`}
>
{pendingCount} to review
</Badge>
)}
@@ -211,8 +219,12 @@ export const CollectionForm: FC<CollectionFormProps> = ({
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<Sensitivity>(initial?.sensitivity ?? "open");
const [curation, setCuration] = useState<Curation>(initial?.curation ?? "live");
const [claimAuto, setClaimAuto] = useState((initial?.claim_extraction ?? "off") === "auto");
@@ -262,23 +274,41 @@ export const CollectionForm: FC<CollectionFormProps> = ({
</Field>
)}
<Field label="Name" hint="What you'll call this corpus — e.g. “Supplier documents”.">
<input
className={inputCls}
value={name}
onChange={(e) => {
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
/>
</Field>
{!isEdit && (
<Field label="Slug" hint="Lower-case, hyphenated. Can't be changed later.">
<Field
label="Identifier"
hint="Used in links and by agents. Filled in from the name — edit if you like, but it can't be changed later."
>
<input
data-action="knowledge-corpus-slug"
className={inputCls}
className={cn(inputCls, "font-mono")}
value={slug}
onChange={(e) => 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
/>
</Field>
)}
<Field label="Name">
<input className={inputCls} value={name} onChange={(e) => setName(e.target.value)} disabled={readOnly} required />
</Field>
<Field label="Description" hint="What belongs in this corpus?">
<textarea
className={cn(inputCls, "min-h-[64px] resize-y")}
@@ -345,20 +375,39 @@ export const CollectionForm: FC<CollectionFormProps> = ({
};
const inputCls =
"w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground outline-none transition focus:border-primary/50 focus:ring-2 focus:ring-primary/30 disabled:opacity-60";
"w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground outline-none transition placeholder:text-muted-foreground focus:border-primary/50 focus:ring-2 focus:ring-primary/30 disabled:opacity-60";
const Field: FC<{ label: string; hint?: string; children: ReactNode }> = ({ label, hint, children }) => (
const Field: FC<{ label: string; hint?: string; children: ReactNode }> = ({ label, hint, children }) => {
const id = useId();
const hintId = `${id}-hint`;
// Link the label (and any hint) to the single control child for screen readers.
const control =
isValidElement(children) && (children.type === "input" || children.type === "select" || children.type === "textarea")
? cloneElement(children as ReactElement<Record<string, unknown>>, {
id,
"aria-describedby": hint ? hintId : undefined,
})
: children;
return (
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-foreground">{label}</label>
{children}
{hint && <p className="text-xs text-muted-foreground">{hint}</p>}
<label htmlFor={id} className="text-sm font-medium text-foreground">
{label}
</label>
{control}
{hint && (
<p id={hintId} className="text-xs text-muted-foreground">
{hint}
</p>
)}
</div>
);
};
const OwnerToggle: FC<{ active: boolean; onClick: () => void; icon: ReactNode; label: string }> = ({ active, onClick, icon, label }) => (
<button
type="button"
onClick={onClick}
aria-pressed={active}
className={cn(
"inline-flex flex-1 items-center justify-center gap-1.5 rounded-lg border px-3 py-2 text-sm font-medium transition",
active ? "border-primary bg-primary/10 text-primary" : "border-border text-muted-foreground hover:bg-muted",

View File

@@ -24,6 +24,7 @@ import {
cn,
formatBytes,
formatDate,
formatPrincipal,
isImageMime,
} from "./_internal";
@@ -153,7 +154,7 @@ export const ProvenancePanel: FC<{ outline: ObjectOutline; className?: string }>
const p = outline.provenance;
const rows: [string, ReactNode][] = [
["Source", p.source_ref ? `${p.source_type} · ${p.source_ref}` : p.source_type],
["Added by", p.created_by ?? "—"],
["Added by", formatPrincipal(p.created_by) ?? "—"],
["Added", formatDate(p.inserted_at)],
["Effective", p.effective_at ? formatDate(p.effective_at) : "—"],
["Last verified", p.verified_at ? formatDate(p.verified_at) : "Not confirmed"],
@@ -187,7 +188,7 @@ export const SupersessionBanner: FC<{
<div className={cn("flex flex-col gap-2", className)}>
{outline.superseded_by && (
<div className="flex items-center gap-2 rounded-lg border border-[var(--warning)]/40 bg-[color-mix(in_oklab,var(--warning)_10%,transparent)] px-3 py-2 text-sm">
<span className="text-[var(--warning)]">A newer version of this document exists.</span>
<span className="text-[color-mix(in_oklab,var(--warning),var(--foreground)_34%)]">A newer version of this document exists.</span>
<button
type="button"
data-action="knowledge-open-newer"

View File

@@ -5,8 +5,8 @@
// EXPORTS
// Types + transport: all of ./types, KnowledgeTransport, MockKnowledgeTransport
// Badges/formatters: SensitivityBadge, CurationBadge, StatusBadge, TierBadge,
// Badge, Spinner, cn, formatBytes, formatDate, formatRelative,
// renderValue, isImageMime
// Badge, Spinner, cn, formatBytes, formatDate, formatPrincipal,
// formatRelative, renderValue, isImageMime
// Collections (W3): CollectionList, CollectionCard, CollectionForm
// Object viewer (W4):ObjectList, ObjectViewer, CatalogCard, ProvenancePanel,
// SupersessionBanner, OutlineNav, TextReader, CitationLink
@@ -27,6 +27,7 @@ export {
TierBadge,
formatBytes,
formatDate,
formatPrincipal,
formatRelative,
renderValue,
isImageMime,

View File

@@ -94,10 +94,16 @@ export interface Entity {
type: string;
}
/** A principal reference as the service emits it: `{type: "account"|"app"|"agent"|…, id}`. */
export interface PrincipalRef {
type: string;
id: string;
}
export interface Provenance {
source_type: string;
source_ref?: string | null;
created_by?: string | null;
created_by?: string | PrincipalRef | null;
inserted_at?: string;
effective_at?: string | null;
verified_at?: string | null;
@@ -210,8 +216,8 @@ export interface Claim {
sensitivity: Sensitivity;
effective_at?: string | null;
verified_at?: string | null;
verified_by?: string | null;
created_by?: string | null;
verified_by?: string | PrincipalRef | null;
created_by?: string | PrincipalRef | null;
updated_at?: string;
cite?: string;
}