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:
@@ -4,8 +4,8 @@
|
|||||||
// dep) so a fresh consumer needs only this lib's alias.
|
// dep) so a fresh consumer needs only this lib's alias.
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
import type { FC, ReactNode } from "react";
|
import type { FC, ReactNode } from "react";
|
||||||
import { Lock, ShieldAlert, Globe, Sparkles, UserCheck, FileText } from "lucide-react";
|
import { Lock, ShieldAlert, Globe, Bot, UserCheck, FileText } from "lucide-react";
|
||||||
import type { AssertionTier, Curation, ObjectStatus, Sensitivity } from "./types";
|
import type { AssertionTier, Curation, ObjectStatus, PrincipalRef, Sensitivity } from "./types";
|
||||||
|
|
||||||
export function cn(...parts: (string | false | null | undefined)[]): string {
|
export function cn(...parts: (string | false | null | undefined)[]): string {
|
||||||
return parts.filter(Boolean).join(" ");
|
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}`;
|
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. */
|
/** Render a claim/JSON value compactly for cards and one-liners. */
|
||||||
export function renderValue(value: unknown): string {
|
export function renderValue(value: unknown): string {
|
||||||
if (value == null) return "—";
|
if (value == null) return "—";
|
||||||
@@ -82,15 +90,33 @@ export function isImageMime(mime?: string | null): boolean {
|
|||||||
const badgeBase =
|
const badgeBase =
|
||||||
"inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium leading-none";
|
"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 }> = ({
|
// Semantic badge tones. The text mixes the semantic colour toward --foreground
|
||||||
tone,
|
// so it clears WCAG AA (4.5:1) on the subtle tint in BOTH light and dark mode:
|
||||||
icon,
|
// in light mode --foreground is near-black (darkens the text), in dark mode it's
|
||||||
children,
|
// near-white (lightens it). A static black mix would fail dark mode.
|
||||||
className,
|
export const successTone =
|
||||||
title,
|
"bg-[color-mix(in_oklab,var(--success)_16%,transparent)] text-[color-mix(in_oklab,var(--success),var(--foreground)_22%)]";
|
||||||
}) => (
|
export const warningTone =
|
||||||
<span className={cn(badgeBase, tone, className)} title={title}>
|
"bg-[color-mix(in_oklab,var(--warning)_18%,transparent)] text-[color-mix(in_oklab,var(--warning),var(--foreground)_34%)]";
|
||||||
{icon}
|
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}
|
{children}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
@@ -104,31 +130,27 @@ const SENSITIVITY: Record<Sensitivity, { label: string; tone: string; icon: Reac
|
|||||||
},
|
},
|
||||||
restricted: {
|
restricted: {
|
||||||
label: "Restricted",
|
label: "Restricted",
|
||||||
tone: "bg-[color-mix(in_oklab,var(--warning)_18%,transparent)] text-[var(--warning)]",
|
tone: warningTone,
|
||||||
icon: <ShieldAlert className="size-3" />,
|
icon: <ShieldAlert className="size-3" />,
|
||||||
title: "Restricted — amounts and personal detail; approved AI destinations only, via redaction.",
|
title: "Restricted — amounts and personal detail; approved AI destinations only, via redaction.",
|
||||||
},
|
},
|
||||||
vault: {
|
vault: {
|
||||||
label: "Vault",
|
label: "Vault",
|
||||||
tone: "bg-[color-mix(in_oklab,var(--destructive)_15%,transparent)] text-destructive",
|
tone: dangerTone,
|
||||||
icon: <Lock className="size-3" />,
|
icon: <Lock className="size-3" />,
|
||||||
title: "Vault — identity/medical grade; never sent to any AI model.",
|
title: "Vault — identity/medical grade; never sent to any AI model.",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SensitivityBadge: FC<{ level: Sensitivity; className?: string }> = ({ level, className }) => {
|
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);
|
// A row can arrive without a sensitivity: objects inherit it from their
|
||||||
// fall back to a neutral badge rather than crash — and never default an
|
// collection, and claims/notes carry none. Render NOTHING in that case — a
|
||||||
// unknown value to "Open", which would misstate its exposure.
|
// "—" pill reads as broken data. Never default an unknown value to "Open",
|
||||||
const s =
|
// which would misstate its exposure.
|
||||||
SENSITIVITY[level] ?? {
|
const s = SENSITIVITY[level];
|
||||||
label: level ?? "—",
|
if (!s) return null;
|
||||||
tone: "bg-muted text-muted-foreground",
|
|
||||||
icon: null,
|
|
||||||
title: "Sensitivity not set.",
|
|
||||||
};
|
|
||||||
return (
|
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}
|
{s.label}
|
||||||
</Badge>
|
</Badge>
|
||||||
);
|
);
|
||||||
@@ -136,11 +158,15 @@ export const SensitivityBadge: FC<{ level: Sensitivity; className?: string }> =
|
|||||||
|
|
||||||
export const CurationBadge: FC<{ curation: Curation; className?: string }> = ({ curation, className }) => {
|
export const CurationBadge: FC<{ curation: Curation; className?: string }> = ({ curation, className }) => {
|
||||||
const gated = curation === "gated";
|
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 (
|
return (
|
||||||
<Badge
|
<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}
|
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"}
|
{gated ? "Gated" : "Live"}
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -148,12 +174,12 @@ export const CurationBadge: FC<{ curation: Curation; className?: string }> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const STATUS: Record<ObjectStatus, { label: string; tone: 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" },
|
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" },
|
superseded: { label: "Superseded", tone: "bg-muted text-muted-foreground" },
|
||||||
archived: { label: "Archived", 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 }) => {
|
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: {
|
agent_asserted: {
|
||||||
label: "Agent-asserted",
|
label: "Agent-asserted",
|
||||||
tone: "bg-[color-mix(in_oklab,var(--info,var(--primary))_16%,transparent)] text-[var(--info,var(--primary))]",
|
tone: infoTone,
|
||||||
icon: <Sparkles className="size-3" />,
|
icon: <Bot className="size-3" />,
|
||||||
title: "Asserted by an agent — not yet confirmed by a person.",
|
title: "Asserted by an agent — not yet confirmed by a person.",
|
||||||
},
|
},
|
||||||
human_confirmed: {
|
human_confirmed: {
|
||||||
label: "You confirmed",
|
label: "You confirmed",
|
||||||
tone: "bg-[color-mix(in_oklab,var(--success)_16%,transparent)] text-[var(--success)]",
|
tone: successTone,
|
||||||
icon: <UserCheck className="size-3" />,
|
icon: <UserCheck className="size-3" />,
|
||||||
title: "Confirmed by a person.",
|
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]]). */
|
/** The trust surface — categorical, never a score ([[feedback_trust_is_human]]). */
|
||||||
export const TierBadge: FC<{ tier: AssertionTier; className?: string }> = ({ tier, className }) => {
|
export const TierBadge: FC<{ tier: AssertionTier; className?: string }> = ({ tier, className }) => {
|
||||||
const t = TIER[tier];
|
const t = TIER[tier] ?? TIER.extracted;
|
||||||
return (
|
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}
|
{t.label}
|
||||||
</Badge>
|
</Badge>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
// via the general claims list. Props in, callbacks out.
|
// via the general claims list. Props in, callbacks out.
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
import { type FC, type ReactNode } from "react";
|
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 type { Claim } from "./types";
|
||||||
import { Badge, TierBadge, cn, formatDate, renderValue } from "./_internal";
|
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">
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
{claims.map((c, i) => (
|
{claims.map((c, i) => (
|
||||||
<div key={c.claim_id} className="flex flex-col gap-2 rounded-lg border border-border bg-card p-3">
|
<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]}
|
{values[i]}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-wrap items-center gap-1.5">
|
<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 = () => (
|
const Empty: FC = () => (
|
||||||
<div className="flex flex-col items-center gap-2 rounded-xl border border-dashed border-border py-12 text-center">
|
<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-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>
|
<p className="text-xs text-muted-foreground">Your agents' claims will appear here when there's something to confirm.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,10 +2,10 @@
|
|||||||
// owner — "Your corpuses" (account) then each organisation (tenant).
|
// owner — "Your corpuses" (account) then each organisation (tenant).
|
||||||
// Props in, callbacks out; the app owns fetching + routing.
|
// 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 { Plus, Users, User, ChevronRight, Loader2 } from "lucide-react";
|
||||||
import type { Collection, CollectionInput, CollectionPatch, Curation, Sensitivity } from "./types";
|
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"];
|
const SENS_ORDER: Sensitivity[] = ["open", "restricted", "vault"];
|
||||||
|
|
||||||
@@ -42,7 +42,11 @@ export const CollectionCard: FC<CollectionCardProps> = ({ collection, pendingCou
|
|||||||
<SensitivityBadge level={collection.sensitivity} />
|
<SensitivityBadge level={collection.sensitivity} />
|
||||||
<CurationBadge curation={collection.curation} />
|
<CurationBadge curation={collection.curation} />
|
||||||
{collection.claim_extraction === "auto" && (
|
{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
|
Auto-claims
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
@@ -50,7 +54,11 @@ export const CollectionCard: FC<CollectionCardProps> = ({ collection, pendingCou
|
|||||||
{collection.object_count ?? 0} {collection.object_count === 1 ? "item" : "items"}
|
{collection.object_count ?? 0} {collection.object_count === 1 ? "item" : "items"}
|
||||||
</span>
|
</span>
|
||||||
{pendingCount != null && pendingCount > 0 && (
|
{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
|
{pendingCount} to review
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
@@ -211,8 +219,12 @@ export const CollectionForm: FC<CollectionFormProps> = ({
|
|||||||
const readOnly = isEdit && initial?.owner_type === "tenant" && !canManageTenant;
|
const readOnly = isEdit && initial?.owner_type === "tenant" && !canManageTenant;
|
||||||
|
|
||||||
const [slug, setSlug] = useState(initial?.slug ?? "");
|
const [slug, setSlug] = useState(initial?.slug ?? "");
|
||||||
|
const [slugTouched, setSlugTouched] = useState(false);
|
||||||
const [name, setName] = useState(initial?.name ?? "");
|
const [name, setName] = useState(initial?.name ?? "");
|
||||||
const [description, setDescription] = useState(initial?.description ?? "");
|
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 [sensitivity, setSensitivity] = useState<Sensitivity>(initial?.sensitivity ?? "open");
|
||||||
const [curation, setCuration] = useState<Curation>(initial?.curation ?? "live");
|
const [curation, setCuration] = useState<Curation>(initial?.curation ?? "live");
|
||||||
const [claimAuto, setClaimAuto] = useState((initial?.claim_extraction ?? "off") === "auto");
|
const [claimAuto, setClaimAuto] = useState((initial?.claim_extraction ?? "off") === "auto");
|
||||||
@@ -262,23 +274,41 @@ export const CollectionForm: FC<CollectionFormProps> = ({
|
|||||||
</Field>
|
</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 && (
|
{!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
|
<input
|
||||||
data-action="knowledge-corpus-slug"
|
data-action="knowledge-corpus-slug"
|
||||||
className={inputCls}
|
className={cn(inputCls, "font-mono")}
|
||||||
value={slug}
|
value={slug}
|
||||||
onChange={(e) => setSlug(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, "-"))}
|
onChange={(e) => {
|
||||||
placeholder="supplier-docs"
|
setSlugTouched(true);
|
||||||
|
setSlug(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, "-"));
|
||||||
|
}}
|
||||||
|
placeholder="supplier-documents"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</Field>
|
</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?">
|
<Field label="Description" hint="What belongs in this corpus?">
|
||||||
<textarea
|
<textarea
|
||||||
className={cn(inputCls, "min-h-[64px] resize-y")}
|
className={cn(inputCls, "min-h-[64px] resize-y")}
|
||||||
@@ -345,20 +375,39 @@ export const CollectionForm: FC<CollectionFormProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const inputCls =
|
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 }) => {
|
||||||
<div className="flex flex-col gap-1.5">
|
const id = useId();
|
||||||
<label className="text-sm font-medium text-foreground">{label}</label>
|
const hintId = `${id}-hint`;
|
||||||
{children}
|
// Link the label (and any hint) to the single control child for screen readers.
|
||||||
{hint && <p className="text-xs text-muted-foreground">{hint}</p>}
|
const control =
|
||||||
</div>
|
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 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 }) => (
|
const OwnerToggle: FC<{ active: boolean; onClick: () => void; icon: ReactNode; label: string }> = ({ active, onClick, icon, label }) => (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
|
aria-pressed={active}
|
||||||
className={cn(
|
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",
|
"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",
|
active ? "border-primary bg-primary/10 text-primary" : "border-border text-muted-foreground hover:bg-muted",
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
cn,
|
cn,
|
||||||
formatBytes,
|
formatBytes,
|
||||||
formatDate,
|
formatDate,
|
||||||
|
formatPrincipal,
|
||||||
isImageMime,
|
isImageMime,
|
||||||
} from "./_internal";
|
} from "./_internal";
|
||||||
|
|
||||||
@@ -153,7 +154,7 @@ export const ProvenancePanel: FC<{ outline: ObjectOutline; className?: string }>
|
|||||||
const p = outline.provenance;
|
const p = outline.provenance;
|
||||||
const rows: [string, ReactNode][] = [
|
const rows: [string, ReactNode][] = [
|
||||||
["Source", p.source_ref ? `${p.source_type} · ${p.source_ref}` : p.source_type],
|
["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)],
|
["Added", formatDate(p.inserted_at)],
|
||||||
["Effective", p.effective_at ? formatDate(p.effective_at) : "—"],
|
["Effective", p.effective_at ? formatDate(p.effective_at) : "—"],
|
||||||
["Last verified", p.verified_at ? formatDate(p.verified_at) : "Not confirmed"],
|
["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)}>
|
<div className={cn("flex flex-col gap-2", className)}>
|
||||||
{outline.superseded_by && (
|
{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">
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-action="knowledge-open-newer"
|
data-action="knowledge-open-newer"
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
// EXPORTS
|
// EXPORTS
|
||||||
// Types + transport: all of ./types, KnowledgeTransport, MockKnowledgeTransport
|
// Types + transport: all of ./types, KnowledgeTransport, MockKnowledgeTransport
|
||||||
// Badges/formatters: SensitivityBadge, CurationBadge, StatusBadge, TierBadge,
|
// Badges/formatters: SensitivityBadge, CurationBadge, StatusBadge, TierBadge,
|
||||||
// Badge, Spinner, cn, formatBytes, formatDate, formatRelative,
|
// Badge, Spinner, cn, formatBytes, formatDate, formatPrincipal,
|
||||||
// renderValue, isImageMime
|
// formatRelative, renderValue, isImageMime
|
||||||
// Collections (W3): CollectionList, CollectionCard, CollectionForm
|
// Collections (W3): CollectionList, CollectionCard, CollectionForm
|
||||||
// Object viewer (W4):ObjectList, ObjectViewer, CatalogCard, ProvenancePanel,
|
// Object viewer (W4):ObjectList, ObjectViewer, CatalogCard, ProvenancePanel,
|
||||||
// SupersessionBanner, OutlineNav, TextReader, CitationLink
|
// SupersessionBanner, OutlineNav, TextReader, CitationLink
|
||||||
@@ -27,6 +27,7 @@ export {
|
|||||||
TierBadge,
|
TierBadge,
|
||||||
formatBytes,
|
formatBytes,
|
||||||
formatDate,
|
formatDate,
|
||||||
|
formatPrincipal,
|
||||||
formatRelative,
|
formatRelative,
|
||||||
renderValue,
|
renderValue,
|
||||||
isImageMime,
|
isImageMime,
|
||||||
|
|||||||
12
src/types.ts
12
src/types.ts
@@ -94,10 +94,16 @@ export interface Entity {
|
|||||||
type: string;
|
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 {
|
export interface Provenance {
|
||||||
source_type: string;
|
source_type: string;
|
||||||
source_ref?: string | null;
|
source_ref?: string | null;
|
||||||
created_by?: string | null;
|
created_by?: string | PrincipalRef | null;
|
||||||
inserted_at?: string;
|
inserted_at?: string;
|
||||||
effective_at?: string | null;
|
effective_at?: string | null;
|
||||||
verified_at?: string | null;
|
verified_at?: string | null;
|
||||||
@@ -210,8 +216,8 @@ export interface Claim {
|
|||||||
sensitivity: Sensitivity;
|
sensitivity: Sensitivity;
|
||||||
effective_at?: string | null;
|
effective_at?: string | null;
|
||||||
verified_at?: string | null;
|
verified_at?: string | null;
|
||||||
verified_by?: string | null;
|
verified_by?: string | PrincipalRef | null;
|
||||||
created_by?: string | null;
|
created_by?: string | PrincipalRef | null;
|
||||||
updated_at?: string;
|
updated_at?: string;
|
||||||
cite?: string;
|
cite?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user