diff --git a/.gitattributes b/.gitattributes index fb7210d2e3..2e8898a5f4 100644 --- a/.gitattributes +++ b/.gitattributes @@ -9,6 +9,7 @@ crates/ui/assets/**/*.css text eol=lf crates/ui/tests/golden/** text eol=lf crates/ui-chrome/templates/** text eol=lf crates/ui-chrome/tests/golden/** text eol=lf +crates/hts-ui/templates/** text eol=lf locales/** text eol=lf # The SQL-on-FHIR conformance fixtures are kept as a byte-verbatim mirror of diff --git a/Cargo.lock b/Cargo.lock index b981f0179c..84ab08116f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3838,6 +3838,9 @@ name = "helios-ui-chrome" version = "0.2.1" dependencies = [ "askama", + "form_urlencoded", + "serde_json", + "url", ] [[package]] diff --git a/crates/hts-ui/src/capability.rs b/crates/hts-ui/src/capability.rs index 5e0bdab4e4..bea4abca56 100644 --- a/crates/hts-ui/src/capability.rs +++ b/crates/hts-ui/src/capability.rs @@ -1,22 +1,31 @@ //! Capability & Conformance page. //! -//! **Shape of record (2026-08-27).** This page mirrors HFS's -//! `crates/ui/templates/pages/capability-statement.html`: same name in the -//! sidebar (`nav-capability-conformance`), same icon (`icons/shield.svg`), -//! same route shape (`/ui/hts/capability-statement` beside HFS's -//! `/ui/capability-statement`), and the same stacked `
` -//! blocks in the same order. It was previously called "Diagnostics" and -//! lived at `/hts/diagnostics`; both are kept working by a 308 redirect -//! registered in `crates/hts/src/server.rs`. +//! **Shape of record (2026-09-01, #808).** This page and HFS's +//! `/ui/capability-statement` are no longer two implementations of one +//! document. The parser, the view model, the four summary cards and — since +//! this page's #808 follow-up — the Raw CapabilityStatement fold all live in +//! [`helios_ui_chrome::capability`] / [`helios_ui_chrome::capability_json`]; +//! what stays here is what is genuinely HTS's: //! -//! Six cards: five mirroring HFS one-for-one, plus **Terminology -//! capabilities** — the one thing only a terminology server can declare. +//! • two upstream fetches instead of one loopback self-call, +//! • per-card degradation rather than a single page-level warning, +//! • the **Terminology capabilities** card, which only a terminology +//! server can declare. +//! +//! It was previously called "Diagnostics" and lived at `/hts/diagnostics`; +//! both are kept working by a 308 redirect registered in +//! `crates/hts/src/server.rs`. +//! +//! Six cards: five rendered from the shared code HFS renders, plus +//! **Terminology capabilities**. //! //! Two upstream sources (`/metadata`, `/metadata?mode=terminology`) are -//! fetched and each renders into its own card. A failure on one is isolated -//! to that card, which renders a `

` carrying -//! the existing `hts-degraded-reason-*` sentence; the other cards are -//! unaffected. +//! fetched and each feeds its own cards. A failure on one is isolated to +//! those cards, which render a `

` carrying the +//! existing `hts-degraded-reason-*` sentence; the cards fed by the other +//! source are unaffected. The shared cards take that sentence directly +//! ([`CapabilityCards::notice`]), so the degraded state costs no duplicated +//! card headings. //! //! Three cards that used to live here were removed on 2026-08-27 because //! each duplicated a surface that already served it better: @@ -35,22 +44,47 @@ use askama::Template; use axum::{ Router, - extract::State, + extract::{Query, State}, response::{Html, IntoResponse, Redirect, Response}, routing::get, }; use axum_htmx::HxRequest; +use helios_ui_chrome::capability::{CapabilityCards, DocsVersion}; +use helios_ui_chrome::capability_json::{self, FragmentEndpoint}; +use serde::Deserialize; use std::sync::Arc; use crate::i18n::{I18n, RequestLocale}; -use crate::upstream::{CapabilityView, TerminologyCapabilitiesView}; +use crate::upstream::TerminologyCapabilitiesView; use crate::{Chrome, HtsUiState}; +/// Where the router registers the fragment endpoint — relative to this +/// crate's `/hts` prefix, the same way every other route in [`routes`] is +/// spelled. [`JSON_FRAGMENT_URL`] is the *public* counterpart: what a browser +/// actually requests once [`router`](crate::router) mounts this at `/ui`. +const JSON_FRAGMENT_ROUTE: &str = "/hts/capability-statement/json-fragment"; +/// The public URL the raw fold's `data-fragment-url` and pagination links +/// point at. Must stay `/ui` + [`JSON_FRAGMENT_ROUTE`] in sync with the mount +/// point [`crate::router`] documents. +const JSON_FRAGMENT_URL: &str = "/ui/hts/capability-statement/json-fragment"; +/// The no-JS fallback link: the page itself, requested with the plain-text +/// query flag. HTS carries no filter or version query params to preserve, so +/// (unlike HFS's `capability_raw_url`) this needs no builder. +const PAGE_RAW_URL: &str = "/ui/hts/capability-statement?raw=1"; + +fn root_fragment_url(state: &HtsUiState) -> String { + capability_json::root_fragment_url(FragmentEndpoint { + base_path: JSON_FRAGMENT_URL, + version: state.fhir_version, + }) +} + // ── Routing ───────────────────────────────────────────────────────────── pub(crate) fn routes() -> Router> { Router::new() .route("/hts/capability-statement", get(capability_page)) + .route(JSON_FRAGMENT_ROUTE, get(capability_json_fragment)) // The page shipped as `/ui/hts/diagnostics` before it was renamed to // match HFS. Keep the old path working — it may be bookmarked, and // the docs and e2e specs referenced it. `Redirect::permanent` emits @@ -73,22 +107,45 @@ struct CapabilityPageTemplate<'a> { /// Everything the stacked cards render, gathered in one pass. /// -/// Each source carries a `Some(view)` **or** a `Some(reason)` — never -/// both. `reason` is the `hts-degraded-reason-*` suffix produced by -/// [`crate::upstream::UpstreamError::degraded_reason`], so the card's -/// warning notice reuses catalog strings that already exist in all three -/// locales rather than minting a per-page "unavailable" string. +/// The four shared cards arrive here as finished HTML — rendered in the +/// handler rather than from the page template because [`CapabilityCards`] is +/// fallible and a template cannot decide what half a page should look like. +/// A card fed by a failed fetch is not absent: it holds the shared card's +/// heading over a `notice--warn` carrying the `hts-degraded-reason-*` +/// sentence produced by +/// [`crate::upstream::UpstreamError::degraded_reason`], so the warning reuses +/// catalog strings that already exist in all three locales rather than +/// minting a per-page "unavailable" string. #[derive(Clone, Debug, Default)] struct CapabilityPageView { - capability: Option, - capability_reason: Option<&'static str>, + summary_card: String, + /// `None` when the server declares no system interactions, and when the + /// fetch failed so we cannot know whether it would have. + /// + /// HTS serves `POST /` (batch) but does not advertise it in + /// `rest[].interaction`, so this card is absent today rather than blank. + /// It appears on its own the moment HTS declares them — the UI never + /// invents the list. + interactions_card: Option, + operations_card: String, + resources_card: String, + /// The Raw CapabilityStatement fold, pre-rendered by + /// [`CapabilityCards::raw`] — the same shell HFS renders, lazy-loading + /// its tree from [`JSON_FRAGMENT_URL`] (#808 follow-up to #798). `None` + /// when `/metadata` could not be read — there is no half-document worth + /// folding. + raw_card: Option, terminology: Option, terminology_reason: Option<&'static str>, } // ── View builder ──────────────────────────────────────────────────────── -async fn build_view(state: &HtsUiState) -> CapabilityPageView { +async fn build_view( + state: &HtsUiState, + i18n: &I18n, + raw_requested: bool, +) -> Result { // Sequential, deliberately. Firing the probes with `tokio::join!` opens // simultaneous upstream connections per page load; under the crate's // parallel test harness (several `#[tokio::test]`s, each with its own @@ -97,19 +154,76 @@ async fn build_view(state: &HtsUiState) -> CapabilityPageView { // handler in this crate makes its upstream calls in sequence for the // same reason, and two localhost round-trips are not the page's cost // centre. - let capability = state.upstream.capability_statement().await; + // + // `fhir_version` is the release code the `hts` binary was built for. An + // unrecognised value falls back to R4 — the workspace default and the + // only release a build can be certain to carry — rather than dropping + // every specification link on the page. + let version = DocsVersion::from_code(state.fhir_version).unwrap_or_default(); + let capability = state.upstream.capability_statement(version).await; let terminology = state.upstream.terminology_capabilities_view().await; - let mut view = CapabilityPageView::default(); - match capability { - Ok(v) => view.capability = Some(v), - Err(e) => view.capability_reason = Some(e.degraded_reason()), - } + let statement = capability.as_ref().ok(); + let reason = capability + .as_ref() + .err() + .map(|e| i18n.t(&format!("hts-degraded-reason-{}", e.degraded_reason()))); + let projection = statement.map(|s| s.cards.clone()).unwrap_or_default(); + + // HTS lists exactly three resource types, so HFS's `filter-rail__search` + // form is deliberately not taken: a search box over three rows is noise, + // not parity. Nor are HFS's `Includes` / `Revincludes` columns — HTS + // emits no `searchInclude` / `searchRevInclude`, and a column of zeroes + // would read as a measurement rather than an absence. + let cards = CapabilityCards::new(i18n, &projection) + .notice(reason.as_deref()) + .operations_empty_key(Some("hts-capability-operations-empty")) + .resources_empty_key("hts-capability-rest-empty"); + + // Unbounded and only computed on explicit request — same trade HFS makes + // for its own `?raw=1` no-JS fallback. The default path never serializes + // the whole document; it hands the fold a fragment URL instead. + let raw_text = if raw_requested { + statement + .map(|s| serde_json::to_string_pretty(&s.document).unwrap_or_default()) + .unwrap_or_default() + } else { + String::new() + }; + let raw_card = statement + .map(|_| { + cards.raw( + raw_requested, + &raw_text, + PAGE_RAW_URL, + &root_fragment_url(state), + ) + }) + .transpose()?; + + let mut view = CapabilityPageView { + summary_card: cards.summary()?, + interactions_card: (statement.is_some() && !projection.interactions.is_empty()) + .then(|| cards.interactions()) + .transpose()?, + operations_card: cards.operations()?, + resources_card: cards.resources()?, + raw_card, + ..Default::default() + }; match terminology { Ok(v) => view.terminology = Some(v), Err(e) => view.terminology_reason = Some(e.degraded_reason()), } - view + Ok(view) +} + +#[derive(Deserialize, Default)] +struct CapabilityQuery { + /// A string flag because the public query spelling is `raw=1`, not a + /// Serde boolean literal such as `raw=true` — mirrors HFS's own + /// `CapabilityQuery`. + raw: Option, } // ── GET /hts/capability-statement ─────────────────────────────────────── @@ -118,25 +232,103 @@ async fn capability_page( State(state): State>, // Taking the extractor is what arms `axum_htmx::AutoVaryLayer`, so the // response carries `Vary: HX-Request`. The page body is identical in - // both modes (HFS's capability page has no fragment endpoint either). + // both modes; only the raw fold's own fragment endpoint is htmx-driven. HxRequest(_is_htmx): HxRequest, locale: RequestLocale, + Query(query): Query, ) -> Response { + let raw_requested = query.raw.as_deref() == Some("1"); + let i18n = I18n::new(locale); let chrome = Chrome { - i18n: I18n::new(locale), + i18n, active_page: "capability-statement", fhir_version: state.fhir_version, version: state.version, }; + // Both legs are `askama::Error`: rendering the shared cards and rendering + // the page around them fail the same way and take the same 500 path. render( - CapabilityPageTemplate { - chrome, - view: build_view(&state).await, - } - .render(), + build_view(&state, &i18n, raw_requested) + .await + .and_then(|view| CapabilityPageTemplate { chrome, view }.render()), ) } +#[derive(Deserialize, Default)] +struct CapabilityJsonQuery { + #[serde(default)] + path: String, + #[serde(default)] + offset: usize, + limit: Option, +} + +// ── GET /hts/capability-statement/json-fragment ───────────────────────── + +/// Mirrors HFS's `capability_json_fragment` handler: re-fetch, plan one +/// bounded level (or the whole subtree when it is small), render. +/// Re-fetching on every fragment click is the same cost model HFS's own +/// loopback self-call already pays per request — see +/// [`crate::upstream::UpstreamClient::capability_statement`]. +async fn capability_json_fragment( + State(state): State>, + locale: RequestLocale, + Query(query): Query, +) -> Response { + let version = DocsVersion::from_code(state.fhir_version).unwrap_or_default(); + let document = match state.upstream.capability_statement(version).await { + Ok(statement) => statement.document, + Err(error) => { + tracing::warn!("CapabilityStatement fragment fetch failed: {error}"); + return ( + axum::http::StatusCode::SERVICE_UNAVAILABLE, + "CapabilityStatement is unavailable", + ) + .into_response(); + } + }; + let limit = query.limit.unwrap_or(capability_json::DEFAULT_PAGE_SIZE); + let i18n = I18n::new(locale); + let endpoint = FragmentEndpoint { + base_path: JSON_FRAGMENT_URL, + version: state.fhir_version, + }; + match capability_json::plan(&document, &query.path, query.offset, limit, endpoint) { + Ok(capability_json::View::Full(json_lines)) => bounded_fragment( + capability_json::render_full(&i18n, json_lines, query.path.is_empty()), + ), + Ok(capability_json::View::Outline(outline)) => { + bounded_fragment(capability_json::render_outline(&i18n, &outline)) + } + Err(capability_json::Error::NotFound) => { + (axum::http::StatusCode::NOT_FOUND, "JSON path not found").into_response() + } + Err(capability_json::Error::InvalidPointer | capability_json::Error::InvalidPage) => ( + axum::http::StatusCode::BAD_REQUEST, + "Invalid JSON fragment request", + ) + .into_response(), + } +} + +fn bounded_fragment(rendered: Result) -> Response { + match rendered { + Ok(html) if html.len() <= capability_json::MAX_FRAGMENT_HTML_BYTES => { + Html(html).into_response() + } + Ok(_) => ( + axum::http::StatusCode::PAYLOAD_TOO_LARGE, + "CapabilityStatement fragment exceeds the rendering budget", + ) + .into_response(), + Err(error) => ( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + format!("template render error: {error}"), + ) + .into_response(), + } +} + fn render(rendered: Result) -> Response { match rendered { Ok(html) => Html(html).into_response(), diff --git a/crates/hts-ui/src/lib.rs b/crates/hts-ui/src/lib.rs index 91605b1b17..2883bbb446 100644 --- a/crates/hts-ui/src/lib.rs +++ b/crates/hts-ui/src/lib.rs @@ -88,9 +88,7 @@ pub use upstream::{ }; // Capability & Conformance page projections. Appended below Slice F's // block to avoid touching the alphabetized list. -pub use upstream::{ - CapabilityOperation, CapabilityRestResource, CapabilityView, TerminologyCapabilitiesView, -}; +pub use upstream::{CapabilityView, TerminologyCapabilitiesView}; // Slice H additions (the concept information plane, Direction B). Appended // below rather than folded into the alphabetized list above so the two // concurrent slices do not collide on the same lines. diff --git a/crates/hts-ui/src/upstream.rs b/crates/hts-ui/src/upstream.rs index beac18fba6..43d2e000e5 100644 --- a/crates/hts-ui/src/upstream.rs +++ b/crates/hts-ui/src/upstream.rs @@ -31,6 +31,9 @@ //! body is also echoed verbatim into the "Raw response" workbench panel per //! §7.3, so nothing is discarded. +use helios_ui_chrome::capability::{ + CoreResourceCatalog, DocsVersion, build_view as build_capability_view, +}; use serde::Deserialize; use serde_json::{Value, json}; use std::time::Duration; @@ -4045,88 +4048,49 @@ fn parse_closure_edges(resource: &Value) -> Vec { // dashboard chart; the capability page folds the raw *CapabilityStatement* // instead, mirroring HFS. -/// Projection of a FHIR `CapabilityStatement` for the Capability & -/// Conformance page. +/// What the Capability & Conformance page needs from `GET /metadata`. /// -/// Field-for-field this mirrors HFS's `crates/ui/src/capability.rs` -/// (`CapabilitySummary` + `OperationRow` + `ResourceRow`) so the two pages -/// can share templates and Fluent keys. It is a documentation surface, not -/// a machine consumer, so unknown fields are silently dropped. +/// The projection itself is [`helios_ui_chrome::capability::CapabilityView`] +/// (#808) — the same one HFS renders, produced by the same parser, so the two +/// pages cannot disagree about what a statement says and a fix to either +/// lands on both. +/// +/// [`Self::document`] keeps the fetched body around rather than a +/// pre-rendered string: the Raw CapabilityStatement fold is now the same +/// bounded, paginated JSON-fragment engine HFS built for #798 +/// ([`helios_ui_chrome::capability_json`]), which pages through a statement +/// of any size — HTS's grows with the data, one +/// `capabilitystatement-supported-system` extension per loaded code system, +/// ~1,975 of them and 422 KB against the bundled seed set — rather than +/// requiring a byte cap up front. #[derive(Clone, Debug, Default)] pub struct CapabilityView { - pub url: String, - pub version: String, - pub name: String, - pub title: String, - pub status: String, - pub date: String, - /// `implementation.description` — HFS shows this as the first summary row. - pub description: String, - pub fhir_version: String, - pub kind: String, - /// `format[]` — the wire formats the server accepts. - pub formats: Vec, - /// System-level `rest[].interaction[].code`. - /// - /// **Empty against HTS today**: HTS serves `POST /` (batch) but does not - /// advertise it in `rest[].interaction`. The template therefore renders - /// this card only when the list is non-empty, so it appears on its own - /// if HTS ever declares them — rather than showing a permanently blank - /// card or, worse, a fabricated list. - pub interactions: Vec, - /// System-level `rest[].operation[]`. - pub operations: Vec, - /// Flattened `rest[].resource[]` summary — resource type + the list of - /// advertised interaction verbs (`read`, `search-type`, ...). Empty - /// when the upstream response does not carry a `rest[]` section. - pub resources: Vec, - /// Pretty-printed statement for the foldable raw block, mirroring the - /// `raw` field HFS keeps on its `CapabilityPage`. Retained from the - /// response already in hand — no second fetch. - /// - /// **Capped at [`RAW_STATEMENT_BYTE_CAP`].** HFS can inline its whole - /// statement because that document is a fixed size; HTS's grows with - /// the data, because it carries one - /// `capabilitystatement-supported-system` extension per loaded code - /// system. Against the bundled seed set that is ~1,975 extensions and a - /// 422 KB block — 95% of the page — on every load, `

` or not. - /// The cap is never silent: [`Self::raw_truncated`] drives a note that - /// states both sizes and links to `/metadata` for the complete document. - pub raw: String, - /// Whether [`Self::raw`] was cut short by the cap. - pub raw_truncated: bool, - /// Full pretty-printed length in bytes, so the note can state what was - /// withheld rather than just admitting that something was. - pub raw_full_bytes: usize, + /// The shared projection, fed straight to + /// [`helios_ui_chrome::capability::CapabilityCards`]. + pub cards: helios_ui_chrome::capability::CapabilityView, + /// The fetched body, unmodified. Feeds the JSON-fragment endpoint's + /// `capability_json::plan` and the `?raw=1` no-JS fallback's full + /// pretty-print — both read it fresh from a re-fetch, same as HFS's own + /// loopback self-call per request. + pub document: Value, } -/// Byte budget for the inlined raw statement (see [`CapabilityView::raw`]). +/// The resource types a Helios terminology server can advertise. /// -/// Sized to hold a whole statement from a server whose `/metadata` does not -/// scale with its content, so the cap only ever engages on a seed-heavy -/// terminology server — exactly the case where inlining it would be wrong. -pub const RAW_STATEMENT_BYTE_CAP: usize = 16 * 1024; +/// HTS's `/metadata` declares exactly CodeSystem, ValueSet and ConceptMap +/// (`crates/hts/src/operations/metadata.rs`), and all three are core +/// resources in every release from R4 to R6 — so answering the shared +/// projection's catalog question needs no schema pack, which is what keeps +/// this crate off the validator's embedded core packs. A resource type HTS +/// grows later is simply not linked into the specification until it is added +/// here; an unlinked row is the safe failure, a link to a page that does not +/// exist is not. +struct TerminologyResources; -/// One row in [`CapabilityView::operations`]. -/// -/// HFS's equivalent (`OperationRow`) also carries a `definition_path` for -/// operations whose canonical resolves to a same-server -/// `/OperationDefinition/{id}`. Every operation HTS advertises points at an -/// `hl7.org` canonical instead, so there is nothing to link and the field -/// is omitted rather than always-empty. -#[derive(Clone, Debug, Default)] -pub struct CapabilityOperation { - pub name: String, - pub definition: String, -} - -/// One row in [`CapabilityView::resources`]. -#[derive(Clone, Debug, Default)] -pub struct CapabilityRestResource { - pub resource_type: String, - pub interactions: Vec, - /// `searchParam[].len()` — HFS shows the same count as a numeric column. - pub search_param_count: usize, +impl CoreResourceCatalog for TerminologyResources { + fn is_core_resource(&self, resource_type: &str) -> bool { + matches!(resource_type, "CodeSystem" | "ValueSet" | "ConceptMap") + } } /// Projection of the `TerminologyCapabilities` fields the Diagnostics @@ -4168,7 +4132,16 @@ pub struct TerminologyCapabilitiesView { impl UpstreamClient { /// `GET /metadata` — the FHIR `CapabilityStatement`. Feeds the /// Capability & Conformance page. - pub async fn capability_statement(&self) -> Result { + /// + /// `version` is the release this binary was built for, and decides which + /// FHIR specification the rendered links point at. It is taken from the + /// caller rather than read off the statement's own `fhirVersion` so the + /// page links at the spec the *server* implements even when an upstream + /// answers with something else. + pub async fn capability_statement( + &self, + version: DocsVersion, + ) -> Result { let url = format!("{}/metadata", self.base_url); let response = self .client @@ -4185,12 +4158,13 @@ impl UpstreamClient { status: status.as_u16(), }); } - let body: Value = response.json().await.map_err(|e| UpstreamError::Decode { + let document: Value = response.json().await.map_err(|e| UpstreamError::Decode { op: "metadata", url: url.clone(), message: e.to_string(), })?; - Ok(parse_capability_statement(&body)) + let cards = build_capability_view(&document, version, &TerminologyResources); + Ok(CapabilityView { cards, document }) } /// `GET /metadata?mode=terminology` — the FHIR @@ -4254,137 +4228,6 @@ impl UpstreamClient { } } -fn parse_capability_statement(body: &Value) -> CapabilityView { - let get_str = |key: &str| -> String { - body.get(key) - .and_then(|v| v.as_str()) - .unwrap_or_default() - .to_owned() - }; - let resources = body - .get("rest") - .and_then(|v| v.as_array()) - .map(|arr| { - arr.iter() - .flat_map(|rest| { - rest.get("resource") - .and_then(|v| v.as_array()) - .cloned() - .unwrap_or_default() - }) - .map(|resource| CapabilityRestResource { - resource_type: resource - .get("type") - .and_then(|v| v.as_str()) - .unwrap_or_default() - .to_owned(), - interactions: resource - .get("interaction") - .and_then(|v| v.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|i| { - i.get("code").and_then(|c| c.as_str()).map(|s| s.to_owned()) - }) - .collect() - }) - .unwrap_or_default(), - search_param_count: resource - .get("searchParam") - .and_then(|v| v.as_array()) - .map(|a| a.len()) - .unwrap_or(0), - }) - .collect() - }) - .unwrap_or_default(); - - // System-level `rest[].interaction[]` and `rest[].operation[]`. Both are - // flattened across every `rest[]` entry for the same reason `resources` - // is: a server may legitimately publish more than one mode. - let rest = |key: &str| -> Vec { - body.get("rest") - .and_then(|v| v.as_array()) - .map(|arr| { - arr.iter() - .flat_map(|r| { - r.get(key) - .and_then(|v| v.as_array()) - .cloned() - .unwrap_or_default() - }) - .collect() - }) - .unwrap_or_default() - }; - let interactions = rest("interaction") - .iter() - .filter_map(|i| i.get("code").and_then(|c| c.as_str()).map(str::to_owned)) - .collect(); - let operations = rest("operation") - .iter() - .map(|o| CapabilityOperation { - name: o - .get("name") - .and_then(|v| v.as_str()) - .unwrap_or_default() - .to_owned(), - definition: o - .get("definition") - .and_then(|v| v.as_str()) - .unwrap_or_default() - .to_owned(), - }) - .collect(); - - // Pretty-printed so the foldable block is readable, then capped — see - // `CapabilityView::raw`. Cut on a char boundary; `floor_char_boundary` - // is still unstable, so walk back to one. - let raw_full = serde_json::to_string_pretty(body).unwrap_or_default(); - let raw_full_bytes = raw_full.len(); - let raw = if raw_full_bytes <= RAW_STATEMENT_BYTE_CAP { - raw_full - } else { - let mut end = RAW_STATEMENT_BYTE_CAP; - while end > 0 && !raw_full.is_char_boundary(end) { - end -= 1; - } - raw_full[..end].to_owned() - }; - - CapabilityView { - url: get_str("url"), - version: get_str("version"), - name: get_str("name"), - title: get_str("title"), - status: get_str("status"), - date: get_str("date"), - description: body - .get("implementation") - .and_then(|i| i.get("description")) - .and_then(|v| v.as_str()) - .unwrap_or_default() - .to_owned(), - fhir_version: get_str("fhirVersion"), - kind: get_str("kind"), - formats: body - .get("format") - .and_then(|v| v.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|f| f.as_str().map(str::to_owned)) - .collect() - }) - .unwrap_or_default(), - interactions, - operations, - resources, - raw, - raw_truncated: raw_full_bytes > RAW_STATEMENT_BYTE_CAP, - raw_full_bytes, - } -} - fn parse_terminology_capabilities_view(body: &Value) -> TerminologyCapabilitiesView { // `expansion.` — `None` when the block or the flag is absent, so // the card can say "not declared" rather than "false". diff --git a/crates/hts-ui/templates/layouts/base.html b/crates/hts-ui/templates/layouts/base.html index 56dcd785f3..f9f1f3310d 100644 --- a/crates/hts-ui/templates/layouts/base.html +++ b/crates/hts-ui/templates/layouts/base.html @@ -143,5 +143,14 @@ {% block content %}{% endblock %} + {#- + Fold-arrow wiring for the shared JSON tree partial + (`crates/ui-chrome/templates/partials/json-view.html`). Loaded + unconditionally and last, exactly where HFS puts it in its own + `crates/ui/templates/layouts/base.html` — the fragments htmx swaps in + arrive after page load, and the script binds by delegation, so a global + include is both correct and the parity-preserving placement. + -#} + diff --git a/crates/hts-ui/templates/pages/capability-statement.html b/crates/hts-ui/templates/pages/capability-statement.html index 9976897638..82ee50ff27 100644 --- a/crates/hts-ui/templates/pages/capability-statement.html +++ b/crates/hts-ui/templates/pages/capability-statement.html @@ -1,32 +1,42 @@ {#- - Capability & Conformance — the HTS mirror of HFS's - `crates/ui/templates/pages/capability-statement.html`. - - Same shell (no `app-shell` body class, no `content--app`, no - `filter-layout`), same `.page-head`, same stacked `
` - blocks in the same order, and the same Fluent keys (`cap-*`) wherever the - semantics are identical — the catalog is shared between both crates, so - reusing the keys is what keeps the two pages from drifting apart. + Capability & Conformance. + + Five of the six cards below are not written here at all: they are rendered + by `helios_ui_chrome::capability::CapabilityCards` and are the very same + markup HFS serves at `/ui/capability-statement` (#808), Raw + CapabilityStatement fold included as of this page's #808 follow-up. Do NOT + inline that markup back into this file — one document rendered by two + templates is exactly what #808 removed, after the copies had already + drifted (HFS had version-correct spec links and colour-coded interaction + chips; this page had neither, and byte-capped its raw fold where HFS + paginated it). + + Card content changes therefore belong in + `crates/ui-chrome/templates/partials/capability-*.html`, where they land on + both products at once. Cards land as *direct* children of `
`: the shared rule `.content > .card ~ .card { margin-top: 20px }` is a direct-child selector, so any wrapper element here would collapse the vertical rhythm. Card order — HFS's five, then the one only a terminology server has: - 1. Server Summary (HFS parity) - 2. System Interactions (HFS parity; hidden while HTS declares none) - 3. Operations (HFS parity) - 4. Per-Resource Capabilities (HFS parity) - 5. Terminology capabilities (HTS-only) - 6. Raw CapabilityStatement (HFS parity) - - Degraded state is per-card, not per-page: one failed upstream fetch shows - its own `notice--warn` and leaves the other cards intact. + 1. Server Summary (shared) + 2. System Interactions (shared; hidden while HTS declares none) + 3. Operations (shared) + 4. Per-Resource Capabilities (shared) + 5. Terminology capabilities (HTS-only, below) + 6. Raw CapabilityStatement (shared) + + Degraded state is per-card, not per-page: a failed `/metadata` fetch is + passed to the shared cards as a notice, so each renders its own heading + over a `notice--warn` and the terminology card is untouched. The raw fold + is the one exception — a failed fetch has no document to fold, so + `view.raw_card` is `None` and the section is absent rather than degraded. Every class used here has a rule in `crates/ui/assets/app.css` (shared verbatim with HFS): page-head, page-head__title, page-head__lede, card, - table-card, card-head, detail__field, detail__code, table-wrap, - data-table, data-table__empty, col-num, tag, url, notice, notice--warn. + card-head, detail__field, detail__code, tag, field__hint, notice, + notice--warn. -#} {% extends "layouts/base.html" %} @@ -38,128 +48,22 @@

{{ chrome.i18n.t("cap-title") }}

{{ chrome.i18n.t("hts-capability-lede") }}

-{#- 1. Server Summary — HFS's field set, in HFS's order. -#} -
-
-

{{ chrome.i18n.t("cap-summary-heading") }}

-
- {% if let Some(reason) = view.capability_reason %} -

{{ chrome.i18n.t(format!("hts-degraded-reason-{}", reason).as_str()) }}

- {% else if let Some(cap) = view.capability %} -
{{ chrome.i18n.t("cap-summary-description") }}
{% if !cap.description.is_empty() %}{{ cap.description }}{% else %}—{% endif %}
-
{{ chrome.i18n.t("cap-summary-url") }}{% if !cap.url.is_empty() %}{{ cap.url }}{% else %}
{% endif %}
-
{{ chrome.i18n.t("cap-summary-fhir-version") }}
{% if !cap.fhir_version.is_empty() %}{{ cap.fhir_version }}{% else %}—{% endif %}
-
{{ chrome.i18n.t("cap-summary-status") }}
{% if !cap.status.is_empty() %}{{ cap.status }}{% else %}—{% endif %}
-
{{ chrome.i18n.t("cap-summary-kind") }}
{% if !cap.kind.is_empty() %}{{ cap.kind }}{% else %}—{% endif %}
-
{{ chrome.i18n.t("cap-summary-date") }}
{% if !cap.date.is_empty() %}{{ cap.date }}{% else %}—{% endif %}
-
{{ chrome.i18n.t("cap-summary-formats") }}
{% if !cap.formats.is_empty() %}{{ cap.formats.join(", ") }}{% else %}—{% endif %}
- {% endif %} -
- -{#- - 2. System Interactions. Rendered only when the server declares some. - - HTS serves `POST /` (batch) but does not advertise it in - `rest[].interaction`, so this card is absent today rather than blank. It - appears on its own the moment HTS declares them — the UI never invents - the list. --#} -{% if let Some(cap) = view.capability %}{% if !cap.interactions.is_empty() %} -
-
-

{{ chrome.i18n.t("cap-interactions-heading") }}

-
-

- {% for i in cap.interactions %} - {{ i }} - {% endfor %} -

-
-{% endif %}{% endif %} +{{ view.summary_card|safe }} -{#- 3. Operations — the system-level `rest[].operation[]`. -#} -
-
-

{{ chrome.i18n.t("cap-operations-heading") }}

-
- {% if let Some(reason) = view.capability_reason %} -

{{ chrome.i18n.t(format!("hts-degraded-reason-{}", reason).as_str()) }}

- {% else if let Some(cap) = view.capability %} -
- - - - - - - - - {% for op in cap.operations %} - - - - - {% endfor %} - {% if cap.operations.is_empty() %} - - {% endif %} - -
{{ chrome.i18n.t("cap-col-operation") }}{{ chrome.i18n.t("cap-col-definition") }}
${{ op.name }}{{ op.definition }}
{{ chrome.i18n.t("hts-capability-operations-empty") }}
-
- {% endif %} -
+{% if let Some(card) = view.interactions_card %} +{{ card|safe }} +{% endif %} -{#- - 4. Per-Resource Capabilities. +{{ view.operations_card|safe }} - HFS pairs this table with a `filter-rail__search` GET form because it - lists ~150 resource types. HTS lists exactly three (CodeSystem, ValueSet, - ConceptMap), so the filter is deliberately not ported — a search box over - three rows is noise, not parity. - - HFS's `Includes` / `Revincludes` columns are likewise absent: HTS emits no - `searchInclude` / `searchRevInclude`, and a column of zeroes would read as - a measurement rather than an absence. --#} -
-
-

{{ chrome.i18n.t("cap-resources-heading") }}

-
- {% if let Some(reason) = view.capability_reason %} -

{{ chrome.i18n.t(format!("hts-degraded-reason-{}", reason).as_str()) }}

- {% else if let Some(cap) = view.capability %} -
- - - - - - - - - - {% for r in cap.resources %} - - - - - - {% endfor %} - {% if cap.resources.is_empty() %} - - {% endif %} - -
{{ chrome.i18n.t("cap-col-type") }}{{ chrome.i18n.t("cap-col-interactions") }}{{ chrome.i18n.t("cap-col-search-params") }}
{{ r.resource_type }}{% for i in r.interactions %}{{ i }} {% endfor %}{{ r.search_param_count }}
{{ chrome.i18n.t("hts-capability-rest-empty") }}
-
- {% endif %} -
+{{ view.resources_card|safe }} {#- 5. Terminology capabilities — the one card HFS cannot have. Deliberately carries **no identity block**. `url`, `version`, `name`, `title` and `status` on TerminologyCapabilities are byte-identical to the - CapabilityStatement rendered above (and HTS emits no `url` at all), so + CapabilityStatement rendered above it (and HTS emits no `url` at all), so repeating them was four duplicated rows and one permanent em-dash. Flags are tri-state: a server that omits a block renders `—` rather than @@ -172,46 +76,50 @@

{{ chrome.i18n.t("hts-capability-terminology-heading") }}

{% if let Some(reason) = view.terminology_reason %}

{{ chrome.i18n.t(format!("hts-degraded-reason-{}", reason).as_str()) }}

{% else if let Some(tc) = view.terminology %} -
{{ chrome.i18n.t("hts-capability-expansion-hierarchical") }}
{%- match tc.expansion_hierarchical -%}{%- when Some(b) -%}{% if b %}{{ chrome.i18n.t("hts-capability-flag-true") }}{% else %}{{ chrome.i18n.t("hts-capability-flag-false") }}{% endif %}{%- when None -%}—{%- endmatch -%}
-
{{ chrome.i18n.t("hts-capability-expansion-paging") }}
{%- match tc.expansion_paging -%}{%- when Some(b) -%}{% if b %}{{ chrome.i18n.t("hts-capability-flag-true") }}{% else %}{{ chrome.i18n.t("hts-capability-flag-false") }}{% endif %}{%- when None -%}—{%- endmatch -%}
-
{{ chrome.i18n.t("hts-capability-expansion-incomplete") }}
{%- match tc.expansion_incomplete -%}{%- when Some(b) -%}{% if b %}{{ chrome.i18n.t("hts-capability-flag-true") }}{% else %}{{ chrome.i18n.t("hts-capability-flag-false") }}{% endif %}{%- when None -%}—{%- endmatch -%}
-
{{ chrome.i18n.t("hts-capability-validate-code-translations") }}
{%- match tc.validate_code_translations -%}{%- when Some(b) -%}{% if b %}{{ chrome.i18n.t("hts-capability-flag-true") }}{% else %}{{ chrome.i18n.t("hts-capability-flag-false") }}{% endif %}{%- when None -%}—{%- endmatch -%}
-
{{ chrome.i18n.t("hts-capability-translation-needs-map") }}
{%- match tc.translation_needs_map -%}{%- when Some(b) -%}{% if b %}{{ chrome.i18n.t("hts-capability-flag-true") }}{% else %}{{ chrome.i18n.t("hts-capability-flag-false") }}{% endif %}{%- when None -%}—{%- endmatch -%}
-
{{ chrome.i18n.t("hts-capability-closure") }}
{% if tc.supports_closure %}{{ chrome.i18n.t("hts-capability-flag-true") }}{% else %}{{ chrome.i18n.t("hts-capability-flag-false") }}{% endif %}
-
{{ chrome.i18n.t("hts-capability-code-systems-declared") }}
- {% if !tc.expansion_parameters.is_empty() %} -
- {{ chrome.i18n.t("hts-capability-expansion-parameters") }} -
{% for p in tc.expansion_parameters %}{{ p }} {% endfor %}
+
+
+
{{ chrome.i18n.t("hts-capability-expansion-hierarchical") }}
{%- match tc.expansion_hierarchical -%}{%- when Some(b) -%}{% if b %}{{ chrome.i18n.t("hts-capability-flag-true") }}{% else %}{{ chrome.i18n.t("hts-capability-flag-false") }}{% endif %}{%- when None -%}—{%- endmatch -%}
+
{{ chrome.i18n.t("hts-capability-expansion-paging") }}
{%- match tc.expansion_paging -%}{%- when Some(b) -%}{% if b %}{{ chrome.i18n.t("hts-capability-flag-true") }}{% else %}{{ chrome.i18n.t("hts-capability-flag-false") }}{% endif %}{%- when None -%}—{%- endmatch -%}
+
{{ chrome.i18n.t("hts-capability-expansion-incomplete") }}
{%- match tc.expansion_incomplete -%}{%- when Some(b) -%}{% if b %}{{ chrome.i18n.t("hts-capability-flag-true") }}{% else %}{{ chrome.i18n.t("hts-capability-flag-false") }}{% endif %}{%- when None -%}—{%- endmatch -%}
+
{{ chrome.i18n.t("hts-capability-validate-code-translations") }}
{%- match tc.validate_code_translations -%}{%- when Some(b) -%}{% if b %}{{ chrome.i18n.t("hts-capability-flag-true") }}{% else %}{{ chrome.i18n.t("hts-capability-flag-false") }}{% endif %}{%- when None -%}—{%- endmatch -%}
+
{{ chrome.i18n.t("hts-capability-translation-needs-map") }}
{%- match tc.translation_needs_map -%}{%- when Some(b) -%}{% if b %}{{ chrome.i18n.t("hts-capability-flag-true") }}{% else %}{{ chrome.i18n.t("hts-capability-flag-false") }}{% endif %}{%- when None -%}—{%- endmatch -%}
+
{{ chrome.i18n.t("hts-capability-closure") }}
{% if tc.supports_closure %}{{ chrome.i18n.t("hts-capability-flag-true") }}{% else %}{{ chrome.i18n.t("hts-capability-flag-false") }}{% endif %}
+
{{ chrome.i18n.t("hts-capability-code-systems-declared") }}
+ {% if !tc.expansion_parameters.is_empty() %} +
+ {{ chrome.i18n.t("hts-capability-expansion-parameters") }} +
{% for p in tc.expansion_parameters %}{{ p }} {% endfor %}
+
+ {% endif %} +
{% endif %} - {% endif %}
{#- - 6. Raw CapabilityStatement — HFS's foldable block, same primitive. - - Capped, unlike HFS's. HFS can inline its whole statement because that - document is a fixed size; HTS's carries one - `capabilitystatement-supported-system` extension per loaded code system, - so against the bundled seeds it is ~422 KB — 95% of the page — shipped on - every load whether or not the `
` is ever opened. - - The cap is never silent: when it engages, the note states both sizes and - links to `/metadata` for the complete document. + 6. Raw CapabilityStatement — the same htmx-lazy, paginated fold HFS renders + (#798, shared in #808's follow-up). Its own fragment endpoint + (`/ui/hts/capability-statement/json-fragment`) re-fetches `/metadata` + against this server's own upstream, so a statement that grows with the + loaded code systems — one `capabilitystatement-supported-system` extension + per system, ~422 KB against the bundled seeds — pages through the same + bounded engine rather than needing its own byte cap. -#} -{% if let Some(cap) = view.capability %} -
-
- {{ chrome.i18n.t("cap-raw-toggle") }} -
{{ cap.raw }}
- {% if cap.raw_truncated %} -

- {{ chrome.i18n.t_arg2("hts-capability-raw-truncated", "shown", cap.raw.len().to_string(), "total", cap.raw_full_bytes.to_string()) }} - {{ chrome.i18n.t("hts-capability-raw-full") }} -

- {% endif %} -
-
+{% if let Some(raw_card) = view.raw_card %} +{{ raw_card|safe }} {% endif %} +{#- + The fold's auto-loader, exactly as HFS wires it on its own copy of this + page (`crates/ui/templates/pages/capability-statement.html`): it listens + for the native `toggle` event on `details[data-capability-json-node]` and + fires the htmx GET against `data-fragment-url`, so opening the fold shows + the tree instead of the server-rendered "Load JSON" fallback. Without this + include the shared card still renders — it just degrades silently, with no + compile-time signal; see the regression tests in `tests/chrome_parity.rs`. + + Kept outside the `{% if %}` above to mirror HFS, whose tag likewise sits + after its own conditional. The per-node arrow toggles come from + `json-view.js`, loaded globally by `layouts/base.html`. +-#} + {% endblock %} diff --git a/crates/hts-ui/tests/capability.rs b/crates/hts-ui/tests/capability.rs index ffacc8f8dd..8fdc90dcfc 100644 --- a/crates/hts-ui/tests/capability.rs +++ b/crates/hts-ui/tests/capability.rs @@ -4,11 +4,14 @@ //! `/__mock_ready` before firing so the mock's TCP listener has finished //! accepting on Windows. //! -//! **Shape of record (2026-08-27).** The page mirrors HFS's -//! `crates/ui/templates/pages/capability-statement.html`: same sidebar -//! label and icon, same route shape, and the same stacked -//! `
` blocks. It was previously "Diagnostics" at -//! `/ui/hts/diagnostics`; that path now 308s here. +//! **Shape of record (2026-09-01, #808).** Four of the six cards are no +//! longer this crate's markup at all: they are rendered by +//! `helios_ui_chrome::capability::CapabilityCards`, the same code HFS's +//! `/ui/capability-statement` renders. These tests therefore assert the +//! *page* — which cards appear, in what order, fed by which fetch, degrading +//! how — and leave the cards' internals to the shared crate's own unit tests. +//! The page was previously "Diagnostics" at `/ui/hts/diagnostics`; that path +//! now 308s here. //! //! The tests exercise: //! @@ -24,8 +27,11 @@ //! moved to Home, and the round-trips went with them. //! 7. The old `/ui/hts/diagnostics` path still resolves, as a 308. //! 8. A 5xx on one source degrades only its own card. -//! 9. (sync) Every CSS class the template names has a real rule in the -//! shared `crates/ui/assets/app.css` — the page adds no CSS. +//! 9. The shared cards arrive with HFS's spec links and colour-coded +//! interaction chips — the two improvements this page did not have while +//! it kept its own copy of the markup. +//! 10. (sync) Every CSS class the page names — its own *and* the shared +//! cards' — has a real rule in the shared `crates/ui/assets/app.css`. use axum::{ Router, @@ -366,6 +372,27 @@ async fn capability_page_mirrors_hfs_and_declares_terminology_capabilities() { "CodeSystem advertises 5 search params and the count column should say so", ); + // ── #808: the shared cards bring HFS's links and colour coding ────── + // This page rendered plain text here while it kept its own copy of the + // markup. The links follow the release the binary was built for, so an + // R4 build never sends the operator at the current-release page (#797). + assert!( + html.contains( + r#"CodeSystem"# + ), + "resource types should link into the release's own specification", + ); + assert!( + html.contains(r#"read"#), + "per-resource interaction verbs should carry HFS's semantic classes", + ); + assert!( + html.contains( + r#"") && html.contains(r#"
"#),
-        "the raw statement should fold into HFS's `
` + `
`",
+        html.contains(r#"id="capability-json-fold""#),
+        "the raw fold should use HFS's shared shell",
     );
-    // Askama emits numeric entities (`"`), so assert on the payload
-    // rather than on a particular quoting of it.
-    let folded = html
-        .split(r#"
"#)
-        .nth(1)
-        .and_then(|s| s.split("
").next()) - .expect("the raw block renders"); assert!( - folded.contains("resourceType") && folded.contains("CapabilityStatement"), - "the folded block should hold the statement; got: {}", - &folded[..folded.len().min(80)], + html.contains(r#"data-fragment-url="/ui/hts/capability-statement/json-fragment"#), + "the fold should lazy-load from HTS's own fragment endpoint, not HFS's", ); assert!( - folded.contains("\n"), - "the folded statement should be pretty-printed, not one compact line", + !html.contains(r#"
"#),
+        "the default view must not inline the statement any more",
+    );
+
+    // The fragment endpoint itself serves the statement, highlighted.
+    let fragment = app
+        .clone()
+        .oneshot(
+            axum::http::Request::get(
+                "/ui/hts/capability-statement/json-fragment?path=&offset=0&limit=100",
+            )
+            .body(Body::empty())
+            .unwrap(),
+        )
+        .await
+        .unwrap();
+    assert_eq!(fragment.status(), StatusCode::OK);
+    let fragment_html = body_text(fragment).await;
+    assert!(
+        fragment_html.contains("resourceType") && fragment_html.contains("CapabilityStatement"),
+        "the fragment should hold the statement; got: {}",
+        &fragment_html[..fragment_html.len().min(120)],
     );
 
     // ── The page fetches exactly two sources ────────────────────────────
@@ -489,9 +531,21 @@ async fn interactions_appear_when_declared_and_one_failure_degrades_only_its_car
         html.contains("System Interactions"),
         "the card should appear once the server declares interactions",
     );
+    // #808: the chips arrive from the shared card, so they carry HFS's
+    // semantic colour classes and HFS's link into the release's HTTP
+    // specification. Before the unification this page emitted a bare
+    // ``.
+    assert!(
+        html.contains(
+            r#"batch"#),
-        "declared interactions should render as `.tag` chips",
+        html.contains(
+            r#"` or
-    // not. Only the seeded deployment exposes this; the small fixtures
-    // above never would.
-    // 400 is already ~10× the 16 KB cap; the real seed set carries ~1,975.
-    // Kept deliberately modest — this fixture is serialized and
-    // pretty-printed on every render, and a needlessly huge one only adds
+    // 422 KB raw block — 95% of the page — if it were ever inlined. Only the
+    // seeded deployment exposes this; the small fixtures above never would.
+    // 400 is already large enough to blow the fragment engine's 1,000-line
+    // render budget and force outline mode; the real seed set carries
+    // ~1,975. Kept deliberately modest — a needlessly huge fixture only adds
     // CPU to a suite that already runs eleven binaries in parallel.
     let bulky: Vec = (0..400)
         .map(|i| {
@@ -541,65 +594,159 @@ async fn interactions_appear_when_declared_and_one_failure_degrades_only_its_car
             })),
         )
         .await;
+    // The page itself never inlines the statement — #808's whole point — so
+    // it stays small however large the statement grows.
     let html = get_page(&app).await;
-    let folded = html
-        .split(r#"
"#)
-        .nth(1)
-        .and_then(|s| s.split("
").next()) - .expect("the raw block renders"); assert!( - folded.len() < 20 * 1024, - "the raw block must stay capped; got {} bytes", - folded.len(), + !html.contains(r#"
"#),
+        "the page must never inline the raw statement",
     );
-    // Never a silent truncation: the note states both sizes and points at
-    // the endpoint that serves the complete document.
     assert!(
-        html.contains("Truncated to the first"),
-        "a truncated statement must say so",
+        html.len() < 60 * 1024,
+        "the whole page should stay small even against a bulky statement; got {} bytes",
+        html.len(),
     );
+
+    // The root fragment cannot fully render 400 extensions inside its
+    // 1,000-line budget, so it degrades to a paginated outline rather than
+    // one gigantic swap.
+    let root_fragment = body_text(
+        app.clone()
+            .oneshot(
+                axum::http::Request::get(
+                    "/ui/hts/capability-statement/json-fragment?path=&offset=0&limit=100",
+                )
+                .body(Body::empty())
+                .unwrap(),
+            )
+            .await
+            .unwrap(),
+    )
+    .await;
     assert!(
-        html.contains(r#""#),
-        "the truncation note must link to the full statement",
+        root_fragment.contains(r#"data-capability-json-page"#),
+        "400 extensions should force the root into outline mode",
     );
     assert!(
-        html.len() < 60 * 1024,
-        "the whole page should stay small even against a bulky statement; got {} bytes",
-        html.len(),
+        root_fragment.contains("[ 400 ]"),
+        "the extension array should summarize its length rather than inline it",
     );
+
+    // Following that row's own link pages through the 400 items themselves.
+    let extension_page = app
+        .clone()
+        .oneshot(
+            axum::http::Request::get(
+                "/ui/hts/capability-statement/json-fragment?path=%2Fextension&offset=0&limit=100",
+            )
+            .body(Body::empty())
+            .unwrap(),
+        )
+        .await
+        .unwrap();
+    assert_eq!(extension_page.status(), StatusCode::OK);
+    let extension_html = body_text(extension_page).await;
+    assert!(extension_html.contains("1–100 / 400"));
+    // Each extension is itself an object, so this level summarizes rather
+    // than inlines it too — the same "expand one bounded level at a time"
+    // rule the root page just proved.
+    assert_eq!(extension_html.matches("{ 2 }").count(), 100);
+
+    // Drilling one level further reaches the actual padding value.
+    let item_html = body_text(
+        app.clone()
+            .oneshot(
+                axum::http::Request::get(
+                    "/ui/hts/capability-statement/json-fragment?path=%2Fextension%2F0&offset=0&limit=100",
+                )
+                .body(Body::empty())
+                .unwrap(),
+            )
+            .await
+            .unwrap(),
+    )
+    .await;
+    assert!(item_html.contains("padding-0"));
 }
 
 /// Guard: the page adds **no** CSS. Every class it names must already have
 /// a rule in the shared stylesheet, so a future edit cannot smuggle in a
 /// reintroduced HTS-only style hook.
 ///
+/// The scan covers the shared cards too (#808). They are the bulk of the
+/// page now, and they are edited from `crates/ui-chrome` — where nothing
+/// otherwise checks that a class reaches an HTS page with a rule behind it,
+/// because that crate carries no stylesheet of its own.
+///
 /// A plain `#[test]`: it reads files and never touches the runtime, so it
 /// stays outside the `#[tokio::test]` budget this file works to.
 #[test]
-fn capability_template_only_uses_classes_that_exist_in_app_css() {
-    const PAGE: &str = include_str!("../templates/pages/capability-statement.html");
+fn capability_markup_only_uses_classes_that_exist_in_app_css() {
     const APP_CSS: &str = include_str!("../../ui/assets/app.css");
-
-    // Skip only the `{#- … -#}` *header* comment, which quotes CSS
-    // selectors and `class="…"` fragments in prose. `split_once` (first
-    // match), not `rsplit_once`: the template carries a short comment above
-    // each card, and splitting on the last one would skip nearly the whole
-    // file — silently reducing this guard to a couple of classes.
-    let body = PAGE.split_once("-#}").map(|(_, rest)| rest).unwrap_or(PAGE);
+    const SOURCES: [(&str, &str); 5] = [
+        (
+            "the HTS page",
+            include_str!("../templates/pages/capability-statement.html"),
+        ),
+        (
+            "the shared summary card",
+            include_str!("../../ui-chrome/templates/partials/capability-summary-card.html"),
+        ),
+        (
+            "the shared interactions card",
+            include_str!("../../ui-chrome/templates/partials/capability-interactions-card.html"),
+        ),
+        (
+            "the shared operations card",
+            include_str!("../../ui-chrome/templates/partials/capability-operations-card.html"),
+        ),
+        (
+            "the shared resources card",
+            include_str!("../../ui-chrome/templates/partials/capability-resources-card.html"),
+        ),
+    ];
 
     let mut checked = 0usize;
-    for chunk in body.split(r#"class=""#).skip(1) {
-        let value = chunk.split('"').next().unwrap_or_default();
-        for class in value.split_whitespace() {
-            assert!(
-                APP_CSS.contains(&format!(".{class}")),
-                "class `{class}` used by the Capability template has no rule in crates/ui/assets/app.css",
-            );
-            checked += 1;
+    for (label, source) in SOURCES {
+        // Skip only the leading `{#- … -#}` / `{# … #}` header comment, which
+        // quotes CSS selectors and `class="…"` fragments in prose. First
+        // match, not last: these files carry a short comment above each card,
+        // and splitting on the last one would skip nearly the whole file —
+        // silently reducing this guard to a couple of classes.
+        let body = source
+            .split_once("-#}")
+            .or_else(|| source.split_once("#}"))
+            .map(|(_, rest)| rest)
+            .unwrap_or(source);
+        for chunk in body.split(r#"class=""#).skip(1) {
+            let value = chunk.split('"').next().unwrap_or_default();
+            // A `class` attribute may interpolate: `class="tag {{ i.tag_class }}"`.
+            // Strip the expression and check the literal classes around it —
+            // the interpolated values are `&'static str`s chosen in Rust, and
+            // the shared crate's unit tests pin them.
+            let literals = value
+                .split("{{")
+                .enumerate()
+                .map(|(i, part)| {
+                    if i == 0 {
+                        part
+                    } else {
+                        part.split_once("}}").map(|(_, rest)| rest).unwrap_or("")
+                    }
+                })
+                .collect::>()
+                .join(" ");
+            for class in literals.split_whitespace() {
+                assert!(
+                    APP_CSS.contains(&format!(".{class}")),
+                    "class `{class}` used by {label} has no rule in crates/ui/assets/app.css",
+                );
+                checked += 1;
+            }
         }
     }
     assert!(
-        checked >= 10,
-        "expected the scan to reach the template's class attributes, only saw {checked}",
+        checked >= 20,
+        "expected the scan to reach the markup's class attributes, only saw {checked}",
     );
 }
diff --git a/crates/hts-ui/tests/chrome_parity.rs b/crates/hts-ui/tests/chrome_parity.rs
index d33d765ad9..7c1bd14dd6 100644
--- a/crates/hts-ui/tests/chrome_parity.rs
+++ b/crates/hts-ui/tests/chrome_parity.rs
@@ -832,3 +832,97 @@ fn workbench_input_group_wrappers_all_carry_the_field_class() {
         "expected the scan to reach all eight workbench group wrappers",
     );
 }
+
+// ── Track H: the Raw CapabilityStatement fold's scripts are wired in ───
+
+#[tokio::test]
+async fn capability_page_loads_the_raw_fold_scripts() {
+    // Regression (#808 follow-up). The Raw CapabilityStatement card is
+    // shared markup — `helios_ui_chrome::capability::CapabilityCards::raw`
+    // — but its *behavior* is two vanilla-JS files that each page has to
+    // include itself:
+    //
+    //   * `capability-json.js` listens for the native `toggle` event on
+    //     `details[data-capability-json-node]` and fires the htmx GET at
+    //     `data-fragment-url`, so opening the fold shows the tree;
+    //   * `json-view.js` binds the per-node fold arrows inside the
+    //     fragment that swap brings back.
+    //
+    // HTS adopted the shared card while loading neither, so the fold opened
+    // onto the server-rendered "Load JSON" fallback and simply sat there.
+    // Nothing failed to compile and no template error surfaced — a missing
+    // `"},
+            "rest": [{"resource": [{"type": ""}]}]
+        });
+        let view = build_view(&hostile, DocsVersion::R4, &NoCoreResources);
+        let cards = CapabilityCards::new(&Labels, &view);
+        let summary = cards.summary().unwrap();
+        assert!(!summary.contains("