diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index dbb8652f..20b8c360 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -202,9 +202,30 @@ Plans: - [x] 16-04-PLAN.md — Integration: Review page wiring + visual verification **UI hint**: yes +### Phase 17: Graph as Entity-Scoped Tab in Detail Pane +**Milestone**: v0.5.0 +**Goal**: Move the entity graph from a peer-tab in Developer view (and a hidden icon button in Standard view) into a `Detail | Graph` tab strip at the top of the right-side detail pane, applied uniformly across both views and across all three entity types (class, property, individual). Property graphs render real domain/range/parent-property edges (an explicit improvement over WebProtege's empty-island treatment). +**Depends on**: none (touches Standard and Developer layouts independently of other Phase 17+ work) +**Requirements**: R1, R2, R3, R4, R5, R6, R7, R8, R9, R10, R11 (locked in 17-SPEC.md) +**Success Criteria** (what must be TRUE): + 1. Both Standard and Developer views render a `Detail | Graph` tab strip at the top of the right-side detail pane for all three entity types (class, property, individual). The Developer view's `Tree | Source | Graph` peer-tab strip is reduced to `Tree | Source` — no separate graph view. + 2. Single-click on a non-focus graph node re-centers the graph; double-click fully selects the entity (URL update, tree highlight, detail pane refresh). Pre-existing behavior preserved. + 3. The active tab persists across selection changes (clicking a different entity in the tree, in search, or via double-click in the graph keeps the user on whichever tab they were on). + 4. The default tab on first entity load in a new session is Detail; subsequent selections honor the user's last-active tab. + 5. The in-pane graph has an "Expand" affordance that opens the existing `EntityGraphModal` (no new modal component). + 6. Property graphs render domain classes, range classes, parent/child properties, and see-also as actual nodes/edges — not a single isolated focus node. Annotation properties render an empty-state message when they have no domain/range/see-also. + 7. The Standard view's prior small "Graph" icon button in the detail panel is removed; the new tab strip is its only graph entry point in that view. +**Plans**: 3 plans +Plans: +- [ ] 17-01-PLAN.md — ontokit-api: extend /entity-graph for property + individual focus + edge_kind enum + tests (cross-repo, autonomous: false) +- [ ] 17-02-PLAN.md — ontokit-web plumbing: PaneTabStrip + SourceTabBody + FullSourceOverlay + useFullSourceOverlay + selectionStore activePaneTab + EntityModal rename + OntologyEdge palette extension +- [ ] 17-03-PLAN.md — ontokit-web layout integration: wire PaneTabStrip into Standard + Developer layouts; remove Graph icon button + Source link + Tree|Source|Graph mode strip; AI smoke test + human UAT checklist +**UI hint**: yes +**Refs**: `.planning/notes/graph-as-entity-pane.md` (decision capture) + ## Progress -**Execution Order:** 7 -> 8 -> 9 -> 10 -> 11 -> 12 -> 13 -> 14 -> 15 -> 16 +**Execution Order:** 7 -> 8 -> 9 -> 10 -> 11 -> 12 -> 13 -> 14 -> 15 -> 16 -> 17 | Phase | Milestone | Plans Complete | Status | Completed | |-------|-----------|----------------|--------|-----------| diff --git a/.planning/STATE.md b/.planning/STATE.md index 7f7fff53..6e5952b0 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -3,15 +3,15 @@ gsd_state_version: 1.0 milestone: v0.4.0 milestone_name: LLM-Assisted Ontology Improvements status: completed -stopped_at: Phase 16 complete — human-verify approved for 16-04 -last_updated: "2026-04-08T15:38:02.879Z" +stopped_at: Phase 17 plans approved +last_updated: "2026-05-02T18:49:51.167Z" last_activity: 2026-04-08 progress: - total_phases: 10 + total_phases: 11 completed_phases: 9 - total_plans: 35 + total_plans: 38 completed_plans: 35 - percent: 100 + percent: 92 --- # Project State @@ -209,6 +209,6 @@ None yet. ## Session Continuity -Last session: 2026-04-08T15:27:40.844Z -Stopped at: Phase 16 complete — human-verify approved for 16-04 -Resume file: None +Last session: 2026-05-02T18:49:51.159Z +Stopped at: Phase 17 plans approved +Resume file: .planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-03-PLAN.md diff --git a/.planning/notes/graph-as-entity-pane.md b/.planning/notes/graph-as-entity-pane.md new file mode 100644 index 00000000..6bdf1fc7 --- /dev/null +++ b/.planning/notes/graph-as-entity-pane.md @@ -0,0 +1,149 @@ +--- +title: Graph as Entity-Scoped Tab in Detail Pane — Design Decisions +date: 2026-05-02 +context: Pre-phase exploration + sketch session; captured for Phase 17 spec/plan. +references: + - WebProtege Entity Graph (stacked under detail panel) + - Folio Enrich (Candidate Details | Entity Graph tab strip) + - Current OntoKit Standard view (Graph icon button in right-side detail panel) + - Current OntoKit Developer view (Graph as peer-tab next to Tree/Source) +sketches: + - .planning/sketches/001-tab-strip-language/ (winner: D — icon + label, ABOVE entity header) + - .planning/sketches/002-standard-view-layout/ (winner: D — Compact + 50/50 tabs) + - .planning/sketches/003-developer-view-and-modal/ (winner: applied + Source-as-third-tab restructure) + - .planning/sketches/004-open-full-source/ (winner: C — Modal ⇄ Maximize round-trip) +--- + +# Graph as Entity-Scoped Tab in Detail Pane + +The graph today is misplaced in both views: a peer-tab in Developer (alongside Tree/Source — implies it's a *navigation surface*, not an entity-scoped view), and a small icon button in Standard that users miss entirely. This note captures the design decisions made during exploration (Decisions 1-8) and refined through a four-sketch validation pass (Decisions 9-11 + visual specs). + +## Problem statement + +A graph is fundamentally an entity-scoped view — it shows a focused class/property/individual surrounded by its neighborhood. The current placement in both views obscures that relationship. Users in Standard view don't realize the graph exists. Users in Developer view treat it as a separate mode they switch to, breaking the connection between the selected entity and what's rendered. + +## The design contract (11 decisions) + +### 1. Purpose: read-only neighborhood view (verify + explore) + +The graph is a read-only context view. It helps users see the selected entity in relation to its siblings, parents, cousins. It is not a manipulation surface (no drag-to-reparent in this scope) and not a presentation/screenshot surface (that's a side benefit). + +### 2. Layout: tabbed swap inside the existing detail pane + +The right-side detail pane gains a tab strip at the top. The two (Standard) or three (Developer) tabs swap content in the same pane. Same screen real estate as today; tabs are one click away; swapped content is never visible simultaneously. + +**Sketch refinements (from 001 + 002):** +- **Tab strip sits ABOVE the entity header** (not below). Reads as "Entity workspace with mode selector," not "Entity card with buried tab." Sketch 001 Variant D. +- **Compact density** — tab buttons use 8px vertical padding + text-xs, matching the left-pane tree-tabs strip exactly so the bottom borders align horizontally across the whole editor. Sketch 002 Variant D. +- **Tabs span the full pane width** — `flex: 1 1 0` so 2 tabs are 50/50, 3 tabs are 33/33/33. Reads as a major mode-selector, not a small control collapsed to one side. +- **Active state** — accent underline + accent-tinted background fill. Same visual language as the left-pane tree-tabs (Classes / Properties / Individuals), so the two strips read as a coherent family. +- **Icons + sentence-case labels** — Lucide `file-text` for Detail, a 3-node graph glyph for Graph, `chevrons-left-right` for Source. Underlines indicate active. + +### 3. Old peer-tab Graph: removed entirely + +Developer view's `Tree | Source | Graph` peer-tab strip is removed. Graph is *only* ever in the right-pane tab strip. Standard view's small icon button is also removed. + +**Sketch refinement (from 003):** the entire left-pane mode strip (Tree | Source) is removed. The left pane shows only the tree (with `Classes | Properties | Individuals` sub-tabs). Source migrates to the right pane as a third tab — see Decision 9. + +### 4. Full-screen affordance: reuse existing EntityGraphModal + +The in-pane graph has an "Expand" button (top-right of the canvas). Clicking it opens the existing `EntityGraphModal` component as a centered overlay above the editor, dismissable via Escape / click-outside / close button. Same component reused for both Standard and Developer views. + +### 5. Entity-type scope: all three (class, property, individual) + +Class, Property, and Individual detail panes each get the tab strip. The graph component adapts per entity type: + +- **Class:** parent classes, child classes, siblings, equivalent classes, disjoint classes, see-also. +- **Property:** **domain classes, range classes**, parent/child properties, see-also. _Explicit improvement over WebProtege, which leaves the property graph as an isolated single-node island._ OntoKit's property graph must show the relationships that make a property meaningful. +- **Individual:** class assertions (rdf:type), object property values, see-also, sameAs. + +**Edge case (defer to plan/build):** Annotation properties typically have no domain/range. Graph for them shows just see-also if any, plus a friendly empty state otherwise. + +### 6. Click behavior: preserve current single/double-click semantics + +- **Single-click on a non-focus node** → re-center the graph on that node (graph navigation only; global selection unchanged). +- **Double-click on a non-focus node** → fully select that entity (URL updates, Tree highlights, Detail tab shows new entity). + +Preserves the "explore the graph without losing your place" model that's a real differentiator vs WebProtege. + +### 7. Tab persistence: stay on current tab across selection changes + +When the user picks a new entity (tree click, search, or double-click in graph), the active tab does *not* reset. If they were on Graph, they stay on Graph. If on Detail, they stay on Detail. If on Source (Developer-only), they stay on Source. + +### 8. Default tab on first load: Detail + +First entity opened in a new session lands on the Detail tab. Decision 7 then keeps the user on whichever tab they choose for the rest of the session. + +### 9. Source as right-pane Developer-only tab _(new — from sketch 003)_ + +The Source affordance migrates from two places that no longer exist: + +- The left-pane `Tree | Source` peer-tab strip (removed per Decision 3 refinement). +- The ` Source` link button in the entity-actions area of the detail header (removed). + +…into a **third tab in the right-pane tab strip** alongside Detail and Graph. Source tab is **conditionally rendered** based on editor mode: + +- **Standard view:** right pane has `Detail | Graph` (2 tabs at 50/50). No Source tab. +- **Developer view:** right pane has `Detail | Graph | Source` (3 tabs at 33/33/33). + +The Source tab shows the **entity-scoped Turtle snippet** — just the block defining the currently-selected entity, with a comment line indicating where in the full file it lives (e.g., `# File: ontology.ttl, line 4,217`). A toolbar above the snippet exposes: + +- **↗ Open full source** — opens the full ontology source (see Decision 10). +- **⎘ Copy snippet** — copies the entity-scoped snippet to clipboard. + +### 10. "Open Full Source" — Modal ⇄ Maximize round-trip _(new — from sketch 004)_ + +When the user clicks **↗ Open full source** from the Source tab, a centered **modal** opens at ~92% viewport with a dim backdrop, reusing the same modal frame component as `EntityGraphModal`. Inside the modal, the user can: + +- **Stay in modal** — Esc / click-outside / ✕ dismisses fully back to the Source tab. +- **⤢ Maximize** (modal header) — promotes the modal to a full editor takeover (tree + detail hidden; only the app header and project bar remain). Inside the takeover: + - **⊟ Restore** (takeover header) — returns to the modal view. Bouncing maximize ↔ restore is symmetric and preserves scroll position. + - **← Back to entity** — dismisses fully back to the Source tab. + +**Terminology:** the takeover-side button is labelled **"Restore"** (matching Windows OS Maximize ↔ Restore convention) with tooltip *"Restore to modal view."* "Minimize" was rejected because in OS land it means send-to-taskbar (hide entirely), not return-to-previous-size — the wrong mental model. + +**Round-trip integrity:** the Restore button is only shown when the takeover was reached via the modal's Maximize. If a user reaches the takeover by some other path (none currently designed, but kept open as a constraint), the Restore button is absent — preserving the chain. + +**State:** +- A `cameFromModal` boolean tracks whether the takeover was reached via Maximize. +- Esc dismisses fully from either size; Restore only changes size. +- Active right-pane tab (Detail / Graph / Source) doesn't change when the source overlay opens or closes. + +### 11. Tree → Source auto-jump (behavior spec) _(new — from sketch 004 follow-up)_ + +When the user has the Source tab active and selects a different entity in the Tree (or via search), the Source tab's content auto-updates to show the newly-selected entity's snippet — no extra click. + +If the **full-source overlay is also open** (in either modal or maximized size), it auto-scrolls to the new entity's lines and re-applies the highlight rule. The overlay does not dismiss on selection change. + +This complements Decision 7 (tab persistence) and Decision 10 (round-trip) — together they make the Source tab feel like a continuously-synced view of "whatever you're looking at in the tree." + +## Implicit consequences (worth flagging for the plan) + +- **Standard view's small Graph icon button** in the right-side detail panel — the affordance the user described as "not obvious" — is removed. The new tab strip replaces it. +- **Developer view's Tree | Source peer-tab strip** is removed entirely. The left pane is just the tree. +- **` Source` link in entity-actions** is removed in both views (replaced by the Source tab in Developer; not exposed at all in Standard). +- **Tab strip component** is shared across both views but receives a `tabs` prop that drives `flex: 1 1 0` layout: 2 tabs in Standard, 3 in Developer. One component, two configurations. + +## Visual decisions captured from sketches + +| What | Decision | Source | +|---|---|---| +| Tab strip position | Above the entity header (topmost chrome of the right pane) | Sketch 001 D | +| Tab styling | Icon + sentence-case label, accent-fill on active, accent underline | Sketch 001 D | +| Tab density | Compact: 8px vertical padding, text-xs, matching left tree-tabs height | Sketch 002 D | +| Tab width | `flex: 1 1 0` — fills the pane (50/50 in Standard, 33/33/33 in Developer) | Sketch 002 D | +| Standard view layout | 2-pane: Tree (~280px) \| Detail/Graph (rest) | Sketch 002 | +| Developer view layout | 2-pane: Tree (~280px, no mode strip) \| Detail/Graph/Source (rest) | Sketch 003 | +| Modal frame | Reuse `EntityGraphModal` shell — same border radius, shadow, dismissal | Sketch 004 | +| Open Full Source default | Modal at ~92% viewport, dim backdrop | Sketch 004 C | +| Open Full Source escape | ⤢ Maximize → full editor takeover; ⊟ Restore returns to modal | Sketch 004 C | +| Restore button label | "Restore" (Windows convention; rejected: Minimize, Exit fullscreen, Back to Modal) | Sketch 004 | + +## Out of scope for Phase 17 + +- Drag-to-reparent in the graph. +- Per-user / per-project default tab preference. +- Graph for properties of properties (rdf:Property meta-level). +- Graph export to SVG/PNG for screenshots (separate feature). +- Source view editing UX changes (the new tab inherits whatever the current Source view does for keybindings, autocomplete, etc.). +- Two-way navigation from full-source line clicks back to tree selection (existing source-IRI index already supports it; just preserve behavior). diff --git a/.planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-01-PLAN.md b/.planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-01-PLAN.md new file mode 100644 index 00000000..993045f1 --- /dev/null +++ b/.planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-01-PLAN.md @@ -0,0 +1,478 @@ +--- +phase: 17-graph-as-entity-scoped-tab-in-detail-pane +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: + - "ontokit-api: ontokit/api/routes/projects.py" + - "ontokit-api: ontokit/services/ontology.py" + - "ontokit-api: ontokit/schemas/graph.py" + - "ontokit-api: tests/unit/test_entity_graph.py" +autonomous: false +requirements: [R11] +tags: [phase-17, backend, entity-graph, cross-repo] + +must_haves: + truths: + - "Backend endpoint /entity-graph accepts focus_iri + focus_type and returns nodes/edges for class, property, and individual focus IRIs" + - "Property focus returns at least one object property with domain class + range class as nodes/edges" + - "Individual focus returns class assertions (rdf:type) + at least one objectProperty edge" + - "Annotation property with no domain/range/parent/seeAlso returns {nodes: [focus_only], edges: []}" + - "Existing /classes/graph endpoint continues to work via deprecating delegation shim" + artifacts: + - path: "ontokit-api: ontokit/api/routes/projects.py" + provides: "GET /api/v1/projects/{id}/ontology/entity-graph route + /classes/graph delegating shim" + contains: "@router.get(\"/{project_id}/ontology/entity-graph\"" + - path: "ontokit-api: ontokit/services/ontology.py" + provides: "build_entity_graph(focus_iri, focus_type) supporting class | property | individual" + contains: "def build_entity_graph" + - path: "ontokit-api: ontokit/schemas/graph.py" + provides: "GraphEdgeType Literal extended with 6 new edge_kind values (domain, range, subPropertyOf, rdfType, sameAs, objectProperty)" + contains: "Literal[\"subClassOf\"" + - path: "ontokit-api: tests/unit/test_entity_graph.py" + provides: "Property + individual + annotation-property focus tests" + contains: "def test_property_focus" + key_links: + - from: "ontokit-api: ontokit/api/routes/projects.py" + to: "ontokit/services/ontology.py::build_entity_graph" + via: "service call with focus_type" + pattern: "build_entity_graph\\(.*focus_type" + - from: "ontokit-api: ontokit/api/routes/projects.py /classes/graph (deprecated)" + to: "/entity-graph handler" + via: "internal delegation with focus_type=\"class\"" + pattern: "deprecated=True" +--- + + +Extend the ontokit-api `/entity-graph` endpoint to accept `focus_iri` + `focus_type` query parameters and return BFS neighborhoods for class, property, and individual focus IRIs (SPEC R11). Property focus must return domain classes, range classes, parent properties (rdfs:subPropertyOf), and seeAlso targets. Individual focus must return class assertions (rdf:type), object property values, sameAs targets, and seeAlso targets. Annotation property with no relationships must return `{nodes: [focus_only], edges: []}`. The legacy `/classes/graph` endpoint stays alive as a deprecating delegation shim (D-05). + +Purpose: This phase is gated on this backend work per D-08 (sequential cross-repo merge order: api -> dev -> web -> dev). The web layer (Plans 17-02, 17-03) cannot wire property/individual graph rendering until this endpoint ships. + +Output: New `/entity-graph` route + extended `build_entity_graph` service + extended `GraphEdgeType` schema + comprehensive pytest coverage in the ontokit-api repo. After this plan ships, the api PR is merged to `catholicos/dev` BEFORE the Phase 17 web branch is cut. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + +**Cross-repo notice (autonomous: false):** This plan modifies the `ontokit-api` repository, which is a separate git repo at `~/Coding Projects/ontokit-api`. The executor MUST: +1. Confirm the api repo working tree is clean and on the correct base branch (sync against `catholicos/dev` first per global instructions). +2. Cut a new branch in `ontokit-api` (suggested name: `entity-graph-property-individual`). +3. Implement all four tasks below in that repo. +4. Run `pytest tests/unit/test_entity_graph.py -x` until green. +5. Open a PR against `catholicos/dev`. +6. **STOP and notify the user before merging.** This is a checkpoint per D-08: human approval needed because the merge to `catholicos/dev` blocks Plan 17-02 from starting. + + + +@.planning/STATE.md +@.planning/ROADMAP.md +@.planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-SPEC.md +@.planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-CONTEXT.md +@.planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-RESEARCH.md +@.planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-PATTERNS.md +@.planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-VALIDATION.md + + + + +From ontokit-api/ontokit/services/ontology.py (per RESEARCH §Q6): +- `build_entity_graph` lives at lines 365-455. +- Class-only gate at line 397: `if (class_uri, RDF.type, OWL.Class) not in graph: return None`. +- `_classify_node` helper at lines 437-455 already understands `property` / `individual` / `external`. + +From ontokit-api/ontokit/api/routes/projects.py (per RESEARCH §Q6): +- Existing `/classes/graph` route at lines 660-701. +- Signature accepts `class_iri`, `branch`, `ancestors_depth`, `descendants_depth`, `max_nodes`, `include_see_also`. +- Uses `Depends(OptionalUser)` auth pattern. + +From ontokit-api/ontokit/schemas/graph.py (per RESEARCH §Q6): +- Existing `GraphEdgeType = Literal[...]` at lines 1-58 with 4 values: `subClassOf | equivalentClass | disjointWith | seeAlso`. +- Comment at line 9: `# Frontend mirror: GraphNodeType in lib/graph/types.ts`. + +Target extended Literal (D-06): +```python +GraphEdgeType = Literal[ + "subClassOf", "equivalentClass", "disjointWith", "seeAlso", # existing class edges + "domain", "range", "subPropertyOf", # property edges + "rdfType", "sameAs", "objectProperty", # individual edges +] +``` + + + + + + + Task 1: Extend GraphEdgeType schema with 6 new edge_kind values + ontokit-api: ontokit/schemas/graph.py + + - ontokit-api: ontokit/schemas/graph.py (entire file — small) + - .planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-RESEARCH.md §Q6 step 3 (locked Literal values) + - .planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-CONTEXT.md D-06 (single edges[] with labelled edge_kind enum) + + + - Test 1: GraphEdgeType Literal accepts each of the 10 values: subClassOf, equivalentClass, disjointWith, seeAlso, domain, range, subPropertyOf, rdfType, sameAs, objectProperty + - Test 2: A schema instance with edge_kind="domain" validates without error + - Test 3: A schema instance with edge_kind="bogus" raises a Pydantic ValidationError + + + Open `ontokit-api/ontokit/schemas/graph.py`. Locate the existing `GraphEdgeType = Literal[...]` declaration (lines 1-58 per RESEARCH §Q6). + + Replace its 4-value Literal with the 10-value Literal from D-06: + ```python + GraphEdgeType = Literal[ + "subClassOf", # existing — class hierarchy + "equivalentClass", # existing — class equivalence + "disjointWith", # existing — class disjointness + "seeAlso", # existing — symmetric reference + "domain", # NEW — property domain (slate-400 solid) + "range", # NEW — property range (slate-400 dashed 4 2) + "subPropertyOf", # NEW — property hierarchy (slate-500 solid) + "rdfType", # NEW — individual class assertion (primary-500 solid) + "sameAs", # NEW — individual identity (purple-500 dashed 5 3) + "objectProperty", # NEW — individual-to-individual link (zinc-500 solid) + ] + ``` + + Preserve the existing comment at line 9 documenting the frontend mirror in `lib/graph/types.ts`. Add a sibling comment listing the new values so the reverse-mirror is obvious. If a `GraphEdge` Pydantic model in the same file constrains `edge_kind` via this Literal, it inherits the new values automatically — no further change. + + Per D-06 (single `edges[]` array with labelled `edge_kind` enum), do NOT introduce a parallel discriminated union — extend the existing one Literal. + + + cd ~/Coding\ Projects/ontokit-api && pytest tests/unit/test_graph_schema.py -k "edge_kind" -x 2>&1 | tail -20 || (cd ~/Coding\ Projects/ontokit-api && python -c "from ontokit.schemas.graph import GraphEdgeType; from typing import get_args; vals = get_args(GraphEdgeType); assert set(vals) == {'subClassOf','equivalentClass','disjointWith','seeAlso','domain','range','subPropertyOf','rdfType','sameAs','objectProperty'}, f'Got {vals}'; print('OK')") + + + - `grep -E '"(domain|range|subPropertyOf|rdfType|sameAs|objectProperty)"' ontokit-api/ontokit/schemas/graph.py | wc -l` returns 6 (one match per new value) + - `grep -c '"subClassOf"' ontokit-api/ontokit/schemas/graph.py` is at least 1 (existing values preserved) + - Python `from ontokit.schemas.graph import GraphEdgeType; from typing import get_args; len(get_args(GraphEdgeType))` returns 10 + - Existing api tests still pass: `pytest tests/unit/ -x` from ontokit-api root exits 0 + + GraphEdgeType Literal has exactly 10 values; all existing api tests pass; frontend mirror comment annotates the new values for the Plan 17-02 mirror task. + + + + Task 2: Extend build_entity_graph for property + individual focus + ontokit-api: ontokit/services/ontology.py + + - ontokit-api: ontokit/services/ontology.py (full file, paying attention to lines 365-455 — existing build_entity_graph and _classify_node) + - .planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-RESEARCH.md §Q6 step 2 (BFS branches per focus_type) + - .planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-RESEARCH.md §Common Pitfalls 5 (annotation property empty-state) + - .planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-CONTEXT.md D-07 (no cap on object-property values for individuals in v1) + + + - Test 1: build_entity_graph(class_iri, focus_type="class") returns the same shape as the legacy class-only call (regression) + - Test 2: build_entity_graph(property_iri, focus_type="property") for an object property with rdfs:domain ex:A and rdfs:range ex:B returns nodes including focus + ex:A + ex:B and edges with edge_kind="domain" and edge_kind="range" + - Test 3: build_entity_graph(property_iri, focus_type="property") for a property with rdfs:subPropertyOf ex:Parent returns an edge with edge_kind="subPropertyOf" + - Test 4: build_entity_graph(individual_iri, focus_type="individual") for an individual with rdf:type ex:ClassA and (focus, ex:knows, ex:OtherIndiv) returns a "rdfType" edge to ex:ClassA AND an "objectProperty" edge to ex:OtherIndiv + - Test 5: build_entity_graph(individual_iri, focus_type="individual") with owl:sameAs ex:Other returns a "sameAs" edge + - Test 6: build_entity_graph(annotation_property_iri, focus_type="property") with NO domain/range/subPropertyOf/seeAlso returns {nodes: [focus_only], edges: []} — exactly one node (the focus) and zero edges + - Test 7: build_entity_graph(class_iri, focus_type="property") returns None (404) — focus IRI does not match focus_type + + + Open `ontokit-api/ontokit/services/ontology.py`. Locate `build_entity_graph` (lines 365-455 per RESEARCH §Q6). + + **Step 1 — Update the signature.** Add `focus_type: Literal["class", "property", "individual"]` as a required keyword arg AFTER existing class-specific args. Default the existing class-only call sites to `focus_type="class"` so legacy callers do not break. + + **Step 2 — Replace the class-only gate at line 397 with a type-aware gate.** Build a small helper: + ```python + def _matches_focus_type(graph, uri, focus_type): + if focus_type == "class": + return (uri, RDF.type, OWL.Class) in graph + if focus_type == "property": + return any( + (uri, RDF.type, t) in graph + for t in (OWL.ObjectProperty, OWL.DatatypeProperty, OWL.AnnotationProperty) + ) + if focus_type == "individual": + # Individual = anything that's not a class or property and has at least one rdf:type assertion + if (uri, RDF.type, OWL.Class) in graph: + return False + if any((uri, RDF.type, t) in graph for t in (OWL.ObjectProperty, OWL.DatatypeProperty, OWL.AnnotationProperty)): + return False + return any(graph.triples((uri, RDF.type, None))) + return False + ``` + Replace the existing `if (class_uri, RDF.type, OWL.Class) not in graph: return None` with `if not _matches_focus_type(graph, focus_uri, focus_type): return None`. + + **Step 3 — Branch the BFS by focus_type.** Keep the existing class BFS as the `focus_type == "class"` path (no behavioral change). Add two new paths: + + For `focus_type == "property"` (per RESEARCH §Q6 step 2): + - Edges from focus: enumerate `(focus, RDFS.domain, ?o)` -> emit edge `{source: focus, target: ?o, edge_kind: "domain"}`. Same for `RDFS.range` -> `"range"`. + - Edges from focus: enumerate `(focus, RDFS.subPropertyOf, ?o)` -> emit `"subPropertyOf"`. Reverse direction: `(?s, RDFS.subPropertyOf, focus)` -> emit `"subPropertyOf"` with source=?s. + - seeAlso: keep existing `include_see_also` knob behavior — enumerate `(focus, RDFS.seeAlso, ?o)` -> emit `"seeAlso"`. + - Each ?o becomes a node; classify via existing `_classify_node` helper (it already handles class/property/individual/external). + - Annotation-property empty-state (per Common Pitfall 5): if NO edges were collected after exhausting domain/range/subPropertyOf/seeAlso, return `{"nodes": [focus_node_only], "edges": []}` directly. The frontend detects this shape and renders the empty-state copy. + + For `focus_type == "individual"` (per RESEARCH §Q6 step 2 + D-07 no cap): + - Edges from focus: enumerate `(focus, RDF.type, ?o)` where ?o is an `owl:Class` -> emit edge `{source: focus, target: ?o, edge_kind: "rdfType"}`. + - Edges from focus: for every triple `(focus, ?p, ?o)` where ?p is `(?p, RDF.type, OWL.ObjectProperty) in graph` AND ?p is not in {RDF.type, RDFS.seeAlso, OWL.sameAs}: emit `{source: focus, target: ?o, edge_kind: "objectProperty", label: local_name(?p)}`. **No cap per D-07** — every assertion surfaces. + - Edges from focus: enumerate `(focus, OWL.sameAs, ?o)` -> emit `"sameAs"` (also reverse `(?s, OWL.sameAs, focus)` since sameAs is symmetric). + - seeAlso: enumerate `(focus, RDFS.seeAlso, ?o)` -> emit `"seeAlso"`. + + **Step 4 — Preserve `_classify_node`.** It already classifies any URI as class/property/individual/external. Reuse without modification — RESEARCH §Q6 confirms it understands all three types. + + **Step 5 — Apply `max_nodes` cap uniformly.** The existing class BFS respects `max_nodes`. Property + individual BFS must respect the same cap to prevent unbounded responses (D-07 says no cap on object-property values BUT max_nodes is a separate global cap — preserve it). + + **Step 6 — Add a docstring** documenting the three focus_type branches and the annotation-property empty-state shape. + + Do NOT introduce a feature flag or try/except fallback (per D-08). If focus_type is not supplied at the route layer, the route defaults to `"class"` for backward compat (Task 3 wires this). + + + cd ~/Coding\ Projects/ontokit-api && pytest tests/unit/test_entity_graph.py::test_property_focus_domain_range tests/unit/test_entity_graph.py::test_property_focus_sub_property_of tests/unit/test_entity_graph.py::test_individual_focus_rdf_type_and_object_property tests/unit/test_entity_graph.py::test_individual_focus_same_as tests/unit/test_entity_graph.py::test_annotation_property_empty_state tests/unit/test_entity_graph.py::test_focus_type_mismatch_returns_none -x 2>&1 | tail -40 + + + - `grep -c "focus_type" ontokit-api/ontokit/services/ontology.py` is at least 6 (signature + helper + three branches + docstring) + - `grep -E "edge_kind=['\"](domain|range|subPropertyOf|rdfType|sameAs|objectProperty)['\"]" ontokit-api/ontokit/services/ontology.py | wc -l` is at least 6 (every new edge_kind value appears at least once in emit code) + - `grep -c "_matches_focus_type" ontokit-api/ontokit/services/ontology.py` is at least 2 (helper definition + at least one call site) + - `grep -c "RDFS.domain\|RDFS.range\|RDFS.subPropertyOf" ontokit-api/ontokit/services/ontology.py` is at least 3 (one for each new RDFLib reference) + - `grep -c "OWL.sameAs\|OWL.ObjectProperty" ontokit-api/ontokit/services/ontology.py` is at least 2 (individual BFS uses these) + - All existing class-focus tests still pass: `pytest tests/unit/test_entity_graph.py -k "class" -x` exits 0 + - Linter/typecheck: `cd ontokit-api && ruff check ontokit/services/ontology.py` exits 0 + + build_entity_graph supports class | property | individual focus; the 10 edge_kind values are emitted in the right contexts; annotation-property empty-state returns the focus-only response; legacy class behavior is unchanged; all six new pytest cases pass. + + + + Task 3: Add /entity-graph route + /classes/graph deprecating shim + ontokit-api: ontokit/api/routes/projects.py + + - ontokit-api: ontokit/api/routes/projects.py (lines 660-701 — existing /classes/graph route) + - .planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-RESEARCH.md §Q6 step 1 + step 4 (deprecation pattern) + - .planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-CONTEXT.md D-05 (single new endpoint + delegating shim during one-release transition window) + + + - Test 1: GET /api/v1/projects/{id}/ontology/entity-graph?focus_iri=...&focus_type=class returns 200 with the same shape as the legacy /classes/graph response + - Test 2: GET /entity-graph?focus_iri=...&focus_type=property for a known object property returns 200 with non-empty edges including edge_kind values from the property set + - Test 3: GET /entity-graph?focus_iri=...&focus_type=individual for a known individual returns 200 with edges including rdfType + - Test 4: GET /entity-graph?focus_iri=...&focus_type=property where the IRI is actually a class returns 404 + - Test 5: GET /entity-graph with missing focus_iri returns 422 (FastAPI validation error) + - Test 6: GET /entity-graph with focus_type="bogus" returns 422 (Literal validation rejects it) + - Test 7: Legacy GET /classes/graph?class_iri=... still returns 200 with identical response to focus_type="class" call (delegating shim works) + - Test 8: OpenAPI schema marks /classes/graph with deprecated=True + + + Open `ontokit-api/ontokit/api/routes/projects.py`. Locate the existing `/classes/graph` route at lines 660-701 (per RESEARCH §Q6). + + **Step 1 — Add a new handler `get_ontology_entity_graph`.** Sibling to the existing class handler. Path: `GET /{project_id}/ontology/entity-graph`. Query params: + - `focus_iri: str` (required) + - `focus_type: Literal["class", "property", "individual"]` (required) + - `branch: Optional[str] = None` + - `ancestors_depth: int = ...` (carry the same default as legacy) + - `descendants_depth: int = ...` (same) + - `max_nodes: int = ...` (same) + - `include_see_also: bool = ...` (same) + - `user: Optional[User] = Depends(OptionalUser)` (mirror legacy auth) + + Body: load the ontology graph for the project + branch (reuse existing helpers used by `/classes/graph`), call `OntologyService.build_entity_graph(focus_iri=..., focus_type=focus_type, ...)`. If it returns `None`, raise `HTTPException(status_code=404, detail=f"No entity found for focus_iri={focus_iri} with focus_type={focus_type}")`. Otherwise return the response model directly. + + **Step 2 — Convert /classes/graph into a delegating shim** (per D-05 + RESEARCH Q6 step 4): + ```python + @router.get( + "/{project_id}/ontology/classes/graph", + deprecated=True, + summary="DEPRECATED. Use /entity-graph?focus_type=class instead.", + ) + async def get_ontology_class_graph_deprecated( + project_id: UUID, + class_iri: str, + # ... all the same query params as before ... + ): + return await get_ontology_entity_graph( + project_id=project_id, + focus_iri=class_iri, + focus_type="class", + # ... pass through other params ... + ) + ``` + + The `deprecated=True` flag surfaces in OpenAPI/Swagger and signals the deprecation window. Per CONTEXT D-05, length is "one release / two releases" — leave a comment `# TODO: Remove after v0.6.0` so the future cleanup is discoverable. + + **Step 3 — Preserve auth + rate-limit decorators.** The existing /classes/graph route uses `OptionalUser`. The new /entity-graph route uses the same. No new auth surface (per phase security context). + + **Step 4 — Input validation hardening (per phase security context):** + - `focus_iri` is a `str` already validated by FastAPI; add `min_length=1` constraint. + - `focus_type` is a `Literal[...]` so FastAPI will return 422 for invalid values automatically. + - Reject control characters in `focus_iri` early (e.g., `if any(ord(c) < 32 for c in focus_iri): raise HTTPException(400)`) to prevent log-injection style payloads. + + Do NOT add a feature flag or try/except fallback (D-08). If `build_entity_graph` raises, let it bubble — FastAPI returns 500 with the standard error envelope. + + + cd ~/Coding\ Projects/ontokit-api && pytest tests/unit/test_entity_graph.py::test_entity_graph_route_class tests/unit/test_entity_graph.py::test_entity_graph_route_property tests/unit/test_entity_graph.py::test_entity_graph_route_individual tests/unit/test_entity_graph.py::test_entity_graph_route_404_on_type_mismatch tests/unit/test_entity_graph.py::test_entity_graph_route_validates_focus_type tests/unit/test_entity_graph.py::test_classes_graph_route_still_works tests/unit/test_entity_graph.py::test_classes_graph_route_marked_deprecated -x 2>&1 | tail -40 + + + - `grep -E '"/{project_id}/ontology/entity-graph"' ontokit-api/ontokit/api/routes/projects.py` returns at least 1 match + - `grep -c "deprecated=True" ontokit-api/ontokit/api/routes/projects.py` is at least 1 (delegation shim flagged) + - `grep -c "focus_type" ontokit-api/ontokit/api/routes/projects.py` is at least 4 (route signature + service call + delegation shim) + - `grep -E 'Literal\[.*"class".*"property".*"individual"\]' ontokit-api/ontokit/api/routes/projects.py` returns at least 1 match (focus_type type annotation) + - All existing /classes/graph integration tests still pass: `pytest tests/unit/test_entity_graph.py -k "classes_graph" -x` exits 0 + - OpenAPI shows the new route: `python -c "from ontokit.main import app; import json; routes=[r.path for r in app.routes]; assert any('/entity-graph' in p for p in routes); print('OK')"` prints OK + - Linter: `ruff check ontokit/api/routes/projects.py` exits 0 + + New /entity-graph route is wired; legacy /classes/graph is preserved as a deprecated delegating shim; all 7 route-level tests pass; OpenAPI documents both routes correctly; auth pattern matches the existing route. + + + + Task 4: Add comprehensive entity-graph tests covering R11 acceptance criteria + ontokit-api: tests/unit/test_entity_graph.py + + - ontokit-api: tests/unit/test_entity_graph.py (existing class-focus tests — copy fixture loading patterns) + - ontokit-api: tests/conftest.py (test ontology fixture patterns, AsyncClient setup) + - .planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-SPEC.md R11 acceptance literal + - .planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-VALIDATION.md (test surface table) + + + Tests covering R11 acceptance literal: "non-empty nodes and edges for at least one object property in the test ontology with domain + range; for at least one individual with rdf:type + ≥1 object property value; for at least one annotation property with see-also. Annotation property with no relationships returns the focus-only response and frontend shows the empty-state copy." + + The corresponding test functions called by Tasks 2 + 3 verify commands MUST exist in this file by end of task: + - test_property_focus_domain_range + - test_property_focus_sub_property_of + - test_individual_focus_rdf_type_and_object_property + - test_individual_focus_same_as + - test_annotation_property_with_see_also (covers R11 acceptance literal) + - test_annotation_property_empty_state (focus-only response shape) + - test_focus_type_mismatch_returns_none + - test_entity_graph_route_class + - test_entity_graph_route_property + - test_entity_graph_route_individual + - test_entity_graph_route_404_on_type_mismatch + - test_entity_graph_route_validates_focus_type + - test_classes_graph_route_still_works + - test_classes_graph_route_marked_deprecated + - test_graph_schema_edge_kind_accepts_all_ten_values + + + Open or create `ontokit-api/tests/unit/test_entity_graph.py`. Use the existing class-focus tests as the fixture-loading + assertion template. + + **Test ontology fixture** — augment the existing test ontology fixture (or create a new one) so it includes: + - At least one `owl:ObjectProperty` with `rdfs:domain ex:ClassA` + `rdfs:range ex:ClassB` + - At least one `owl:ObjectProperty` with `rdfs:subPropertyOf ex:ParentProp` + - At least one individual `ex:John` with `rdf:type ex:Person` and `(ex:John, ex:knows, ex:Jane)` and `(ex:John, owl:sameAs, ex:JohnDoe)` + - At least one `owl:AnnotationProperty` with `rdfs:seeAlso ex:Reference` + - At least one `owl:AnnotationProperty` with NO domain/range/subPropertyOf/seeAlso (for empty-state) + + Use rdflib `Graph().parse(data=..., format="turtle")` to build the fixture inline, OR add a new fixture file under `tests/fixtures/`. Match the existing conftest.py style. + + **Service-level tests (call `build_entity_graph` directly):** + 1. `test_property_focus_domain_range`: assert response has node for ex:ObjectProp + ex:ClassA + ex:ClassB; edges include `{edge_kind: "domain", target: ex:ClassA}` and `{edge_kind: "range", target: ex:ClassB}`. + 2. `test_property_focus_sub_property_of`: assert subPropertyOf edge present. + 3. `test_individual_focus_rdf_type_and_object_property`: assert edges include `{edge_kind: "rdfType", target: ex:Person}` AND `{edge_kind: "objectProperty", target: ex:Jane}`. + 4. `test_individual_focus_same_as`: assert `sameAs` edge present (both directions). + 5. `test_annotation_property_with_see_also`: focus IRI is the annotation property with seeAlso; assert response has 2 nodes + 1 seeAlso edge (R11 acceptance literal: "at least one annotation property with see-also"). + 6. `test_annotation_property_empty_state`: focus IRI is the annotation property with NO relationships; assert `len(response["nodes"]) == 1 and response["nodes"][0]["iri"] == focus_iri and response["edges"] == []`. + 7. `test_focus_type_mismatch_returns_none`: pass a class IRI with `focus_type="property"`; assert build_entity_graph returns None. + + **Route-level tests (use existing `AsyncClient` test fixture, pattern from conftest.py):** + 8. `test_entity_graph_route_class`: GET /entity-graph?focus_iri={class}&focus_type=class — assert 200 + same shape as legacy /classes/graph. + 9. `test_entity_graph_route_property`: focus_type=property — assert 200 with property-edge kinds. + 10. `test_entity_graph_route_individual`: focus_type=individual — assert 200 with individual-edge kinds. + 11. `test_entity_graph_route_404_on_type_mismatch`: class IRI with focus_type=property — assert 404. + 12. `test_entity_graph_route_validates_focus_type`: focus_type=bogus — assert 422. + 13. `test_classes_graph_route_still_works`: legacy GET /classes/graph?class_iri=... — assert 200, response identical to focus_type=class call (delegation works). + 14. `test_classes_graph_route_marked_deprecated`: introspect FastAPI app routes; assert the /classes/graph route has `deprecated=True`. + + **Schema-level test:** + 15. `test_graph_schema_edge_kind_accepts_all_ten_values` (lives in `tests/unit/test_graph_schema.py` — create file if missing): instantiate a Pydantic model that uses `GraphEdgeType` once per value; assert no validation errors. Also assert `Pydantic.ValidationError` is raised for `edge_kind="bogus"`. + + Use `pytest.mark.parametrize` where it reduces duplication (e.g., the 10 edge_kind values). + + Do NOT add Playwright e2e or Chrome DevTools MCP tests (per SPEC out-of-scope) — that lives in Plan 17-03. + + + cd ~/Coding\ Projects/ontokit-api && pytest tests/unit/test_entity_graph.py tests/unit/test_graph_schema.py -x 2>&1 | tail -50 + + + - `grep -c "^def test_" ontokit-api/tests/unit/test_entity_graph.py` is at least 14 (covers all 14 entity-graph tests) + - `grep -c "^def test_" ontokit-api/tests/unit/test_graph_schema.py` is at least 1 + - Every test name listed in the behavior block appears at least once: `for t in test_property_focus_domain_range test_individual_focus_rdf_type_and_object_property test_annotation_property_empty_state test_classes_graph_route_marked_deprecated; do grep -c "def $t" ontokit-api/tests/unit/test_entity_graph.py; done` produces 4 lines each ≥ 1 + - Full pytest suite green from ontokit-api root: `pytest -x` exits 0 + - Coverage on `build_entity_graph` is non-trivial: `pytest --cov=ontokit.services.ontology --cov-report=term tests/unit/test_entity_graph.py | grep ontology.py` shows coverage ≥ 70% + + All 14 entity-graph tests + the schema test pass; the test ontology fixture includes the required artifacts (object property with domain+range, individual with rdf:type+object-property+sameAs, annotation property with seeAlso, annotation property with no relationships); coverage on `build_entity_graph` is at least 70%. + + + + Checkpoint: Open ontokit-api PR and merge to catholicos/dev + (human action — no file modifications by Claude in this checkpoint) + + HUMAN ACTION REQUIRED. Per D-08 (sequential cross-repo merge order: api -> dev -> web -> dev), Plan 17-02 cannot start until the api PR has merged. Follow the steps in below. + + + Tasks 1–4 are complete. The api branch in ~/Coding Projects/ontokit-api now has: + - GraphEdgeType Literal extended with 6 new edge_kind values + - build_entity_graph extended for class | property | individual focus + - New /entity-graph route + /classes/graph deprecating shim + - 14 new pytest cases covering R11 acceptance criteria + All tests pass; ruff is clean. + + + cd ~/Coding\ Projects/ontokit-api && pytest -x 2>&1 | tail -3 && ruff check . 2>&1 | tail -3 + + API PR merged to catholicos/dev; local api restarted; smoke curl against /entity-graph returns 200 for property + individual focus; user types "api merged". + + 1. From ~/Coding Projects/ontokit-api, run `pytest -x` and confirm green. + 2. Run `ruff check .` and confirm clean. + 3. Push the branch and open a PR against `catholicos/dev` titled "Phase 17 backend: entity-graph for property + individual focus". + - Include the planning context per global feedback (link to Phase 17 planning artifacts). + 4. Wait for CI to pass. + 5. **Per global feedback "approve means merge: alea-institute only — NEVER merge CatholicOS PRs":** + - If the PR is on `alea-institute/ontokit-api`: you may merge after approval. + - If the PR is on `CatholicOS/ontokit-api`: STOP. Peer review required. Do NOT merge. + 6. After the api PR merges to `catholicos/dev`, deploy locally (`./ontokit-api.sh restart` or equivalent) and confirm: + - `curl 'http://localhost:8000/api/v1/projects/{project_id}/ontology/entity-graph?focus_iri=...&focus_type=property'` returns 200 with non-empty edges. + - `curl 'http://localhost:8000/api/v1/projects/{project_id}/ontology/classes/graph?class_iri=...'` still returns 200 (legacy delegation works). + 7. Per global feedback "auto-rebase after merge": rebase any other open ontokit-api PRs against the new dev tip. + 8. Per global feedback "update issue 34 on completion": tick the corresponding item in CatholicOS/ontokit-web#34 if present. + + Type "api merged" to confirm the api PR has merged to catholicos/dev — Plan 17-02 cuts its branch from this point. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| HTTP client → /entity-graph route | Untrusted query params (focus_iri, focus_type) cross here | +| Route handler → build_entity_graph service | Validated query params hand off to RDFLib graph traversal | + +## STRIDE Threat Register (ASVS L1) + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-17-01 | T (Tampering) | /entity-graph focus_iri query param | mitigate | FastAPI Pydantic validation enforces `min_length=1`; reject control characters (ord < 32) early in the route handler to block log-injection-style payloads | +| T-17-02 | T (Tampering) | /entity-graph focus_type query param | mitigate | `Literal["class","property","individual"]` annotation forces 422 on any other value before reaching the service | +| T-17-03 | I (Information disclosure) | RDFLib graph traversal | accept | Read-only BFS over project's own ontology; no PII; no cross-project access (project_id scoping inherited from existing route auth) | +| T-17-04 | D (Denial of service) | Individual focus with very dense object-property graph | mitigate | `max_nodes` global cap is preserved across all three focus_type branches per Task 2 step 5 — D-07 says no per-predicate cap, but max_nodes still bounds total size | +| T-17-05 | E (Elevation of privilege) | OptionalUser auth on new route | accept | Mirrors existing /classes/graph auth; no new privileged action introduced; project_id scoping unchanged | +| T-17-06 | S (Spoofing) | Cross-repo schema drift between api Literal and web Literal mirror | mitigate | D-08 sequential merge order: api ships first; Plan 17-02 mirrors the schema with a code comment cross-referencing the api file. Drift would surface as a runtime type error, not silent corruption | + + + +- All 14 pytest cases in `test_entity_graph.py` pass +- Schema test confirms 10 edge_kind values +- Existing class-focus tests continue to pass (regression check) +- Linter (ruff) clean +- OpenAPI documents `/entity-graph` and flags `/classes/graph` as deprecated +- API PR merged to `catholicos/dev` per D-08 sequential merge order + + + +- New endpoint `GET /api/v1/projects/{id}/ontology/entity-graph?focus_iri=...&focus_type=class|property|individual` is live on `catholicos/dev` +- Object property with domain + range returns non-empty graph +- Individual with rdf:type + object-property values returns non-empty graph +- Annotation property with seeAlso returns 2 nodes + 1 edge +- Annotation property with NO relationships returns `{nodes: [focus_only], edges: []}` +- Legacy `/classes/graph` continues to work via delegation +- 10 edge_kind values are emitted by service in correct contexts + + + +After completion, create `.planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-01-SUMMARY.md` documenting: +- Final api PR URL +- Date the api PR merged to catholicos/dev (gating event for Plan 17-02) +- Test count (target: 14 entity-graph + 1 schema = 15 new tests, all passing) +- Any deviations from D-05 / D-06 / D-07 / D-08 + diff --git a/.planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-02-PLAN.md b/.planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-02-PLAN.md new file mode 100644 index 00000000..711bf056 --- /dev/null +++ b/.planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-02-PLAN.md @@ -0,0 +1,1226 @@ +--- +phase: 17-graph-as-entity-scoped-tab-in-detail-pane +plan: 02 +type: execute +wave: 2 +depends_on: ["17-01"] +files_modified: + - "lib/stores/selectionStore.ts" + - "lib/api/graph.ts" + - "lib/graph/types.ts" + - "lib/hooks/useFullSourceOverlay.ts" + - "lib/hooks/useEffectiveTab.ts" + - "lib/ontology/extractEntitySnippet.ts" + - "components/editor/PaneTabStrip.tsx" + - "components/editor/SourceTabBody.tsx" + - "components/editor/FullSourceOverlay.tsx" + - "components/graph/EntityGraphModal.tsx" + - "components/graph/EntityModal.tsx" + - "components/graph/OntologyEdge.tsx" + - "components/graph/OntologyGraph.tsx" + - "app/globals.css" + - "__tests__/components/editor/PaneTabStrip.test.tsx" + - "__tests__/components/editor/SourceTabBody.test.tsx" + - "__tests__/components/editor/FullSourceOverlay.test.tsx" + - "__tests__/components/graph/OntologyEdge.test.tsx" + - "__tests__/lib/hooks/useFullSourceOverlay.test.tsx" + - "__tests__/lib/ontology/extractEntitySnippet.test.ts" + - "__tests__/lib/stores/selectionStore.test.ts" +autonomous: true +requirements: [R1, R6, R7, R8, R9, R10, R11] +tags: [phase-17, frontend, primitives, store, hooks] + +must_haves: + truths: + - "selectionStore exposes activePaneTab field defaulting to 'detail' (SPEC R8)" + - "useEffectiveTab(editorMode) returns 'detail' when stored is 'source' AND mode is 'standard', without mutating store (D-15)" + - "PaneTabStrip renders tabs with role='tablist' + role='tab' + aria-selected/aria-controls + roving tabindex" + - "PaneTabStrip uses identical Tailwind density (px-3 py-2 text-xs font-medium + border-b border-slate-200) as EntityTabBar so bottom borders align" + - "useFullSourceOverlay state machine: closed → modal → maximized → modal (cameFromModal stays true on bounce); Esc closes from any size" + - "useFullSourceOverlay sets document.documentElement.dataset.overlayTakeover='true' only while size==='maximized', cleared on close/restore/unmount" + - "extractEntitySnippet returns {text, startLine, endLine} for class/property/individual subjects; null when not found; respects findBlock continuation-line guard" + - "EntityGraphModal is renamed to EntityModal with prop contract {isOpen, onClose, children, headerExtras?, size?, onMaximize?, onRestore?, titleId?}" + - "graphApi.getEntityGraph(focusIri, focusType) calls GET /entity-graph?focus_iri=...&focus_type=... and returns the new edge_kind union" + - "OntologyEdge edgeTypeConfig contains all 10 edge_kind entries with the locked palette from UI-SPEC §Color" + - "OntologyGraph renders annotation-property empty-state when nodes.length===1 && edges.length===0" + - "SourceTabBody renders Monaco snippet + toolbar (line-comment + Open full source + Copy snippet) + empty state when extractor returns null" + - "FullSourceOverlay renders inside EntityModal body slot + headerExtras with Maximize2 / Minimize2 (Restore) icons" + artifacts: + - path: "lib/stores/selectionStore.ts" + provides: "activePaneTab + setActivePaneTab + clear resets to 'detail'; PaneTab type export" + contains: "activePaneTab" + - path: "lib/hooks/useEffectiveTab.ts" + provides: "Derived fallback hook (D-15)" + exports: ["useEffectiveTab"] + - path: "components/editor/PaneTabStrip.tsx" + provides: "Shared TabStrip used by Standard + Developer right panes" + exports: ["PaneTabStrip", "PaneTabDefinition"] + - path: "components/editor/SourceTabBody.tsx" + provides: "Entity-scoped Monaco snippet + toolbar + empty state" + exports: ["SourceTabBody"] + - path: "components/editor/FullSourceOverlay.tsx" + provides: "Full Monaco editor body for EntityModal full-source overlay" + exports: ["FullSourceOverlay"] + - path: "components/graph/EntityModal.tsx" + provides: "Renamed + generalized modal shell with body slot + headerExtras + size" + exports: ["EntityModal"] + - path: "lib/hooks/useFullSourceOverlay.ts" + provides: "Modal/Maximize/Restore state machine with CSS attribute side-effect" + exports: ["useFullSourceOverlay"] + - path: "lib/ontology/extractEntitySnippet.ts" + provides: "Type-agnostic snippet extractor wrapping findBlock" + exports: ["extractEntitySnippet", "EntitySnippet"] + - path: "lib/graph/types.ts" + provides: "GraphEdgeType union mirrored from api with 10 values" + contains: "domain" + - path: "lib/api/graph.ts" + provides: "graphApi.getEntityGraph(focusIri, focusType) calling /entity-graph" + contains: "/entity-graph" + - path: "components/graph/OntologyEdge.tsx" + provides: "edgeTypeConfig with 10 entries + stroke-width logic for hierarchy/semantic edges" + contains: "objectProperty" + - path: "app/globals.css" + provides: "[data-overlay-takeover='true'] CSS rules hiding tree + detail panes" + contains: "data-overlay-takeover" + key_links: + - from: "components/editor/PaneTabStrip.tsx" + to: "lib/stores/selectionStore.ts (via consumer prop)" + via: "external activeTab + onTabChange driven by store" + pattern: "activeTab" + - from: "lib/hooks/useFullSourceOverlay.ts" + to: "document.documentElement.dataset.overlayTakeover" + via: "useEffect side-effect on size==='maximized'" + pattern: "overlayTakeover" + - from: "components/editor/SourceTabBody.tsx" + to: "lib/ontology/extractEntitySnippet.ts" + via: "useMemo over (sourceContent, iri)" + pattern: "extractEntitySnippet" + - from: "lib/api/graph.ts" + to: "ontokit-api /api/v1/projects/{id}/ontology/entity-graph" + via: "fetch with focus_iri + focus_type query params" + pattern: "focus_type" + - from: "components/graph/OntologyEdge.tsx" + to: "lib/graph/types.ts GraphEdgeType" + via: "Record exhaustive map" + pattern: "edgeTypeConfig" +--- + + +Build all the leaf-level web primitives, store extensions, hooks, and API client changes that Plan 17-03 will integrate into the layouts. Touches no layout files — those changes happen in 17-03. This plan is the "scaffolding" wave: every component, hook, and store extension lands here with passing Vitest tests, but nothing is wired into `StandardEditorLayout.tsx` / `DeveloperEditorLayout.tsx` yet. + +Purpose: Decoupling primitives from layout integration keeps each plan within ~50% context budget, lets Plan 17-03 focus on layout wiring + cleanup + AI smoke without re-establishing component contracts, and delivers a clean wave-2 git history (each task is a green-tested module). + +Output: 9 new web modules + 4 modifications + 7 new test files. Plan 17-03 then imports these in the layouts. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + +**Branch cut requirement:** This plan's branch in `ontokit-web` MUST be cut from `catholicos/dev` AFTER Plan 17-01's api PR has merged to `catholicos/dev` AND PR #88 (`entity-graph-pr`) has merged to `catholicos/dev` (per SPEC Constraints + D-08). + +Per global feedback "Sync & rebase all repos": fetch + rebase ontokit-web against `catholicos/dev` before starting. + +Per project CLAUDE.md: dev server is `./ontokit-web.sh start` (port 3000). Tests run via `npm run test`. + + + +@.planning/STATE.md +@.planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-SPEC.md +@.planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-CONTEXT.md +@.planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-RESEARCH.md +@.planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-PATTERNS.md +@.planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-UI-SPEC.md +@.planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-VALIDATION.md +@CLAUDE.md +@components/editor/standard/EntityTabBar.tsx +@lib/stores/selectionStore.ts +@lib/ontology/turtleUtils.ts + + + + +PaneTabStrip props (UI-SPEC §Component Inventory + RESEARCH Code Example 1): +```ts +export type PaneTab = "detail" | "graph" | "source"; + +export interface PaneTabDefinition { + id: PaneTab; + label: string; + icon: LucideIcon; // from "lucide-react" +} + +interface PaneTabStripProps { + tabs: PaneTabDefinition[]; + activeTab: PaneTab; + onTabChange: (tab: PaneTab) => void; +} +``` + +EntityModal props (D-09 + RESEARCH §Q2): +```ts +interface EntityModalProps { + isOpen: boolean; + onClose: () => void; + children: ReactNode; + headerExtras?: ReactNode; + size?: "modal" | "maximized"; + onMaximize?: () => void; + onRestore?: () => void; + titleId?: string; +} +``` + +useFullSourceOverlay return contract (RESEARCH Code Example 2): +```ts +{ + isOpen: boolean; + size: "closed" | "modal" | "maximized"; + cameFromModal: boolean; + open: () => void; + close: () => void; + maximize: () => void; + restore: () => void; +} +``` + +extractEntitySnippet contract (RESEARCH §Q4): +```ts +export interface EntitySnippet { + text: string; + startLine: number; // 1-indexed + endLine: number; // 1-indexed +} +export function extractEntitySnippet(source: string, iri: string): EntitySnippet | null; +``` + +selectionStore extension (RESEARCH Code Example 3): +```ts +export type PaneTab = "detail" | "graph" | "source"; + +interface SelectionState { + // ... existing iri / type / mode / setSelection / setMode + activePaneTab: PaneTab; + setActivePaneTab: (tab: PaneTab) => void; +} +// clear() resets activePaneTab to "detail" +``` + +GraphEdgeType (mirror from api Plan 17-01): +```ts +export type GraphEdgeType = + | "subClassOf" | "equivalentClass" | "disjointWith" | "seeAlso" + | "domain" | "range" | "subPropertyOf" + | "rdfType" | "sameAs" | "objectProperty"; +``` + +OntologyEdge edgeTypeConfig palette (UI-SPEC §Color — LOCKED): +```ts +const edgeTypeConfig: Record = { + subClassOf: { stroke: "#94a3b8", label: "subClassOf", markerEnd: "url(#arrow-slate)" }, + equivalentClass: { stroke: "#3b82f6", strokeDasharray: "5 3", label: "equivalentTo" }, + disjointWith: { stroke: "#ef4444", strokeDasharray: "5 3", label: "disjointWith" }, + seeAlso: { stroke: "#9ca3af", strokeDasharray: "2 4", label: "seeAlso" }, + domain: { stroke: "#94a3b8", label: "domain", markerEnd: "url(#arrow-slate)" }, + range: { stroke: "#94a3b8", strokeDasharray: "4 2", label: "range", markerEnd: "url(#arrow-slate)" }, + subPropertyOf: { stroke: "#64748b", label: "subPropertyOf", markerEnd: "url(#arrow-slate)" }, + rdfType: { stroke: "#0ea5e9", label: "rdf:type", markerEnd: "url(#arrow-slate)" }, + sameAs: { stroke: "#a855f7", strokeDasharray: "5 3", label: "sameAs" }, + objectProperty: { stroke: "#71717a", label: "", markerEnd: "url(#arrow-slate)" }, +}; +``` + +Stroke widths: 1.5 for subClassOf | subPropertyOf | rdfType; 1 for everything else. + + + + + + + Task 1: Extend selectionStore + add useEffectiveTab + extend tests + lib/stores/selectionStore.ts, lib/hooks/useEffectiveTab.ts, __tests__/lib/stores/selectionStore.test.ts + + - lib/stores/selectionStore.ts (full file — small, currently has iri/type/mode) + - lib/stores/editorModeStore.ts (for EditorMode type import) + - __tests__/lib/stores/selectionStore.test.ts (existing tests — extend) + - .planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-RESEARCH.md §Code Example 3 (locked store extension) + - .planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-CONTEXT.md D-13, D-14, D-15 (selectionStore + non-persist + derived fallback) + - .planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-PATTERNS.md §lib/stores/selectionStore.ts + + + - Test 1: useSelectionStore initial state has activePaneTab === "detail" (SPEC R8 default) + - Test 2: setActivePaneTab("graph") updates activePaneTab to "graph" + - Test 3: clear() resets activePaneTab to "detail" (NOT null — distinct from iri/type/mode which clear to null) + - Test 4: setSelection does NOT change activePaneTab (R6 persistence) + - Test 5: useEffectiveTab(editorMode="standard") with stored "source" returns "detail" + - Test 6: useEffectiveTab(editorMode="standard") with stored "graph" returns "graph" + - Test 7: useEffectiveTab(editorMode="developer") with stored "source" returns "source" + - Test 8: useEffectiveTab does NOT call setActivePaneTab — store remains "source" after deriving "detail" (D-15 — never mutates) + + + **Sub-step A — Extend `lib/stores/selectionStore.ts`** per D-13/D-14/D-15 and RESEARCH Code Example 3: + + 1. Add a `PaneTab` type export at the top: `export type PaneTab = "detail" | "graph" | "source";` + 2. Add `activePaneTab: PaneTab` to `SelectionState` interface (default `"detail"`). + 3. Add `setActivePaneTab: (tab: PaneTab) => void` to `SelectionState`. + 4. In the `create` initializer, set `activePaneTab: "detail"`. + 5. Add the setter: `setActivePaneTab: (tab) => set({ activePaneTab: tab })`. + 6. Update `clear` to reset activePaneTab to `"detail"` (NOT null) — this matches SPEC R8 ("first entity opened in a NEW SESSION lands on Detail") and distinguishes it from iri/type/mode which clear to null. + 7. Preserve the existing non-persist semantic — DO NOT add `persist` middleware. Confirm by NOT adding any import from `zustand/middleware`. + + **Sub-step B — Create `lib/hooks/useEffectiveTab.ts`** per D-15: + ```ts + "use client"; + import { useSelectionStore, type PaneTab } from "@/lib/stores/selectionStore"; + import type { EditorMode } from "@/lib/stores/editorModeStore"; + + /** + * D-15: Derived fallback for activePaneTab. Source tab is Developer-only; + * when editorMode is "standard" but stored tab is "source", components + * should render Detail. Store value is NEVER mutated by mode change so + * switching back to Developer restores the user's Source preference. + */ + export function useEffectiveTab(editorMode: EditorMode): PaneTab { + const stored = useSelectionStore((s) => s.activePaneTab); + return stored === "source" && editorMode === "standard" ? "detail" : stored; + } + ``` + + **Sub-step C — Extend `__tests__/lib/stores/selectionStore.test.ts`** with the 4 store tests + 4 hook tests above. + + For the hook tests, use `renderHook` from `@testing-library/react`: + ```ts + import { renderHook } from "@testing-library/react"; + import { useEffectiveTab } from "@/lib/hooks/useEffectiveTab"; + ``` + For test 8 (D-15 immutability), call `useSelectionStore.getState().setActivePaneTab("source")` first, then `renderHook(() => useEffectiveTab("standard"))`, assert returned value is `"detail"`, then assert `useSelectionStore.getState().activePaneTab` is still `"source"`. + + Use `beforeEach(() => useSelectionStore.getState().clear())` for test isolation (already in existing tests). + + + npm run test -- selectionStore --run 2>&1 | tail -30 && npm run test -- useEffectiveTab --run 2>&1 | tail -10 + + + - `grep -c "activePaneTab" lib/stores/selectionStore.ts` is at least 4 (type field + initializer + setter + clear reset) + - `grep -c "setActivePaneTab" lib/stores/selectionStore.ts` is at least 2 (interface + initializer) + - `grep -E "^export type PaneTab" lib/stores/selectionStore.ts` returns exactly 1 match + - `grep -c "persist" lib/stores/selectionStore.ts` returns 0 — non-persist preserved per D-14 + - `grep -c "setActivePaneTab" lib/hooks/useEffectiveTab.ts` returns 0 — hook MUST NOT mutate per D-15 (Pitfall 3) + - `grep -E "^export function useEffectiveTab" lib/hooks/useEffectiveTab.ts` returns exactly 1 match + - `grep -c "^ it\(" __tests__/lib/stores/selectionStore.test.ts | head -1` shows at least 8 new it-blocks (4 store + 4 hook) + - `npm run test -- selectionStore useEffectiveTab --run` exits 0 + + selectionStore exports activePaneTab + setActivePaneTab; clear() resets to "detail"; useEffectiveTab is a pure derivation; all 8 new tests pass; existing 7 selectionStore tests still pass. + + + + Task 2: Mirror GraphEdgeType + extend OntologyEdge palette + tests + lib/graph/types.ts, components/graph/OntologyEdge.tsx, __tests__/components/graph/OntologyEdge.test.tsx + + - lib/graph/types.ts (full file — current 4-value GraphEdgeType) + - components/graph/OntologyEdge.tsx (full file — current edgeTypeConfig at lines 21-47, stroke-width logic at lines 88-90) + - .planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-UI-SPEC.md §Color (locked palette table for 10 edge_kind values) + - .planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-RESEARCH.md §Q7 (lineage rationale) + - .planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-PATTERNS.md §components/graph/OntologyEdge.tsx + + + - Test 1: edgeTypeConfig has exactly 10 entries with keys matching the GraphEdgeType union + - Test 2: For each edge_kind in [subClassOf, equivalentClass, disjointWith, seeAlso, domain, range, subPropertyOf, rdfType, sameAs, objectProperty]: rendering OntologyEdge with that edge_kind produces an SVG path with the correct stroke color and (where applicable) strokeDasharray + - Test 3: Stroke width is 1.5 for edge_kind ∈ {subClassOf, subPropertyOf, rdfType} and 1 for all others + - Test 4: edgeTypeConfig key set is exhaustive over GraphEdgeType (TypeScript Record ensures this at compile time; runtime check via Object.keys length === 10) + - Test 5: For sameAs and seeAlso, no markerEnd (symmetric relations are markerless per UI-SPEC) + - Test 6: For rdfType, label is exactly "rdf:type" (with colon) per palette + - Test 7: For objectProperty, default label is "" (UI-SPEC); the OntologyEdge component should accept an override label prop so individual graphs can show the actual predicate's local name + + + **Sub-step A — Extend `lib/graph/types.ts` GraphEdgeType union** to mirror the api Literal from Plan 17-01 Task 1: + ```ts + /** + * Mirror of GraphEdgeType in ontokit-api/ontokit/schemas/graph.py. + * Keep in sync — single source of truth is the api schema; web hand-mirrors per repo convention. + * 10 values total. + */ + export type GraphEdgeType = + | "subClassOf" + | "equivalentClass" + | "disjointWith" + | "seeAlso" + | "domain" + | "range" + | "subPropertyOf" + | "rdfType" + | "sameAs" + | "objectProperty"; + ``` + + **Sub-step B — Extend `components/graph/OntologyEdge.tsx` edgeTypeConfig** to include all 10 entries from UI-SPEC §Color (locked palette in `` block above). Append the 6 new entries to the existing 4. Use the EXACT hex values from UI-SPEC — do not approximate. + + **Sub-step C — Update stroke-width logic** at lines 88-90 from: + ```tsx + strokeWidth: edgeType === "subClassOf" ? 1.5 : 1, + ``` + to: + ```tsx + strokeWidth: (edgeType === "subClassOf" || edgeType === "subPropertyOf" || edgeType === "rdfType") ? 1.5 : 1, + ``` + Per UI-SPEC: 1.5 for hierarchical/semantic-primary edges; 1 for all others. + + **Sub-step D — Add a `labelOverride?: string` prop** for the objectProperty case. Individual graphs need to display the actual predicate's local name (e.g., "knows", "owns") instead of the literal `` placeholder. When `labelOverride` is provided, render it; otherwise fall back to `edgeTypeConfig[edgeType].label`. + + **Sub-step E — Create `__tests__/components/graph/OntologyEdge.test.tsx`** (or extend existing if it lands via PR #88 — read the file first; if missing, create following the EntityTabBar.test.tsx scaffolding). Use `vi.mock("@/lib/utils", () => ({ cn: (...args) => args.filter(Boolean).join(" ") }))` per project pattern. Use `@xyflow/react` test utilities or render OntologyEdge inside a minimal ReactFlow wrapper to assert SVG attributes. + + Implement the 7 test cases from ``. For test 4, use: + ```ts + import type { GraphEdgeType } from "@/lib/graph/types"; + const expectedKeys: GraphEdgeType[] = ["subClassOf", "equivalentClass", "disjointWith", "seeAlso", "domain", "range", "subPropertyOf", "rdfType", "sameAs", "objectProperty"]; + expect(Object.keys(edgeTypeConfig).sort()).toEqual(expectedKeys.sort()); + ``` + + + npm run test -- OntologyEdge --run 2>&1 | tail -30 && npm run type-check 2>&1 | tail -10 + + + - `grep -E "(subClassOf|equivalentClass|disjointWith|seeAlso|domain|range|subPropertyOf|rdfType|sameAs|objectProperty)" lib/graph/types.ts | wc -l` is at least 10 (every value appears in the union) + - `grep -c "subPropertyOf\|rdfType\|sameAs\|objectProperty\|domain\|range" components/graph/OntologyEdge.tsx` is at least 6 (all new keys in edgeTypeConfig) + - `grep -E "stroke: \"#94a3b8\"|stroke: \"#0ea5e9\"|stroke: \"#a855f7\"|stroke: \"#71717a\"|stroke: \"#64748b\"" components/graph/OntologyEdge.tsx | wc -l` is at least 6 (palette hex values from UI-SPEC are present verbatim) + - `grep -c "strokeDasharray" components/graph/OntologyEdge.tsx` is at least 5 (existing 3 dashed + new range "4 2" + new sameAs "5 3") + - `grep -c "subPropertyOf.*1\\.5\|rdfType.*1\\.5" components/graph/OntologyEdge.tsx` is at least 1 (stroke-width updated) + - `grep -c "labelOverride" components/graph/OntologyEdge.tsx` is at least 2 (prop type + render usage) + - `npm run test -- OntologyEdge --run` exits 0 + - `npm run type-check` exits 0 (TypeScript Record exhaustiveness enforced) + + GraphEdgeType is a 10-value union mirroring the api Literal; OntologyEdge.edgeTypeConfig has exhaustive 10 entries with locked UI-SPEC palette; stroke widths set per UI-SPEC; labelOverride prop wired for objectProperty case; all 7 tests pass; npm run type-check is clean. + + + + Task 3: Refactor lib/api/graph.ts to call /entity-graph with focus_type + extend types + lib/api/graph.ts + + - lib/api/graph.ts (post-PR #88 version — currently calls /classes/graph with class_iri) + - lib/api/projects.ts (canonical API client style precedent) + - lib/api/client.ts (re-export pattern + ApiError + auth header convention) + - .planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-CONTEXT.md D-05 (single new endpoint + frontend method) + - .planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-PATTERNS.md §lib/api/graph.ts + + + - Test 1: graphApi.getEntityGraph(focusIri, "class") issues GET to /api/v1/projects/{id}/ontology/entity-graph?focus_iri=...&focus_type=class (verified via fetch mock) + - Test 2: graphApi.getEntityGraph(focusIri, "property") sends focus_type=property + - Test 3: graphApi.getEntityGraph(focusIri, "individual") sends focus_type=individual + - Test 4: focusType defaults to "class" when omitted (backward compat for any callers not yet updated) + - Test 5: 404 response is propagated as ApiError with status=404 (existing client error wrapping) + - Test 6: Response is typed against the extended GraphEdgeType (compile-time check via npm run type-check) + + + Read post-PR-#88 `lib/api/graph.ts` first. Confirm the current method signature (likely `getEntityGraph(focusIri: string)` or `getClassGraph`). + + **Refactor:** + 1. Update the method signature: `getEntityGraph(projectId: string, focusIri: string, focusType: "class" | "property" | "individual" = "class", options?: GraphOptions)` where `GraphOptions` carries `branch`, `ancestorsDepth`, `descendantsDepth`, `maxNodes`, `includeSeeAlso`. + 2. Change the URL from `/classes/graph` to `/entity-graph`. + 3. Change the query param `class_iri` to `focus_iri`. + 4. Add new query param `focus_type`. + 5. Update the response type to use the extended `GraphEdgeType` (10 values) — `OntologyGraphEdge.kind: GraphEdgeType` should pick up the wider union automatically once Task 2 ships the type. + 6. Add a JSDoc comment block referencing CONTEXT D-05: "Calls the /entity-graph endpoint introduced in Phase 17. The legacy /classes/graph route remains as a deprecated shim during the v0.5.0→v0.6.0 transition window." + 7. Re-export from `lib/api/client.ts` per CLAUDE.md convention (same pattern as embeddingsApi, qualityApi, analyticsApi). + + Do NOT add a feature flag or try/catch fallback for /classes/graph (D-08). If the api isn't deployed, getEntityGraph returns 404 — that's fine, it's a coordinated release. + + **Test scaffolding:** create or extend `__tests__/lib/api/graph.test.ts` (read the file first; if it lands via PR #88 use that as base). Mock fetch via `vi.spyOn(global, "fetch")` and assert URL + query string contents. + + + npm run test -- "lib/api/graph" --run 2>&1 | tail -20 && npm run type-check 2>&1 | tail -10 + + + - `grep -c '"/entity-graph"' lib/api/graph.ts` is at least 1 + - `grep -c "focus_type" lib/api/graph.ts` is at least 2 (URL builder + jsdoc) + - `grep -c "focus_iri" lib/api/graph.ts` is at least 1 + - `grep -c "class_iri" lib/api/graph.ts` returns 0 (legacy param name removed from the new endpoint call) + - `grep -c "/classes/graph" lib/api/graph.ts` returns 0 (legacy route not called from the new method) + - `grep -c "graphApi" lib/api/client.ts` is at least 1 (re-export wired) + - `npm run test -- "lib/api/graph" --run` exits 0 + - `npm run type-check` exits 0 + + graphApi.getEntityGraph calls /entity-graph with focus_iri + focus_type; backward-compat default focusType="class"; response typed against extended GraphEdgeType; legacy /classes/graph URL not present; tests + type-check pass. + + + + Task 4: Build PaneTabStrip + tests + components/editor/PaneTabStrip.tsx, __tests__/components/editor/PaneTabStrip.test.tsx + + - components/editor/standard/EntityTabBar.tsx (canonical density precedent — Tailwind classes MUST match) + - __tests__/components/editor/standard/EntityTabBar.test.tsx (test scaffolding template) + - .planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-UI-SPEC.md §Component Inventory (PaneTabStrip locked spec) + - .planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-RESEARCH.md §Code Example 1 (full component skeleton) + - .planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-RESEARCH.md §Q1 (ARIA tablist wiring) + - .planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-PATTERNS.md §PaneTabStrip + + + - Test 1: Renders all tabs from the tabs prop array (test with 2 tabs and 3 tabs) + - Test 2: Container has role="tablist" + - Test 3: Each button has role="tab", aria-selected reflects activeTab, aria-controls="pane-panel-{id}", id="pane-tab-{id}" + - Test 4: Active tab has tabIndex=0; inactive tabs have tabIndex=-1 (roving tabindex pattern) + - Test 5: Click on inactive tab calls onTabChange with that tab's id + - Test 6: ArrowRight on focused tab calls onTabChange with next tab (cycles to first when at end) + - Test 7: ArrowLeft cycles backward (cycles to last when at first) + - Test 8: Home calls onTabChange with first tab; End calls with last tab + - Test 9: Active tab has Tailwind classes "border-b-2 border-primary-600 bg-primary-50 text-primary-600" + - Test 10: Inactive tab has classes "text-slate-500 hover:bg-slate-50" + - Test 11: Each tab renders its icon (Lucide component) at h-3.5 w-3.5 + - Test 12: Container has classes "flex border-b border-slate-200 dark:border-slate-700" (LOCKED — bottom-border alignment with EntityTabBar) + - Test 13: Each button has classes "flex-1 px-3 py-2 text-xs font-medium" (LOCKED — density parity with EntityTabBar) + + + Implement `components/editor/PaneTabStrip.tsx` exactly per RESEARCH §Code Example 1 (already locked). Reproduced here as the source of truth — do NOT deviate: + + ```tsx + "use client"; + + import { useCallback } from "react"; + import type { LucideIcon } from "lucide-react"; + import { cn } from "@/lib/utils"; + + export type PaneTab = "detail" | "graph" | "source"; + + export interface PaneTabDefinition { + id: PaneTab; + label: string; + icon: LucideIcon; + } + + interface PaneTabStripProps { + tabs: PaneTabDefinition[]; + activeTab: PaneTab; + onTabChange: (tab: PaneTab) => void; + } + + export function PaneTabStrip({ tabs, activeTab, onTabChange }: PaneTabStripProps) { + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + const idx = tabs.findIndex((t) => t.id === activeTab); + if (idx === -1) return; + if (e.key === "ArrowRight") { + e.preventDefault(); + onTabChange(tabs[(idx + 1) % tabs.length].id); + } else if (e.key === "ArrowLeft") { + e.preventDefault(); + onTabChange(tabs[(idx - 1 + tabs.length) % tabs.length].id); + } else if (e.key === "Home") { + e.preventDefault(); + onTabChange(tabs[0].id); + } else if (e.key === "End") { + e.preventDefault(); + onTabChange(tabs[tabs.length - 1].id); + } + }, + [tabs, activeTab, onTabChange] + ); + + return ( +
+ {tabs.map((tab) => { + const Icon = tab.icon; + const isActive = activeTab === tab.id; + return ( + + ); + })} +
+ ); + } + ``` + + Note the LOCKED Tailwind classes: `flex border-b border-slate-200 dark:border-slate-700` on the container; `flex-1 px-3 py-2 text-xs font-medium` on the button. These match `EntityTabBar.tsx` byte-for-byte to satisfy the SPEC bottom-border alignment constraint. Do not change them. + + **Tests** in `__tests__/components/editor/PaneTabStrip.test.tsx` — follow the EntityTabBar.test.tsx scaffolding pattern (with `vi.mock("@/lib/utils", () => ({ cn: (...args) => args.filter(Boolean).join(" ") }))` to bypass class merging in unit tests). Use `screen.getByRole("tab", {name: ...})` for ARIA assertions and `fireEvent.keyDown` for keyboard tests. + + For the icon test, pass a stub Lucide icon: `const StubIcon = () => ` and assert `screen.getAllByTestId("stub-icon")` length matches tabs length. +
+ + npm run test -- PaneTabStrip --run 2>&1 | tail -30 + + + - File exists: `test -f components/editor/PaneTabStrip.tsx` + - `grep -c "role=\"tablist\"" components/editor/PaneTabStrip.tsx` is exactly 1 + - `grep -c "role=\"tab\"" components/editor/PaneTabStrip.tsx` is exactly 1 + - `grep -c "aria-selected" components/editor/PaneTabStrip.tsx` is exactly 1 + - `grep -c "aria-controls" components/editor/PaneTabStrip.tsx` is exactly 1 + - `grep -c "tabIndex" components/editor/PaneTabStrip.tsx` is exactly 1 + - `grep -E "flex border-b border-slate-200 dark:border-slate-700" components/editor/PaneTabStrip.tsx` returns exactly 1 match (LOCKED density parity) + - `grep -E "flex-1 .*px-3 py-2 text-xs font-medium" components/editor/PaneTabStrip.tsx` returns exactly 1 match (LOCKED density parity) + - `grep -c "ArrowRight\|ArrowLeft\|Home\|End" components/editor/PaneTabStrip.tsx` is at least 4 + - `grep -c "border-primary-600\|bg-primary-50\|text-primary-600" components/editor/PaneTabStrip.tsx` is at least 3 (active state classes from UI-SPEC) + - `grep -c "^ it\(\|^ it(" __tests__/components/editor/PaneTabStrip.test.tsx | head -1` is at least 13 (covers all 13 behavior cases) + - `npm run test -- PaneTabStrip --run` exits 0 + + PaneTabStrip is a self-contained, ARIA-compliant tablist matching EntityTabBar density; 13 unit tests pass; component is ready to be consumed by Plan 17-03 layouts. +
+ + + Task 5: Build extractEntitySnippet + useFullSourceOverlay + global CSS takeover rule + tests + lib/ontology/extractEntitySnippet.ts, lib/hooks/useFullSourceOverlay.ts, app/globals.css, __tests__/lib/ontology/extractEntitySnippet.test.ts, __tests__/lib/hooks/useFullSourceOverlay.test.tsx + + - lib/ontology/turtleUtils.ts (lines 178-277 — findBlock; lines 207-211, 233-235, 265-268 — continuation-line guards) + - lib/ontology/turtleClassUpdater.ts (existing findBlock callsite pattern: parseDeclarations + slice) + - lib/hooks/useKeyboardShortcuts.ts (lifecycle-managed DOM side-effect with cleanup — analog) + - app/globals.css (existing @layer base / utilities — find the right insertion point) + - .planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-RESEARCH.md §Code Example 2 (useFullSourceOverlay locked) + - .planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-RESEARCH.md §Q3 (CSS toggle takeover details) + - .planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-RESEARCH.md §Q4 (extractEntitySnippet wrapper locked) + - .planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-CONTEXT.md D-01..D-04, D-09..D-12 + - .planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-PATTERNS.md §extractEntitySnippet, §useFullSourceOverlay + + + extractEntitySnippet: + - Test 1: Class block extraction: given a Turtle source with `ex:Foo a owl:Class ; rdfs:label "Foo" .`, extractEntitySnippet(source, "http://example.org/Foo") returns {text: "ex:Foo a owl:Class ; rdfs:label \"Foo\" .", startLine: 1, endLine: 1} (1-indexed) + - Test 2: Property block extraction: ObjectProperty with rdfs:domain + rdfs:range, multi-line — returns full block + - Test 3: Individual block extraction: NamedIndividual with rdf:type assertions + - Test 4: Not-found IRI returns null + - Test 5: Continuation-line guard: a Turtle source where ex:Foo appears as the OBJECT of a continuation line (e.g., `ex:Bar rdfs:seeAlso\n ex:Foo ;`) does NOT match ex:Foo as the subject — must return null OR find ex:Foo's actual subject definition elsewhere + - Test 6: Returns 1-indexed line numbers (NOT 0-indexed) + + useFullSourceOverlay: + - Test 7: Initial state: { isOpen: false, size: "closed", cameFromModal: false } + - Test 8: open() transitions to size="modal", cameFromModal=false + - Test 9: maximize() (when size==="modal") transitions to size="maximized", cameFromModal=true + - Test 10: maximize() when size!=="modal" is a no-op + - Test 11: restore() (when size==="maximized") transitions to size="modal", cameFromModal stays true (preserves bounce) + - Test 12: close() resets to size="closed", cameFromModal=false from any size + - Test 13: While size==="maximized", document.documentElement.dataset.overlayTakeover === "true" + - Test 14: After restore() or close(), document.documentElement.dataset.overlayTakeover is undefined + - Test 15: Bounce maximize → restore → maximize keeps cameFromModal=true throughout + - Test 16: Hook unmount clears document.documentElement.dataset.overlayTakeover + + + **Sub-step A — Create `lib/ontology/extractEntitySnippet.ts`** per RESEARCH §Q4 (locked wrapper): + ```ts + import { findBlock, parseDeclarations } from "@/lib/ontology/turtleUtils"; + + export interface EntitySnippet { + text: string; + startLine: number; // 1-indexed + endLine: number; // 1-indexed + } + + /** + * Type-agnostic snippet extractor for the Source tab body. + * Wraps findBlock from turtleUtils.ts (which already handles class/property/individual subjects). + * Continuation-line guard is preserved by findBlock at all three fallback layers. + */ + export function extractEntitySnippet(source: string, iri: string): EntitySnippet | null { + if (!source || !iri) return null; + const { prefixes, base } = parseDeclarations(source); + const lines = source.split("\n"); + const block = findBlock(lines, iri, prefixes, base); + if (!block) return null; + return { + text: lines.slice(block.startLine, block.endLine + 1).join("\n"), + startLine: block.startLine + 1, // findBlock returns 0-indexed; convert to 1-indexed + endLine: block.endLine + 1, + }; + } + ``` + + Verify the imports — `parseDeclarations` and `findBlock` must be exported from `lib/ontology/turtleUtils.ts`. If `parseDeclarations` is not currently exported, export it (small change inside that file). `findBlock` is already exported per RESEARCH §Q4. + + **Sub-step B — Create `lib/hooks/useFullSourceOverlay.ts`** per RESEARCH §Code Example 2 (locked): + ```ts + "use client"; + import { useCallback, useEffect, useState } from "react"; + + type Size = "closed" | "modal" | "maximized"; + + /** + * State machine for the Modal ⇄ Maximize ⇄ Restore round-trip on the Source tab's full-source overlay. + * D-10: local state (not store). D-11: CSS attribute toggle on . D-12: Esc closes from any size. + */ + export function useFullSourceOverlay() { + const [state, setState] = useState<{ size: Size; cameFromModal: boolean }>({ + size: "closed", + cameFromModal: false, + }); + + const open = useCallback(() => setState({ size: "modal", cameFromModal: false }), []); + const maximize = useCallback( + () => setState((s) => (s.size === "modal" ? { size: "maximized", cameFromModal: true } : s)), + [] + ); + const restore = useCallback( + () => setState((s) => (s.size === "maximized" ? { size: "modal", cameFromModal: true } : s)), + [] + ); + const close = useCallback(() => setState({ size: "closed", cameFromModal: false }), []); + + useEffect(() => { + if (state.size === "maximized") { + document.documentElement.dataset.overlayTakeover = "true"; + } else { + delete document.documentElement.dataset.overlayTakeover; + } + return () => { + delete document.documentElement.dataset.overlayTakeover; + }; + }, [state.size]); + + return { + isOpen: state.size !== "closed", + size: state.size, + cameFromModal: state.cameFromModal, + open, + close, + maximize, + restore, + }; + } + ``` + + **Sub-step C — Add CSS rules to `app/globals.css`** per RESEARCH §Q3 + D-11. Find the `@layer utilities` or equivalent section and append: + ```css + /* Phase 17: Full-source overlay takeover (D-11). Hides surrounding panes + when the overlay maximizes. Monaco container is intentionally NOT hidden + so its IStandaloneCodeEditor instance persists across Maximize ⇄ Restore. */ + [data-overlay-takeover="true"] .editor-tree-pane, + [data-overlay-takeover="true"] .editor-detail-pane { + display: none; + } + [data-overlay-takeover="true"] .editor-overlay-modal { + position: fixed; + inset: 0; + width: 100vw; + height: 100vh; + max-width: none; + border-radius: 0; + } + ``` + + The class names `.editor-tree-pane`, `.editor-detail-pane`, and `.editor-overlay-modal` will be applied by Plan 17-03 in the layouts (and by the EntityModal renaming in Task 6 below). Adding the rules now is fine — they are no-ops until those classes appear. + + **Sub-step D — Tests:** + - `__tests__/lib/ontology/extractEntitySnippet.test.ts`: 6 tests above. Use small inline Turtle fixtures (10-30 line strings). + - `__tests__/lib/hooks/useFullSourceOverlay.test.tsx`: 10 tests (7-16). Use `renderHook` + `act` from `@testing-library/react`. For the cleanup test (16), call `unmount()` after maximizing and assert `document.documentElement.dataset.overlayTakeover` is undefined. + + + npm run test -- extractEntitySnippet useFullSourceOverlay --run 2>&1 | tail -40 + + + - `test -f lib/ontology/extractEntitySnippet.ts && test -f lib/hooks/useFullSourceOverlay.ts` (both files exist) + - `grep -c "findBlock\|parseDeclarations" lib/ontology/extractEntitySnippet.ts` is at least 2 + - `grep -c "block.startLine + 1" lib/ontology/extractEntitySnippet.ts` is at least 1 (1-indexed conversion) + - `grep -c "overlayTakeover" lib/hooks/useFullSourceOverlay.ts` is at least 2 (set + delete) + - `grep -c "cameFromModal" lib/hooks/useFullSourceOverlay.ts` is at least 5 (initial + 4 transitions) + - `grep -c "data-overlay-takeover" app/globals.css` is at least 1 (CSS rule added; comment lines stripped — use `grep -v '^\s*/\*' app/globals.css | grep -c "data-overlay-takeover"` if needed) + - `grep -c "editor-tree-pane\|editor-detail-pane\|editor-overlay-modal" app/globals.css` is at least 3 + - `grep -c "^ it\(\|^ it(" __tests__/lib/ontology/extractEntitySnippet.test.ts | head -1` is at least 6 + - `grep -c "^ it\(\|^ it(" __tests__/lib/hooks/useFullSourceOverlay.test.tsx | head -1` is at least 10 + - `npm run test -- extractEntitySnippet useFullSourceOverlay --run` exits 0 + + extractEntitySnippet wraps findBlock with 1-indexed output; useFullSourceOverlay state machine + CSS attribute side-effect work end-to-end; CSS takeover rules added to globals.css; 16 tests pass. + + + + Task 6: Rename EntityGraphModal → EntityModal with generalized props + update graph consumer + components/graph/EntityGraphModal.tsx, components/graph/EntityModal.tsx, components/graph/OntologyGraph.tsx + + - components/graph/EntityGraphModal.tsx (post-PR-#88 version — current focus-trap + Esc handler at lines 25-83 per RESEARCH §Q2) + - components/ui/dialog.tsx (Radix Dialog primitive — backdrop + dismissal markup precedent) + - components/graph/OntologyGraph.tsx (post-PR-#88 version — current EntityGraphModal consumer) + - __tests__/components/graph/EntityGraphModal.test.tsx (existing tests — must update for new prop contract) + - .planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-CONTEXT.md D-09 (rename + slot generalization) + - .planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-UI-SPEC.md §EntityModal (locked spec) + - .planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-RESEARCH.md §Q2 (new prop contract) + + + - Test 1: EntityModal renders children prop in body slot (verify with simple hello) + - Test 2: EntityModal renders headerExtras prop in header (verify with ) + - Test 3: When isOpen=false, modal does NOT render + - Test 4: When size==="modal", outer container has classes "w-[92vw] h-[92vh] max-w-none rounded-lg shadow-2xl" + - Test 5: When size==="maximized", outer container has class "editor-overlay-modal" (the CSS rule from Task 5 expands it via [data-overlay-takeover="true"]) + - Test 6: Outer container has role="dialog", aria-modal="true", aria-labelledby pointing at titleId when provided + - Test 7: Esc keypress calls onClose + - Test 8: Click on backdrop calls onClose + - Test 9: ✕ button click calls onClose + - Test 10: onMaximize prop is exposed (not called by the shell — caller wires it through headerExtras) + - Test 11: OntologyGraph still renders correctly after consumer migration to EntityModal (existing graph tests pass) + + + **Sub-step A — Rename file + update imports:** + 1. Run `git mv components/graph/EntityGraphModal.tsx components/graph/EntityModal.tsx` + 2. In the renamed file, change all internal references: `EntityGraphModal` → `EntityModal`. The exported function/component name is `EntityModal`. + 3. Update `__tests__/components/graph/EntityGraphModal.test.tsx` → `__tests__/components/graph/EntityModal.test.tsx` via `git mv`. + + **Sub-step B — Generalize props in `components/graph/EntityModal.tsx`** per D-09 + UI-SPEC + RESEARCH §Q2: + + Replace the existing graph-specific prop interface with: + ```tsx + interface EntityModalProps { + isOpen: boolean; + onClose: () => void; + children: ReactNode; + headerExtras?: ReactNode; + size?: "modal" | "maximized"; + onMaximize?: () => void; // exposed for caller wiring; shell does not invoke + onRestore?: () => void; // same + titleId?: string; // for aria-labelledby (caller renders title in body or headerExtras) + } + ``` + + Replace the hard-coded graph child with `{children}` in the body slot. Add a header slot rendering `{headerExtras}` on the right side (e.g., flex-row container with title on left, headerExtras on right). Preserve the existing focus trap + Esc handler + click-outside dismissal logic from lines 25-83 (per RESEARCH §Q2 — keep intact). + + Outer container classes per UI-SPEC: + - Default size: `"w-[92vw] h-[92vh] max-w-none rounded-lg shadow-2xl bg-white dark:bg-slate-800 editor-overlay-modal"` + - Backdrop: `"fixed inset-0 z-50 bg-black/50"` (existing) + - The `editor-overlay-modal` class is the hook for the CSS rule from Task 5 — when `[data-overlay-takeover="true"]` is set on ``, the modal expands to fullscreen. + + ARIA: `role="dialog"`, `aria-modal="true"`, `aria-labelledby={titleId}` (only when titleId is provided). + + Add `editor-tree-pane` / `editor-detail-pane` class hooks awareness — those classes are applied by the layouts in Plan 17-03; nothing for this task to do beyond the CSS rule already in Task 5. + + **Sub-step C — Migrate the existing graph consumer in `components/graph/OntologyGraph.tsx`:** + Find where the modal is currently rendered (post-PR-#88). Wrap the OntologyGraph body in `...` with the new prop contract: + - Pass the existing graph component as `children`. + - Pass any existing graph-specific header chrome as `headerExtras` (or leave undefined if none). + - Keep `isOpen` / `onClose` from the existing state. + + **Sub-step D — Update `__tests__/components/graph/EntityModal.test.tsx`** for the new prop contract. Keep the existing focus-trap + Esc tests; add the 11 tests above. Use the same RTL + vi.mock pattern. + + **Sub-step E — Find-and-replace `EntityGraphModal` → `EntityModal` across the repo.** Use `grep -rn "EntityGraphModal" --include="*.ts" --include="*.tsx" .` to find all references; update them. There SHOULD be no consumers other than `OntologyGraph.tsx` and the test, but verify. + + + npm run test -- EntityModal OntologyGraph --run 2>&1 | tail -30 && grep -rn "EntityGraphModal" --include="*.ts" --include="*.tsx" . | grep -v ".planning\|node_modules" 2>&1 | head -5 + + + - File `components/graph/EntityGraphModal.tsx` does NOT exist: `! test -f components/graph/EntityGraphModal.tsx` + - File `components/graph/EntityModal.tsx` exists: `test -f components/graph/EntityModal.tsx` + - `grep -rn "EntityGraphModal" --include="*.ts" --include="*.tsx" .` excluding `.planning/` and `node_modules/` returns 0 lines (the rename is complete repo-wide) + - `grep -c "headerExtras\|onMaximize\|onRestore\|titleId" components/graph/EntityModal.tsx` is at least 4 (all new props are referenced in the component) + - `grep -c "{children}" components/graph/EntityModal.tsx` is at least 1 (body slot rendered) + - `grep -c "editor-overlay-modal" components/graph/EntityModal.tsx` is at least 1 (CSS hook class applied) + - `grep -c "role=\"dialog\"\|aria-modal" components/graph/EntityModal.tsx` is at least 2 (ARIA preserved) + - `grep -c "w-\\[92vw\\]\\|h-\\[92vh\\]" components/graph/EntityModal.tsx` is at least 2 (UI-SPEC sizing) + - `grep -c "EntityModal" components/graph/OntologyGraph.tsx` is at least 1 (consumer updated) + - `npm run test -- EntityModal OntologyGraph --run` exits 0 + - `npm run type-check` exits 0 + + EntityGraphModal is renamed to EntityModal repo-wide; new prop contract supports children + headerExtras + size + maximize/restore callbacks + titleId; existing graph consumer migrated; existing focus-trap + Esc + click-outside dismissal still work; tests + type-check pass. + + + + Task 7: Build SourceTabBody + FullSourceOverlay + tests + components/editor/SourceTabBody.tsx, components/editor/FullSourceOverlay.tsx, __tests__/components/editor/SourceTabBody.test.tsx, __tests__/components/editor/FullSourceOverlay.test.tsx + + - components/editor/PropertyDetailPanel.tsx (lines 83-86 — useMemo over (sourceContent, iri) pattern) + - components/editor/EntityPlaceholderDetail.tsx (lines 13-21 — empty-state pattern) + - components/editor/TurtleEditor.tsx (Monaco wrapper — readOnly prop, language, theme inheritance; CONFIRM whether forwardRef + useImperativeHandle exposes a `revealLineInCenter(line: number)` method. As of phase 17 planning: TurtleEditor does NOT forward a ref — Task 7 MUST add that, mirroring the OntologySourceEditor pattern below.) + - components/editor/OntologySourceEditor.tsx (existing full-pane Monaco renderer — analog for FullSourceOverlay; lines 48 + 196-onwards already do `forwardRef` + `useImperativeHandle(ref, () => ({ revealLineInCenter, ... }))`. Reuse this pattern verbatim for TurtleEditor when wiring the FullSourceOverlay ref API.) + - lib/editor/indexWorker.ts (lines 4-37 — IriPosition + sourceIriIndex public API; line is 1-indexed at line 105) + - .planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-UI-SPEC.md §Source-tab body, §Source full-source overlay body, §Copywriting Contract + - .planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-PATTERNS.md §SourceTabBody, §FullSourceOverlay + - .planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-CONTEXT.md D-01..D-04 + + + SourceTabBody: + - Test 1: When sourceContent + iri are non-empty AND extractEntitySnippet returns a snippet, renders Monaco with the snippet text + readOnly + language="turtle" + - Test 2: Toolbar renders `# File: ontology.ttl, line {N}` where N is 1-indexed (sourceIriIndex.get(iri)?.line, fallback to snippet.startLine) + - Test 3: Toolbar has "Open full source" primary button + "Copy snippet" secondary button + divider between + - Test 4: Click "Open full source" calls onOpenFullSource prop (which Plan 17-03 will wire to useFullSourceOverlay.open) + - Test 5: Click "Copy snippet" calls navigator.clipboard.writeText with the snippet text + shows "Snippet copied" toast (mock toast) + - Test 6: When extractEntitySnippet returns null, renders empty state with FileQuestion icon + heading "Source not available for this entity" + body "It may be defined in an imported ontology." — toolbar STILL shows "Open full source" button (D-02) + - Test 7: Snippet computation is memoized over (sourceContent, iri) — re-render with same props does not call extractEntitySnippet again (use vi.spyOn) + + FullSourceOverlay: + - Test 8: Renders a full-pane Monaco editor with the entire sourceContent + readOnly + language="turtle" + - Test 9: On open with focusIri set, scrolls Monaco to sourceIriIndex.get(focusIri)?.line and applies highlight + - Test 10: When focusIri changes while open (R10 auto-jump), scrolls Monaco to new entity's line WITHOUT dismissing the overlay + - Test 11: When size==="modal" AND no cameFromModal: headerExtras renders Maximize2 icon button with aria-label="Maximize source view" + - Test 12: When size==="maximized" AND cameFromModal: headerExtras renders Minimize2 icon button with aria-label="Restore to modal view" + - Test 13: When size==="modal" AND cameFromModal: headerExtras still shows Maximize2 (returning to modal — Maximize available again) + - Test 14: Body has class "editor-overlay-modal" (so CSS takeover from Task 5 applies) + - Test 15 (B2 — Monaco scroll-preservation across Maximize⇄Restore, R9 / D-11 acceptance): Mount `` containing ``. Capture the Monaco editor instance ref BEFORE the size transition. Trigger `size: 'modal' → 'maximized' → 'modal'` (Maximize then Restore). Assert the SAME Monaco instance ref persists — NOT a new instance. (Implementation note: this guards against a regression where Maximize remounts FullSourceOverlay and loses scroll position.) + - Test 16 (B2 — panes remain in DOM during maximize, R9 acceptance): While `size === 'maximized'`, assert that elements with `data-testid="editor-tree-pane"` AND `data-testid="editor-detail-pane"` (or the testids the layout in Plan 17-03 uses; if absent at this point, the test is shipped with `data-testid="editor-tree-pane"` markers added as part of Sub-step D in Plan 17-03 Task 2) STILL appear in the DOM via `expect(getByTestId(...)).toBeInTheDocument()`. CSS-only takeover MUST hide them visually without unmounting. (Implementation note: this catches a regression where someone wraps panes in `{!isMaximized && ...}` instead of using the `[data-overlay-takeover]` CSS rule from Task 5.) + - Test 17 (B4 — focusIri change calls revealLineInCenter, R10): Render `` with overlay open and `focusIri="ex:Foo"` (whose sourceIriIndex line=10). Re-render with `focusIri="ex:Bar"` (line=42). Use a `vi.spyOn` or non-mocked stub of the TurtleEditor ref to assert `revealLineInCenter` was called with `42` after the focusIri change. Test must NOT mock revealLineInCenter behind a TODO — the method must be wired end-to-end via the TurtleEditor ref forwarded in this task. + + + **Sub-step A — Build `components/editor/SourceTabBody.tsx`:** + ```tsx + "use client"; + import { useCallback, useMemo } from "react"; + import { ExternalLink, Copy, FileQuestion } from "lucide-react"; + import { extractEntitySnippet } from "@/lib/ontology/extractEntitySnippet"; + import { TurtleEditor } from "@/components/editor/TurtleEditor"; + import type { IriPosition } from "@/lib/editor/indexWorker"; + import { cn } from "@/lib/utils"; + + interface SourceTabBodyProps { + sourceContent: string; + focusIri: string | null; + sourceIriIndex?: Map; + onOpenFullSource: () => void; + onCopyToast?: (msg: string) => void; + } + + export function SourceTabBody({ + sourceContent, + focusIri, + sourceIriIndex, + onOpenFullSource, + onCopyToast, + }: SourceTabBodyProps) { + const snippet = useMemo(() => { + if (!focusIri || !sourceContent) return null; + return extractEntitySnippet(sourceContent, focusIri); + }, [sourceContent, focusIri]); + + const startLine = focusIri + ? sourceIriIndex?.get(focusIri)?.line ?? snippet?.startLine ?? null + : null; + + const handleCopy = useCallback(async () => { + if (!snippet) return; + try { + await navigator.clipboard.writeText(snippet.text); + onCopyToast?.("Snippet copied"); + } catch { + onCopyToast?.("Couldn't copy snippet. Try again."); + } + }, [snippet, onCopyToast]); + + return ( +
+ {/* Toolbar */} +
+ + {startLine !== null + ? `# File: ontology.ttl, line ${startLine}` + : "# File: ontology.ttl"} + +
+ +
+
+ + {/* Body */} + {snippet ? ( +
+ +
+ ) : ( +
+
+ )} +
+ ); + } + ``` + + Key choices: copy strings come VERBATIM from UI-SPEC §Copywriting Contract. The Monaco wrapper API is pulled from `TurtleEditor.tsx` — verify the actual prop names by reading that file first; if it accepts `value` + `readOnly` + `language`, use those; if different, adapt accordingly. + + **Sub-step B — Build `components/editor/FullSourceOverlay.tsx`:** + ```tsx + "use client"; + import { useEffect, useRef } from "react"; + import { Maximize2, Minimize2 } from "lucide-react"; + import { TurtleEditor } from "@/components/editor/TurtleEditor"; + import type { IriPosition } from "@/lib/editor/indexWorker"; + + interface FullSourceOverlayProps { + sourceContent: string; + focusIri: string | null; + sourceIriIndex?: Map; + size: "modal" | "maximized"; + cameFromModal: boolean; + onMaximize: () => void; + onRestore: () => void; + } + + export function FullSourceOverlay({ + sourceContent, + focusIri, + sourceIriIndex, + size, + cameFromModal, + onMaximize, + onRestore, + }: FullSourceOverlayProps) { + const editorRef = useRef(null); // Monaco IStandaloneCodeEditor instance + + // Auto-scroll to focusIri's line on mount AND on focusIri change (R10) + useEffect(() => { + if (!focusIri) return; + const line = sourceIriIndex?.get(focusIri)?.line; + if (!line) return; + // Defer to next tick so Monaco has mounted; reuse existing TurtleEditor's revealLineInCenter API + // The exact API call depends on TurtleEditor's exposed ref pattern — verify by reading TurtleEditor.tsx + // Pattern: editorRef.current?.revealLineInCenter(line); editorRef.current?.setSelection({...}) + }, [focusIri, sourceIriIndex]); + + return ( +
+ {/* Header extras are passed through EntityModal's headerExtras prop, so this body + just renders the editor. Maximize/Restore buttons live in headerExtras at the layout level. */} +
+ +
+
+ ); + } + + /** + * Helper to build the headerExtras content. Plan 17-03 calls this when wiring EntityModal. + */ + export function FullSourceHeaderExtras({ + size, + cameFromModal, + onMaximize, + onRestore, + }: Pick) { + if (size === "maximized" && cameFromModal) { + return ( + + ); + } + return ( + + ); + } + ``` + + **REQUIRED first sub-step (B4): Confirm + add TurtleEditor ref API.** + 1. Read `components/editor/TurtleEditor.tsx` AND `components/editor/OntologySourceEditor.tsx`. As of phase 17 planning, `TurtleEditor` keeps its Monaco instance in a private `editorRef` (line 86) but does NOT forward it; `OntologySourceEditor` already exposes `revealLineInCenter(line: number)` via `forwardRef` + `useImperativeHandle` (lines 48 + 196-onwards). + 2. **Mandate:** before completing Task 7, refactor `TurtleEditor.tsx` to mirror the `OntologySourceEditor` pattern: + - Wrap the component in `forwardRef(...)`. + - Define `interface TurtleEditorRef { revealLineInCenter: (line: number) => void; }`. + - Inside, call `useImperativeHandle(ref, () => ({ revealLineInCenter: (line) => { editorRef.current?.revealLineInCenter(line); editorRef.current?.setPosition({ lineNumber: line, column: 1 }); } }), [])`. + - This is non-negotiable per B4 — there is no TODO fallback. The R10 acceptance literal "modal scrolls to new entity's lines" cannot ship as a deferred TODO. + 3. Verify by greppable concrete string: `grep -c 'revealLineInCenter' components/editor/TurtleEditor.tsx` is at least 1 after this sub-step. + 4. In `FullSourceOverlay.tsx`, replace the placeholder useEffect comment with the real call: + ```tsx + const editorRef = useRef(null); + useEffect(() => { + if (!focusIri) return; + const line = sourceIriIndex?.get(focusIri)?.line; + if (!line) return; + // Defer one tick so Monaco has mounted before reveal. + queueMicrotask(() => editorRef.current?.revealLineInCenter(line)); + }, [focusIri, sourceIriIndex]); + ``` + Pass `` (now that TurtleEditor forwards the ref). + + Tests cover the contract — they verify revealLineInCenter is called with the right line number on focusIri change. No mocking-away of the auto-scroll behavior is permitted (per B4). + + **Sub-step C — Tests:** + - `__tests__/components/editor/SourceTabBody.test.tsx`: 7 tests (1-7). Mock TurtleEditor: `vi.mock("@/components/editor/TurtleEditor", () => ({ TurtleEditor: ({value}: any) =>
{value}
}))`. Mock clipboard: `vi.spyOn(navigator.clipboard, "writeText").mockResolvedValue()`. Mock toast via prop callback. + - `__tests__/components/editor/FullSourceOverlay.test.tsx`: 7 tests (8-14). Use the same TurtleEditor mock. Test FullSourceHeaderExtras separately for tests 11/12/13. +
+ + npm run test -- SourceTabBody FullSourceOverlay --run 2>&1 | tail -30 + + + - `test -f components/editor/SourceTabBody.tsx && test -f components/editor/FullSourceOverlay.tsx` + - `grep -c "extractEntitySnippet" components/editor/SourceTabBody.tsx` is at least 1 (memoized derivation) + - `grep -c "useMemo" components/editor/SourceTabBody.tsx` is at least 1 + - `grep -c "Source not available for this entity" components/editor/SourceTabBody.tsx` is at least 1 (D-02 empty-state copy verbatim) + - `grep -c "It may be defined in an imported ontology" components/editor/SourceTabBody.tsx` is at least 1 + - `grep -c "Open full source" components/editor/SourceTabBody.tsx` is at least 1 + - `grep -c "Copy snippet" components/editor/SourceTabBody.tsx` is at least 1 + - `grep -c "# File: ontology.ttl" components/editor/SourceTabBody.tsx` is at least 1 + - `grep -c "FileQuestion" components/editor/SourceTabBody.tsx` is at least 1 + - `grep -c "editor-overlay-modal" components/editor/FullSourceOverlay.tsx` is at least 1 + - `grep -c "Maximize2\|Minimize2" components/editor/FullSourceOverlay.tsx` is at least 2 + - `grep -c "Restore to modal view\|Maximize source view" components/editor/FullSourceOverlay.tsx` is at least 2 (UI-SPEC aria-labels) + - `grep -c "^ it\(\|^ it(" __tests__/components/editor/SourceTabBody.test.tsx | head -1` is at least 7 + - `grep -c "^ it\(\|^ it(" __tests__/components/editor/FullSourceOverlay.test.tsx | head -1` is at least 7 + - `npm run test -- SourceTabBody FullSourceOverlay --run` exits 0 + - **B4 ref API mandate:** `grep -c 'revealLineInCenter' components/editor/TurtleEditor.tsx` is at least 1 (TurtleEditor exposes the ref API) + - **B4 ref API mandate:** `grep -c 'revealLineInCenter' components/editor/FullSourceOverlay.tsx` is at least 1 (overlay calls it on focusIri change) + - **B4 ref API mandate:** `grep -c 'forwardRef\|useImperativeHandle' components/editor/TurtleEditor.tsx` is at least 2 (forwardRef + useImperativeHandle both present) + - **B2 scroll-preservation test name:** `grep -c 'preserves Monaco instance across Maximize.*Restore\|same monaco.*instance.*ref persists\|maximize.*restore.*same.*instance' __tests__/components/editor/FullSourceOverlay.test.tsx` is at least 1 (Test 15 named with one of these greppable phrases) + - **B2 panes-in-DOM test name:** `grep -c 'panes remain in DOM during maximize\|editor-tree-pane.*toBeInTheDocument\|editor-detail-pane.*toBeInTheDocument' __tests__/components/editor/FullSourceOverlay.test.tsx` is at least 1 (Test 16 named with one of these greppable phrases) + - **B4 focusIri-change test name:** `grep -c 'focusIri change calls revealLineInCenter\|revealLineInCenter.*called with.*42\|focusIri.*change.*reveal' __tests__/components/editor/FullSourceOverlay.test.tsx` is at least 1 (Test 17 named with one of these greppable phrases) + + SourceTabBody renders memoized snippet + toolbar + empty state per UI-SPEC; FullSourceOverlay renders Monaco body + exports a header-extras helper for Maximize/Restore button rendering; both components mock cleanly in tests; 14 tests pass. +
+ + + Task 8: Add annotation-property empty-state to OntologyGraph + tests + components/graph/OntologyGraph.tsx, __tests__/components/graph/OntologyGraph.test.tsx + + - components/graph/OntologyGraph.tsx (post-PR-#88 — read full file) + - components/editor/EntityPlaceholderDetail.tsx (empty-state layout pattern) + - .planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-UI-SPEC.md §Annotation-property empty graph state, §Copywriting Contract (heading + body verbatim) + - .planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-RESEARCH.md §Common Pitfalls 5 + - .planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-SPEC.md R11 (annotation property empty-state acceptance) + + + - Test 1: OntologyGraph receives graphData with nodes.length===1 && edges.length===0 → renders empty-state component (heading + body + Tag icon) + - Test 2: Empty-state heading text is exactly "No relationships to display" (UI-SPEC verbatim) + - Test 3: Empty-state body text is exactly "This annotation property has no domain, range, parent, or see-also relationships to render." (UI-SPEC verbatim) + - Test 4: Empty-state uses Lucide Tag icon (test by data-testid or className check) + - Test 5: When nodes.length > 1 OR edges.length > 0, OntologyGraph renders the normal ReactFlow canvas (no empty-state) + - Test 6: When nodes.length === 1 but the single node is NOT the focus (i.e., a sibling), normal canvas renders (NOT empty-state) — the empty-state trigger is specifically focus-only + - Test 7 (B3 — SPEC literal aria-label present): When the empty-state renders, the container element has `aria-label="No relationships to display for this annotation property"` (SPEC R11 literal). Use `getByLabelText('No relationships to display for this annotation property')` to assert presence. + - Test 8 (B3 — both visible strings present): When the empty-state renders, BOTH the heading text "No relationships to display" (rendered in `

