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:
parent
b7ec02b869
commit
bcba7cba1e
10 changed files with 1001 additions and 8 deletions
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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 [
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
327
workbench-ui/src/app/logos/LogosContentsTabs.tsx
Normal file
327
workbench-ui/src/app/logos/LogosContentsTabs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue