diff --git a/workbench-ui/src/api/client.ts b/workbench-ui/src/api/client.ts index 1ddae855..78489df8 100644 --- a/workbench-ui/src/api/client.ts +++ b/workbench-ui/src/api/client.ts @@ -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 { + return apiFetch( + `/logos/packs/${encodeURIComponent(packId)}/contents`, + ); +} + +export async function fetchLogosPackAlignment( + packId: string, +): Promise { + const envelope = await apiFetch>( + `/logos/packs/${encodeURIComponent(packId)}/alignment`, + ); + return envelope.items; +} + export async function fetchVaultSummary(): Promise { return apiFetch("/vault/summary"); } diff --git a/workbench-ui/src/api/queries.ts b/workbench-ui/src/api/queries.ts index 58c47d3a..fc57fa97 100644 --- a/workbench-ui/src/api/queries.ts +++ b/workbench-ui/src/api/queries.ts @@ -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({ + 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({ + 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({ queryKey: ["api", "vault", "summary"], diff --git a/workbench-ui/src/app/EvidenceChainRail.tsx b/workbench-ui/src/app/EvidenceChainRail.tsx index 7580994a..9b483458 100644 --- a/workbench-ui/src/app/EvidenceChainRail.tsx +++ b/workbench-ui/src/app/EvidenceChainRail.tsx @@ -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 [ diff --git a/workbench-ui/src/app/RightInspector.tsx b/workbench-ui/src/app/RightInspector.tsx index da623f87..954e92c0 100644 --- a/workbench-ui/src/app/RightInspector.tsx +++ b/workbench-ui/src/app/RightInspector.tsx @@ -309,6 +309,120 @@ function LogosPackInspector({ ); } +function LogosEntryInspector({ + subject, +}: { + subject: Extract; +}) { + const { data } = subject; + return ( +
+

+ CORE-Logos Entry +

+

+ {subject.packId} · {subject.entryId} +

+ {data ? ( + 0 + ? [{ key: "domains", value: data.semantic_domains.join(", ") }] + : []), + ]} + /> + ) : ( + + )} +
+ ); +} + +function LogosGlossInspector({ + subject, +}: { + subject: Extract; +}) { + const { data } = subject; + return ( +
+

+ CORE-Logos Gloss +

+

+ {subject.packId} · {subject.glossId} +

+ {data ? ( + 0 + ? [{ key: "entries", value: data.entry_ids.join(", "), mono: true }] + : []), + ...(data.epistemic_status + ? [{ key: "epistemic", value: data.epistemic_status }] + : []), + ]} + /> + ) : ( + + )} +
+ ); +} + +function LogosMorphologyInspector({ + subject, +}: { + subject: Extract; +}) { + 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 ( +
+

+ CORE-Logos Morphology +

+

+ {subject.packId} · {subject.morphologyId} +

+ {data ? ( + + ) : ( + + )} +
+ ); +} + function VaultEntryInspector({ subject }: { subject: Extract }) { const { data } = subject; return ( @@ -445,6 +559,12 @@ function InspectorProjection({ subject }: { subject: EvidenceSubject }) { return ; case "logos_pack": return ; + case "logos_entry": + return ; + case "logos_gloss": + return ; + case "logos_morphology": + return ; case "vault_entry": return ; case "audit_event": diff --git a/workbench-ui/src/app/evidenceAddress.test.ts b/workbench-ui/src/app/evidenceAddress.test.ts index 26f62d80..53bde301 100644 --- a/workbench-ui/src/app/evidenceAddress.test.ts +++ b/workbench-ui/src/app/evidenceAddress.test.ts @@ -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); + }); +}); diff --git a/workbench-ui/src/app/evidenceAddress.ts b/workbench-ui/src/app/evidenceAddress.ts index 8e0c6691..260ff8c9 100644 --- a/workbench-ui/src/app/evidenceAddress.ts +++ b/workbench-ui/src/app/evidenceAddress.ts @@ -11,6 +11,9 @@ import type { EvidenceSubject } from "./evidenceContext"; // run -> /runs/ // pack -> /packs/ // logos_pack -> /logos/ (?inspect uses logos:) +// logos_entry -> /logos/?inspect=logos_entry:/ +// logos_gloss -> /logos/?inspect=logos_gloss:/ +// logos_morphology -> /logos/?inspect=logos_morphology:/ // vault_entry -> /vault?inspect=vault: // audit_event -> /audit?inspect=audit: // calibration_class -> /calibration?inspect=calibration: @@ -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 `/`. 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 }; diff --git a/workbench-ui/src/app/evidenceContext.tsx b/workbench-ui/src/app/evidenceContext.tsx index 1752d224..f9c948c0 100644 --- a/workbench-ui/src/app/evidenceContext.tsx +++ b/workbench-ui/src/app/evidenceContext.tsx @@ -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 } diff --git a/workbench-ui/src/app/logos/LogosContentsTabs.tsx b/workbench-ui/src/app/logos/LogosContentsTabs.tsx new file mode 100644 index 00000000..1c76614e --- /dev/null +++ b/workbench-ui/src/app/logos/LogosContentsTabs.tsx @@ -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 ( + + ); +} + +function Tag({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + +function DanglingFlag() { + return ( + + dangling morphology + + ); +} + +function RowShell({ + selected, + onSelect, + children, +}: { + selected: boolean; + onSelect: () => void; + children: React.ReactNode; +}) { + return ( +
+ {children} +
+ ); +} + +// ---------------------------------------------------------------- Lexicon ---- + +export function LexiconTab({ + rows, + danglingEntryIds, + selectedEntryId, + onSelect, +}: { + rows: readonly LogosLexiconRow[]; + danglingEntryIds: ReadonlySet; + selectedEntryId: string | null; + onSelect: (row: LogosLexiconRow) => void; +}) { + const [query, setQuery] = useState(""); + const [statusFilter, setStatusFilter] = useState("all"); + const [domainFilter, setDomainFilter] = useState("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 ( +
+ +
+ status + setStatusFilter("all")} /> + {statuses.map((s) => ( + setStatusFilter(s)} /> + ))} +
+ {domains.length > 0 ? ( +
+ domain + setDomainFilter("all")} /> + {domains.map((d) => ( + setDomainFilter(d)} /> + ))} +
+ ) : null} +
+ {filtered.length} of {rows.length} entries +
+ row.entry_id} + onActivate={onSelect} + renderItem={(row, _index, focused) => ( + onSelect(row)}> +
+ + {row.surface} · {row.lemma} + + {row.entry_id} +
+
+ {row.language} + {row.pos ?? row.part_of_speech ? {row.pos ?? row.part_of_speech} : null} + {row.epistemic_status} + {row.semantic_domains.map((d) => ( + {d} + ))} + {danglingEntryIds.has(row.entry_id) ? : null} +
+
+ )} + /> +
+ ); +} + +// ----------------------------------------------------------------- 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 ( +
+ +
+ {filtered.length} of {rows.length} glosses +
+ row.gloss_id} + onActivate={onSelect} + renderItem={(row, _index, focused) => ( + onSelect(row)}> +
+ {row.lemma} + {row.pos ? {row.pos} : null} +
+

{row.gloss}

+ {row.entry_ids.length > 0 ? ( +
+ {row.entry_ids.map((e) => ( + {e} + ))} +
+ ) : null} +
+ )} + /> +
+ ); +} + +// -------------------------------------------------------------- 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 ( +
+ +
+ {filtered.length} of {rows.length} morphology records +
+ row.morphology_id} + onActivate={onSelect} + renderItem={(row, _index, focused) => { + const chain = morphologyChain(row); + return ( + onSelect(row)} + > +
+ + {row.surface} · {row.lemma} + + + {row.morphology_id} + +
+ {chain.length > 0 ? ( +
+ {chain.map((seg, i) => ( + + {i > 0 ? : null} + {seg} + + ))} +
+ ) : null} +
+ ); + }} + /> +
+ ); +} diff --git a/workbench-ui/src/app/logos/LogosRoute.test.tsx b/workbench-ui/src/app/logos/LogosRoute.test.tsx index 9db98ac9..19ad057b 100644 --- a/workbench-ui/src/app/logos/LogosRoute.test.tsx +++ b/workbench-ui/src/app/logos/LogosRoute.test.tsx @@ -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(); + }); }); diff --git a/workbench-ui/src/app/logos/LogosRoute.tsx b/workbench-ui/src/app/logos/LogosRoute.tsx index 0f047667..1a51061b 100644 --- a/workbench-ui/src/app/logos/LogosRoute.tsx +++ b/workbench-ui/src/app/logos/LogosRoute.tsx @@ -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; + 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 ; + if (contentsError) { + return ( + + ); + } + if (!contents) return null; + if (tab === "lexicon") { + return ( + + ); + } + if (tab === "glosses") { + return ( + + ); + } + return ( + + ); +} + 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 ( " }} /> ); @@ -618,6 +700,9 @@ function Workspace({ if (!overview) return null; + const isContentsTab = + activeTab === "lexicon" || activeTab === "glosses" || activeTab === "morphology"; + return ( {activeTab === "overview" ? : null} {activeTab === "identity" ? : null} + {isContentsTab ? ( + + ) : null} {activeTab === "safety" ? ( safetyLoading ? ( @@ -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 ; } @@ -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} />