diff --git a/AGENTS.md b/AGENTS.md index 735988f0..d5056555 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -75,7 +75,9 @@ against a live local stack (`make up`) and self-skip without one -- see `frontend/` has its own toolchain (Node pinned via `frontend/mise.toml`, pnpm via Corepack -- do not add a second Node package manager or a -floating Node version): +floating Node version). Related-node walk chips live in +`RelatedNodeChip` (ADR 0014). Caption and accessible name stay in +`relatedNodeCaption.ts`. Do not invent a primary organization. ```bash cd frontend && pnpm install diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index b5265451..b96169e0 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -331,6 +331,8 @@ unresolved aliases of that same org collapse into it. Related-node organization chips use the entity-level label (`Demo Corp (Company)`), not `Organization`. Related-node post chips show the post title only, not `(Post)`. +`RelatedNodeChip` plus `RelatedNodeChip.stories.tsx` are the +repeating walk inventory (ADR 0014). `GET /api/posts` and `GET /api/posts/{post_id}` include `voc_type_label` / `visibility_label` from `common_lookup_value` so diff --git a/CHANGELOG.md b/CHANGELOG.md index 17dfe0ed..6e78a2f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ All notable changes to this project are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.76.0] - 2026-08-17 + +### Changed + +- Related-node walk chips share `RelatedNodeChip` and a Storybook + inventory (ADR 0014). After seed, walking from Demo Corp still + shows "Ada West, Demo Corp (Our side)". Priya Nair stays + "Priya Nair (Counterparty)" — two orgs are never collapsed into + an invented primary. Click a chip to continue the walk or open + that post. + ## [0.75.0] - 2026-08-16 ### Changed diff --git a/docs/adr/0014-related-node-chip-stories.md b/docs/adr/0014-related-node-chip-stories.md new file mode 100644 index 00000000..e37a2b06 --- /dev/null +++ b/docs/adr/0014-related-node-chip-stories.md @@ -0,0 +1,41 @@ +# ADR-0014: Related-node chips share one module and story inventory + +- Status: Accepted +- Date: 2026-08-17 +- Stack: `feat/related-node-person-side-labels-main` (#92) @ `9bb5829` + +## Context + +Related-node walk chips live inline in `App.tsx`. The Figma synthetic +chip library (ADR 0002, +https://www.figma.com/design/nMmCeOdwGMKPxDrG8pWEAX) names the same +four buyer states: unique affiliation, side-only when two orgs would +invent a primary, organization level, and post title only. Repeating +the caption and accessible-name rules in App, tests, and a later +Storybook host would drift. + +## Decision + +1. `relatedNodeCaption` / `relatedNodeChipAccessibleName` own the + caption contract. Person chips name side plus a unique org. + Multiple distinct affiliations stay omitted. Organization chips + use the entity-level label. Post chips are the title only. +2. `RelatedNodeChip` is the only repeating walk control. +3. `RelatedNodeChip.stories.tsx` is the inventory. Host it with + Storybook 10 when the later token stack lands. Until then the + same states are locked by vitest. + +This slice does not add `affiliation_ambiguous` or a "multiple +organizations" caption. That next-action copy is #123 / #192. + +## Consequences + +Walking from Demo Corp still shows `Ada West, Demo Corp (Our side)`. +Priya Nair stays `Priya Nair (Counterparty)`. Click a chip to continue +the walk or open the post. Do not mix this increment into #74. + +## References + +World Wide Web Consortium. (2024). *Web content accessibility +guidelines (WCAG) 2.2* (Success Criterion 2.5.3 Label in Name). +https://www.w3.org/TR/WCAG22/#label-in-name diff --git a/frontend/package.json b/frontend/package.json index 575b7c58..9acf4850 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "0.75.0", + "version": "0.76.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b973228c..00f910f9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -54,6 +54,13 @@ import { } from "./api"; import { LineageDag } from "./LineageDag"; import { subgraphForPost } from "./lineageLayout"; +import { RelatedNodeChip } from "./RelatedNodeChip"; +import { + NODE_CORPORATE_ENTITY, + NODE_PERSON, + NODE_POST, + relatedNodeCaption, +} from "./relatedNodeCaption"; import "./App.css"; function orchestratorUnavailableMessage(err: unknown, action: string): string { @@ -465,34 +472,6 @@ function VocEvidenceSection({ ); } -const NODE_PERSON = "node_person"; -const NODE_POST = "node_post"; -const NODE_CORPORATE_ENTITY = "node_corporate_entity"; - -function relatedNodeCaption(node: RelatedNode): string { - const name = node.label ?? node.node_id; - if (node.node_type_code === NODE_PERSON) { - const side = node.person_side_label?.trim() || node.person_side_code?.trim(); - const org = node.affiliation_organization_name?.trim(); - if (side && org) { - return `${name}, ${org} (${side})`; - } - if (side) { - return `${name} (${side})`; - } - } - if (node.node_type_code === NODE_CORPORATE_ENTITY) { - const level = node.entity_level_label?.trim() || node.entity_level_code?.trim(); - if (level) { - return `${name} (${level})`; - } - } - if (node.node_type_code === NODE_POST) { - return name; - } - return `${name} (${node.ontology_label ?? node.node_type_code})`; -} - const VERIFICATION_BADGE: Record = { verify_pending: "Not yet checked", verify_corroborated: "Corroborated", @@ -701,48 +680,47 @@ function KeymanPanel({ ) : ( diff --git a/frontend/src/RelatedNodeChip.stories.tsx b/frontend/src/RelatedNodeChip.stories.tsx new file mode 100644 index 00000000..2155be87 --- /dev/null +++ b/frontend/src/RelatedNodeChip.stories.tsx @@ -0,0 +1,90 @@ +import type { RelatedNode } from "./api"; +import { RelatedNodeChip } from "./RelatedNodeChip"; +import type { RelatedNodeChipAction } from "./relatedNodeCaption"; + +type RelatedNodeStoryArgs = { + action: RelatedNodeChipAction; + onSelect: (node: RelatedNode) => void; + node: RelatedNode; +}; + +/** + * Storybook inventory for the repeating related-node chip. + * + * Host this file with Storybook 10 (Vite + React) when the later + * token stack lands. Until then the same four states are locked by + * RelatedNodeChip.test.tsx and relatedNodeCaption.test.ts. + * + * Buyer states after seed: + * - Ada West, Demo Corp (Our side) + * - Priya Nair (Counterparty) — two orgs stay omitted + * - Demo Corp (Company) + * - Linked post (title only) + */ +const meta = { + title: "Lineage/RelatedNodeChip", + component: RelatedNodeChip, +}; + +export default meta; + +function node(partial: Partial & Pick): RelatedNode { + return { + node_id: "node-1", + relevance: 0.4, + ...partial, + }; +} + +export const UniqueAffiliation = { + args: { + action: "walk_person", + onSelect: () => undefined, + node: node({ + node_type_code: "node_person", + label: "Ada West", + person_side_label: "Our side", + affiliation_organization_name: "Demo Corp", + }), + } satisfies RelatedNodeStoryArgs, +}; + +export const SideOnlyPluralAffiliations = { + args: { + action: "walk_person", + onSelect: () => undefined, + node: node({ + node_type_code: "node_person", + label: "Priya Nair", + person_side_label: "Counterparty", + }), + } satisfies RelatedNodeStoryArgs, +}; + +export const OrganizationAndPost = { + render: () => ( +
    +
  • + undefined} + node={node({ + node_type_code: "node_corporate_entity", + label: "Demo Corp", + entity_level_label: "Company", + })} + /> +
  • +
  • + undefined} + node={node({ + node_type_code: "node_post", + label: "Linked post", + })} + /> +
  • +
+ ), +}; diff --git a/frontend/src/RelatedNodeChip.test.tsx b/frontend/src/RelatedNodeChip.test.tsx new file mode 100644 index 00000000..4a46aa21 --- /dev/null +++ b/frontend/src/RelatedNodeChip.test.tsx @@ -0,0 +1,66 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import type { RelatedNode } from "./api"; +import { RelatedNodeChip } from "./RelatedNodeChip"; +import { + UniqueAffiliation, + SideOnlyPluralAffiliations, +} from "./RelatedNodeChip.stories"; + +function node(partial: Partial & Pick): RelatedNode { + return { + node_id: "node-1", + relevance: 0.4, + ...partial, + }; +} + +describe("RelatedNodeChip", () => { + it("keeps the unique-affiliation caption inside the walk name", () => { + const caption = "Ada West, Demo Corp (Our side)"; + render( + undefined} + />, + ); + expect(screen.getByRole("button", { name: `Related nodes for ${caption}` })).toHaveTextContent( + caption, + ); + }); + + it("does not invent a primary org on a side-only chip", () => { + const caption = "Priya Nair (Counterparty)"; + render( + undefined} + />, + ); + expect(screen.getByRole("button", { name: `Related nodes for ${caption}` })).toHaveTextContent( + caption, + ); + expect(screen.queryByText(/Northridge/)).not.toBeInTheDocument(); + }); + + it("opens the post when the buyer clicks a title-only chip", async () => { + const onSelect = vi.fn(); + render( + , + ); + await userEvent.click(screen.getByRole("button", { name: "Open related post: Linked post" })); + expect(onSelect).toHaveBeenCalledTimes(1); + expect(onSelect.mock.calls[0][0].node_id).toBe("post-1"); + }); +}); diff --git a/frontend/src/RelatedNodeChip.tsx b/frontend/src/RelatedNodeChip.tsx new file mode 100644 index 00000000..97a27d7a --- /dev/null +++ b/frontend/src/RelatedNodeChip.tsx @@ -0,0 +1,34 @@ +import type { RelatedNode } from "./api"; +import { + relatedNodeCaption, + relatedNodeChipAccessibleName, + type RelatedNodeChipAction, +} from "./relatedNodeCaption"; +import "./relatedNodeTokens.css"; + +/** + * One related-node chip. Caption, tokens, and accessible name stay + * one contract so the walk inventory matches the Figma chip library + * (ADR 0002 / 0014). + */ +export function RelatedNodeChip({ + node, + action, + onSelect, +}: { + node: RelatedNode; + action: RelatedNodeChipAction; + onSelect: (node: RelatedNode) => void; +}) { + const caption = relatedNodeCaption(node); + return ( + + ); +} diff --git a/frontend/src/relatedNodeCaption.test.ts b/frontend/src/relatedNodeCaption.test.ts new file mode 100644 index 00000000..53d678bd --- /dev/null +++ b/frontend/src/relatedNodeCaption.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; +import type { RelatedNode } from "./api"; +import { + relatedNodeCaption, + relatedNodeChipAccessibleName, +} from "./relatedNodeCaption"; + +function node(partial: Partial & Pick): RelatedNode { + return { + node_id: "node-1", + relevance: 0.4, + ...partial, + }; +} + +describe("relatedNodeCaption", () => { + it("names a unique affiliation and side", () => { + expect( + relatedNodeCaption( + node({ + node_type_code: "node_person", + label: "Ada West", + person_side_label: "Our side", + affiliation_organization_name: "Demo Corp", + }), + ), + ).toBe("Ada West, Demo Corp (Our side)"); + }); + + it("keeps a side-only chip when no single org is known", () => { + expect( + relatedNodeCaption( + node({ + node_type_code: "node_person", + label: "Priya Nair", + person_side_label: "Counterparty", + }), + ), + ).toBe("Priya Nair (Counterparty)"); + }); + + it("names the organization level, not the ontology class", () => { + expect( + relatedNodeCaption( + node({ + node_type_code: "node_corporate_entity", + label: "Demo Corp", + entity_level_label: "Company", + }), + ), + ).toBe("Demo Corp (Company)"); + }); + + it("shows a post title only", () => { + expect( + relatedNodeCaption( + node({ + node_type_code: "node_post", + label: "Linked post", + }), + ), + ).toBe("Linked post"); + }); +}); + +describe("relatedNodeChipAccessibleName", () => { + it("contains the visible caption for a walk chip", () => { + expect( + relatedNodeChipAccessibleName("Ada West, Demo Corp (Our side)", "walk_person"), + ).toBe("Related nodes for Ada West, Demo Corp (Our side)"); + }); + + it("names the next action on a post chip", () => { + expect(relatedNodeChipAccessibleName("Linked post", "open_post")).toBe( + "Open related post: Linked post", + ); + }); +}); diff --git a/frontend/src/relatedNodeCaption.ts b/frontend/src/relatedNodeCaption.ts new file mode 100644 index 00000000..170f236c --- /dev/null +++ b/frontend/src/relatedNodeCaption.ts @@ -0,0 +1,64 @@ +import type { RelatedNode } from "./api"; + +export const NODE_PERSON = "node_person"; +export const NODE_POST = "node_post"; +export const NODE_CORPORATE_ENTITY = "node_corporate_entity"; + +/** + * Decision-facing label for a related-node chip on the #92 walk. + * + * Person chips use the authorized side label and, when exactly one + * organization identity is known, that organization. Multiple + * distinct affiliations stay omitted so a second org is never + * collapsed into an invented primary. Organization chips use the + * entity-level label. Post chips are the title only. + */ +export function relatedNodeCaption(node: RelatedNode): string { + const name = node.label ?? node.node_id; + if (node.node_type_code === NODE_PERSON) { + const side = node.person_side_label?.trim() || node.person_side_code?.trim(); + const org = node.affiliation_organization_name?.trim(); + if (side && org) { + return `${name}, ${org} (${side})`; + } + if (side) { + return `${name} (${side})`; + } + } + if (node.node_type_code === NODE_CORPORATE_ENTITY) { + const level = node.entity_level_label?.trim() || node.entity_level_code?.trim(); + if (level) { + return `${name} (${level})`; + } + } + if (node.node_type_code === NODE_POST) { + return name; + } + return `${name} (${node.ontology_label ?? node.node_type_code})`; +} + +export type RelatedNodeChipAction = "walk_person" | "walk_entity" | "open_post"; + +/** + * Accessible name for a related-node chip. + * + * The visible caption is contained in the name (WCAG 2.2 Success + * Criterion 2.5.3). Walk chips continue the graph. Post chips open + * the evidence body. + */ +export function relatedNodeChipAccessibleName( + caption: string, + action: RelatedNodeChipAction, +): string { + switch (action) { + case "walk_person": + case "walk_entity": + return `Related nodes for ${caption}`; + case "open_post": + return `Open related post: ${caption}`; + default: { + const _exhaustive: never = action; + return _exhaustive; + } + } +} diff --git a/frontend/src/relatedNodeTokens.css b/frontend/src/relatedNodeTokens.css new file mode 100644 index 00000000..45e91885 --- /dev/null +++ b/frontend/src/relatedNodeTokens.css @@ -0,0 +1,23 @@ +:root { + --related-node-chip-font: inherit; + --related-node-chip-color: inherit; + --related-node-chip-padding: 0; + --related-node-chip-background: none; + --related-node-chip-border: none; + --related-node-chip-text-align: left; + --related-node-chip-cursor: pointer; +} + +.related-node-chip { + background: var(--related-node-chip-background); + border: var(--related-node-chip-border); + padding: var(--related-node-chip-padding); + color: var(--related-node-chip-color); + cursor: var(--related-node-chip-cursor); + font: var(--related-node-chip-font); + text-align: var(--related-node-chip-text-align); +} + +.related-node-chip:hover { + text-decoration: underline; +} diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index 1710c009..691380be 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -35,4 +35,4 @@ "sentence_excerpts", ] -__version__ = "0.75.0" +__version__ = "0.76.0" diff --git a/pyproject.toml b/pyproject.toml index 764ebad7..b10fc3bb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "0.75.0" +version = "0.76.0" description = "Reconstructs git-branch-style lineage DAGs from scattered short records using multi-channel score fusion and LLM adjudication." readme = "README.md" license = { text = "MIT" } diff --git a/uv.lock b/uv.lock index 08eab776..9de5184c 100644 --- a/uv.lock +++ b/uv.lock @@ -355,7 +355,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "0.75.0" +version = "0.76.0" source = { virtual = "." } dependencies = [ { name = "certifi" },