feat(workbench): CORE-Logos Studio LG-4 — Alignment tab + holonomy-absent guard

The trilingual resonance centerpiece, read-only over GET /logos/packs/{id}/alignment:
- deterministic DagViewer (he -> grc -> en longest-path) + selectable edge list;
  alignmentToDag is a pure projection pinned by a golden-file layout test
- invalid alignment targets surfaced honestly (the undeclared en-collapse-*
  anchors render a warning, never smoothed over)
- logos_alignment_edge evidence subject (logos_alignment_edge:<packId>/<edgeId>)
  with RightInspector projection + EvidenceChainRail derivation
- holonomy stays absent: no tab, no proof card, no success element (guard test)

Full workbench-ui vitest: 516 passed, clean exit; build clean.
This commit is contained in:
Shay 2026-06-14 12:59:35 -07:00
parent bcba7cba1e
commit 5474a15205
10 changed files with 579 additions and 3 deletions

View file

@ -247,6 +247,30 @@ export function deriveStages(subject: EvidenceSubject): RailStage[] | null {
stage("action", "dim", "proposal mode none"),
];
}
case "logos_alignment_edge": {
const d = subject.data;
return [
stage("intent", "dim", "not applicable to read-only Logos alignment inspection"),
stage("subject", "lit", "selected CORE-Logos alignment edge"),
stage(
"provenance",
d ? evidenceAny(...d.evidence_ids) : "hollow",
"evidence_ids (cross-language attestations)",
),
stage(
"admissibility",
d ? (d.invalid_target ? "hollow" : evidenceOf(d.target_pack_id)) : "hollow",
"target resolves to a declared lexicon entry (invalid_target = dangling)",
),
stage(
"replay",
d ? evidenceOf(d.relation) : "hollow",
"relation + weight (deterministic resonance edge)",
),
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

@ -423,6 +423,48 @@ function LogosMorphologyInspector({
);
}
function LogosAlignmentEdgeInspector({
subject,
}: {
subject: Extract<EvidenceSubject, { kind: "logos_alignment_edge" }>;
}) {
const { data } = subject;
return (
<div className="grid gap-3">
<h3 className="text-xs font-semibold text-[var(--color-text-secondary)]">
CORE-Logos Alignment Edge
</h3>
<p className="m-0 font-mono text-xs text-[var(--color-text-primary)]">
{subject.packId} · {subject.edgeId}
</p>
{data ? (
<MetadataTable
rows={[
{ key: "source", value: data.source_id, mono: true },
{ key: "target", value: data.target_id, mono: true },
{ key: "relation", value: data.relation },
{ key: "weight", value: data.weight.toFixed(2), mono: true },
{
key: "target_pack",
value: data.target_pack_id ?? "unresolved",
mono: data.target_pack_id !== null,
},
{
key: "target",
value: data.invalid_target ? "invalid (dangling)" : "resolved",
},
...(data.evidence_ids.length > 0
? [{ key: "evidence", value: data.evidence_ids.join(", "), mono: true }]
: []),
]}
/>
) : (
<DetailNotLoaded />
)}
</div>
);
}
function VaultEntryInspector({ subject }: { subject: Extract<EvidenceSubject, { kind: "vault_entry" }> }) {
const { data } = subject;
return (
@ -565,6 +607,8 @@ function InspectorProjection({ subject }: { subject: EvidenceSubject }) {
return <LogosGlossInspector subject={subject} />;
case "logos_morphology":
return <LogosMorphologyInspector subject={subject} />;
case "logos_alignment_edge":
return <LogosAlignmentEdgeInspector subject={subject} />;
case "vault_entry":
return <VaultEntryInspector subject={subject} />;
case "audit_event":

View file

@ -299,6 +299,10 @@ describe("logos sub-entity addresses (LG-3)", () => {
subject: { kind: "logos_morphology", packId: PACK, morphologyId: "he-morph-001" },
inspect: `logos_morphology:${PACK}/he-morph-001`,
},
{
subject: { kind: "logos_alignment_edge", packId: PACK, edgeId: "a1b2c3d4e5f60718" },
inspect: `logos_alignment_edge:${PACK}/a1b2c3d4e5f60718`,
},
];
it.each(subEntities)(

View file

@ -14,6 +14,7 @@ import type { EvidenceSubject } from "./evidenceContext";
// 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>
// logos_alignment_edge -> /logos/<packId>?inspect=logos_alignment_edge:<packId>/<edgeId>
// vault_entry -> /vault?inspect=vault:<entryIndex>
// audit_event -> /audit?inspect=audit:<eventId>
// calibration_class -> /calibration?inspect=calibration:<className>
@ -69,6 +70,12 @@ export function sameIdentity(a: EvidenceSubject, b: EvidenceSubject): boolean {
b.packId === a.packId &&
b.morphologyId === a.morphologyId
);
case "logos_alignment_edge":
return (
b.kind === "logos_alignment_edge" &&
b.packId === a.packId &&
b.edgeId === a.edgeId
);
case "vault_entry":
return b.kind === "vault_entry" && b.entryIndex === a.entryIndex;
case "audit_event":
@ -115,6 +122,7 @@ function subjectAddress(subject: AddressableSubject): SubjectAddress {
case "logos_entry":
case "logos_gloss":
case "logos_morphology":
case "logos_alignment_edge":
{
const params = emptyParams();
params.set(INSPECT_PARAM, subjectToInspectValue(subject));
@ -163,6 +171,8 @@ export function subjectToInspectValue(subject: AddressableSubject): string {
return `logos_gloss:${subject.packId}/${subject.glossId}`;
case "logos_morphology":
return `logos_morphology:${subject.packId}/${subject.morphologyId}`;
case "logos_alignment_edge":
return `logos_alignment_edge:${subject.packId}/${subject.edgeId}`;
case "vault_entry":
return `vault:${subject.entryIndex}`;
case "audit_event":
@ -249,6 +259,16 @@ export function inspectValueToSubject(
morphologyId: parts.subId,
};
}
case "logos_alignment_edge": {
const parts = splitPackScoped(id);
return parts === null
? null
: {
kind: "logos_alignment_edge",
packId: parts.packId,
edgeId: parts.subId,
};
}
case "vault": {
const entryIndex = parseTurnId(id);
return entryIndex === null ? null : { kind: "vault_entry", entryIndex };

View file

@ -18,6 +18,7 @@ import type {
LogosLexiconRow,
LogosGlossRow,
LogosMorphologyRow,
LogosAlignmentRow,
SafetyVerdict,
} from "../types/api";
@ -105,6 +106,12 @@ export type EvidenceSubject =
morphologyId: string;
data?: LogosMorphologyRow;
}
| {
kind: "logos_alignment_edge";
packId: string;
edgeId: string;
data?: LogosAlignmentRow;
}
| { 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,73 @@
{
"nodes": [
{
"id": "grc-023",
"layer": 0,
"row": 0,
"x": 24,
"y": 24
},
{
"id": "he-001",
"layer": 0,
"row": 1,
"x": 24,
"y": 86
},
{
"id": "en-collapse-breath",
"layer": 1,
"row": 0,
"x": 240,
"y": 24
},
{
"id": "grc-001",
"layer": 1,
"row": 1,
"x": 240,
"y": 86
},
{
"id": "en-024",
"layer": 2,
"row": 0,
"x": 456,
"y": 24
}
],
"edges": [
{
"from": "grc-001",
"to": "en-024",
"points": {
"x1": 384,
"y1": 108,
"x2": 456,
"y2": 46
}
},
{
"from": "grc-023",
"to": "en-collapse-breath",
"points": {
"x1": 168,
"y1": 46,
"x2": 240,
"y2": 46
}
},
{
"from": "he-001",
"to": "grc-001",
"points": {
"x1": 168,
"y1": 108,
"x2": 240,
"y2": 108
}
}
],
"width": 624,
"height": 154
}

View file

@ -0,0 +1,114 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { layoutDag } from "../../design/components/Dag/layout";
import type { LogosAlignmentRow } from "../../types/api";
import { AlignmentTab, alignmentToDag } from "./LogosAlignmentTab";
import golden from "./LogosAlignment.layout.golden.json";
// Fixture mirrors the golden generator exactly: he -> grc -> en resonance plus
// one undeclared en-collapse-* target (invalid).
const FIXTURE: LogosAlignmentRow[] = [
{
edge_id: "edge-he-grc-1",
source_id: "he-001",
target_id: "grc-001",
relation: "cross_lang.logos.utterance",
weight: 0.95,
evidence_ids: ["John1:1", "Gen1:1"],
target_pack_id: "grc_logos_micro_v1",
target_resolved: true,
invalid_target: false,
},
{
edge_id: "edge-grc-en-1",
source_id: "grc-001",
target_id: "en-024",
relation: "cross_lang.logos.utterance.en",
weight: 0.95,
evidence_ids: ["John1:1"],
target_pack_id: "en_core_cognition_v1",
target_resolved: true,
invalid_target: false,
},
{
edge_id: "edge-grc-collapse-1",
source_id: "grc-023",
target_id: "en-collapse-breath",
relation: "cross_lang.no_english_collapse",
weight: 0.0,
evidence_ids: ["adr-0073c"],
target_pack_id: null,
target_resolved: false,
invalid_target: true,
},
];
function compactLayout(rows: readonly LogosAlignmentRow[]) {
const { nodes, edges } = alignmentToDag(rows);
const layout = layoutDag(nodes, edges);
return {
nodes: layout.nodes.map(({ id, layer, row, x, y }) => ({ id, layer, row, x, y })),
edges: layout.edges.map(({ from, to, points }) => ({ from, to, points })),
width: layout.width,
height: layout.height,
};
}
describe("alignmentToDag", () => {
it("matches the committed golden deterministic layout", () => {
expect(compactLayout(FIXTURE)).toEqual(golden);
});
it("is order-independent in node placement (sorted) and re-runs identically", () => {
const reversed = [...FIXTURE].reverse();
// Nodes are sorted, so node placement is invariant to edge order.
expect(compactLayout(reversed).nodes).toEqual(compactLayout(FIXTURE).nodes);
// And the projection itself is referentially deterministic.
expect(alignmentToDag(FIXTURE)).toEqual(alignmentToDag(FIXTURE));
});
it("derives one node per distinct id and one edge per row", () => {
const { nodes, edges } = alignmentToDag(FIXTURE);
expect(nodes.map((n) => n.id)).toEqual([
"en-024",
"en-collapse-breath",
"grc-001",
"grc-023",
"he-001",
]);
expect(edges).toHaveLength(FIXTURE.length);
});
});
describe("AlignmentTab", () => {
it("renders the resonance graph, edge cards, and an honest invalid-target warning", () => {
render(<AlignmentTab rows={FIXTURE} selectedEdgeId={null} onSelect={() => {}} />);
expect(screen.getByLabelText("CORE-Logos cross-language alignment graph")).toBeInTheDocument();
expect(screen.getByText("3 alignment edges")).toBeInTheDocument();
// The undeclared collapse anchor surfaces as invalid — not smoothed over.
expect(screen.getByText("1 invalid target")).toBeInTheDocument();
expect(
screen.getByText(/invalid target — en-collapse-breath resolves to no declared lexicon entry/),
).toBeInTheDocument();
expect(screen.getAllByText("John1:1").length).toBeGreaterThan(0);
expect(screen.getByText("Gen1:1")).toBeInTheDocument();
});
it("invokes onSelect with the row when an edge card is activated", async () => {
const onSelect = vi.fn();
const user = userEvent.setup();
render(<AlignmentTab rows={FIXTURE} selectedEdgeId={null} onSelect={onSelect} />);
await user.click(screen.getByText("cross_lang.logos.utterance"));
expect(onSelect).toHaveBeenCalledWith(FIXTURE[0]);
});
it("renders an honest empty state when the pack declares no edges", () => {
render(<AlignmentTab rows={[]} selectedEdgeId={null} onSelect={() => {}} />);
expect(
screen.getByText("This pack declares no cross-language alignment edges."),
).toBeInTheDocument();
});
});

View file

@ -0,0 +1,168 @@
import { useMemo } from "react";
import { DagViewer } from "../../design/components/Dag/Dag";
import type { DagEdgeInput, DagNodeInput } from "../../design/components/Dag/layout";
import type { LogosAlignmentRow } from "../../types/api";
// Read-only CORE-Logos alignment tab (LG-4). The trilingual resonance graph
// (he -> grc -> en) renders through the deterministic Dag primitive; the edge
// list carries selection + honest invalid-target warnings. Nothing is
// recomputed — every value is a field from GET /logos/packs/{id}/alignment.
/**
* Pure, deterministic projection of alignment rows into Dag inputs. Exported so
* the golden-file layout test can pin the geometry. Node order is sorted (not
* row order) so the layout is stable regardless of edge ordering; edges follow
* row order (the endpoint is already deterministic).
*/
export function alignmentToDag(rows: readonly LogosAlignmentRow[]): {
nodes: DagNodeInput[];
edges: DagEdgeInput[];
} {
const ids = new Set<string>();
for (const row of rows) {
ids.add(row.source_id);
ids.add(row.target_id);
}
const nodes: DagNodeInput[] = Array.from(ids)
.sort((a, b) => a.localeCompare(b))
.map((id) => ({ id, label: id }));
const edges: DagEdgeInput[] = rows.map((row) => ({
from: row.source_id,
to: row.target_id,
label: row.relation,
}));
return { nodes, edges };
}
function languageOf(id: string): string {
if (id.startsWith("he-")) return "Hebrew";
if (id.startsWith("grc-")) return "Koine Greek";
if (id.startsWith("en-")) return "English";
return "—";
}
function EdgeCard({
row,
selected,
onSelect,
}: {
row: LogosAlignmentRow;
selected: boolean;
onSelect: () => void;
}) {
return (
<div
role="button"
tabIndex={0}
aria-current={selected ? "true" : undefined}
onClick={onSelect}
onKeyDown={(event) => {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
onSelect();
}}
className={`grid gap-1 rounded-md border px-3 py-2 text-left transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--color-focus-ring)] ${
row.invalid_target
? "border-[var(--color-state-warning-border)] bg-[var(--color-state-warning-bg)]"
: selected
? "border-[var(--color-selected-border)] bg-[var(--color-selected-bg)]"
: "border-[var(--color-border-subtle)] hover:bg-[var(--color-surface-inset)]"
}`}
>
<div className="flex items-center justify-between gap-2 font-mono text-xs text-[var(--color-text-primary)]">
<span className="truncate">
{row.source_id} <span aria-hidden className="text-[var(--color-text-muted)]"></span> {row.target_id}
</span>
<span className="text-[var(--color-text-secondary)]">{row.weight.toFixed(2)}</span>
</div>
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] text-[var(--color-text-secondary)]">
<span>{row.relation}</span>
<span aria-hidden className="text-[var(--color-text-muted)]">·</span>
<span>
{languageOf(row.source_id)} {languageOf(row.target_id)}
</span>
{row.target_pack_id ? (
<>
<span aria-hidden className="text-[var(--color-text-muted)]">·</span>
<span className="font-mono">{row.target_pack_id}</span>
</>
) : null}
</div>
{row.evidence_ids.length > 0 ? (
<div className="flex flex-wrap gap-1">
{row.evidence_ids.map((e) => (
<span
key={e}
className="inline-flex h-5 items-center rounded border border-[var(--color-border-subtle)] bg-[var(--color-surface-inset)] px-1.5 text-[10px] font-mono text-[var(--color-text-secondary)]"
>
{e}
</span>
))}
</div>
) : null}
{row.invalid_target ? (
<p className="m-0 text-[11px] text-[var(--color-state-warning-text)]">
invalid target {row.target_id} resolves to no declared lexicon entry
</p>
) : null}
</div>
);
}
export function AlignmentTab({
rows,
selectedEdgeId,
onSelect,
}: {
rows: readonly LogosAlignmentRow[];
selectedEdgeId: string | null;
onSelect: (row: LogosAlignmentRow) => void;
}) {
const { nodes, edges } = useMemo(() => alignmentToDag(rows), [rows]);
const invalidCount = useMemo(
() => rows.filter((r) => r.invalid_target).length,
[rows],
);
if (rows.length === 0) {
return (
<p className="m-0 text-sm text-[var(--color-text-secondary)]">
This pack declares no cross-language alignment edges.
</p>
);
}
return (
<div className="grid min-h-0 gap-4">
<section>
<h3 className="m-0 mb-2 text-xs font-semibold text-[var(--color-text-secondary)]">
Resonance graph (deterministic)
</h3>
<DagViewer
nodes={nodes}
edges={edges}
ariaLabel="CORE-Logos cross-language alignment graph"
showInspector={false}
/>
</section>
<section className="grid gap-2">
<div className="flex items-center justify-between text-xs text-[var(--color-text-secondary)]">
<span>{rows.length} alignment edges</span>
{invalidCount > 0 ? (
<span className="text-[var(--color-state-warning-text)]">
{invalidCount} invalid target{invalidCount === 1 ? "" : "s"}
</span>
) : null}
</div>
{rows.map((row) => (
<EdgeCard
key={row.edge_id}
row={row}
selected={row.edge_id === selectedEdgeId}
onSelect={() => onSelect(row)}
/>
))}
</section>
</div>
);
}

View file

@ -5,6 +5,7 @@ import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createTestQueryClient } from "../../test/createTestQueryClient";
import type {
LogosAlignmentRow,
LogosPackContents,
LogosPackOverview,
LogosPackSummary,
@ -174,6 +175,34 @@ function contentsFor(packId: string): LogosPackContents {
};
}
function alignmentFor(packId: string): LogosAlignmentRow[] {
const he = packId.includes("he");
return [
{
edge_id: "edge-1",
source_id: he ? "he-001" : "grc-001",
target_id: he ? "grc-001" : "en-024",
relation: "cross_lang.logos.utterance",
weight: 0.95,
evidence_ids: ["John1:1", "Gen1:1"],
target_pack_id: he ? "grc_logos_micro_v1" : "en_core_cognition_v1",
target_resolved: true,
invalid_target: false,
},
{
edge_id: "edge-collapse",
source_id: he ? "he-023" : "grc-023",
target_id: "en-collapse-breath",
relation: "cross_lang.no_english_collapse",
weight: 0.0,
evidence_ids: ["adr-0073c"],
target_pack_id: null,
target_resolved: false,
invalid_target: true,
},
];
}
function okEnvelope(data: unknown) {
return { ok: true, generated_at: "2026-06-14T00:00:00Z", data };
}
@ -202,6 +231,13 @@ function stubLogosFetch() {
json: async () => okEnvelope(contentsFor(packId)),
});
}
const alignmentMatch = path.match(/^\/logos\/packs\/([^/]+)\/alignment$/);
if (alignmentMatch) {
const packId = decodeURIComponent(alignmentMatch[1]);
return Promise.resolve({
json: async () => okEnvelope({ items: alignmentFor(packId) }),
});
}
const overviewMatch = path.match(/^\/logos\/packs\/([^/]+)$/);
if (overviewMatch) {
const packId = decodeURIComponent(overviewMatch[1]);
@ -298,7 +334,7 @@ describe("LogosRoute", () => {
expect(screen.queryByText(/Draft proposal/i)).not.toBeInTheDocument();
});
it("selects a pack, renders Overview, projects logos_pack evidence, and defers only the alignment endpoint", async () => {
it("selects a pack, renders Overview, projects logos_pack evidence, and fetches contents + alignment", async () => {
const { paths } = stubLogosFetch();
const user = userEvent.setup();
renderRoute();
@ -316,9 +352,9 @@ describe("LogosRoute", () => {
expect(screen.queryByText(/proof card/i)).not.toBeInTheDocument();
expect(screen.getByText("CORE-Logos Pack")).toBeInTheDocument();
expect(screen.getByText("0 / missing_evidence")).toBeInTheDocument();
// LG-3 fetches contents eagerly on pack-select; /alignment stays deferred to LG-4.
// LG-3 + LG-4 fetch contents and alignment eagerly on pack-select.
expect(paths).toContain("/logos/packs/he_logos_micro_v1/contents");
expect(paths).not.toContain("/logos/packs/he_logos_micro_v1/alignment");
expect(paths).toContain("/logos/packs/he_logos_micro_v1/alignment");
});
it("renders Identity passport fields and the raw live overview projection", async () => {
@ -411,4 +447,44 @@ describe("LogosRoute", () => {
await user.click(screen.getByText("word, matter, or spoken thing"));
expect(await screen.findByText("CORE-Logos Gloss")).toBeInTheDocument();
});
it("renders the Alignment tab, surfaces invalid targets, and projects a logos_alignment_edge subject", async () => {
stubLogosFetch();
const user = userEvent.setup();
renderRoute("/logos/he_logos_micro_v1");
await user.click(await screen.findByRole("tab", { name: "Alignment" }));
// Real edges from /alignment, deterministic graph, honest invalid-target warning.
expect(
await screen.findByLabelText("CORE-Logos cross-language alignment graph"),
).toBeInTheDocument();
expect(screen.getByText("2 alignment edges")).toBeInTheDocument();
expect(screen.getByText("1 invalid target")).toBeInTheDocument();
expect(
screen.getByText(/invalid target — en-collapse-breath resolves to no declared lexicon entry/),
).toBeInTheDocument();
await user.click(screen.getByText("cross_lang.logos.utterance"));
expect(await screen.findByText("CORE-Logos Alignment Edge")).toBeInTheDocument();
await waitFor(() =>
expect(screen.getByTestId("location")).toHaveTextContent(
"inspect=logos_alignment_edge%3Ahe_logos_micro_v1%2Fedge-1",
),
);
});
it("never renders a holonomy tab or proof/success element, even with alignment present", async () => {
stubLogosFetch();
const user = userEvent.setup();
renderRoute("/logos/he_logos_micro_v1");
await user.click(await screen.findByRole("tab", { name: "Alignment" }));
await screen.findByLabelText("CORE-Logos cross-language alignment graph");
expect(screen.queryByRole("tab", { name: /holonomy/i })).not.toBeInTheDocument();
expect(screen.queryByText(/proof card/i)).not.toBeInTheDocument();
expect(screen.queryByText(/holonomy proof/i)).not.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 {
useLogosPackAlignment,
useLogosPackContents,
useLogosPackOverview,
useLogosPacks,
@ -21,6 +22,7 @@ import {
SafetyVerdictBadge,
} from "../../design/components/badges";
import type {
LogosAlignmentRow,
LogosAlignmentTargetIssue,
LogosGlossRow,
LogosLexiconRow,
@ -36,6 +38,7 @@ import { pushRecentItem } from "../commandRegistry";
import { subjectToUrl } from "../evidenceAddress";
import { useEvidenceSubject, type EvidenceSubject } from "../evidenceContext";
import { GlossesTab, LexiconTab, MorphologyTab } from "./LogosContentsTabs";
import { AlignmentTab } from "./LogosAlignmentTab";
const LOGOS_TABS: readonly Tab[] = [
{ id: "overview", label: "Overview" },
@ -43,6 +46,7 @@ const LOGOS_TABS: readonly Tab[] = [
{ id: "lexicon", label: "Lexicon" },
{ id: "glosses", label: "Glosses" },
{ id: "morphology", label: "Morphology" },
{ id: "alignment", label: "Alignment" },
{ id: "safety", label: "Safety" },
];
@ -586,10 +590,12 @@ interface ContentsSelection {
entryId: string | null;
glossId: string | null;
morphologyId: string | null;
edgeId: string | null;
danglingEntryIds: ReadonlySet<string>;
onSelectEntry: (row: LogosLexiconRow) => void;
onSelectGloss: (row: LogosGlossRow) => void;
onSelectMorphology: (row: LogosMorphologyRow) => void;
onSelectEdge: (row: LogosAlignmentRow) => void;
}
function ContentsGate({
@ -658,6 +664,9 @@ function Workspace({
contents,
contentsLoading,
contentsError,
alignment,
alignmentLoading,
alignmentError,
selection,
}: {
selectedPackId: string | null;
@ -670,6 +679,9 @@ function Workspace({
contents?: LogosPackContents;
contentsLoading: boolean;
contentsError: unknown;
alignment?: LogosAlignmentRow[];
alignmentLoading: boolean;
alignmentError: unknown;
selection: ContentsSelection;
}) {
const [activeTab, setActiveTab] = useState("overview");
@ -721,6 +733,24 @@ function Workspace({
selection={selection}
/>
) : null}
{activeTab === "alignment" ? (
alignmentLoading ? (
<LoadingState label="Loading CORE-Logos alignment..." />
) : alignmentError ? (
<ErrorState
whatFailed={errorMessage(alignmentError)}
mutationStatus="No Logos mutation occurred."
reproducer={`curl /logos/packs/${selectedPackId}/alignment`}
retrySafety="Retry: safe"
/>
) : alignment ? (
<AlignmentTab
rows={alignment}
selectedEdgeId={selection.edgeId}
onSelect={selection.onSelectEdge}
/>
) : null
) : null}
{activeTab === "safety" ? (
safetyLoading ? (
<LoadingState label="Loading CORE-Logos safety..." />
@ -750,6 +780,7 @@ export function LogosRoute() {
const overviewQuery = useLogosPackOverview(selectedPackId);
const safetyQuery = useLogosPackSafety(selectedPackId);
const contentsQuery = useLogosPackContents(selectedPackId);
const alignmentQuery = useLogosPackAlignment(selectedPackId);
useEffect(() => {
if (selectedPackId === null) return;
@ -782,6 +813,7 @@ export function LogosRoute() {
entryId: subject.kind === "logos_entry" ? subject.entryId : null,
glossId: subject.kind === "logos_gloss" ? subject.glossId : null,
morphologyId: subject.kind === "logos_morphology" ? subject.morphologyId : null,
edgeId: subject.kind === "logos_alignment_edge" ? subject.edgeId : null,
danglingEntryIds: new Set(
safetyQuery.data?.dangling_morphology_links.map((issue) => issue.entry_id) ?? [],
),
@ -808,6 +840,17 @@ export function LogosRoute() {
data: row,
},
),
onSelectEdge: (row) =>
selectSubEntity(
selectedPackId === null
? { kind: "none" }
: {
kind: "logos_alignment_edge",
packId: selectedPackId,
edgeId: row.edge_id,
data: row,
},
),
};
if (packsQuery.isLoading) {
@ -856,6 +899,9 @@ export function LogosRoute() {
contents={contentsQuery.data}
contentsLoading={contentsQuery.isLoading}
contentsError={contentsQuery.isError ? contentsQuery.error : null}
alignment={alignmentQuery.data}
alignmentLoading={alignmentQuery.isLoading}
alignmentError={alignmentQuery.isError ? alignmentQuery.error : null}
selection={selection}
/>
</section>