feat(workbench): CORE-Logos Studio LG-3 — Lexicon / Glosses / Morphology contents tabs

Read-only contents tabs over GET /logos/packs/{id}/contents:
- Lexicon (VirtualizedList + SearchInput + status/domain filters; dangling-link
  flag cross-referenced from the safety report, never recomputed)
- Glosses (linked entry ids) and Morphology (operator chain rendered in schema
  order — root/prefix/stem/suffix never re-sorted)
- three pack-scoped evidence subjects (logos_entry/gloss/morphology) with
  logos_*:<packId>/<subId> address grammar, RightInspector projections, and
  EvidenceChainRail derivations
- contents fetched eagerly on pack-select; /alignment stays deferred to LG-4

Full workbench-ui vitest: 506 passed, clean exit; build clean.
This commit is contained in:
Shay 2026-06-14 12:51:08 -07:00
parent b7ec02b869
commit bcba7cba1e
10 changed files with 1001 additions and 8 deletions

View file

@ -20,6 +20,8 @@ import type {
LogosPackSummary,
LogosPackOverview,
LogosSafetyReport,
LogosPackContents,
LogosAlignmentRow,
VaultSummary,
VaultEntry,
CalibrationClass,
@ -316,6 +318,23 @@ export async function fetchLogosPackSafety(packId: string): Promise<LogosSafetyR
);
}
export async function fetchLogosPackContents(
packId: string,
): Promise<LogosPackContents> {
return apiFetch<LogosPackContents>(
`/logos/packs/${encodeURIComponent(packId)}/contents`,
);
}
export async function fetchLogosPackAlignment(
packId: string,
): Promise<LogosAlignmentRow[]> {
const envelope = await apiFetch<ItemsEnvelope<LogosAlignmentRow>>(
`/logos/packs/${encodeURIComponent(packId)}/alignment`,
);
return envelope.items;
}
export async function fetchVaultSummary(): Promise<VaultSummary> {
return apiFetch<VaultSummary>("/vault/summary");
}

View file

