From d000409c33e734c1863aa02ae509cc01e5bfb485 Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Sun, 27 Sep 2026 09:50:03 +0000
Subject: [PATCH 1/7] Adopt facto-stack Antora extensions and playbook wiring
Merge DevCentr/facto-stack compose: indexify URLs, site-nav-tree with
curated OSO sidebar roots, nav-typology, page-context, link tooling,
mermaid-client, diagram-lightbox, and Kroki for non-Mermaid diagrams.
Point grammaton docs at Desktop-Tooling/grammaton-ui-shell.
Co-authored-by: Ryan Johnson
---
antora-playbook-local.yml | 50 +++++++-
antora-playbook.yml | 52 ++++++++-
package.json | 15 ++-
pnpm-lock.yaml | 238 +++++++++++++++++++++++++++++++++++++-
4 files changed, 348 insertions(+), 7 deletions(-)
diff --git a/antora-playbook-local.yml b/antora-playbook-local.yml
index 8f23700..bb4d494 100644
--- a/antora-playbook-local.yml
+++ b/antora-playbook-local.yml
@@ -1,3 +1,4 @@
+# Local sibling checkouts — same Facto extensions as antora-playbook.yml
site:
title: OpenShellOrg Docs
url: https://docs.opensh.org
@@ -7,12 +8,50 @@ site:
site_home_url: https://opensh.org
site_home_label: OpenShellOrg home
header_doc_title: OpenShellOrg Docs
+ nav_typology_diataxis: 'true'
+
+urls:
+ html_extension_style: indexify
antora:
extensions:
- '@antora/lunr-extension'
- require: '@antora-supplemental/unversioned-component-urls'
+ - require: '@antora-supplemental/site-nav-tree'
+ include:
+ - open-shell-org
+ - shell-architecture
+ - about
+ - nu-require
+ - nu-emit
+ - project-map
+ order:
+ - open-shell-org
+ - shell-architecture
+ - about
+ - nu-require
+ - nu-emit
+ - project-map
+ - require: '@antora-supplemental/nav-typology'
+ - require: '@antora-supplemental/nav-typology-diataxis'
- '@antora-supplemental/build-stack'
+ - '@antora-supplemental/page-edit'
+ - require: '@antora-supplemental/link-validator'
+ reportEmail: openshell@devcentr.org
+ softFail403: true
+ failLevel: none
+ - require: '@antora-supplemental/linkinator'
+ statusDefault: unchecked
+ - require: '@antora-supplemental/extension-lister'
+ layouts: [footer]
+ - require: '@antora-supplemental/orphan-finder/extension'
+ reportEmail: openshell@devcentr.org
+ failOnUnresolved: false
+ - require: '@antora-supplemental/page-context/antora'
+ component: open-shell-org
+ path: keywords
+ - require: '@antora-supplemental/mermaid-client/antora'
+ - require: '@antora-supplemental/diagram-lightbox'
content:
sources:
@@ -43,7 +82,7 @@ content:
- url: ../project-map
branches: HEAD
start_path: docs
- - url: ../actor-shell
+ - url: ../grammaton-ui-shell
branches: HEAD
start_path: docs
- url: ../terminal-gui-prompts
@@ -57,12 +96,21 @@ ui:
supplemental_files: ./supplemental-ui
asciidoc:
+ extensions:
+ - asciidoctor-kroki
+ - '@antora-supplemental/page-context'
attributes:
stem: latexmath
experimental: ""
idprefix: ""
idseparator: "-"
page-pagination: ""
+ kroki-server-url: https://kroki.io
+ kroki-fetch-diagram: true
+ mermaid-client: ''
+ mermaid-client-mode: client
+ page-context-active: ''
+ page-context-keyword-base: '/open-shell-org/keywords'
output:
dir: build/site
diff --git a/antora-playbook.yml b/antora-playbook.yml
index 358c4a6..889c7e4 100644
--- a/antora-playbook.yml
+++ b/antora-playbook.yml
@@ -1,3 +1,4 @@
+# OpenShellOrg Docs — CI/production (Facto stack + OSO hub sources)
site:
title: OpenShellOrg Docs
url: https://docs.opensh.org
@@ -11,13 +12,50 @@ site:
site_home_url: https://opensh.org
site_home_label: OpenShellOrg home
header_doc_title: OpenShellOrg Docs
+ nav_typology_diataxis: 'true'
+
+urls:
+ html_extension_style: indexify
antora:
extensions:
- '@antora/lunr-extension'
- require: '@antora-supplemental/unversioned-component-urls'
- - '@antora-supplemental/page-edit'
+ - require: '@antora-supplemental/site-nav-tree'
+ include:
+ - open-shell-org
+ - shell-architecture
+ - about
+ - nu-require
+ - nu-emit
+ - project-map
+ order:
+ - open-shell-org
+ - shell-architecture
+ - about
+ - nu-require
+ - nu-emit
+ - project-map
+ - require: '@antora-supplemental/nav-typology'
+ - require: '@antora-supplemental/nav-typology-diataxis'
- '@antora-supplemental/build-stack'
+ - '@antora-supplemental/page-edit'
+ - require: '@antora-supplemental/link-validator'
+ reportEmail: openshell@devcentr.org
+ softFail403: true
+ failLevel: none
+ - require: '@antora-supplemental/linkinator'
+ statusDefault: unchecked
+ - require: '@antora-supplemental/extension-lister'
+ layouts: [footer]
+ - require: '@antora-supplemental/orphan-finder/extension'
+ reportEmail: openshell@devcentr.org
+ failOnUnresolved: false
+ - require: '@antora-supplemental/page-context/antora'
+ component: open-shell-org
+ path: keywords
+ - require: '@antora-supplemental/mermaid-client/antora'
+ - require: '@antora-supplemental/diagram-lightbox'
content:
sources:
@@ -48,7 +86,8 @@ content:
- url: https://github.com/openshellorg/project-map.git
branches: [main]
start_path: docs
- - url: https://github.com/openshellorg/actor-shell.git
+ # Grammaton UI Shell moved from openshellorg/actor-shell to Desktop-Tooling/grammaton-ui-shell
+ - url: https://github.com/Desktop-Tooling/grammaton-ui-shell.git
branches: [main]
start_path: docs
- url: https://github.com/openshellorg/terminal-gui-prompts.git
@@ -62,12 +101,21 @@ ui:
supplemental_files: ./supplemental-ui
asciidoc:
+ extensions:
+ - asciidoctor-kroki
+ - '@antora-supplemental/page-context'
attributes:
stem: latexmath
experimental: ""
idprefix: ""
idseparator: "-"
page-pagination: ""
+ kroki-server-url: https://kroki.io
+ kroki-fetch-diagram: true
+ mermaid-client: ''
+ mermaid-client-mode: client
+ page-context-active: ''
+ page-context-keyword-base: '/open-shell-org/keywords'
output:
dir: build/site
diff --git a/package.json b/package.json
index 862e8d3..b1be35b 100644
--- a/package.json
+++ b/package.json
@@ -10,12 +10,25 @@
"format": "dprint fmt",
"format:check": "dprint check"
},
+ "dependencies": {
+ "@antora-supplemental/page-context": "github:antora-supplemental/page-context#v0.6.7",
+ "@antora/lunr-extension": "^1.0.0-alpha.13",
+ "asciidoctor-kroki": "^0.18.1"
+ },
"devDependencies": {
"@antora-supplemental/build-stack": "github:antora-supplemental/build-stack#main",
+ "@antora-supplemental/diagram-lightbox": "github:antora-supplemental/antora-diagram-engines#dbdca5920074ef1559bdd263f874270292df9558&path:packages/diagram-lightbox",
+ "@antora-supplemental/extension-lister": "github:antora-supplemental/extension-lister#v0.1.0",
+ "@antora-supplemental/link-validator": "github:antora-supplemental/link-validator#v0.2.0",
+ "@antora-supplemental/linkinator": "github:antora-supplemental/linkinator#v0.1.0",
+ "@antora-supplemental/mermaid-client": "github:antora-supplemental/antora-diagram-engines#dbdca5920074ef1559bdd263f874270292df9558&path:packages/mermaid-client",
+ "@antora-supplemental/nav-typology": "github:antora-supplemental/nav-typology#c3e4006",
+ "@antora-supplemental/nav-typology-diataxis": "github:antora-supplemental/nav-typology-diataxis#main",
+ "@antora-supplemental/orphan-finder": "github:antora-supplemental/orphan-finder#v0.1.0",
"@antora-supplemental/page-edit": "github:antora-supplemental/page-edit#v0.2.4",
+ "@antora-supplemental/site-nav-tree": "github:antora-supplemental/site-nav-tree#54b6cbb",
"@antora-supplemental/unversioned-component-urls": "github:antora-supplemental/antora-unversioned-component-urls#v2.1.0",
"@antora/cli": "^3.1.14",
- "@antora/lunr-extension": "1.0.0-alpha.13",
"@antora/site-generator": "^3.1.14"
}
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 2e2a809..751962e 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -7,22 +7,56 @@ settings:
importers:
.:
+ dependencies:
+ '@antora-supplemental/page-context':
+ specifier: github:antora-supplemental/page-context#v0.6.7
+ version: https://codeload.github.com/antora-supplemental/page-context/tar.gz/ee6fb4d68f5daf10e3b51f4a817e2ebfd5d09d6d(@antora/site-generator@3.1.15)
+ '@antora/lunr-extension':
+ specifier: ^1.0.0-alpha.13
+ version: 1.0.0-alpha.13
+ asciidoctor-kroki:
+ specifier: ^0.18.1
+ version: 0.18.1(@asciidoctor/core@2.2.9)
devDependencies:
'@antora-supplemental/build-stack':
specifier: github:antora-supplemental/build-stack#main
version: https://codeload.github.com/antora-supplemental/build-stack/tar.gz/84875b509693847d123c066d815af3269dd3054c(@antora/site-generator@3.1.15)
+ '@antora-supplemental/diagram-lightbox':
+ specifier: github:antora-supplemental/antora-diagram-engines#dbdca5920074ef1559bdd263f874270292df9558&path:packages/diagram-lightbox
+ version: https://codeload.github.com/antora-supplemental/antora-diagram-engines/tar.gz/dbdca5920074ef1559bdd263f874270292df9558#path:packages/diagram-lightbox(@antora/site-generator@3.1.15)
+ '@antora-supplemental/extension-lister':
+ specifier: github:antora-supplemental/extension-lister#v0.1.0
+ version: https://codeload.github.com/antora-supplemental/extension-lister/tar.gz/19149b332ead9cf681a118aa0e730f8cda325900(@antora/site-generator@3.1.15)
+ '@antora-supplemental/link-validator':
+ specifier: github:antora-supplemental/link-validator#v0.2.0
+ version: https://codeload.github.com/antora-supplemental/link-validator/tar.gz/67b5f6ef0c341d4c6613517b0b31910dd0dd00b1(@antora/site-generator@3.1.15)
+ '@antora-supplemental/linkinator':
+ specifier: github:antora-supplemental/linkinator#v0.1.0
+ version: https://codeload.github.com/antora-supplemental/linkinator/tar.gz/68eda3930ad811adbc0bd1fafdbd0199217a94c3(@antora/site-generator@3.1.15)
+ '@antora-supplemental/mermaid-client':
+ specifier: github:antora-supplemental/antora-diagram-engines#dbdca5920074ef1559bdd263f874270292df9558&path:packages/mermaid-client
+ version: https://codeload.github.com/antora-supplemental/antora-diagram-engines/tar.gz/dbdca5920074ef1559bdd263f874270292df9558#path:packages/mermaid-client(@antora/site-generator@3.1.15)
+ '@antora-supplemental/nav-typology':
+ specifier: github:antora-supplemental/nav-typology#c3e4006
+ version: https://codeload.github.com/antora-supplemental/nav-typology/tar.gz/c3e4006cd0bc3f5dbbee6e03f87e52bd93d61be0(@antora/site-generator@3.1.15)
+ '@antora-supplemental/nav-typology-diataxis':
+ specifier: github:antora-supplemental/nav-typology-diataxis#main
+ version: https://codeload.github.com/antora-supplemental/nav-typology-diataxis/tar.gz/92e0eca1675bafedb19dffe3239b78a80de0d17d(@antora-supplemental/nav-typology@https://codeload.github.com/antora-supplemental/nav-typology/tar.gz/c3e4006cd0bc3f5dbbee6e03f87e52bd93d61be0(@antora/site-generator@3.1.15))(@antora/site-generator@3.1.15)
+ '@antora-supplemental/orphan-finder':
+ specifier: github:antora-supplemental/orphan-finder#v0.1.0
+ version: https://codeload.github.com/antora-supplemental/orphan-finder/tar.gz/5f32d2d07a76adf4171c6da3015f3e74b4653198(@antora/site-generator@3.1.15)
'@antora-supplemental/page-edit':
specifier: github:antora-supplemental/page-edit#v0.2.4
version: https://codeload.github.com/antora-supplemental/page-edit/tar.gz/207bc008e73b24673a813bfb9b0e0245cb0a47e3(@antora/site-generator@3.1.15)
+ '@antora-supplemental/site-nav-tree':
+ specifier: github:antora-supplemental/site-nav-tree#54b6cbb
+ version: https://codeload.github.com/antora-supplemental/site-nav-tree/tar.gz/54b6cbb504f29563a5fa97ee832aae06d6703154(@antora/site-generator@3.1.15)
'@antora-supplemental/unversioned-component-urls':
specifier: github:antora-supplemental/antora-unversioned-component-urls#v2.1.0
version: https://codeload.github.com/antora-supplemental/antora-unversioned-component-urls/tar.gz/fab7292ee987bc1b479ad174f4a394fe8952998e(@antora/site-generator@3.1.15)
'@antora/cli':
specifier: ^3.1.14
version: 3.1.15
- '@antora/lunr-extension':
- specifier: 1.0.0-alpha.13
- version: 1.0.0-alpha.13
'@antora/site-generator':
specifier: ^3.1.14
version: 3.1.15
@@ -58,6 +92,12 @@ importers:
packages:
+ '@antora-supplemental/asciidoc-link-validator@https://codeload.github.com/antora-supplemental/asciidoc-link-validator/tar.gz/e522c175fd7eb18a04773092a220ba6fce37e521':
+ resolution: {tarball: https://codeload.github.com/antora-supplemental/asciidoc-link-validator/tar.gz/e522c175fd7eb18a04773092a220ba6fce37e521}
+ version: 0.2.0
+ engines: {node: '>=18'}
+ hasBin: true
+
'@antora-supplemental/build-stack@https://codeload.github.com/antora-supplemental/build-stack/tar.gz/84875b509693847d123c066d815af3269dd3054c':
resolution: {tarball: https://codeload.github.com/antora-supplemental/build-stack/tar.gz/84875b509693847d123c066d815af3269dd3054c}
version: 0.1.2
@@ -65,6 +105,97 @@ packages:
peerDependencies:
'@antora/site-generator': '>=3.1.0'
+ '@antora-supplemental/diagram-engines-shared@https://codeload.github.com/antora-supplemental/antora-diagram-engines/tar.gz/dbdca5920074ef1559bdd263f874270292df9558#path:packages/shared':
+ resolution: {path: packages/shared, tarball: https://codeload.github.com/antora-supplemental/antora-diagram-engines/tar.gz/dbdca5920074ef1559bdd263f874270292df9558}
+ version: 0.1.0
+ engines: {node: '>=18'}
+
+ '@antora-supplemental/diagram-lightbox@https://codeload.github.com/antora-supplemental/antora-diagram-engines/tar.gz/dbdca5920074ef1559bdd263f874270292df9558#path:packages/diagram-lightbox':
+ resolution: {path: packages/diagram-lightbox, tarball: https://codeload.github.com/antora-supplemental/antora-diagram-engines/tar.gz/dbdca5920074ef1559bdd263f874270292df9558}
+ version: 0.1.0
+ engines: {node: '>=18'}
+ peerDependencies:
+ '@antora/site-generator': '>=3.1.0'
+ peerDependenciesMeta:
+ '@antora/site-generator':
+ optional: true
+
+ '@antora-supplemental/extension-lister@https://codeload.github.com/antora-supplemental/extension-lister/tar.gz/19149b332ead9cf681a118aa0e730f8cda325900':
+ resolution: {tarball: https://codeload.github.com/antora-supplemental/extension-lister/tar.gz/19149b332ead9cf681a118aa0e730f8cda325900}
+ version: 0.1.0
+ engines: {node: '>=18'}
+ peerDependencies:
+ '@antora/site-generator': '>=3.1.0'
+ peerDependenciesMeta:
+ '@antora/site-generator':
+ optional: true
+
+ '@antora-supplemental/link-validator@https://codeload.github.com/antora-supplemental/link-validator/tar.gz/67b5f6ef0c341d4c6613517b0b31910dd0dd00b1':
+ resolution: {tarball: https://codeload.github.com/antora-supplemental/link-validator/tar.gz/67b5f6ef0c341d4c6613517b0b31910dd0dd00b1}
+ version: 0.2.0
+ engines: {node: '>=18'}
+ peerDependencies:
+ '@antora/site-generator': '>=3.1.0'
+ peerDependenciesMeta:
+ '@antora/site-generator':
+ optional: true
+
+ '@antora-supplemental/linkinator@https://codeload.github.com/antora-supplemental/linkinator/tar.gz/68eda3930ad811adbc0bd1fafdbd0199217a94c3':
+ resolution: {tarball: https://codeload.github.com/antora-supplemental/linkinator/tar.gz/68eda3930ad811adbc0bd1fafdbd0199217a94c3}
+ version: 0.1.0
+ engines: {node: '>=18'}
+ peerDependencies:
+ '@antora/site-generator': '>=3.1.0'
+ peerDependenciesMeta:
+ '@antora/site-generator':
+ optional: true
+
+ '@antora-supplemental/mermaid-client@https://codeload.github.com/antora-supplemental/antora-diagram-engines/tar.gz/dbdca5920074ef1559bdd263f874270292df9558#path:packages/mermaid-client':
+ resolution: {path: packages/mermaid-client, tarball: https://codeload.github.com/antora-supplemental/antora-diagram-engines/tar.gz/dbdca5920074ef1559bdd263f874270292df9558}
+ version: 0.1.0
+ engines: {node: '>=18'}
+ peerDependencies:
+ '@antora/site-generator': '>=3.1.0'
+ peerDependenciesMeta:
+ '@antora/site-generator':
+ optional: true
+
+ '@antora-supplemental/nav-typology-diataxis@https://codeload.github.com/antora-supplemental/nav-typology-diataxis/tar.gz/92e0eca1675bafedb19dffe3239b78a80de0d17d':
+ resolution: {tarball: https://codeload.github.com/antora-supplemental/nav-typology-diataxis/tar.gz/92e0eca1675bafedb19dffe3239b78a80de0d17d}
+ version: 0.1.0
+ engines: {node: '>=18'}
+ peerDependencies:
+ '@antora-supplemental/nav-typology': '>=0.1.0'
+ '@antora/site-generator': '>=3.1.0'
+
+ '@antora-supplemental/nav-typology@https://codeload.github.com/antora-supplemental/nav-typology/tar.gz/c3e4006cd0bc3f5dbbee6e03f87e52bd93d61be0':
+ resolution: {tarball: https://codeload.github.com/antora-supplemental/nav-typology/tar.gz/c3e4006cd0bc3f5dbbee6e03f87e52bd93d61be0}
+ version: 0.1.8
+ engines: {node: '>=18'}
+ peerDependencies:
+ '@antora/site-generator': '>=3.1.0'
+
+ '@antora-supplemental/orphan-finder@https://codeload.github.com/antora-supplemental/orphan-finder/tar.gz/5f32d2d07a76adf4171c6da3015f3e74b4653198':
+ resolution: {tarball: https://codeload.github.com/antora-supplemental/orphan-finder/tar.gz/5f32d2d07a76adf4171c6da3015f3e74b4653198}
+ version: 0.1.0
+ engines: {node: '>=18'}
+ hasBin: true
+ peerDependencies:
+ '@antora/site-generator': '>=3.1.0'
+ peerDependenciesMeta:
+ '@antora/site-generator':
+ optional: true
+
+ '@antora-supplemental/page-context@https://codeload.github.com/antora-supplemental/page-context/tar.gz/ee6fb4d68f5daf10e3b51f4a817e2ebfd5d09d6d':
+ resolution: {tarball: https://codeload.github.com/antora-supplemental/page-context/tar.gz/ee6fb4d68f5daf10e3b51f4a817e2ebfd5d09d6d}
+ version: 0.6.7
+ engines: {node: '>=18'}
+ peerDependencies:
+ '@antora/site-generator': '>=3.1.0'
+ peerDependenciesMeta:
+ '@antora/site-generator':
+ optional: true
+
'@antora-supplemental/page-edit@https://codeload.github.com/antora-supplemental/page-edit/tar.gz/207bc008e73b24673a813bfb9b0e0245cb0a47e3':
resolution: {tarball: https://codeload.github.com/antora-supplemental/page-edit/tar.gz/207bc008e73b24673a813bfb9b0e0245cb0a47e3}
version: 0.2.4
@@ -72,6 +203,18 @@ packages:
peerDependencies:
'@antora/site-generator': '>=3.1.0'
+ '@antora-supplemental/site-nav-tree@https://codeload.github.com/antora-supplemental/site-nav-tree/tar.gz/54b6cbb504f29563a5fa97ee832aae06d6703154':
+ resolution: {tarball: https://codeload.github.com/antora-supplemental/site-nav-tree/tar.gz/54b6cbb504f29563a5fa97ee832aae06d6703154}
+ version: 0.5.3
+ engines: {node: '>=18'}
+ peerDependencies:
+ '@antora/site-generator': '>=3.1.0'
+
+ '@antora-supplemental/triage-ux-kit@https://codeload.github.com/antora-supplemental/triage-ux-kit/tar.gz/4b7888d2f18048e68e1176c9aca4abf7aa4bac40':
+ resolution: {tarball: https://codeload.github.com/antora-supplemental/triage-ux-kit/tar.gz/4b7888d2f18048e68e1176c9aca4abf7aa4bac40}
+ version: 0.1.0
+ engines: {node: '>=18'}
+
'@antora-supplemental/unversioned-component-urls@https://codeload.github.com/antora-supplemental/antora-unversioned-component-urls/tar.gz/fab7292ee987bc1b479ad174f4a394fe8952998e':
resolution: {tarball: https://codeload.github.com/antora-supplemental/antora-unversioned-component-urls/tar.gz/fab7292ee987bc1b479ad174f4a394fe8952998e}
version: 2.1.0
@@ -1141,6 +1284,12 @@ packages:
argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
+ asciidoctor-kroki@0.18.1:
+ resolution: {integrity: sha512-eQxbBCaPTbyNoJtk62Gp+6h4LlJp2147g7eS0QIVjqaLpFa8sseH0BlMiBoATrJUYv1w3nR+FTzvloBJ/MioYg==}
+ engines: {node: '>=10'}
+ peerDependencies:
+ '@asciidoctor/core': '>=2.2 <4.0'
+
asciidoctor-opal-runtime@0.3.4:
resolution: {integrity: sha512-zqd6zn1LV+PZ69AP/kEbB00zuPHMIAJY3IX8+aZV+X1qOwatYvKGjsMmdMc5ApfhtkjZ4mYkqiTPJWnEnBiMJg==}
engines: {node: '>=8.11'}
@@ -2165,6 +2314,11 @@ packages:
resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==}
engines: {node: '>= 18'}
+ mkdirp@2.1.3:
+ resolution: {integrity: sha512-sjAkg21peAG9HS+Dkx7hlG9Ztx7HLeKnvB3NQRcu/mltCVmvkF0pisbiTSfDVYTT86XEfZrTUosLdZLStquZUw==}
+ engines: {node: '>=10'}
+ hasBin: true
+
mlly@1.8.0:
resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==}
@@ -2278,6 +2432,9 @@ packages:
pako@1.0.11:
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
+ pako@2.1.0:
+ resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==}
+
parse5@7.3.0:
resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==}
@@ -2507,6 +2664,9 @@ packages:
run-parallel@1.2.0:
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
+ rusha@0.8.14:
+ resolution: {integrity: sha512-cLgakCUf6PedEu15t8kbsjnwIFFR2D4RfL+W3iWFJ4iac7z4B0ZI8fxy4R3J956kAI68HclCFGL8MPoUVC3qVA==}
+
safe-buffer@5.1.2:
resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==}
@@ -3105,14 +3265,71 @@ packages:
snapshots:
+ '@antora-supplemental/asciidoc-link-validator@https://codeload.github.com/antora-supplemental/asciidoc-link-validator/tar.gz/e522c175fd7eb18a04773092a220ba6fce37e521':
+ dependencies:
+ js-yaml: 4.1.1
+
'@antora-supplemental/build-stack@https://codeload.github.com/antora-supplemental/build-stack/tar.gz/84875b509693847d123c066d815af3269dd3054c(@antora/site-generator@3.1.15)':
dependencies:
'@antora/site-generator': 3.1.15
+ '@antora-supplemental/diagram-engines-shared@https://codeload.github.com/antora-supplemental/antora-diagram-engines/tar.gz/dbdca5920074ef1559bdd263f874270292df9558#path:packages/shared': {}
+
+ '@antora-supplemental/diagram-lightbox@https://codeload.github.com/antora-supplemental/antora-diagram-engines/tar.gz/dbdca5920074ef1559bdd263f874270292df9558#path:packages/diagram-lightbox(@antora/site-generator@3.1.15)':
+ dependencies:
+ '@antora-supplemental/diagram-engines-shared': https://codeload.github.com/antora-supplemental/antora-diagram-engines/tar.gz/dbdca5920074ef1559bdd263f874270292df9558#path:packages/shared
+ optionalDependencies:
+ '@antora/site-generator': 3.1.15
+
+ '@antora-supplemental/extension-lister@https://codeload.github.com/antora-supplemental/extension-lister/tar.gz/19149b332ead9cf681a118aa0e730f8cda325900(@antora/site-generator@3.1.15)':
+ optionalDependencies:
+ '@antora/site-generator': 3.1.15
+
+ '@antora-supplemental/link-validator@https://codeload.github.com/antora-supplemental/link-validator/tar.gz/67b5f6ef0c341d4c6613517b0b31910dd0dd00b1(@antora/site-generator@3.1.15)':
+ dependencies:
+ '@antora-supplemental/asciidoc-link-validator': https://codeload.github.com/antora-supplemental/asciidoc-link-validator/tar.gz/e522c175fd7eb18a04773092a220ba6fce37e521
+ optionalDependencies:
+ '@antora/site-generator': 3.1.15
+
+ '@antora-supplemental/linkinator@https://codeload.github.com/antora-supplemental/linkinator/tar.gz/68eda3930ad811adbc0bd1fafdbd0199217a94c3(@antora/site-generator@3.1.15)':
+ optionalDependencies:
+ '@antora/site-generator': 3.1.15
+
+ '@antora-supplemental/mermaid-client@https://codeload.github.com/antora-supplemental/antora-diagram-engines/tar.gz/dbdca5920074ef1559bdd263f874270292df9558#path:packages/mermaid-client(@antora/site-generator@3.1.15)':
+ dependencies:
+ '@antora-supplemental/diagram-engines-shared': https://codeload.github.com/antora-supplemental/antora-diagram-engines/tar.gz/dbdca5920074ef1559bdd263f874270292df9558#path:packages/shared
+ optionalDependencies:
+ '@antora/site-generator': 3.1.15
+
+ '@antora-supplemental/nav-typology-diataxis@https://codeload.github.com/antora-supplemental/nav-typology-diataxis/tar.gz/92e0eca1675bafedb19dffe3239b78a80de0d17d(@antora-supplemental/nav-typology@https://codeload.github.com/antora-supplemental/nav-typology/tar.gz/c3e4006cd0bc3f5dbbee6e03f87e52bd93d61be0(@antora/site-generator@3.1.15))(@antora/site-generator@3.1.15)':
+ dependencies:
+ '@antora-supplemental/nav-typology': https://codeload.github.com/antora-supplemental/nav-typology/tar.gz/c3e4006cd0bc3f5dbbee6e03f87e52bd93d61be0(@antora/site-generator@3.1.15)
+ '@antora/site-generator': 3.1.15
+
+ '@antora-supplemental/nav-typology@https://codeload.github.com/antora-supplemental/nav-typology/tar.gz/c3e4006cd0bc3f5dbbee6e03f87e52bd93d61be0(@antora/site-generator@3.1.15)':
+ dependencies:
+ '@antora/site-generator': 3.1.15
+
+ '@antora-supplemental/orphan-finder@https://codeload.github.com/antora-supplemental/orphan-finder/tar.gz/5f32d2d07a76adf4171c6da3015f3e74b4653198(@antora/site-generator@3.1.15)':
+ dependencies:
+ '@antora-supplemental/triage-ux-kit': https://codeload.github.com/antora-supplemental/triage-ux-kit/tar.gz/4b7888d2f18048e68e1176c9aca4abf7aa4bac40
+ optionalDependencies:
+ '@antora/site-generator': 3.1.15
+
+ '@antora-supplemental/page-context@https://codeload.github.com/antora-supplemental/page-context/tar.gz/ee6fb4d68f5daf10e3b51f4a817e2ebfd5d09d6d(@antora/site-generator@3.1.15)':
+ optionalDependencies:
+ '@antora/site-generator': 3.1.15
+
'@antora-supplemental/page-edit@https://codeload.github.com/antora-supplemental/page-edit/tar.gz/207bc008e73b24673a813bfb9b0e0245cb0a47e3(@antora/site-generator@3.1.15)':
dependencies:
'@antora/site-generator': 3.1.15
+ '@antora-supplemental/site-nav-tree@https://codeload.github.com/antora-supplemental/site-nav-tree/tar.gz/54b6cbb504f29563a5fa97ee832aae06d6703154(@antora/site-generator@3.1.15)':
+ dependencies:
+ '@antora/site-generator': 3.1.15
+
+ '@antora-supplemental/triage-ux-kit@https://codeload.github.com/antora-supplemental/triage-ux-kit/tar.gz/4b7888d2f18048e68e1176c9aca4abf7aa4bac40': {}
+
'@antora-supplemental/unversioned-component-urls@https://codeload.github.com/antora-supplemental/antora-unversioned-component-urls/tar.gz/fab7292ee987bc1b479ad174f4a394fe8952998e(@antora/site-generator@3.1.15)':
dependencies:
'@antora/site-generator': 3.1.15
@@ -4151,6 +4368,15 @@ snapshots:
argparse@2.0.1: {}
+ asciidoctor-kroki@0.18.1(@asciidoctor/core@2.2.9):
+ dependencies:
+ '@asciidoctor/core': 2.2.9
+ json5: 2.2.3
+ mkdirp: 2.1.3
+ pako: 2.1.0
+ rusha: 0.8.14
+ unxhr: 1.2.0
+
asciidoctor-opal-runtime@0.3.4:
dependencies:
fast-glob: 3.3.3
@@ -5149,6 +5375,8 @@ snapshots:
dependencies:
minipass: 7.1.2
+ mkdirp@2.1.3: {}
+
mlly@1.8.0:
dependencies:
acorn: 8.15.0
@@ -5340,6 +5568,8 @@ snapshots:
pako@1.0.11: {}
+ pako@2.1.0: {}
+
parse5@7.3.0:
dependencies:
entities: 6.0.1
@@ -5597,6 +5827,8 @@ snapshots:
dependencies:
queue-microtask: 1.2.3
+ rusha@0.8.14: {}
+
safe-buffer@5.1.2: {}
safe-buffer@5.2.1: {}
From 6302d386da5e9de5af7c1c622d5f1e6082a98791 Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Sun, 27 Sep 2026 09:50:05 +0000
Subject: [PATCH 2/7] Add Facto supplemental UI for nav-tree and diagrams
Copy nav-typology and site-nav-tree companion partials, helpers, and
scripts from the DevCentr docs pattern. Wire mermaid-client and
diagram-lightbox via head/footer partials; drop legacy site-mermaid assets.
Co-authored-by: Ryan Johnson
---
CHANGELOG.adoc | 6 +
README.md | 2 +-
docs/modules/ROOT/pages/ecosystem.adoc | 2 +-
supplemental-ui/css/nav-typology.css | 117 +++++++
supplemental-ui/css/site-mermaid.css | 30 --
.../css/site-nav-column-scroll.css | 101 ++++++
.../helpers/adt-bc-trail-crumbs.js | 156 +++++++++
supplemental-ui/helpers/adt-version-label.js | 31 ++
supplemental-ui/helpers/in-site-nav-tree.js | 22 ++
supplemental-ui/helpers/nav-component-logo.js | 36 ++
supplemental-ui/helpers/nav-typology-icon.js | 109 ++++++
supplemental-ui/helpers/nav-typology-id.js | 60 ++++
.../helpers/strip-nav-typology-emoji.js | 17 +
supplemental-ui/img/nav-typology.svg | 35 ++
supplemental-ui/js/site-mermaid.js | 97 ------
supplemental-ui/js/site-nav-tree-current.js | 321 ++++++++++++++++++
supplemental-ui/js/site-toc-main-article.js | 117 +++++++
supplemental-ui/partials/breadcrumbs.hbs | 81 +++++
supplemental-ui/partials/footer-content.hbs | 15 +-
supplemental-ui/partials/head-meta.hbs | 113 ++++++
supplemental-ui/partials/nav-menu.hbs | 13 +
supplemental-ui/partials/nav-tree.hbs | 27 ++
22 files changed, 1371 insertions(+), 137 deletions(-)
create mode 100644 supplemental-ui/css/nav-typology.css
delete mode 100644 supplemental-ui/css/site-mermaid.css
create mode 100644 supplemental-ui/css/site-nav-column-scroll.css
create mode 100644 supplemental-ui/helpers/adt-bc-trail-crumbs.js
create mode 100644 supplemental-ui/helpers/adt-version-label.js
create mode 100644 supplemental-ui/helpers/in-site-nav-tree.js
create mode 100644 supplemental-ui/helpers/nav-component-logo.js
create mode 100644 supplemental-ui/helpers/nav-typology-icon.js
create mode 100644 supplemental-ui/helpers/nav-typology-id.js
create mode 100644 supplemental-ui/helpers/strip-nav-typology-emoji.js
create mode 100644 supplemental-ui/img/nav-typology.svg
delete mode 100644 supplemental-ui/js/site-mermaid.js
create mode 100644 supplemental-ui/js/site-nav-tree-current.js
create mode 100644 supplemental-ui/js/site-toc-main-article.js
create mode 100644 supplemental-ui/partials/breadcrumbs.hbs
create mode 100644 supplemental-ui/partials/head-meta.hbs
create mode 100644 supplemental-ui/partials/nav-menu.hbs
create mode 100644 supplemental-ui/partials/nav-tree.hbs
diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc
index b70b7c7..b41137f 100644
--- a/CHANGELOG.adoc
+++ b/CHANGELOG.adoc
@@ -1,5 +1,11 @@
= Changelog
+== 2026-09-27 -- Facto stack for docs.opensh.org
+
+* Adopt `@antora-supplemental/facto-stack` wiring: `indexify` URLs, site-nav-tree curated sidebar roots, nav-typology (+ Diátaxis), page-context/edit, link-validator, linkinator, extension-lister, orphan-finder, build-stack footer, client Mermaid + diagram lightbox, Kroki bake for non-Mermaid diagrams.
+* Playbook content source: `openshellorg/actor-shell` → `Desktop-Tooling/grammaton-ui-shell` (component `grammaton-ui-shell`).
+* Supplemental UI: nav-tree / typology partials and helpers from the Facto compose pattern (DevCentr reference).
+
== 2026-09-26 -- Config Key Sanitation renamed to Config Lifecycle Management
* The protocol page is now xref:standard-config-lifecycle-management.adoc[Config Lifecycle Management]; the old `standard-config-key-sanitation` URL redirects to it.
diff --git a/README.md b/README.md
index fb116af..e94cfbc 100644
--- a/README.md
+++ b/README.md
@@ -33,7 +33,7 @@
## About The Project
-Documentation hub for [OpenShellOrg](https://github.com/openshellorg) — Antora site aggregating org projects, plus SOS certification packages in this monorepo.
+Documentation hub for [OpenShellOrg](https://github.com/openshellorg) — Antora site aggregating org projects, plus SOS certification packages in this monorepo. The published site follows the [facto-stack](https://github.com/antora-supplemental/facto-stack) Antora compose pack (Valentus + Lunr + page-context + site-nav-tree + maint extensions), aligned with [DevCentr docs](https://docs.devcentr.org/).
### Mission
diff --git a/docs/modules/ROOT/pages/ecosystem.adoc b/docs/modules/ROOT/pages/ecosystem.adoc
index 5cc9b85..ccb50bf 100644
--- a/docs/modules/ROOT/pages/ecosystem.adoc
+++ b/docs/modules/ROOT/pages/ecosystem.adoc
@@ -33,7 +33,7 @@ link:https://github.com/AMDphreak/connectome-fs[connectome-fs] owns node/edition
Spec and ownership live in shell-architecture:
* xref:shell-architecture::shell-host-and-env-refresh.adoc[env-refresh + terminal fork] — Nu-first `env-refresh` CLI (planned) and https://github.com/openshellorg/terminal[`openshellorg/terminal`] (fork of Windows Terminal) with per-tab env health + Fix/Insert desync bar; DevCentr prefers these when installed
-* xref:actor-shell::index.adoc[actor-shell] — non-frustrating Windows/Linux UI shell on an Erlang-style actor runtime (D); Phase 1 `libbeam_d` landed
+* xref:grammaton-ui-shell::index.adoc[Grammaton UI Shell] — non-frustrating Windows/Linux UI shell on an Erlang-style actor runtime (D); Phase 1 `libbeam_d` landed (formerly actor-shell; now `Desktop-Tooling/grammaton-ui-shell`)
== Orientation and help
diff --git a/supplemental-ui/css/nav-typology.css b/supplemental-ui/css/nav-typology.css
new file mode 100644
index 0000000..3da59e9
--- /dev/null
+++ b/supplemental-ui/css/nav-typology.css
@@ -0,0 +1,117 @@
+:root {
+ --typology-component-root: #178f80;
+ --typology-spec-component: #2563eb;
+ --typology-spec-feature: #7c3aed;
+ --typology-diataxis-tutorial: #2563eb;
+ --typology-diataxis-howto: #178f80;
+ --typology-diataxis-reference: #7c3aed;
+ --typology-diataxis-explanation: #d97706;
+ --typology-changelog: #c2410c;
+ --typology-overview: #0f766e;
+ /* Larger solid glyphs; DeepWiki left-nav text-sm ≈ 0.875rem */
+ --nav-typology-icon-size: 1.05em;
+ --nav-typology-font-size: 0.875rem;
+ --nav-typology-font-size-root: 0.9375rem;
+ --nav-typology-toggle-size: 0.625rem;
+}
+
+html.dark-theme {
+ --typology-component-root: #2dd4bf;
+ --typology-spec-component: #60a5fa;
+ --typology-spec-feature: #a78bfa;
+ --typology-diataxis-tutorial: #60a5fa;
+ --typology-diataxis-howto: #2dd4bf;
+ --typology-diataxis-reference: #a78bfa;
+ --typology-diataxis-explanation: #fbbf24;
+ --typology-changelog: #fb923c;
+ --typology-overview: #2dd4bf;
+}
+
+/*
+ * Avoid a nested horizontal scrollport inside the SoftNav rail (wheel / middle-button
+ * jank). Clip overflow; labels wrap or ellipsize instead of forcing overflow-x:auto.
+ */
+nav.nav-menu.nav-tree-only,
+nav.nav-menu.adt-nav-tree-only {
+ overflow-x: hidden;
+}
+
+nav.nav-menu.nav-tree-only .nav-item > .nav-link,
+nav.nav-menu.adt-nav-tree-only .nav-item > .nav-link,
+nav.nav-menu.nav-tree-only .nav-item > .nav-text,
+nav.nav-menu.adt-nav-tree-only .nav-item > .nav-text {
+ display: flex;
+ align-items: flex-start;
+ gap: 0.4rem;
+ font-size: var(--nav-typology-font-size);
+ line-height: 1.35;
+}
+
+/* Component forest roots: slightly larger section label (DeepWiki section feel). */
+nav.nav-menu.nav-tree-only .nav-item[data-depth="0"] > .nav-link,
+nav.nav-menu.adt-nav-tree-only .nav-item[data-depth="0"] > .nav-link,
+nav.nav-menu.nav-tree-only .nav-item[data-depth="0"] > .nav-text,
+nav.nav-menu.adt-nav-tree-only .nav-item[data-depth="0"] > .nav-text {
+ font-size: var(--nav-typology-font-size-root);
+ font-weight: 600;
+}
+
+/* Expander chevrons must stay smaller than label text. */
+nav.nav-menu.nav-tree-only .nav-item-toggle,
+nav.nav-menu.adt-nav-tree-only .nav-item-toggle {
+ width: var(--nav-typology-toggle-size) !important;
+ height: var(--nav-typology-toggle-size) !important;
+ min-width: var(--nav-typology-toggle-size);
+ min-height: var(--nav-typology-toggle-size);
+}
+
+nav.nav-menu.nav-tree-only .nav-typology-icon,
+nav.nav-menu.adt-nav-tree-only .nav-typology-icon {
+ width: var(--nav-typology-icon-size);
+ height: var(--nav-typology-icon-size);
+ min-width: var(--nav-typology-icon-size);
+ max-width: var(--nav-typology-icon-size);
+ flex: 0 0 var(--nav-typology-icon-size);
+ margin-top: 0.15em;
+ fill: currentColor;
+ overflow: visible;
+}
+
+nav.nav-menu.nav-tree-only .nav-component-logo,
+nav.nav-menu.adt-nav-tree-only .nav-component-logo {
+ width: var(--nav-typology-icon-size);
+ height: var(--nav-typology-icon-size);
+ min-width: var(--nav-typology-icon-size);
+ max-width: var(--nav-typology-icon-size);
+ flex: 0 0 var(--nav-typology-icon-size);
+ margin-top: 0.15em;
+ object-fit: contain;
+}
+
+nav.nav-menu.nav-tree-only .nav-item > .nav-link > .nav-text,
+nav.nav-menu.adt-nav-tree-only .nav-item > .nav-link > .nav-text,
+nav.nav-menu.nav-tree-only .nav-item > .nav-text > .nav-typology-label,
+nav.nav-menu.adt-nav-tree-only .nav-item > .nav-text > .nav-typology-label {
+ min-width: 0;
+ flex: 1 1 auto;
+ white-space: normal;
+ overflow-wrap: anywhere;
+ word-break: break-word;
+}
+
+.nav-typology-icon--component-root { color: var(--typology-component-root); }
+.nav-typology-icon--spec-component { color: var(--typology-spec-component); }
+.nav-typology-icon--spec-feature { color: var(--typology-spec-feature); }
+.nav-typology-icon--diataxis-tutorial { color: var(--typology-diataxis-tutorial); }
+.nav-typology-icon--diataxis-howto { color: var(--typology-diataxis-howto); }
+.nav-typology-icon--diataxis-reference { color: var(--typology-diataxis-reference); }
+.nav-typology-icon--diataxis-explanation { color: var(--typology-diataxis-explanation); }
+.nav-typology-icon--changelog { color: var(--typology-changelog); font-weight: 600; }
+.nav-typology-icon--overview { color: var(--typology-overview); }
+
+nav.nav-menu.nav-tree-only .nav-item[data-nav-typology="changelog"] > .nav-link,
+nav.nav-menu.adt-nav-tree-only .nav-item[data-nav-typology="changelog"] > .nav-link,
+nav.nav-menu.nav-tree-only .nav-item[data-nav-typology="changelog"] > .nav-text,
+nav.nav-menu.adt-nav-tree-only .nav-item[data-nav-typology="changelog"] > .nav-text {
+ font-weight: 600;
+}
\ No newline at end of file
diff --git a/supplemental-ui/css/site-mermaid.css b/supplemental-ui/css/site-mermaid.css
deleted file mode 100644
index a9966cb..0000000
--- a/supplemental-ui/css/site-mermaid.css
+++ /dev/null
@@ -1,30 +0,0 @@
-/* Mermaid diagrams in Antora content */
-.adt-mermaid,
-div.mermaid {
- margin: 1.25rem 0 1.75rem;
- padding: 1rem 0.75rem;
- overflow-x: auto;
- border: 1px solid color-mix(in srgb, currentColor 14%, transparent);
- border-radius: 0.5rem;
- background: color-mix(in srgb, currentColor 4%, transparent);
-
- --mermaid-primaryColor: color-mix(in srgb, currentColor 8%, transparent);
- --mermaid-primaryTextColor: currentColor;
- --mermaid-primaryBorderColor: color-mix(in srgb, currentColor 35%, transparent);
- --mermaid-lineColor: color-mix(in srgb, currentColor 45%, transparent);
- --mermaid-textColor: currentColor;
- --mermaid-mainBkg: color-mix(in srgb, currentColor 8%, transparent);
- --mermaid-nodeBorder: color-mix(in srgb, currentColor 35%, transparent);
- --mermaid-clusterBkg: color-mix(in srgb, currentColor 5%, transparent);
- --mermaid-clusterBorder: color-mix(in srgb, currentColor 30%, transparent);
- --mermaid-titleColor: currentColor;
- --mermaid-edgeLabelBackground: color-mix(in srgb, currentColor 4%, transparent);
-}
-
-.adt-mermaid svg,
-div.mermaid svg {
- display: block;
- max-width: 100%;
- height: auto;
- margin-inline: auto;
-}
diff --git a/supplemental-ui/css/site-nav-column-scroll.css b/supplemental-ui/css/site-nav-column-scroll.css
new file mode 100644
index 0000000..c927147
--- /dev/null
+++ b/supplemental-ui/css/site-nav-column-scroll.css
@@ -0,0 +1,101 @@
+/*
+ * Independent column scroll for Valentus 2.x on the DevCentr docs hub.
+ *
+ * Root cause (still after 11d35d6): site-doc-layout
+ * (1) align-self:stretch + height:auto on .nav so the rail grows with the article
+ * (2) overflow-y:visible on .nav-panel-menu and overflow-y:auto on nav.nav-menu
+ * so the tree (not the SoftNav scroll key) is the scrollport — or neither is
+ * (3) overscroll-behavior:auto !important chains wheel to the page
+ * A fixed height of (100vh - navbar) on a column that starts *below* the tool band
+ * also hangs past the fold, so page scroll was required to reach the tree bottom.
+ *
+ * Fix: sticky + max-height = remaining viewport under sticky top; panel
+ * [data-panel=menu] is the only vertical scrollport (SoftNav scroll-y key);
+ * contain overscroll; tree grows with content (horizontal edge-fade only).
+ */
+:root {
+ --adt-nav-sticky-top: var(--navbar-height, 3.5rem);
+ --adt-nav-sticky-max: calc(100vh - var(--adt-nav-sticky-top));
+}
+
+@supports (height: 100dvh) {
+ :root {
+ --adt-nav-sticky-max: calc(100dvh - var(--adt-nav-sticky-top));
+ }
+}
+
+@media screen and (min-width: 1024px) {
+ .body.adt-body {
+ align-items: flex-start !important;
+ }
+
+ .body.adt-body > .nav-container,
+ .nav-container {
+ align-self: flex-start !important;
+ position: sticky !important;
+ top: var(--adt-nav-sticky-top) !important;
+ max-height: var(--adt-nav-sticky-max) !important;
+ height: var(--adt-nav-sticky-max) !important;
+ min-height: 0 !important;
+ overflow: hidden !important;
+ }
+
+ .nav-container .nav,
+ .nav {
+ position: static !important;
+ top: auto !important;
+ height: 100% !important;
+ max-height: 100% !important;
+ min-height: 0 !important;
+ display: flex !important;
+ flex-direction: column !important;
+ flex: 1 1 auto !important;
+ }
+
+ .nav .panels {
+ display: flex !important;
+ flex-direction: column !important;
+ flex: 1 1 auto !important;
+ min-height: 0 !important;
+ height: 100% !important;
+ max-height: 100% !important;
+ }
+
+ /*
+ * Vertical scrollport = [data-panel=menu] (JS restore key).
+ * Defeat Valentus overflow-y:visible + overscroll chaining.
+ */
+ body.adt-body .nav .nav-panel-menu,
+ .nav-container .nav-panel-menu,
+ .nav-panel-menu {
+ flex: 1 1 auto !important;
+ min-height: 0 !important;
+ min-width: 0 !important;
+ height: auto !important;
+ max-height: none !important;
+ overflow-x: hidden !important;
+ overflow-y: auto !important;
+ overscroll-behavior: contain !important;
+ -ms-scroll-chaining: none !important;
+ }
+
+ body.adt-body .nav nav.nav-menu.nav-tree-only,
+ body.adt-body .nav nav.nav-menu.adt-nav-tree-only,
+ .nav-container nav.nav-menu.nav-tree-only,
+ .nav-container nav.nav-menu.adt-nav-tree-only,
+ nav.nav-menu.nav-tree-only,
+ nav.nav-menu.adt-nav-tree-only {
+ flex: 0 0 auto !important;
+ min-height: 0 !important;
+ height: auto !important;
+ max-height: none !important;
+ overflow-x: hidden !important;
+ overflow-y: visible !important;
+ }
+}
+
+/* SoftNav seamlessness: do NOT blank the tree with visibility:hidden.
+ * Prior data-snt-nav-ready hide caused a flash on every soft navigation
+ * (worse than DeepWiki). site-nav-tree 0.5.3 + SoftNav 0.2.0 patch currents
+ * in place instead.
+ */
diff --git a/supplemental-ui/helpers/adt-bc-trail-crumbs.js b/supplemental-ui/helpers/adt-bc-trail-crumbs.js
new file mode 100644
index 0000000..8a5f09e
--- /dev/null
+++ b/supplemental-ui/helpers/adt-bc-trail-crumbs.js
@@ -0,0 +1,156 @@
+'use strict'
+
+/**
+ * Hub override of Valentus `adt-bc-trail-crumbs`.
+ *
+ * Stock Valentus (with site-nav-tree) only drops a leading crumb that matches the
+ * *current* component root. On this hub, `home` is the first forest root and
+ * home/nav.adoc xrefs into other components, so Antora's trail often starts with
+ * Home even when page.component is e.g. general-knowledge. The house icon already
+ * covers site home — the underlined trail should stay within the current component.
+ *
+ * While `site.keys.site_nav_tree` is on:
+ * 1. Strip leading crumbs that are foreign component roots (esp. Home / site home)
+ * 2. Then apply the usual current-component-root strip
+ */
+
+function normalizeUrl (url) {
+ if (url == null || url === '') return ''
+ let u = String(url).split(/[?#]/)[0]
+ u = u.replace(/\/index\.html$/i, '/')
+ if (u.length > 1) u = u.replace(/\/+$/, '/') || '/'
+ return u
+}
+
+function crumbContent (crumb) {
+ if (!crumb || crumb.content == null) return ''
+ return String(crumb.content).trim()
+}
+
+function componentRootUrl (component) {
+ if (!component) return ''
+ const latest = component.latest
+ if (latest && latest.url) return latest.url
+ const versions = component.versions
+ if (Array.isArray(versions) && versions[0] && versions[0].url) return versions[0].url
+ return component.url || ''
+}
+
+function componentRootTitle (component) {
+ if (!component) return ''
+ return (
+ component.title ||
+ (component.latest && (component.latest.title || component.latest.displayVersion)) ||
+ ''
+ )
+}
+
+function crumbMatchesComponentRoot (crumb, component) {
+ if (!crumb || !component) return false
+ const rootUrl = componentRootUrl(component)
+ const rootTitle = componentRootTitle(component)
+ const urlMatch =
+ crumb.url && rootUrl && normalizeUrl(crumb.url) === normalizeUrl(rootUrl)
+ const titleMatch =
+ crumbContent(crumb) &&
+ rootTitle &&
+ crumbContent(crumb) === String(rootTitle).trim()
+ return !!(urlMatch || titleMatch)
+}
+
+function isSiteHomeCrumb (crumb, site, homeComponent) {
+ if (!crumb) return false
+ const content = crumbContent(crumb)
+ if (content.toLowerCase() === 'home') return true
+
+ const homeUrl = site && site.homeUrl
+ if (crumb.url && homeUrl && normalizeUrl(crumb.url) === normalizeUrl(homeUrl)) {
+ return true
+ }
+
+ if (homeComponent && crumbMatchesComponentRoot(crumb, homeComponent)) return true
+ return false
+}
+
+function findHomeComponent (site) {
+ const components = site && site.components
+ if (!components) return null
+ if (components.home) return components.home
+ // Antora may expose components as a map-like object or array
+ if (typeof components[Symbol.iterator] === 'function') {
+ for (const c of components) {
+ if (c && (c.name === 'home' || String(c.title || '').trim() === 'Home')) return c
+ }
+ }
+ for (const key of Object.keys(components)) {
+ const c = components[key]
+ if (c && (c.name === 'home' || key === 'home')) return c
+ }
+ return null
+}
+
+function listSiteComponents (site) {
+ const components = site && site.components
+ if (!components) return []
+ if (typeof components[Symbol.iterator] === 'function') return Array.from(components)
+ return Object.keys(components).map((k) => components[k]).filter(Boolean)
+}
+
+function isForeignComponentRootCrumb (crumb, site, currentComponentName) {
+ if (!crumb) return false
+ for (const component of listSiteComponents(site)) {
+ const name = component && component.name
+ if (!name || name === currentComponentName) continue
+ if (crumbMatchesComponentRoot(crumb, component)) return true
+ }
+ return false
+}
+
+module.exports = (breadcrumbs, options) => {
+ const list = Array.isArray(breadcrumbs) ? breadcrumbs.slice() : []
+ if (!list.length) return list
+
+ const root = options && options.data && options.data.root
+ const site = root && root.site
+ const keys = site && site.keys
+ if (!keys || String(keys.site_nav_tree) !== 'true') return list
+
+ const page = root.page
+ const currentComponent = page && page.component
+ const currentName = currentComponent && currentComponent.name
+ const homeComponent = findHomeComponent(site)
+
+ // 1. Drop leading foreign roots (Home first among them) / site-home crumbs
+ while (list.length) {
+ const first = list[0]
+ const foreign =
+ isSiteHomeCrumb(first, site, homeComponent) ||
+ isForeignComponentRootCrumb(first, site, currentName)
+ // Never strip the current component's own root here — step 2 owns that
+ if (currentComponent && crumbMatchesComponentRoot(first, currentComponent)) break
+ if (!foreign) break
+ list.shift()
+ }
+
+ // 2. Stock Valentus: drop leading crumb that duplicates the current component root
+ const cv = page && page.componentVersion
+ const componentUrl = cv && cv.url
+ const componentTitle =
+ (currentComponent && currentComponent.title) ||
+ (cv && (cv.title || cv.displayVersion))
+
+ const first = list[0]
+ if (!first) return list
+
+ const urlMatch =
+ first.url &&
+ componentUrl &&
+ normalizeUrl(first.url) === normalizeUrl(componentUrl)
+ const titleMatch =
+ first.content &&
+ componentTitle &&
+ String(first.content) === String(componentTitle)
+
+ if (urlMatch || titleMatch) return list.slice(1)
+ return list
+}
diff --git a/supplemental-ui/helpers/adt-version-label.js b/supplemental-ui/helpers/adt-version-label.js
new file mode 100644
index 0000000..2f29331
--- /dev/null
+++ b/supplemental-ui/helpers/adt-version-label.js
@@ -0,0 +1,31 @@
+'use strict'
+
+/**
+ * Version kicker / dropdown label: named versions as-is; implicit default → fallback.
+ * Default fallback is `~` (Antora’s unversioned marker) for dropdown rows.
+ * Kickers pass fallback="Version" so the button reads “Version”, not `~`.
+ * Keep in sync with valentus-theme (hub overrides breadcrumbs for site-nav-tree filter).
+ */
+function isNamedVersion (value) {
+ if (value == null) return false
+ const s = String(value).trim()
+ if (s === '' || s === '~') return false
+ const lower = s.toLowerCase()
+ return lower !== 'default'
+}
+
+module.exports = (versionRef, { hash } = {}) => {
+ const fallback = (hash && hash.fallback) || '~'
+ let display
+ let version
+ if (versionRef && typeof versionRef === 'object') {
+ display = versionRef.displayVersion
+ version = versionRef.version
+ } else {
+ display = versionRef
+ version = hash && hash.version
+ }
+ if (isNamedVersion(display)) return String(display)
+ if (isNamedVersion(version)) return String(version)
+ return fallback
+}
diff --git a/supplemental-ui/helpers/in-site-nav-tree.js b/supplemental-ui/helpers/in-site-nav-tree.js
new file mode 100644
index 0000000..62db717
--- /dev/null
+++ b/supplemental-ui/helpers/in-site-nav-tree.js
@@ -0,0 +1,22 @@
+'use strict'
+
+/**
+ * True when a component name belongs in the curated site-nav-tree forest.
+ * Reads site.keys.site_nav_tree_include / _exclude published by the extension.
+ * No include list → all components except exclude (legacy all-forest mode).
+ */
+module.exports = (name, { data } = {}) => {
+ if (name == null || name === '') return false
+ const keys = (data && data.root && data.root.site && data.root.site.keys) || {}
+ const split = (v) =>
+ String(v || '')
+ .split(',')
+ .map((s) => s.trim())
+ .filter(Boolean)
+ const include = split(keys.site_nav_tree_include)
+ const exclude = split(keys.site_nav_tree_exclude)
+ const n = String(name)
+ if (exclude.includes(n)) return false
+ if (include.length) return include.includes(n)
+ return true
+}
diff --git a/supplemental-ui/helpers/nav-component-logo.js b/supplemental-ui/helpers/nav-component-logo.js
new file mode 100644
index 0000000..ebe4c2d
--- /dev/null
+++ b/supplemental-ui/helpers/nav-component-logo.js
@@ -0,0 +1,36 @@
+'use strict'
+
+/**
+ * Optional per-component nav logos (site-nav-tree depth-0 roots and subtree items).
+ * Map Antora component name → supplemental-ui/img/nav-logos/.
+ */
+const LOGOS = {}
+
+function normalizeUrl (url) {
+ if (url == null || url === '') return ''
+ let u = String(url).split(/[?#]/)[0]
+ u = u.replace(/\/index\.html$/i, '/')
+ if (u.length > 1) u = u.replace(/\/+$/, '') || '/'
+ return u
+}
+
+function componentNameFromUrl (url) {
+ const path = normalizeUrl(url)
+ if (!path || path === '/') return ''
+ const segment = path.replace(/^\/+/, '').split('/')[0]
+ return segment || ''
+}
+
+module.exports = (item, options = {}) => {
+ if (!item || typeof item !== 'object') return ''
+ const name = componentNameFromUrl(item.url)
+ if (!name) return ''
+ const file = LOGOS[name]
+ if (!file) return ''
+ const uiRoot = options.data?.root?.uiRootPath || options.data?.root?.siteRootPath || '/_'
+ return (
+ `
`
+ )
+}
+
+module.exports.componentNameFromUrl = componentNameFromUrl
diff --git a/supplemental-ui/helpers/nav-typology-icon.js b/supplemental-ui/helpers/nav-typology-icon.js
new file mode 100644
index 0000000..bf735d9
--- /dev/null
+++ b/supplemental-ui/helpers/nav-typology-icon.js
@@ -0,0 +1,109 @@
+'use strict'
+
+const { resolveTypologyId: resolveTypologyIdCore } = require('@antora-supplemental/nav-typology/lib/resolve-typology')
+
+const TYPOLOGIES = {
+ 'component-root': { id: 'component-root', spriteId: 'icon-component-root', label: 'Component' },
+ 'spec-component': { id: 'spec-component', spriteId: 'icon-spec-component', label: 'Component spec' },
+ 'spec-feature': { id: 'spec-feature', spriteId: 'icon-spec-feature', label: 'Feature spec' },
+ 'diataxis-tutorial': { id: 'diataxis-tutorial', spriteId: 'icon-diataxis-tutorial', label: 'Tutorial' },
+ 'diataxis-howto': { id: 'diataxis-howto', spriteId: 'icon-diataxis-howto', label: 'How-to' },
+ 'diataxis-reference': { id: 'diataxis-reference', spriteId: 'icon-diataxis-reference', label: 'Reference' },
+ 'diataxis-explanation': {
+ id: 'diataxis-explanation',
+ spriteId: 'icon-diataxis-explanation',
+ label: 'Explanation',
+ },
+ changelog: {
+ id: 'changelog',
+ spriteId: 'icon-changelog',
+ label: 'Changelog',
+ },
+ overview: {
+ id: 'overview',
+ spriteId: 'icon-overview',
+ label: 'Overview',
+ },
+}
+
+/** Typologies that render an icon by default (component-root is opt-in). */
+const DEFAULT_ICON_IDS = new Set([
+ 'spec-component',
+ 'spec-feature',
+ 'diataxis-tutorial',
+ 'diataxis-howto',
+ 'diataxis-reference',
+ 'diataxis-explanation',
+ 'changelog',
+ 'overview',
+])
+
+function siteKeys (options = {}) {
+ return (options.data && options.data.root && options.data.root.site && options.data.root.site.keys) || {}
+}
+
+function diataxisEnabled (options = {}) {
+ const keys = siteKeys(options)
+ return keys.nav_typology_diataxis === 'true' || keys.nav_typology === 'true'
+}
+
+/** Opt-in: site.keys.nav_typology_component_root_icons === 'true' */
+function componentRootIconsEnabled (options = {}) {
+ return siteKeys(options).nav_typology_component_root_icons === 'true'
+}
+
+/**
+ * Icons on by default when Diataxis typology is enabled.
+ * Temporary A/B kill-switch: site.keys.nav_typology_icons === 'false'
+ */
+function typologyIconsEnabled (options = {}) {
+ const keys = siteKeys(options)
+ if (keys.nav_typology_icons === 'false') return false
+ return true
+}
+
+function shouldRenderIcon (meta, options = {}) {
+ if (!meta) return false
+ if (!typologyIconsEnabled(options)) return false
+ if (meta.id === 'component-root') return componentRootIconsEnabled(options)
+ return DEFAULT_ICON_IDS.has(meta.id)
+}
+
+function resolveTypology (item, options = {}) {
+ if (!item || typeof item !== 'object') return null
+
+ const level = options.hash?.level ?? 0
+ const depth = Number(level) || 0
+ const parentTypologyId = options.hash?.parentTypologyId || ''
+ const diataxis = diataxisEnabled(options)
+
+ let id = null
+ if (depth === 0 && item.url && Array.isArray(item.items) && item.items.length) {
+ id = 'component-root'
+ } else {
+ id = resolveTypologyIdCore(item, {
+ depth,
+ parentTypologyId,
+ diataxisEnabled: diataxis,
+ skipBuildFallback: false,
+ })
+ }
+
+ return id && TYPOLOGIES[id] ? TYPOLOGIES[id] : null
+}
+
+module.exports = (item, options = {}) => {
+ const meta = resolveTypology(item, options)
+ if (!shouldRenderIcon(meta, options)) return ''
+ const uiRoot = options.data?.root?.uiRootPath || options.data?.root?.siteRootPath || '/_'
+ // Explicit size attributes match --nav-typology-icon-size (solid glyphs read larger).
+ return (
+ ``
+ )
+}
+
+module.exports.resolveTypology = resolveTypology
+module.exports.shouldRenderIcon = shouldRenderIcon
+module.exports.componentRootIconsEnabled = componentRootIconsEnabled
+module.exports.typologyIconsEnabled = typologyIconsEnabled
diff --git a/supplemental-ui/helpers/nav-typology-id.js b/supplemental-ui/helpers/nav-typology-id.js
new file mode 100644
index 0000000..df23f6e
--- /dev/null
+++ b/supplemental-ui/helpers/nav-typology-id.js
@@ -0,0 +1,60 @@
+'use strict'
+
+const { resolveTypologyId: resolveTypologyIdCore } = require('@antora-supplemental/nav-typology/lib/resolve-typology')
+
+const TYPOLOGIES = {
+ 'component-root': { id: 'component-root', spriteId: 'icon-component-root', label: 'Component' },
+ 'spec-component': { id: 'spec-component', spriteId: 'icon-spec-component', label: 'Component spec' },
+ 'spec-feature': { id: 'spec-feature', spriteId: 'icon-spec-feature', label: 'Feature spec' },
+ 'diataxis-tutorial': { id: 'diataxis-tutorial', spriteId: 'icon-diataxis-tutorial', label: 'Tutorial' },
+ 'diataxis-howto': { id: 'diataxis-howto', spriteId: 'icon-diataxis-howto', label: 'How-to' },
+ 'diataxis-reference': { id: 'diataxis-reference', spriteId: 'icon-diataxis-reference', label: 'Reference' },
+ 'diataxis-explanation': {
+ id: 'diataxis-explanation',
+ spriteId: 'icon-diataxis-explanation',
+ label: 'Explanation',
+ },
+ changelog: {
+ id: 'changelog',
+ spriteId: 'icon-changelog',
+ label: 'Changelog',
+ },
+ overview: {
+ id: 'overview',
+ spriteId: 'icon-overview',
+ label: 'Overview',
+ },
+}
+
+function diataxisEnabled ({ data } = {}) {
+ const keys = (data && data.root && data.root.site && data.root.site.keys) || {}
+ return keys.nav_typology_diataxis === 'true' || keys.nav_typology === 'true'
+}
+
+function resolveTypology (item, options = {}) {
+ if (!item || typeof item !== 'object') return null
+
+ const level = options.hash?.level ?? 0
+ const depth = Number(level) || 0
+ const parentTypologyId = options.hash?.parentTypologyId || ''
+ const diataxis = diataxisEnabled(options)
+
+ let id = null
+ if (depth === 0 && item.url && Array.isArray(item.items) && item.items.length) {
+ id = 'component-root'
+ } else {
+ id = resolveTypologyIdCore(item, {
+ depth,
+ parentTypologyId,
+ diataxisEnabled: diataxis,
+ skipBuildFallback: false,
+ })
+ }
+
+ return id && TYPOLOGIES[id] ? TYPOLOGIES[id] : null
+}
+
+module.exports = (item, options = {}) => {
+ const meta = resolveTypology(item, options)
+ return meta ? meta.id : ''
+}
diff --git a/supplemental-ui/helpers/strip-nav-typology-emoji.js b/supplemental-ui/helpers/strip-nav-typology-emoji.js
new file mode 100644
index 0000000..d697def
--- /dev/null
+++ b/supplemental-ui/helpers/strip-nav-typology-emoji.js
@@ -0,0 +1,17 @@
+'use strict'
+
+const LEADING_EMOJI_RE =
+ /^[\s\uFE0F\u200D]*(?:🎓|🛠️?|📚|🧠|📋|🧩|✨|📦)[\s\uFE0F\u200D]*/u
+const LEADING_NAV_DOT_RE = /^[\s]*\.(?=\S)/u
+
+module.exports = (text) => {
+ if (text == null || text === '') return text
+ let out = String(text)
+ let prev
+ do {
+ prev = out
+ out = out.replace(LEADING_EMOJI_RE, '')
+ out = out.replace(LEADING_NAV_DOT_RE, '')
+ } while (out !== prev)
+ return out.trimStart()
+}
diff --git a/supplemental-ui/img/nav-typology.svg b/supplemental-ui/img/nav-typology.svg
new file mode 100644
index 0000000..a654ce1
--- /dev/null
+++ b/supplemental-ui/img/nav-typology.svg
@@ -0,0 +1,35 @@
+
+
+
\ No newline at end of file
diff --git a/supplemental-ui/js/site-mermaid.js b/supplemental-ui/js/site-mermaid.js
deleted file mode 100644
index 8a29e7b..0000000
--- a/supplemental-ui/js/site-mermaid.js
+++ /dev/null
@@ -1,97 +0,0 @@
-/**
- * Render AsciiDoc Mermaid blocks in Antora pages.
- * Supports [source,mermaid] (code.language-mermaid) and bare [mermaid] listingblocks.
- */
-;(function () {
- const MERMAID_CDN = 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js'
-
- function isDark() {
- return document.documentElement.classList.contains('dark-theme')
- }
-
- function looksLikeMermaid(text) {
- const t = String(text || '').trim()
- return /^(flowchart|graph|sequenceDiagram|classDiagram|stateDiagram|erDiagram|journey|gantt|pie|mindmap|timeline|gitGraph|C4Context|C4Container)\b/.test(
- t,
- )
- }
-
- function collectTargets() {
- const nodes = []
- document.querySelectorAll('code.language-mermaid, code[data-lang="mermaid"]').forEach((code) => {
- const block = code.closest('.listingblock') || code.parentElement
- if (block) nodes.push({ block, text: code.textContent || '' })
- })
- document.querySelectorAll('.listingblock > .content > pre').forEach((pre) => {
- if (pre.querySelector('code.language-mermaid, code[data-lang="mermaid"]')) return
- const text = pre.textContent || ''
- if (!looksLikeMermaid(text)) return
- const block = pre.closest('.listingblock')
- if (block) nodes.push({ block, text })
- })
- return nodes
- }
-
- function mountDiagram(block, text, index) {
- const host = document.createElement('div')
- host.className = 'mermaid adt-mermaid'
- host.setAttribute('data-mermaid-index', String(index))
- host.textContent = text.trim()
- block.replaceWith(host)
- return host
- }
-
- function loadScript(src) {
- return new Promise((resolve, reject) => {
- if (window.mermaid) {
- resolve(window.mermaid)
- return
- }
- const existing = document.querySelector('script[data-adt-mermaid]')
- if (existing) {
- existing.addEventListener('load', () => resolve(window.mermaid))
- existing.addEventListener('error', reject)
- return
- }
- const s = document.createElement('script')
- s.src = src
- s.async = true
- s.dataset.adtMermaid = '1'
- s.onload = () => resolve(window.mermaid)
- s.onerror = reject
- document.head.appendChild(s)
- })
- }
-
- async function renderAll() {
- const targets = collectTargets()
- if (!targets.length) return
-
- const mermaid = await loadScript(MERMAID_CDN)
- mermaid.initialize({
- startOnLoad: false,
- securityLevel: 'strict',
- theme: isDark() ? 'dark' : 'default',
- flowchart: { htmlLabels: true, curve: 'basis' },
- })
-
- const hosts = targets.map((t, i) => mountDiagram(t.block, t.text, i))
- await mermaid.run({ nodes: hosts })
- }
-
- function boot() {
- renderAll().catch((err) => console.warn('[adt-mermaid]', err))
- }
-
- if (document.readyState === 'loading') {
- document.addEventListener('DOMContentLoaded', boot)
- } else {
- boot()
- }
-
- function onSoftNavLoaded (fn) {
- if (window.SoftNav && typeof SoftNav.on === 'function') SoftNav.on('loaded', fn)
- else document.addEventListener('soft-nav:loaded', function (e) { fn(e.detail || {}) })
- }
- onSoftNavLoaded(function () { boot() })
-})()
diff --git a/supplemental-ui/js/site-nav-tree-current.js b/supplemental-ui/js/site-nav-tree-current.js
new file mode 100644
index 0000000..9c87a72
--- /dev/null
+++ b/supplemental-ui/js/site-nav-tree-current.js
@@ -0,0 +1,321 @@
+/**
+ * Prefer the deepest is-current-page when site-nav-tree inlines many components.
+ * Default UI expands only the first match; a duplicated start-page URL on the
+ * component root then leaves children behind an inactive anonymous wrapper.
+ *
+ * Also retains expand/collapse state across page navigations via sessionStorage
+ * so opening other component trees is not wiped when the current-path rewrite
+ * runs (which clears and re-applies is-active along the active page only).
+ *
+ * Expand keys use component-absolute pathnames (not relative hrefs) so SoftNav
+ * depth changes and absolutize() do not look like inject/remove of the tree.
+ *
+ * Nav panel scrollTop is persisted separately so left-rail position survives
+ * SoftNav swaps and full document loads independently of the content column.
+ *
+ * Does not blank the tree (no visibility:hidden / data-snt-nav-ready hide).
+ * Scroll saves are debounced; SoftNav before/unload flush immediately.
+ */
+;(function () {
+ 'use strict'
+
+ // v2: pathname keys (stable across URL depth / SoftNav absolutize)
+ var STORAGE_KEY = 'site-nav-tree:expanded-v2'
+ var SCROLL_KEY = 'site-nav-tree:scroll-y'
+ var scrollSaveTimer = null
+
+ function normalizeHref (href) {
+ if (!href) return ''
+ try {
+ var u = new URL(href, window.location.href)
+ var path = u.pathname || '/'
+ path = path.replace(/\/index\.html$/i, '/')
+ if (path.length > 1) path = path.replace(/\/+$/, '/') || '/'
+ return path
+ } catch (e) {
+ return String(href).split(/[?#]/)[0]
+ }
+ }
+
+ function itemKey (el) {
+ var link = el.querySelector(':scope > .nav-link')
+ var href = link && link.getAttribute('href')
+ if (href) return 'h:' + normalizeHref(href)
+ var labelEl = el.querySelector(':scope > .nav-text, :scope > .nav-link')
+ var label = ((labelEl && labelEl.textContent) || '').trim().replace(/\s+/g, ' ')
+ var parts = [(el.getAttribute('data-depth') || '0') + ':' + label]
+ var parent = el.parentElement ? el.parentElement.closest('li.nav-item') : null
+ while (parent) {
+ var plink = parent.querySelector(':scope > .nav-link')
+ var phref = plink && plink.getAttribute('href')
+ if (phref) {
+ parts.unshift('h:' + normalizeHref(phref))
+ } else {
+ var plabelEl = parent.querySelector(':scope > .nav-text, :scope > .nav-link')
+ var plabel = ((plabelEl && plabelEl.textContent) || '').trim().replace(/\s+/g, ' ')
+ parts.unshift((parent.getAttribute('data-depth') || '0') + ':' + plabel)
+ }
+ parent = parent.parentElement ? parent.parentElement.closest('li.nav-item') : null
+ }
+ return 'p:' + parts.join('/')
+ }
+
+ function loadExpanded () {
+ try {
+ var raw = sessionStorage.getItem(STORAGE_KEY)
+ var parsed = raw ? JSON.parse(raw) : []
+ return Array.isArray(parsed) ? parsed : []
+ } catch (e) {
+ return []
+ }
+ }
+
+ function saveExpanded (keys) {
+ try {
+ var unique = []
+ var seen = Object.create(null)
+ keys.forEach(function (k) {
+ if (!k || seen[k]) return
+ seen[k] = true
+ unique.push(k)
+ })
+ sessionStorage.setItem(STORAGE_KEY, JSON.stringify(unique))
+ } catch (e) {
+ /* private mode / quota - ignore */
+ }
+ }
+
+ function collectExpanded (menu) {
+ return [].slice.call(menu.querySelectorAll('.nav-item.is-active')).map(itemKey)
+ }
+
+ function applyExpanded (menu, keys) {
+ if (!keys || !keys.length) return
+ var want = Object.create(null)
+ keys.forEach(function (k) {
+ want[k] = true
+ })
+ menu.querySelectorAll('.nav-item').forEach(function (el) {
+ if (want[itemKey(el)]) el.classList.add('is-active')
+ })
+ }
+
+ function persistMenu (menu) {
+ if (!menu) return
+ saveExpanded(collectExpanded(menu))
+ }
+
+ function navScrollEl () {
+ return document.querySelector('.nav-container [data-panel=menu]')
+ }
+
+ function saveNavScrollNow () {
+ var el = navScrollEl()
+ if (!el) return
+ try {
+ sessionStorage.setItem(SCROLL_KEY, String(el.scrollTop || 0))
+ } catch (e) {
+ /* ignore */
+ }
+ }
+
+ function saveNavScroll () {
+ if (scrollSaveTimer != null) return
+ scrollSaveTimer = window.setTimeout(function () {
+ scrollSaveTimer = null
+ saveNavScrollNow()
+ }, 100)
+ }
+
+ function flushNavScroll () {
+ if (scrollSaveTimer != null) {
+ window.clearTimeout(scrollSaveTimer)
+ scrollSaveTimer = null
+ }
+ saveNavScrollNow()
+ }
+
+ function restoreNavScroll () {
+ var el = navScrollEl()
+ if (!el) return
+ var raw
+ try {
+ raw = sessionStorage.getItem(SCROLL_KEY)
+ } catch (e) {
+ return
+ }
+ if (raw == null || raw === '') return
+ var y = parseInt(raw, 10)
+ if (isNaN(y)) return
+ el.scrollTop = y
+ requestAnimationFrame(function () {
+ var again = navScrollEl()
+ if (again) again.scrollTop = y
+ })
+ }
+
+ /**
+ * After SoftNav swaps (or marks current), restore remembered expansion *and*
+ * the current-page path without collapsing non-current siblings the reader
+ * left open under other (or the same) component roots.
+ */
+ function siteNavTreeCurrent () {
+ var menu = document.querySelector('.nav-container [data-panel=menu]')
+ if (!menu) {
+ try {
+ document.documentElement.setAttribute('data-snt-nav-ready', '1')
+ } catch (e) {
+ /* ignore */
+ }
+ return
+ }
+
+ var remembered = loadExpanded()
+
+ var currents = [].slice.call(menu.querySelectorAll('.nav-item.is-current-page'))
+ if (!currents.length) {
+ applyExpanded(menu, remembered)
+ persistMenu(menu)
+ restoreNavScroll()
+ try {
+ document.documentElement.setAttribute('data-snt-nav-ready', '1')
+ } catch (e) {
+ /* ignore */
+ }
+ return
+ }
+
+ currents.sort(function (a, b) {
+ return (parseInt(b.getAttribute('data-depth'), 10) || 0) - (parseInt(a.getAttribute('data-depth'), 10) || 0)
+ })
+ var best = currents[0]
+
+ currents.forEach(function (el) {
+ if (el !== best) el.classList.remove('is-current-page')
+ })
+
+ menu.querySelectorAll('.nav-item.is-current-path').forEach(function (el) {
+ el.classList.remove('is-current-path')
+ })
+ menu.querySelectorAll('.nav-item.is-active').forEach(function (el) {
+ el.classList.remove('is-active')
+ })
+
+ var node = best
+ while (node && !(node.classList && node.classList.contains('nav-menu'))) {
+ if (node.tagName === 'LI' && node.classList.contains('nav-item')) {
+ node.classList.add('is-active', 'is-current-path')
+ }
+ node = node.parentNode
+ }
+ best.classList.add('is-active', 'is-current-page')
+
+ applyExpanded(menu, remembered)
+ persistMenu(menu)
+ restoreNavScroll()
+ try {
+ document.documentElement.setAttribute('data-snt-nav-ready', '1')
+ } catch (e) {
+ /* ignore */
+ }
+ }
+
+ /**
+ * SoftNav keeps the live forest DOM (no panel wipe). Mark the deepest nav-link
+ * matching destPath as is-current-page, then expand via siteNavTreeCurrent.
+ */
+ function markCurrentByPath (pathname) {
+ var menu = document.querySelector('.nav-container [data-panel=menu]')
+ if (!menu) return
+ var want = normalizeHref(pathname)
+ if (!want) return
+ var matches = []
+ menu.querySelectorAll('a.nav-link').forEach(function (a) {
+ if (normalizeHref(a.getAttribute('href') || a.href) === want) {
+ var li = a.closest('li.nav-item')
+ if (li) matches.push(li)
+ }
+ })
+ if (!matches.length) return
+ matches.sort(function (a, b) {
+ return (parseInt(b.getAttribute('data-depth'), 10) || 0) - (parseInt(a.getAttribute('data-depth'), 10) || 0)
+ })
+ menu.querySelectorAll('.nav-item.is-current-page').forEach(function (el) {
+ el.classList.remove('is-current-page')
+ })
+ matches[0].classList.add('is-current-page')
+ }
+
+ function onToggleClick (e) {
+ var toggle = e.target && e.target.closest && e.target.closest('.nav-item-toggle')
+ if (!toggle) return
+ var menu = document.querySelector('.nav-container [data-panel=menu]')
+ if (!menu || !menu.contains(toggle)) return
+ setTimeout(function () {
+ persistMenu(menu)
+ }, 0)
+ }
+
+ window.siteNavTreeCurrent = siteNavTreeCurrent
+ window.siteNavTreeMarkCurrentByPath = markCurrentByPath
+ window.siteNavTreeSaveScroll = flushNavScroll
+ window.siteNavTreeRestoreScroll = restoreNavScroll
+
+ document.addEventListener('click', onToggleClick)
+
+ // Debounced persist while the reader moves the left rail only.
+ document.addEventListener(
+ 'scroll',
+ function (e) {
+ var el = navScrollEl()
+ if (!el) return
+ if (e.target === el || (e.target && el.contains(e.target))) saveNavScroll()
+ },
+ true
+ )
+ function bindPanelScroll () {
+ var el = navScrollEl()
+ if (!el || el.getAttribute('data-snt-scroll-bound')) return
+ el.setAttribute('data-snt-scroll-bound', '1')
+ el.addEventListener('scroll', saveNavScroll, { passive: true })
+ }
+ bindPanelScroll()
+ window.addEventListener('beforeunload', flushNavScroll)
+ document.addEventListener(
+ 'click',
+ function (e) {
+ var a = e.target && e.target.closest && e.target.closest('a.nav-link')
+ if (!a) return
+ var panel = navScrollEl()
+ if (panel && panel.contains(a)) flushNavScroll()
+ },
+ true
+ )
+
+ siteNavTreeCurrent()
+ bindPanelScroll()
+
+ function onSoftNavLoaded (fn) {
+ if (window.SoftNav && typeof SoftNav.on === 'function') SoftNav.on('loaded', fn)
+ else document.addEventListener('soft-nav:loaded', function (e) { fn(e.detail || {}) })
+ }
+ function onSoftNavBefore (fn) {
+ if (window.SoftNav && typeof SoftNav.on === 'function') SoftNav.on('before', fn)
+ else document.addEventListener('soft-nav:before', function (e) { fn(e.detail || {}) })
+ }
+ onSoftNavBefore(function () {
+ flushNavScroll()
+ })
+ onSoftNavLoaded(function (detail) {
+ if (detail && detail.url && typeof markCurrentByPath === 'function') {
+ try {
+ markCurrentByPath(detail.url)
+ } catch (e) {
+ /* ignore */
+ }
+ }
+ siteNavTreeCurrent()
+ bindPanelScroll()
+ restoreNavScroll()
+ })
+})()
\ No newline at end of file
diff --git a/supplemental-ui/js/site-toc-main-article.js b/supplemental-ui/js/site-toc-main-article.js
new file mode 100644
index 0000000..67d6c8c
--- /dev/null
+++ b/supplemental-ui/js/site-toc-main-article.js
@@ -0,0 +1,117 @@
+/**
+ * Keep the right-rail Contents chrome even when the article has no section
+ * headings. Default UI site.js removes aside.toc.sidebar when its heading
+ * query is empty; SoftNav also leaves a stale TOC because it only swaps
+ * mast/nav/article.
+ *
+ * Prefer a "Main article" placeholder that jumps to the page title (or article
+ * top) so long pages still have a Contents affordance.
+ */
+;(function () {
+ 'use strict'
+
+ function ensureTitleId (article) {
+ var h1 = article.querySelector('h1.page, h1.adt-page-title, h1')
+ if (!h1) return null
+ if (!h1.id) h1.id = 'main-article'
+ return h1
+ }
+
+ function headingSelector (levels) {
+ var n = 'article.doc'
+ var parts = []
+ var i
+ for (i = 0; i <= levels; i++) {
+ var r = [n]
+ if (i) {
+ var c
+ for (c = 1; c <= i; c++) r.push((c === 2 ? '.sectionbody>' : '') + '.sect' + c)
+ r.push('h' + (i + 1) + '[id]' + (i > 1 ? ':not(.discrete)' : ''))
+ } else {
+ r.push('h1[id].sect0')
+ }
+ parts.push(r.join('>'))
+ }
+ return parts.join(',')
+ }
+
+ function ensureTocAside () {
+ var aside = document.querySelector('aside.toc.sidebar')
+ if (aside) return aside
+ var body = document.querySelector('.body.adt-body') || document.querySelector('.body')
+ if (!body) return null
+ aside = document.createElement('aside')
+ aside.className = 'toc sidebar'
+ aside.setAttribute('data-title', 'Contents')
+ aside.setAttribute('data-levels', '2')
+ var menu = document.createElement('div')
+ menu.className = 'toc-menu'
+ aside.appendChild(menu)
+ body.appendChild(aside)
+ return aside
+ }
+
+ function rebuildToc () {
+ if (document.body && document.body.classList.contains('-toc')) return
+ var article = document.querySelector('article.doc')
+ if (!article) return
+
+ var aside = ensureTocAside()
+ if (!aside) return
+
+ var levels = parseInt(aside.getAttribute('data-levels') || '2', 10)
+ if (isNaN(levels) || levels < 0) levels = 2
+
+ var headings = [].slice.call(article.querySelectorAll(headingSelector(levels)))
+ var menu = aside.querySelector('.toc-menu')
+ if (!menu) {
+ menu = document.createElement('div')
+ menu.className = 'toc-menu'
+ aside.appendChild(menu)
+ }
+ menu.innerHTML = ''
+
+ var title = document.createElement('h3')
+ title.textContent = aside.getAttribute('data-title') || 'Contents'
+ menu.appendChild(title)
+
+ var ul = document.createElement('ul')
+ menu.appendChild(ul)
+
+ if (!headings.length) {
+ var h1 = ensureTitleId(article)
+ var li = document.createElement('li')
+ li.dataset.level = '0'
+ var a = document.createElement('a')
+ a.textContent = 'Main article'
+ a.href = h1 ? '#' + h1.id : '#'
+ li.appendChild(a)
+ ul.appendChild(li)
+ return
+ }
+
+ headings.forEach(function (h) {
+ var li = document.createElement('li')
+ li.dataset.level = String(parseInt(h.nodeName.slice(1), 10) - 1)
+ var a = document.createElement('a')
+ a.textContent = h.textContent
+ a.href = '#' + h.id
+ li.appendChild(a)
+ ul.appendChild(li)
+ })
+ }
+
+ function onSoftNav (fn) {
+ if (window.SoftNav && typeof SoftNav.on === 'function') SoftNav.on('loaded', fn)
+ else document.addEventListener('soft-nav:loaded', function (e) { fn(e.detail || {}) })
+ }
+
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', rebuildToc)
+ } else {
+ rebuildToc()
+ }
+ onSoftNav(function () {
+ rebuildToc()
+ })
+})()
diff --git a/supplemental-ui/partials/breadcrumbs.hbs b/supplemental-ui/partials/breadcrumbs.hbs
new file mode 100644
index 0000000..ffe882b
--- /dev/null
+++ b/supplemental-ui/partials/breadcrumbs.hbs
@@ -0,0 +1,81 @@
+{{!
+ Hub breadcrumbs: Valentus mast (component + version + copyable "/" trail seps).
+ Only hub delta vs stock Valentus: component dropdown filtered to site-nav-tree
+ curated roots via `in-site-nav-tree` (plus the current component).
+}}
+
diff --git a/supplemental-ui/partials/footer-content.hbs b/supplemental-ui/partials/footer-content.hbs
index f5725ef..0551c05 100644
--- a/supplemental-ui/partials/footer-content.hbs
+++ b/supplemental-ui/partials/footer-content.hbs
@@ -1,12 +1,11 @@
-
-
-
-
-
-
+{{> mermaid-client-scripts}}
+{{> diagram-lightbox-scripts}}
+
+
+
diff --git a/supplemental-ui/partials/head-meta.hbs b/supplemental-ui/partials/head-meta.hbs
new file mode 100644
index 0000000..c15ef84
--- /dev/null
+++ b/supplemental-ui/partials/head-meta.hbs
@@ -0,0 +1,113 @@
+{{!--
+ HACK (interim paint-hold): hide until layered Valentus CSS + prefs + web fonts.
+ Vendored from valentus-theme 2.0.13+; keep brand/mermaid/math links with the
+ other stylesheets, before the fonts.ready release script.
+--}}
+
+
+
+
+
+
+
+
+
+
+
+{{!-- Valentus bundle CSS (vendored head-meta pattern) --}}
+{{> mermaid-client-styles}}
+{{> diagram-lightbox-styles}}
+
+
+
+
+{{!-- Independent nav vs article column scroll (after Valentus layout) --}}
+
+
+{{!-- read-width FOUC (same as ui-modules/packages/read-width/ui/js/adt-read-width-fouc.js) --}}
+
+{{!-- font-size FOUC (same as ui-modules/packages/font-size/ui/js/adt-font-size-fouc.js) --}}
+
+{{!-- Webfont FOUT: kick loads, then release paint-hold (3s timeout if CDN stalls). --}}
+
+{{#if page.origin.webUrl}}
+
+{{/if}}
diff --git a/supplemental-ui/partials/nav-menu.hbs b/supplemental-ui/partials/nav-menu.hbs
new file mode 100644
index 0000000..5ab96a4
--- /dev/null
+++ b/supplemental-ui/partials/nav-menu.hbs
@@ -0,0 +1,13 @@
+{{!
+ Companion partial for Valentus when @antora-supplemental/site-nav-tree is on.
+ Component titles live in the tree roots - do not also print adt-nav-component-title.
+ Keeps stock nav-tree partial + Default UI / Valentus expand-collapse JS.
+}}
+
diff --git a/supplemental-ui/partials/nav-tree.hbs b/supplemental-ui/partials/nav-tree.hbs
new file mode 100644
index 0000000..bdd6c79
--- /dev/null
+++ b/supplemental-ui/partials/nav-tree.hbs
@@ -0,0 +1,27 @@
+{{#if navigation.length}}
+
+{{/if}}
From d1422c00178c158c95d7285a1c5bf3751ae74b46 Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Sun, 27 Sep 2026 09:53:16 +0000
Subject: [PATCH 3/7] Wire Facto themed-SVG audits and DevCentr runtime pattern
Add verify-themed-svg-dark-mode Antora extension, vendored @dev-centr/themed-svg
browser runtime, sync/check scripts, shell-architecture upstream diagram audits,
and CI diagram-audit job. Document upstream theme manifest fix for label colors.
Co-authored-by: Ryan Johnson
---
.github/workflows/docs.yml | 36 +
CHANGELOG.adoc | 1 +
README.md | 3 +
antora-playbook-local.yml | 1 +
antora-playbook.yml | 1 +
lib/verify-themed-svg-dark-mode.js | 208 ++
package.json | 14 +-
pnpm-lock.yaml | 60 +
scripts/audit-themed-svg-gaps.mjs | 70 +
scripts/check-shell-architecture-diagrams.mjs | 27 +
scripts/shell-architecture-themed-svg-fix.md | 18 +
scripts/sync-themed-svg-runtime.mjs | 19 +
supplemental-ui/js/site-themed-svg.js | 19 +-
.../js/vendor/themed-svg-element.js | 2229 +++++++++++++++++
test/verify-themed-svg-dark-mode.test.js | 69 +
15 files changed, 2769 insertions(+), 6 deletions(-)
create mode 100644 lib/verify-themed-svg-dark-mode.js
create mode 100644 scripts/audit-themed-svg-gaps.mjs
create mode 100644 scripts/check-shell-architecture-diagrams.mjs
create mode 100644 scripts/shell-architecture-themed-svg-fix.md
create mode 100644 scripts/sync-themed-svg-runtime.mjs
create mode 100644 supplemental-ui/js/vendor/themed-svg-element.js
create mode 100644 test/verify-themed-svg-dark-mode.test.js
diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
index bb77bdf..d686e7d 100644
--- a/.github/workflows/docs.yml
+++ b/.github/workflows/docs.yml
@@ -5,12 +5,17 @@ on:
branches: [main, master]
paths:
- "docs/**"
+ - "lib/**"
+ - "scripts/**"
+ - "test/**"
- "supplemental-ui/**"
- "antora-playbook.yml"
- "antora-playbook-local.yml"
- "package.json"
- "pnpm-lock.yaml"
- ".github/workflows/docs.yml"
+ pull_request:
+ branches: [main, master]
workflow_dispatch:
repository_dispatch:
types: [docs-rebuild]
@@ -27,9 +32,36 @@ concurrency:
cancel-in-progress: true
jobs:
+ diagram-audit:
+ name: Themed SVG and diagram checks
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup pnpm
+ uses: pnpm/action-setup@v4
+
+ - name: Setup Node
+ uses: actions/setup-node@v4
+ with:
+ node-version: "22"
+ cache: "pnpm"
+ cache-dependency-path: pnpm-lock.yaml
+
+ - name: Install dependencies
+ run: pnpm install --frozen-lockfile
+
+ - name: Sync themed-svg browser runtime
+ run: pnpm run diagrams:sync
+
+ - name: Run diagram and dark-mode audits
+ run: pnpm run diagrams:check:all
+
build:
name: Build Antora docs
runs-on: ubuntu-latest
+ needs: diagram-audit
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -47,6 +79,9 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile
+ - name: Sync themed-svg browser runtime
+ run: pnpm run diagrams:sync
+
- name: Build Antora docs
id: antora
uses: antora-supplemental/antora-build-action@v2
@@ -69,6 +104,7 @@ jobs:
name: Deploy to GitHub Pages
runs-on: ubuntu-latest
needs: build
+ if: github.event_name != 'pull_request'
environment:
name: github-pages
url: ${{ steps.deploy.outputs.page_url }}
diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc
index b41137f..5053af5 100644
--- a/CHANGELOG.adoc
+++ b/CHANGELOG.adoc
@@ -5,6 +5,7 @@
* Adopt `@antora-supplemental/facto-stack` wiring: `indexify` URLs, site-nav-tree curated sidebar roots, nav-typology (+ Diátaxis), page-context/edit, link-validator, linkinator, extension-lister, orphan-finder, build-stack footer, client Mermaid + diagram lightbox, Kroki bake for non-Mermaid diagrams.
* Playbook content source: `openshellorg/actor-shell` → `Desktop-Tooling/grammaton-ui-shell` (component `grammaton-ui-shell`).
* Supplemental UI: nav-tree / typology partials and helpers from the Facto compose pattern (DevCentr reference).
+* Diagram engines: client Mermaid + diagram-lightbox + Kroki bake; `[.themed-svg]` host runtime vendored from `@dev-centr/themed-svg`; Antora `verify-themed-svg-dark-mode` extension; `pnpm diagrams:check:all` audits upstream shell-architecture diagram staleness and dark-mode text gaps in CI.
== 2026-09-26 -- Config Key Sanitation renamed to Config Lifecycle Management
diff --git a/README.md b/README.md
index e94cfbc..b421c61 100644
--- a/README.md
+++ b/README.md
@@ -103,6 +103,9 @@ pnpm build
pnpm docs
# or local sibling checkouts:
pnpm docs:local
+
+# Themed SVG / diagram audits (Facto compose; fails on stale or dark-mode text gaps)
+pnpm diagrams:check:all
```
Published docs: https://docs.opensh.org/
diff --git a/antora-playbook-local.yml b/antora-playbook-local.yml
index bb4d494..d41a446 100644
--- a/antora-playbook-local.yml
+++ b/antora-playbook-local.yml
@@ -35,6 +35,7 @@ antora:
- require: '@antora-supplemental/nav-typology'
- require: '@antora-supplemental/nav-typology-diataxis'
- '@antora-supplemental/build-stack'
+ - ./lib/verify-themed-svg-dark-mode.js
- '@antora-supplemental/page-edit'
- require: '@antora-supplemental/link-validator'
reportEmail: openshell@devcentr.org
diff --git a/antora-playbook.yml b/antora-playbook.yml
index 889c7e4..ed3318a 100644
--- a/antora-playbook.yml
+++ b/antora-playbook.yml
@@ -39,6 +39,7 @@ antora:
- require: '@antora-supplemental/nav-typology'
- require: '@antora-supplemental/nav-typology-diataxis'
- '@antora-supplemental/build-stack'
+ - ./lib/verify-themed-svg-dark-mode.js
- '@antora-supplemental/page-edit'
- require: '@antora-supplemental/link-validator'
reportEmail: openshell@devcentr.org
diff --git a/lib/verify-themed-svg-dark-mode.js b/lib/verify-themed-svg-dark-mode.js
new file mode 100644
index 0000000..da092e1
--- /dev/null
+++ b/lib/verify-themed-svg-dark-mode.js
@@ -0,0 +1,208 @@
+'use strict'
+
+const fs = require('node:fs')
+const path = require('node:path')
+
+/**
+ * Antora extension: WARN (once per finding) when Themed SVG adaptive images
+ * leave dark-mode text hard to read.
+ *
+ * Borrow-from-light: dark preset may reuse light values (optional borrowFromLight[]).
+ * Parent -> child: light-stable surfaces keep nested text stable; WARN when a changed
+ * darker surface/canvas still has light-mode text left behind.
+ */
+
+function pictureName (fileLabel) {
+ if (!fileLabel) return 'diagram'
+ try {
+ return path.basename(String(fileLabel))
+ } catch {
+ return String(fileLabel)
+ }
+}
+
+function normalizeColor (value) {
+ if (typeof value !== 'string') return ''
+ return value.trim().toLowerCase().replace(/\s+/g, '')
+}
+
+function sameColor (a, b) {
+ return normalizeColor(a) !== '' && normalizeColor(a) === normalizeColor(b)
+}
+
+function borrowedTokenIds (manifest) {
+ const out = new Set()
+ if (!manifest?.presets?.light || !manifest?.presets?.dark) return out
+ const { light, dark } = manifest.presets
+ for (const id of Object.keys(light)) {
+ if (Object.prototype.hasOwnProperty.call(dark, id) && sameColor(light[id], dark[id])) out.add(id)
+ }
+ const explicit = manifest.borrowFromLight || manifest['x-devcentr-borrow-from-light'] || []
+ if (Array.isArray(explicit)) for (const id of explicit) out.add(id)
+ return out
+}
+
+function textTokensInside (surfaceToken, allTokenIds) {
+ const ids = allTokenIds instanceof Set ? allTokenIds : new Set(allTokenIds)
+ const related = []
+ if (surfaceToken === 'color.canvas') {
+ for (const id of ids) {
+ if (id === 'color.text.primary' || id === 'color.text.muted') related.push(id)
+ }
+ return related
+ }
+ const m = surfaceToken.match(/^color\.surface\.(.+)$/)
+ if (!m) return related
+ const leaf = m[1]
+ for (const candidate of [`color.text.on-${leaf}`, `color.text.${leaf}`, `color.text.on.${leaf}`]) {
+ if (ids.has(candidate)) related.push(candidate)
+ }
+ return related
+}
+
+function isDarkerBackground (lightHex, darkHex) {
+ const lum = (hex) => {
+ const h = normalizeColor(hex).replace('#', '')
+ if (!/^([0-9a-f]{3}|[0-9a-f]{6})$/.test(h)) return null
+ const full = h.length === 3 ? h.split('').map((c) => c + c).join('') : h
+ const r = parseInt(full.slice(0, 2), 16) / 255
+ const g = parseInt(full.slice(2, 4), 16) / 255
+ const b = parseInt(full.slice(4, 6), 16) / 255
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b
+ }
+ const a = lum(lightHex)
+ const b = lum(darkHex)
+ if (a == null || b == null) return darkHex !== lightHex
+ return b < a - 0.05
+}
+
+function findDarkModeTextGaps (svg, fileLabel, manifest) {
+ const gaps = []
+ const name = pictureName(fileLabel)
+ if (!/prefers-color-scheme:\s*dark/.test(svg)) return gaps
+ if (!svg.includes('--themed-svg-')) {
+ gaps.push(
+ `Dark-mode picture ${name} is set up for dark mode but is missing theme colors, so text may not adapt.`
+ )
+ return gaps
+ }
+
+ const nsMatch = svg.match(/--themed-svg-([a-z0-9-]+)-color-text-primary/)
+ const ns = (manifest && manifest.namespace) || (nsMatch && nsMatch[1])
+ const textVar = ns
+ ? new RegExp(`fill:var\\(--themed-svg-${ns}-color-text-primary`)
+ : /fill:var\(--themed-svg-[a-z0-9-]+-color-text-primary/
+
+ const borrowed = borrowedTokenIds(manifest)
+ const light = (manifest && manifest.presets && manifest.presets.light) || {}
+ const dark = (manifest && manifest.presets && manifest.presets.dark) || {}
+ const allIds = new Set([...Object.keys(light), ...Object.keys(dark)])
+
+ const canvasBorrowed = borrowed.has('color.canvas')
+ const textPrimaryBorrowed = borrowed.has('color.text.primary')
+ const lightStableFigure = canvasBorrowed && textPrimaryBorrowed
+ const canvasChangedToDark =
+ light['color.canvas'] &&
+ dark['color.canvas'] &&
+ !canvasBorrowed &&
+ isDarkerBackground(light['color.canvas'], dark['color.canvas'])
+
+ const rootRule = svg.match(/#my-svg\{[^}]*\}/)
+ if (rootRule) {
+ const hardcoded = /fill:\s*#([0-9a-fA-F]{3,8})|fill:\s*rgb\(/.test(rootRule[0]) && !textVar.test(rootRule[0])
+ if (hardcoded && !lightStableFigure) {
+ gaps.push(
+ `Dark-mode picture ${name} still has dark text that will be hard to read. Try lighter text for dark backgrounds.`
+ )
+ }
+ }
+
+ const labelRule = svg.match(/#my-svg \.label\{[^}]*\}/)
+ if (
+ labelRule &&
+ /color:\s*#([0-9a-fA-F]{3,8})|color:\s*rgb\(0,\s*0,\s*0\)/.test(labelRule[0]) &&
+ !/color:var\(--themed-svg-/.test(labelRule[0]) &&
+ !lightStableFigure
+ ) {
+ gaps.push(
+ `Dark-mode picture ${name} still has dark labels that will be hard to read. Try lighter label text for dark backgrounds.`
+ )
+ }
+
+ if (canvasChangedToDark && textPrimaryBorrowed) {
+ gaps.push(
+ `Dark-mode picture ${name}: the background got darker, but the text still uses light-mode colors and will be hard to read. Use lighter text for dark backgrounds.`
+ )
+ }
+
+ for (const surfaceId of allIds) {
+ if (!surfaceId.startsWith('color.surface.')) continue
+ const childTextIds = textTokensInside(surfaceId, allIds)
+ for (const textId of childTextIds) {
+ if (!light[textId] || !dark[textId]) continue
+ const surfaceBorrowed = borrowed.has(surfaceId)
+ const textBorrowed = borrowed.has(textId)
+ const surfaceChanged =
+ light[surfaceId] && dark[surfaceId] && !surfaceBorrowed && !sameColor(light[surfaceId], dark[surfaceId])
+ if (surfaceBorrowed && !textBorrowed && !sameColor(light[textId], dark[textId])) {
+ gaps.push(
+ `Dark-mode picture ${name}: a box stays light-colored in dark mode, but text inside it was recolored for dark mode. Keep that text matching the light look too.`
+ )
+ }
+ if (surfaceChanged && isDarkerBackground(light[surfaceId], dark[surfaceId]) && textBorrowed) {
+ gaps.push(
+ `Dark-mode picture ${name}: a panel got darker in dark mode, but text on it still uses light-mode colors and will be hard to read. Use lighter text on that panel.`
+ )
+ }
+ }
+ }
+
+ return gaps
+}
+
+function loadSiblingManifest (svgPath) {
+ if (!svgPath) return null
+ const base = path.basename(svgPath, '.svg')
+ const candidates = [
+ svgPath.replace(/\.svg$/i, '.theme.json'),
+ path.join(path.dirname(svgPath), '..', 'partials', 'diagrams', `${base}.theme.json`),
+ ]
+ for (const themePath of candidates) {
+ try {
+ if (fs.existsSync(themePath)) return JSON.parse(fs.readFileSync(themePath, 'utf8'))
+ } catch {
+ /* ignore */
+ }
+ }
+ return null
+}
+
+module.exports.register = function () {
+ this.on('contentClassified', ({ contentCatalog }) => {
+ const logger = this.getLogger('themed-svg-dark-text')
+ for (const file of contentCatalog.getFiles()) {
+ const srcPath = file.src && file.src.path ? file.src.path : file.path
+ if (!srcPath || !srcPath.endsWith('.svg')) continue
+ if (/\.(host|fixed)\.svg$/.test(srcPath)) continue
+
+ let svg
+ try {
+ svg = file.contents.toString('utf8')
+ } catch {
+ continue
+ }
+ if (!svg.includes('prefers-color-scheme')) continue
+
+ const abspath = file.src && file.src.abspath ? file.src.abspath : null
+ const manifest = loadSiblingManifest(abspath)
+ const label = abspath || srcPath
+ for (const gap of findDarkModeTextGaps(svg, label, manifest)) {
+ logger.warn(gap)
+ }
+ }
+ })
+}
+
+module.exports.findDarkModeTextGaps = findDarkModeTextGaps
+module.exports.borrowedTokenIds = borrowedTokenIds
+module.exports.pictureName = pictureName
\ No newline at end of file
diff --git a/package.json b/package.json
index b1be35b..301be2d 100644
--- a/package.json
+++ b/package.json
@@ -7,6 +7,12 @@
"build": "pnpm -r build",
"docs": "antora antora-playbook.yml",
"docs:local": "antora antora-playbook-local.yml",
+ "diagrams:sync": "node scripts/sync-themed-svg-runtime.mjs",
+ "diagrams:check": "node scripts/sync-themed-svg-runtime.mjs --check",
+ "diagrams:check:shell-architecture": "node scripts/check-shell-architecture-diagrams.mjs",
+ "diagrams:audit:dark-text": "node scripts/audit-themed-svg-gaps.mjs",
+ "diagrams:check:all": "pnpm diagrams:check && pnpm diagrams:check:shell-architecture && pnpm diagrams:audit:dark-text && pnpm test:dark-text",
+ "test:dark-text": "node --test test/verify-themed-svg-dark-mode.test.js",
"format": "dprint fmt",
"format:check": "dprint check"
},
@@ -29,6 +35,12 @@
"@antora-supplemental/site-nav-tree": "github:antora-supplemental/site-nav-tree#54b6cbb",
"@antora-supplemental/unversioned-component-urls": "github:antora-supplemental/antora-unversioned-component-urls#v2.1.0",
"@antora/cli": "^3.1.14",
- "@antora/site-generator": "^3.1.14"
+ "@antora/site-generator": "^3.1.14",
+ "@dev-centr/themed-svg": "0.2.3"
+ },
+ "pnpm": {
+ "onlyBuiltDependencies": [
+ "puppeteer"
+ ]
}
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 751962e..7db41b0 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -60,6 +60,9 @@ importers:
'@antora/site-generator':
specifier: ^3.1.14
version: 3.1.15
+ '@dev-centr/themed-svg':
+ specifier: 0.2.3
+ version: 0.2.3
apps/main:
dependencies:
@@ -400,6 +403,11 @@ packages:
'@deno/shim-deno@0.19.2':
resolution: {integrity: sha512-q3VTHl44ad8T2Tw2SpeAvghdGOjlnLPDNO2cpOxwMrBE/PVas6geWpbpIgrM+czOCH0yejp0yi8OaTuB+NU40Q==}
+ '@dev-centr/themed-svg@0.2.3':
+ resolution: {integrity: sha512-mEd90oVCPTj3Bj8wSQ9gKgJdHiM6JDbGYlVyM5sm0RZJYkwStDk8bM0RQ/fq0P4263nN96qHxlk0zPYrMs0E3Q==}
+ engines: {node: '>=20'}
+ hasBin: true
+
'@esbuild/aix-ppc64@0.25.12':
resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==}
engines: {node: '>=18'}
@@ -1184,6 +1192,9 @@ packages:
'@types/resolve@1.20.2':
resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==}
+ '@types/trusted-types@2.0.7':
+ resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
+
'@types/unist@3.0.3':
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
@@ -1210,6 +1221,10 @@ packages:
peerDependencies:
vinxi: ^0.5.5
+ '@xmldom/xmldom@0.8.15':
+ resolution: {integrity: sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA==}
+ engines: {node: '>=10.0.0'}
+
abbrev@3.0.1:
resolution: {integrity: sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==}
engines: {node: ^18.17.0 || >=20.5.0}
@@ -1483,6 +1498,10 @@ packages:
color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
+ color-name@2.1.1:
+ resolution: {integrity: sha512-p2FdgwVx1a9yWBHP2wI0VgShkDpgN4kZISkxdNipGBJWpa5G6b04OINlVWCyJj0JmfvcPrgqt95E9k8yvaOJFg==}
+ engines: {node: '>=12.20'}
+
colorette@2.0.20:
resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==}
@@ -1664,6 +1683,9 @@ packages:
resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
engines: {node: '>= 4'}
+ dompurify@3.4.16:
+ resolution: {integrity: sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ==}
+
domutils@3.2.2:
resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==}
@@ -2515,6 +2537,9 @@ packages:
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
engines: {node: '>= 0.4'}
+ postcss-value-parser@4.2.0:
+ resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==}
+
postcss@8.5.6:
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
engines: {node: ^10 || ^12 || >=14}
@@ -2677,6 +2702,10 @@ packages:
resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==}
engines: {node: '>=10'}
+ saxes@6.0.0:
+ resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
+ engines: {node: '>=v12.22.7'}
+
scule@1.3.0:
resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==}
@@ -3217,6 +3246,9 @@ packages:
resolution: {integrity: sha512-1Dly4xqlulvPD3fZUQJLY+FUIeqN3N2MM3uqe4rCJftAvOjFa3jFGfctOgluGx4ahPbUCsZkmJILiP0Vi4T6lQ==}
engines: {node: '>=4'}
+ xmlchars@2.2.0:
+ resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
+
y18n@5.0.8:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'}
@@ -3623,6 +3655,15 @@ snapshots:
'@deno/shim-deno-test': 0.5.0
which: 4.0.0
+ '@dev-centr/themed-svg@0.2.3':
+ dependencies:
+ '@xmldom/xmldom': 0.8.15
+ color-name: 2.1.1
+ dompurify: 3.4.16
+ postcss: 8.5.6
+ postcss-value-parser: 4.2.0
+ saxes: 6.0.0
+
'@esbuild/aix-ppc64@0.25.12':
optional: true
@@ -4229,6 +4270,9 @@ snapshots:
'@types/resolve@1.20.2': {}
+ '@types/trusted-types@2.0.7':
+ optional: true
+
'@types/unist@3.0.3': {}
'@ungap/structured-clone@1.3.0': {}
@@ -4296,6 +4340,8 @@ snapshots:
recast: 0.23.11
vinxi: 0.5.11(db0@0.3.4)(ioredis@5.9.2)(jiti@2.6.1)(terser@5.46.0)
+ '@xmldom/xmldom@0.8.15': {}
+
abbrev@3.0.1: {}
abort-controller@3.0.0:
@@ -4567,6 +4613,8 @@ snapshots:
color-name@1.1.4: {}
+ color-name@2.1.1: {}
+
colorette@2.0.20: {}
comma-separated-tokens@2.0.3: {}
@@ -4692,6 +4740,10 @@ snapshots:
dependencies:
domelementtype: 2.3.0
+ dompurify@3.4.16:
+ optionalDependencies:
+ '@types/trusted-types': 2.0.7
+
domutils@3.2.2:
dependencies:
dom-serializer: 2.0.0
@@ -5662,6 +5714,8 @@ snapshots:
possible-typed-array-names@1.1.0: {}
+ postcss-value-parser@4.2.0: {}
+
postcss@8.5.6:
dependencies:
nanoid: 3.3.11
@@ -5835,6 +5889,10 @@ snapshots:
safe-stable-stringify@2.5.0: {}
+ saxes@6.0.0:
+ dependencies:
+ xmlchars: 2.2.0
+
scule@1.3.0: {}
secure-json-parse@2.7.0: {}
@@ -6473,6 +6531,8 @@ snapshots:
xdg-basedir@3.0.0: {}
+ xmlchars@2.2.0: {}
+
y18n@5.0.8: {}
yallist@3.1.1: {}
diff --git a/scripts/audit-themed-svg-gaps.mjs b/scripts/audit-themed-svg-gaps.mjs
new file mode 100644
index 0000000..31e0e9f
--- /dev/null
+++ b/scripts/audit-themed-svg-gaps.mjs
@@ -0,0 +1,70 @@
+/**
+ * Fail when adaptive Themed SVG figures have dark-mode text contrast gaps.
+ * Scans shell-architecture committed images (same layout as Antora aggregated content).
+ */
+import { execFileSync } from 'node:child_process'
+import { existsSync, mkdtempSync, readFileSync, readdirSync, statSync, rmSync } from 'node:fs'
+import { basename, dirname, join } from 'node:path'
+import { tmpdir } from 'node:os'
+import { findDarkModeTextGaps } from '../lib/verify-themed-svg-dark-mode.js'
+
+const repo = 'https://github.com/openshellorg/shell-architecture.git'
+const branch = process.env.SHELL_ARCHITECTURE_REF || 'main'
+const imageDirRel = 'docs/modules/ROOT/images'
+
+function cloneWorktree () {
+ const dir = mkdtempSync(join(tmpdir(), 'shell-architecture-audit-'))
+ execFileSync('git', ['clone', '--depth', '1', '--branch', branch, repo, dir], { stdio: 'inherit' })
+ return dir
+}
+
+function manifestForSvg (svgPath) {
+ const base = basename(svgPath, '.svg')
+ const candidates = [
+ svgPath.replace(/\.svg$/i, '.theme.json'),
+ join(dirname(svgPath), '..', 'partials', 'diagrams', `${base}.theme.json`),
+ ]
+ for (const themePath of candidates) {
+ try {
+ if (existsSync(themePath)) return JSON.parse(readFileSync(themePath, 'utf8'))
+ } catch {
+ /* ignore */
+ }
+ }
+ return null
+}
+
+function listAdaptiveSvgs (dir) {
+ const out = []
+ for (const name of readdirSync(dir)) {
+ const full = join(dir, name)
+ if (!statSync(full).isFile()) continue
+ if (!name.endsWith('.svg')) continue
+ if (/\.(host|fixed)\.svg$/.test(name)) continue
+ out.push(full)
+ }
+ return out
+}
+
+const root = cloneWorktree()
+try {
+ const imageDir = join(root, imageDirRel)
+ const failures = []
+ for (const svgPath of listAdaptiveSvgs(imageDir)) {
+ const svg = readFileSync(svgPath, 'utf8')
+ if (!svg.includes('prefers-color-scheme')) continue
+ const manifest = manifestForSvg(svgPath)
+ if (!manifest) continue
+ const gaps = findDarkModeTextGaps(svg, svgPath, manifest)
+ for (const gap of gaps) failures.push(gap)
+ }
+ if (failures.length) {
+ console.error('Themed SVG dark-mode audit failed:\n')
+ for (const line of failures) console.error(`- ${line}`)
+ process.exitCode = 1
+ } else {
+ console.log('Themed SVG dark-mode audit: no gaps in shell-architecture adaptive figures.')
+ }
+} finally {
+ rmSync(root, { recursive: true, force: true })
+}
diff --git a/scripts/check-shell-architecture-diagrams.mjs b/scripts/check-shell-architecture-diagrams.mjs
new file mode 100644
index 0000000..aa5efdd
--- /dev/null
+++ b/scripts/check-shell-architecture-diagrams.mjs
@@ -0,0 +1,27 @@
+/**
+ * Run openshellorg/shell-architecture diagram staleness checks (mermaid-svg-css-vars pipeline).
+ */
+import { execFileSync } from 'node:child_process'
+import { mkdtempSync, rmSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+
+const repo = 'https://github.com/openshellorg/shell-architecture.git'
+const branch = process.env.SHELL_ARCHITECTURE_REF || 'main'
+
+const worktree = mkdtempSync(join(tmpdir(), 'shell-architecture-diagram-check-'))
+try {
+ execFileSync('git', ['clone', '--depth', '1', '--branch', branch, repo, worktree], { stdio: 'inherit' })
+ execFileSync('pnpm', ['install', '--frozen-lockfile'], { cwd: worktree, stdio: 'inherit' })
+ execFileSync('pnpm', ['run', 'diagrams:check'], { cwd: worktree, stdio: 'inherit' })
+} catch (err) {
+ console.error(
+ '\nUpstream shell-architecture diagram check failed. Regenerate with `pnpm diagrams` in openshellorg/shell-architecture',
+ )
+ console.error(
+ 'and ensure theme manifests include #my-svg .label color binding for dark-mode labels.\n',
+ )
+ throw err
+} finally {
+ rmSync(worktree, { recursive: true, force: true })
+}
diff --git a/scripts/shell-architecture-themed-svg-fix.md b/scripts/shell-architecture-themed-svg-fix.md
new file mode 100644
index 0000000..556520a
--- /dev/null
+++ b/scripts/shell-architecture-themed-svg-fix.md
@@ -0,0 +1,18 @@
+Apply in openshellorg/shell-architecture so docs hub `pnpm diagrams:check:all` passes:
+
+1. In each `docs/modules/ROOT/partials/diagrams/*.theme.json`, add:
+
+```json
+{
+ "kind": "stylesheet",
+ "selector": "#my-svg .label",
+ "property": "color",
+ "token": "color.text.primary"
+}
+```
+
+2. Run `pnpm install && pnpm diagrams && pnpm diagrams:check` and commit the regenerated
+ `docs/modules/ROOT/images/*.svg` and `*.host.svg` pairs.
+
+Without this, Antora `verify-themed-svg-dark-mode` warns on edge labels in dark mode, and hub CI
+diagram audits fail against `main`.
diff --git a/scripts/sync-themed-svg-runtime.mjs b/scripts/sync-themed-svg-runtime.mjs
new file mode 100644
index 0000000..c468434
--- /dev/null
+++ b/scripts/sync-themed-svg-runtime.mjs
@@ -0,0 +1,19 @@
+import { copyFile, mkdir, readFile } from 'node:fs/promises'
+import { dirname, resolve } from 'node:path'
+
+const source = resolve('node_modules/@dev-centr/themed-svg/browser/themed-svg-element.js')
+const destination = resolve('supplemental-ui/js/vendor/themed-svg-element.js')
+const check = process.argv.includes('--check')
+
+if (check) {
+ const [published, committed] = await Promise.all([
+ readFile(source),
+ readFile(destination),
+ ])
+ if (!published.equals(committed)) {
+ throw new Error('themed SVG browser runtime is stale; run pnpm diagrams:generate')
+ }
+} else {
+ await mkdir(dirname(destination), { recursive: true })
+ await copyFile(source, destination)
+}
diff --git a/supplemental-ui/js/site-themed-svg.js b/supplemental-ui/js/site-themed-svg.js
index 5f9bc5e..de3e473 100644
--- a/supplemental-ui/js/site-themed-svg.js
+++ b/supplemental-ui/js/site-themed-svg.js
@@ -1,11 +1,20 @@
-import { upgradeThemedSvgImages } from "https://cdn.jsdelivr.net/npm/@dev-centr/themed-svg@0.1.1/browser/themed-svg-element.js"
+import { upgradeThemedSvgImages } from './vendor/themed-svg-element.js'
-upgradeThemedSvgImages(document, {
- selector: ".imageblock.themed-svg img",
-})
+function upgradeMarkedDiagrams () {
+ document.querySelectorAll('.imageblock.themed-svg img').forEach((image) => {
+ image.setAttribute('data-themed-svg', '')
+ })
+ upgradeThemedSvgImages()
+}
+
+if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', upgradeMarkedDiagrams, { once: true })
+} else {
+ upgradeMarkedDiagrams()
+}
function onSoftNavLoaded (fn) {
if (window.SoftNav && typeof SoftNav.on === 'function') SoftNav.on('loaded', fn)
else document.addEventListener('soft-nav:loaded', function (e) { fn(e.detail || {}) })
}
-onSoftNavLoaded(function () { upgradeThemedSvgImages(document, { selector: ".imageblock.themed-svg img" }) })
+onSoftNavLoaded(function () { upgradeMarkedDiagrams() })
diff --git a/supplemental-ui/js/vendor/themed-svg-element.js b/supplemental-ui/js/vendor/themed-svg-element.js
new file mode 100644
index 0000000..c222520
--- /dev/null
+++ b/supplemental-ui/js/vendor/themed-svg-element.js
@@ -0,0 +1,2229 @@
+var __defProp = Object.defineProperty;
+var __getOwnPropNames = Object.getOwnPropertyNames;
+var __esm = (fn, res, err) => function __init() {
+ if (err) throw err[0];
+ try {
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
+ } catch (e) {
+ throw err = [e], e;
+ }
+};
+var __export = (target, all) => {
+ for (var name in all)
+ __defProp(target, name, { get: all[name], enumerable: true });
+};
+
+// node_modules/.pnpm/dompurify@3.4.15/node_modules/dompurify/dist/purify.es.mjs
+var purify_es_exports = {};
+__export(purify_es_exports, {
+ default: () => purify
+});
+function _arrayLikeToArray(r, a) {
+ (null == a || a > r.length) && (a = r.length);
+ for (var e = 0, n = Array(a); e < a; e++) n[e] = r[e];
+ return n;
+}
+function _arrayWithHoles(r) {
+ if (Array.isArray(r)) return r;
+}
+function _iterableToArrayLimit(r, l) {
+ var t = null == r ? null : "undefined" != typeof Symbol && r[Symbol.iterator] || r["@@iterator"];
+ if (null != t) {
+ var e, n, i, u, a = [], f = true, o = false;
+ try {
+ if (i = (t = t.call(r)).next, 0 === l) ;
+ else for (; !(f = (e = i.call(t)).done) && (a.push(e.value), a.length !== l); f = true) ;
+ } catch (r2) {
+ o = true, n = r2;
+ } finally {
+ try {
+ if (!f && null != t.return && (u = t.return(), Object(u) !== u)) return;
+ } finally {
+ if (o) throw n;
+ }
+ }
+ return a;
+ }
+}
+function _nonIterableRest() {
+ throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.");
+}
+function _slicedToArray(r, e) {
+ return _arrayWithHoles(r) || _iterableToArrayLimit(r, e) || _unsupportedIterableToArray(r, e) || _nonIterableRest();
+}
+function _unsupportedIterableToArray(r, a) {
+ if (r) {
+ if ("string" == typeof r) return _arrayLikeToArray(r, a);
+ var t = {}.toString.call(r).slice(8, -1);
+ return "Object" === t && r.constructor && (t = r.constructor.name), "Map" === t || "Set" === t ? Array.from(r) : "Arguments" === t || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(t) ? _arrayLikeToArray(r, a) : void 0;
+ }
+}
+function unapply(func) {
+ return function(thisArg) {
+ if (thisArg instanceof RegExp) {
+ thisArg.lastIndex = 0;
+ }
+ for (var _len3 = arguments.length, args = new Array(_len3 > 1 ? _len3 - 1 : 0), _key3 = 1; _key3 < _len3; _key3++) {
+ args[_key3 - 1] = arguments[_key3];
+ }
+ return apply(func, thisArg, args);
+ };
+}
+function unconstruct(Func) {
+ return function() {
+ for (var _len4 = arguments.length, args = new Array(_len4), _key4 = 0; _key4 < _len4; _key4++) {
+ args[_key4] = arguments[_key4];
+ }
+ return construct(Func, args);
+ };
+}
+function addToSet(set, array) {
+ let transformCaseFunc = arguments.length > 2 && arguments[2] !== void 0 ? arguments[2] : stringToLowerCase;
+ if (setPrototypeOf) {
+ setPrototypeOf(set, null);
+ }
+ if (!arrayIsArray(array)) {
+ return set;
+ }
+ let l = array.length;
+ while (l--) {
+ let element = array[l];
+ if (typeof element === "string") {
+ const lcElement = transformCaseFunc(element);
+ if (lcElement !== element) {
+ if (!isFrozen(array)) {
+ array[l] = lcElement;
+ }
+ element = lcElement;
+ }
+ }
+ set[element] = true;
+ }
+ return set;
+}
+function cleanArray(array) {
+ for (let index = 0; index < array.length; index++) {
+ const isPropertyExist = objectHasOwnProperty(array, index);
+ if (!isPropertyExist) {
+ array[index] = null;
+ }
+ }
+ return array;
+}
+function clone(object) {
+ const newObject = create(null);
+ for (const _ref2 of entries(object)) {
+ var _ref3 = _slicedToArray(_ref2, 2);
+ const property = _ref3[0];
+ const value = _ref3[1];
+ const isPropertyExist = objectHasOwnProperty(object, property);
+ if (isPropertyExist) {
+ if (arrayIsArray(value)) {
+ newObject[property] = cleanArray(value);
+ } else if (value && typeof value === "object" && value.constructor === Object) {
+ newObject[property] = clone(value);
+ } else {
+ newObject[property] = value;
+ }
+ }
+ }
+ return newObject;
+}
+function stringifyValue(value) {
+ switch (typeof value) {
+ case "string": {
+ return value;
+ }
+ case "number": {
+ return numberToString(value);
+ }
+ case "boolean": {
+ return booleanToString(value);
+ }
+ case "bigint": {
+ return bigintToString ? bigintToString(value) : "0";
+ }
+ case "symbol": {
+ return symbolToString ? symbolToString(value) : "Symbol()";
+ }
+ case "undefined": {
+ return objectToString(value);
+ }
+ case "function":
+ case "object": {
+ if (value === null) {
+ return objectToString(value);
+ }
+ const valueAsRecord = value;
+ const valueToString = lookupGetter(valueAsRecord, "toString");
+ if (typeof valueToString === "function") {
+ const stringified = valueToString(valueAsRecord);
+ return typeof stringified === "string" ? stringified : objectToString(stringified);
+ }
+ return objectToString(value);
+ }
+ default: {
+ return objectToString(value);
+ }
+ }
+}
+function lookupGetter(object, prop) {
+ while (object !== null) {
+ const desc = getOwnPropertyDescriptor(object, prop);
+ if (desc) {
+ if (desc.get) {
+ return unapply(desc.get);
+ }
+ if (typeof desc.value === "function") {
+ return unapply(desc.value);
+ }
+ }
+ object = getPrototypeOf(object);
+ }
+ function fallbackValue() {
+ return null;
+ }
+ return fallbackValue;
+}
+function isRegex(value) {
+ try {
+ regExpTest(value, "");
+ return true;
+ } catch (_unused) {
+ return false;
+ }
+}
+function createDOMPurify() {
+ let window2 = arguments.length > 0 && arguments[0] !== void 0 ? arguments[0] : getGlobal();
+ const DOMPurify = (root) => createDOMPurify(root);
+ DOMPurify.version = "3.4.15";
+ DOMPurify.removed = [];
+ if (!window2 || !window2.document || window2.document.nodeType !== NODE_TYPE.document || !window2.Element) {
+ DOMPurify.isSupported = false;
+ return DOMPurify;
+ }
+ let document = window2.document;
+ const originalDocument = document;
+ const currentScript = originalDocument.currentScript;
+ window2.DocumentFragment;
+ const HTMLTemplateElement = window2.HTMLTemplateElement, Node = window2.Node, Element = window2.Element, NodeFilter = window2.NodeFilter, _window$NamedNodeMap = window2.NamedNodeMap;
+ _window$NamedNodeMap === void 0 ? window2.NamedNodeMap || window2.MozNamedAttrMap : _window$NamedNodeMap;
+ window2.HTMLFormElement;
+ const DOMParser = window2.DOMParser, trustedTypes = window2.trustedTypes;
+ const ElementPrototype = Element.prototype;
+ const cloneNode = lookupGetter(ElementPrototype, "cloneNode");
+ const remove = lookupGetter(ElementPrototype, "remove");
+ const removeAttributeNode = lookupGetter(ElementPrototype, "removeAttributeNode");
+ const getNextSibling = lookupGetter(ElementPrototype, "nextSibling");
+ const getChildNodes = lookupGetter(ElementPrototype, "childNodes");
+ const getParentNode = lookupGetter(ElementPrototype, "parentNode");
+ const getShadowRoot = lookupGetter(ElementPrototype, "shadowRoot");
+ const getAttributes = lookupGetter(ElementPrototype, "attributes");
+ const getNodeType = Node && Node.prototype ? lookupGetter(Node.prototype, "nodeType") : null;
+ const getNodeName = Node && Node.prototype ? lookupGetter(Node.prototype, "nodeName") : null;
+ const getOwnerDocument = Node && Node.prototype ? lookupGetter(Node.prototype, "ownerDocument") : null;
+ const _readNodeType = function _readNodeType2(node) {
+ return getNodeType ? getNodeType(node) : node.nodeType;
+ };
+ const _readNodeName = function _readNodeName2(node) {
+ return getNodeName ? getNodeName(node) : node.nodeName;
+ };
+ if (typeof HTMLTemplateElement === "function") {
+ const template = document.createElement("template");
+ if (template.content && template.content.ownerDocument) {
+ document = template.content.ownerDocument;
+ }
+ }
+ let trustedTypesPolicy;
+ let emptyHTML = "";
+ let defaultTrustedTypesPolicy;
+ let defaultTrustedTypesPolicyResolved = false;
+ let IN_TRUSTED_TYPES_POLICY = 0;
+ const _assertNotInTrustedTypesPolicy = function _assertNotInTrustedTypesPolicy2() {
+ if (IN_TRUSTED_TYPES_POLICY > 0) {
+ throw typeErrorCreate('A configured TRUSTED_TYPES_POLICY callback (createHTML or createScriptURL) must not call DOMPurify.sanitize, as that causes infinite recursion. Do not pass a policy whose callbacks wrap DOMPurify as TRUSTED_TYPES_POLICY; see the "DOMPurify and Trusted Types" section of the README.');
+ }
+ };
+ const _createTrustedHTML = function _createTrustedHTML2(html2) {
+ _assertNotInTrustedTypesPolicy();
+ IN_TRUSTED_TYPES_POLICY++;
+ try {
+ return trustedTypesPolicy.createHTML(html2);
+ } finally {
+ IN_TRUSTED_TYPES_POLICY--;
+ }
+ };
+ const _createTrustedScriptURL = function _createTrustedScriptURL2(scriptUrl) {
+ _assertNotInTrustedTypesPolicy();
+ IN_TRUSTED_TYPES_POLICY++;
+ try {
+ return trustedTypesPolicy.createScriptURL(scriptUrl);
+ } finally {
+ IN_TRUSTED_TYPES_POLICY--;
+ }
+ };
+ const _getDefaultTrustedTypesPolicy = function _getDefaultTrustedTypesPolicy2() {
+ if (!defaultTrustedTypesPolicyResolved) {
+ defaultTrustedTypesPolicy = _createTrustedTypesPolicy(trustedTypes, currentScript);
+ defaultTrustedTypesPolicyResolved = true;
+ }
+ return defaultTrustedTypesPolicy;
+ };
+ const _document = document, implementation = _document.implementation, createNodeIterator = _document.createNodeIterator, createDocumentFragment = _document.createDocumentFragment, getElementsByTagName = _document.getElementsByTagName;
+ const importNode = originalDocument.importNode;
+ let hooks = _createHooksMap();
+ DOMPurify.isSupported = typeof entries === "function" && typeof getParentNode === "function" && implementation && implementation.createHTMLDocument !== void 0;
+ const MUSTACHE_EXPR$1 = MUSTACHE_EXPR, ERB_EXPR$1 = ERB_EXPR, TMPLIT_EXPR$1 = TMPLIT_EXPR, DATA_ATTR$1 = DATA_ATTR, ARIA_ATTR$1 = ARIA_ATTR, IS_SCRIPT_OR_DATA$1 = IS_SCRIPT_OR_DATA, ATTR_WHITESPACE$1 = ATTR_WHITESPACE, CUSTOM_ELEMENT$1 = CUSTOM_ELEMENT;
+ let IS_ALLOWED_URI$1 = IS_ALLOWED_URI;
+ let ALLOWED_TAGS = null;
+ const DEFAULT_ALLOWED_TAGS = addToSet({}, [...html$1, ...svg$1, ...svgFilters, ...mathMl$1, ...text]);
+ let ALLOWED_ATTR = null;
+ const DEFAULT_ALLOWED_ATTR = addToSet({}, [...html, ...svg, ...mathMl, ...xml]);
+ let CUSTOM_ELEMENT_HANDLING = Object.seal(create(null, {
+ tagNameCheck: {
+ writable: true,
+ configurable: false,
+ enumerable: true,
+ value: null
+ },
+ attributeNameCheck: {
+ writable: true,
+ configurable: false,
+ enumerable: true,
+ value: null
+ },
+ allowCustomizedBuiltInElements: {
+ writable: true,
+ configurable: false,
+ enumerable: true,
+ value: false
+ }
+ }));
+ let FORBID_TAGS = null;
+ let FORBID_ATTR = null;
+ const EXTRA_ELEMENT_HANDLING = Object.seal(create(null, {
+ tagCheck: {
+ writable: true,
+ configurable: false,
+ enumerable: true,
+ value: null
+ },
+ attributeCheck: {
+ writable: true,
+ configurable: false,
+ enumerable: true,
+ value: null
+ }
+ }));
+ let ALLOW_ARIA_ATTR = true;
+ let ALLOW_DATA_ATTR = true;
+ let ALLOW_UNKNOWN_PROTOCOLS = false;
+ let ALLOW_SELF_CLOSE_IN_ATTR = true;
+ let SAFE_FOR_TEMPLATES = false;
+ let SAFE_FOR_XML = true;
+ let WHOLE_DOCUMENT = false;
+ let SET_CONFIG = false;
+ let SET_CONFIG_ALLOWED_TAGS = null;
+ let SET_CONFIG_ALLOWED_ATTR = null;
+ let FORCE_BODY = false;
+ let RETURN_DOM = false;
+ let RETURN_DOM_FRAGMENT = false;
+ let RETURN_TRUSTED_TYPE = false;
+ let SANITIZE_DOM = true;
+ let SANITIZE_NAMED_PROPS = false;
+ const SANITIZE_NAMED_PROPS_PREFIX = "user-content-";
+ let KEEP_CONTENT = true;
+ let IN_PLACE = false;
+ let USE_PROFILES = {};
+ let FORBID_CONTENTS = null;
+ const DEFAULT_FORBID_CONTENTS = addToSet({}, [
+ "annotation-xml",
+ "audio",
+ "colgroup",
+ "desc",
+ "foreignobject",
+ "head",
+ "iframe",
+ "math",
+ "mi",
+ "mn",
+ "mo",
+ "ms",
+ "mtext",
+ "noembed",
+ "noframes",
+ "noscript",
+ "plaintext",
+ "script",
+ // mirrors the selected