`) AND the body text "This annotation property has no domain, range, parent, or see-also relationships to render." (rendered in `

`) are present in the DOM. Assert with `getByText` for each. + + + Read the post-#88 `components/graph/OntologyGraph.tsx`. Identify where the graphData is consumed (likely a `useEffect` that builds nodes/edges arrays for ReactFlow, or an early-return on empty data). + + **Add the empty-state branch at the top of the render function:** + ```tsx + // Phase 17 R11: annotation-property empty-state per UI-SPEC + CONTEXT D-06. + // Backend returns {nodes: [focus_only], edges: []} when an annotation property has no + // domain/range/subPropertyOf/seeAlso. Frontend detects and renders friendly copy. + const isEmptyAnnotationProperty = + graphData.nodes.length === 1 && + graphData.edges.length === 0 && + graphData.nodes[0].is_focus === true; + + if (isEmptyAnnotationProperty) { + return ( +

+
+ ); + } + ``` + + Import `Tag` from `lucide-react`. Verify the actual node shape — `is_focus` is the field name from the api response; if the field is named differently (e.g., `isFocus` after camelCase conversion in lib/api/graph.ts), adjust accordingly. Read the post-#88 `lib/graph/types.ts` `OntologyGraphNode` type to confirm. + + Per RESEARCH §Common Pitfalls 5: the trigger is `nodes.length === 1 && edges.length === 0 && nodes[0].is_focus`. The third clause prevents false positives when a normal graph happens to have only one node that is not the focus. + + **B3 reconcile (SPEC R11 vs UI-SPEC §Copywriting copy collision):** SPEC R11 specifies the literal string "No relationships to display for this annotation property" while UI-SPEC §Copywriting splits the visible copy into a heading ("No relationships to display") and body ("This annotation property has no domain, range, parent, or see-also relationships to render."). Reconciliation: render the SPEC literal as the container's `aria-label` (so screen readers announce the SPEC-literal sentence) AND keep the visible heading + body per UI-SPEC. All three strings ship in the empty-state DOM. Tests grep for ALL THREE. + + **Tests** in `__tests__/components/graph/OntologyGraph.test.tsx` — read the file first; if it lands via PR #88, extend it; otherwise create following the pattern. Mock ReactFlow as a stub `
` to keep tests fast. + + Add the 6 tests above using minimal graphData fixtures. + + + npm run test -- OntologyGraph --run 2>&1 | tail -30 + + + - `grep -c "No relationships to display for this annotation property" components/graph/OntologyGraph.tsx` is at least 1 (B3: SPEC R11 literal — rendered as aria-label on empty-state container so screen readers announce the SPEC sentence verbatim) + - `grep -c ">No relationships to display<\|>\s*No relationships to display\s*<" components/graph/OntologyGraph.tsx` is at least 1 (B3: UI-SPEC §Copywriting heading verbatim — visible

text) + - `grep -c "This annotation property has no domain, range, parent, or see-also relationships to render" components/graph/OntologyGraph.tsx` is at least 1 (B3: UI-SPEC §Copywriting body verbatim — visible

text) + - `grep -c "aria-label" components/graph/OntologyGraph.tsx` is at least 1 (B3: aria-label attribute carries the SPEC literal) + - `grep -c "is_focus\|isFocus" components/graph/OntologyGraph.tsx` is at least 1 (focus-detection guard from Pitfall 5) + - `grep -c "Tag" components/graph/OntologyGraph.tsx` is at least 1 (Lucide icon import + render) + - `grep -c "^ it\(\|^ it(" __tests__/components/graph/OntologyGraph.test.tsx | head -1` shows at least 6 new tests added + - `npm run test -- OntologyGraph --run` exits 0 + + OntologyGraph detects {nodes:[focus_only], edges:[]} and renders the locked UI-SPEC empty-state copy; 6 tests cover the trigger conditions; existing graph rendering tests still pass. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| Backend /entity-graph response → frontend renderer | Untrusted JSON crosses here (already authenticated by NextAuth session token + project_id scoping at the api) | +| User clipboard → SourceTabBody Copy snippet | Outbound only — copies snippet text TO clipboard, not the other way | +| document.documentElement.dataset.overlayTakeover ← useFullSourceOverlay | Hook owns this attribute exclusively in Phase 17 | + +## STRIDE Threat Register (ASVS L1) + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-17-07 | I (Information disclosure) | SourceTabBody Monaco snippet | accept | Source content is already visible elsewhere in the editor (the existing left-pane Source view); the new snippet view does not expose new data | +| T-17-08 | T (Tampering) | extractEntitySnippet input (full ontology source) | mitigate | findBlock continuation-line guard already prevents object-reference confusion (RESEARCH Common Pitfall 2). Memoization key `(sourceContent, iri)` prevents stale extractions | +| T-17-09 | T (Tampering) | OntologyEdge labelOverride prop (objectProperty case) | mitigate | label is rendered via React's standard text node interpolation (no dangerouslySetInnerHTML); React auto-escapes. Risk of XSS via predicate name is nil | +| T-17-10 | D (Denial of service) | useFullSourceOverlay state machine | accept | Local component state; no remote trigger; bounded transitions (closed→modal→maximized; cleanup on unmount guaranteed) | +| T-17-11 | I (Information disclosure) | navigator.clipboard.writeText in SourceTabBody | accept | User-initiated action with visible UI affordance; clipboard API requires explicit user gesture; same content the user is already viewing | +| T-17-12 | T (Tampering) | API response shape from /entity-graph | mitigate | Frontend GraphEdgeType union + TypeScript Record exhaustiveness in OntologyEdge edgeTypeConfig surface schema drift at compile time. Runtime validation at the API client layer is out of scope per project conventions but planned for milestone v0.6.0+ | + + + +- All 8 tasks ship with passing Vitest unit tests +- `npm run test` shows all new test files green +- `npm run type-check` exits 0 (Record exhaustiveness enforced) +- `npm run lint` exits 0 +- File rename `EntityGraphModal` → `EntityModal` is complete repo-wide +- New components are NOT yet wired into layouts (that's Plan 17-03) +- CSS rule for [data-overlay-takeover] is in app/globals.css and lints clean + + + +- 9 new web modules created (PaneTabStrip, SourceTabBody, FullSourceOverlay, useFullSourceOverlay, useEffectiveTab, extractEntitySnippet, EntityModal renamed) +- 4 modifications: selectionStore (+activePaneTab), graphApi (+focus_type), OntologyEdge (+6 edge types), OntologyGraph (+empty state) +- 7 new test files / extensions, all green +- All locked contracts from UI-SPEC + RESEARCH followed verbatim +- Bottom-border alignment Tailwind classes present in PaneTabStrip (verified via grep) +- D-15 immutability test confirms useEffectiveTab does not mutate the store +- D-08 sequential merge order respected (this plan's branch was cut from dev AFTER api PR merged) + + + +After completion, create `.planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-02-SUMMARY.md` documenting: +- All 9 new modules + 4 modifications with file paths +- Total test count added (target: ~60+ unit tests across 8 test files) +- Confirmation that EntityGraphModal → EntityModal rename is complete repo-wide +- Confirmation that selectionStore is non-persist (D-14) +- Confirmation that useEffectiveTab does NOT mutate the store (D-15) +- Any deviations from RESEARCH locked code (should be zero — every contract is locked) + diff --git a/.planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-03-PLAN.md b/.planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-03-PLAN.md new file mode 100644 index 00000000..b6edbf35 --- /dev/null +++ b/.planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-03-PLAN.md @@ -0,0 +1,853 @@ +--- +phase: 17-graph-as-entity-scoped-tab-in-detail-pane +plan: 03 +type: execute +wave: 3 +depends_on: ["17-02"] +files_modified: + - "components/editor/standard/StandardEditorLayout.tsx" + - "components/editor/developer/DeveloperEditorLayout.tsx" + - "components/editor/ClassDetailPanel.tsx" + - "components/editor/PropertyDetailPanel.tsx" + - "components/editor/IndividualDetailPanel.tsx" + - "lib/hooks/useGraphData.ts" + - ".planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-AI-SMOKE-TEST.md" + - ".planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-UAT-CHECKLIST.md" +autonomous: false +requirements: [R1, R2, R3, R4, R5, R6, R7, R8, R9, R10, R11] +tags: [phase-17, frontend, layout-integration, smoke-test, uat] + +must_haves: + truths: + - "Standard view renders Detail|Graph (50/50) tab strip at top of right pane for all three entity types (R1)" + - "Developer view renders Detail|Graph|Source (33/33/33) tab strip at top of right pane for all three entity types (R1)" + - "Bottom border of right-pane tab strip aligns horizontally with bottom border of left-pane Classes|Properties|Individuals strip (R1, verified via DevTools MCP screenshot)" + - "Developer view's left pane shows only the tree — no Tree|Source mode strip; no Graph peer-tab anywhere (R2)" + - "Standard view's detail panels show no Graph icon button in entity-actions (R3)" + - "All three detail panels in BOTH views show no Source link in entity-actions (R4)" + - "Single-click on non-focus graph node re-centers; double-click changes URL + tree highlight + tab stays on Graph (R5)" + - "Active tab persists across entity selection changes within the same session (R6)" + - "Source tab active in Developer then switch to Standard mode falls back to Detail without store mutation (R6 + D-15)" + - "First entity load in a fresh session opens on Detail tab (R8)" + - "Source tab body renders entity-scoped Turtle snippet with line-comment + Open full source + Copy snippet (R7)" + - "Open full source opens EntityModal at ~92vw/92vh with backdrop, dismissable via Esc/click-outside/X (R9)" + - "Modal Maximize triggers full editor takeover (tree + detail hidden via CSS toggle); Restore returns to modal; bounce works (R9)" + - "Esc dismisses overlay from any size; Restore button hidden when not reached via Maximize (R9)" + - "With Source tab active and full-source modal open, selecting different entity makes the modal scroll to new entity's lines (R10)" + - "Property graph renders domain/range/subPropertyOf/seeAlso edges (R11 frontend integration)" + - "Individual graph renders rdfType/objectProperty/sameAs/seeAlso edges (R11 frontend integration)" + - "Annotation property with no relationships renders empty-state copy (R11)" + - "AI smoke test report committed to phase directory walking R1, R5, R6, R7, R8, R9 across Standard+Developer x light+dark" + - "Human UAT checklist generated covering all 11 requirements" + artifacts: + - path: "components/editor/standard/StandardEditorLayout.tsx" + provides: "PaneTabStrip wired (Detail|Graph); Graph icon button removed; Source link removed" + contains: "PaneTabStrip" + - path: "components/editor/developer/DeveloperEditorLayout.tsx" + provides: "PaneTabStrip wired (Detail|Graph|Source); Tree|Source mode strip removed; viewMode state deleted; Source link removed" + contains: "PaneTabStrip" + - path: "components/editor/ClassDetailPanel.tsx" + provides: "Source link button removed (R4); Graph icon button removed (R3)" + - path: "components/editor/PropertyDetailPanel.tsx" + provides: "Source link + Graph button removed (R3, R4)" + - path: "components/editor/IndividualDetailPanel.tsx" + provides: "Source link + Graph button removed (R3, R4)" + - path: ".planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-AI-SMOKE-TEST.md" + provides: "AI smoke test report walking R1, R5, R6, R7, R8, R9 x {Standard, Developer} x {light, dark}" + contains: "Pass" + - path: ".planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-UAT-CHECKLIST.md" + provides: "Human UAT checklist for all 11 requirements" + contains: "R1" + key_links: + - from: "StandardEditorLayout / DeveloperEditorLayout" + to: "useEffectiveTab(editorMode) and useSelectionStore.activePaneTab" + via: "render-time derivation (D-15)" + pattern: "useEffectiveTab" + - from: "DeveloperEditorLayout (Source tab body)" + to: "useFullSourceOverlay() + EntityModal + FullSourceOverlay" + via: "open() callback wired to SourceTabBody.onOpenFullSource" + pattern: "useFullSourceOverlay" + - from: "DeveloperEditorLayout (root container)" + to: "[data-overlay-takeover] CSS rule from Plan 17-02 Task 5" + via: "className editor-tree-pane and editor-detail-pane on the layout panes" + pattern: "editor-tree-pane" + - from: "useGraphData (lib/hooks/useGraphData.ts)" + to: "graphApi.getEntityGraph(focusIri, focusType)" + via: "focusType derived from selection.type (class | property | individual)" + pattern: "focusType" +--- + + +Integrate all primitives, hooks, and store extensions from Plan 17-02 into the live editor layouts. Remove the legacy Graph icon button, the Source link, and the Developer-view Tree|Source|Graph mode strip. Wire the new tab strip + Source tab + full-source overlay end-to-end. Apply the editor-tree-pane / editor-detail-pane / editor-overlay-modal className hooks so the CSS takeover from Plan 17-02 Task 5 fires correctly. Wire useGraphData to pass focusType through to the new entity-graph endpoint so property + individual graphs render. Run an AI-driven Chrome DevTools MCP smoke test and produce a UAT checklist. + +Purpose: This is the integration wave. Everything from Plan 17-02 becomes user-visible behavior here. The plan ends with verifiable visual evidence (smoke test report) and a human UAT checklist mapped 1:1 to SPEC R1-R11. + +Output: 5 modified components + 1 modified hook + 2 new docs (smoke test report + UAT checklist). Phase 17 is shippable after this plan ships AND human UAT signs off. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + +Per project CLAUDE.md: dev server is `./ontokit-web.sh start` (port 3000); Chrome DevTools MCP is the only browser-testing tool — never `/snap/bin/chromium --headless`; write screenshots to `$HOME/`, not `/tmp/`; clean up screenshot files after reading them. + +Per global feedback "Include planning in PRs": link the Phase 17 planning artifacts in the eventual PR description. + +Per global feedback "Sync & rebase all repos": confirm both ontokit-web and ontokit-api are rebased against catholicos/dev before opening the eventual PR. + + + +@.planning/STATE.md +@.planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-SPEC.md +@.planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-CONTEXT.md +@.planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-RESEARCH.md +@.planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-PATTERNS.md +@.planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-UI-SPEC.md +@.planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-VALIDATION.md +@.planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-01-PLAN.md +@.planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-02-PLAN.md +@CLAUDE.md +@components/editor/standard/StandardEditorLayout.tsx +@components/editor/developer/DeveloperEditorLayout.tsx +@components/editor/ClassDetailPanel.tsx +@components/editor/PaneTabStrip.tsx +@components/editor/SourceTabBody.tsx +@components/editor/FullSourceOverlay.tsx +@components/graph/EntityModal.tsx +@lib/stores/selectionStore.ts +@lib/hooks/useFullSourceOverlay.ts +@lib/hooks/useEffectiveTab.ts + + +Locked contracts the executor depends on. All shipped by Plan 17-02. + +components/editor/PaneTabStrip.tsx exports: + type PaneTab = "detail" | "graph" | "source" + interface PaneTabDefinition { id: PaneTab; label: string; icon: LucideIcon; } + function PaneTabStrip(props: { tabs, activeTab, onTabChange }): JSX.Element + +lib/stores/selectionStore.ts: + store has activePaneTab + setActivePaneTab(tab); clear() resets to "detail" + +lib/hooks/useEffectiveTab.ts: + function useEffectiveTab(editorMode: EditorMode): PaneTab + Returns "detail" when stored is "source" AND mode is "standard"; else echoes stored value. Does NOT mutate the store. + +lib/hooks/useFullSourceOverlay.ts: + function useFullSourceOverlay(): { isOpen; size; cameFromModal; open; close; maximize; restore; } + Side-effect: sets document.documentElement.dataset.overlayTakeover="true" while size==="maximized" + +components/graph/EntityModal.tsx: + interface EntityModalProps { isOpen; onClose; children; headerExtras?; size?; onMaximize?; onRestore?; titleId?; } + +components/editor/SourceTabBody.tsx: + interface SourceTabBodyProps { sourceContent; focusIri; sourceIriIndex?; onOpenFullSource; onCopyToast?; } + +components/editor/FullSourceOverlay.tsx: + function FullSourceOverlay(props) + function FullSourceHeaderExtras(props) + +lib/api/graph.ts (post-Plan 17-02): + graphApi.getEntityGraph(projectId, focusIri, focusType?, options?): Promise + +Lucide icons used in this plan (verify availability before use): + FileText (Detail tab), Network (Graph tab), FileCode2 (Source tab). + + + + + + + Task 1: Wire PaneTabStrip into StandardEditorLayout + remove Graph icon button + Source link + components/editor/standard/StandardEditorLayout.tsx, components/editor/ClassDetailPanel.tsx, components/editor/PropertyDetailPanel.tsx, components/editor/IndividualDetailPanel.tsx + + - components/editor/standard/StandardEditorLayout.tsx (full file — find showGraph state at lines 213-214 + Graph icon button at lines 462-471 + selection mirroring effect at lines 254-273) + - components/editor/ClassDetailPanel.tsx (lines 608-617 — Source link to delete) + - components/editor/PropertyDetailPanel.tsx (full file — find any Source link / Graph button to delete) + - components/editor/IndividualDetailPanel.tsx (full file — find any Source link / Graph button to delete) + - components/editor/PaneTabStrip.tsx (consumer contract from Plan 17-02) + - lib/hooks/useEffectiveTab.ts (derived fallback hook from Plan 17-02) + - .planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-PATTERNS.md StandardEditorLayout + DetailPanel sections + - .planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-UI-SPEC.md Component Inventory + Visual Hierarchy + Color + - .planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-RESEARCH.md Q8 (Source-link removal verification) + + + - Test 1: After integration, StandardEditorLayout renders PaneTabStrip with exactly 2 tabs (Detail, Graph) above the entity header in the right pane + - Test 2: Clicking the Graph tab updates useSelectionStore.activePaneTab to "graph" and renders the OntologyGraph component as the right-pane body + - Test 3: Clicking the Detail tab updates activePaneTab to "detail" and renders the existing detail panel body + - Test 4: Selection change (different class) does NOT change activePaneTab (R6 persistence) + - Test 5 (B5 — R4 positive absence assertions in ClassDetailPanel): assert each of the following is true in the rendered ClassDetailPanel: + - `expect(screen.queryByText(/Source/i)).not.toBeInTheDocument()` (or scoped to the entity-actions area via `within(getByTestId('entity-actions')).queryByText(/Source/i)` if the testid is available) + - `expect(screen.queryByLabelText(/View in source/i)).not.toBeInTheDocument()` + - `expect(screen.queryByRole('button', { name: /source/i })).not.toBeInTheDocument()` + - Test 6 (B5 — R3 absence in ClassDetailPanel): `expect(screen.queryByLabelText(/Show relationship graph/i)).not.toBeInTheDocument()` AND `expect(screen.queryByLabelText(/View graph/i)).not.toBeInTheDocument()` + - Test 7 (B5 — same R3 + R4 positive-absence assertions for PropertyDetailPanel AND IndividualDetailPanel — replicate the three queryBy* assertions from Test 5 and the two queryByLabelText assertions from Test 6 in BOTH `__tests__/components/editor/PropertyDetailPanel.test.tsx` AND `__tests__/components/editor/IndividualDetailPanel.test.tsx`. Per RESEARCH Assumption A1, these panels likely never had the link, so the standalone grep-for-absent-string is vacuous; the runtime queryBy* assertions establish a positive guarantee that catches regressions if the link is ever added.) + - Test 8: showGraph state is fully removed from StandardEditorLayout — no useState reference to showGraph remains + - Test 9: Right pane has className containing "editor-detail-pane" (so the data-overlay-takeover CSS rule from Plan 17-02 Task 5 fires) + + + Sub-step A — Modify components/editor/standard/StandardEditorLayout.tsx: + + 1. Delete showGraph state (lines 213-214 + 231 per RESEARCH Q8). Remove the useState declaration and any setShowGraph callsites. + + 2. Delete the Graph icon button (lines 462-471 — the headerActions block with the Show relationship graph button). Drop the headerActions prop entirely from the detail-panel render call. + + 3. Add useEffectiveTab hook usage at the top of the component: + + import { useEffectiveTab } from "@/lib/hooks/useEffectiveTab"; + import { useSelectionStore } from "@/lib/stores/selectionStore"; + import { PaneTabStrip, type PaneTabDefinition } from "@/components/editor/PaneTabStrip"; + import { OntologyGraph } from "@/components/graph/OntologyGraph"; + import { FileText, Network } from "lucide-react"; + + const editorMode = useEditorModeStore((s) => s.editorMode); + const effectiveTab = useEffectiveTab(editorMode); + const setActivePaneTab = useSelectionStore((s) => s.setActivePaneTab); + + const standardTabs: PaneTabDefinition[] = [ + { id: "detail", label: "Detail", icon: FileText }, + { id: "graph", label: "Graph", icon: Network }, + ]; + + 4. Insert PaneTabStrip ABOVE the right-pane entity header. Find the right-pane container wrapping the detail panel. Insert at the top of that container: + + + + 5. Apply className "editor-detail-pane" to the right-pane container so the CSS takeover rule from Plan 17-02 Task 5 fires when Maximize occurs. If the container already has classes, append. + + 6. Conditionally render the body based on effectiveTab: + +

+ {effectiveTab === "graph" && selectedIri ? ( + + ) : ( + /* existing detail-panel render */ + + )} +
+ + In Standard view, effectiveTab is never "source" per D-15 (useEffectiveTab maps "source" to "detail" when mode is standard) — so the conditional only needs the two branches. + + 7. Apply "editor-tree-pane" className to the LEFT pane container. Same reason — the CSS takeover hides it when Maximize fires. + + Sub-step B — Delete the Source link from components/editor/ClassDetailPanel.tsx (lines 608-617 per RESEARCH Q8). The block looks like: + + {onNavigateToSource && ( + + )} + + Delete this block. Drop the onNavigateToSource prop from ClassDetailPanelProps and from every callsite. Remove the Code import if no longer used. + + Per RESEARCH Q8 + R3: also confirm there is no Share2 Graph icon button in ClassDetailPanel.tsx. If present (sometimes in headerActions consumer), delete from there too. + + Sub-step C — Repeat the deletion in components/editor/PropertyDetailPanel.tsx and components/editor/IndividualDetailPanel.tsx. Per RESEARCH Q8, grep first using: + + grep -n "onNavigateToSource\\|View in Source\\|Show relationship graph" components/editor/PropertyDetailPanel.tsx components/editor/IndividualDetailPanel.tsx + + For every match, delete the corresponding block. If there are no matches, no change needed for that file — but the prop interface MUST drop onNavigateToSource if it exists, and the consumer (StandardEditorLayout) MUST stop passing it. + + Sub-step D — Update detail-panel call sites in StandardEditorLayout to drop the onNavigateToSource prop entirely. After this task, StandardEditorLayout passes no Source-related props to any detail panel. + + Sub-step E — Add unit tests to __tests__/components/editor/standard/StandardEditorLayout.test.tsx (create if missing). Tests 1-4 + 8-9 from behavior. Mock children components (PaneTabStrip, OntologyGraph, ClassDetailPanel) for fast-running tests. + + Add deletion-verification tests to: + - __tests__/components/editor/ClassDetailPanel.test.tsx (extend): tests 5 + 6 + - __tests__/components/editor/PropertyDetailPanel.test.tsx (extend or create): test 7 + - __tests__/components/editor/IndividualDetailPanel.test.tsx (extend or create): test 7 + + + npm run test -- StandardEditorLayout ClassDetailPanel PropertyDetailPanel IndividualDetailPanel --run 2>&1 | tail -30 + + + - grep -c showGraph components/editor/standard/StandardEditorLayout.tsx returns 0 + - grep -c onNavigateToSource components/editor/ClassDetailPanel.tsx components/editor/PropertyDetailPanel.tsx components/editor/IndividualDetailPanel.tsx returns 0 across all three files combined + - grep -c "View in Source" components/editor/ClassDetailPanel.tsx components/editor/PropertyDetailPanel.tsx components/editor/IndividualDetailPanel.tsx returns 0 + - grep -c "Show relationship graph" components/editor/standard/StandardEditorLayout.tsx components/editor/ClassDetailPanel.tsx returns 0 + - grep -c PaneTabStrip components/editor/standard/StandardEditorLayout.tsx is at least 2 (import + render) + - grep -c useEffectiveTab components/editor/standard/StandardEditorLayout.tsx is at least 2 (import + call) + - grep -c "editor-detail-pane" components/editor/standard/StandardEditorLayout.tsx is at least 1 + - grep -c "editor-tree-pane" components/editor/standard/StandardEditorLayout.tsx is at least 1 + - grep -c "FileText\|Network" components/editor/standard/StandardEditorLayout.tsx is at least 2 (icon imports) + - npm run test -- StandardEditorLayout ClassDetailPanel PropertyDetailPanel IndividualDetailPanel --run exits 0 + - npm run type-check exits 0 + - **B5 positive-absence assertions are in source (not just grep-of-absent-string):** `grep -cE "queryByText.*[Ss]ource|queryByLabelText.*[Ss]ource|queryByRole.*[Ss]ource" __tests__/components/editor/ClassDetailPanel.test.tsx __tests__/components/editor/PropertyDetailPanel.test.tsx __tests__/components/editor/IndividualDetailPanel.test.tsx | awk -F: '{sum += $2} END {print sum}'` is at least 3 (one per panel — at least one queryBy* assertion checking the Source link is absent) + - **B5 positive-absence assertions for Graph button (R3):** `grep -cE "queryByLabelText.*[Ss]how relationship graph|queryByLabelText.*[Vv]iew graph" __tests__/components/editor/ClassDetailPanel.test.tsx __tests__/components/editor/PropertyDetailPanel.test.tsx __tests__/components/editor/IndividualDetailPanel.test.tsx | awk -F: '{sum += $2} END {print sum}'` is at least 3 (one per panel — at least one queryBy* assertion checking the Graph icon button is absent) + + StandardEditorLayout renders PaneTabStrip with 2 tabs above the entity header; Graph icon button + Source link are gone from all three detail panels; showGraph state deleted; CSS-takeover hooks (editor-tree-pane, editor-detail-pane) applied; tests pass; type-check clean. + + + + Task 2: Refactor DeveloperEditorLayout — remove mode strip, wire 3-tab PaneTabStrip + Source tab + full-source overlay + components/editor/developer/DeveloperEditorLayout.tsx, lib/hooks/useGraphData.ts + + - components/editor/developer/DeveloperEditorLayout.tsx (full file — pay attention to lines 246, 350-369, 374-417, 421-434, 435-600, 601-647 per RESEARCH Q8) + - components/editor/SourceTabBody.tsx (consumer contract from Plan 17-02) + - components/editor/FullSourceOverlay.tsx (consumer contract from Plan 17-02; FullSourceHeaderExtras helper) + - components/graph/EntityModal.tsx (renamed from Plan 17-02 with new prop contract) + - lib/hooks/useFullSourceOverlay.ts (state machine from Plan 17-02) + - lib/hooks/useGraphData.ts (post-PR-#88 — must accept focusType pass-through) + - .planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-RESEARCH.md Q8 (Tree|Source mode strip removal — exact lines + handler list) + - .planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-RESEARCH.md Common Pitfalls 6 (stale viewMode references — REQUIRED grep verification) + - .planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-UI-SPEC.md Component Inventory (Source-tab body + FullSourceOverlay specs) + + + - Test 1: DeveloperEditorLayout renders PaneTabStrip with exactly 3 tabs (Detail, Graph, Source) above the entity header in the right pane + - Test 2: Clicking the Source tab renders SourceTabBody with sourceContent + focusIri props + - Test 3: Clicking Open full source in SourceTabBody triggers useFullSourceOverlay.open() — EntityModal renders with FullSourceOverlay as children + - Test 4: Inside EntityModal headerExtras, Maximize button click calls useFullSourceOverlay.maximize() and document.documentElement.dataset.overlayTakeover becomes "true" + - Test 5: Restore button only renders when size==="maximized" AND cameFromModal===true + - Test 6: Esc key dismisses the overlay regardless of size; activePaneTab remains unchanged + - Test 7: After overlay closes, activePaneTab is still "source" (R10 — overlay does not change tab) + - Test 8: Selection change while overlay is open: FullSourceOverlay receives the new focusIri prop and scrolls Monaco accordingly (R10) + - Test 9: viewMode state is fully deleted — no useState reference to viewMode remains + - Test 10: DeveloperView type is fully deleted (no `type DeveloperView` remains) + - Test 11: handleViewModeChange is fully deleted + - Test 12: The Tree|Source|Graph mode-strip JSX (lines 374-417 per RESEARCH) is fully deleted — no flex rounded-lg border bg-slate-100 p-0.5 mode-strip container remains + - Test 13: Left pane shows only the entity tree — no peer-tab strip (R2) + - Test 14: Right pane container has className containing "editor-detail-pane" + - Test 15: Left pane container has className containing "editor-tree-pane" + - Test 16: EntityModal usage has className "editor-overlay-modal" (so CSS takeover rule fires when Maximize) + + + Sub-step A — Aggressive cleanup of viewMode state per RESEARCH Q8 + Common Pitfalls 6: + + 1. Delete the viewMode state at line 246: useState("tree"). + 2. Delete the DeveloperView type at line 58. + 3. Delete handleViewModeChange at lines 356-361. + 4. Repurpose handleNavigateToSource at lines 363-369 — replace its body with setActivePaneTab("source"). The function name can stay the same (callers like the entityNavigationRef handler still call it). + 5. Delete the entire mode-strip JSX block at lines 374-417 (the Tree|Source|Graph tab buttons). + 6. Delete the source pre-load on hover at lines 342-347 (the prefetch was tied to mode switching; with Source as a tab, source content is always loaded once the user navigates to it, no prefetch needed). + 7. Update the entityNavigationRef handler at lines 264-279: when type === "other", call setActivePaneTab("source") and setPendingScrollIri(iri) instead of setViewMode("source"). + 8. Find the lines 421-434 viewMode === "graph" body block and the lines 601-647 viewMode === "source" body block — these will be re-used inside the right-pane tabpanel render later in this task. + 9. Find the lines 435-600 viewMode === "tree" branch — this is now the unconditional tree+detail layout (no mode wrapper). + 10. After the deletions, run `grep -n viewMode components/editor/developer/DeveloperEditorLayout.tsx` and confirm 0 matches (per RESEARCH Common Pitfalls 6 — required verification). + + Sub-step B — Add tab-strip integration: + + 1. Import the new dependencies: + + import { useEffectiveTab } from "@/lib/hooks/useEffectiveTab"; + import { useSelectionStore } from "@/lib/stores/selectionStore"; + import { PaneTabStrip, type PaneTabDefinition } from "@/components/editor/PaneTabStrip"; + import { useFullSourceOverlay } from "@/lib/hooks/useFullSourceOverlay"; + import { EntityModal } from "@/components/graph/EntityModal"; + import { SourceTabBody } from "@/components/editor/SourceTabBody"; + import { FullSourceOverlay, FullSourceHeaderExtras } from "@/components/editor/FullSourceOverlay"; + import { OntologyGraph } from "@/components/graph/OntologyGraph"; + import { FileText, Network, FileCode2 } from "lucide-react"; + + 2. Add hook usage near the top of the component: + + const editorMode = useEditorModeStore((s) => s.editorMode); + const effectiveTab = useEffectiveTab(editorMode); + const setActivePaneTab = useSelectionStore((s) => s.setActivePaneTab); + const overlay = useFullSourceOverlay(); + + const developerTabs: PaneTabDefinition[] = [ + { id: "detail", label: "Detail", icon: FileText }, + { id: "graph", label: "Graph", icon: Network }, + { id: "source", label: "Source", icon: FileCode2 }, + ]; + + 3. Apply className "editor-tree-pane" to the LEFT pane container (the tree pane). + 4. Apply className "editor-detail-pane" to the RIGHT pane container (detail pane). + + 5. Insert PaneTabStrip ABOVE the entity header in the right pane: + + + + 6. Render the right-pane body conditionally on effectiveTab: + +
+ {effectiveTab === "detail" && } + {effectiveTab === "graph" && selectedIri && ( + + )} + {effectiveTab === "source" && ( + toast(msg)} + /> + )} +
+ + Use the appropriate detail panel based on the active entity type — the existing layout already routes Class/Property/Individual to the right component; preserve that. + + 7. Render the EntityModal with FullSourceOverlay outside the layout grid (at the layout root level so it can take over): + + {overlay.isOpen && ( + + } + > + + + )} + + Title element must use id="full-source-title" so the aria-labelledby points at it. + + Sub-step C — Wire useGraphData to pass focusType through: + + Open lib/hooks/useGraphData.ts. Find the call to graphApi.getEntityGraph (or wherever the API client is invoked). Update the signature to accept a focusType param and pass it through: + + export function useGraphData(projectId: string, focusIri: string | null, focusType: "class" | "property" | "individual" = "class", options?) { + // ... existing hook body + return useQuery({ + queryKey: ["graph", projectId, focusIri, focusType], + queryFn: () => graphApi.getEntityGraph(projectId, focusIri, focusType, options), + enabled: !!focusIri, + }); + } + + The default of "class" preserves backward compat for any caller that has not yet been updated. OntologyGraph component (called from the layouts) MUST pass focusType through to useGraphData based on the entity type from selectionStore. + + Sub-step D — Tests in __tests__/components/editor/developer/DeveloperEditorLayout.test.tsx (create if missing): + + Mock heavy children: PaneTabStrip, OntologyGraph, SourceTabBody, EntityModal, ClassDetailPanel — replace with stubs that report their props. Mock useFullSourceOverlay to a controllable stub via vi.mock. Use vi.spyOn(useSelectionStore, "getState") for setActivePaneTab assertions. + + Implement tests 1-16 from behavior. The viewMode-deletion tests (9-12) are best done as static-source assertions: read the file content via fs.readFileSync and assert grep-style absence — but since this is JS-test land, do them as tooling-level checks in acceptance_criteria below. +
+ + npm run test -- DeveloperEditorLayout useGraphData --run 2>&1 | tail -30 + + + - grep -n viewMode components/editor/developer/DeveloperEditorLayout.tsx returns 0 matches (per RESEARCH Common Pitfalls 6 required check) + - grep -c DeveloperView components/editor/developer/DeveloperEditorLayout.tsx returns 0 + - grep -c handleViewModeChange components/editor/developer/DeveloperEditorLayout.tsx returns 0 + - grep -c "Tree | Source | Graph\|Tree|Source|Graph" components/editor/developer/DeveloperEditorLayout.tsx returns 0 (mode-strip JSX gone) + - grep -c PaneTabStrip components/editor/developer/DeveloperEditorLayout.tsx is at least 2 (import + render) + - grep -c useFullSourceOverlay components/editor/developer/DeveloperEditorLayout.tsx is at least 2 (import + call) + - grep -c SourceTabBody components/editor/developer/DeveloperEditorLayout.tsx is at least 2 + - grep -c FullSourceOverlay components/editor/developer/DeveloperEditorLayout.tsx is at least 2 + - grep -c EntityModal components/editor/developer/DeveloperEditorLayout.tsx is at least 2 + - grep -c "editor-tree-pane" components/editor/developer/DeveloperEditorLayout.tsx is at least 1 + - grep -c "editor-detail-pane" components/editor/developer/DeveloperEditorLayout.tsx is at least 1 + - grep -c "FileText\|Network\|FileCode2" components/editor/developer/DeveloperEditorLayout.tsx is at least 3 (3 icons for 3 tabs) + - grep -c focusType lib/hooks/useGraphData.ts is at least 2 (param + pass-through) + - grep -c focusType lib/hooks/useGraphData.ts AND queryKey contains focusType: `grep -c "queryKey.*focusType\\|focusType.*queryKey" lib/hooks/useGraphData.ts` is at least 1 (cache key includes focus type so different types don't collide) + - npm run test -- DeveloperEditorLayout useGraphData --run exits 0 + - npm run type-check exits 0 + - npm run lint exits 0 + + DeveloperEditorLayout has no viewMode state, no DeveloperView type, no Tree|Source|Graph mode strip; PaneTabStrip with 3 tabs renders above the entity header; Source tab body + Maximize/Restore round-trip wired through EntityModal + FullSourceOverlay; useGraphData passes focusType to the api; CSS-takeover className hooks applied; tests + type-check + lint pass. +
+ + + Task 3: AI-driven Chrome DevTools MCP smoke test + report + .planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-AI-SMOKE-TEST.md + + - .planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-SPEC.md (Acceptance Criteria — AI smoke test mandate; R1, R5, R6, R7, R8, R9 walkthrough scenarios) + - .planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-RESEARCH.md Q10 (smoke-test format + MCP commands) + - $HOME/.claude/CLAUDE.md (browser testing rules — Chrome DevTools MCP only; screenshots to $HOME; clean up after reading) + - CLAUDE.md (./ontokit-web.sh start; port 3000) + + + The smoke test MUST cover the following matrix: 6 requirements (R1, R5, R6, R7, R8, R9) x 2 modes (Standard, Developer) x 2 themes (light, dark) = 24 scenarios. + + For each scenario: + - Navigate to the editor with a deterministic test entity selected + - Take a screenshot + - Read the screenshot to verify the requirement + - Log Pass/Fail with screenshot evidence path + + Specific R1 visual check: bottom-border y-coordinate of the right-pane PaneTabStrip equals bottom-border y-coordinate of the left-pane EntityTabBar (within ±0 pixels per SPEC). + + + Sub-step A — Start the dev server and confirm reachability: + + 1. Run `./ontokit-web.sh restart --force` (per CLAUDE.md non-interactive auto-detect; --force kills any blocking process). + 2. Confirm the actual port from `.ontokit-web.log` (default 3000 but script may increment on conflict per project memory). + 3. Confirm the api is also up at http://localhost:8000 — Plan 17-01 must be deployed locally (api branch merged to dev, local instance restarted). + + Sub-step B — Pick a deterministic test entity set: + + Use FOLIO test ontology entities. Pick one of each type: + - Class: ex. http://folio.openlegalstandard.org/classes/Court (or any concrete class with parents + children) + - Object property: ex. http://folio.openlegalstandard.org/properties/decidedBy (any with rdfs:domain + rdfs:range) + - Individual: any individual with rdf:type + at least one object-property assertion + - Annotation property: any with no domain/range/seeAlso (for the empty-state test) + + Document the chosen entities at the top of the smoke-test report so the test is repeatable. + + Sub-step C — Build the report file 17-AI-SMOKE-TEST.md with this exact structure: + + # Phase 17 — AI Smoke Test Report + + **Run date:** {ISO timestamp} + **Tester:** Claude Code (this session) + **Tooling:** Chrome DevTools MCP via mcp__chrome-devtools tools + **Dev server:** http://localhost:{port} (per .ontokit-web.log) + **API server:** http://localhost:8000 + + ## Test Entities + + | Type | IRI | Local name | + |------|-----|-----------| + | Class | ... | ... | + | Object property | ... | ... | + | Individual | ... | ... | + | Annotation property (with relations) | ... | ... | + | Annotation property (empty) | ... | ... | + + ## Scenarios + + ### Scenario 1.1 — R1 Tab strip placement (Standard, light) + - Navigate to: http://localhost:{port}/projects/{id}/editor?classIri={class}&editorMode=standard + - Theme: light (set via theme toggle if needed) + - Screenshot: $HOME/phase17-r1-standard-light.png + - **Verify:** PaneTabStrip with exactly 2 tabs (Detail, Graph) renders above the entity header in the right pane. Bottom border y-coordinate of right-pane strip equals bottom border y-coordinate of left-pane Classes|Properties|Individuals strip. + - **Result:** Pass / Fail + - **Notes:** ... + + (... 23 more scenarios for the matrix ...) + + ## Summary + + | Requirement | Standard light | Standard dark | Developer light | Developer dark | + |-------------|---------------|---------------|------------------|------------------| + | R1 | Pass | Pass | Pass | Pass | + | R5 | ... | + | R6 | ... | + | R7 | (N/A in Standard) | (N/A) | ... | ... | + | R8 | ... | + | R9 | (N/A in Standard) | (N/A) | ... | ... | + + ## Regressions + + List any failed scenarios + screenshot paths + fix recommendations. If all pass, write "None — phase ready for human UAT." + + ## Cleanup + + All screenshot files at $HOME/phase17-*.png have been deleted per CLAUDE.md global instructions. + + Sub-step D — Execute the matrix: + + For each scenario, use Chrome DevTools MCP tools (verify available tool names via the MCP listing — likely names: navigate_page, click, take_screenshot, wait_for, evaluate_script). + + Pseudocode for one scenario: + + mcp navigate_page url=http://localhost:3000/projects/{id}/editor?classIri={iri}&editorMode=standard + mcp wait_for selector="[role='tablist']" timeout=10000 + mcp take_screenshot path=$HOME/phase17-r1-standard-light.png fullPage=true + (Read screenshot file to verify the strip) + (For pixel alignment R1: mcp evaluate_script returning { rightStripBottom: getBoundingClientRect of right tablist; leftStripBottom: getBoundingClientRect of left tablist } ) + Compare values; log Pass if delta === 0px + + For R5 (single-click recenter, double-click select): + mcp navigate_page ...&editorMode=developer + mcp click selector="[role='tab'][aria-controls='pane-panel-graph']" + mcp wait_for selector=".react-flow__renderer" + mcp take_screenshot + mcp click selector=".react-flow__node:not(.react-flow__node--focus)" (single) + (Verify recenter without URL change) + mcp click selector=".react-flow__node:not(.react-flow__node--focus)" clickCount=2 (double) + (Verify URL change to ?classIri=... + tree highlight) + + For R6 (tab persistence): + Click Graph tab + Click a different entity in the tree + (Verify tab is still Graph) + + For R7 (Source tab — Developer only): + editorMode=developer + Click Source tab + (Verify SourceTabBody renders with toolbar + Monaco snippet) + + For R8 (default Detail on first load): + Open in incognito or clear localStorage first + Navigate to editor without resumed selection + (Verify Detail tab is active) + + For R9 (Modal/Maximize/Restore round-trip): + Click Source tab + Click Open full source + (Verify EntityModal opens at modal size) + Click Maximize + (Verify takeover — tree + detail panes hidden via CSS) + Click Restore + (Verify back to modal size) + Click Maximize again (bounce test) + Press Esc + (Verify overlay closed; tab still on Source) + + Sub-step E — After all scenarios, write the summary table + regressions section. Per CLAUDE.md, delete all screenshot files: `rm -f $HOME/phase17-*.png`. + + Sub-step F — If ANY scenario fails, file the bug fixes inline (modify the relevant component file and re-run). Smoke test must end with all scenarios passing OR explicit per-scenario known-issue notes that the user accepts. + + + test -f .planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-AI-SMOKE-TEST.md && grep -c "Scenario\|Pass\|Fail" .planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-AI-SMOKE-TEST.md + + + - File .planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-AI-SMOKE-TEST.md exists + - The report contains "## Test Entities" + "## Scenarios" + "## Summary" + "## Regressions" + "## Cleanup" sections + - The Scenarios section has at least 24 scenario entries (one per matrix cell, minus N/A combinations — minimum: R1 x 4 + R5 x 4 + R6 x 4 + R7 x 2 (Developer-only) + R8 x 4 + R9 x 2 (Developer-only) = 20 actually-run scenarios; the report explicitly notes which are N/A) + - Every scenario has a Pass or Fail verdict + - The Summary table maps every (requirement x mode x theme) cell to Pass / Fail / N/A + - The Cleanup section confirms screenshot deletion (`ls $HOME/phase17-*.png 2>&1` returns "No such file or directory") + - If any scenario failed, the Regressions section documents fix steps that were taken inline + + 17-AI-SMOKE-TEST.md is written + committed; all scenarios pass OR are documented as known issues; screenshots cleaned up; phase is ready for human UAT. + + + + Task 4: Generate human UAT checklist mapped 1:1 to SPEC R1-R11 + .planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-UAT-CHECKLIST.md + + - .planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-SPEC.md (Requirements R1-R11 + Acceptance Criteria checkboxes) + - .planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-VALIDATION.md (Manual-Only Verifications table) + - HUMAN-UAT-CHECKLIST.md (existing checklist format precedent at project root, if it exists) + + + The UAT checklist must: + - Cover every requirement R1-R11 with a verifiable user-action + expected outcome + - Be runnable by a non-technical reviewer in under 30 minutes + - Reference deterministic test entities (same as smoke test, for repeatability) + - Include sign-off date + reviewer name fields + + + Build .planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-UAT-CHECKLIST.md with the following structure: + + # Phase 17 — Human UAT Checklist + + **Phase:** 17 — Graph as Entity-Scoped Tab in Detail Pane + **Generated:** {ISO date} + **Status:** Pending sign-off + + ## Setup + + 1. Start the dev server: `./ontokit-web.sh start` + 2. Confirm the api is running: `curl http://localhost:8000/api/v1/health` + 3. Open the editor: `http://localhost:3000/projects/{test-project-id}/editor` + 4. Pick the test entities defined in 17-AI-SMOKE-TEST.md. + + ## Requirements + + ### R1 — Tab strip placement + - [ ] In Standard mode, click any class in the tree. Confirm a 2-tab strip (Detail | Graph) appears at the top of the right pane. + - [ ] In Developer mode, click any class. Confirm a 3-tab strip (Detail | Graph | Source) appears at the top of the right pane. + - [ ] Confirm the bottom border of the right-pane strip aligns horizontally with the bottom border of the left-pane Classes|Properties|Individuals strip. + - [ ] Repeat for a property and for an individual — same strip in each case. + + ### R2 — Old peer-tab Graph removed (Developer view) + - [ ] In Developer mode, scan the left pane top to bottom. Confirm there is NO Tree | Source | Graph mode strip anywhere. + - [ ] Confirm the only tab strip in the left pane is Classes | Properties | Individuals. + + ### R3 — Old Graph icon button removed (Standard view) + - [ ] In Standard mode, click any class. Inspect the entity-actions row (right of the IRI). Confirm there is NO Graph button (no Share2 icon). + - [ ] Repeat for property and individual. + + ### R4 — Old Source link removed (both views) + - [ ] In Standard mode AND Developer mode, click any class. Inspect the entity-actions row. Confirm there is NO Source link / button. + - [ ] Repeat for property and individual. + + ### R5 — Click semantics preserved + - [ ] Open the Graph tab on any class. Single-click an unfocused (non-blue) node. Confirm the graph re-centers WITHOUT changing the URL or tree highlight. + - [ ] Double-click the same node. Confirm the URL changes to ?classIri=..., the tree highlights the new entity, and the active tab stays on Graph. + + ### R6 — Tab persistence across selection + - [ ] Click the Graph tab. Click a different entity in the tree. Confirm the tab is still Graph. + - [ ] Click the Source tab in Developer mode. Click a different entity. Confirm the Source tab is still active and shows the new entity's snippet. + - [ ] In Developer with Source tab active, switch to Standard mode. Confirm the tab falls back to Detail (Source is not available in Standard). + - [ ] Switch back to Developer mode. Confirm the tab is Source again (D-15: store value not mutated). + + ### R7 — Source tab body + - [ ] In Developer mode, click the Source tab. Confirm the toolbar shows "# File: ontology.ttl, line N" + "↗ Open full source" + "Copy snippet" buttons. + - [ ] Confirm the body shows a Monaco editor with the entity's Turtle snippet (not the full file). + - [ ] Confirm the snippet is read-only. + - [ ] Click "Copy snippet". Paste into a text editor. Confirm the snippet was copied. + - [ ] Pick an entity defined in an imported ontology (no local definition). Confirm the empty-state message renders ("Source not available for this entity"). + + ### R8 — Default tab on first load + - [ ] Open a new browser tab in incognito mode. Navigate to the editor. Click any entity. Confirm the active tab is Detail. + + ### R9 — Modal Maximize Restore round-trip + - [ ] In Developer mode with Source tab active, click "Open full source". Confirm a modal opens at ~92% viewport with a dim backdrop. + - [ ] In the modal header, click the Maximize icon. Confirm the modal expands to fullscreen — the tree and detail pane disappear; only the app header + project bar remain. + - [ ] In the takeover header, click the Restore icon. Confirm the modal returns to ~92% viewport. + - [ ] Click Maximize again (bounce test). Confirm the takeover renders again. + - [ ] Press Esc. Confirm the overlay dismisses fully and you return to the Source tab. + - [ ] Open Source again. Click Maximize. Note the Restore button is visible. + - [ ] Click Restore once. Note the Restore button is still visible (cameFromModal preserved on bounce). + - [ ] Press Esc. Reopen full source. Confirm the Restore button is NOT visible (came directly from modal — fresh open). + - [ ] In maximize state, scroll Monaco to a deep line. Click Restore. Confirm the scroll position is preserved. + + ### R10 — Tree-to-Source auto-jump + - [ ] In Developer mode with Source tab active, click a different entity in the tree. Confirm the Source tab content auto-updates to the new entity's snippet without an extra click. + - [ ] Open the full-source modal. Click yet another entity. Confirm the modal scrolls to the new entity's lines without dismissing. + + ### R11 — Property + Individual graph backend + - [ ] Click an object property in the tree. Click the Graph tab. Confirm nodes for the property + its domain class(es) + range class(es) appear with edges labelled "domain" and "range". + - [ ] Click a property with rdfs:subPropertyOf. Confirm a "subPropertyOf" edge appears. + - [ ] Click an individual in the tree. Click the Graph tab. Confirm nodes for the individual + its rdf:type class + connected individuals via object properties appear with edges labelled "rdf:type" and the predicate's local name. + - [ ] Click an individual with owl:sameAs. Confirm a "sameAs" edge appears. + - [ ] Click an annotation property with no domain/range/seeAlso. Confirm the Graph tab renders the empty-state copy: "No relationships to display" + "This annotation property has no domain, range, parent, or see-also relationships to render." + + ## Sign-off + + - [ ] All R1-R11 acceptance criteria above pass + - [ ] Reviewer name: _______________ + - [ ] Date: _______________ + - [ ] Signed-off via reply to PR / commit comment: _______________ + + ## Notes / Issues + (List any UAT findings here. Pass even with minor cosmetic issues if they are tracked as separate follow-up tickets.) + + + test -f .planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-UAT-CHECKLIST.md && grep -c "^### R" .planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-UAT-CHECKLIST.md + + + - File 17-UAT-CHECKLIST.md exists in the phase directory + - grep -c "^### R" 17-UAT-CHECKLIST.md is exactly 11 (one section per requirement R1-R11) + - Each requirement section has at least 1 unchecked checkbox: `grep -c "^- \[ \]" 17-UAT-CHECKLIST.md` is at least 30 (covers all requirements with multiple checks) + - The Setup section names the dev server commands from CLAUDE.md + - The Sign-off section has reviewer name + date + signed-off fields + + 17-UAT-CHECKLIST.md is committed; all 11 requirements have explicit user-action + expected-outcome checkboxes; the checklist is runnable in under 30 minutes by a non-technical reviewer. + + + + Checkpoint: Human UAT walkthrough using 17-UAT-CHECKLIST.md + (human action — no file modifications by Claude in this checkpoint) + + HUMAN ACTION REQUIRED. Walk through 17-UAT-CHECKLIST.md (created in Task 4) end-to-end. Tick every checkbox; document any failures in the Notes section. Sign off the checklist when all 11 requirements pass. Per D-08 + global feedback "approve means merge — alea-institute only", the eventual web PR merges only after this checkpoint signs off AND any required peer review on the target repo completes. + + + test -f .planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-UAT-CHECKLIST.md && grep -c "Reviewer name: " .planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-UAT-CHECKLIST.md + + Human UAT signed off; 17-UAT-CHECKLIST.md has reviewer name + date populated; user types "uat signed-off". + + Phase 17 frontend integration is complete: + - Standard view + Developer view both render the new PaneTabStrip at the top of the right pane + - Old Graph icon button + Source link removed from all three detail panels + - Developer view's Tree|Source|Graph mode strip removed entirely + - Source tab + EntityModal (renamed) + FullSourceOverlay + useFullSourceOverlay state machine wired + - useGraphData passes focusType through, so property + individual graphs render with the new edge palette + - Annotation property empty-state copy renders when backend returns focus-only response + - All Vitest unit tests green + - AI smoke test report (17-AI-SMOKE-TEST.md) shows all scenarios passing + + + 1. Open .planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-UAT-CHECKLIST.md. + 2. Run through each of the 11 requirement sections in order. Tick each checkbox as you verify. + 3. For any unchecked item, document the issue in the Notes section of the checklist. + 4. If all 11 sections pass, fill in the Sign-off section: reviewer name + date + sign-off note. + 5. Per global feedback "Include planning in PRs": confirm the eventual PR description includes: + - Link to .planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-SPEC.md + - Link to .planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-AI-SMOKE-TEST.md + - Link to the signed-off 17-UAT-CHECKLIST.md + 6. Per global feedback "approve means merge — alea-institute only": if the web PR is on alea-institute/ontokit-web, merge after approval; if CatholicOS/ontokit-web, wait for peer review and DO NOT merge. + 7. Per global feedback "auto-rebase after merge": after the web PR merges, rebase any other open ontokit-web PRs. + 8. Per global feedback "update issue 34 on completion": tick the corresponding line item in CatholicOS/ontokit-web#34. + + Type "uat signed-off" to confirm all 11 requirements passed manual review and the phase is shippable. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| User browser → Next.js page (editor) | Standard NextAuth session token; no new auth surface in this plan | +| Layout → SourceTabBody → clipboard API | User-initiated outbound copy, same risk as Plan 17-02 Task 7 | +| Layout → useGraphData → /entity-graph | Authenticated GET with project_id scoping inherited from existing route auth | +| useFullSourceOverlay → document.documentElement.dataset.overlayTakeover | Hook owns this attribute; no other code path writes it | + +## STRIDE Threat Register (ASVS L1) + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-17-13 | T (Tampering) | useGraphData focusType param threading | mitigate | TypeScript Literal "class"|"property"|"individual" enforces compile-time validation; default of "class" preserves behavior on missing values | +| T-17-14 | I (Information disclosure) | OntologyGraph rendered in Graph tab | accept | Read-only graph; no PII; same data already accessible via the existing /classes/graph route | +| T-17-15 | D (Denial of service) | Maximize takeover via CSS attribute toggle | accept | Single document attribute write per state transition; useEffect cleanup guarantees attribute removal on unmount; bounded transitions | +| T-17-16 | E (Elevation of privilege) | Tab persistence in selectionStore (non-persist) | accept | activePaneTab is session-only client state; no privileged action; D-14 explicitly chose non-persist to avoid stale-state attacks across reload | +| T-17-17 | T (Tampering) | EntityModal aria-labelledby titleId | mitigate | titleId is rendered as id attribute on a known element controlled by the layout; no user-supplied IDs reach this attribute | +| T-17-18 | I (Information disclosure) | Smoke test screenshots written to $HOME | mitigate | Screenshots are deleted by Task 3 Sub-step E (per CLAUDE.md global instructions); no persistent artifact in repo or home dir | + + + +- All Vitest unit tests across StandardEditorLayout + DeveloperEditorLayout + 3 detail panels + useGraphData pass +- npm run type-check exits 0 +- npm run lint exits 0 +- 17-AI-SMOKE-TEST.md exists and shows all 24 (or N/A-adjusted) scenarios passing +- 17-UAT-CHECKLIST.md exists with R1-R11 sections + sign-off block +- viewMode references gone from DeveloperEditorLayout (RESEARCH Common Pitfalls 6 explicit grep check) +- onNavigateToSource references gone from all three detail panels + their consumers +- editor-tree-pane / editor-detail-pane / editor-overlay-modal className hooks applied (so CSS takeover from Plan 17-02 Task 5 actually fires) +- Human UAT signed off + + + +- Standard view: 2-tab PaneTabStrip (Detail|Graph) at top of right pane (R1) +- Developer view: 3-tab PaneTabStrip (Detail|Graph|Source) at top of right pane (R1) +- Bottom borders of left + right strips align (R1 visual constraint) +- Developer view's Tree|Source|Graph peer-tab strip removed (R2) +- Standard view's Graph icon button removed (R3) +- Source link removed from all three detail panels in both views (R4) +- Single/double-click semantics preserved on graph nodes (R5) +- Tab persistence works across selection changes; D-15 fallback works on mode switch (R6) +- Source tab body renders snippet + toolbar + empty state (R7) +- Default tab is Detail on first session load (R8) +- Modal/Maximize/Restore round-trip works including Esc dismissal + Restore visibility logic + Monaco scroll preservation (R9) +- Source tab auto-jumps + full-source modal scrolls on selection change (R10) +- Property + individual graphs render with new edge palette; annotation property empty-state copy renders (R11 frontend) +- AI smoke test report committed; human UAT signed off + + + +After completion, create .planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-03-SUMMARY.md documenting: +- Final web PR URL +- Date the web PR merged to catholicos/dev +- Test count delta vs Plan 17-02 +- Confirmation that 17-AI-SMOKE-TEST.md and 17-UAT-CHECKLIST.md were committed and signed off +- Any cosmetic / accessibility findings filed as follow-up issues +- Phase 17 closure note: SPEC R1-R11 all met + diff --git a/.planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-CONTEXT.md b/.planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-CONTEXT.md new file mode 100644 index 00000000..859e4d20 --- /dev/null +++ b/.planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-CONTEXT.md @@ -0,0 +1,173 @@ +# Phase 17: Graph as Entity-Scoped Tab in Detail Pane - Context + +**Gathered:** 2026-05-02 +**Status:** Ready for planning + + +## Phase Boundary + +Restructure the right-side detail pane to host a `Detail | Graph[ | Source]` tab strip across both Standard and Developer views, removing scattered graph/source affordances. Source migrates from left-pane mode strip (Developer) and entity-actions link (both views) into a Developer-only third right-pane tab. Source tab opens entity-scoped Turtle snippets with a Modal ⇄ Maximize round-trip for full-source viewing. Backend gains property + individual graph support so all three entity types render meaningful neighborhoods. + + + + +## Requirements (locked via SPEC.md) + +**11 requirements are locked.** See `17-SPEC.md` for full requirements, boundaries, and acceptance criteria. + +Downstream agents MUST read `17-SPEC.md` before planning or implementing. Requirements are not duplicated here. + +**In scope (from SPEC.md):** +- New `` component (shared across both views, `flex: 1 1 0`, accepts `tabs` prop) +- New `activePaneTab` field + setter in `useSelectionStore` +- Standard view layout integration (replace Graph icon button + ` Source` link with the tab strip) +- Developer view layout integration (remove Tree | Source mode strip + Graph peer-tab + ` Source` link; add right-pane 3-tab strip with Source tab) +- Source tab body: entity-scoped Turtle snippet + `↗ Open full source` + `⎘ Copy snippet` + line-number comment +- Modal ⇄ Maximize ⇄ Restore round-trip for `↗ Open full source` (reuses `EntityGraphModal` shell) +- Backend (`ontokit-api`): extend entity-graph endpoint to handle property and individual focus IRIs +- Property graph rendering — domain classes, range classes, parent properties, see-also (full content, not WebProtege empty-island) +- Individual graph rendering — class assertions, object property values, see-also, sameAs +- Annotation property empty-state copy +- Unit tests (Vitest) for new components and store extensions +- AI-driven smoke testing via Chrome DevTools MCP across Standard + Developer × light + dark modes +- Manual UAT covering all 11 requirements + +**Out of scope (from SPEC.md):** +- Drag-to-reparent in the graph +- Per-user / per-project default tab preference +- Graph for properties of properties (rdf:Property meta-level) +- Graph export to SVG/PNG for screenshots +- Source view editing UX changes +- Two-way navigation from full-source line clicks back to tree selection +- New Playwright e2e tests +- Cosmetic refresh of `EntityGraphModal` + + + + +## Implementation Decisions + +### Source snippet extraction (right-pane Source tab) +- **D-01:** Snippet computed **frontend-only** from already-loaded full source. Generalize `findBlock` in `lib/ontology/turtleClassUpdater.ts` to handle property + individual subject patterns (currently class-only). Use the Web Worker IRI index from `lib/editor/indexWorker.ts` for the start line number. +- **D-02:** When the extractor cannot locate the entity (e.g., entity defined in an imported ontology, malformed source), render an **empty-state body**: `"Source not available for this entity — it may be defined in an imported ontology."` Toolbar still shows `↗ Open full source` so the user can verify in the full file. +- **D-03:** Line-number comment format is **start line only**: `# File: ontology.ttl, line N` (matches SPEC Requirement 7 literal). The full-source modal handles range navigation by scrolling. +- **D-04:** Memoize snippet via `useMemo((sourceText, iri) => extract(...))` — extraction is regex over potentially-large Turtle (50k+ lines). Cache invalidates naturally on source reload or selection change. + +### Backend endpoint shape (Requirement 11) +- **D-05:** Introduce a single new endpoint `GET /api/v1/projects/{id}/ontology/entity-graph?focus_iri=...&focus_type=class|property|individual` and **deprecate `/classes/graph`** during a one-release transition window (it delegates internally to the new endpoint). Frontend `lib/api/graph.ts` exposes one method `graphApi.getEntityGraph(focusIri, focusType?)`. +- **D-06:** Response uses **single `edges[]` array with a labelled `edge_kind` enum** — `{ subClassOf, equivalentClass, disjointWith, seeAlso, domain, range, subPropertyOf, rdfType, sameAs, objectProperty }`. Frontend renderer maps `edge_kind` → visual treatment in `OntologyEdge.tsx`. One schema across class/property/individual contexts. +- **D-07:** **No cap on object-property values for individuals in v1** — BFS surfaces every `(?indiv ?p ?other)` assertion. ELK layout absorbs density. Configurable depth/cap is deferred to v0.6+. +- **D-08:** **Sequential cross-repo merge order** — `ontokit-api` PR (entity-graph property+individual) lands on `dev` first; Phase 17 web branch is cut from `dev` after the api merge. **No feature gate, no try/catch fallback.** Web work for tab strip + Source tab can begin in parallel before api merges (those don't depend on the new endpoint), but property/individual graph wiring is gated on the api merge. + +### Modal shell extension (Requirement 9 round-trip) +- **D-09:** **Rename `EntityGraphModal` → `EntityModal`** as part of Phase 17. Props: `{ isOpen, onClose, children, headerExtras?: ReactNode, size?: 'modal' | 'maximized', onMaximize?, onRestore? }`. Each consumer (graph use-case, full-source use-case) renders its own body as `children` and supplies `headerExtras` for size-control buttons. Modal mechanics (backdrop, sizing, dismissal) live in the shell. PR #88's existing graph usage migrates to the renamed component as the same PR. +- **D-10:** Round-trip state (`cameFromModal: boolean`, `size: 'closed' | 'modal' | 'maximized'`) lives in **local `useState` in the Source-tab consumer** via a `useFullSourceOverlay()` hook. Modal shell stays state-light and use-case-agnostic. `Restore` button only renders when `cameFromModal === true`. +- **D-11:** Maximized takeover is implemented by **CSS toggling on the layout root** (`data-overlay-takeover` attribute → `.editor-tree { display: none }` etc.) — **NOT** by unmounting/remounting components. This preserves Monaco scroll position across `Maximize → Restore → Maximize` round-trips (SPEC Decision 10 mandates "scroll preserved"). +- **D-12:** **Esc / click-outside / ✕ all dismiss the overlay fully**, regardless of size. `Restore` is the only path back from maximized → modal. Matches SPEC Requirement 9 acceptance literal: "Press Esc → returns to Source tab" (from either size). + +### activePaneTab persistence + fallback +- **D-13:** `activePaneTab: 'detail' | 'graph' | 'source' | null` is added to **`useSelectionStore`** (per SPEC Requirement 6 literal — "useSelectionStore gains an activePaneTab field"). Selection-related state stays colocated. +- **D-14:** **Session-only persistence** — Zustand non-persist (selectionStore is already non-persist). Page reload starts on Detail. Matches SPEC Requirement 8 literal: "first entity opened in a NEW SESSION lands on Detail." +- **D-15:** **Derived fallback at render** — components consume `useEffectiveTab(editorMode)` which returns `(activePaneTab === 'source' && editorMode === 'standard') ? 'detail' : activePaneTab`. The store value is **never mutated** by mode change. User returning to Developer view restores their Source tab preference. Most graceful fallback semantics. +- **D-16:** **No URL deep-linking** for the active tab — tab is session state, not URL state. URL stays focused on entity (`?classIri=...`, `?propertyIri=...`, `?individualIri=...`). Deep-linkable tabs deferred to v0.6+ backlog. + +### Claude's Discretion +- TabStrip component file location, internal markup, focus-ring details, and prop naming beyond the `tabs` array contract — pick whatever lines up with existing patterns in `components/editor/`. +- Edge color/stroke specifics for new `edge_kind` values (`domain`, `range`, `subPropertyOf`, `sameAs`, `rdfType`, `objectProperty`) — coordinate with existing `OntologyEdge.tsx` palette; lineage-coloring philosophy from MEMORY (focus=blue, root=red) extends naturally. +- AI smoke-test report file format and location within the phase directory — choose whatever makes verification re-runs simple. +- Annotation-property empty-state copy wording (SPEC gives a placeholder; pick the final user-facing string). +- Deprecation window length for `/classes/graph` (one release / two releases) — coordinate with api repo conventions. + + + + +## Canonical References + +**Downstream agents MUST read these before planning or implementing.** + +### Phase artifacts (locked requirements + design contract) +- `.planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-SPEC.md` — Locked requirements, boundaries, acceptance criteria. **MUST read before planning.** +- `.planning/notes/graph-as-entity-pane.md` — 11-decision design contract (placement, click semantics, persistence, modal terminology). Anchors all UX decisions in this phase. +- `.planning/sketches/001-tab-strip-language/index.html` — Tab strip variant winner (D: above-header, icon + sentence-case label). +- `.planning/sketches/002-standard-view-layout/index.html` — Standard view 50/50 tab layout (winner D: compact, full-width). +- `.planning/sketches/003-developer-view-and-modal/index.html` — Developer view 33/33/33 + Source-as-third-tab restructure. +- `.planning/sketches/004-open-full-source/index.html` — Modal ⇄ Maximize round-trip (winner C). + +### Roadmap context +- `.planning/ROADMAP.md` §"Phase 17: Graph as Entity-Scoped Tab in Detail Pane" — phase scope, 7 success criteria, UI hint flag. +- `.planning/REQUIREMENTS.md` — milestone-level (v0.5.0) requirements that frame Phase 17's role. +- `.planning/PROJECT.md` — project core value (collaborative SME ontology editing) for downstream alignment. + +### Reusable code (frontend) +- `lib/ontology/turtleClassUpdater.ts` — `findBlock` helper (continuation-line aware) — **generalize for property + individual subjects** in this phase. +- `lib/editor/indexWorker.ts` — Web Worker IRI index — provides start line numbers per IRI for snippet `# File: ... line N` comment. +- `lib/stores/selectionStore.ts` — extend with `activePaneTab` field + setter; non-persist (session-only). +- `lib/api/graph.ts` — frontend client — refactor `getEntityGraph()` to call new `/entity-graph` endpoint with `focus_type`. +- `components/graph/OntologyGraph.tsx`, `OntologyEdge.tsx`, `OntologyNode.tsx` — extend to handle property/individual focus + new `edge_kind` values. + +### Reusable code (modal shell) +- `components/graph/EntityGraphModal.tsx` (lands with PR #88) — **rename to `EntityModal`** in this phase; generalize props per D-09. + +### Cross-repo +- `ontokit-api` PR #37 (`entity-graph-endpoint`) — class-only graph BFS handler; this phase extends to property + individual focus and renames the route to `/entity-graph` with `focus_type` discriminator. +- `ontokit-web` PR #88 (`entity-graph-pr`) — **must merge to dev BEFORE Phase 17 web branch is cut.** + +### Memory (carry-forward patterns) +- MEMORY.md `editorModeStore` pattern (localStorage persist with `useThemeSync` hook) — referenced for **contrast** with D-14 (we explicitly do NOT use this pattern for activePaneTab). +- MEMORY.md `findBlock` continuation-line behavior — known landmine: `;`/`,` on prev line means object reference, not subject definition. Generalization to property/individual MUST preserve this guard. + + + + +## Existing Code Insights + +### Reusable Assets +- **`findBlock` (`lib/ontology/turtleClassUpdater.ts`)** — extracts a class block from full Turtle source by IRI; continuation-line aware. Generalize to accept any subject pattern (class IRI, property IRI, individual IRI) → unified snippet extraction for the Source tab. +- **Web Worker IRI index (`lib/editor/indexWorker.ts`)** — already maps IRI → line number for lint diagnostics. Reuse to populate the `# File: ontology.ttl, line N` comment without re-parsing source. +- **`useSelectionStore` (`lib/stores/selectionStore.ts`)** — current shape `{ iri, type, mode }`, non-persist Zustand. Extend with `activePaneTab` to maintain locality of selection-related state per SPEC Requirement 6. +- **`graphApi.getEntityGraph()` (`lib/api/graph.ts`)** — class-only today; refactor to accept `focus_type` and call `/entity-graph` instead of `/classes/graph`. +- **`OntologyEdge.tsx`** — currently handles 4 edge types (lineage-coloring); extend palette for new `edge_kind` enum values from D-06. +- **`EntityGraphModal.tsx`** (PR #88) — modal shell with backdrop, dismissal, sizing — rename + generalize per D-09. +- **`useThemeSync` pattern (`editorModeStore` + `app/providers.tsx`)** — established root-mounted hook pattern; precedent for any cross-component effects (e.g., the layout-root `data-overlay-takeover` toggle from D-11). + +### Established Patterns +- **Source-modify-via-PUT pattern**: writes go through full Turtle PUT (`PUT /source` with full text + commit message), NOT direct REST entity endpoints. Phase 17 is read-only for source — no writes — so this pattern is informational only. +- **Lineage-based graph coloring**: focus=blue, root=red, ancestor=gray, seeAlso=purple. Extend to property/individual edges naturally (e.g., `domain`/`range` borrow ancestor/cousin colors; `sameAs` borrows seeAlso). +- **Continuous editing toggle pattern (`editorModeStore`)** — localStorage persist with sync hook; precedent shows we have the option for activePaneTab if we ever want to upgrade D-14, but SPEC explicitly chose session-only. +- **Resizable panel divider** — established for tree/detail; tab strip lives ABOVE the divider, so width logic stays clean. + +### Integration Points +- **Right pane top chrome**: `Class/Property/IndividualDetailPanel` currently render `pip + name + IRI + Copy IRI button + Graph icon button + Source button`. Phase 17 inserts `` ABOVE the entity header and removes the Graph icon button + ` Source` link from entity-actions. +- **Developer left pane**: `DeveloperEditorLayout` currently renders `Tree | Source` mode strip + `Graph` peer-tab. Phase 17 removes the entire mode strip — left pane becomes tree-only with `Classes | Properties | Individuals` sub-tabs. +- **Layout root for takeover**: app shell needs a `data-overlay-takeover` toggle target. CSS hides `.editor-tree` and `.editor-detail` (or analogous classes) when set; Modal shell controls the attribute via the consumer hook. +- **Cross-repo handshake**: `ontokit-api` ships `/entity-graph` first; web `lib/api/graph.ts` consumes it post-api-merge. Sequential, no fallback (D-08). + + + + +## Specific Ideas + +- **Modal ⇄ Maximize terminology**: locked to "Maximize" / "Restore" (Windows OS convention) — rejected "Minimize" because in OS land it means send-to-taskbar, wrong mental model. Captured pre-spec in `graph-as-entity-pane.md` §10. +- **Bottom-border alignment**: bottom border of right-pane tab strip MUST align horizontally with bottom border of left-pane `Classes | Properties | Individuals` strip. Pixel constraint — verifier checks via DevTools MCP screenshot. SPEC Constraints + Acceptance Criteria. +- **Property graph "full content"**: must render domain classes, range classes, parent properties, see-also — explicit improvement over WebProtege's empty-island treatment. Acceptance counts a non-empty graph for at least one object property in the test ontology. +- **Annotation property empty-state**: when no domain/range/parent/see-also exists, frontend renders a friendly empty-state (e.g., "No relationships to display for this annotation property"). Backend returns `{nodes: [focus_only], edges: []}`. + + + + +## Deferred Ideas + +- **Per-user / per-project default tab preference** — global default is Detail (D-14, SPEC Decision 8); per-user persistence is a v0.6.0+ enhancement. +- **URL-deep-linkable active tab** (`?tab=detail|graph|source`) — out of SPEC scope; rejected because it conflicts with session-only persistence semantics. Backlog candidate for "shareable entity views." +- **Configurable graph depth + per-predicate cap** — `?depth=N&max_neighbors=K` knobs on `/entity-graph`. Out of v1; v0.6+ when dense individuals start to render busy. +- **Drag-to-reparent in graph** — Phase 17 keeps graph read-only; manipulation is a separate feature (per SPEC Out-of-scope). +- **Graph export to SVG/PNG** — screenshot/share affordance; separate feature. +- **Two-way navigation from full-source line clicks → tree selection** — the existing source-IRI index supports it; preserve current behavior, don't redesign in this phase. +- **Cosmetic refresh of `EntityModal`** — reuse the visual shell as-is from PR #88; visual polish is a separate small follow-up if it becomes a UAT finding. + + + +--- + +*Phase: 17-graph-as-entity-scoped-tab-in-detail-pane* +*Context gathered: 2026-05-02* diff --git a/.planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-DISCUSSION-LOG.md b/.planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-DISCUSSION-LOG.md new file mode 100644 index 00000000..9aca5647 --- /dev/null +++ b/.planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-DISCUSSION-LOG.md @@ -0,0 +1,209 @@ +# Phase 17: Graph as Entity-Scoped Tab in Detail Pane - Discussion Log + +> **Audit trail only.** Do not use as input to planning, research, or execution agents. +> Decisions are captured in CONTEXT.md — this log preserves the alternatives considered. + +**Date:** 2026-05-02 +**Phase:** 17-graph-as-entity-scoped-tab-in-detail-pane +**Areas discussed:** Source snippet extraction, Backend endpoint shape, Modal shell extension API, activePaneTab persistence + +--- + +## Source snippet extraction + +### Q1: Where should the entity-scoped Turtle snippet be computed? + +| Option | Description | Selected | +|--------|-------------|----------| +| Frontend, reuse existing assets | Use Web Worker IRI index for line number, generalize findBlock to handle property + individual blocks. Zero new backend work, zero round-trips, works offline. | ✓ | +| New backend endpoint | ontokit-api adds /api/v1/projects/{id}/source/snippet?iri=... returning {text, startLine, endLine}. Cleaner separation. | | +| Hybrid — frontend extract, backend fallback | Frontend extraction by default; backend endpoint as fallback for entities the IRI index can't locate. | | + +**User's choice:** Frontend, reuse existing assets +**Notes:** Generalizing findBlock for property/individual subjects is a one-helper, three-pattern change; full source already loaded for editor. + +### Q2: How should the snippet handle entities the extractor can't locate? + +| Option | Description | Selected | +|--------|-------------|----------| +| Empty state with explanation | "Source not available for this entity — it may be defined in an imported ontology." Toolbar still shows ↗ Open full source. | ✓ | +| Reconstruct from REST API data | Use loaded class/property/individual data to synthesize a Turtle block. | | +| Hide Source tab when unavailable | Conditionally render the tab. | | + +**User's choice:** Empty state with explanation +**Notes:** Honest, simple, no fake data. Hide-tab option rejected because it would break Requirement 6 tab persistence. + +### Q3: What should the line-number comment show? + +| Option | Description | Selected | +|--------|-------------|----------| +| Start line only — # File: ontology.ttl, line 4217 | Matches SPEC Requirement 7's literal example. | ✓ | +| Range — # File: ontology.ttl, lines 4217–4232 | More precise; communicates block size. | | +| No line comment, just snippet | Cleaner aesthetic. | | + +**User's choice:** Start line only + +### Q4: Should snippet recompute be cached? + +| Option | Description | Selected | +|--------|-------------|----------| +| Yes — useMemo on (sourceText, iri) | Snippet extraction is regex over potentially-large Turtle (50k+ lines). | ✓ | +| No — recompute on every render | Simpler. | | +| React Query with sourceText + iri as queryKey | Same caching guarantees + integrates with existing patterns. | | + +**User's choice:** useMemo on (sourceText, iri) + +--- + +## Backend endpoint shape + +### Q1: How should the backend surface property + individual graph support? + +| Option | Description | Selected | +|--------|-------------|----------| +| Single /entity-graph endpoint, deprecate /classes/graph | New /api/v1/projects/{id}/ontology/entity-graph?focus_iri=...&focus_type=class\|property\|individual. Old endpoint delegates internally during deprecation window. | ✓ | +| Extend /classes/graph to accept any IRI | Same endpoint, new behavior. | | +| Three sibling endpoints | /classes/graph, /properties/graph, /individuals/graph with shared BFS core. | | + +**User's choice:** Single /entity-graph endpoint, deprecate /classes/graph + +### Q2: How should the response shape evolve to handle type-specific edge labels? + +| Option | Description | Selected | +|--------|-------------|----------| +| Single edges[] with labelled edge_kind enum | { subClassOf, equivalentClass, disjointWith, seeAlso, domain, range, subPropertyOf, rdfType, sameAs, objectProperty } | ✓ | +| Separate response schemas per focus type | /classes returns { parents, children, ... }; /properties returns { domains, ranges, ... } | | +| Generic predicate-labelled edges | Edge { source, target, predicate_iri }. Maximally flexible. | | + +**User's choice:** Single edges[] with labelled edge_kind enum + +### Q3: How should object-property values rendered for individuals be limited? + +| Option | Description | Selected | +|--------|-------------|----------| +| All object property values, no cap | BFS surfaces every (?indiv ?p ?other) assertion. ELK layout absorbs density. | ✓ | +| Cap at top-K per predicate (e.g., 5) | Server limits with sort heuristic. | | +| Configurable depth + cap via query params | ?depth=1&max_neighbors=20 | | + +**User's choice:** All object property values, no cap (v1) + +### Q4: How should the web frontend be deployable before the api endpoint ships? + +| Option | Description | Selected | +|--------|-------------|----------| +| Sequential merges, no feature gate | api PR → dev first; web PR cut from dev after api lands. | ✓ | +| Web ships first with try/catch fallback | property/individual focus calls 404 → empty state. | | +| Feature flag (NEXT_PUBLIC_GRAPH_PROPERTY_INDIVIDUAL=true) | Web ships with flag default-off; flip on once api deploys. | | + +**User's choice:** Sequential merges, no feature gate + +--- + +## Modal shell extension API + +### Q1: How should EntityGraphModal be generalized? + +| Option | Description | Selected | +|--------|-------------|----------| +| Rename to EntityModal; accept children + headerExtras + size props | One place to edit modal mechanics; both consumers render their own body as children. | ✓ | +| Keep EntityGraphModal name; add body + headerExtras render-props | Less churn (PR #88 just landed). | | +| Wrap with new FullSourceModal that composes EntityGraphModal | {...} internally renders . | | + +**User's choice:** Rename to EntityModal + +### Q2: Where should the cameFromModal state for the round-trip live? + +| Option | Description | Selected | +|--------|-------------|----------| +| Local useState in the FullSource consumer | useFullSourceOverlay() hook in Source tab body tracks { isOpen, size, cameFromModal }. | ✓ | +| Zustand sourceOverlayStore | Global store. | | +| Inside EntityModal shell as internal state | Shell owns size state. | | + +**User's choice:** Local useState in the FullSource consumer +**Notes:** Modal shell stays state-light and use-case-agnostic; Restore hidden when !cameFromModal. + +### Q3: What components make up the takeover view when Maximized? + +| Option | Description | Selected | +|--------|-------------|----------| +| Render shell content full-screen, hide tree+detail via CSS class on layout root | data-overlay-takeover attribute → CSS hides .editor-tree, .editor-detail. Preserves Monaco scroll position across round-trip. | ✓ | +| Mount a separate FullEditorTakeover component, unmount on Restore | Cleanest separation but loses Monaco scroll position. | | +| Use HTML Fullscreen API | Native; gets Esc-to-close for free. | | + +**User's choice:** CSS toggle on layout root +**Notes:** Critical — SPEC Decision 10 mandates Restore preserves scroll position; only the no-unmount path satisfies this. + +### Q4: How should Esc / click-outside / ✕ dismissal be wired? + +| Option | Description | Selected | +|--------|-------------|----------| +| Esc, click-outside, ✕ all dismiss to closed regardless of size | Matches SPEC Requirement 9 acceptance literal. | ✓ | +| Esc respects current size — Esc on maximized = restore to modal | Mirrors OS app behavior. | | +| Escape closes overlay; Maximize button is the only way to maximize, Restore only way back | No click-outside on maximized. | | + +**User's choice:** Esc, click-outside, ✕ all dismiss to closed regardless of size + +--- + +## activePaneTab persistence + +### Q1: How should activePaneTab persist across page reloads? + +| Option | Description | Selected | +|--------|-------------|----------| +| Session-only (Zustand non-persist) | Matches SPEC literal "first entity opened in a NEW SESSION lands on Detail". | ✓ | +| Persist in localStorage (like editorMode) | Mirrors editorModeStore pattern. | | +| sessionStorage (per-tab persistence, dies on tab close) | Halfway. | | + +**User's choice:** Session-only (Zustand non-persist) + +### Q2: How should fallback work when the active tab becomes unavailable? + +| Option | Description | Selected | +|--------|-------------|----------| +| Compute at render — derived getter, store untouched | useEffectiveTab(editorMode) returns 'detail' when current tab unavailable. Store still holds 'source'; user returning to Developer view restores Source tab. | ✓ | +| Mutate store on mode change — reset to detail | useEffect listens to editorMode change; setActivePaneTab('detail') if unavailable. | | +| Block tab change attempts to unavailable tabs | TabStrip filters out tabs not valid for current editorMode. | | + +**User's choice:** Compute at render — derived getter +**Notes:** Most graceful — preserves user's last explicit choice across mode round-trips. + +### Q3: Where should activePaneTab live? + +| Option | Description | Selected | +|--------|-------------|----------| +| Extend selectionStore | SPEC explicitly names this: "useSelectionStore gains an activePaneTab field" (Requirement 6). | ✓ | +| New paneTabStore | Cleaner separation by concern. | | +| URL query param (?tab=detail\|graph\|source) | Deep-linkable. | | + +**User's choice:** Extend selectionStore + +### Q4: Should the URL also reflect the active tab (deep-linking)? + +| Option | Description | Selected | +|--------|-------------|----------| +| No — tab is session state, not URL state | Matches SPEC. | ✓ | +| Yes — add ?tab= query param for shareability | "Send a colleague this entity's graph" via URL. | | + +**User's choice:** No +**Notes:** Deferred to v0.6+ backlog as a candidate for shareable entity views. + +--- + +## Claude's Discretion + +- TabStrip component file location, internal markup, focus-ring details, and prop naming beyond the `tabs` array contract +- Edge color/stroke specifics for new `edge_kind` values (`domain`, `range`, `subPropertyOf`, `sameAs`, `rdfType`, `objectProperty`) +- AI smoke-test report file format and location within the phase directory +- Annotation-property empty-state copy wording (final user-facing string) +- Deprecation window length for `/classes/graph` (one release / two releases) — coordinate with api repo conventions + +## Deferred Ideas + +- Per-user / per-project default tab preference (v0.6+) +- URL-deep-linkable active tab (`?tab=...`) — backlog candidate for shareable entity views +- Configurable graph depth + per-predicate cap on `/entity-graph` (v0.6+) +- Drag-to-reparent in graph (separate feature) +- Graph export to SVG/PNG (separate feature) +- Two-way navigation from full-source line clicks → tree selection (preserve existing behavior, don't redesign) +- Cosmetic refresh of `EntityModal` (post-Phase-17 polish if UAT finding) diff --git a/.planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-PATTERNS.md b/.planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-PATTERNS.md new file mode 100644 index 00000000..c6eb4a7a --- /dev/null +++ b/.planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-PATTERNS.md @@ -0,0 +1,780 @@ +# Phase 17: Graph as Entity-Scoped Tab in Detail Pane — Pattern Map + +**Mapped:** 2026-05-02 +**Files analyzed:** 21 (16 web + 2 api + 3 web tests) +**Analogs found:** 18 / 21 (3 api files have analogs in a separate repo, documented from RESEARCH.md) + +## File Classification + +### Frontend (ontokit-web) — New Files + +| New File | Role | Data Flow | Closest Analog | Match Quality | +|----------|------|-----------|----------------|---------------| +| `components/editor/PaneTabStrip.tsx` | component (presentational tab strip) | event-driven | `components/editor/standard/EntityTabBar.tsx` | exact (canonical density precedent) | +| `components/editor/SourceTabBody.tsx` | component (entity-scoped Monaco snippet + toolbar) | request-response (memoized over already-loaded source) | `components/editor/PropertyDetailPanel.tsx` (`useMemo` over `sourceContent + iri` pattern) | role-match (panel that derives view from source) | +| `components/editor/FullSourceOverlay.tsx` | component (full-Monaco modal body) | request-response | `components/editor/OntologySourceEditor.tsx` (existing full-pane Monaco renderer in `DeveloperEditorLayout`) | role-match (Monaco wrapper) | +| `lib/hooks/useFullSourceOverlay.ts` | hook (local UI state machine + DOM side-effect) | event-driven | `lib/hooks/useKeyboardShortcuts.ts` (lifecycle-managed DOM side-effect with cleanup) | role-match (local-state hook with `useEffect` cleanup) | +| `lib/ontology/extractEntitySnippet.ts` | utility (Turtle text → snippet slice) | transform | `lib/ontology/turtleClassUpdater.ts` (uses `findBlock` + `lines.slice(...)` pattern) | exact | +| `__tests__/components/editor/PaneTabStrip.test.tsx` | test (component behavior) | n/a | `__tests__/components/editor/standard/EntityTabBar.test.tsx` | exact | +| `__tests__/components/editor/SourceTabBody.test.tsx` | test | n/a | `__tests__/components/editor/standard/EntityTabBar.test.tsx` + `__tests__/components/editor/ClassDetailPanel.test.tsx` | role-match | +| `__tests__/components/editor/FullSourceOverlay.test.tsx` | test | n/a | (will exist once PR #88 merges) `__tests__/components/graph/EntityGraphModal.test.tsx` per RESEARCH | role-match (post-#88) | +| `__tests__/lib/hooks/useFullSourceOverlay.test.tsx` | test (hook state machine) | n/a | `__tests__/lib/hooks/useKeyboardShortcuts.test.ts` | role-match | +| `__tests__/lib/ontology/extractEntitySnippet.test.ts` | test (transform) | n/a | `__tests__/lib/ontology/` existing turtle tests + `__tests__/lib/stores/selectionStore.test.ts` | role-match | + +### Frontend (ontokit-web) — Modified Files + +| Modified File | Role | Data Flow | Closest Analog (or self) | Match Quality | +|---------------|------|-----------|--------------------------|---------------| +| `lib/stores/selectionStore.ts` | store | event-driven | itself (extend existing pattern from #228 `mode` field) | exact (in-file) | +| `lib/api/graph.ts` (lands via PR #88) | api-client | request-response | sibling `lib/api/projects.ts` / `lib/api/lint.ts` (existing API client style) | role-match | +| `components/graph/EntityGraphModal.tsx` → `EntityModal.tsx` | component (modal shell) | event-driven | `components/ui/dialog.tsx` (Radix Dialog primitive in repo) | role-match | +| `components/graph/OntologyEdge.tsx` | component (edge renderer) | transform | itself (extend `edgeTypeConfig` Record) | exact (in-file) | +| `components/graph/OntologyGraph.tsx` | component (canvas) | request-response | itself (lands via PR #88) | exact (in-file) | +| `components/editor/standard/StandardEditorLayout.tsx` | layout | event-driven | itself (extend with ``, delete `showGraph` state + Graph icon button at lines 462-471) | exact (in-file) | +| `components/editor/developer/DeveloperEditorLayout.tsx` | layout | event-driven | itself (delete mode-strip lines 374-417, remove `viewMode` state, add right-pane tab strip) | exact (in-file) | +| `components/editor/ClassDetailPanel.tsx` | component (detail) | n/a | itself (delete ` Source` button lines 608-617, drop `headerActions` Graph button) | exact (in-file) | +| `components/editor/PropertyDetailPanel.tsx` | component (detail) | n/a | itself (mirror `ClassDetailPanel` deletion) | exact (in-file) | +| `components/editor/IndividualDetailPanel.tsx` | component (detail) | n/a | itself (mirror `ClassDetailPanel` deletion) | exact (in-file) | +| `lib/ontology/turtleUtils.ts` | utility | n/a | itself — `findBlock` already type-agnostic per RESEARCH §Q4; **no code change needed** | exact (verify-only) | +| `lib/editor/indexWorker.ts` | utility | n/a | itself — public API confirmed at lines 4-37 | exact (verify-only) | + +### Backend (ontokit-api) — Cross-Repo Files + +| File | Role | Data Flow | Analog | Match Quality | +|------|------|-----------|--------|---------------| +| `ontokit/api/routes/projects.py` (new `/entity-graph` route + delegating shim) | route handler | request-response | existing `/classes/graph` route at `projects.py:660-701` (per RESEARCH) | exact (sibling-route precedent in same file) | +| `ontokit/services/ontology.py` (extend `build_entity_graph`) | service | transform (RDFLib BFS) | existing class-only `build_entity_graph` at `ontology.py:365-455` (per RESEARCH) | exact (extend) | +| `ontokit/schemas/graph.py` (extend `GraphEdgeType` Literal with 6 new values) | schema | n/a | existing 4-value Literal at `schemas/graph.py:1-58` (per RESEARCH) | exact (extend) | +| `tests/unit/test_entity_graph.py` (add property/individual/annotation focus tests) | test | n/a | existing class-focus tests (per RESEARCH; note: tests live in api repo, not in this repo) | exact (extend) | + +> **Cross-repo note:** ontokit-api source is not in this working tree. Patterns for those files come from RESEARCH.md §Q6 + §Architecture Patterns + §Code Examples and from the api codebase as referenced there. The planner should resolve api file lines against the api repo at plan-execution time. + +## Pattern Assignments + +### `components/editor/PaneTabStrip.tsx` (component, event-driven) + +**Analog:** `components/editor/standard/EntityTabBar.tsx` + +**Imports + skeleton pattern** (lines 1-17): +```tsx +"use client"; + +import { cn } from "@/lib/utils"; + +export type EntityTab = "classes" | "properties" | "individuals"; + +interface EntityTabBarProps { + activeTab: EntityTab; + onTabChange: (tab: EntityTab) => void; + classCounts?: { total: number }; +} + +const tabs: { id: EntityTab; label: string }[] = [ + { id: "classes", label: "Classes" }, + ... +]; +``` + +**Container + tab markup pattern** (lines 19-37) — **MUST use these exact Tailwind classes for SPEC bottom-border alignment constraint**: +```tsx +export function EntityTabBar({ activeTab, onTabChange }: EntityTabBarProps) { + return ( +
+ {tabs.map((tab) => ( + + ))} +
+ ); +} +``` + +**Phase 17 deltas (specified by UI-SPEC §Component Inventory + RESEARCH Pattern 1):** +- Add ARIA roles: `role="tablist"` on container; `role="tab"`, `aria-selected`, `aria-controls`, `id`, `tabIndex={isActive ? 0 : -1}` on each button. +- Add keyboard nav: `onKeyDown` handler on the container (Left/Right/Home/End cycle through `tabs[]`). +- Add icon prop slot — render `` before label, with `gap-1.5` between. +- Add accent-tinted active background per UI-SPEC: `bg-primary-50 dark:bg-primary-900/20` (extends EntityTabBar's underline-only treatment to underline + tint, deliberate distinction between "tree filter" vs "workspace mode" navigation). +- Add focus-visible ring: `focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/50 focus-visible:ring-inset`. + +--- + +### `components/editor/SourceTabBody.tsx` (component, request-response) + +**Analog:** `components/editor/PropertyDetailPanel.tsx` (lines 83-86) — canonical `useMemo` over `(sourceContent, iri)` pattern + +**Memoized derivation pattern** (PropertyDetailPanel:83-86): +```tsx +const detail = useMemo((): ParsedPropertyDetail | null => { + if (!propertyIri || !sourceContent) return null; + return extractPropertyDetail(sourceContent, propertyIri); +}, [propertyIri, sourceContent, refreshKey]); // eslint-disable-line react-hooks/exhaustive-deps +``` + +**Empty-state pattern** (`components/editor/EntityPlaceholderDetail.tsx:13-21`): +```tsx +return ( +
+
+