@ -23,6 +23,8 @@ import {
fetchLogosPacks,
fetchLogosPackOverview,
fetchLogosPackSafety,
fetchLogosPackContents,
fetchLogosPackAlignment,
fetchVaultSummary,
fetchVaultEntries,
fetchCalibrationClasses,
@ -59,6 +61,8 @@ import type {
LogosPackSummary,
LogosPackOverview,
LogosSafetyReport,
LogosPackContents,
LogosAlignmentRow,
VaultSummary,
VaultEntry,
CalibrationClass,
@ -434,6 +438,26 @@ export function useLogosPackSafety(packId?: string | null) {
});
}
export function useLogosPackContents(packId?: string | null) {
return useQuery<LogosPackContents, WorkbenchApiError>({
queryKey: ["api", "logos", "pack", packId ?? null, "contents"],
queryFn: () => fetchLogosPackContents(packId as string),
enabled: typeof packId === "string" && packId.length > 0,
staleTime: 30_000,
refetchOnWindowFocus: false,
});
}
export function useLogosPackAlignment(packId?: string | null) {
return useQuery<LogosAlignmentRow[], WorkbenchApiError>({
queryKey: ["api", "logos", "pack", packId ?? null, "alignment"],
queryFn: () => fetchLogosPackAlignment(packId as string),
enabled: typeof packId === "string" && packId.length > 0,
staleTime: 30_000,
refetchOnWindowFocus: false,
});
}
export function useVaultSummary() {
return useQuery<VaultSummary, WorkbenchApiError>({
queryKey: ["api", "vault", "summary"],

View file

@ -183,6 +183,70 @@ export function deriveStages(subject: EvidenceSubject): RailStage[] | null {
stage("action", "dim", "proposal mode none"),
];
}
case "logos_entry": {
const d = subject.data;
return [
stage("intent", "dim", "not applicable to read-only Logos entry inspection"),
stage("subject", "lit", "selected CORE-Logos lexical entry"),
stage(
"provenance",
d ? evidenceAny(...d.provenance_ids) : "hollow",
"provenance_ids",
),
stage(
"admissibility",
d ? evidenceOf(d.epistemic_status) : "hollow",
"epistemic_status (ADR-0021 revision position)",
),
stage("replay", "dim", "not applicable to a static lexicon row"),
stage("authority", "dim", "read-only Logos Studio has no mutation authority"),
stage("action", "dim", "proposal mode none"),
];
}
case "logos_gloss": {
const d = subject.data;
return [
stage("intent", "dim", "not applicable to read-only Logos gloss inspection"),
stage("subject", "lit", "selected CORE-Logos gloss"),
stage(
"provenance",
d ? evidenceAny(...d.provenance_ids) : "hollow",
"provenance_ids",
),
stage(
"admissibility",
d ? evidenceOf(d.epistemic_status) : "hollow",
"epistemic_status",
),
stage(
"replay",
d ? evidenceAny(...d.entry_ids) : "hollow",
"linked entry_ids",
),
stage("authority", "dim", "read-only Logos Studio has no mutation authority"),
stage("action", "dim", "proposal mode none"),
];
}
case "logos_morphology": {
const d = subject.data;
return [
stage("intent", "dim", "not applicable to read-only Logos morphology inspection"),
stage("subject", "lit", "selected CORE-Logos morphology record"),
stage(
"provenance",
d ? evidenceAny(d.root, d.stem) : "hollow",
"root / stem",
),
stage(
"admissibility",
d ? evidenceOf(d.morphology_id) : "hollow",
"morphology_id (lexicon link target)",
),
stage("replay", "dim", "not applicable to a static morphology row"),
stage("authority", "dim", "read-only Logos Studio has no mutation authority"),
stage("action", "dim", "proposal mode none"),
];
}
case "vault_entry": {
const d = subject.data;
return [

View file

@ -309,6 +309,120 @@ function LogosPackInspector({
);
}
function LogosEntryInspector({
subject,
}: {
subject: Extract<EvidenceSubject, { kind: "logos_entry" }>;
}) {
const { data } = subject;
return (
<div className="grid gap-3">
<h3 className="text-xs font-semibold text-[var(--color-text-secondary)]">
CORE-Logos Entry
</h3>
<p className="m-0 font-mono text-xs text-[var(--color-text-primary)]">
{subject.packId} · {subject.entryId}
</p>
{data ? (
<MetadataTable
rows={[
{ key: "surface", value: data.surface },
{ key: "lemma", value: data.lemma },
{ key: "language", value: data.language },
...(data.pos ?? data.part_of_speech
? [{ key: "pos", value: (data.pos ?? data.part_of_speech) as string }]
: []),
...(data.morphology_id
? [{ key: "morphology", value: data.morphology_id, mono: true }]
: []),
{ key: "epistemic", value: data.epistemic_status },
...(data.semantic_domains.length > 0
? [{ key: "domains", value: data.semantic_domains.join(", ") }]
: []),
]}
/>
) : (
<DetailNotLoaded />
)}
</div>
);
}
function LogosGlossInspector({
subject,
}: {
subject: Extract<EvidenceSubject, { kind: "logos_gloss" }>;
}) {
const { data } = subject;
return (
<div className="grid gap-3">
<h3 className="text-xs font-semibold text-[var(--color-text-secondary)]">
CORE-Logos Gloss
</h3>
<p className="m-0 font-mono text-xs text-[var(--color-text-primary)]">
{subject.packId} · {subject.glossId}
</p>
{data ? (
<MetadataTable
rows={[
{ key: "lemma", value: data.lemma },
{ key: "gloss", value: data.gloss },
...(data.pos ? [{ key: "pos", value: data.pos }] : []),
...(data.entry_ids.length > 0
? [{ key: "entries", value: data.entry_ids.join(", "), mono: true }]
: []),
...(data.epistemic_status
? [{ key: "epistemic", value: data.epistemic_status }]
: []),
]}
/>
) : (
<DetailNotLoaded />
)}
</div>
);
}
function LogosMorphologyInspector({
subject,
}: {
subject: Extract<EvidenceSubject, { kind: "logos_morphology" }>;
}) {
const { data } = subject;
// Render the operator chain in schema order — ordering is load-bearing for
// Semitic root / Koine grammar, never re-sorted.
const chain = data
? [
...data.prefix_chain,
...(data.root ? [`${data.root}`] : data.stem ? [data.stem] : []),
...data.suffix_chain,
].join(" · ")
: "";
return (
<div className="grid gap-3">
<h3 className="text-xs font-semibold text-[var(--color-text-secondary)]">
CORE-Logos Morphology
</h3>
<p className="m-0 font-mono text-xs text-[var(--color-text-primary)]">
{subject.packId} · {subject.morphologyId}
</p>
{data ? (
<MetadataTable
rows={[
{ key: "surface", value: data.surface },
{ key: "lemma", value: data.lemma },
{ key: "language", value: data.language },
...(data.root ? [{ key: "root", value: data.root, mono: true }] : []),
...(chain ? [{ key: "chain", value: chain, mono: true }] : []),
]}
/>
) : (
<DetailNotLoaded />
)}
</div>
);
}
function VaultEntryInspector({ subject }: { subject: Extract<EvidenceSubject, { kind: "vault_entry" }> }) {
const { data } = subject;
return (
@ -445,6 +559,12 @@ function InspectorProjection({ subject }: { subject: EvidenceSubject }) {
return <PackInspector subject={subject} />;
case "logos_pack":
return <LogosPackInspector subject={subject} />;
case "logos_entry":
return <LogosEntryInspector subject={subject} />;
case "logos_gloss":
return <LogosGlossInspector subject={subject} />;
case "logos_morphology":
return <LogosMorphologyInspector subject={subject} />;
case "vault_entry":
return <VaultEntryInspector subject={subject} />;
case "audit_event":

View file

@ -283,3 +283,53 @@ describe("isAddressable / sameIdentity", () => {
expect(sameIdentity({ kind: "none" }, { kind: "none" })).toBe(true);
});
});
describe("logos sub-entity addresses (LG-3)", () => {
const PACK = "he_logos_micro_v1";
const subEntities: Array<{ subject: AddressableSubject; inspect: string }> = [
{
subject: { kind: "logos_entry", packId: PACK, entryId: "he-001" },
inspect: `logos_entry:${PACK}/he-001`,
},
{
subject: { kind: "logos_gloss", packId: PACK, glossId: "gloss-1" },
inspect: `logos_gloss:${PACK}/gloss-1`,
},
{
subject: { kind: "logos_morphology", packId: PACK, morphologyId: "he-morph-001" },
inspect: `logos_morphology:${PACK}/he-morph-001`,
},
];
it.each(subEntities)(
"addresses $subject.kind under the pack route with an inspect value",
({ subject, inspect }) => {
expect(subjectToInspectValue(subject)).toBe(inspect);
const url = subjectToUrl(subject);
expect(url).toBe(`/logos/${PACK}?${INSPECT_PARAM}=${encodeURIComponent(inspect)}`);
},
);
it.each(subEntities)(
"round-trips $subject.kind: route resolves to the pack, inspect to the sub-entity",
({ subject }) => {
const { route, inspect } = roundTrip(subject);
expect(route).toEqual({ kind: "logos_pack", packId: PACK });
expect(inspect).toEqual(subject);
},
);
it("rejects a malformed pack-scoped inspect value (no separator)", () => {
expect(inspectValueToSubject("logos_entry:onlypack")).toBeNull();
expect(inspectValueToSubject("logos_gloss:trailing/")).toBeNull();
});
it("distinguishes a logos_pack from a logos_entry sharing the pack id", () => {
expect(
sameIdentity(
{ kind: "logos_pack", packId: PACK },
{ kind: "logos_entry", packId: PACK, entryId: "he-001" },
),
).toBe(false);
});
});

View file

@ -11,6 +11,9 @@ import type { EvidenceSubject } from "./evidenceContext";
// run -> /runs/<sessionId>
// pack -> /packs/<packId>
// logos_pack -> /logos/<packId> (?inspect uses logos:<packId>)
// logos_entry -> /logos/<packId>?inspect=logos_entry:<packId>/<entryId>
// logos_gloss -> /logos/<packId>?inspect=logos_gloss:<packId>/<glossId>
// logos_morphology -> /logos/<packId>?inspect=logos_morphology:<packId>/<morphologyId>
// vault_entry -> /vault?inspect=vault:<entryIndex>
// audit_event -> /audit?inspect=audit:<eventId>
// calibration_class -> /calibration?inspect=calibration:<className>
@ -48,6 +51,24 @@ export function sameIdentity(a: EvidenceSubject, b: EvidenceSubject): boolean {
return b.kind === "pack" && b.packId === a.packId;
case "logos_pack":
return b.kind === "logos_pack" && b.packId === a.packId;
case "logos_entry":
return (
b.kind === "logos_entry" &&
b.packId === a.packId &&
b.entryId === a.entryId
);
case "logos_gloss":
return (
b.kind === "logos_gloss" &&
b.packId === a.packId &&
b.glossId === a.glossId
);
case "logos_morphology":
return (
b.kind === "logos_morphology" &&
b.packId === a.packId &&
b.morphologyId === a.morphologyId
);
case "vault_entry":
return b.kind === "vault_entry" && b.entryIndex === a.entryIndex;
case "audit_event":
@ -91,6 +112,14 @@ function subjectAddress(subject: AddressableSubject): SubjectAddress {
return { path: `/packs/${encodeURIComponent(subject.packId)}`, params: emptyParams() };
case "logos_pack":
return { path: `/logos/${encodeURIComponent(subject.packId)}`, params: emptyParams() };
case "logos_entry":
case "logos_gloss":
case "logos_morphology":
{
const params = emptyParams();
params.set(INSPECT_PARAM, subjectToInspectValue(subject));
return { path: `/logos/${encodeURIComponent(subject.packId)}`, params };
}
case "vault_entry":
{
const params = emptyParams();
@ -128,6 +157,12 @@ export function subjectToInspectValue(subject: AddressableSubject): string {
return `pack:${subject.packId}`;
case "logos_pack":
return `logos:${subject.packId}`;
case "logos_entry":
return `logos_entry:${subject.packId}/${subject.entryId}`;
case "logos_gloss":
return `logos_gloss:${subject.packId}/${subject.glossId}`;
case "logos_morphology":
return `logos_morphology:${subject.packId}/${subject.morphologyId}`;
case "vault_entry":
return `vault:${subject.entryIndex}`;
case "audit_event":
@ -156,6 +191,17 @@ function parseTurnId(raw: string): number | null {
return Number.isSafeInteger(value) ? value : null;
}
// Pack-scoped sub-entity ids encode as `<packId>/<subId>`. Neither a pack id
// nor a lexicon/gloss/morphology/edge id contains "/", so the first slash is
// the unambiguous separator. Returns null for malformed input (never throws).
function splitPackScoped(
raw: string,
): { packId: string; subId: string } | null {
const sep = raw.indexOf("/");
if (sep <= 0 || sep === raw.length - 1) return null;
return { packId: raw.slice(0, sep), subId: raw.slice(sep + 1) };
}
export function inspectValueToSubject(
value: string | null,
): AddressableSubject | null {
@ -181,6 +227,28 @@ export function inspectValueToSubject(
return { kind: "pack", packId: id };
case "logos":
return { kind: "logos_pack", packId: id };
case "logos_entry": {
const parts = splitPackScoped(id);
return parts === null
? null
: { kind: "logos_entry", packId: parts.packId, entryId: parts.subId };
}
case "logos_gloss": {
const parts = splitPackScoped(id);
return parts === null
? null
: { kind: "logos_gloss", packId: parts.packId, glossId: parts.subId };
}
case "logos_morphology": {
const parts = splitPackScoped(id);
return parts === null
? null
: {
kind: "logos_morphology",
packId: parts.packId,
morphologyId: parts.subId,
};
}
case "vault": {
const entryIndex = parseTurnId(id);
return entryIndex === null ? null : { kind: "vault_entry", entryIndex };

View file

@ -15,6 +15,9 @@ import type {
EvalRunResult,
CalibrationClass,
LogosPackOverview,
LogosLexiconRow,
LogosGlossRow,
LogosMorphologyRow,
SafetyVerdict,
} from "../types/api";
@ -84,6 +87,24 @@ export type EvidenceSubject =
| { kind: "run"; sessionId: string; data?: RunSubjectData }
| { kind: "pack"; packId: string; data?: PackSubjectData }
| { kind: "logos_pack"; packId: string; data?: LogosPackSubjectData }
| {
kind: "logos_entry";
packId: string;
entryId: string;
data?: LogosLexiconRow;
}
| {
kind: "logos_gloss";
packId: string;
glossId: string;
data?: LogosGlossRow;
}
| {
kind: "logos_morphology";
packId: string;
morphologyId: string;
data?: LogosMorphologyRow;
}
| { kind: "vault_entry"; entryIndex: number; data?: VaultEntrySubjectData }
| { kind: "audit_event"; eventId: string; data?: AuditEventSubjectData }
| { kind: "calibration_class"; className: string; data?: CalibrationClass }

View file

@ -0,0 +1,327 @@
import { useMemo, useState } from "react";
import { SearchInput } from "../../design/components/SearchInput/SearchInput";
import { VirtualizedList } from "../../design/components/VirtualizedList/VirtualizedList";
import type {
LogosGlossRow,
LogosLexiconRow,
LogosMorphologyRow,
} from "../../types/api";
// Read-only CORE-Logos contents tabs (LG-3). Each list reuses VirtualizedList
// (windowing + keyboard nav via useListNavigation) and SearchInput. Selection
// publishes a pack-scoped evidence subject; no data is recomputed here.
const LIST_HEIGHT = 360;
const TEST_RECT = { width: 720, height: LIST_HEIGHT };
function FilterChip({
label,
active,
onClick,
}: {
label: string;
active: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
aria-pressed={active}
onClick={onClick}
className={`h-6 rounded-md border px-2 text-xs transition-colors ${
active
? "border-[var(--color-selected-border)] bg-[var(--color-selected-bg)] text-[var(--color-text-primary)]"
: "border-[var(--color-border-subtle)] bg-[var(--color-surface-inset)] text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]"
}`}
>
{label}
</button>
);
}
function Tag({ children }: { children: React.ReactNode }) {
return (
<span className="inline-flex h-5 items-center rounded border border-[var(--color-border-subtle)] bg-[var(--color-surface-inset)] px-1.5 text-[10px] text-[var(--color-text-secondary)]">
{children}
</span>
);
}
function DanglingFlag() {
return (
<span className="inline-flex h-5 items-center rounded border border-[var(--color-state-warning-border)] bg-[var(--color-state-warning-bg)] px-1.5 text-[10px] text-[var(--color-state-warning-text)]">
dangling morphology
</span>
);
}
function RowShell({
selected,
onSelect,
children,
}: {
selected: boolean;
onSelect: () => void;
children: React.ReactNode;
}) {
return (
<div
role="button"
tabIndex={-1}
aria-current={selected ? "true" : undefined}
onClick={onSelect}
className={`grid gap-1 border-b border-[var(--color-border-subtle)] px-3 py-2 text-left ${
selected
? "border-l-2 border-l-[var(--color-selected-border)] bg-[var(--color-selected-bg)] pl-[10px]"
: "border-l-2 border-l-transparent pl-[10px]"
}`}
>
{children}
</div>
);
}
// ---------------------------------------------------------------- Lexicon ----
export function LexiconTab({
rows,
danglingEntryIds,
selectedEntryId,
onSelect,
}: {
rows: readonly LogosLexiconRow[];
danglingEntryIds: ReadonlySet<string>;
selectedEntryId: string | null;
onSelect: (row: LogosLexiconRow) => void;
}) {
const [query, setQuery] = useState("");
const [statusFilter, setStatusFilter] = useState<string>("all");
const [domainFilter, setDomainFilter] = useState<string>("all");
const statuses = useMemo(
() => Array.from(new Set(rows.map((r) => r.epistemic_status))).sort(),
[rows],
);
const domains = useMemo(
() => Array.from(new Set(rows.flatMap((r) => r.semantic_domains))).sort(),
[rows],
);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
return rows.filter((r) => {
if (statusFilter !== "all" && r.epistemic_status !== statusFilter) return false;
if (domainFilter !== "all" && !r.semantic_domains.includes(domainFilter)) return false;
if (!q) return true;
return (
r.surface.toLowerCase().includes(q) ||
r.lemma.toLowerCase().includes(q) ||
r.entry_id.toLowerCase().includes(q) ||
r.semantic_domains.some((d) => d.toLowerCase().includes(q))
);
});
}, [rows, query, statusFilter, domainFilter]);
return (
<div className="grid min-h-0 gap-3">
<SearchInput
placeholder="Search surface, lemma, entry id, or domain"
value={query}
onChange={setQuery}
/>
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-[10px] uppercase text-[var(--color-text-muted)]">status</span>
<FilterChip label="all" active={statusFilter === "all"} onClick={() => setStatusFilter("all")} />
{statuses.map((s) => (
<FilterChip key={s} label={s} active={statusFilter === s} onClick={() => setStatusFilter(s)} />
))}
</div>
{domains.length > 0 ? (
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-[10px] uppercase text-[var(--color-text-muted)]">domain</span>
<FilterChip label="all" active={domainFilter === "all"} onClick={() => setDomainFilter("all")} />
{domains.map((d) => (
<FilterChip key={d} label={d} active={domainFilter === d} onClick={() => setDomainFilter(d)} />
))}
</div>
) : null}
<div className="text-xs text-[var(--color-text-secondary)]">
{filtered.length} of {rows.length} entries
</div>
<VirtualizedList
items={filtered}
ariaLabel="CORE-Logos lexicon"
height={LIST_HEIGHT}
estimateSize={64}
initialRect={TEST_RECT}
getKey={(row) => row.entry_id}
onActivate={onSelect}
renderItem={(row, _index, focused) => (
<RowShell selected={focused || row.entry_id === selectedEntryId} onSelect={() => onSelect(row)}>
<div className="flex items-center justify-between gap-2">
<span className="truncate font-mono text-sm text-[var(--color-text-primary)]">
{row.surface} · {row.lemma}
</span>
<span className="font-mono text-[10px] text-[var(--color-text-muted)]">{row.entry_id}</span>
</div>
<div className="flex flex-wrap items-center gap-1.5">
<Tag>{row.language}</Tag>
{row.pos ?? row.part_of_speech ? <Tag>{row.pos ?? row.part_of_speech}</Tag> : null}
<Tag>{row.epistemic_status}</Tag>
{row.semantic_domains.map((d) => (
<Tag key={d}>{d}</Tag>
))}
{danglingEntryIds.has(row.entry_id) ? <DanglingFlag /> : null}
</div>
</RowShell>
)}
/>
</div>
);
}
// ----------------------------------------------------------------- Glosses ----
export function GlossesTab({
rows,
selectedGlossId,
onSelect,
}: {
rows: readonly LogosGlossRow[];
selectedGlossId: string | null;
onSelect: (row: LogosGlossRow) => void;
}) {
const [query, setQuery] = useState("");
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return rows;
return rows.filter(
(r) =>
r.lemma.toLowerCase().includes(q) ||
r.gloss.toLowerCase().includes(q) ||
r.entry_ids.some((e) => e.toLowerCase().includes(q)),
);
}, [rows, query]);
return (
<div className="grid min-h-0 gap-3">
<SearchInput
placeholder="Search lemma, gloss text, or linked entry id"
value={query}
onChange={setQuery}
/>
<div className="text-xs text-[var(--color-text-secondary)]">
{filtered.length} of {rows.length} glosses
</div>
<VirtualizedList
items={filtered}
ariaLabel="CORE-Logos glosses"
height={LIST_HEIGHT}
estimateSize={64}
initialRect={TEST_RECT}
getKey={(row) => row.gloss_id}
onActivate={onSelect}
renderItem={(row, _index, focused) => (
<RowShell selected={focused || row.gloss_id === selectedGlossId} onSelect={() => onSelect(row)}>
<div className="flex items-center justify-between gap-2">
<span className="truncate font-mono text-sm text-[var(--color-text-primary)]">{row.lemma}</span>
{row.pos ? <Tag>{row.pos}</Tag> : null}
</div>
<p className="m-0 text-xs text-[var(--color-text-secondary)] [text-wrap:balance]">{row.gloss}</p>
{row.entry_ids.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{row.entry_ids.map((e) => (
<Tag key={e}>{e}</Tag>
))}
</div>
) : null}
</RowShell>
)}
/>
</div>
);
}
// -------------------------------------------------------------- Morphology ----
/** The operator chain, rendered in schema order — never re-sorted. */
export function morphologyChain(row: LogosMorphologyRow): string[] {
return [
...row.prefix_chain,
...(row.root ? [`${row.root}`] : row.stem ? [row.stem] : []),
...row.suffix_chain,
];
}
export function MorphologyTab({
rows,
selectedMorphologyId,
onSelect,
}: {
rows: readonly LogosMorphologyRow[];
selectedMorphologyId: string | null;
onSelect: (row: LogosMorphologyRow) => void;
}) {
const [query, setQuery] = useState("");
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return rows;
return rows.filter(
(r) =>
r.surface.toLowerCase().includes(q) ||
r.lemma.toLowerCase().includes(q) ||
r.morphology_id.toLowerCase().includes(q) ||
(r.root ?? "").toLowerCase().includes(q),
);
}, [rows, query]);
return (
<div className="grid min-h-0 gap-3">
<SearchInput
placeholder="Search surface, lemma, root, or morphology id"
value={query}
onChange={setQuery}
/>
<div className="text-xs text-[var(--color-text-secondary)]">
{filtered.length} of {rows.length} morphology records
</div>
<VirtualizedList
items={filtered}
ariaLabel="CORE-Logos morphology"
height={LIST_HEIGHT}
estimateSize={64}
initialRect={TEST_RECT}
getKey={(row) => row.morphology_id}
onActivate={onSelect}
renderItem={(row, _index, focused) => {
const chain = morphologyChain(row);
return (
<RowShell
selected={focused || row.morphology_id === selectedMorphologyId}
onSelect={() => onSelect(row)}
>
<div className="flex items-center justify-between gap-2">
<span className="truncate font-mono text-sm text-[var(--color-text-primary)]">
{row.surface} · {row.lemma}
</span>
<span className="font-mono text-[10px] text-[var(--color-text-muted)]">
{row.morphology_id}
</span>
</div>
{chain.length > 0 ? (
<div className="flex flex-wrap items-center gap-1 font-mono text-xs text-[var(--color-text-secondary)]">
{chain.map((seg, i) => (
<span key={`${row.morphology_id}-${i}`} className="flex items-center gap-1">
{i > 0 ? <span aria-hidden className="text-[var(--color-text-muted)]"></span> : null}
<span>{seg}</span>
</span>
))}
</div>
) : null}
</RowShell>
);
}}
/>
</div>
);
}

View file

@ -2,9 +2,10 @@ import { QueryClientProvider } from "@tanstack/react-query";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createTestQueryClient } from "../../test/createTestQueryClient";
import type {
LogosPackContents,
LogosPackOverview,
LogosPackSummary,
LogosSafetyReport,
@ -106,6 +107,73 @@ function safetyFor(
};
}
function contentsFor(packId: string): LogosPackContents {
const he = packId.includes("he");
return {
schema_version: "logos_pack_contents_v1",
pack_id: packId,
manifest: { pack_id: packId },
lexicon: [
{
// grc's entry_id matches the warning safety report's dangling link.
entry_id: he ? "he-001" : "logos.entry.1",
surface: he ? "דבר" : "logos",
lemma: he ? "דבר" : "logos",
language: he ? "he" : "grc",
part_of_speech: "NOUN",
pos: "NOUN",
morphology_id: he ? "he-morph-001" : "missing-morph",
morphology_tags: [],
semantic_domains: ["speech"],
provenance_ids: ["John1:1"],
epistemic_status: "speculative",
},
{
entry_id: he ? "he-002" : "grc-002",
surface: he ? "אמר" : "rhema",
lemma: he ? "אמר" : "rhema",
language: he ? "he" : "grc",
part_of_speech: "VERB",
pos: "VERB",
morphology_id: null,
morphology_tags: [],
semantic_domains: ["cognition"],
provenance_ids: [],
epistemic_status: "coherent",
},
],
glosses: [
{
gloss_id: "gloss-1",
lemma: he ? "דבר" : "logos",
gloss: "word, matter, or spoken thing",
pos: "NOUN",
entry_ids: he ? ["he-001"] : ["logos.entry.1"],
provenance_ids: [],
epistemic_status: "speculative",
raw: {},
},
],
morphology: [
{
morphology_id: he ? "he-morph-001" : "grc-morph-001",
surface: he ? "דברים" : "logoi",
lemma: he ? "דבר" : "logos",
language: he ? "he" : "grc",
root: he ? "דבר" : null,
prefix_chain: [],
stem: he ? null : "log",
inflection: {},
suffix_chain: he ? ["ים"] : ["oi"],
},
],
frames: [],
compositions: [],
alignment_edges: [],
holonomy_cases: [],
};
}
function okEnvelope(data: unknown) {
return { ok: true, generated_at: "2026-06-14T00:00:00Z", data };
}
@ -127,6 +195,13 @@ function stubLogosFetch() {
json: async () => okEnvelope(safetyFor(packId)),
});
}
const contentsMatch = path.match(/^\/logos\/packs\/([^/]+)\/contents$/);
if (contentsMatch) {
const packId = decodeURIComponent(contentsMatch[1]);
return Promise.resolve({
json: async () => okEnvelope(contentsFor(packId)),
});
}
const overviewMatch = path.match(/^\/logos\/packs\/([^/]+)$/);
if (overviewMatch) {
const packId = decodeURIComponent(overviewMatch[1]);
@ -176,7 +251,33 @@ function renderRoute(initialEntry = "/logos") {
);
}
// happy-dom has no layout engine; the VirtualizedList virtualizer reads
// offsetWidth/offsetHeight (0x0) and renders nothing. Give elements a
// real-looking layout size so the contents-tab rows mount (same workaround
// as VirtualizedList.test.tsx).
const offsetDescriptors = {
offsetHeight: Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetHeight"),
offsetWidth: Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetWidth"),
};
beforeEach(() => {
Object.defineProperty(HTMLElement.prototype, "offsetHeight", {
configurable: true,
get: () => 360,
});
Object.defineProperty(HTMLElement.prototype, "offsetWidth", {
configurable: true,
get: () => 720,
});
});
afterEach(() => {
if (offsetDescriptors.offsetHeight) {
Object.defineProperty(HTMLElement.prototype, "offsetHeight", offsetDescriptors.offsetHeight);
}
if (offsetDescriptors.offsetWidth) {
Object.defineProperty(HTMLElement.prototype, "offsetWidth", offsetDescriptors.offsetWidth);
}
vi.restoreAllMocks();
vi.unstubAllGlobals();
localStorage.clear();
@ -197,7 +298,7 @@ describe("LogosRoute", () => {
expect(screen.queryByText(/Draft proposal/i)).not.toBeInTheDocument();
});
it("selects a pack, renders Overview, projects logos_pack evidence, and avoids deferred endpoints", async () => {
it("selects a pack, renders Overview, projects logos_pack evidence, and defers only the alignment endpoint", async () => {
const { paths } = stubLogosFetch();
const user = userEvent.setup();
renderRoute();
@ -215,7 +316,8 @@ describe("LogosRoute", () => {
expect(screen.queryByText(/proof card/i)).not.toBeInTheDocument();
expect(screen.getByText("CORE-Logos Pack")).toBeInTheDocument();
expect(screen.getByText("0 / missing_evidence")).toBeInTheDocument();
expect(paths).not.toContain("/logos/packs/he_logos_micro_v1/contents");
// LG-3 fetches contents eagerly on pack-select; /alignment stays deferred to LG-4.
expect(paths).toContain("/logos/packs/he_logos_micro_v1/contents");
expect(paths).not.toContain("/logos/packs/he_logos_micro_v1/alignment");
});
@ -249,4 +351,64 @@ describe("LogosRoute", () => {
expect(screen.getAllByText("Unknown").length).toBeGreaterThan(0);
expect(screen.queryByText("Clear")).not.toBeInTheDocument();
});
it("renders the Lexicon tab and projects a logos_entry evidence subject on select", async () => {
stubLogosFetch();
const user = userEvent.setup();
renderRoute("/logos/he_logos_micro_v1");
await user.click(await screen.findByRole("tab", { name: "Lexicon" }));
// Real lexicon rows from /contents, not recomputed.
expect(await screen.findByText("דבר · דבר")).toBeInTheDocument();
expect(screen.getByText("he-001")).toBeInTheDocument();
await user.click(screen.getByText("דבר · דבר"));
// Selecting an entry publishes a pack-scoped logos_entry subject + address.
expect(await screen.findByText("CORE-Logos Entry")).toBeInTheDocument();
await waitFor(() =>
expect(screen.getByTestId("location")).toHaveTextContent(
"inspect=logos_entry%3Ahe_logos_micro_v1%2Fhe-001",
),
);
});
it("flags a lexicon entry whose morphology link is dangling per the safety report", async () => {
stubLogosFetch();
const user = userEvent.setup();
// grc safety reports entry logos.entry.1 -> missing-morph as dangling.
renderRoute("/logos/grc_logos_cognition_v1");
await user.click(await screen.findByRole("tab", { name: "Lexicon" }));
expect(await screen.findByText("logos · logos")).toBeInTheDocument();
expect(screen.getByText("dangling morphology")).toBeInTheDocument();
});
it("renders the Morphology tab chain in schema order (root then suffix)", async () => {
stubLogosFetch();
const user = userEvent.setup();
renderRoute("/logos/he_logos_micro_v1");
await user.click(await screen.findByRole("tab", { name: "Morphology" }));
expect(await screen.findByText("דברים · דבר")).toBeInTheDocument();
// Chain renders root (√דבר) before the suffix (ים), never re-sorted.
expect(screen.getByText("√דבר")).toBeInTheDocument();
expect(screen.getByText("ים")).toBeInTheDocument();
expect(screen.getByText("he-morph-001")).toBeInTheDocument();
});
it("renders the Glosses tab with linked entry ids", async () => {
stubLogosFetch();
const user = userEvent.setup();
renderRoute("/logos/he_logos_micro_v1");
await user.click(await screen.findByRole("tab", { name: "Glosses" }));
expect(await screen.findByText("word, matter, or spoken thing")).toBeInTheDocument();
await user.click(screen.getByText("word, matter, or spoken thing"));
expect(await screen.findByText("CORE-Logos Gloss")).toBeInTheDocument();
});
});

View file

@ -3,6 +3,7 @@ import type { ReactNode } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { WorkbenchApiError } from "../../api/client";
import {
useLogosPackContents,
useLogosPackOverview,
useLogosPacks,
useLogosPackSafety,
@ -21,7 +22,11 @@ import {
} from "../../design/components/badges";
import type {
LogosAlignmentTargetIssue,
LogosGlossRow,
LogosLexiconRow,
LogosMorphologyLinkIssue,
LogosMorphologyRow,
LogosPackContents,
LogosPackOverview,
LogosPackSummary,
LogosSafetyReport,
@ -29,11 +34,15 @@ import type {
} from "../../types/api";
import { pushRecentItem } from "../commandRegistry";
import { subjectToUrl } from "../evidenceAddress";
import { useEvidenceSubject } from "../evidenceContext";
import { useEvidenceSubject, type EvidenceSubject } from "../evidenceContext";
import { GlossesTab, LexiconTab, MorphologyTab } from "./LogosContentsTabs";
const LOGOS_TABS: readonly Tab[] = [
{ id: "overview", label: "Overview" },
{ id: "identity", label: "Identity" },
{ id: "lexicon", label: "Lexicon" },
{ id: "glosses", label: "Glosses" },
{ id: "morphology", label: "Morphology" },
{ id: "safety", label: "Safety" },
];
@ -573,6 +582,71 @@ function StatusStrip({
);
}
interface ContentsSelection {
entryId: string | null;
glossId: string | null;
morphologyId: string | null;
danglingEntryIds: ReadonlySet<string>;
onSelectEntry: (row: LogosLexiconRow) => void;
onSelectGloss: (row: LogosGlossRow) => void;
onSelectMorphology: (row: LogosMorphologyRow) => void;
}
function ContentsGate({
tab,
selectedPackId,
contents,
contentsLoading,
contentsError,
selection,
}: {
tab: string;
selectedPackId: string;
contents?: LogosPackContents;
contentsLoading: boolean;
contentsError: unknown;
selection: ContentsSelection;
}) {
if (contentsLoading) return <LoadingState label="Loading CORE-Logos contents..." />;
if (contentsError) {
return (
<ErrorState
whatFailed={errorMessage(contentsError)}
mutationStatus="No Logos mutation occurred."
reproducer={`curl /logos/packs/${selectedPackId}/contents`}
retrySafety="Retry: safe"
/>
);
}
if (!contents) return null;
if (tab === "lexicon") {
return (
<LexiconTab
rows={contents.lexicon}
danglingEntryIds={selection.danglingEntryIds}
selectedEntryId={selection.entryId}
onSelect={selection.onSelectEntry}
/>
);
}
if (tab === "glosses") {
return (
<GlossesTab
rows={contents.glosses}
selectedGlossId={selection.glossId}
onSelect={selection.onSelectGloss}
/>
);
}
return (
<MorphologyTab
rows={contents.morphology}
selectedMorphologyId={selection.morphologyId}
onSelect={selection.onSelectMorphology}
/>
);
}
function Workspace({
selectedPackId,
overview,
@ -581,6 +655,10 @@ function Workspace({
safety,
safetyLoading,
safetyError,
contents,
contentsLoading,
contentsError,
selection,
}: {
selectedPackId: string | null;
overview?: LogosPackOverview;
@ -589,13 +667,17 @@ function Workspace({
safety?: LogosSafetyReport;
safetyLoading: boolean;
safetyError: unknown;
contents?: LogosPackContents;
contentsLoading: boolean;
contentsError: unknown;
selection: ContentsSelection;
}) {
const [activeTab, setActiveTab] = useState("overview");
if (selectedPackId === null) {
return (
<EmptyState
statement="Select a CORE-Logos pack to inspect overview, identity, and safety evidence."
statement="Select a CORE-Logos pack to inspect overview, identity, contents, and safety evidence."
nextAction={{ kind: "cli", command: "core pack validate <path>" }}
/>
);
@ -618,6 +700,9 @@ function Workspace({
if (!overview) return null;
const isContentsTab =
activeTab === "lexicon" || activeTab === "glosses" || activeTab === "morphology";
return (
<Panel
title={overview.pack_id}
@ -626,6 +711,16 @@ function Workspace({
<TabBar tabs={LOGOS_TABS} activeTab={activeTab} onTabChange={setActiveTab}>
{activeTab === "overview" ? <OverviewTab overview={overview} /> : null}
{activeTab === "identity" ? <IdentityTab overview={overview} /> : null}
{isContentsTab ? (
<ContentsGate
tab={activeTab}
selectedPackId={selectedPackId}
contents={contents}
contentsLoading={contentsLoading}
contentsError={contentsError}
selection={selection}
/>
) : null}
{activeTab === "safety" ? (
safetyLoading ? (
<LoadingState label="Loading CORE-Logos safety..." />
@ -649,11 +744,12 @@ export function LogosRoute() {
const { logosPackId } = useParams();
const selectedPackId = logosPackId && logosPackId.length > 0 ? logosPackId : null;
const navigate = useNavigate();
const { setSubject } = useEvidenceSubject();
const { subject, setSubject } = useEvidenceSubject();
const packsQuery = useLogosPacks();
const overviewQuery = useLogosPackOverview(selectedPackId);
const safetyQuery = useLogosPackSafety(selectedPackId);
const contentsQuery = useLogosPackContents(selectedPackId);
useEffect(() => {
if (selectedPackId === null) return;
@ -670,12 +766,50 @@ export function LogosRoute() {
}, [selectedPackId, setSubject, overviewQuery.data, safetyQuery.data]);
function selectPack(pack: LogosPackSummary) {
const subject = { kind: "logos_pack" as const, packId: pack.pack_id };
const path = subjectToUrl(subject);
const next = { kind: "logos_pack" as const, packId: pack.pack_id };
const path = subjectToUrl(next);
navigate(path, { replace: true });
pushRecentItem({ label: pack.pack_id, path });
}
function selectSubEntity(next: EvidenceSubject) {
if (next.kind === "none") return;
setSubject(next);
navigate(subjectToUrl(next), { replace: true });
}
const selection: ContentsSelection = {
entryId: subject.kind === "logos_entry" ? subject.entryId : null,
glossId: subject.kind === "logos_gloss" ? subject.glossId : null,
morphologyId: subject.kind === "logos_morphology" ? subject.morphologyId : null,
danglingEntryIds: new Set(
safetyQuery.data?.dangling_morphology_links.map((issue) => issue.entry_id) ?? [],
),
onSelectEntry: (row) =>
selectSubEntity(
selectedPackId === null
? { kind: "none" }
: { kind: "logos_entry", packId: selectedPackId, entryId: row.entry_id, data: row },
),
onSelectGloss: (row) =>
selectSubEntity(
selectedPackId === null
? { kind: "none" }
: { kind: "logos_gloss", packId: selectedPackId, glossId: row.gloss_id, data: row },
),
onSelectMorphology: (row) =>
selectSubEntity(
selectedPackId === null
? { kind: "none" }
: {
kind: "logos_morphology",
packId: selectedPackId,
morphologyId: row.morphology_id,
data: row,
},
),
};
if (packsQuery.isLoading) {
return <LoadingState label="Loading CORE-Logos packs..." />;
}
@ -719,6 +853,10 @@ export function LogosRoute() {
safety={safetyQuery.data}
safetyLoading={safetyQuery.isLoading}
safetyError={safetyQuery.isError ? safetyQuery.error : null}
contents={contentsQuery.data}
contentsLoading={contentsQuery.isLoading}
contentsError={contentsQuery.isError ? contentsQuery.error : null}
selection={selection}
/>
</section>
</SplitPane>