diff --git a/CHANGELOG.md b/CHANGELOG.md index eaeb913..9fe5a56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,30 @@ All notable changes to this project are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/), and the project adheres to [Semantic Versioning](https://semver.org/). +## [Unreleased] + +### Added +- Viewer: exhibit captions (`Exhibit.description`) now render above the + rendered exhibit body in the RESULT block — previously parsed but dropped + from the detail pane (#55). +- Viewer: the detail header now shows the mono node id and an `isolated` pill + for nodes belonging to an isolated subtree (#56). +- Viewer: DEPENDS ON block in the detail pane — outgoing and incoming + `DependsOn` links render as clickable chips that jump-select the target + node; unknown targets render non-clickable (#57). +- Viewer: the evidence / built-on / depends-on / result / sources detail + blocks are now collapsible (`
`) with right-aligned item + counts, so table-heavy artifacts stay readable (#58). + +### Fixed +- Viewer: the global ←/→ replay keys are now also ignored while a modal panel + is open or focus is in a textarea, so arrows can't step the replay behind a + dialog (#59). +- Viewer: DEPENDS ON / DEPENDED ON BY jump chips (#57) now truncate long + labels with a proper ellipsis instead of a hard mid-word cut-off — the + `.chip` base class sets `display: inline-flex`, on which `text-overflow: + ellipsis` has no effect; `chip-jump` now overrides to `inline-block`. + ## [0.1.11] - 2026-07-20 ### Fixed diff --git a/Cargo.lock b/Cargo.lock index 0f0a253..0e6e355 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -125,7 +125,7 @@ checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" [[package]] name = "ara-cli" -version = "0.1.11" +version = "0.1.12" dependencies = [ "ara-core", "arc-swap", @@ -148,7 +148,7 @@ dependencies = [ [[package]] name = "ara-core" -version = "0.1.11" +version = "0.1.12" dependencies = [ "dagre-dgl-rs", "insta", @@ -160,7 +160,7 @@ dependencies = [ [[package]] name = "ara-viewer" -version = "0.1.11" +version = "0.1.12" dependencies = [ "ara-core", "console_error_panic_hook", @@ -178,7 +178,7 @@ dependencies = [ [[package]] name = "ara-wasm" -version = "0.1.11" +version = "0.1.12" [[package]] name = "arc-swap" diff --git a/Cargo.toml b/Cargo.toml index 13ee36b..f3bff8b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "3" members = ["crates/*"] [workspace.package] -version = "0.1.11" +version = "0.1.12" edition = "2024" license = "MPL-2.0" repository = "https://github.com/ARA-Labs/ara-cli" diff --git a/crates/ara-cli/assets/viewer.source-hash b/crates/ara-cli/assets/viewer.source-hash index 0056f4c..81e30ef 100644 --- a/crates/ara-cli/assets/viewer.source-hash +++ b/crates/ara-cli/assets/viewer.source-hash @@ -1 +1 @@ -71a9204bb75ec9a40329aaa9485dbd1d54ce5129e3ff7e32c736973bdcc33c7e +154193f53a25f8cabb0fe326e0c14e578be5d3f7190bbd724c8399c6819224f5 diff --git a/crates/ara-cli/assets/viewer/ara-viewer-3b9263f1ff965232.js b/crates/ara-cli/assets/viewer/ara-viewer-1213ab1da86ce3ef.js similarity index 95% rename from crates/ara-cli/assets/viewer/ara-viewer-3b9263f1ff965232.js rename to crates/ara-cli/assets/viewer/ara-viewer-1213ab1da86ce3ef.js index 359dce7..e9e7a62 100644 --- a/crates/ara-cli/assets/viewer/ara-viewer-3b9263f1ff965232.js +++ b/crates/ara-cli/assets/viewer/ara-viewer-1213ab1da86ce3ef.js @@ -518,7 +518,7 @@ function __wbg_get_imports() { const a = state0.a; state0.a = 0; try { - return __wasm_bindgen_func_elem_2201(a, state0.b, arg0, arg1); + return __wasm_bindgen_func_elem_2353(a, state0.b, arg0, arg1); } finally { state0.a = a; } @@ -585,6 +585,10 @@ function __wbg_get_imports() { const ret = getObject(arg0).querySelectorAll(getStringFromWasm0(arg1, arg2)); return addHeapObject(ret); }, arguments); }, + __wbg_querySelector_fd7d157ebe17cd16: function() { return handleError(function (arg0, arg1, arg2) { + const ret = getObject(arg0).querySelector(getStringFromWasm0(arg1, arg2)); + return isLikeNone(ret) ? 0 : addHeapObject(ret); + }, arguments); }, __wbg_queueMicrotask_0ab5b2d2393e99b9: function(arg0) { const ret = getObject(arg0).queueMicrotask; return addHeapObject(ret); @@ -669,6 +673,9 @@ function __wbg_get_imports() { __wbg_set_once_51a9fb6b8af8a72b: function(arg0, arg1) { getObject(arg0).once = arg1 !== 0; }, + __wbg_set_open_ac65a528ddc36ede: function(arg0, arg1) { + getObject(arg0).open = arg1 !== 0; + }, __wbg_set_reason_18dc06ea0d60243b: function(arg0, arg1, arg2) { getObject(arg0).reason = getStringFromWasm0(arg1, arg2); }, @@ -778,38 +785,38 @@ function __wbg_get_imports() { return ret; }, __wbindgen_cast_0000000000000001: function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 235, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`. - const ret = makeMutClosure(arg0, arg1, __wasm_bindgen_func_elem_2140); + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 284, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`. + const ret = makeMutClosure(arg0, arg1, __wasm_bindgen_func_elem_2281); return addHeapObject(ret); }, __wbindgen_cast_0000000000000002: function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 58, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. - const ret = makeMutClosure(arg0, arg1, __wasm_bindgen_func_elem_955); + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 68, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + const ret = makeMutClosure(arg0, arg1, __wasm_bindgen_func_elem_1011); return addHeapObject(ret); }, __wbindgen_cast_0000000000000003: function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("CloseEvent")], shim_idx: 58, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. - const ret = makeMutClosure(arg0, arg1, __wasm_bindgen_func_elem_955_2); + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("CloseEvent")], shim_idx: 68, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + const ret = makeMutClosure(arg0, arg1, __wasm_bindgen_func_elem_1011_2); return addHeapObject(ret); }, __wbindgen_cast_0000000000000004: function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 58, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. - const ret = makeMutClosure(arg0, arg1, __wasm_bindgen_func_elem_955_3); + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 68, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + const ret = makeMutClosure(arg0, arg1, __wasm_bindgen_func_elem_1011_3); return addHeapObject(ret); }, __wbindgen_cast_0000000000000005: function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("KeyboardEvent")], shim_idx: 58, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. - const ret = makeMutClosure(arg0, arg1, __wasm_bindgen_func_elem_955_4); + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("KeyboardEvent")], shim_idx: 68, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + const ret = makeMutClosure(arg0, arg1, __wasm_bindgen_func_elem_1011_4); return addHeapObject(ret); }, __wbindgen_cast_0000000000000006: function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("MessageEvent")], shim_idx: 58, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. - const ret = makeMutClosure(arg0, arg1, __wasm_bindgen_func_elem_955_5); + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("MessageEvent")], shim_idx: 68, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + const ret = makeMutClosure(arg0, arg1, __wasm_bindgen_func_elem_1011_5); return addHeapObject(ret); }, __wbindgen_cast_0000000000000007: function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 56, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. - const ret = makeMutClosure(arg0, arg1, __wasm_bindgen_func_elem_2094); + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 66, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + const ret = makeMutClosure(arg0, arg1, __wasm_bindgen_func_elem_2241); return addHeapObject(ret); }, __wbindgen_cast_0000000000000008: function(arg0, arg1) { @@ -831,34 +838,34 @@ function __wbg_get_imports() { }; } -function __wasm_bindgen_func_elem_2094(arg0, arg1) { - wasm.__wasm_bindgen_func_elem_2094(arg0, arg1); +function __wasm_bindgen_func_elem_2241(arg0, arg1) { + wasm.__wasm_bindgen_func_elem_2241(arg0, arg1); } -function __wasm_bindgen_func_elem_955(arg0, arg1, arg2) { - wasm.__wasm_bindgen_func_elem_955(arg0, arg1, addHeapObject(arg2)); +function __wasm_bindgen_func_elem_1011(arg0, arg1, arg2) { + wasm.__wasm_bindgen_func_elem_1011(arg0, arg1, addHeapObject(arg2)); } -function __wasm_bindgen_func_elem_955_2(arg0, arg1, arg2) { - wasm.__wasm_bindgen_func_elem_955_2(arg0, arg1, addHeapObject(arg2)); +function __wasm_bindgen_func_elem_1011_2(arg0, arg1, arg2) { + wasm.__wasm_bindgen_func_elem_1011_2(arg0, arg1, addHeapObject(arg2)); } -function __wasm_bindgen_func_elem_955_3(arg0, arg1, arg2) { - wasm.__wasm_bindgen_func_elem_955_3(arg0, arg1, addHeapObject(arg2)); +function __wasm_bindgen_func_elem_1011_3(arg0, arg1, arg2) { + wasm.__wasm_bindgen_func_elem_1011_3(arg0, arg1, addHeapObject(arg2)); } -function __wasm_bindgen_func_elem_955_4(arg0, arg1, arg2) { - wasm.__wasm_bindgen_func_elem_955_4(arg0, arg1, addHeapObject(arg2)); +function __wasm_bindgen_func_elem_1011_4(arg0, arg1, arg2) { + wasm.__wasm_bindgen_func_elem_1011_4(arg0, arg1, addHeapObject(arg2)); } -function __wasm_bindgen_func_elem_955_5(arg0, arg1, arg2) { - wasm.__wasm_bindgen_func_elem_955_5(arg0, arg1, addHeapObject(arg2)); +function __wasm_bindgen_func_elem_1011_5(arg0, arg1, arg2) { + wasm.__wasm_bindgen_func_elem_1011_5(arg0, arg1, addHeapObject(arg2)); } -function __wasm_bindgen_func_elem_2140(arg0, arg1, arg2) { +function __wasm_bindgen_func_elem_2281(arg0, arg1, arg2) { try { const retptr = wasm.__wbindgen_add_to_stack_pointer(-16); - wasm.__wasm_bindgen_func_elem_2140(retptr, arg0, arg1, addHeapObject(arg2)); + wasm.__wasm_bindgen_func_elem_2281(retptr, arg0, arg1, addHeapObject(arg2)); var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true); var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true); if (r1) { @@ -869,8 +876,8 @@ function __wasm_bindgen_func_elem_2140(arg0, arg1, arg2) { } } -function __wasm_bindgen_func_elem_2201(arg0, arg1, arg2, arg3) { - wasm.__wasm_bindgen_func_elem_2201(arg0, arg1, addHeapObject(arg2), addHeapObject(arg3)); +function __wasm_bindgen_func_elem_2353(arg0, arg1, arg2, arg3) { + wasm.__wasm_bindgen_func_elem_2353(arg0, arg1, addHeapObject(arg2), addHeapObject(arg3)); } diff --git a/crates/ara-cli/assets/viewer/ara-viewer-1213ab1da86ce3ef_bg.wasm b/crates/ara-cli/assets/viewer/ara-viewer-1213ab1da86ce3ef_bg.wasm new file mode 100644 index 0000000..31965fa Binary files /dev/null and b/crates/ara-cli/assets/viewer/ara-viewer-1213ab1da86ce3ef_bg.wasm differ diff --git a/crates/ara-cli/assets/viewer/ara-viewer-3b9263f1ff965232_bg.wasm b/crates/ara-cli/assets/viewer/ara-viewer-3b9263f1ff965232_bg.wasm deleted file mode 100644 index 2ac9b7b..0000000 Binary files a/crates/ara-cli/assets/viewer/ara-viewer-3b9263f1ff965232_bg.wasm and /dev/null differ diff --git a/crates/ara-cli/assets/viewer/index.html b/crates/ara-cli/assets/viewer/index.html index 48d2b03..cf18981 100644 --- a/crates/ara-cli/assets/viewer/index.html +++ b/crates/ara-cli/assets/viewer/index.html @@ -4,14 +4,14 @@ ARA Viewer - + - + diff --git a/crates/ara-cli/assets/viewer/styles-e6588b2ec91f581b.css b/crates/ara-cli/assets/viewer/styles-8e96cbefbbb65688.css similarity index 94% rename from crates/ara-cli/assets/viewer/styles-e6588b2ec91f581b.css rename to crates/ara-cli/assets/viewer/styles-8e96cbefbbb65688.css index a4669b5..823371c 100644 --- a/crates/ara-cli/assets/viewer/styles-e6588b2ec91f581b.css +++ b/crates/ara-cli/assets/viewer/styles-8e96cbefbbb65688.css @@ -967,6 +967,58 @@ body.is-resizing.resizing-row { cursor: row-resize; } border: 1px solid var(--line); } +/* Node id in the detail meta row (#56). */ +.detail-node-id { + font-size: 0.75rem; + color: var(--muted); +} + +/* Isolated-subtree pill (#56): reuses the tree-mode isobox tokens. */ +.pill.iso { + display: inline-flex; + align-items: center; + padding: 0.1rem 0.45rem; + border-radius: 9999px; + font-size: 0.7rem; + background-color: var(--iso-bg); + color: var(--iso-ink); + border: 1px solid var(--iso-line); +} + +/* ── Collapsible content blocks (#58) ──────────────────────────────────────── + EVIDENCE / BUILT ON / DEPENDS ON / RESULT / SOURCES render as + `
`; the summary carries the block label plus a right-aligned + muted item count, matching the hub's per-block count tags. */ + +.block-summary { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + cursor: pointer; + list-style: none; +} + +.block-summary::-webkit-details-marker { + display: none; +} + +.block-summary:hover .block-label { + color: var(--accent-text); +} + +.block-count { + font-size: 0.7rem; + color: var(--muted); +} + +/* A closed block shows only its summary row — no residual flex gap. */ +details.block:not([open]) { + gap: 0; + padding-top: 0.45rem; + padding-bottom: 0.45rem; +} + /* ── Content blocks ──────────────────────────────────────────────────────────── */ .block { @@ -1114,6 +1166,34 @@ body.is-resizing.resizing-row { cursor: row-resize; } border: 1px solid var(--line); } +/* Clickable chip button (DEPENDS ON jump chips, #57). Truncates long labels + with an ellipsis; the full text stays available via the hover tooltip. + `display: inline-block` overrides the inherited `.chip` `inline-flex` — + `text-overflow: ellipsis` has no effect on a flex container. */ +button.chip-jump { + display: inline-block; + cursor: pointer; + max-width: 36ch; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + transition: border-color 0.12s, color 0.12s; +} + +button.chip-jump:hover { + border-color: var(--accent); + color: var(--accent-text); +} + +/* Exhibit caption (#55): `Exhibit.description` above the rendered body, + figcaption-style. */ +.exhibit-caption { + font-family: var(--font-mono); + font-size: 0.75rem; + color: var(--muted); + margin: 0.5rem 0 0.2rem; +} + /* ── Empty-node note ─────────────────────────────────────────────────────────── */ .empty-note { diff --git a/crates/ara-viewer/public/styles.css b/crates/ara-viewer/public/styles.css index a4669b5..823371c 100644 --- a/crates/ara-viewer/public/styles.css +++ b/crates/ara-viewer/public/styles.css @@ -967,6 +967,58 @@ body.is-resizing.resizing-row { cursor: row-resize; } border: 1px solid var(--line); } +/* Node id in the detail meta row (#56). */ +.detail-node-id { + font-size: 0.75rem; + color: var(--muted); +} + +/* Isolated-subtree pill (#56): reuses the tree-mode isobox tokens. */ +.pill.iso { + display: inline-flex; + align-items: center; + padding: 0.1rem 0.45rem; + border-radius: 9999px; + font-size: 0.7rem; + background-color: var(--iso-bg); + color: var(--iso-ink); + border: 1px solid var(--iso-line); +} + +/* ── Collapsible content blocks (#58) ──────────────────────────────────────── + EVIDENCE / BUILT ON / DEPENDS ON / RESULT / SOURCES render as + `
`; the summary carries the block label plus a right-aligned + muted item count, matching the hub's per-block count tags. */ + +.block-summary { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + cursor: pointer; + list-style: none; +} + +.block-summary::-webkit-details-marker { + display: none; +} + +.block-summary:hover .block-label { + color: var(--accent-text); +} + +.block-count { + font-size: 0.7rem; + color: var(--muted); +} + +/* A closed block shows only its summary row — no residual flex gap. */ +details.block:not([open]) { + gap: 0; + padding-top: 0.45rem; + padding-bottom: 0.45rem; +} + /* ── Content blocks ──────────────────────────────────────────────────────────── */ .block { @@ -1114,6 +1166,34 @@ body.is-resizing.resizing-row { cursor: row-resize; } border: 1px solid var(--line); } +/* Clickable chip button (DEPENDS ON jump chips, #57). Truncates long labels + with an ellipsis; the full text stays available via the hover tooltip. + `display: inline-block` overrides the inherited `.chip` `inline-flex` — + `text-overflow: ellipsis` has no effect on a flex container. */ +button.chip-jump { + display: inline-block; + cursor: pointer; + max-width: 36ch; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + transition: border-color 0.12s, color 0.12s; +} + +button.chip-jump:hover { + border-color: var(--accent); + color: var(--accent-text); +} + +/* Exhibit caption (#55): `Exhibit.description` above the rendered body, + figcaption-style. */ +.exhibit-caption { + font-family: var(--font-mono); + font-size: 0.75rem; + color: var(--muted); + margin: 0.5rem 0 0.2rem; +} + /* ── Empty-node note ─────────────────────────────────────────────────────────── */ .empty-note { diff --git a/crates/ara-viewer/src/detail.rs b/crates/ara-viewer/src/detail.rs index 882b268..b70abeb 100644 --- a/crates/ara-viewer/src/detail.rs +++ b/crates/ara-viewer/src/detail.rs @@ -8,7 +8,7 @@ //! the rest of the viewer it compiles on native too (no browser-only APIs), //! so no `cfg` gating is needed. -use ara_core::{ExhibitKind, Manifest, Node, NodeFields, NodeId}; +use ara_core::{ExhibitKind, LinkKind, Manifest, Node, NodeFields, NodeId}; use leptos::prelude::*; use crate::kind::kind_meta; @@ -57,10 +57,48 @@ pub struct ExhibitView { pub kind: String, /// Origin of the exhibit, when stated. pub source: Option, + /// Caption / description prose, rendered above the exhibit body (#55). + pub description: Option, /// Raw markdown body, verbatim (rendered client-side; issue #32). pub body: String, } +/// A node referenced by a `DependsOn` link (DEPENDS ON block, #57). +/// +/// Fields are private: construct via [`DepView::resolved`] / +/// [`DepView::unresolved`] so `known` can never drift out of sync with +/// `label` (a resolved target always carries the resolved label; an +/// unresolved one always carries the raw id and is non-clickable). +#[derive(Debug, Clone, PartialEq)] +pub struct DepView { + /// Target node id. + id: NodeId, + /// Display text: target `label ?? id` when known, else the raw id. + label: String, + /// False when the target id has no matching node — renders non-clickable. + known: bool, +} + +impl DepView { + /// A target that resolved to a real node: clickable chip, resolved label. + fn resolved(id: &NodeId, label: String) -> Self { + Self { + id: id.clone(), + label, + known: true, + } + } + + /// A target id with no matching node: raw id as label, non-clickable. + fn unresolved(id: &NodeId) -> Self { + Self { + id: id.clone(), + label: id.as_str().to_string(), + known: false, + } + } +} + /// A single typed field entry in the per-kind section. #[derive(Debug, Clone, PartialEq)] pub struct TypedField { @@ -76,8 +114,13 @@ pub struct TypedField { /// Leptos `DetailPane` component (and by unit tests, without a browser). #[derive(Debug, Clone, PartialEq)] pub struct DetailModel { + /// Node id (e.g. `N03`), shown in the header meta row (#56). + pub id: NodeId, /// `label ?? id` — always present. pub title: String, + /// True when the node is the root of, or belongs to, an isolated subtree + /// (#56). Drives the `isolated` pill in the header. + pub isolated: bool, /// Kind badge string: canonical lowercase for named kinds, raw string for /// `Other(_)`. pub kind_badge: String, @@ -99,6 +142,10 @@ pub struct DetailModel { pub built_on: Vec, /// Exhibits linked to this node (RESULT block), in source order. pub result_exhibits: Vec, + /// Outgoing `DependsOn` targets (DEPENDS ON block, #57), in link order. + pub depends_on: Vec, + /// Incoming `DependsOn` sources ("depended on by"), in link order. + pub depended_on_by: Vec, /// Provenance refs. pub source_refs: Vec, } @@ -195,13 +242,39 @@ pub fn detail_model(node: &Node, manifest: &Manifest) -> DetailModel { file: ex.file.clone(), kind: exhibit_kind_label(&ex.kind).to_string(), source: ex.source.clone(), + description: ex.description.clone(), body: ex.body.clone(), }) }) .collect(); + // ── DEPENDS ON: outgoing + incoming DependsOn links, link order ──────── + // Labels resolve against `manifest.nodes` (`label ?? id`); an unknown + // target keeps the raw id and renders non-clickable (never panics). + let dep_view = |id: &NodeId| match manifest.nodes.iter().find(|n| &n.id == id) { + Some(n) => DepView::resolved( + id, + n.label.clone().unwrap_or_else(|| n.id.as_str().to_string()), + ), + None => DepView::unresolved(id), + }; + let depends_on: Vec = manifest + .links + .iter() + .filter(|l| l.kind == LinkKind::DependsOn && l.from == node.id) + .map(|l| dep_view(&l.to)) + .collect(); + let depended_on_by: Vec = manifest + .links + .iter() + .filter(|l| l.kind == LinkKind::DependsOn && l.to == node.id) + .map(|l| dep_view(&l.from)) + .collect(); + DetailModel { + id: node.id.clone(), title, + isolated: is_in_isolated_subtree(node, manifest), kind_badge: meta.badge, kind_css_class: meta.css_class.to_string(), kind_glyph: meta.glyph, @@ -212,10 +285,46 @@ pub fn detail_model(node: &Node, manifest: &Manifest) -> DetailModel { claims, built_on, result_exhibits, + depends_on, + depended_on_by, source_refs: node.source_refs.clone(), } } +/// True when `node` carries the `isolated` flag itself or belongs to an +/// isolated subtree (the flag lives on the subtree root only; children +/// inherit placement — see `Node.isolated` in `ara-core`). +/// +/// Walks `Child` links upward (first parent wins) to the subtree root and +/// returns that root's flag. A `visited` set guards against malformed Child +/// cycles so a bad manifest cannot infinite-loop (matches `tree_model`'s +/// guard). A node with no parent is its own root. +fn is_in_isolated_subtree(node: &Node, manifest: &Manifest) -> bool { + let mut current = &node.id; + let mut visited = std::collections::HashSet::new(); + loop { + if !visited.insert(current) { + return false; // Child cycle — treat as non-isolated. + } + let parent = manifest + .links + .iter() + .find(|l| l.kind == LinkKind::Child && l.to == *current) + .map(|l| &l.from); + match parent { + Some(p) => current = p, + None => { + return manifest + .nodes + .iter() + .find(|n| &n.id == current) + .map(|n| n.isolated) + .unwrap_or(false); + } + } + } +} + /// Short lowercase label for an [`ExhibitKind`], matching our lowercase /// block-label convention. fn exhibit_kind_label(kind: &ExhibitKind) -> &'static str { @@ -363,8 +472,9 @@ fn typed_fields_for(node: &Node) -> Vec { // ── Leptos component ────────────────────────────────────────────────────────── // Like the rest of the viewer (scene.rs, main.rs), this component is compiled -// on both native and wasm32 targets. No browser-only APIs are used here; -// the Leptos proc-macros and signal types work on native too. +// on both native and wasm32 targets. The only DOM-facing call (`set_open` on +// a `NodeRef` element) is a no-op off-wasm because the ref is only populated +// in the browser. /// Renders the detail pane for the currently selected node. /// @@ -400,7 +510,7 @@ pub fn DetailPane( .into_any(), Some(node) => { let model = detail_model(node, &manifest); - render_detail(model).into_any() + render_detail(model, selected).into_any() } } } @@ -408,8 +518,46 @@ pub fn DetailPane( } } +/// A collapsible content block (#58): `
` with the block label +/// on the left and a muted item count on the right, matching the hub's +/// per-block count tags. Used for the EVIDENCE / BUILT ON / DEPENDS ON / +/// RESULT / SOURCES blocks; the description and typed-field blocks stay +/// always-open. +/// +/// Collapse state must not leak across node selection: Leptos patches the +/// `
` in place when `selected` changes (same view shape) and skips +/// attribute writes whose value didn't change, so a user-collapsed block +/// would silently stay collapsed for the next node. The effect below watches +/// `selected` and forcibly re-applies `open` on the live element. +#[component] +fn CollapsibleBlock( + label: &'static str, + count: usize, + class: &'static str, + selected: RwSignal>, + children: Children, +) -> impl IntoView { + let details = NodeRef::::new(); + Effect::new(move |_| { + selected.track(); + // `None` off-wasm (no DOM); the open attribute already covers mount. + if let Some(el) = details.get() { + el.set_open(true); + } + }); + view! { +
+ + {label} + {count} + + {children()} +
+ } +} + /// Render a fully-populated `DetailModel` into DOM. -fn render_detail(m: DetailModel) -> impl IntoView { +fn render_detail(m: DetailModel, selected: RwSignal>) -> impl IntoView { let is_empty = m.is_empty(); let dead_end_class = if m.kind_css_class == "dead_end" { "dead_end" @@ -423,6 +571,8 @@ fn render_detail(m: DetailModel) -> impl IntoView {

{m.title.clone()}

+ // Node id (#56): mono, muted, first in the meta row. + {m.id.to_string()} // Kind chip: glyph + badge text. dead_end gets warn colour. @@ -434,6 +584,10 @@ fn render_detail(m: DetailModel) -> impl IntoView { {m.support_level.clone().map(|sl| view! { {sl} })} + // Isolated-subtree pill (#56) when the node is in one. + {m.isolated.then(|| view! { + "isolated" + })}
@@ -483,9 +637,9 @@ fn render_detail(m: DetailModel) -> impl IntoView { // ── 4. Evidence (notes + claims) ────────────────────────────── // Omit the whole block if both are empty. {if !m.evidence_notes.is_empty() || !m.claims.is_empty() { + let count = m.evidence_notes.len() + m.claims.len(); Some(view! { -
- "evidence" + // Evidence notes list {if !m.evidence_notes.is_empty() { Some(view! { @@ -515,7 +669,7 @@ fn render_detail(m: DetailModel) -> impl IntoView {
} }).collect::>()} - + }) } else { None @@ -526,8 +680,7 @@ fn render_detail(m: DetailModel) -> impl IntoView { // the hub. Chips carry the RW id + cite. {if !m.built_on.is_empty() { Some(view! { -
- "built on" +
{m.built_on.iter().map(|bo| { let text = if bo.cite.is_empty() { @@ -538,7 +691,60 @@ fn render_detail(m: DetailModel) -> impl IntoView { view! { {text} } }).collect::>()}
-
+ + }) + } else { + None + }} + + // ── 5b. Depends on (node→node DependsOn links, #57) ──────────── + // Clickable chips jump-select the target node; unknown targets + // render non-clickable. Incoming links ("depended on by") get + // their own labelled chip row inside the same block. + {if !m.depends_on.is_empty() || !m.depended_on_by.is_empty() { + let count = m.depends_on.len() + m.depended_on_by.len(); + let dep_chip = move |d: &DepView| { + let text = format!("{} · {}", d.id, d.label); + let tip = text.clone(); + if d.known { + let target = d.id.clone(); + view! { + + } + .into_any() + } else { + view! { {text} }.into_any() + } + }; + Some(view! { + + {if !m.depends_on.is_empty() { + Some(view! { +
+ {m.depends_on.iter().map(dep_chip).collect::>()} +
+ }) + } else { + None + }} + {if !m.depended_on_by.is_empty() { + Some(view! { + "depended on by" +
+ {m.depended_on_by.iter().map(dep_chip).collect::>()} +
+ }) + } else { + None + }} +
}) } else { None @@ -551,8 +757,7 @@ fn render_detail(m: DetailModel) -> impl IntoView { // `.exhibit-body` scroll container (issue #32, see `markdown.rs`). {if !m.result_exhibits.is_empty() { Some(view! { -
- "result" +
{m.result_exhibits.iter().map(|ex| { let text = format!("{} · {}", ex.id, ex.kind); @@ -565,10 +770,15 @@ fn render_detail(m: DetailModel) -> impl IntoView { {m.result_exhibits.iter().filter(|ex| !ex.body.trim().is_empty()).map(|ex| { let rendered = crate::markdown::render_exhibit_body(&ex.body); view! { + // Caption (#55): `Exhibit.description` rendered + // above its body, figcaption-style. + {ex.description.clone().map(|cap| view! { +

{cap}

+ })}
} }).collect::>()} -
+
}) } else { None @@ -577,14 +787,13 @@ fn render_detail(m: DetailModel) -> impl IntoView { // ── 7. Provenance ───────────────────────────────────────────── {if !m.source_refs.is_empty() { Some(view! { -
- "sources" +
{m.source_refs.iter().map(|r| view! { {r.clone()} }).collect::>()}
-
+ }) } else { None @@ -635,7 +844,7 @@ fn status_css_class(status: Option<&str>) -> &'static str { mod tests { use super::*; use ara_core::{ - Binding, BindingRole, BuiltOn, Claim, ClaimId, Exhibit, ExhibitKind, Manifest, Node, + Binding, BindingRole, BuiltOn, Claim, ClaimId, Exhibit, ExhibitKind, Link, Manifest, Node, NodeExhibit, NodeFields, NodeId, NodeKind, RelatedWork, }; @@ -1256,4 +1465,254 @@ mod tests { }]; assert!(!detail_model(&node, &m2).is_empty()); } + + // ── #55: exhibit captions ─────────────────────────────────────────────── + + /// `Exhibit.description` propagates to `ExhibitView.description`. + #[test] + fn exhibit_description_propagated() { + let node = make_node("N01", NodeKind::Question, NodeFields::Question); + let mut manifest = bare_manifest(); + manifest.exhibits = vec![ + Exhibit { + id: "T01".to_string(), + file: "evidence/tab1.md".to_string(), + kind: ExhibitKind::Table, + source: None, + description: Some("Table 1: Speedrun Progression".to_string()), + claims: vec![], + body: "| a | b |".to_string(), + }, + Exhibit { + id: "T02".to_string(), + file: "evidence/tab2.md".to_string(), + kind: ExhibitKind::Table, + source: None, + description: None, + claims: vec![], + body: String::new(), + }, + ]; + manifest.node_exhibits = vec![ + NodeExhibit { + node: NodeId::new("N01"), + exhibit: "T01".to_string(), + }, + NodeExhibit { + node: NodeId::new("N01"), + exhibit: "T02".to_string(), + }, + ]; + + let m = detail_model(&node, &manifest); + assert_eq!( + m.result_exhibits[0].description, + Some("Table 1: Speedrun Progression".to_string()) + ); + assert_eq!(m.result_exhibits[1].description, None); + } + + // ── #56: node id + isolated ───────────────────────────────────────────── + + #[test] + fn id_always_populated() { + let node = make_node("N07", NodeKind::Question, NodeFields::Question); + let m = detail_model(&node, &bare_manifest()); + assert_eq!(m.id, NodeId::new("N07")); + } + + /// A root carrying `isolated: true` → isolated model; a plain root → not. + #[test] + fn isolated_flag_on_root() { + let mut iso = make_node("N01", NodeKind::Question, NodeFields::Question); + iso.isolated = true; + let mut manifest = bare_manifest(); + manifest.nodes = vec![iso.clone()]; + assert!(detail_model(&iso, &manifest).isolated); + + let plain = make_node("N02", NodeKind::Question, NodeFields::Question); + manifest.nodes = vec![plain.clone()]; + assert!(!detail_model(&plain, &manifest).isolated); + } + + /// A child of an isolated root inherits the isolated placement; a child + /// of a non-isolated root does not. + #[test] + fn isolated_inherited_from_subtree_root() { + let mut root = make_node("N01", NodeKind::Question, NodeFields::Question); + root.isolated = true; + let child = make_node( + "N02", + NodeKind::Experiment, + NodeFields::Experiment { result: None }, + ); + let grandchild = make_node( + "N03", + NodeKind::Experiment, + NodeFields::Experiment { result: None }, + ); + let mut manifest = bare_manifest(); + manifest.nodes = vec![root.clone(), child.clone(), grandchild.clone()]; + manifest.links = vec![ + Link { + from: NodeId::new("N01"), + to: NodeId::new("N02"), + kind: LinkKind::Child, + }, + Link { + from: NodeId::new("N02"), + to: NodeId::new("N03"), + kind: LinkKind::Child, + }, + ]; + + assert!(detail_model(&child, &manifest).isolated); + assert!(detail_model(&grandchild, &manifest).isolated); + + // Same shape with a non-isolated root → nothing isolated. + root.isolated = false; + manifest.nodes = vec![root, child.clone(), grandchild.clone()]; + assert!(!detail_model(&child, &manifest).isolated); + } + + /// A Child cycle cannot infinite-loop the root walk; treated as + /// non-isolated. + #[test] + fn isolated_child_cycle_guard() { + let node = make_node("N01", NodeKind::Question, NodeFields::Question); + let mut manifest = bare_manifest(); + manifest.nodes = vec![node.clone()]; + manifest.links = vec![Link { + from: NodeId::new("N01"), + to: NodeId::new("N01"), + kind: LinkKind::Child, + }]; + assert!(!detail_model(&node, &manifest).isolated); + } + + /// A node with TWO incoming `Child` links (a hand-edited manifest can + /// reach the viewer without ara-core's single-parent validation): the + /// upward walk takes the FIRST matching Child link in `manifest.links` + /// order — "first parent wins". Pinned so the order-dependence of the + /// isolated pill on such manifests is an explicit contract. + #[test] + fn isolated_multi_parent_first_child_link_wins() { + let mut iso_root = make_node("N01", NodeKind::Question, NodeFields::Question); + iso_root.isolated = true; + let plain_root = make_node("N02", NodeKind::Question, NodeFields::Question); + let child = make_node("N03", NodeKind::Question, NodeFields::Question); + let iso_link = Link { + from: NodeId::new("N01"), + to: NodeId::new("N03"), + kind: LinkKind::Child, + }; + let plain_link = Link { + from: NodeId::new("N02"), + to: NodeId::new("N03"), + kind: LinkKind::Child, + }; + let mut manifest = bare_manifest(); + manifest.nodes = vec![iso_root, plain_root, child.clone()]; + + // Isolated parent first → isolated. + manifest.links = vec![iso_link.clone(), plain_link.clone()]; + assert!( + detail_model(&child, &manifest).isolated, + "first Child link (isolated root) wins" + ); + + // Reversed → non-isolated. + manifest.links = vec![plain_link, iso_link]; + assert!( + !detail_model(&child, &manifest).isolated, + "first Child link (plain root) wins" + ); + } + + // ── #57: depends-on linkage ───────────────────────────────────────────── + + /// Outgoing and incoming DependsOn links resolve to id + label (`label ?? + /// id` of the other node), in link order; Child links are ignored. + #[test] + fn depends_on_resolves_both_directions() { + let node = make_node("N02", NodeKind::Question, NodeFields::Question); + let mut a = make_node("N01", NodeKind::Question, NodeFields::Question); + a.label = Some("Root question".to_string()); + let c = make_node("N03", NodeKind::Question, NodeFields::Question); + let mut manifest = bare_manifest(); + manifest.nodes = vec![a, node.clone(), c]; + manifest.links = vec![ + // Child link to the same node — must NOT appear in the deps block. + Link { + from: NodeId::new("N01"), + to: NodeId::new("N02"), + kind: LinkKind::Child, + }, + // Outgoing: N02 depends on N01 (labelled) and N99 (unknown). + Link { + from: NodeId::new("N02"), + to: NodeId::new("N01"), + kind: LinkKind::DependsOn, + }, + Link { + from: NodeId::new("N02"), + to: NodeId::new("N99"), + kind: LinkKind::DependsOn, + }, + // Incoming: N03 depends on N02. + Link { + from: NodeId::new("N03"), + to: NodeId::new("N02"), + kind: LinkKind::DependsOn, + }, + ]; + + let m = detail_model(&node, &manifest); + + assert_eq!(m.depends_on.len(), 2); + assert_eq!(m.depends_on[0].id, NodeId::new("N01")); + assert_eq!(m.depends_on[0].label, "Root question"); + assert!(m.depends_on[0].known); + // Unknown target: raw id as label, non-clickable. + assert_eq!(m.depends_on[1].id, NodeId::new("N99")); + assert_eq!(m.depends_on[1].label, "N99"); + assert!(!m.depends_on[1].known); + + assert_eq!(m.depended_on_by.len(), 1); + assert_eq!(m.depended_on_by[0].id, NodeId::new("N03")); + assert_eq!(m.depended_on_by[0].label, "N03"); // no label → id fallback + assert!(m.depended_on_by[0].known); + } + + /// No DependsOn links → both vecs empty (block omitted). + #[test] + fn no_depends_on_yields_empty_vecs() { + let node = make_node("N01", NodeKind::Question, NodeFields::Question); + let m = detail_model(&node, &bare_manifest()); + assert!(m.depends_on.is_empty()); + assert!(m.depended_on_by.is_empty()); + } + + /// Duplicate `DependsOn` links between the same pair (normally stripped + /// by `ara-core`'s `dedupe_links` at compile time, but the viewer + /// deserializes manifests directly) are preserved as-is: one chip per + /// link, no dedup in `detail_model`. Pinned as intentional — the viewer + /// renders the manifest faithfully rather than silently editing it. + #[test] + fn duplicate_depends_on_links_rendered_as_is() { + let node = make_node("N01", NodeKind::Question, NodeFields::Question); + let target = make_node("N02", NodeKind::Question, NodeFields::Question); + let mut manifest = bare_manifest(); + manifest.nodes = vec![node.clone(), target]; + let link = Link { + from: NodeId::new("N01"), + to: NodeId::new("N02"), + kind: LinkKind::DependsOn, + }; + manifest.links = vec![link.clone(), link]; + + let m = detail_model(&node, &manifest); + assert_eq!(m.depends_on.len(), 2, "duplicate links are not deduped"); + assert_eq!(m.depends_on[0], m.depends_on[1]); + } } diff --git a/crates/ara-viewer/src/replay.rs b/crates/ara-viewer/src/replay.rs index b0c996b..e57db17 100644 --- a/crates/ara-viewer/src/replay.rs +++ b/crates/ara-viewer/src/replay.rs @@ -157,10 +157,20 @@ pub fn install_arrow_key_listener( && let Some(el) = target.dyn_ref::() { let tag = el.tag_name(); - if tag == "INPUT" || tag == "SELECT" { + if tag == "INPUT" || tag == "SELECT" || tag == "TEXTAREA" { return; } } + // #59: skip while a modal panel is open so arrows don't step the + // replay behind the dialog (`.modal-scrim` is only in the DOM + // while a modal is open — see `modal.rs`). + let modal_open = leptos::web_sys::window() + .and_then(|w| w.document()) + .and_then(|d| d.query_selector(".modal-scrim").ok().flatten()) + .is_some(); + if modal_open { + return; + } let dir = match ev.key().as_str() { "ArrowLeft" => Some(Step::Prev), "ArrowRight" => Some(Step::Next), diff --git a/crates/ara-viewer/tests/web.rs b/crates/ara-viewer/tests/web.rs index 4e01945..45a284c 100644 --- a/crates/ara-viewer/tests/web.rs +++ b/crates/ara-viewer/tests/web.rs @@ -687,13 +687,13 @@ fn built_on_and_result_blocks_render_after_evidence() { // Both new blocks are present. let built_on = container - .query_selector("div.built-on-block") + .query_selector(".built-on-block") .unwrap() .expect("built-on-block must render") .dyn_into::() .unwrap(); let result = container - .query_selector("div.result-block") + .query_selector(".result-block") .unwrap() .expect("result-block must render") .dyn_into::() @@ -746,16 +746,13 @@ fn node_without_linkage_renders_neither_block() { assert!( container - .query_selector("div.built-on-block") + .query_selector(".built-on-block") .unwrap() .is_none(), "node with no built_on must NOT render a built-on-block" ); assert!( - container - .query_selector("div.result-block") - .unwrap() - .is_none(), + container.query_selector(".result-block").unwrap().is_none(), "node with no exhibits must NOT render a result-block" ); } @@ -864,10 +861,7 @@ fn empty_exhibit_body_renders_no_body_container() { // The result-block (with its chip) renders... assert!( - container - .query_selector("div.result-block") - .unwrap() - .is_some(), + container.query_selector(".result-block").unwrap().is_some(), "result-block must render for a node with an exhibit" ); // ...but the empty body produces no body container. @@ -880,6 +874,78 @@ fn empty_exhibit_body_renders_no_body_container() { ); } +// ── Collapse-reset fixture: two nodes, each with an evidence note ───────────── +const COLLAPSE_FIXTURE_JSON: &str = r#"{ + "nodes": [ + { + "id": "N01", + "kind": "experiment", + "label": "First experiment", + "source_refs": [], + "evidence_notes": ["note one"], + "fields": { "experiment": {} }, + "pos": { "x": 100.0, "y": 100.0 } + }, + { + "id": "N02", + "kind": "experiment", + "label": "Second experiment", + "source_refs": [], + "evidence_notes": ["note two"], + "fields": { "experiment": {} }, + "pos": { "x": 300.0, "y": 100.0 } + } + ], + "links": [], + "bindings": [], + "claims": [], + "bounds": { "x": 0.0, "y": 0.0, "width": 500.0, "height": 500.0 } +}"#; + +// ── Test: a user-collapsed block re-opens on node switch ────────────────────── + +/// Collapsing a block is native `
` state, not reactive. Since Leptos +/// patches the pane in place across selections (same view shape), the `open` +/// attribute must be re-applied reactively off `selected` — otherwise a block +/// the user collapsed stays collapsed for the next node. Collapse EVIDENCE on +/// N01, switch to N02, assert the block is open again. +#[wasm_bindgen_test] +async fn collapsible_block_reopens_on_node_switch() { + let doc = web_sys::window().unwrap().document().unwrap(); + let container = body_div(&doc); + + let manifest = parse_manifest(COLLAPSE_FIXTURE_JSON).expect("collapse fixture must parse"); + let selected: RwSignal> = + RwSignal::new(Some(ara_core::NodeId::new("N01"))); + let (load_state, _) = signal(LoadState::Loaded(manifest)); + + let _handle = leptos::mount::mount_to(container.clone(), move || { + view! { } + }); + + let details = container + .query_selector("details.evidence-block") + .unwrap() + .expect("evidence block must render for N01"); + assert!(details.get_attribute("open").is_some(), "block starts open"); + + // Simulate the user collapsing the block (native
toggle). + details.remove_attribute("open").unwrap(); + + // Switch node: the pane patches in place, so `open` must be re-applied. + selected.set(Some(ara_core::NodeId::new("N02"))); + leptos::task::tick().await; + + let details = container + .query_selector("details.evidence-block") + .unwrap() + .expect("evidence block must render for N02"); + assert!( + details.get_attribute("open").is_some(), + "collapsed state must not leak across node selection" + ); +} + // ── Test: layout toggle flips the active segment + drives the signal ────────── /// Mounts `LayoutToggle` bound to a `layout` signal. Asserts: diff --git a/docs/hub-parity.md b/docs/hub-parity.md index 86f2c6a..aaceecb 100644 --- a/docs/hub-parity.md +++ b/docs/hub-parity.md @@ -116,10 +116,14 @@ that closes a loop) remain fatal. Viewer" brand when the manifest carries no titled paper. - **Per-node detail blocks**, in the corrected hub order: *(inert REASONING slot)* → **WHAT IT DID** (`result`, relabelled) → **evidence** (notes + - claims) → **BUILT ON** (related-work chips) → **RESULT** (exhibit chips + - linkage) → provenance. BUILT ON and RESULT omit entirely when empty. RESULT - shows *which* exhibits apply (chips + `node→exhibit` linkage), **not** the - rendered figure/table bodies (D4). + claims) → **BUILT ON** (related-work chips) → **DEPENDS ON** (node→node + jump chips, outgoing + incoming; clicking selects the target node) → + **RESULT** (exhibit chips + linkage + `description` captions + rendered + markdown bodies) → provenance. BUILT ON, DEPENDS ON, and RESULT omit + entirely when empty. The evidence/built-on/depends-on/result/sources blocks + are collapsible `
` with right-aligned item counts, matching + the hub's per-block count tags. The detail header carries the mono node id + and an `isolated` pill for nodes in an isolated subtree. - **Shared `Modal`** (E7) — one reusable, accessible dialog: `role="dialog"` + `aria-modal` + `aria-labelledby`, focus moves in on open, a Tab/Shift+Tab focus trap that wraps at both ends, Esc and scrim-click close, focus returns diff --git a/docs/stage-3-viewer.md b/docs/stage-3-viewer.md index a236513..ccdc35f 100644 --- a/docs/stage-3-viewer.md +++ b/docs/stage-3-viewer.md @@ -219,14 +219,19 @@ the scene render, so a Stage-4 manifest swap will not reset it. is a single reactive `viewBox` update (wheel zoom clamped 0.2–5.0, pointer-drag pan). Nodes are `tabindex=0`, `role="button"`, `aria-label = "label, kind"`; Enter/Space selects. -- **Detail (`#detail`)** — header (`label ?? id`, kind chip+badge, `support_level` - pill) → description → per-kind typed fields in canonical order (`Experiment`: - result; `Decision`: choice → rationale → alternatives; `DeadEnd`: `why_failed` - as the primary `.block.reason` lead; `Question`/`Insight`/`Other`: none) → - evidence notes + claims resolved through `bindings` with - supported/refuted/hypothesis status pills → `source_refs` provenance chips. +- **Detail (`#detail`)** — header (mono node id, `label ?? id` title, kind + chip+badge, `support_level` pill, `isolated` pill when the node belongs to an + isolated subtree) → description → per-kind typed fields in canonical order + (`Experiment`: result; `Decision`: choice → rationale → alternatives; `DeadEnd`: + `why_failed` as the primary `.block.reason` lead; `Question`/`Insight`/`Other`: + none) → evidence notes + claims resolved through `bindings` with + supported/refuted/hypothesis status pills → BUILT ON chips → DEPENDS ON jump + chips (outgoing + "depended on by"; clicking a chip selects that node) → RESULT + (exhibit chips, `description` captions, rendered bodies) → `source_refs` + provenance chips. The evidence / built-on / depends-on / result / sources + blocks are collapsible `
` with a right-aligned item count (#58). Empty nodes show "Nothing recorded for this node."; every block is omitted when - its data is absent. Richer blocks (quote/figure/table/diff/glossary/deps/recipe) + its data is absent. Richer blocks (quote/figure/table/diff/glossary/recipe) are skinned but **inert pending `T-REAL-CORPUS`**. No LLM is called at view time. - **Toolbar** — case-insensitive search (label/id/kind/bound-claim text), a type `