+ Select a {entityType.toLowerCase()} to view its details +

+
+
+); +``` + +**Phase 17 deltas (UI-SPEC §Component Inventory + Copywriting Contract):** +- Toolbar row: `flex items-center justify-between h-8 px-3 bg-slate-50 dark:bg-slate-900/50 border-b border-slate-200 dark:border-slate-700` + - Left: `# File: ontology.ttl, line {N}` in `font-mono text-xs text-slate-500 dark:text-slate-400` (line N from `sourceIriIndex.get(iri)?.line` per RESEARCH §Q5). + - Right: `↗ Open full source` (primary, accent hover `hover:bg-primary-50`) | divider | `⎘ Copy snippet` (secondary). +- Body: existing `TurtleEditor` (Monaco) configured `readOnly: true`. Snippet text from `extractEntitySnippet(sourceText, iri)`. +- Empty state copy when `extractEntitySnippet` returns `null` — `Lucide.FileQuestion` icon + "Source not available for this entity" + "It may be defined in an imported ontology." Toolbar still shows `↗ Open full source`. + +--- + +### `components/editor/FullSourceOverlay.tsx` (component, request-response) + +**Analog:** `components/editor/OntologySourceEditor.tsx` (existing full-pane Monaco wrapper) — already in repo + +**Phase 17 deltas:** +- Renders inside `EntityModal` body slot (post-rename of `EntityGraphModal`). +- On open: scroll to entity start line via `sourceIriIndex.get(iri)?.line`, apply highlight rule (existing source-IRI plumbing). +- On selection change while open (R10): scroll to new entity, do NOT dismiss. +- `headerExtras`: when `size === 'modal'` → ``. When `size === 'maximized' && cameFromModal` → ``. + +**Critical (RESEARCH Pitfall 1):** Monaco container must NEVER unmount across Maximize/Restore — single instance lives in this component. CSS attribute toggle on `` is the only mechanism that hides the surrounding panes. + +--- + +### `lib/hooks/useFullSourceOverlay.ts` (hook, event-driven) + +**Analog:** `lib/hooks/useKeyboardShortcuts.ts` (lifecycle-managed DOM side-effect with cleanup) + +**Cleanup pattern** (useKeyboardShortcuts:50-60+): +```tsx +"use client"; + +import { useEffect } from "react"; + +export function useKeyboardShortcuts(shortcuts: ShortcutDefinition[]): void { + useEffect(() => { + if (shortcuts.length === 0) return; + + const handler = (e: KeyboardEvent) => { ... }; + document.addEventListener("keydown", handler); + return () => document.removeEventListener("keydown", handler); + }, [shortcuts]); +} +``` + +**Phase 17 hook contract (RESEARCH Code Example 2 — already locked):** +```tsx +"use client"; +import { useCallback, useEffect, useState } from "react"; + +type Size = "closed" | "modal" | "maximized"; + +export function useFullSourceOverlay() { + const [state, setState] = useState<{ size: Size; cameFromModal: boolean }>({ + size: "closed", cameFromModal: false, + }); + const open = useCallback(() => setState({ size: "modal", cameFromModal: false }), []); + const maximize = useCallback(() => setState((s) => + s.size === "modal" ? { size: "maximized", cameFromModal: true } : s), []); + const restore = useCallback(() => setState((s) => + s.size === "maximized" ? { size: "modal", cameFromModal: true } : s), []); + const close = useCallback(() => setState({ size: "closed", cameFromModal: false }), []); + + useEffect(() => { + if (state.size === "maximized") { + document.documentElement.dataset.overlayTakeover = "true"; + } else { + delete document.documentElement.dataset.overlayTakeover; + } + return () => { delete document.documentElement.dataset.overlayTakeover; }; + }, [state.size]); + + return { isOpen: state.size !== "closed", size: state.size, cameFromModal: state.cameFromModal, + open, close, maximize, restore }; +} +``` + +--- + +### `lib/ontology/extractEntitySnippet.ts` (utility, transform) + +**Analog:** `lib/ontology/turtleUtils.ts:178-277` (`findBlock`) + existing `lib/ontology/turtleClassUpdater.ts` callsite pattern + +**Source `findBlock` skeleton** (turtleUtils.ts:178-215): +```ts +export function findBlock( + lines: string[], + iri: string, + prefixes: PrefixMap, + base?: string, +): BlockRange | null { + const forms = iriTurtleForms(iri, prefixes, base); + for (let i = 0; i < lines.length; i++) { + const trimmed = lines[i].trim(); + if (!trimmed || trimmed.startsWith("#") || trimmed.startsWith("@") || + /^(PREFIX|BASE)\s/i.test(trimmed)) continue; + const isSubject = forms.some((f) => { + if (!trimmed.startsWith(f)) return false; + const after = trimmed[f.length]; + return !after || after === " " || after === "\t"; + }); + if (isSubject) { + // CRITICAL: continuation-line guard — same landmine on prev-line ;/, + const prevLine = i > 0 ? lines[i - 1].trim() : ""; + const isContinuation = prevLine.endsWith(";") || prevLine.endsWith(","); + if (!isContinuation) return { startLine: i, endLine: scanToBlockEnd(lines, i) }; + } + } + // ... fallback layers also include the same continuation-line guard +} +``` + +**Phase 17 wrapper (RESEARCH §Q4 Action — already locked):** +```ts +export interface EntitySnippet { + text: string; // joined lines + startLine: number; // 1-indexed + endLine: number; // 1-indexed +} +export function extractEntitySnippet(source: string, iri: string): EntitySnippet | null { + const { prefixes, base } = parseDeclarations(source); + const lines = source.split("\n"); + const block = findBlock(lines, iri, prefixes, base); + if (!block) return null; + return { + text: lines.slice(block.startLine, block.endLine + 1).join("\n"), + startLine: block.startLine + 1, + endLine: block.endLine + 1, + }; +} +``` + +`findBlock` already handles property + individual subjects (verified RESEARCH §Q4) — no generalization required. CONTEXT D-01 ("generalize findBlock") is partially obsolete; only the wrapper is new. + +--- + +### `lib/stores/selectionStore.ts` (store, event-driven) — **MODIFY** + +**Analog:** itself — extend the existing `mode` field pattern (added in PR #228) for `activePaneTab` + +**Existing store structure** (selectionStore.ts:1-39): +```ts +import { create } from "zustand"; +import type { SelectableEntityType } from "@/lib/utils/selectionUrl"; + +export type ProjectViewMode = "viewer" | "editor"; + +interface SelectionState { + iri: string | null; + type: SelectableEntityType | null; + mode: ProjectViewMode | null; + setSelection: (iri: string | null, type: SelectableEntityType | null) => void; + setMode: (mode: ProjectViewMode) => void; + clear: () => void; +} + +export const useSelectionStore = create()((set) => ({ + iri: null, type: null, mode: null, + setSelection: (iri, type) => set({ iri, type }), + setMode: (mode) => set({ mode }), + clear: () => set({ iri: null, type: null, mode: null }), +})); +``` + +**Phase 17 extension (RESEARCH §Code Example 3 — already locked):** +```ts +export type PaneTab = "detail" | "graph" | "source"; + +interface SelectionState { + // ... existing fields + activePaneTab: PaneTab; // default "detail" per SPEC R8 + setActivePaneTab: (tab: PaneTab) => void; +} + +export const useSelectionStore = create()((set) => ({ + // ... existing initializers + activePaneTab: "detail", + setActivePaneTab: (tab) => set({ activePaneTab: tab }), + clear: () => set({ iri: null, type: null, mode: null, activePaneTab: "detail" }), +})); + +/** Derived effective tab — D-15: never mutates the store */ +export function useEffectiveTab(editorMode: EditorMode): PaneTab { + const stored = useSelectionStore((s) => s.activePaneTab); + return stored === "source" && editorMode === "standard" ? "detail" : stored; +} +``` + +Non-persist pattern (D-14) is preserved — selectionStore is already non-persist (line 24-30 docblock confirms "Not persisted — on a full page reload"). + +--- + +### `lib/api/graph.ts` (api-client, request-response) — **MODIFY** (lands via PR #88) + +**Analog:** `lib/api/projects.ts:1-40` (canonical API-client module pattern in repo) + +**API-client module pattern** (lib/api/projects.ts:1-7): +```ts +/** + * Projects API client + */ +import { api, type UploadProgress } from "./client"; + +// Types +export type ProjectRole = "owner" | "admin" | "editor" | "suggester" | "viewer"; +``` + +**Phase 17 delta (D-05):** +- Refactor `getEntityGraph(focusIri, focusType?)` to call `GET /api/v1/projects/{id}/ontology/entity-graph?focus_iri=...&focus_type=class|property|individual`. +- Mirror `GraphEdgeType` Literal type extension from `ontokit-api/ontokit/schemas/graph.py` (10 values per UI-SPEC §Color). +- Re-export from `lib/api/client.ts` per existing pattern (`embeddingsApi`, `qualityApi`, etc. per CLAUDE.md). + +--- + +### `components/graph/EntityGraphModal.tsx` → `EntityModal.tsx` (component, event-driven) + +**Analog:** `components/ui/dialog.tsx` (Radix Dialog primitive, lines 1-53) + +**Existing Radix Dialog wrapper pattern** (components/ui/dialog.tsx:1-15): +```tsx +"use client"; + +import * as React from "react"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { X } from "lucide-react"; +import { cn } from "@/lib/utils"; + +const Dialog = DialogPrimitive.Root; +const DialogTrigger = DialogPrimitive.Trigger; +const DialogPortal = DialogPrimitive.Portal; +const DialogClose = DialogPrimitive.Close; +``` + +**Existing overlay backdrop + dismissal markup** (dialog.tsx:16-29): +```tsx +const DialogOverlay = React.forwardRef<...>(({ className, ...props }, ref) => ( + +)); +``` + +**Phase 17 prop generalization (RESEARCH §Q2 — locked contract):** +```tsx +interface EntityModalProps { + isOpen: boolean; + onClose: () => void; + children: ReactNode; // body slot — graph or full-source + headerExtras?: ReactNode; // right-side header buttons (Maximize, Restore) + size?: 'modal' | 'maximized'; + onMaximize?: () => void; + onRestore?: () => void; + titleId?: string; // for aria-labelledby +} +``` + +Modal sizing classes (UI-SPEC §Spacing): `w-[92vw] h-[92vh] max-w-none rounded-lg shadow-2xl`. When `size === 'maximized'` AND `[data-overlay-takeover="true"]` is set on ``, CSS rule expands modal to `position: fixed; inset: 0; width: 100vw; height: 100vh`. + +--- + +### `components/graph/OntologyEdge.tsx` (component, transform) — **MODIFY** + +**Analog:** itself (extend `edgeTypeConfig` Record at lines 21-47) + +**Existing edge config pattern** (OntologyEdge.tsx:21-47): +```tsx +const edgeTypeConfig: Record = { + subClassOf: { stroke: "#94a3b8", label: "subClassOf", markerEnd: "url(#arrow-slate)" }, + equivalentClass: { stroke: "#3b82f6", strokeDasharray: "5 3", label: "equivalentTo" }, + disjointWith: { stroke: "#ef4444", strokeDasharray: "5 3", label: "disjointWith" }, + seeAlso: { stroke: "#9ca3af", strokeDasharray: "2 4", label: "seeAlso" }, +}; +``` + +**Existing stroke-width logic** (OntologyEdge.tsx:88-90): +```tsx +strokeWidth: edgeType === "subClassOf" ? 1.5 : 1, +``` + +**Phase 17 extension (UI-SPEC §Color — already locked palette):** Add 6 new entries following the same `{stroke, strokeDasharray?, label, markerEnd?}` shape. Stroke-width logic generalizes to: `1.5` for `subClassOf | subPropertyOf | rdfType`; `1` for others. New entries: +```tsx +domain: { stroke: "#94a3b8", label: "domain", markerEnd: "url(#arrow-slate)" }, +range: { stroke: "#94a3b8", strokeDasharray: "4 2", label: "range", markerEnd: "url(#arrow-slate)" }, +subPropertyOf: { stroke: "#64748b", label: "subPropertyOf", markerEnd: "url(#arrow-slate)" }, +rdfType: { stroke: "#0ea5e9", label: "rdf:type", markerEnd: "url(#arrow-slate)" }, +sameAs: { stroke: "#a855f7", strokeDasharray: "5 3", label: "sameAs" }, +objectProperty: { stroke: "#71717a", label: "", markerEnd: "url(#arrow-slate)" }, +``` + +--- + +### `components/editor/standard/StandardEditorLayout.tsx` (layout, event-driven) — **MODIFY** + +**Analog:** itself (extend in place; remove `showGraph` state and Graph icon button) + +**Existing Graph icon button to delete** (StandardEditorLayout.tsx:462-471): +```tsx +headerActions={selectedIri ? ( + +) : undefined} +``` + +**Existing `showGraph` state to delete** (StandardEditorLayout.tsx:213-214 + 231): +```tsx +// Graph view state +const [showGraph, setShowGraph] = useState(false); +// ... usage in entityNavigationRef handler: +setShowGraph(false); // line 231 +``` + +**Existing entity-tab + selection mirroring pattern** (StandardEditorLayout.tsx:264-273) — preserve and extend with `activePaneTab`: +```tsx +const setSelection = useSelectionStore((s) => s.setSelection); +useEffect(() => { + if (activeTab === "classes") { + setSelection(selectedIri ?? null, selectedIri ? "class" : null); + } else if (activeTab === "properties") { + setSelection(selectedPropertyIri ?? null, selectedPropertyIri ? "property" : null); + } else { + setSelection(selectedIndividualIri ?? null, selectedIndividualIri ? "individual" : null); + } +}, [activeTab, selectedIri, selectedPropertyIri, selectedIndividualIri, setSelection]); +``` + +**Phase 17 deltas (R3 + R6):** +- Insert `` ABOVE the entity header in the right pane. Source feeds from `useSelectionStore.activePaneTab` via `useEffectiveTab(editorMode)`. +- Delete `showGraph` state + `setShowGraph` callsites; render the right-pane body conditionally on `effectiveTab === 'graph'` instead. +- Drop `headerActions={...Graph button...}` prop. + +--- + +### `components/editor/developer/DeveloperEditorLayout.tsx` (layout, event-driven) — **MODIFY** + +**Analog:** itself + sibling `StandardEditorLayout` + +**Existing Tree|Source|Graph mode strip to delete** (DeveloperEditorLayout.tsx:374-417): +```tsx +{/* Developer Sub-Header: View Mode Tabs */} +
+
+ + + +
+
+``` + +**Existing `viewMode` state usages** (per RESEARCH §Q8 — DeveloperEditorLayout.tsx:246, 350-369, 374-417, 421, 435, 601-647): all branches collapse — `viewMode === "graph"` body becomes Graph tab body; `viewMode === "source"` body becomes `SourceTabBody` content; tree branch becomes the only top-level layout. + +**Phase 17 deltas (R2 + R7):** +- Delete the entire mode-strip block (lines 374-417). +- Delete `viewMode` state + `DeveloperView` type + `handleViewModeChange` + `entityNavigationRef.type==="other"` logic that calls `setViewMode("source")` (line 271-274). +- `handleNavigateToSource` becomes `setActivePaneTab('source')`. +- Insert `` ABOVE the right-pane entity header. +- Right-pane body conditional on `effectiveTab` (`'detail' | 'graph' | 'source'`), with `'source'` → ``. +- Verifier task: `git grep -n viewMode components/editor/developer/` after deletion (RESEARCH Pitfall 6). + +--- + +### `components/editor/{Class,Property,Individual}DetailPanel.tsx` (component, n/a) — **MODIFY** + +**Analog:** itself (delete ` Source` button — pattern is identical across all three panels) + +**Existing ` Source` button to delete** (ClassDetailPanel.tsx:608-617): +```tsx +{onNavigateToSource && ( + +)} +``` + +**Phase 17 deltas (R3 + R4):** +- Delete the `{onNavigateToSource && (...)}` block from all three panels. +- Drop `onNavigateToSource` prop from each panel's interface (and its consumer in both layouts). +- Drop `headerActions` Graph button (already covered in `StandardEditorLayout` delta above). +- **Verification (RESEARCH §Q8 + Assumption A1):** grep `PropertyDetailPanel.tsx` and `IndividualDetailPanel.tsx` for `Source`/`onNavigateToSource`/`View in` to confirm whether the link exists there before declaring R4 complete; initial scout grep was empty in those files, but re-verify at plan time. + +--- + +### `__tests__/components/editor/PaneTabStrip.test.tsx` (test, n/a) — **NEW** + +**Analog:** `__tests__/components/editor/standard/EntityTabBar.test.tsx` + +**Test scaffolding pattern** (EntityTabBar.test.tsx:1-23): +```tsx +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import React from "react"; + +vi.mock("@/lib/utils", () => ({ + cn: (...args: unknown[]) => args.filter(Boolean).join(" "), +})); + +import { EntityTabBar } from "@/components/editor/standard/EntityTabBar"; + +describe("EntityTabBar", () => { + const onTabChange = vi.fn(); + beforeEach(() => { vi.clearAllMocks(); }); + + it("renders all three tabs", () => { + render(); + expect(screen.getByText("Classes")).toBeDefined(); + expect(screen.getByText("Properties")).toBeDefined(); + expect(screen.getByText("Individuals")).toBeDefined(); + }); +``` + +**Click + active-style assertions** (EntityTabBar.test.tsx:25-72): +```tsx +it("applies active styling to the classes tab when active", () => { + render(); + const classesBtn = screen.getByText("Classes"); + expect(classesBtn.className).toContain("border-primary-600"); +}); + +it("calls onTabChange with 'classes' when Classes is clicked", () => { + render(); + fireEvent.click(screen.getByText("Classes")); + expect(onTabChange).toHaveBeenCalledWith("classes"); +}); +``` + +**Phase 17 deltas:** add ARIA assertions (`getByRole("tab")`, `aria-selected` toggling), add keyboard nav tests via `fireEvent.keyDown(container, { key: "ArrowRight" })`, add icon-rendering smoke check, test that `tabs` array prop without Source tab renders only 2 tabs (Standard view scenario). + +--- + +### `__tests__/lib/stores/selectionStore.test.ts` (test, n/a) — **EXTEND** + +**Analog:** itself + +**Existing test pattern** (selectionStore.test.ts:1-44): +```ts +import { beforeEach, describe, expect, it } from "vitest"; +import { useSelectionStore } from "@/lib/stores/selectionStore"; + +describe("useSelectionStore", () => { + beforeEach(() => { + useSelectionStore.getState().clear(); + }); + + it("starts with no selection", () => { + const state = useSelectionStore.getState(); + expect(state.iri).toBeNull(); + expect(state.type).toBeNull(); + }); + + it("setSelection records both iri and type", () => { + useSelectionStore.getState().setSelection("ex:Person", "class"); + expect(useSelectionStore.getState()).toMatchObject({ iri: "ex:Person", type: "class" }); + ... + }); + + it("clear resets selection and mode to null", () => { + useSelectionStore.getState().setSelection("ex:Person", "class"); + useSelectionStore.getState().setMode("editor"); + useSelectionStore.getState().clear(); + expect(useSelectionStore.getState().iri).toBeNull(); + expect(useSelectionStore.getState().type).toBeNull(); + expect(useSelectionStore.getState().mode).toBeNull(); + }); +``` + +**Phase 17 additions:** +- `it("starts with activePaneTab='detail'", ...)` — default per SPEC R8. +- `it("setActivePaneTab updates the field", ...)` — direct update. +- `it("clear resets activePaneTab to 'detail' (not null)", ...)` — confirms clear semantics. +- `it("activePaneTab persists across selection changes", ...)` — set tab + change selection + assert tab unchanged (SPEC R6). +- `useEffectiveTab(editorMode)` derivation: assert `'source' + 'standard' === 'detail'`, `'source' + 'developer' === 'source'`, `'graph' + 'standard' === 'graph'` — D-15 fallback (use `renderHook` from RTL since `useEffectiveTab` calls `useSelectionStore`). + +--- + +### `__tests__/lib/hooks/useFullSourceOverlay.test.tsx` (test, n/a) — **NEW** + +**Analog:** `__tests__/lib/hooks/useKeyboardShortcuts.test.ts` + +**Hook test scaffolding pattern** (useKeyboardShortcuts.test.ts:1-26): +```ts +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { renderHook } from "@testing-library/react"; +import { useKeyboardShortcuts, ... } from "@/lib/hooks/useKeyboardShortcuts"; + +beforeEach(() => { vi.clearAllMocks(); }); +afterEach(() => { vi.restoreAllMocks(); }); + +describe("useKeyboardShortcuts", () => { + it("registers keydown event listener", () => { + const addSpy = vi.spyOn(document, "addEventListener"); + const action = vi.fn(); + renderHook(() => useKeyboardShortcuts(shortcuts)); + expect(addSpy).toHaveBeenCalledWith("keydown", expect.any(Function)); + }); +``` + +**Phase 17 coverage:** +- State machine transitions: `open()` → modal/cameFromModal=false; `maximize()` → maximized/cameFromModal=true; `restore()` → modal/cameFromModal=true (preserves bounce); `close()` → closed/cameFromModal=false. +- Bounce: open → maximize → restore → maximize keeps `cameFromModal=true` throughout. +- Side-effect: `document.documentElement.dataset.overlayTakeover` is `'true'` only when size==='maximized'; cleared on restore/close; cleared on hook unmount via `act` + `unmount()`. + +--- + +### `__tests__/lib/ontology/extractEntitySnippet.test.ts` (test, n/a) — **NEW** + +**Analog:** existing tests under `__tests__/lib/ontology/` (turtle-related test files) + +**Phase 17 coverage:** +- Class block extraction: known IRI in fixture → returns expected `{text, startLine, endLine}`. +- Property block extraction. +- Individual block extraction. +- Not-found IRI → returns `null`. +- Continuation-line guard: an IRI that appears as a `;`-continued object reference does NOT cause spurious match (RESEARCH Pitfall 2). + +--- + +### Backend (cross-repo, ontokit-api) — Documentation only + +These three files live in the separate `ontokit-api` repo. The planner should resolve their analog code at plan-execution time by reading the api source directly. Patterns below come from RESEARCH §Q6 + §Don't Hand-Roll: + +**`ontokit/api/routes/projects.py`** — new `/entity-graph` route + delegating shim +- **Analog:** existing `/classes/graph` route at `projects.py:660-701` (per RESEARCH). +- **Pattern:** mirror the existing route's signature (path params, query params, FastAPI `Depends(OptionalUser)`), accept `focus_iri` + `focus_type` in addition to existing knobs (`branch`, `ancestors_depth`, `descendants_depth`, `max_nodes`, `include_see_also`). +- **Deprecation pattern (D-05):** keep `/classes/graph` as `@router.get(..., deprecated=True)` decorator → delegates to new handler with `focus_type="class"` hardcoded. + +**`ontokit/services/ontology.py`** — extend `build_entity_graph` +- **Analog:** existing `build_entity_graph` at `ontology.py:365-455`. Class-only gate at line 397: `if (class_uri, RDF.type, OWL.Class) not in graph: return None`. `_classify_node` at 437-455 already understands `property`/`individual`/`external`. +- **Pattern:** parametrize on `focus_type`. Branch BFS by type (RESEARCH §Q6): + - `class` (existing, unchanged). + - `property` → BFS over `rdfs:domain`, `rdfs:range`, `rdfs:subPropertyOf` (up + down), `rdfs:seeAlso`. + - `individual` → BFS over `rdf:type`, all `(?indiv ?p ?other)` triples where `?p ∈ owl:ObjectProperty`, `owl:sameAs`, `rdfs:seeAlso`. + - Annotation property with no neighborhood → `{nodes: [focus_only], edges: []}`. + +**`ontokit/schemas/graph.py`** — extend `GraphEdgeType` Literal +- **Analog:** existing 4-value Literal at `schemas/graph.py:1-58`. +- **Pattern:** add `domain | range | subPropertyOf | rdfType | sameAs | objectProperty` to the existing `Literal[...]` union. Mirror in `lib/graph/types.ts` (frontend hand-mirror; comment at `schemas/graph.py:9` confirms convention). + +**`tests/unit/test_entity_graph.py`** — extend tests +- **Analog:** existing class-focus tests in same file. +- **Pattern:** mirror the existing fixture-loading + assertion structure for property/individual/annotation focus tests. Coverage from SPEC R11 acceptance: at least one object property with domain+range, one individual with rdf:type+1 obj prop, one annotation property with seeAlso, one annotation property with no relationships → focus-only response. + +--- + +## Shared Patterns + +### Auth / Access Control +**Source:** existing API client pattern (`lib/api/client.ts` + sibling clients) +**Apply to:** `lib/api/graph.ts` modifications +- All API calls already pass `accessToken` via session per CLAUDE.md "Authentication Flow" — no new auth pattern introduced. New `/entity-graph` endpoint inherits the same `OptionalUser` dependency on the api side (per RESEARCH §Security Domain). + +### Tailwind density tokens (LOCKED — visual alignment) +**Source:** `components/editor/standard/EntityTabBar.tsx:21-31` +**Apply to:** `PaneTabStrip` (mandatory for SPEC bottom-border alignment constraint) +``` +flex border-b border-slate-200 dark:border-slate-700 +flex-1 px-3 py-2 text-xs font-medium transition-colors +``` +Active: `border-b-2 border-primary-600 text-primary-600 dark:border-primary-400 dark:text-primary-400`. Phase 17 adds the accent-tint background `bg-primary-50 dark:bg-primary-900/20` to the existing pattern. + +### Empty-state copy / layout +**Source:** `components/editor/EntityPlaceholderDetail.tsx:13-21` +**Apply to:** `SourceTabBody` (extractor empty state) + Graph tab (annotation property empty state) +- Layout: `flex h-full items-center justify-center p-8 text-center` +- Body text: `text-sm text-slate-500 dark:text-slate-400` +- Heading text: `text-sm font-semibold text-slate-700 dark:text-slate-300` +- Lucide icon: `h-8 w-8 text-slate-400 mb-3` + +### Memoized derivation from `(sourceContent, iri)` +**Source:** `components/editor/PropertyDetailPanel.tsx:83-86` +**Apply to:** `SourceTabBody` (snippet extraction memoization per D-04) +```tsx +const snippet = useMemo(() => { + if (!iri || !sourceContent) return null; + return extractEntitySnippet(sourceContent, iri); +}, [iri, sourceContent, refreshKey]); +``` + +### Selection-store mirroring on tab activation +**Source:** `components/editor/standard/StandardEditorLayout.tsx:254-273` +**Apply to:** layouts when integrating `PaneTabStrip` +- Pattern: `useEffect` that mirrors local UI state (active entity-tab, active pane-tab) into the Zustand store so cross-page chrome (Viewer/Editor switcher, side-page Back-to-project links) stays consistent. Selection mirroring continues as-is; new `activePaneTab` set is direct (`setActivePaneTab(tab)` in the tab strip click handler). + +### Vitest + RTL test scaffolding +**Source:** `__tests__/components/editor/standard/EntityTabBar.test.tsx:1-23` + `__tests__/lib/hooks/useKeyboardShortcuts.test.ts:1-26` +**Apply to:** all new tests +- Use `vi.mock("@/lib/utils", () => ({ cn: ... }))` to bypass class-merge in unit tests. +- Use `renderHook` from RTL for hook tests; `render` + `screen` for components. +- Behavioral assertions over snapshots (project preference per RESEARCH §Q11). + +### Continuation-line guard (RESEARCH Pitfall 2) +**Source:** `lib/ontology/turtleUtils.ts:207-211, 233-235, 265-268` +**Apply to:** any code path that locates Turtle subject blocks +- Always check `prevLine.endsWith(";") || prevLine.endsWith(",")` before declaring a line a subject definition. The existing `findBlock` already enforces this at all three fallback layers; do NOT re-implement subject detection. + +### Lucide icon convention +**Source:** existing repo (per CLAUDE.md / global instructions) +**Apply to:** all new components +- `FileText` for Detail tab; `Network` for Graph tab; `FileCode2` for Source tab; `Maximize2`/`Minimize2` for size buttons; `Tag` for annotation-property empty graph; `FileQuestion` for source-not-found empty state. (UI-SPEC §Component Inventory locks these.) + +## No Analog Found + +| File | Role | Data Flow | Reason | +|------|------|-----------|--------| +| `lib/api/graph.ts` (today) | api-client | request-response | File doesn't exist on `dev` HEAD — lands via PR #88. Phase 17 modifies the post-#88 version. Use `lib/api/projects.ts` as the API-client style precedent until #88 merges. | +| `components/graph/EntityGraphModal.tsx` (today) | component (modal) | event-driven | File doesn't exist on `dev` HEAD — lands via PR #88. Phase 17 renames + generalizes the post-#88 version. Use `components/ui/dialog.tsx` (Radix Dialog primitive) as the modal-shell precedent until #88 merges. | +| `__tests__/components/editor/FullSourceOverlay.test.tsx` | test | n/a | No precedent for "full Monaco in modal" tests in current codebase. Once `__tests__/components/graph/EntityGraphModal.test.tsx` lands via PR #88, that's the analog; until then, base on `EntityTabBar.test.tsx` shape + `useKeyboardShortcuts.test.ts` Esc-key pattern. | +| Backend files (3 + 1 test) | api-side | request-response | Source lives in `ontokit-api` repo (not in this working tree). RESEARCH.md §Q6 documents the exact analog file paths and line numbers; resolve at plan-execution time. | + +## Metadata + +**Analog search scope:** +- `components/editor/`, `components/editor/standard/`, `components/editor/developer/` +- `components/editor/shared/`, `components/graph/`, `components/ui/` +- `lib/hooks/`, `lib/stores/`, `lib/api/`, `lib/ontology/`, `lib/editor/` +- `__tests__/components/editor/`, `__tests__/lib/stores/`, `__tests__/lib/hooks/` + +**Files scanned (Read):** 12 source files + 3 test files +**Files scanned (Bash/grep):** ~25 directory listings + content greps +**Pattern extraction date:** 2026-05-02 + +**Key patterns identified:** +- All right-pane tab strips share the locked Tailwind density tokens (`flex-1 px-3 py-2 text-xs font-medium` + `border-b border-slate-200 dark:border-slate-700`) for SPEC bottom-border alignment. +- `useMemo` over `(sourceContent, iri)` is the canonical pattern for entity-scoped views derived from source — `PropertyDetailPanel` already uses it. +- `findBlock` in `turtleUtils.ts` is type-agnostic; the continuation-line guard at three fallback layers is the critical landmine to preserve. +- Selection-store extension follows the same in-place pattern used for `mode` (#228); non-persist semantics are documented in the store's existing docblock. +- All new components ship matching Vitest behavioral tests with `vi.mock("@/lib/utils")` and RTL `render`/`fireEvent` — snapshot tests are explicitly avoided. diff --git a/.planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-RESEARCH.md b/.planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-RESEARCH.md new file mode 100644 index 00000000..d1550416 --- /dev/null +++ b/.planning/phases/17-graph-as-entity-scoped-tab-in-detail-pane/17-RESEARCH.md @@ -0,0 +1,916 @@ +# Phase 17: Graph as Entity-Scoped Tab in Detail Pane — Research + +**Researched:** 2026-05-02 +**Domain:** React 19 / Next.js 15 frontend restructure (TabStrip + modal round-trip + Source-tab) + cross-repo FastAPI/RDFLib backend extension (entity-graph BFS for property + individual focus) +**Confidence:** HIGH (most upstream code already exists in repo; SPEC + UI-SPEC + 11 design decisions are locked) + +## Summary + +Phase 17 is mostly an **integration phase, not a research-heavy phase**. SPEC, CONTEXT, and UI-SPEC are pre-locked with 11 requirements + 16 implementation decisions + a full visual contract. The remaining unknowns are concrete code locations, prop contracts of components landing via PR #88, and a small set of generalization questions (findBlock, edge palette, ARIA tablist wiring). + +Scout findings: +- `findBlock` in `lib/ontology/turtleUtils.ts` is **already type-agnostic** — it matches subject IRIs by Turtle form, not by `owl:Class`-specific assertions. CONTEXT D-01 ("generalize findBlock") is partially obsolete; the function already handles property and individual subjects. The single landmine to preserve is the `prevLine.endsWith(";")||","` continuation-line guard at lines 207–211 / 233–235 / 265–268 — already implemented at all three fallback layers. +- `EntityTabBar.tsx` (the existing left-pane `Classes|Properties|Individuals` strip) is the canonical Tailwind density precedent (`flex-1 px-3 py-2 text-xs font-medium`). The new `PaneTabStrip` MUST use these exact classes for the bottom-border alignment constraint. +- PR #88's `EntityGraphModal` is currently a **graph-specific modal**. It needs renaming to `EntityModal` AND prop generalization (`children`, `headerExtras`, `size`, `onMaximize`, `onRestore`) — this is part of Phase 17, not pre-existing. +- PR #88's `lib/api/graph.ts` calls `/api/v1/projects/{id}/ontology/classes/graph` with `class_iri=...` (class-only). Phase 17 introduces a new `/entity-graph?focus_iri=...&focus_type=...` endpoint and refactors the client to call it. +- `useGraphData` was completely rewritten by PR #88 — current `dev` HEAD has the legacy client-side BFS version. Phase 17 builds on PR #88's server-side BFS hook, so any plan task touching `useGraphData` must base off PR #88's version. +- `lib/editor/indexWorker.ts` already builds an IRI → `{line, col, len}` index for ANY subject (class/property/individual), keyed by full IRI. Phase 17's `# File: ontology.ttl, line N` comment can read this directly via `sourceIriIndex` (already plumbed through `DeveloperEditorLayout`). +- Backend (`ontokit-api`): `ontokit/services/ontology.py:365` `build_entity_graph()` exists and rejects non-class IRIs at line 397 with `if (class_uri, RDF.type, OWL.Class) not in graph: return None`. Phase 17 needs to extend this method (or add sibling methods) to handle property + individual focus, plus rename the route to `/entity-graph` with a `focus_type` discriminator. + +**Primary recommendation:** Treat Phase 17 as **three plans**: +1. **API plan (ontokit-api)**: extend `build_entity_graph` to handle property/individual focus, add `focus_type` query param, expose new `/entity-graph` route, deprecate `/classes/graph` as an internal delegating shim, add `edge_kind` enum extension, add tests. +2. **Web plumbing plan (ontokit-web)**: rename `EntityGraphModal` → `EntityModal` with new slot props, add `PaneTabStrip` component, add `activePaneTab` to `selectionStore`, add `useEffectiveTab` derivation, add `useFullSourceOverlay` hook, extract `extractEntitySnippet` from `findBlock`, extend `OntologyEdge` palette for new `edge_kind` values, refactor `lib/api/graph.ts` to call `/entity-graph`. +3. **Web layout plan (ontokit-web)**: integrate `PaneTabStrip` into both layouts, remove old peer-tab strip + Graph icon button + ` Source` link, wire `SourceTabBody` + `FullSourceOverlay`, ensure tab persistence + auto-jump behavior, run AI smoke test, fix regressions. + +## Architectural Responsibility Map + +| Capability | Primary Tier | Secondary Tier | Rationale | +|------------|-------------|----------------|-----------| +| TabStrip rendering / state-bind | Browser/Client | — | Pure UI state; lives in component + zustand selectionStore | +| `activePaneTab` persistence (session-only) | Browser/Client | — | D-13/14: non-persist Zustand; resets on reload by design | +| Source snippet extraction | Browser/Client | — | D-01/04: already-loaded source text + memoized findBlock; no API call | +| Full-source overlay | Browser/Client | — | D-10/11: local useState hook + CSS attribute toggle on layout root | +| Maximize/Restore CSS takeover | Browser/Client | — | D-11: `[data-overlay-takeover="true"]` on `` hides `.editor-tree-pane`/`.editor-detail-pane` | +| Entity-graph BFS (class/property/individual) | API/Backend | — | RDFLib graph traversal; SPEC R11 | +| Edge_kind enum (10 values) | API/Backend → Browser | — | Backend emits, frontend renders palette per UI-SPEC §Color | +| Class-graph backward compat | API/Backend | — | D-05: `/classes/graph` delegates to `/entity-graph` during deprecation window | +| Auto-jump source on selection change | Browser/Client | — | SPEC R10: existing `pendingScrollIri` plumbing already in `DeveloperEditorLayout` | + +## Standard Stack + +### Core (already in repo — versions verified from `package.json` 2026-05-02) +| Library | Version | Purpose | Why Standard | +|---------|---------|---------|--------------| +| Next.js | ^16.2.4 | App framework | [VERIFIED: package.json] | +| React | ^19.0.0 | UI runtime | [VERIFIED: package.json] | +| TypeScript | (project default) | Type safety | [VERIFIED: tsconfig present] | +| Tailwind CSS | 3.x | Styling | [VERIFIED: tailwind.config.ts present, darkMode: "class"] | +| @xyflow/react | ^12.10.1 | Graph rendering | [VERIFIED: package.json] | +| elkjs | ^0.11.0 | Graph layout | [VERIFIED: package.json] | +| zustand | ^5.0.0 | Client state | [VERIFIED: package.json] | +| @radix-ui/react-dialog | ^1.1.0 | Modal primitive (focus trap, dismissal) | [VERIFIED: package.json] | +| @radix-ui/react-tabs | ^1.1.0 | Available but NOT used here per UI-SPEC | [VERIFIED: package.json — UI-SPEC chooses native ARIA tablist for store-driven external control, matching `EntityTabBar` and Phase 16 `ShardTabNavigator`] | +| lucide-react | ^1.14.0 | Icons | [VERIFIED: package.json] | +| Monaco Editor | (via existing TurtleEditor) | Source editing | [VERIFIED: components/editor/TurtleEditor.tsx present] | +| Vitest | ^4.0.18 | Unit tests | [VERIFIED: package.json] | + +### Supporting +| Library | Version | Purpose | When to Use | +|---------|---------|---------|-------------| +| @testing-library/react | (existing) | RTL render/fireEvent for unit tests | Test new components per `__tests__/components/editor/standard/EntityTabBar.test.tsx` precedent [VERIFIED: file exists] | +| MCP chrome-devtools | (Claude Code MCP) | AI smoke testing | SPEC mandate; `/snap/bin/chromium` headless explicitly disallowed per MEMORY | + +### Alternatives Considered +| Instead of | Could Use | Tradeoff | +|------------|-----------|----------| +| Native ARIA tablist | `@radix-ui/react-tabs` | Radix Tabs ships internal state; Phase 17 needs external `activePaneTab` from `selectionStore` to drive selection. UI-SPEC explicitly rejects Radix Tabs for this reason, mirroring Phase 16's `ShardTabNavigator` precedent. [CITED: 17-UI-SPEC.md §Design System] | +| Modal as new component | Reuse `EntityGraphModal` (renamed) | D-09: rename + slot generalization keeps backdrop / focus-trap / dismissal logic in one place. | +| `display: none` for takeover | Visibility:hidden / absolute offscreen | D-11 picks CSS attribute toggle (`[data-overlay-takeover="true"] .editor-tree-pane { display: none }`). See Common Pitfall 1 for Monaco scroll-preservation analysis. | + +**Installation:** No new dependencies needed. All required packages are already in `package.json`. + +**Version verification:** +```bash +# Run from ontokit-web/ (already verified 2026-05-02): +node -p "require('./package.json').dependencies['@radix-ui/react-dialog']" # ^1.1.0 +node -p "require('./package.json').dependencies['lucide-react']" # ^1.14.0 +``` + +## Phase Requirements + +| ID | Description | Research Support | +|----|-------------|------------------| +| R1 | TabStrip becomes topmost chrome of right pane (Detail/Graph or Detail/Graph/Source) | UI-SPEC §Component Inventory locks Tailwind classes; `EntityTabBar.tsx` is the parity precedent | +| R2 | Old peer-tab `Tree\|Source\|Graph` strip removed (Developer view) | `DeveloperEditorLayout.tsx:373-417` defines the strip; remove entirely. `viewMode` state at line 246 must be repurposed (Source moves to right-pane tab). See Q8 below | +| R3 | Old Graph icon button removed (Standard view entity-actions) | `StandardEditorLayout.tsx:462-471` renders the `` button; delete | +| R4 | Old ` Source` link removed (both views, all 3 detail panels) | `ClassDetailPanel.tsx:608-617` (verified). Property + Individual panels: needs grep verification — see Q8 | +| R5 | Click semantics preserved (single = recenter, double = select) | PR #88's `OntologyGraph.tsx` already implements this; preserve when graph renders inside Graph tab | +| R6 | `activePaneTab` persists across selection changes | D-13: add field to `selectionStore`. Current store at `lib/stores/selectionStore.ts:12-22` has `iri`/`type`/`mode`; extend | +| R7 | Source as Developer-only third tab with snippet + toolbar | New `SourceTabBody` component; reuse `Monaco TurtleEditor` in readOnly mode | +| R8 | Default tab on first load = Detail | D-14: non-persist Zustand defaults to `'detail'` (or `null` → fallback) | +| R9 | Modal ⇄ Maximize ⇄ Restore round-trip | D-09/10/11/12: new `useFullSourceOverlay` hook + renamed `EntityModal` slot props | +| R10 | Tree → Source auto-jump on selection change | Existing `pendingScrollIri` plumbing in `DeveloperEditorLayout` (lines 153, 365–368, 642–643) — preserve | +| R11 | Backend supports class + property + individual focus | `ontokit-api` `build_entity_graph` at `ontokit/services/ontology.py:365`; route at `ontokit/api/routes/projects.py:660-701` | + +## Architecture Patterns + +### System Architecture Diagram + +``` + [User clicks tab in PaneTabStrip] + ↓ + useSelectionStore.setActivePaneTab(tab) + ↓ + ┌─────────── activePaneTab ───────────┐ + ↓ ↓ ↓ + 'detail' 'graph' 'source' + ↓ ↓ ↓ + [Class/Property/ [OntologyGraph [SourceTabBody] + IndividualDetail- with focusIri] ↓ + Panel body] ↓ ┌──────┴──────┐ + ↓ ↓ ↓ + graphApi.getEntityGraph() Monaco snippet ↗ Open full + ↓ via extract- source btn + [server-side BFS: EntityBlock( ↓ + class | property | source, iri) useFullSource- + individual] Overlay.open() + ↓ ↓ + {nodes, edges with ┌────── EntityModal (renamed) ──────┐ + edge_kind ∈ 10 vals} │ = FullSourceOverlay │ + ↓ │ (full Monaco at ~92vw/92vh) │ + OntologyEdge palette │ = ⤢Max / ⊟Restore │ + maps edge_kind → color │ buttons │ + └─────────────────────────────────────┘ + ↓ + ⤢ Maximize → setattr + data-overlay-takeover="true" + → CSS hides .editor-tree-pane + + .editor-detail-pane + ⊟ Restore → unset attr (Monaco never + unmounts; scroll preserved) + + + Backend (ontokit-api): + GET /api/v1/projects/{id}/ontology/entity-graph?focus_iri=...&focus_type=class|property|individual + ↓ + OntologyService.build_entity_graph(focus_iri, focus_type) + ↓ + ┌──────────────┼──────────────┐ + ↓ ↓ ↓ + focus_type= focus_type= focus_type= + class property individual + (existing (NEW: domain, (NEW: rdf:type, + BFS up/down) range, sub- object props, + PropertyOf, sameAs, + seeAlso) seeAlso) + ↓ + {nodes, edges with edge_kind enum} + ↓ + Old route /classes/graph → delegates to /entity-graph (D-05 deprecation) +``` + +### Recommended File Placement +``` +ontokit-web/ +├── components/editor/ +│ ├── PaneTabStrip.tsx # NEW (sibling of EntityTabBar) +│ ├── SourceTabBody.tsx # NEW +│ ├── FullSourceOverlay.tsx # NEW +│ ├── ClassDetailPanel.tsx # MODIFY: remove Source button (lines 608-617) +│ ├── PropertyDetailPanel.tsx # MODIFY: remove Source + (any) Graph button +│ ├── IndividualDetailPanel.tsx # MODIFY: remove Source + (any) Graph button +│ ├── developer/DeveloperEditorLayout.tsx # MODIFY: remove Tree|Source|Graph mode strip; integrate PaneTabStrip +│ └── standard/StandardEditorLayout.tsx # MODIFY: remove Graph icon button (lines 462-471); integrate PaneTabStrip +├── components/graph/ +│ └── EntityGraphModal.tsx → EntityModal.tsx # RENAME + prop generalization (D-09) +├── lib/ +│ ├── api/graph.ts # MODIFY (PR #88 base): refactor getEntityGraph for /entity-graph + focus_type +│ ├── graph/types.ts # MODIFY: extend GraphEdgeType with 6 new values +│ ├── hooks/useFullSourceOverlay.ts # NEW +│ ├── hooks/useGraphData.ts # MODIFY (PR #88 base): pass focus_type through +│ ├── ontology/extractEntitySnippet.ts # NEW (or reuse findBlock + slice) +│ └── stores/selectionStore.ts # MODIFY: add activePaneTab + setter +├── components/graph/OntologyEdge.tsx # MODIFY: extend edgeTypeConfig with 6 new edge_kind entries +└── __tests__/ # NEW unit tests for: PaneTabStrip, SourceTabBody, useFullSourceOverlay, EntityModal slot wiring, selectionStore.activePaneTab, edge palette extension + +ontokit-api/ +├── ontokit/api/routes/projects.py # MODIFY: add new /entity-graph route; mark /classes/graph deprecated (delegate) +├── ontokit/services/ontology.py # MODIFY: extend build_entity_graph to handle property + individual focus +├── ontokit/services/entity_graph_helpers.py # MODIFY: add property_neighbors() + individual_neighbors() helpers +├── ontokit/schemas/graph.py # MODIFY: extend GraphEdgeType Literal with 6 new values +└── tests/unit/test_entity_graph.py # MODIFY: add property + individual + annotation-property focus tests +``` + +### Pattern 1: Native ARIA tablist with external state control (Phase 16 precedent) +**What:** TabStrip uses `role="tablist"` + ` + ))} +

+``` + +Keyboard nav (Left/Right/Home/End/Enter) is handled by an `onKeyDown` on the tablist container, walking `tabs` index and updating focus + calling `onTabChange`. [CITED: WAI-ARIA Authoring Practices — Tabs Pattern] + +### Pattern 2: CSS attribute-toggled takeover (D-11) +**What:** Maximize sets `document.documentElement.dataset.overlayTakeover = "true"`. CSS rule (in `app/globals.css` or scoped per layout) hides the tree + detail panes: +```css +[data-overlay-takeover="true"] .editor-tree-pane, +[data-overlay-takeover="true"] .editor-detail-pane { + display: none; +} +[data-overlay-takeover="true"] .editor-overlay-modal { + position: fixed; inset: 0; + width: 100vw; height: 100vh; + border-radius: 0; + max-width: none; +} +``` +**When to use:** When a child overlay needs to expand beyond a portal modal but the underlying component (Monaco) MUST keep its scroll position. Unmount/remount strategies destroy Monaco's view state. +**Why:** Monaco's `IEditor` instance is tied to its DOM container. Hiding via `display: none` on an *ancestor* element preserves the editor instance — when restored, Monaco re-uses its existing scroll position. Hiding the editor's container directly (or unmounting it) discards view state. [VERIFIED: PR #88's `OntologySourceEditor.tsx` uses `monaco.editor.IStandaloneCodeEditor` instance, which preserves view state across DOM hide/show as long as the instance is not disposed] + +### Pattern 3: Useful Memo for snippet extraction (D-04) +```tsx +// Source: D-04 specification +const snippet = useMemo(() => { + if (!sourceContent || !focusIri) return null; + return extractEntitySnippet(sourceContent, focusIri); +}, [sourceContent, focusIri]); +``` +`extractEntitySnippet` wraps the existing `findBlock` (re-exported from `lib/ontology/turtleUtils.ts`), slices `lines[startLine..endLine]`, joins with `\n`, returns `{snippet, startLine}` so the toolbar can render `# File: ontology.ttl, line ${startLine + 1}`. (`startLine` is 0-indexed in `findBlock`; the comment uses 1-indexed lines per `IriPosition.line` convention in `indexWorker.ts:28`.) + +### Anti-Patterns to Avoid +- **Don't unmount Monaco on Maximize/Restore.** Use CSS attribute toggle only (D-11). Unmounting destroys scroll position even if you save the line number — Monaco's word wrap, fold state, and selection are all lost. +- **Don't mutate `activePaneTab` on mode change.** D-15: derive `useEffectiveTab(editorMode)` at render time. Mutation breaks the user's preference (Developer→Standard→Developer should restore Source tab). +- **Don't add a feature flag or try/catch fallback for the new `/entity-graph` endpoint.** D-08: sequential cross-repo merge order — api ships first to dev, then web cuts from dev. No graceful degradation; if API isn't deployed, web breaks (and that's fine — it's a coordinated release). +- **Don't re-parse the full Turtle source for line numbers.** The Web Worker IRI index already maps every subject IRI to `{line, col, len}`. Read it from `sourceIriIndex` (already a `Map` plumbed through `DeveloperEditorLayout` — lines 91, 152, 366). + +## Don't Hand-Roll + +| Problem | Don't Build | Use Instead | Why | +|---------|-------------|-------------|-----| +| Subject-block discovery in Turtle | New regex per entity type | Existing `findBlock` from `lib/ontology/turtleUtils.ts:178-277` | Already type-agnostic. Handles full IRI, prefixed names, relative IRIs (via @base), continuation-line guards. The "class-only" misimpression comes from `turtleClassUpdater.ts` — but `findBlock` itself is generic. | +| IRI → line number lookup | Re-parse Turtle in component | `sourceIriIndex: Map` from `lib/editor/indexWorker.ts` | Already runs in Web Worker, already plumbed through the editor layouts. | +| Modal focus trap + dismissal | Custom focus management | PR #88's `EntityGraphModal` (renamed) — keeps existing focus trap at `EntityGraphModal.tsx:34-83` | Don't reimplement; just generalize props. | +| Tab keyboard nav | Custom focus rover | Native ARIA tablist pattern + `tabIndex={activeTab === tab.id ? 0 : -1}` | Standard pattern; matches Phase 16 `ShardTabNavigator`. | +| BFS over RDF graph | Custom traversal | `OntologyService.build_entity_graph` at `ontokit/services/ontology.py:365` | Extend, don't rewrite. The `_classify_node` function at line 437-455 already detects properties + individuals — the gating is at line 397 (`if (class_uri, RDF.type, OWL.Class) not in graph: return None`). | +| Annotation-property "no relationships" detection | Custom check on response | Backend returns `{nodes: [focus_only], edges: []}` per D-05/SPEC R11; frontend checks `edges.length === 0 && nodes.length === 1` | Single source of truth in API. | + +**Key insight:** Phase 17 is mostly a **wiring + rename + extend** phase. The platform already has every primitive needed; the planner's job is to sequence the changes correctly across two repos. + +## Per-Question Findings + +### Q1. TabStrip ARIA tablist wiring +**Resolved.** UI-SPEC §Accessibility lines 312-323 fully specify the pattern. Implementation: +- Container: `
` +- Each tab: `