Files
2026-08-04 19:24:12 +00:00

5140 lines
204 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en" class="govuk-template">
<head>
<meta charset="UTF-8">
<!-- Anything between these markers survives regeneration by
/arckit:pages. Put site-specific <head> content here — analytics
tags, verification meta, custom fonts. The template itself must
stay site-neutral: it ships to every ArcKit user, so a hardcoded
measurement ID here would end up on everybody's page (#727). -->
<!-- ARCKIT:PRESERVE -->
<!-- /ARCKIT:PRESERVE -->
<script>
// Prevent flash-of-wrong-theme by applying theme before paint
(function() {
var stored = localStorage.getItem('arckit-theme');
if (stored) {
document.documentElement.setAttribute('data-theme', stored);
} else if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
document.documentElement.setAttribute('data-theme', 'dark');
}
})();
</script>
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="description" content="Architecture governance documentation for {{REPO}} — requirements, architecture decisions, designs, risk register, and compliance artefacts generated with ArcKit.">
<meta name="author" content="{{REPO_OWNER}}">
<meta property="og:title" content="{{REPO}} — Architecture Documentation">
<meta property="og:description" content="Requirements, ADRs, designs, risk register, and compliance artefacts for {{REPO}}, generated with ArcKit.">
<meta property="og:type" content="website">
<meta property="og:site_name" content="{{REPO}}">
<meta property="og:url" content="{{SITE_URL}}">
<meta property="og:image" content="https://arckit.org/og-image.png">
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="{{REPO}} — Architecture Documentation">
<meta name="twitter:description" content="Requirements, ADRs, designs, risk register, and compliance artefacts for {{REPO}}, generated with ArcKit.">
<meta name="twitter:image" content="https://arckit.org/og-image.png">
<title>{{REPO}} — Architecture Documentation</title>
<link rel="canonical" href="{{SITE_URL}}">
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@graph": [
{
"@type": "Organization",
"@id": "{{REPO_URL}}#org",
"name": "{{REPO_OWNER}}",
"url": "https://github.com/{{REPO_OWNER}}"
},
{
"@type": "SoftwareSourceCode",
"@id": "{{REPO_URL}}#source",
"name": "{{REPO}}",
"codeRepository": "{{REPO_URL}}",
"url": "{{REPO_URL}}",
"author": { "@id": "{{REPO_URL}}#org" }
},
{
"@type": "WebSite",
"name": "{{REPO}} Architecture Documentation",
"description": "Architecture governance artefacts for {{REPO}} — requirements, decisions, designs, risk register, and compliance assessments — generated with ArcKit.",
"publisher": { "@id": "{{REPO_URL}}#org" },
"about": { "@id": "{{REPO_URL}}#source" },
"inLanguage": "en"
}
]
}
</script>
<!-- GOV.UK Frontend -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/govuk-frontend@5.13.0/dist/govuk/govuk-frontend.min.css">
<!-- Marked.js for Markdown -->
<script src="https://cdn.jsdelivr.net/npm/marked@15.0.6/marked.min.js"></script>
<!-- Mermaid.js for Diagrams -->
<script src="https://cdn.jsdelivr.net/npm/mermaid@11.15.0/dist/mermaid.min.js"></script>
<!-- SVG Pan Zoom for interactive diagrams -->
<script src="https://cdn.jsdelivr.net/npm/svg-pan-zoom@3.6.2/dist/svg-pan-zoom.min.js"></script>
<style>
/* CSS Variables for theming */
:root {
--sidebar-width: 300px;
--sidebar-collapsed-width: 0px;
--toc-width: 220px;
--header-height: 50px;
--transition-speed: 0.3s;
/* Surfaces */
--bg-page: #ffffff;
--bg-surface: #ffffff;
--bg-surface-alt: #f3f2f1;
--bg-sidebar: #f3f2f1;
--bg-header: #0b0c0c;
--bg-code: #f3f2f1;
--bg-code-block: #0b0c0c;
--bg-hover: #e8e8e8;
/* Text */
--text-primary: #0b0c0c;
--text-secondary: #505a5f;
--text-inverse: #ffffff;
--text-code-block: #ffffff;
--text-link: #1d70b8;
--text-link-hover: #003078;
/* Borders */
--border-main: #b1b4b6;
--border-light: #e8e8e8;
/* Status */
--color-success: #00703c;
--color-warning: #f47738;
--color-error: #d4351c;
--color-focus: #ffdd00;
/* Legacy aliases (used throughout) */
--primary-color: var(--text-link);
--primary-dark: var(--text-link-hover);
--background-light: var(--bg-surface-alt);
--border-color: var(--border-main);
--text-muted: var(--text-secondary);
}
[data-theme="dark"] {
--bg-page: #1a1a1a;
--bg-surface: #232323;
--bg-surface-alt: #2a2a2a;
--bg-sidebar: #1e1e1e;
--bg-header: #0b0c0c;
--bg-code: #2a2a2a;
--bg-code-block: #1a1a1a;
--bg-hover: #333333;
--text-primary: #e0e0e0;
--text-secondary: #a0a0a0;
--text-inverse: #ffffff;
--text-code-block: #e0e0e0;
--text-link: #6eb0e6;
--text-link-hover: #a0cfef;
--border-main: #444444;
--border-light: #333333;
--color-success: #00a04e;
--color-warning: #ff9f5a;
--color-error: #e85a4a;
--color-focus: #ffdd00;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg-page: #1a1a1a;
--bg-surface: #232323;
--bg-surface-alt: #2a2a2a;
--bg-sidebar: #1e1e1e;
--bg-header: #0b0c0c;
--bg-code: #2a2a2a;
--bg-code-block: #1a1a1a;
--bg-hover: #333333;
--text-primary: #e0e0e0;
--text-secondary: #a0a0a0;
--text-inverse: #ffffff;
--text-code-block: #e0e0e0;
--text-link: #6eb0e6;
--text-link-hover: #a0cfef;
--border-main: #444444;
--border-light: #333333;
--color-success: #00a04e;
--color-warning: #ff9f5a;
--color-error: #e85a4a;
--color-focus: #ffdd00;
}
}
/* Font Override - GDS Transport only for *.gov.uk domains */
body, .govuk-template, .govuk-template__body, [class*="govuk-"] {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Helvetica, Arial, sans-serif !important;
}
body {
background: var(--bg-page);
color: var(--text-primary);
}
html {
scroll-behavior: smooth;
}
/* Layout */
.app-layout {
display: flex;
min-height: calc(100vh - var(--header-height) - 80px);
}
/* Sidebar */
.app-sidebar {
width: var(--sidebar-width);
min-width: var(--sidebar-width);
background: var(--bg-sidebar);
border-right: 1px solid var(--border-color);
overflow-y: auto;
position: sticky;
top: 0;
height: calc(100vh - var(--header-height));
transition: width var(--transition-speed), min-width var(--transition-speed), transform var(--transition-speed);
display: flex;
flex-direction: column;
}
.app-sidebar.collapsed {
width: var(--sidebar-collapsed-width);
min-width: var(--sidebar-collapsed-width);
overflow: hidden;
}
.app-sidebar-header {
padding: 1rem;
background: var(--primary-color);
color: var(--text-inverse);
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
}
.app-sidebar-header h2 {
color: var(--text-inverse);
margin: 0;
font-size: 1.1rem;
}
.app-sidebar-toggle {
background: transparent;
border: none;
color: var(--text-inverse);
cursor: pointer;
font-size: 1.25rem;
padding: 0.25rem;
opacity: 0.8;
transition: opacity 0.2s;
}
.app-sidebar-toggle:hover {
opacity: 1;
}
/* Search */
.app-search {
padding: 0.75rem 1rem;
background: var(--bg-surface);
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
}
.app-search-input {
width: 100%;
padding: 0.5rem 0.75rem;
border: 2px solid var(--border-color);
border-radius: 0;
font-size: 0.9rem;
font-family: inherit;
background: var(--bg-surface);
color: var(--text-primary);
}
.app-search-input:focus {
outline: 3px solid var(--color-focus);
outline-offset: 0;
border-color: var(--text-primary);
}
.app-search-results {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: var(--bg-surface);
border: 1px solid var(--border-color);
border-top: none;
max-height: 300px;
overflow-y: auto;
z-index: 100;
display: none;
}
.app-search-results.visible {
display: block;
}
.app-search-result {
padding: 0.5rem 0.75rem;
cursor: pointer;
font-size: 0.875rem;
border-bottom: 1px solid var(--background-light);
}
.app-search-result:hover {
background: var(--background-light);
}
.app-search-result-title {
font-weight: 600;
}
.app-search-result-path {
font-size: 0.75rem;
color: var(--text-muted);
}
/* Navigation */
.app-nav-container {
flex: 1;
overflow-y: auto;
}
.app-nav-section {
border-bottom: 1px solid var(--border-color);
}
.app-nav-section-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
background: var(--bg-surface);
color: var(--text-primary);
cursor: pointer;
font-weight: 700;
border: none;
width: 100%;
text-align: left;
font-size: 0.9rem;
}
.app-nav-section-header:hover {
background: var(--background-light);
}
.app-nav-section-header .chevron {
transition: transform 0.2s;
font-size: 0.75rem;
}
.app-nav-section-header.collapsed .chevron {
transform: rotate(-90deg);
}
.app-nav-list {
list-style: none;
margin: 0;
padding: 0;
}
.app-nav-list li {
margin: 0;
}
.app-nav-list a {
display: block;
padding: 0.5rem 1rem 0.5rem 1.5rem;
color: var(--primary-color);
text-decoration: none;
font-size: 0.875rem;
border-left: 4px solid transparent;
transition: background 0.15s, border-color 0.15s;
}
.app-nav-list a:hover {
background: var(--bg-surface);
border-left-color: var(--primary-color);
}
.app-nav-list a.active {
background: var(--bg-surface);
border-left-color: var(--primary-color);
font-weight: 700;
}
.app-nav-category {
font-size: 0.7rem;
color: var(--text-muted);
padding: 0.5rem 1rem 0.25rem 1.5rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.app-nav-external {
padding: 6px 12px 6px 16px;
font-size: 14px;
color: var(--text-secondary);
font-style: italic;
}
.app-nav-file-type {
font-size: 11px;
text-transform: uppercase;
color: var(--text-secondary);
margin-left: 4px;
}
.app-nav-version {
float: right;
font-size: 0.7rem;
color: var(--text-muted);
font-weight: 400;
}
.app-nav-versioned {
display: flex;
align-items: center;
}
.app-nav-versioned a {
flex: 1;
min-width: 0;
}
.app-nav-version-select {
flex-shrink: 0;
font-size: 0.7rem;
color: var(--text-muted);
background: transparent;
border: 1px solid var(--border-color);
border-radius: 3px;
padding: 0 4px;
margin-right: 0.5rem;
cursor: pointer;
}
/* Main Content - Full Width, Left Aligned */
.app-content-wrapper {
flex: 1;
display: flex;
min-width: 0;
position: relative;
}
.app-content {
flex: 1;
padding: 2rem 2rem 2rem 2.5rem;
overflow-y: auto;
min-width: 0;
}
.app-content-inner {
max-width: 100%;
}
/* Table of Contents Sidebar (Left of Content) */
.app-toc {
width: var(--toc-width);
min-width: var(--toc-width);
padding: 1.5rem 1rem;
border-right: 1px solid var(--border-color);
position: sticky;
top: 0;
height: calc(100vh - var(--header-height));
overflow-y: auto;
font-size: 0.8125rem;
background: var(--background-light);
}
.app-toc-title {
font-weight: 700;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
margin-bottom: 0.75rem;
}
.app-toc-list {
list-style: none;
margin: 0;
padding: 0;
}
.app-toc-list li {
margin: 0;
}
.app-toc-list a {
display: block;
padding: 0.25rem 0.75rem 0.25rem 0;
color: var(--text-muted);
text-decoration: none;
border-right: 2px solid transparent;
margin-right: -1rem;
transition: color 0.15s, border-color 0.15s;
}
.app-toc-list a:hover {
color: var(--primary-color);
}
.app-toc-list a.active {
color: var(--primary-color);
border-right-color: var(--primary-color);
}
.app-toc-list .toc-h3 {
padding-left: 0.75rem;
font-size: 0.75rem;
}
/* Document Header */
.app-doc-header {
border-bottom: 1px solid var(--border-color);
padding-bottom: 1rem;
margin-bottom: 2rem;
}
.app-doc-breadcrumb {
font-size: 0.875rem;
color: var(--text-muted);
margin-bottom: 0.5rem;
}
.app-doc-title {
margin: 0;
font-size: 2.25rem;
}
/* HTML deck artifacts (e.g. ARC-001-DECK-v1.0.html) */
.app-deck-newtab {
display: inline-block;
margin-top: 0.75rem;
font-size: 0.875rem;
color: var(--primary-color);
text-decoration: none;
font-weight: 600;
}
.app-deck-newtab:hover {
text-decoration: underline;
}
.app-deck-frame {
display: block;
width: 100%;
height: 80vh;
border: 1px solid var(--border-color);
border-radius: 4px;
background: #fff;
}
/* Loading State */
.app-loading {
display: flex;
align-items: center;
justify-content: center;
padding: 4rem;
color: var(--text-muted);
}
.app-spinner {
width: 24px;
height: 24px;
border: 3px solid var(--background-light);
border-top-color: var(--primary-color);
border-radius: 50%;
animation: spin 1s linear infinite;
margin-right: 1rem;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Error State */
.app-error {
background: var(--color-warning);
color: var(--text-inverse);
padding: 1rem;
margin-bottom: 1rem;
}
/* Welcome Screen */
.app-welcome {
text-align: left;
padding: 2rem 0;
}
.app-welcome h1 {
margin-bottom: 1rem;
}
.app-welcome p {
color: var(--text-muted);
max-width: 600px;
margin: 0 0 2rem 0;
}
.app-welcome-features {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1.5rem;
max-width: 900px;
text-align: left;
}
.app-welcome-feature {
background: var(--background-light);
padding: 1.25rem;
border-left: 4px solid var(--primary-color);
}
.app-welcome-feature h3 {
margin: 0 0 0.5rem;
font-size: 1rem;
}
.app-welcome-feature p {
margin: 0;
font-size: 0.875rem;
color: var(--text-muted);
}
/* Keyboard shortcuts help */
.app-keyboard-hint {
font-size: 0.75rem;
color: var(--text-muted);
margin-top: 2rem;
text-align: left;
}
.app-keyboard-hint kbd {
background: var(--bg-surface);
border: 1px solid var(--border-color);
border-radius: 3px;
padding: 0.125rem 0.375rem;
font-family: inherit;
font-size: 0.7rem;
}
/* Dashboard Styles */
.app-dashboard-kpis {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1rem;
margin-bottom: 1.5rem;
}
.app-dashboard-kpi {
background: var(--bg-surface);
border: 1px solid var(--border-color);
border-top: 4px solid var(--primary-color);
padding: 1.25rem;
}
.app-dashboard-kpi-value {
font-size: 2.25rem;
font-weight: 700;
color: var(--text-primary);
line-height: 1.1;
}
.app-dashboard-kpi-label {
font-size: 0.875rem;
color: var(--text-muted);
margin-top: 0.25rem;
}
.app-dashboard-kpi-bar {
height: 4px;
background: var(--background-light);
margin-top: 0.75rem;
border-radius: 2px;
overflow: hidden;
}
.app-dashboard-kpi-bar-fill {
height: 100%;
border-radius: 2px;
transition: width 0.6s ease;
}
.app-dashboard-panels {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
margin-bottom: 1.5rem;
}
.app-dashboard-panel {
background: var(--bg-surface);
border: 1px solid var(--border-color);
padding: 1.25rem;
}
.app-dashboard-panel h3 {
margin: 0 0 1rem 0;
font-size: 1rem;
color: var(--text-primary);
}
.app-dashboard-chart-container {
display: flex;
align-items: center;
gap: 1.5rem;
}
.app-dashboard-chart-container svg {
flex-shrink: 0;
}
.app-dashboard-legend {
list-style: none;
padding: 0;
margin: 0;
font-size: 0.8125rem;
}
.app-dashboard-legend li {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.2rem 0;
}
.app-dashboard-legend-swatch {
width: 12px;
height: 12px;
border-radius: 2px;
flex-shrink: 0;
}
.app-dashboard-legend-count {
color: var(--text-muted);
margin-left: auto;
font-variant-numeric: tabular-nums;
}
.app-dashboard-bar-row {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.5rem;
font-size: 0.8125rem;
}
.app-dashboard-bar-label {
width: 120px;
flex-shrink: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.app-dashboard-bar-track {
flex: 1;
height: 18px;
background: var(--background-light);
border-radius: 2px;
overflow: hidden;
}
.app-dashboard-bar-fill {
height: 100%;
border-radius: 2px;
transition: width 0.6s ease;
min-width: 2px;
}
.app-dashboard-bar-value {
width: 40px;
text-align: right;
flex-shrink: 0;
font-variant-numeric: tabular-nums;
color: var(--text-muted);
}
.app-dashboard-table {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
}
.app-dashboard-table th,
.app-dashboard-table td {
border: 1px solid var(--border-color);
padding: 0.625rem 0.75rem;
text-align: left;
}
.app-dashboard-table th {
background: var(--background-light);
font-weight: 700;
font-size: 0.8125rem;
}
.app-dashboard-table tr:hover {
background: var(--bg-surface-alt);
}
.app-dashboard-coverage-bar {
display: inline-block;
width: 60px;
height: 10px;
background: var(--background-light);
border-radius: 2px;
overflow: hidden;
vertical-align: middle;
margin-right: 0.5rem;
}
.app-dashboard-coverage-fill {
height: 100%;
border-radius: 2px;
}
.app-dashboard-checklist {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
list-style: none;
padding: 0;
margin: 0;
font-size: 0.875rem;
}
.app-dashboard-checklist li {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.5rem;
background: var(--background-light);
border-radius: 2px;
}
.app-dashboard-link {
color: var(--primary-color);
text-decoration: none;
}
.app-dashboard-link:hover {
text-decoration: underline;
}
.app-dashboard-check {
color: var(--color-success);
font-weight: 700;
}
.app-dashboard-cross {
color: var(--color-error);
font-weight: 700;
}
.app-header__dashboard-link {
color: var(--text-inverse) !important;
background: var(--color-success);
padding: 0.25rem 0.75rem;
border-radius: 3px;
}
.app-header__dashboard-link:hover {
background: #005a30; /* darker green — no variable needed */
}
/* Vendor Scores */
.app-dashboard-scores-section {
margin-bottom: 1.5rem;
}
.app-dashboard-scores-header {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 0.5rem;
}
.app-dashboard-scores-date {
font-size: 0.8125rem;
color: var(--text-muted, #505a5f);
}
.app-dashboard-score-bar {
display: flex;
align-items: center;
margin-bottom: 0.375rem;
}
.app-dashboard-score-bar-label {
min-width: 150px;
font-size: 0.875rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.app-dashboard-score-bar-track {
flex: 1;
height: 18px;
background: var(--surface-secondary, #f3f2f1);
border-radius: 3px;
margin: 0 0.5rem;
}
.app-dashboard-score-bar-fill {
height: 100%;
border-radius: 3px;
transition: width 0.3s ease;
}
.app-dashboard-score-bar-pct {
min-width: 40px;
text-align: right;
font-size: 0.875rem;
font-weight: bold;
}
.app-dashboard-score-cell {
text-align: center;
font-weight: bold;
border-radius: 3px;
padding: 2px 6px;
}
@media (max-width: 900px) {
.app-dashboard-kpis {
grid-template-columns: repeat(2, 1fr);
}
.app-dashboard-panels {
grid-template-columns: 1fr;
}
.app-dashboard-checklist {
grid-template-columns: 1fr;
}
}
@media (max-width: 600px) {
.app-dashboard-kpis {
grid-template-columns: 1fr;
}
.app-dashboard-kpi-value {
font-size: 1.75rem;
}
.app-dashboard-chart-container {
flex-direction: column;
}
.app-dashboard-bar-label {
width: 80px;
}
}
/* Markdown Content Styles */
.app-markdown h1 { font-size: 2rem; margin-top: 0; }
.app-markdown h2 { font-size: 1.5rem; margin-top: 2.5rem; border-bottom: 1px solid var(--border-color); padding-bottom: 0.5rem; scroll-margin-top: 1rem; }
.app-markdown h3 { font-size: 1.25rem; margin-top: 2rem; scroll-margin-top: 1rem; }
.app-markdown h4 { font-size: 1.1rem; margin-top: 1.5rem; }
.app-markdown p { margin: 1rem 0; line-height: 1.6; }
.app-markdown ul, .app-markdown ol {
margin: 1rem 0;
padding-left: 2rem;
}
.app-markdown li { margin: 0.5rem 0; line-height: 1.5; }
.app-markdown table {
width: 100%;
border-collapse: collapse;
margin: 1.5rem 0;
font-size: 0.9rem;
}
.app-markdown th, .app-markdown td {
border: 1px solid var(--border-color);
padding: 0.75rem;
text-align: left;
}
.app-markdown th {
background: var(--background-light);
font-weight: 700;
}
.app-markdown tr:hover {
background: var(--bg-surface-alt);
}
.app-markdown code {
background: var(--bg-code);
padding: 0.125rem 0.375rem;
font-family: 'SF Mono', 'Consolas', 'Liberation Mono', monospace;
font-size: 0.875em;
border-radius: 2px;
}
.app-markdown pre {
background: var(--bg-code-block);
color: var(--text-code-block);
padding: 1.25rem;
overflow-x: auto;
margin: 1.5rem 0;
border-radius: 0;
}
.app-markdown pre code {
background: none;
padding: 0;
color: inherit;
border-radius: 0;
}
.app-markdown blockquote {
border-left: 4px solid var(--primary-color);
margin: 1.5rem 0;
padding: 0.75rem 1.25rem;
background: var(--background-light);
}
.app-markdown blockquote p:first-child {
margin-top: 0;
}
.app-markdown blockquote p:last-child {
margin-bottom: 0;
}
.app-markdown a {
color: var(--primary-color);
}
.app-markdown img {
max-width: 100%;
height: auto;
}
.app-markdown hr {
border: none;
border-top: 1px solid var(--border-color);
margin: 2rem 0;
}
/* Guides list */
.app-guides-list {
list-style: none;
padding: 0;
margin: 0 0 2rem 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 0.5rem;
}
.app-guides-list li {
margin: 0;
}
.app-guides-list a {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
background: var(--background-light);
border-left: 4px solid var(--primary-color);
color: var(--primary-color);
text-decoration: none;
transition: background 0.15s;
}
.app-guides-list a:hover {
background: var(--bg-hover);
}
/* Guide status badges */
.guide-status {
font-size: 0.65em;
padding: 2px 8px;
border-radius: 3px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.02em;
flex-shrink: 0;
margin-left: 8px;
}
.guide-status.live {
background: #00703c;
color: var(--text-inverse);
}
.guide-status.beta {
background: #1d70b8;
color: var(--text-inverse);
}
.guide-status.alpha {
background: #f47738;
color: var(--text-inverse);
}
.guide-status.experimental {
background: #912b88;
color: var(--text-inverse);
}
.guide-status.community {
background: #f3f2f1;
color: #383f43;
border: 1px solid #b1b4b6;
}
/* Mermaid Diagrams - Full Width */
.app-markdown .mermaid {
background: var(--bg-surface);
padding: 1.5rem;
margin: 1.5rem 0;
border: 1px solid var(--border-color);
text-align: center;
overflow-x: auto;
}
.app-markdown .mermaid svg {
max-width: 100%;
height: auto;
}
/* PlantUML Diagrams */
.app-markdown .plantuml-diagram {
background: var(--bg-surface);
padding: 1.5rem;
margin: 1.5rem 0;
border: 1px solid var(--border-color);
text-align: center;
overflow-x: auto;
}
.app-markdown .plantuml-diagram img {
max-width: 100%;
height: auto;
}
[data-theme="dark"] .app-markdown .plantuml-diagram img {
filter: invert(1) hue-rotate(180deg);
}
/* Interactive Diagram Containers (svg-pan-zoom) */
.mermaid-container {
position: relative;
margin: 1.5rem 0;
border: 1px solid var(--border-color);
background: var(--bg-surface);
}
.mermaid-viewport {
overflow: hidden;
cursor: grab;
max-height: 70vh;
min-height: 200px;
}
.mermaid-viewport:active {
cursor: grabbing;
}
.mermaid-container .mermaid {
padding: 0;
margin: 0;
border: none;
overflow: visible;
background: transparent;
}
.mermaid-container .mermaid svg {
max-width: none;
width: 100% !important;
height: 100% !important;
}
.mermaid-controls {
position: absolute;
top: 0.5rem;
right: 0.5rem;
display: flex;
gap: 0.25rem;
opacity: 0;
transition: opacity 0.2s ease;
z-index: 10;
}
.mermaid-container:hover .mermaid-controls,
.mermaid-container:focus-within .mermaid-controls {
opacity: 1;
}
.mermaid-ctrl-btn {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-surface);
border: 1px solid var(--border-color);
border-radius: 4px;
cursor: pointer;
color: var(--text-color);
padding: 0;
line-height: 1;
}
.mermaid-ctrl-btn:hover {
background: var(--hover-bg);
}
.mermaid-ctrl-btn svg {
width: 16px;
height: 16px;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.mermaid-container.fullscreen {
position: fixed;
inset: 0;
z-index: 200;
margin: 0;
border: none;
border-radius: 0;
}
.mermaid-container.fullscreen .mermaid-viewport {
max-height: 100vh;
height: 100vh;
}
.mermaid-container.fullscreen .mermaid-controls {
opacity: 1;
top: 1rem;
right: 1rem;
}
/* PlantUML Interactive Containers (reuse mermaid-container pattern) */
.plantuml-container {
position: relative;
margin: 1.5rem 0;
border: 1px solid var(--border-color);
background: var(--bg-surface);
}
.plantuml-viewport {
overflow: hidden;
cursor: grab;
max-height: 70vh;
min-height: 200px;
display: flex;
align-items: center;
justify-content: center;
}
.plantuml-viewport:active {
cursor: grabbing;
}
.plantuml-container .plantuml-diagram {
padding: 0;
margin: 0;
border: none;
overflow: visible;
background: transparent;
}
.plantuml-container .plantuml-diagram img {
max-width: none;
}
.plantuml-container .plantuml-controls {
position: absolute;
top: 0.5rem;
right: 0.5rem;
display: flex;
gap: 0.25rem;
opacity: 0;
transition: opacity 0.2s ease;
z-index: 10;
}
.plantuml-container:hover .plantuml-controls,
.plantuml-container:focus-within .plantuml-controls {
opacity: 1;
}
.plantuml-container.fullscreen {
position: fixed;
inset: 0;
z-index: 200;
margin: 0;
border: none;
border-radius: 0;
}
.plantuml-container.fullscreen .plantuml-viewport {
max-height: 100vh;
height: 100vh;
}
.plantuml-container.fullscreen .plantuml-controls {
opacity: 1;
top: 1rem;
right: 1rem;
}
/* Mobile header hamburger button */
.app-mobile-menu {
display: none;
background: transparent;
border: none;
color: var(--text-inverse);
font-size: 1.5rem;
cursor: pointer;
padding: 0.25rem 0.5rem;
line-height: 1;
}
/* Mobile sidebar backdrop */
.app-sidebar-backdrop {
display: none;
position: fixed;
inset: 0;
top: var(--header-height);
background: rgba(0, 0, 0, 0.5);
z-index: 99;
}
.app-sidebar-backdrop.visible {
display: block;
}
/* Sidebar expand button (visible when collapsed) */
.app-sidebar-expand {
display: none;
position: fixed;
left: 0;
top: calc(var(--header-height) + 1rem);
background: var(--primary-color);
color: var(--text-inverse);
border: none;
padding: 0.5rem 0.75rem;
cursor: pointer;
z-index: 99;
border-radius: 0 4px 4px 0;
}
.app-sidebar.collapsed ~ .app-content-wrapper .app-sidebar-expand {
display: block;
}
/* Header */
.app-header {
background: var(--bg-header);
height: var(--header-height);
display: flex;
align-items: center;
position: sticky;
top: 0;
z-index: 50;
}
.app-header__container {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 1.5rem;
width: 100%;
}
.app-header__left {
display: flex;
align-items: center;
gap: 1rem;
}
.app-header__link {
color: var(--text-inverse);
font-size: 1.25rem;
font-weight: 700;
text-decoration: none;
}
.app-header__link:hover {
text-decoration: underline;
}
.app-header__nav {
display: flex;
align-items: center;
gap: 1.5rem;
}
.app-header__nav a,
.app-header__stats {
color: var(--border-main);
text-decoration: none;
font-size: 0.875rem;
}
.app-header__nav a:hover {
color: var(--text-inverse);
text-decoration: underline;
}
.app-header__guides-link {
color: var(--text-inverse) !important;
background: var(--primary-color);
padding: 0.25rem 0.75rem;
border-radius: 3px;
}
.app-header__guides-link:hover {
background: var(--primary-dark);
}
/* Footer */
.app-footer {
background: var(--background-light);
padding: 1.5rem 0;
border-top: 1px solid var(--border-color);
}
.app-footer-inner {
padding: 0 1.5rem;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.875rem;
color: var(--text-muted);
}
.app-footer a {
color: var(--primary-color);
}
/* Responsive */
@media (max-width: 1200px) {
.app-toc {
display: none;
}
}
@media (max-width: 900px) {
.app-sidebar {
position: fixed;
left: calc(-1 * var(--sidebar-width));
top: var(--header-height);
z-index: 100;
height: calc(100vh - var(--header-height));
transition: left var(--transition-speed);
box-shadow: none;
}
.app-sidebar.open {
left: 0;
box-shadow: 2px 0 8px rgba(0,0,0,0.15);
}
.app-sidebar.collapsed {
left: calc(-1 * var(--sidebar-width));
}
.app-mobile-menu {
display: block;
}
.app-sidebar-expand {
display: none !important;
}
.app-content {
padding: 1.5rem;
}
.app-header__nav a:not(.app-header__guides-link) {
display: none;
}
}
@media (max-width: 600px) {
.app-content {
padding: 1rem;
}
.app-doc-title {
font-size: 1.25rem;
}
.app-markdown h1 {
font-size: 1.25rem;
}
.app-welcome h1 {
font-size: 1.25rem;
}
.app-welcome-features {
grid-template-columns: 1fr;
}
}
/* Touch devices: always show diagram controls */
@media (max-width: 900px) {
.mermaid-controls,
.plantuml-controls {
opacity: 1;
}
.mermaid-ctrl-btn,
.plantuml-controls button {
width: 36px;
height: 36px;
}
.mermaid-ctrl-btn svg,
.plantuml-controls button svg {
width: 18px;
height: 18px;
}
}
/* Print Styles */
@media print {
.app-sidebar, .app-mobile-menu, .app-header, .app-footer, .app-toc, .app-sidebar-expand, .app-sidebar-backdrop {
display: none !important;
}
.app-content {
padding: 0;
max-width: 100%;
}
.app-markdown .mermaid {
page-break-inside: avoid;
}
.mermaid-container {
page-break-inside: avoid;
}
.mermaid-controls {
display: none !important;
}
.mermaid-viewport {
overflow: visible;
max-height: none;
cursor: default;
}
.mermaid-container .mermaid svg {
max-width: 100% !important;
height: auto !important;
}
.app-markdown .plantuml-diagram {
page-break-inside: avoid;
}
.plantuml-container {
page-break-inside: avoid;
}
.plantuml-controls {
display: none !important;
}
.plantuml-viewport {
overflow: visible;
max-height: none;
cursor: default;
}
.plantuml-container .plantuml-diagram img {
max-width: 100% !important;
height: auto !important;
}
}
/* Version Badge */
.app-version-badge {
font-size: 11px;
color: var(--text-inverse);
opacity: 0.5;
padding: 2px 6px;
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 3px;
font-family: monospace;
white-space: nowrap;
}
/* Icon Links (repo, etc.) */
.app-icon-link {
color: var(--text-inverse);
opacity: 0.7;
display: flex;
align-items: center;
padding: 4px;
transition: opacity 0.2s;
text-decoration: none;
}
.app-icon-link:hover {
opacity: 1;
}
/* Theme Toggle */
.app-theme-toggle {
background: transparent;
border: none;
color: var(--text-inverse);
cursor: pointer;
padding: 0.25rem;
display: flex;
align-items: center;
opacity: 0.8;
transition: opacity 0.2s;
}
.app-theme-toggle:hover {
opacity: 1;
}
.theme-sun { display: none; }
.theme-moon { display: inline; }
[data-theme="dark"] .theme-sun { display: inline; }
[data-theme="dark"] .theme-moon { display: none; }
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) .theme-sun { display: inline; }
:root:not([data-theme="light"]) .theme-moon { display: none; }
}
/* Focus styles for accessibility */
*:focus-visible {
outline: 3px solid var(--color-focus);
outline-offset: 0;
}
/* ── Dependency Map ── */
.app-depmap-container {
border: 1px solid var(--border-main);
border-radius: 4px;
background: var(--bg-surface);
overflow: hidden;
}
.app-depmap-controls {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
border-bottom: 1px solid var(--border-main);
background: var(--bg-surface-alt);
}
.app-depmap-controls label {
font-size: 14px;
font-weight: 700;
color: var(--text-primary);
}
.app-depmap-controls select {
font-size: 14px;
padding: 4px 8px;
border: 1px solid var(--border-main);
border-radius: 4px;
background: var(--bg-surface);
color: var(--text-primary);
}
.app-depmap-svg {
display: block;
width: 100%;
overflow-x: auto;
}
.app-depmap-node {
cursor: pointer;
transition: opacity 0.15s;
}
.app-depmap-node rect {
transition: stroke-width 0.15s;
}
.app-depmap-node:hover rect {
stroke-width: 2.5;
}
.app-depmap-node.orphan rect {
stroke-dasharray: 4,3;
}
.app-depmap-node.stale rect {
stroke: #d4351c;
stroke-width: 2;
}
.app-depmap-node.draft rect {
stroke-dasharray: 6,3;
stroke: #f47738;
}
.app-depmap-node.dimmed {
opacity: 0.15;
}
.app-depmap-health-badge {
display: inline-block;
margin-left: 4px;
padding: 1px 6px;
border-radius: 8px;
font-size: 11px;
font-weight: 600;
}
.app-depmap-health-badge--stale { background: #fcebeb; color: #d4351c; }
.app-depmap-health-badge--draft { background: #fff4e5; color: #b04300; }
.app-depmap-health-badge--orphan { background: #f3f2f1; color: #505a5f; }
.app-depmap-edge {
fill: none;
stroke-width: 1.5;
opacity: 0.3;
transition: opacity 0.15s;
}
.app-depmap-edge.highlighted {
opacity: 1;
stroke-width: 2;
}
.app-depmap-edge.dimmed {
opacity: 0.06;
}
.app-depmap-tooltip {
position: fixed;
background: var(--bg-surface);
border: 1px solid var(--border-main);
border-radius: 4px;
padding: 8px 12px;
font-size: 12px;
line-height: 1.4;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
pointer-events: none;
z-index: 1000;
max-width: 260px;
}
.app-depmap-tooltip strong {
display: block;
margin-bottom: 2px;
color: var(--text-primary);
}
.app-depmap-tooltip span {
color: var(--text-secondary);
}
.app-depmap-legend {
display: flex;
flex-wrap: wrap;
gap: 12px;
padding: 10px 16px;
border-top: 1px solid var(--border-main);
font-size: 12px;
color: var(--text-secondary);
}
.app-depmap-legend-item {
display: flex;
align-items: center;
gap: 4px;
}
.app-depmap-legend-swatch {
width: 12px;
height: 12px;
border-radius: 2px;
}
.app-depmap-empty {
text-align: center;
padding: 48px 16px;
color: var(--text-secondary);
font-size: 16px;
}
.app-depmap-layout-toggle {
display: inline-flex;
border: 1px solid var(--border-main);
border-radius: 4px;
overflow: hidden;
}
.app-depmap-layout-toggle button {
border: none;
background: var(--bg-surface);
color: var(--text-primary);
font-size: 12px;
font-weight: 600;
padding: 4px 12px;
cursor: pointer;
}
.app-depmap-layout-toggle button:not(:last-child) {
border-right: 1px solid var(--border-main);
}
.app-depmap-layout-toggle button.active {
background: var(--text-primary);
color: var(--bg-surface);
}
.app-depmap-layout-toggle button:hover:not(.active) {
background: var(--bg-surface-alt);
}
</style>
</head>
<body class="govuk-template__body">
<!-- Skip Link -->
<a href="#main-content" class="govuk-skip-link">Skip to main content</a>
<!-- Header -->
<header class="app-header">
<div class="app-header__container">
<div class="app-header__left">
<button class="app-mobile-menu" id="mobile-menu" aria-label="Open navigation menu">&#9776;</button>
<a href="#dashboard" class="app-header__link" id="header-title">Project Documentation</a>
</div>
<nav class="app-header__nav" aria-label="Site navigation">
<a href="#dashboard" id="dashboard-link" class="app-header__dashboard-link">Dashboard</a>
<a href="#guides" id="guides-link" class="app-header__guides-link">Guides</a>
<a href="#roles" id="roles-link" class="app-header__guides-link">Roles</a>
<a href="#dependency-map" id="depmap-link" class="app-header__guides-link">Document Map</a>
<a href="https://tractorjuice.github.io/arc-kit/">ArcKit</a>
<span class="app-version-badge">v{{VERSION}}</span>
<a href="{{REPO_URL}}" target="_blank" rel="noopener" class="app-icon-link" aria-label="Repository" title="Repository">
<svg width="18" height="18" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0016 8c0-4.42-3.58-8-8-8z"/>
</svg>
</a>
<a href="https://discord.gg/HsA4Y3hQ4" target="_blank" rel="noopener" class="app-icon-link" aria-label="Discord community" title="Discord community">
<svg width="18" height="18" viewBox="0 0 16 16" fill="currentColor">
<path d="M13.545 2.907a13.227 13.227 0 0 0-3.257-1.011.05.05 0 0 0-.052.025c-.141.25-.297.577-.406.833a12.19 12.19 0 0 0-3.658 0 8.258 8.258 0 0 0-.412-.833.051.051 0 0 0-.052-.025c-1.125.194-2.22.534-3.257 1.011a.041.041 0 0 0-.021.018C.356 6.024-.213 9.047.066 12.032c.001.014.01.028.021.037a13.276 13.276 0 0 0 3.995 2.02.05.05 0 0 0 .056-.019c.308-.42.582-.863.818-1.329a.05.05 0 0 0-.01-.059.051.051 0 0 0-.018-.011 8.875 8.875 0 0 1-1.248-.595.05.05 0 0 1-.02-.066.051.051 0 0 1 .015-.019c.084-.063.168-.129.248-.195a.05.05 0 0 1 .051-.007c2.619 1.196 5.454 1.196 8.041 0a.052.052 0 0 1 .053.007c.08.066.164.132.248.195a.051.051 0 0 1-.004.085 8.254 8.254 0 0 1-1.249.594.05.05 0 0 0-.03.03.052.052 0 0 0 .003.041c.24.465.515.909.817 1.329a.05.05 0 0 0 .056.019 13.235 13.235 0 0 0 4.001-2.02.049.049 0 0 0 .021-.037c.334-3.451-.559-6.449-2.366-9.106a.034.034 0 0 0-.02-.019Zm-8.198 7.307c-.789 0-1.438-.724-1.438-1.612 0-.889.637-1.613 1.438-1.613.807 0 1.45.73 1.438 1.613 0 .888-.637 1.612-1.438 1.612Zm5.316 0c-.788 0-1.438-.724-1.438-1.612 0-.889.637-1.613 1.438-1.613.807 0 1.451.73 1.438 1.613 0 .888-.631 1.612-1.438 1.612Z"/>
</svg>
</a>
<button class="app-theme-toggle" id="theme-toggle" aria-label="Toggle dark mode" title="Toggle dark mode">
<svg class="app-theme-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle class="theme-sun" cx="12" cy="12" r="5"/>
<line class="theme-sun" x1="12" y1="1" x2="12" y2="3"/>
<line class="theme-sun" x1="12" y1="21" x2="12" y2="23"/>
<line class="theme-sun" x1="4.22" y1="4.22" x2="5.64" y2="5.64"/>
<line class="theme-sun" x1="18.36" y1="18.36" x2="19.78" y2="19.78"/>
<line class="theme-sun" x1="1" y1="12" x2="3" y2="12"/>
<line class="theme-sun" x1="21" y1="12" x2="23" y2="12"/>
<line class="theme-sun" x1="4.22" y1="19.78" x2="5.64" y2="18.36"/>
<line class="theme-sun" x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
<path class="theme-moon" d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
</svg>
</button>
</nav>
</div>
</header>
<!-- Main Layout -->
<div class="app-layout">
<!-- Sidebar Navigation -->
<nav class="app-sidebar" id="sidebar" aria-label="Document navigation">
<div class="app-sidebar-header">
<h2>Documents</h2>
<button class="app-sidebar-toggle" id="sidebar-toggle" aria-label="Collapse sidebar" title="Collapse sidebar (Ctrl+B)">
</button>
</div>
<div class="app-search" id="search-container">
<input type="search" class="app-search-input" id="search-input" placeholder="Search documents... (Ctrl+K)" aria-label="Search documents">
<div class="app-search-results" id="search-results"></div>
</div>
<div class="app-nav-container" id="nav-container">
<!-- Navigation will be populated by JavaScript -->
<div class="app-loading">
<div class="app-spinner"></div>
Loading...
</div>
</div>
</nav>
<!-- Mobile sidebar backdrop -->
<div class="app-sidebar-backdrop" id="sidebar-backdrop"></div>
<!-- Content Wrapper -->
<div class="app-content-wrapper">
<!-- Sidebar expand button -->
<button class="app-sidebar-expand" id="sidebar-expand" aria-label="Expand sidebar">
</button>
<!-- Table of Contents (Left Side) -->
<aside class="app-toc" id="toc" aria-label="Table of contents">
<div class="app-toc-title">On this page</div>
<ul class="app-toc-list" id="toc-list">
<!-- Populated by JavaScript -->
</ul>
</aside>
<!-- Main Content -->
<main class="app-content" id="main-content">
<div class="app-content-inner" id="content">
<div class="app-welcome">
<h1>Welcome</h1>
<p>Select a document from the navigation menu to view its contents. This documentation site supports Mermaid diagrams, full-text search, and keyboard navigation.</p>
<div class="app-welcome-features">
<div class="app-welcome-feature">
<h3>Search</h3>
<p>Use the search bar or press Ctrl+K to quickly find documents.</p>
</div>
<div class="app-welcome-feature">
<h3>Navigation</h3>
<p>Browse by project or category in the sidebar. Use arrow keys to navigate.</p>
</div>
<div class="app-welcome-feature">
<h3>Diagrams</h3>
<p>Mermaid diagrams render automatically with full interactivity.</p>
</div>
<div class="app-welcome-feature">
<h3>Table of Contents</h3>
<p>Long documents show a table of contents for easy navigation.</p>
</div>
</div>
<p class="app-keyboard-hint">
<kbd>Ctrl</kbd>+<kbd>K</kbd> Search &nbsp;&nbsp;
<kbd>Ctrl</kbd>+<kbd>B</kbd> Toggle sidebar &nbsp;&nbsp;
<kbd>Esc</kbd> Close search
</p>
</div>
</div>
</main>
</div>
</div>
<!-- Footer -->
<footer class="app-footer">
<div class="app-footer-inner">
<span>Generated by <a href="https://github.com/tractorjuice/arc-kit" target="_blank">ArcKit</a></span>
<span id="footer-generated"></span>
</div>
</footer>
<script>
// ============================================
// Configuration - UPDATE THESE VALUES
// ============================================
const CONFIG = {
repo: '{{REPO}}', // Repository name
contentBaseUrl: '{{CONTENT_BASE_URL}}' // Fallback base URL for GitHub Pages (raw.githubusercontent.com)
};
// ============================================
// State
// ============================================
let manifest = null;
let healthData = null;
let telemetryData = null;
let documentCache = new Map();
let allDocuments = [];
let currentTocObserver = null;
// ============================================
// Utilities
// ============================================
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function getRawUrl(path) {
return `../${path}`;
}
function getFallbackUrl(path) {
return CONFIG.contentBaseUrl ? `${CONFIG.contentBaseUrl}/${path}` : null;
}
function getDocumentId(path) {
return path.replace(/[^a-zA-Z0-9]/g, '-');
}
function extractTitle(path) {
const filename = path.split('/').pop().replace(/\.(md|html)$/i, '');
return filename
.replace(/-/g, ' ')
.replace(/\b\w/g, l => l.toUpperCase());
}
function debounce(fn, delay) {
let timeout;
return (...args) => {
clearTimeout(timeout);
timeout = setTimeout(() => fn(...args), delay);
};
}
// ============================================
// PlantUML Text Encoding
// ============================================
// PlantUML supports ~h (hex) prefix for hex-encoded source text.
// This avoids needing a deflate library while keeping URLs valid.
// See: https://plantuml.com/text-encoding
function plantumlEncodeHex(text) {
const bytes = new TextEncoder().encode(text);
let hex = '';
for (let i = 0; i < bytes.length; i++) {
hex += bytes[i].toString(16).padStart(2, '0');
}
return '~h' + hex;
}
function plantumlGetUrl(source, format) {
format = format || 'svg';
return 'https://www.plantuml.com/plantuml/' + format + '/' + plantumlEncodeHex(source);
}
// ============================================
// Marked Configuration
// ============================================
const renderer = new marked.Renderer();
renderer.code = function(code, language) {
// Handle object input (newer marked versions)
if (typeof code === 'object') {
language = code.lang;
code = code.text;
}
if (language === 'mermaid') {
return `<pre class="mermaid">${escapeHtml(code)}</pre>`;
}
if (language === 'plantuml') {
const encoded = btoa(unescape(encodeURIComponent(code)));
return `<div class="plantuml-diagram" data-plantuml-source="${encoded}"><pre><code>${escapeHtml(code)}</code></pre></div>`;
}
const langClass = language ? `language-${language}` : '';
return `<pre><code class="${langClass}">${escapeHtml(code)}</code></pre>`;
};
// Add IDs to headings for ToC links
renderer.heading = function(text, level) {
// Handle object input (newer marked versions)
if (typeof text === 'object') {
level = text.depth;
text = text.text;
}
const id = text.toLowerCase().replace(/[^\w]+/g, '-');
return `<h${level} id="${id}">${text}</h${level}>`;
};
marked.setOptions({
renderer: renderer,
gfm: true,
breaks: false
});
// ============================================
// Mermaid Configuration
// ============================================
mermaid.initialize({
startOnLoad: false,
theme: isDarkMode() ? 'dark' : 'default',
securityLevel: 'loose',
flowchart: { useMaxWidth: true },
sequence: { useMaxWidth: true }
});
// ============================================
// Theme Toggle
// ============================================
function toggleTheme() {
const current = document.documentElement.getAttribute('data-theme');
const next = current === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', next);
localStorage.setItem('arckit-theme', next);
mermaid.initialize({
startOnLoad: false,
theme: next === 'dark' ? 'dark' : 'default',
securityLevel: 'loose',
flowchart: { useMaxWidth: true },
sequence: { useMaxWidth: true }
});
}
function isDarkMode() {
return document.documentElement.getAttribute('data-theme') === 'dark' ||
(!document.documentElement.getAttribute('data-theme') &&
window.matchMedia('(prefers-color-scheme: dark)').matches);
}
// ============================================
// Search Functionality
// ============================================
function buildSearchIndex(manifest) {
allDocuments = [];
// Add guides
if (manifest.guides) {
manifest.guides.forEach(doc => {
allDocuments.push({
...doc,
searchText: `${doc.title} ${doc.section || ''} ${doc.category || ''} ${doc.pack || ''} guide command`.toLowerCase()
});
});
}
// Add role guides
if (manifest.roleGuides) {
manifest.roleGuides.forEach(doc => {
allDocuments.push({
...doc,
searchText: `${doc.title} ${doc.family || ''} role ddat`.toLowerCase()
});
});
}
// Add global documents
if (manifest.global) {
manifest.global.forEach(doc => {
allDocuments.push({
...doc,
searchText: `${doc.title} ${doc.category || ''} global`.toLowerCase()
});
});
}
// Add project documents
if (manifest.projects) {
manifest.projects.forEach(project => {
const projectName = project.name || project.id;
if (project.documents) {
project.documents.forEach(doc => {
allDocuments.push({
...doc,
project: projectName,
searchText: `${doc.title} ${doc.category || ''} ${projectName}`.toLowerCase()
});
});
}
if (project.diagrams) {
project.diagrams.forEach(doc => {
allDocuments.push({
...doc,
project: projectName,
category: 'Diagrams',
searchText: `${doc.title} diagrams ${projectName}`.toLowerCase()
});
});
}
if (project.decisions) {
project.decisions.forEach(doc => {
allDocuments.push({
...doc,
project: projectName,
category: 'Decisions',
searchText: `${doc.title} decisions adr ${projectName}`.toLowerCase()
});
});
}
if (project.wardleyMaps) {
project.wardleyMaps.forEach(doc => {
allDocuments.push({
...doc,
project: projectName,
category: 'Wardley Maps',
searchText: `${doc.title} wardley map strategy ${projectName}`.toLowerCase()
});
});
}
if (project.dataContracts) {
project.dataContracts.forEach(doc => {
allDocuments.push({
...doc,
project: projectName,
category: 'Data Contracts',
searchText: `${doc.title} data contract mesh ${projectName}`.toLowerCase()
});
});
}
if (project.research) {
project.research.forEach(doc => {
allDocuments.push({
...doc,
project: projectName,
category: 'Research',
searchText: `${doc.title} research ${projectName}`.toLowerCase()
});
});
}
if (project.reviews) {
project.reviews.forEach(doc => {
allDocuments.push({
...doc,
project: projectName,
category: 'Reviews',
searchText: `${doc.title} review design ${projectName}`.toLowerCase()
});
});
}
if (project.vendors) {
project.vendors.forEach(vendor => {
if (vendor.documents) {
vendor.documents.forEach(doc => {
allDocuments.push({
...doc,
project: projectName,
vendor: vendor.name,
searchText: `${doc.title} ${vendor.name} vendor ${projectName}`.toLowerCase()
});
});
}
});
}
if (project.vendorProfiles) {
project.vendorProfiles.forEach(doc => {
allDocuments.push({
...doc,
project: projectName,
category: 'Vendor Profiles',
searchText: `${doc.title} vendor profile ${projectName}`.toLowerCase()
});
});
}
if (project.techNotes) {
project.techNotes.forEach(doc => {
allDocuments.push({
...doc,
project: projectName,
category: 'Tech Notes',
searchText: `${doc.title} tech note technology ${projectName}`.toLowerCase()
});
});
}
if (project.dataSourceProfiles) {
project.dataSourceProfiles.forEach(doc => {
allDocuments.push({
...doc,
project: projectName,
category: 'Data Source Profiles',
searchText: `${doc.title} data source profile api dataset ${projectName}`.toLowerCase()
});
});
}
});
}
}
function searchDocuments(query) {
if (!query || query.length < 2) return [];
const terms = query.toLowerCase().split(/\s+/);
return allDocuments
.filter(doc => terms.every(term => doc.searchText.includes(term)))
.slice(0, 10);
}
function setupSearch() {
const input = document.getElementById('search-input');
const results = document.getElementById('search-results');
const doSearch = debounce(() => {
const query = input.value.trim();
const matches = searchDocuments(query);
if (matches.length === 0) {
results.classList.remove('visible');
return;
}
results.innerHTML = matches.map(doc => `
<div class="app-search-result" data-path="${doc.path}">
<div class="app-search-result-title">${doc.title}</div>
<div class="app-search-result-path">${doc.project ? doc.project + ' / ' : ''}${doc.category || 'Document'}</div>
</div>
`).join('');
results.classList.add('visible');
// Add click handlers
results.querySelectorAll('.app-search-result').forEach(el => {
el.addEventListener('click', () => {
const path = el.dataset.path;
window.location.hash = path;
loadDocument(path);
input.value = '';
results.classList.remove('visible');
// Update active state
document.querySelectorAll('#nav-container a').forEach(a => {
a.classList.toggle('active', a.dataset.path === path);
});
// Close mobile sidebar
closeMobileSidebar();
});
});
}, 150);
input.addEventListener('input', doSearch);
input.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
input.value = '';
results.classList.remove('visible');
input.blur();
}
});
// Close results when clicking outside
document.addEventListener('click', (e) => {
if (!e.target.closest('#search-container')) {
results.classList.remove('visible');
}
});
}
// ============================================
// Guides Index Page
// ============================================
function showGuidesIndex() {
const content = document.getElementById('content');
const toc = document.getElementById('toc');
toc.style.display = 'none';
if (!manifest || !manifest.guides || manifest.guides.length === 0) {
content.innerHTML = `
<div class="app-welcome">
<h1>Guides</h1>
<p>No guides available. Guides are command usage documentation found in <code>docs/guides/</code>.</p>
</div>
`;
return;
}
// Group guides by manifest-provided section and category
const bySection = {};
manifest.guides.forEach(guide => {
const section = guide.section || 'Other / Uncategorised';
const cat = guide.category || 'Other';
if (!bySection[section]) bySection[section] = {};
if (!bySection[section][cat]) bySection[section][cat] = [];
bySection[section][cat].push(guide);
});
const sortByOrder = (keys, order) => keys.sort((a, b) => {
const aIdx = order.indexOf(a);
const bIdx = order.indexOf(b);
if (aIdx === -1 && bIdx === -1) return a.localeCompare(b);
return (aIdx === -1 ? 999 : aIdx) - (bIdx === -1 ? 999 : bIdx);
});
const sectionOrder = manifest.guideSectionOrder || ['Core Workflows', 'Plugin and Extension Operations', 'Overlay Packs', 'Community Guides', 'Other / Uncategorised'];
const categoryOrder = manifest.guideCategoryOrder || ['Getting Started', 'Discovery', 'Planning', 'Architecture', 'Wardley Mapping', 'Governance', 'Compliance', 'Operations', 'Procurement', 'Interoperability', 'Integrations', 'Reporting', 'Plugin Operations', 'Community', 'Other'];
const sortedSections = sortByOrder(Object.keys(bySection), sectionOrder);
let guidesHtml = '';
sortedSections.forEach(section => {
guidesHtml += `<h2>${escapeHtml(section)}</h2>`;
const sortedCategories = sortByOrder(Object.keys(bySection[section]), categoryOrder);
sortedCategories.forEach(category => {
guidesHtml += `<h3>${escapeHtml(category)}</h3><ul class="app-guides-list">`;
bySection[section][category]
.sort((a, b) => (a.title || a.path).localeCompare(b.title || b.path))
.forEach(guide => {
const statusBadge = guide.status
? `<span class="guide-status ${escapeHtml(guide.status)}">${escapeHtml(guide.status)}</span>`
: '';
const path = escapeHtml(guide.path);
guidesHtml += `<li><a href="#${path}" data-path="${path}"><span>${escapeHtml(guide.title)}</span>${statusBadge}</a></li>`;
});
guidesHtml += '</ul>';
});
});
content.innerHTML = `
<div class="app-doc-header">
<div class="app-doc-breadcrumb">Documentation</div>
<h1 class="app-doc-title">AI Command Guides</h1>
</div>
<div class="app-markdown">
<p>These guides explain how to use each ArcKit command, including prerequisites, expected outputs, and best practices.</p>
${guidesHtml}
</div>
`;
// Add click handlers for guide links
content.querySelectorAll('a[data-path]').forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
const path = link.dataset.path;
window.location.hash = path;
loadDocument(path);
});
});
// Clear active states in sidebar
document.querySelectorAll('#nav-container a').forEach(a => a.classList.remove('active'));
}
// ============================================
// Roles Index Page (DDaT Role Guides)
// ============================================
function showRolesIndex() {
const content = document.getElementById('content');
const toc = document.getElementById('toc');
toc.style.display = 'none';
if (!manifest || !manifest.roleGuides || manifest.roleGuides.length === 0) {
content.innerHTML = '<div class="app-welcome"><h1>DDaT Role Guides</h1>' +
'<p>No role guides available. Role guides map ArcKit commands to ' +
'<a href="https://ddat-capability-framework.service.gov.uk/" target="_blank" rel="noopener">DDaT Capability Framework</a> roles.</p></div>';
return;
}
// Group role guides by family
const byFamily = {};
manifest.roleGuides.forEach(function(guide) {
const family = guide.family || 'Other';
if (!byFamily[family]) byFamily[family] = [];
byFamily[family].push(guide);
});
// Sort families in logical order
const familyOrder = ['Architecture', 'Chief Digital and Data', 'Product and Delivery', 'Data', 'IT Operations', 'Software Development', 'Other'];
const sortedFamilies = Object.keys(byFamily).sort(function(a, b) {
const aIdx = familyOrder.indexOf(a);
const bIdx = familyOrder.indexOf(b);
return (aIdx === -1 ? 999 : aIdx) - (bIdx === -1 ? 999 : bIdx);
});
// Build role guide HTML using safe string concatenation from manifest data
let rolesHtml = '';
sortedFamilies.forEach(function(family) {
rolesHtml += '<h2>' + family + '</h2><ul class="app-guides-list">';
byFamily[family].forEach(function(guide) {
const cmdCount = guide.commandCount
? '<span class="guide-status beta">' + guide.commandCount + ' commands</span>'
: '';
rolesHtml += '<li><a href="#' + encodeURIComponent(guide.path) + '" data-path="' + guide.path + '"><span>' + guide.title + '</span>' + cmdCount + '</a></li>';
});
rolesHtml += '</ul>';
});
content.innerHTML = '<div class="app-doc-header">' +
'<div class="app-doc-breadcrumb">Documentation</div>' +
'<h1 class="app-doc-title">DDaT Role Guides</h1></div>' +
'<div class="app-markdown">' +
'<p>Which ArcKit commands should <strong>you</strong> use? These guides map ArcKit commands to ' +
'<a href="https://ddat-capability-framework.service.gov.uk/" target="_blank" rel="noopener">UK Government DDaT Capability Framework</a> ' +
'roles, so you can focus on the commands relevant to your job.</p>' +
rolesHtml + '</div>';
// Add click handlers for role guide links
content.querySelectorAll('a[data-path]').forEach(function(link) {
link.addEventListener('click', function(e) {
e.preventDefault();
const path = link.dataset.path;
window.location.hash = path;
loadDocument(path);
});
});
// Clear active states in sidebar
document.querySelectorAll('#nav-container a').forEach(function(a) { a.classList.remove('active'); });
}
// ============================================
// Dashboard
// ============================================
const CORE_ARTIFACTS = ['REQ', 'STKE', 'RISK', 'SOBC', 'PLAN', 'DATA'];
// Loaded from manifest.typeCategories (written by sync-guides hook from config/doc-types.mjs)
let TYPE_CATEGORIES = {};
const CATEGORY_COLORS = {
'Getting Started': '#4c6272',
Discovery: '#1d70b8',
Planning: '#00703c',
Architecture: '#f47738',
Governance: '#912b88',
Compliance: '#5694ca',
Operations: '#28a197',
Procurement: '#d53880',
Integrations: '#4c2c92',
Reporting: '#85994b',
Other: '#b1b4b6'
};
function extractTypeCode(path) {
const filename = path.split('/').pop();
const match = filename.match(/^ARC-\d{3}-([A-Z][A-Z0-9-]+?)-(?:\d{3}-)?v\d/);
return match ? match[1] : null;
}
function computeDashboardMetrics(manifest) {
const realProjects = (manifest.projects || []).filter(p => /^\d{3}-/.test(p.id));
let totalDocs = 0;
let totalADRs = 0;
const categoryCounts = {};
const projectMetrics = [];
realProjects.forEach(project => {
const docs = project.documents || [];
const diagrams = project.diagrams || [];
const decisions = project.decisions || [];
const vendors = project.vendors || [];
const wardleyMaps = project.wardleyMaps || [];
const dataContracts = project.dataContracts || [];
const research = project.research || [];
const reviews = project.reviews || [];
const vendorProfiles = project.vendorProfiles || [];
const techNotes = project.techNotes || [];
const allDocs = [...docs, ...diagrams, ...decisions, ...wardleyMaps, ...dataContracts, ...research, ...reviews, ...vendorProfiles, ...techNotes];
vendors.forEach(v => { if (v.documents) allDocs.push(...v.documents); });
const projectDocCount = allDocs.length;
totalDocs += projectDocCount;
totalADRs += decisions.length;
// Category counts
allDocs.forEach(doc => {
const typeCode = extractTypeCode(doc.path || '');
const category = typeCode ? (TYPE_CATEGORIES[typeCode] || 'Other') : 'Other';
categoryCounts[category] = (categoryCounts[category] || 0) + 1;
});
// Per-project coverage
const presentTypes = new Set();
allDocs.forEach(doc => {
const tc = extractTypeCode(doc.path || '');
if (tc) presentTypes.add(tc);
});
const corePresent = CORE_ARTIFACTS.filter(t => presentTypes.has(t)).length;
const coverage = CORE_ARTIFACTS.length > 0 ? Math.round((corePresent / CORE_ARTIFACTS.length) * 100) : 0;
// First document path for linking
const firstDoc = docs[0] || diagrams[0] || decisions[0] || wardleyMaps[0] || dataContracts[0] || research[0] || reviews[0];
projectMetrics.push({
name: project.name || project.id,
id: project.id,
docs: projectDocCount,
diagrams: diagrams.length,
adrs: decisions.length,
vendors: vendors.length,
vendorProfiles: vendorProfiles.length,
techNotes: techNotes.length,
coverage,
firstDocPath: firstDoc ? firstDoc.path : null
});
});
// Add global docs
const globalDocs = manifest.global || [];
totalDocs += globalDocs.length;
globalDocs.forEach(doc => {
const typeCode = extractTypeCode(doc.path || '');
const category = typeCode ? (TYPE_CATEGORIES[typeCode] || 'Other') : 'Other';
categoryCounts[category] = (categoryCounts[category] || 0) + 1;
});
// Average coverage
const avgCoverage = projectMetrics.length > 0
? Math.round(projectMetrics.reduce((sum, p) => sum + p.coverage, 0) / projectMetrics.length)
: 0;
// Governance coverage (portfolio-wide)
const GOVERNANCE_TYPES = {
'Requirements (REQ)': 'REQ',
'Stakeholders (STKE)': 'STKE',
'Risk Register (RISK)': 'RISK',
'Business Case (SOBC)': 'SOBC',
'Project Plan (PLAN)': 'PLAN',
'Data Model (DATA)': 'DATA',
'Traceability (TRAC)': 'TRAC',
'Architecture Decisions (ADR)': 'ADR',
'Secure by Design (SECD)': 'SECD',
'TCoP Assessment (TCOP)': 'TCOP',
'DPIA (DPIA)': 'DPIA',
'Principles Compliance': 'PRIN-COMP'
};
const firstDocByType = {};
realProjects.forEach(project => {
const allDocs = [...(project.documents || []), ...(project.diagrams || []),
...(project.decisions || []), ...(project.wardleyMaps || []),
...(project.dataContracts || []), ...(project.research || []),
...(project.reviews || []),
...(project.vendorProfiles || []), ...(project.techNotes || [])];
allDocs.forEach(doc => {
const tc = extractTypeCode(doc.path || '');
if (tc && !firstDocByType[tc]) firstDocByType[tc] = doc.path;
});
});
const governanceCoverage = {};
Object.entries(GOVERNANCE_TYPES).forEach(([label, code]) => {
governanceCoverage[label] = {
present: !!firstDocByType[code],
path: firstDocByType[code] || null
};
});
return {
totalProjects: realProjects.length,
totalDocs,
totalADRs,
avgCoverage,
categoryCounts,
projectMetrics,
governanceCoverage
};
}
function renderDonutChart(containerId, data) {
const container = document.getElementById(containerId);
if (!container) return;
const total = data.reduce((sum, d) => sum + d.value, 0);
if (total === 0) {
container.innerHTML = '<p style="color:var(--text-muted);font-size:0.875rem;">No data</p>';
return;
}
const size = 140;
const radius = 56;
const holeRadius = 36;
const cx = size / 2;
const cy = size / 2;
let currentAngle = -Math.PI / 2;
let paths = '';
data.filter(d => d.value > 0).forEach(d => {
const sliceAngle = (d.value / total) * 2 * Math.PI;
const endAngle = currentAngle + sliceAngle;
const largeArc = sliceAngle > Math.PI ? 1 : 0;
// Handle full circle (single segment)
if (Math.abs(sliceAngle - 2 * Math.PI) < 0.001) {
paths += `<circle cx="${cx}" cy="${cy}" r="${radius}" fill="none" stroke="${d.color}" stroke-width="${radius - holeRadius}"/>`;
} else {
const x1 = cx + radius * Math.cos(currentAngle);
const y1 = cy + radius * Math.sin(currentAngle);
const x2 = cx + radius * Math.cos(endAngle);
const y2 = cy + radius * Math.sin(endAngle);
const ix1 = cx + holeRadius * Math.cos(endAngle);
const iy1 = cy + holeRadius * Math.sin(endAngle);
const ix2 = cx + holeRadius * Math.cos(currentAngle);
const iy2 = cy + holeRadius * Math.sin(currentAngle);
paths += `<path d="M${x1},${y1} A${radius},${radius} 0 ${largeArc} 1 ${x2},${y2} L${ix1},${iy1} A${holeRadius},${holeRadius} 0 ${largeArc} 0 ${ix2},${iy2} Z" fill="${d.color}"/>`;
}
currentAngle = endAngle;
});
const textColor = getComputedStyle(document.documentElement).getPropertyValue('--text-primary').trim() || '#0b0c0c';
const svg = `<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">
${paths}
<text x="${cx}" y="${cy}" text-anchor="middle" dominant-baseline="central" font-size="20" font-weight="700" fill="${textColor}">${total}</text>
</svg>`;
const legendHtml = data.filter(d => d.value > 0).map(d =>
`<li><span class="app-dashboard-legend-swatch" style="background:${d.color}"></span>${d.label}<span class="app-dashboard-legend-count">${d.value}</span></li>`
).join('');
container.innerHTML = `
<div class="app-dashboard-chart-container">
${svg}
<ul class="app-dashboard-legend">${legendHtml}</ul>
</div>
`;
}
function scoreColor(score) {
if (score >= 2.5) return '#00703c';
if (score >= 1.5) return '#f47738';
if (score >= 0.5) return '#f47738';
return '#d4351c';
}
function renderProjectHealthPanel(manifest) {
const ph = manifest && manifest.projectHealth;
if (!ph || !Array.isArray(ph.projects) || ph.projects.length === 0) return '';
function pctColor(pct) {
if (pct >= 80) return '#00703c';
if (pct >= 50) return '#f47738';
return '#d4351c';
}
const cards = ph.projects.map(p => {
const compPct = p.compliance.pct;
const covPct = p.coverage.essentialPct;
const recHtml = (p.recommendations || []).slice(0, 3).map(r =>
`<li><code>${escapeHtml(r.command)}</code> <span style="color:var(--text-secondary);font-size:12px">— Tier ${r.tier} ${escapeHtml(r.label)}</span></li>`
).join('');
const noRec = (p.recommendations || []).length === 0
? '<li style="color:#00703c">All essentials present</li>'
: '';
const flags = [];
if (p.health.stale > 0) flags.push(`<span class="app-depmap-health-badge app-depmap-health-badge--stale">${p.health.stale} stale</span>`);
if (p.health.draft > 0) flags.push(`<span class="app-depmap-health-badge app-depmap-health-badge--draft">${p.health.draft} draft</span>`);
if (p.health.orphan > 0) flags.push(`<span class="app-depmap-health-badge app-depmap-health-badge--orphan">${p.health.orphan} orphan</span>`);
const flagHtml = flags.length > 0
? flags.join(' ')
: '<span style="color:#00703c;font-size:12px">&#10003; No health issues</span>';
return `
<div style="border:1px solid var(--border-main);border-radius:4px;padding:1rem;background:var(--bg-surface)">
<div style="display:flex;justify-content:space-between;align-items:baseline;gap:1rem;margin-bottom:0.5rem">
<strong>${escapeHtml(p.project)}</strong>
<span style="font-size:12px;color:var(--text-secondary)">${p.artifactCount} docs · ${p.edgeCount} refs · density ${p.density}</span>
</div>
<div class="app-dashboard-bar-row" style="margin:0.25rem 0">
<div class="app-dashboard-bar-label" style="font-size:12px">Essential coverage</div>
<div class="app-dashboard-bar-track"><div class="app-dashboard-bar-fill" style="width:${covPct}%;background:${pctColor(covPct)}"></div></div>
<div class="app-dashboard-bar-value">${covPct}%</div>
</div>
<div class="app-dashboard-bar-row" style="margin:0.25rem 0">
<div class="app-dashboard-bar-label" style="font-size:12px">Compliance readiness</div>
<div class="app-dashboard-bar-track"><div class="app-dashboard-bar-fill" style="width:${compPct}%;background:${pctColor(compPct)}"></div></div>
<div class="app-dashboard-bar-value">${p.compliance.presentHigh.length}/${p.compliance.total}</div>
</div>
<div style="margin-top:0.5rem">${flagHtml}</div>
<div style="margin-top:0.75rem">
<div style="font-size:12px;font-weight:700;color:var(--text-secondary);text-transform:uppercase;letter-spacing:0.5px;margin-bottom:0.25rem">Next steps</div>
<ul style="margin:0;padding-left:1.25rem;font-size:13px">${recHtml}${noRec}</ul>
</div>
</div>
`;
}).join('');
return `
<div class="app-dashboard-panel" style="margin-bottom:1.5rem">
<h3>Project Health & Next Steps</h3>
<p style="font-size:12px;color:var(--text-secondary);margin:0 0 0.75rem 0">
Coverage = essential doc types present (Tiers 14). Compliance = HIGH-severity governance types present.
Stale = lastModified ${ph.staleThresholdDays}+ days ago. Generated ${new Date(ph.baseline).toLocaleString()}.
</p>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:1rem">
${cards}
</div>
</div>
`;
}
function renderVendorScores(manifest) {
let html = '';
const projects = (manifest.projects || []).filter(p => p.vendorScores);
if (projects.length === 0) return '';
projects.forEach(project => {
const vs = project.vendorScores;
const categories = vs.categories || [];
const vendors = vs.vendors || [];
if (vendors.length === 0) return;
const dateStr = vs.lastUpdated
? new Date(vs.lastUpdated).toLocaleDateString()
: '';
// Table header
let catHeaders = categories.map(c => `<th>${escapeHtml(c)}</th>`).join('');
let tableRows = '';
vendors.forEach(v => {
const pct = v.maxPossible > 0
? Math.round((v.totalRaw / v.maxPossible) * 100)
: 0;
let catCells = categories.map(c => {
const avg = (v.categoryAverages && v.categoryAverages[c]) || 0;
return `<td><span class="app-dashboard-score-cell" style="background:${scoreColor(avg)}20;color:${scoreColor(avg)}">${avg.toFixed(1)}</span></td>`;
}).join('');
tableRows += `<tr>
<td>${escapeHtml(v.name)}</td>
<td style="font-weight:bold">${v.totalWeighted.toFixed(2)}</td>
<td>${v.totalRaw}/${v.maxPossible}</td>
${catCells}
</tr>`;
});
// Score bars
let bars = '';
vendors.forEach(v => {
const pct = v.maxPossible > 0
? Math.round((v.totalRaw / v.maxPossible) * 100)
: 0;
bars += `
<div class="app-dashboard-score-bar">
<div class="app-dashboard-score-bar-label">${escapeHtml(v.name)}</div>
<div class="app-dashboard-score-bar-track">
<div class="app-dashboard-score-bar-fill" style="width:${pct}%;background:${scoreColor(v.totalWeighted)}"></div>
</div>
<div class="app-dashboard-score-bar-pct">${pct}%</div>
</div>
`;
});
html += `
<div class="app-dashboard-panel app-dashboard-scores-section" style="margin-bottom:1.5rem;">
<div class="app-dashboard-scores-header">
<h3>Vendor Scores &mdash; ${escapeHtml(project.name)}</h3>
${dateStr ? `<span class="app-dashboard-scores-date">Updated: ${dateStr}</span>` : ''}
</div>
<table class="app-dashboard-table">
<thead><tr><th>Vendor</th><th>Weighted</th><th>Raw</th>${catHeaders}</tr></thead>
<tbody>${tableRows}</tbody>
</table>
${bars}
</div>
`;
});
return html;
}
function showDashboard(healthData) {
const content = document.getElementById('content');
const toc = document.getElementById('toc');
toc.style.display = 'none';
if (!manifest) {
content.innerHTML = '<div class="app-loading"><div class="app-spinner"></div>Loading...</div>';
return;
}
const m = computeDashboardMetrics(manifest);
function coverageColor(pct) {
if (pct >= 80) return '#00703c';
if (pct >= 50) return '#f47738';
return '#d4351c';
}
// KPI cards
let html = `
<div class="app-doc-header">
<div class="app-doc-breadcrumb">Portfolio Overview</div>
<h1 class="app-doc-title">Architecture Governance Dashboard</h1>
</div>
<div class="app-dashboard-kpis">
<div class="app-dashboard-kpi">
<div class="app-dashboard-kpi-value">${m.totalProjects}</div>
<div class="app-dashboard-kpi-label">Total Projects</div>
</div>
<div class="app-dashboard-kpi">
<div class="app-dashboard-kpi-value">${m.totalDocs}</div>
<div class="app-dashboard-kpi-label">Total Documents</div>
</div>
<div class="app-dashboard-kpi">
<div class="app-dashboard-kpi-value">${m.totalADRs}</div>
<div class="app-dashboard-kpi-label">Architecture Decisions</div>
</div>
<div class="app-dashboard-kpi">
<div class="app-dashboard-kpi-value">${m.avgCoverage}%</div>
<div class="app-dashboard-kpi-label">Avg Artifact Coverage</div>
<div class="app-dashboard-kpi-bar">
<div class="app-dashboard-kpi-bar-fill" style="width:${m.avgCoverage}%;background:${coverageColor(m.avgCoverage)}"></div>
</div>
</div>
</div>
`;
// Row 2: Donut chart + Coverage bars
let coverageBars = '';
m.projectMetrics.forEach(p => {
const barLabel = p.firstDocPath
? `<a href="#${p.firstDocPath}" class="app-dashboard-link" data-path="${p.firstDocPath}">${escapeHtml(p.name)}</a>`
: escapeHtml(p.name);
coverageBars += `
<div class="app-dashboard-bar-row">
<div class="app-dashboard-bar-label" title="${escapeHtml(p.name)}">${barLabel}</div>
<div class="app-dashboard-bar-track">
<div class="app-dashboard-bar-fill" style="width:${p.coverage}%;background:${coverageColor(p.coverage)}"></div>
</div>
<div class="app-dashboard-bar-value">${p.coverage}%</div>
</div>
`;
});
if (m.projectMetrics.length === 0) {
coverageBars = '<p style="color:var(--text-muted);font-size:0.875rem;">No projects found</p>';
}
html += `
<div class="app-dashboard-panels">
<div class="app-dashboard-panel">
<h3>Documents by Category</h3>
<div id="dashboard-category-chart"></div>
</div>
<div class="app-dashboard-panel">
<h3>Project Artifact Coverage</h3>
${coverageBars}
</div>
</div>
`;
// Row 3: Projects table
// Build per-project health lookup
const healthByProject = {};
if (healthData && healthData.projects) {
healthData.projects.forEach(hp => { healthByProject[hp.id] = hp; });
}
if (m.projectMetrics.length > 0) {
let tableRows = '';
m.projectMetrics.forEach(p => {
const nameCell = p.firstDocPath
? `<a href="#${p.firstDocPath}" class="app-dashboard-link" data-path="${p.firstDocPath}">${escapeHtml(p.name)}</a>`
: escapeHtml(p.name);
let healthCell = '\u2014';
if (healthData) {
const hp = healthByProject[p.id];
if (hp && hp.findings && hp.findings.length > 0) {
const hasHigh = hp.findings.some(f => f.severity === 'HIGH');
const hasMedium = hp.findings.some(f => f.severity === 'MEDIUM');
const color = hasHigh ? '#d4351c' : hasMedium ? '#f47738' : '#b1b4b6';
healthCell = `<span style="color:${color};font-weight:bold">${hp.findings.length}</span>`;
} else {
healthCell = '<span style="color:#00703c">&#10003;</span>';
}
}
tableRows += `<tr>
<td>${nameCell}</td>
<td>${p.docs}</td>
<td>${p.diagrams}</td>
<td>${p.adrs}</td>
<td>${p.vendors}</td>
<td>${(p.vendorProfiles || 0) + (p.techNotes || 0)}</td>
<td>${healthCell}</td>
<td><span class="app-dashboard-coverage-bar"><span class="app-dashboard-coverage-fill" style="width:${p.coverage}%;background:${coverageColor(p.coverage)}"></span></span>${p.coverage}%</td>
</tr>`;
});
html += `
<div class="app-dashboard-panel" style="margin-bottom:1.5rem;">
<h3>Projects</h3>
<table class="app-dashboard-table">
<thead><tr><th>Project</th><th>Docs</th><th>Diagrams</th><th>ADRs</th><th>Vendors</th><th>Knowledge</th><th>Health</th><th>Coverage</th></tr></thead>
<tbody>${tableRows}</tbody>
</table>
</div>
`;
}
// Row 3.7: Compliance & Next Steps (from manifest.projectHealth)
html += renderProjectHealthPanel(manifest);
// Row 3.5: Vendor Scores
html += renderVendorScores(manifest);
// Row 4: Governance checklist
let checklistHtml = '';
Object.entries(m.governanceCoverage).forEach(([label, info]) => {
const icon = info.present
? '<span class="app-dashboard-check">&#10003;</span>'
: '<span class="app-dashboard-cross">&#10007;</span>';
const labelHtml = info.path
? `<a href="#${info.path}" class="app-dashboard-link" data-path="${info.path}">${escapeHtml(label)}</a>`
: escapeHtml(label);
checklistHtml += `<li>${icon} ${labelHtml}</li>`;
});
html += `
<div class="app-dashboard-panels">
<div class="app-dashboard-panel">
<h3>Governance Coverage</h3>
<ul class="app-dashboard-checklist">${checklistHtml}</ul>
</div>
</div>
`;
// Row 5: Artifact Health (only if healthData exists)
if (healthData) {
const hs = healthData.summary || { HIGH: 0, MEDIUM: 0, LOW: 0, total: 0 };
let healthLeftHtml = '';
if (hs.total === 0) {
healthLeftHtml = '<p style="color:#00703c;font-weight:bold;margin:1rem 0;">&#10003; All clear &mdash; no findings</p>';
} else {
const maxCount = Math.max(hs.HIGH, hs.MEDIUM, hs.LOW, 1);
const bars = [
{ label: 'HIGH', count: hs.HIGH, color: '#d4351c' },
{ label: 'MEDIUM', count: hs.MEDIUM, color: '#f47738' },
{ label: 'LOW', count: hs.LOW, color: '#b1b4b6' }
];
bars.forEach(b => {
const pct = Math.round((b.count / maxCount) * 100);
healthLeftHtml += `
<div class="app-dashboard-bar-row">
<div class="app-dashboard-bar-label" style="min-width:70px">${b.label}</div>
<div class="app-dashboard-bar-track">
<div class="app-dashboard-bar-fill" style="width:${pct}%;background:${b.color}"></div>
</div>
<div class="app-dashboard-bar-value">${b.count}</div>
</div>
`;
});
}
const scanDate = healthData.generated ? new Date(healthData.generated).toLocaleDateString() : 'Unknown';
const scanArtifacts = healthData.scanned ? healthData.scanned.artifacts : '?';
const scanProjects = healthData.scanned ? healthData.scanned.projects : '?';
healthLeftHtml += `<p style="color:var(--text-muted);font-size:0.75rem;margin-top:0.75rem;">Scanned: ${scanDate} \u2014 ${scanArtifacts} artifacts across ${scanProjects} projects</p>`;
const typeLabels = {
'STALE-RSCH': 'Stale Research',
'FORGOTTEN-ADR': 'Forgotten ADRs',
'UNRESOLVED-COND': 'Unresolved Conditions',
'ORPHAN-REQ': 'Orphaned Requirements',
'MISSING-TRACE': 'Missing Traceability',
'VERSION-DRIFT': 'Version Drift'
};
let healthRightHtml = '<ul class="app-dashboard-checklist">';
const bt = healthData.byType || {};
Object.entries(typeLabels).forEach(([key, label]) => {
const count = bt[key] || 0;
if (count > 0) {
healthRightHtml += `<li><span class="app-dashboard-cross">&#10007;</span> ${escapeHtml(label)}: ${count}</li>`;
}
});
if (Object.values(bt).every(v => !v)) {
healthRightHtml += '<li><span class="app-dashboard-check">&#10003;</span> No issues detected</li>';
}
healthRightHtml += '</ul>';
html += `
<div class="app-dashboard-panels">
<div class="app-dashboard-panel">
<h3>Artifact Health</h3>
${healthLeftHtml}
</div>
<div class="app-dashboard-panel">
<h3>Findings by Type</h3>
${healthRightHtml}
</div>
</div>
`;
}
// Row 6: Session Telemetry (only if telemetryData exists)
if (telemetryData && Array.isArray(telemetryData.sessions) && telemetryData.sessions.length > 0) {
const sessions = telemetryData.sessions;
const recent = sessions.slice(0, 10); // last 10 for aggregates
const display = sessions.slice(0, 5); // last 5 in the table
// Aggregate over last 10 sessions
let totalToolCalls = 0;
let totalAgents = 0;
let totalMcp = 0;
let p50Sum = 0;
let p50Count = 0;
const effortMix = {}; // tier → count
recent.forEach(s => {
if (typeof s.effort === 'string' && s.effort) {
effortMix[s.effort] = (effortMix[s.effort] || 0) + 1;
}
const t = s.telemetry;
if (!t) return;
if (typeof t.toolCalls === 'number') totalToolCalls += t.toolCalls;
if (typeof t.p50 === 'number') { p50Sum += t.p50; p50Count++; }
if (Array.isArray(t.agents)) t.agents.forEach(a => { totalAgents += a.count || 0; });
if (Array.isArray(t.mcp)) t.mcp.forEach(m => { totalMcp += m.count || 0; });
});
const medianP50 = p50Count > 0 ? Math.round(p50Sum / p50Count) : null;
// Effort tier ordering for stable rendering (low → max).
const EFFORT_ORDER = ['low', 'medium', 'high', 'xhigh', 'max'];
const effortEntries = EFFORT_ORDER
.filter(k => effortMix[k])
.map(k => `${k}: ${effortMix[k]}`)
.concat(
Object.keys(effortMix)
.filter(k => !EFFORT_ORDER.includes(k))
.map(k => `${k}: ${effortMix[k]}`)
);
const effortMixLabel = effortEntries.length > 0 ? effortEntries.join(' · ') : '—';
let activityLeftHtml = `
<div class="app-dashboard-bar-row">
<div class="app-dashboard-bar-label" style="min-width:130px">Tool calls</div>
<div class="app-dashboard-bar-value">${totalToolCalls.toLocaleString()}</div>
</div>
<div class="app-dashboard-bar-row">
<div class="app-dashboard-bar-label" style="min-width:130px">Median p50 latency</div>
<div class="app-dashboard-bar-value">${medianP50 !== null ? medianP50 + ' ms' : '—'}</div>
</div>
<div class="app-dashboard-bar-row">
<div class="app-dashboard-bar-label" style="min-width:130px">Agents spawned</div>
<div class="app-dashboard-bar-value">${totalAgents.toLocaleString()}</div>
</div>
<div class="app-dashboard-bar-row">
<div class="app-dashboard-bar-label" style="min-width:130px">MCP calls</div>
<div class="app-dashboard-bar-value">${totalMcp.toLocaleString()}</div>
</div>
<div class="app-dashboard-bar-row">
<div class="app-dashboard-bar-label" style="min-width:130px">Effort mix</div>
<div class="app-dashboard-bar-value" style="font-size:0.8rem">${escapeHtml(effortMixLabel)}</div>
</div>
`;
activityLeftHtml += `<p style="color:var(--text-muted);font-size:0.75rem;margin-top:0.75rem;">Across last ${recent.length} session${recent.length === 1 ? '' : 's'}${sessions.length} total tracked.</p>`;
let activityRightHtml = '<ul class="app-dashboard-checklist">';
display.forEach(s => {
const dt = s.ts ? new Date(s.ts) : null;
const dateLabel = dt ? `${dt.toLocaleDateString()} ${dt.toTimeString().slice(0, 5)}` : '?';
const t = s.telemetry || {};
const parts = [];
if (typeof t.toolCalls === 'number') parts.push(`${t.toolCalls} tools`);
if (typeof t.p50 === 'number') parts.push(`p50=${t.p50}ms`);
if (Array.isArray(t.agents) && t.agents.length > 0) {
const total = t.agents.reduce((sum, a) => sum + (a.count || 0), 0);
parts.push(`${total} agent${total === 1 ? '' : 's'}`);
}
if (Array.isArray(t.mcp) && t.mcp.length > 0) {
const total = t.mcp.reduce((sum, m) => sum + (m.count || 0), 0);
parts.push(`${total} MCP`);
}
if (Array.isArray(t.byAgent)) {
const topAgent = t.byAgent.find(a => a.agent !== 'main');
if (topAgent) {
const calls = (topAgent.toolCalls || 0) + (topAgent.mcpCalls || 0);
parts.push(`top agent: ${topAgent.agent} (${calls})`);
}
}
const summary = parts.length > 0 ? parts.join(' · ') : 'no telemetry';
const failureMark = s.isFailure ? ' <span style="color:#d4351c">⚠</span>' : '';
const effortChip = (typeof s.effort === 'string' && s.effort)
? ` <span style="display:inline-block;font-size:0.7rem;padding:1px 6px;border-radius:8px;background:var(--surface-muted,#eef2f7);color:var(--text-muted);margin-left:4px">${escapeHtml(s.effort)}</span>`
: '';
activityRightHtml += `<li><strong>${escapeHtml(dateLabel)}</strong>${failureMark} — <span style="color:var(--text-muted)">${escapeHtml(s.type || 'general')}</span>${effortChip}: ${escapeHtml(summary)}</li>`;
});
activityRightHtml += '</ul>';
html += `
<div class="app-dashboard-panels">
<div class="app-dashboard-panel">
<h3>Session Telemetry</h3>
${activityLeftHtml}
</div>
<div class="app-dashboard-panel">
<h3>Recent Sessions</h3>
${activityRightHtml}
</div>
</div>
`;
}
// Note: innerHTML is used here with data sourced from local manifest.json
// and health.json files (both generated by ArcKit commands, not user input).
// All user-facing text passes through escapeHtml() above.
content.innerHTML = html;
// Render donut charts after DOM is populated
const categoryData = Object.entries(CATEGORY_COLORS)
.map(([label, color]) => ({ label, value: m.categoryCounts[label] || 0, color }));
renderDonutChart('dashboard-category-chart', categoryData);
// Wire up dashboard links to navigate to documents
content.querySelectorAll('.app-dashboard-link').forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
const path = link.dataset.path;
window.location.hash = path;
loadDocument(path);
// Update sidebar active state
document.querySelectorAll('#nav-container a').forEach(a => {
a.classList.toggle('active', a.dataset.path === path);
});
});
});
// Clear active states in sidebar
document.querySelectorAll('#nav-container a').forEach(a => a.classList.remove('active'));
// Scroll to top
document.getElementById('main-content').scrollTo(0, 0);
}
// ============================================
// Dependency Map
// ============================================
function showDependencyMap() {
const content = document.getElementById('content');
const toc = document.getElementById('toc');
toc.style.display = 'none';
if (!manifest.dependencyGraph || Object.keys(manifest.dependencyGraph.nodes).length === 0) {
content.textContent = '';
const h1 = document.createElement('h1');
h1.textContent = 'Dependency Map';
content.appendChild(h1);
const empty = document.createElement('div');
empty.className = 'app-depmap-empty';
const p1 = document.createElement('p');
p1.textContent = 'No dependency graph data available.';
const p2 = document.createElement('p');
p2.textContent = 'Run /arckit:pages in a project with ARC documents to generate the graph.';
empty.appendChild(p1);
empty.appendChild(p2);
content.appendChild(empty);
document.getElementById('main-content').scrollTo(0, 0);
return;
}
const { nodes, edges } = manifest.dependencyGraph;
const nodeIds = Object.keys(nodes);
// Build connected set for orphan detection
const connectedIds = new Set();
for (const e of edges) {
connectedIds.add(e.from);
for (const nid of nodeIds) {
if (nid.startsWith(e.to)) connectedIds.add(nid);
}
}
// Build project list for filter
const projectSet = new Set(nodeIds.map(id => nodes[id].project));
const projects = [...projectSet].sort();
const CATEGORY_ORDER = [
'Getting Started', 'Discovery', 'Planning', 'Architecture', 'Governance',
'Compliance', 'Operations', 'Procurement', 'Integrations', 'Reporting', 'Other'
];
// Check if any nodes have valid dates (for timeline toggle)
const hasValidDates = nodeIds.some(id => {
const d = nodes[id].createdDate;
return d && !isNaN(new Date(d).getTime());
});
let currentLayout = 'category';
if (hasValidDates) {
const stored = localStorage.getItem('arckit-depmap-layout');
if (stored === 'timeline') currentLayout = 'timeline';
}
// Build controls DOM
content.textContent = '';
const h1 = document.createElement('h1');
h1.textContent = 'Dependency Map';
content.appendChild(h1);
const container = document.createElement('div');
container.className = 'app-depmap-container';
const controls = document.createElement('div');
controls.className = 'app-depmap-controls';
const layoutLabel = document.createElement('label');
layoutLabel.textContent = 'Layout:';
layoutLabel.style.cssText = 'font-size:14px;font-weight:700;color:var(--text-primary)';
controls.appendChild(layoutLabel);
const toggleWrap = document.createElement('div');
toggleWrap.className = 'app-depmap-layout-toggle';
const btnCategory = document.createElement('button');
btnCategory.textContent = 'Category';
btnCategory.className = currentLayout === 'category' ? 'active' : '';
toggleWrap.appendChild(btnCategory);
if (hasValidDates) {
const btnTimeline = document.createElement('button');
btnTimeline.textContent = 'Timeline';
btnTimeline.className = currentLayout === 'timeline' ? 'active' : '';
toggleWrap.appendChild(btnTimeline);
btnTimeline.addEventListener('click', () => {
currentLayout = 'timeline';
localStorage.setItem('arckit-depmap-layout', 'timeline');
btnCategory.className = '';
btnTimeline.className = 'active';
renderGraph(select.value);
});
}
btnCategory.addEventListener('click', () => {
currentLayout = 'category';
localStorage.setItem('arckit-depmap-layout', 'category');
btnCategory.className = 'active';
const btnT = toggleWrap.querySelector('button:nth-child(2)');
if (btnT) btnT.className = '';
renderGraph(select.value);
});
controls.appendChild(toggleWrap);
const label = document.createElement('label');
label.setAttribute('for', 'depmap-project-filter');
label.textContent = 'Project:';
controls.appendChild(label);
const select = document.createElement('select');
select.id = 'depmap-project-filter';
const optAll = document.createElement('option');
optAll.value = '';
optAll.textContent = 'All Projects';
select.appendChild(optAll);
for (const p of projects) {
const opt = document.createElement('option');
opt.value = p;
opt.textContent = p;
select.appendChild(opt);
}
controls.appendChild(select);
const statsSpan = document.createElement('span');
statsSpan.id = 'depmap-stats';
statsSpan.style.cssText = 'margin-left:auto;font-size:12px;color:var(--text-secondary)';
controls.appendChild(statsSpan);
container.appendChild(controls);
const svgWrap = document.createElement('div');
svgWrap.id = 'depmap-svg-wrap';
svgWrap.style.cssText = 'overflow-x:auto;padding:16px';
container.appendChild(svgWrap);
const legendEl = document.createElement('div');
legendEl.className = 'app-depmap-legend';
legendEl.id = 'depmap-legend';
container.appendChild(legendEl);
content.appendChild(container);
// Render legend using DOM
for (const cat of CATEGORY_ORDER) {
if (!CATEGORY_COLORS[cat]) continue;
const item = document.createElement('span');
item.className = 'app-depmap-legend-item';
const swatch = document.createElement('span');
swatch.className = 'app-depmap-legend-swatch';
swatch.style.background = CATEGORY_COLORS[cat];
item.appendChild(swatch);
item.appendChild(document.createTextNode(' ' + cat));
legendEl.appendChild(item);
}
// Health legend separator + entries
const healthLegend = [
{ label: 'Stale', style: 'border:2px solid #d4351c;background:transparent' },
{ label: 'Draft', style: 'border:2px dashed #f47738;background:transparent' },
{ label: 'Orphan', style: 'border:1px dashed #505a5f;background:transparent' },
];
const sep = document.createElement('span');
sep.style.cssText = 'border-left:1px solid var(--border-main);height:14px;margin:0 4px';
legendEl.appendChild(sep);
for (const h of healthLegend) {
const item = document.createElement('span');
item.className = 'app-depmap-legend-item';
const swatch = document.createElement('span');
swatch.className = 'app-depmap-legend-swatch';
swatch.style.cssText = h.style;
item.appendChild(swatch);
item.appendChild(document.createTextNode(' ' + h.label));
legendEl.appendChild(item);
}
// Tooltip element
const tooltip = document.createElement('div');
tooltip.className = 'app-depmap-tooltip';
tooltip.style.display = 'none';
document.body.appendChild(tooltip);
// ── Timeline utilities ──
function computeDateBuckets(filteredIds) {
const validDates = [];
for (const id of filteredIds) {
const d = nodes[id].createdDate;
if (!d) continue;
const parsed = new Date(d);
if (isNaN(parsed.getTime())) continue;
validDates.push(parsed);
}
if (validDates.length === 0) return null;
validDates.sort((a, b) => a - b);
const minDate = validDates[0];
const maxDate = validDates[validDates.length - 1];
const rangeDays = (maxDate - minDate) / 86400000;
let granularity, buckets;
if (rangeDays < 90) {
granularity = 'weekly';
buckets = generateWeeklyBuckets(minDate, maxDate);
} else if (rangeDays <= 547) {
granularity = 'monthly';
buckets = generateMonthlyBuckets(minDate, maxDate);
} else {
granularity = 'quarterly';
buckets = generateQuarterlyBuckets(minDate, maxDate);
}
return { granularity, buckets };
}
function generateWeeklyBuckets(minDate, maxDate) {
const buckets = [];
const MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
const start = new Date(minDate);
const day = start.getDay();
const diff = day === 0 ? -6 : 1 - day;
start.setDate(start.getDate() + diff);
start.setHours(0, 0, 0, 0);
const end = new Date(maxDate);
end.setHours(23, 59, 59, 999);
let current = new Date(start);
while (current <= end) {
const weekStart = new Date(current);
const weekEnd = new Date(current);
weekEnd.setDate(weekEnd.getDate() + 6);
weekEnd.setHours(23, 59, 59, 999);
const weekNum = Math.ceil(weekStart.getDate() / 7);
const label = 'W' + weekNum + ' ' + MONTHS[weekStart.getMonth()];
buckets.push({ label, start: weekStart, end: weekEnd });
current.setDate(current.getDate() + 7);
}
return buckets;
}
function generateMonthlyBuckets(minDate, maxDate) {
const buckets = [];
const MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
let y = minDate.getFullYear(), m = minDate.getMonth();
const endY = maxDate.getFullYear(), endM = maxDate.getMonth();
while (y < endY || (y === endY && m <= endM)) {
const start = new Date(y, m, 1);
const end = new Date(y, m + 1, 0, 23, 59, 59, 999);
const label = MONTHS[m] + ' ' + y;
buckets.push({ label, start, end });
m++;
if (m > 11) { m = 0; y++; }
}
return buckets;
}
function generateQuarterlyBuckets(minDate, maxDate) {
const buckets = [];
let y = minDate.getFullYear();
let q = Math.floor(minDate.getMonth() / 3);
const endY = maxDate.getFullYear();
const endQ = Math.floor(maxDate.getMonth() / 3);
while (y < endY || (y === endY && q <= endQ)) {
const startMonth = q * 3;
const start = new Date(y, startMonth, 1);
const end = new Date(y, startMonth + 3, 0, 23, 59, 59, 999);
const label = 'Q' + (q + 1) + ' ' + y;
buckets.push({ label, start, end });
q++;
if (q > 3) { q = 0; y++; }
}
return buckets;
}
function assignNodeToBucket(node, buckets) {
if (!node.createdDate) return -1;
const d = new Date(node.createdDate);
if (isNaN(d.getTime())) return -1;
for (let i = 0; i < buckets.length; i++) {
if (d >= buckets[i].start && d <= buckets[i].end) return i;
}
return -1;
}
function renderNodes(svg, filteredIds, positions, connectedIds, NS) {
const nodeW = 110, nodeH = 44;
for (const id of filteredIds) {
const node = nodes[id];
const pos = positions[id];
if (!pos) continue;
const color = CATEGORY_COLORS[TYPE_CATEGORIES[node.type] || 'Other'] || CATEGORY_COLORS.Other;
const h = node.health || {};
const isOrphan = !connectedIds.has(id) || !!h.orphan;
const shortTitle = (node.title || '').length > 16
? node.title.substring(0, 15) + '\u2026'
: (node.title || id);
const classes = ['app-depmap-node'];
if (isOrphan) classes.push('orphan');
if (h.stale) classes.push('stale');
if (h.draft) classes.push('draft');
const g = document.createElementNS(NS, 'g');
g.setAttribute('class', classes.join(' '));
g.dataset.id = id;
g.dataset.path = node.path;
const rect = document.createElementNS(NS, 'rect');
rect.setAttribute('x', pos.x);
rect.setAttribute('y', pos.y);
rect.setAttribute('width', nodeW);
rect.setAttribute('height', nodeH);
rect.setAttribute('rx', '4');
rect.setAttribute('fill', 'var(--bg-surface, #fff)');
rect.setAttribute('stroke', color);
rect.setAttribute('stroke-width', '1.5');
g.appendChild(rect);
const typeText = document.createElementNS(NS, 'text');
typeText.setAttribute('x', pos.x + nodeW / 2);
typeText.setAttribute('y', pos.y + 16);
typeText.setAttribute('text-anchor', 'middle');
typeText.setAttribute('font-size', '11');
typeText.setAttribute('font-weight', '700');
typeText.setAttribute('fill', color);
typeText.textContent = node.type;
g.appendChild(typeText);
const titleText = document.createElementNS(NS, 'text');
titleText.setAttribute('x', pos.x + nodeW / 2);
titleText.setAttribute('y', pos.y + 30);
titleText.setAttribute('text-anchor', 'middle');
titleText.setAttribute('font-size', '8');
titleText.setAttribute('fill', 'var(--text-secondary, #666)');
titleText.textContent = shortTitle;
g.appendChild(titleText);
// Last-modified indicator dot
if (node.lastModified) {
const lmDate = new Date(node.lastModified);
if (!isNaN(lmDate.getTime())) {
const daysAgo = (Date.now() - lmDate.getTime()) / 86400000;
let dotColor = null;
if (daysAgo < 7) dotColor = '#00703c';
else if (daysAgo < 30) dotColor = '#f47738';
if (dotColor) {
const dot = document.createElementNS(NS, 'circle');
dot.setAttribute('cx', pos.x + nodeW - 6);
dot.setAttribute('cy', pos.y + 6);
dot.setAttribute('r', '3');
dot.setAttribute('fill', dotColor);
g.appendChild(dot);
}
}
}
// Event listeners
g.addEventListener('mouseenter', () => {
const nodeId = g.dataset.id;
const connNodes = new Set([nodeId]);
svg.querySelectorAll('.app-depmap-edge').forEach(p => {
if (p.dataset.from === nodeId || p.dataset.to === nodeId) {
p.classList.add('highlighted');
connNodes.add(p.dataset.from);
connNodes.add(p.dataset.to);
} else {
p.classList.add('dimmed');
}
});
svg.querySelectorAll('.app-depmap-node').forEach(n => {
if (!connNodes.has(n.dataset.id)) n.classList.add('dimmed');
});
const nd = nodes[nodeId];
if (nd) {
const ttTitle = document.createElement('strong');
ttTitle.textContent = nd.title || nodeId;
const ttMeta = document.createElement('span');
ttMeta.textContent = nd.type + ' \u00b7 ' + nd.project + ' \u00b7 ' + (nd.status || 'No status') + ' \u2014 Severity: ' + (nd.severity || 'LOW');
tooltip.textContent = '';
tooltip.appendChild(ttTitle);
tooltip.appendChild(ttMeta);
const ndh = nd.health || {};
if (ndh.stale || ndh.draft || ndh.orphan) {
const ttHealth = document.createElement('span');
ttHealth.style.cssText = 'display:block;margin-top:4px;font-weight:600;color:#d4351c';
const flags = [];
if (ndh.stale) flags.push('Stale (' + ndh.ageDays + 'd)');
if (ndh.draft) flags.push('Draft');
if (ndh.orphan) flags.push('Orphan');
ttHealth.textContent = '\u26a0 ' + flags.join(' \u00b7 ');
tooltip.appendChild(ttHealth);
}
tooltip.style.display = 'block';
const r = g.getBoundingClientRect();
tooltip.style.left = (r.right + 8) + 'px';
tooltip.style.top = r.top + 'px';
}
});
g.addEventListener('mouseleave', () => {
svg.querySelectorAll('.app-depmap-edge').forEach(p => {
p.classList.remove('highlighted', 'dimmed');
});
svg.querySelectorAll('.app-depmap-node').forEach(n => {
n.classList.remove('dimmed');
});
tooltip.style.display = 'none';
});
g.addEventListener('click', () => {
const docPath = g.dataset.path;
if (docPath) {
tooltip.style.display = 'none';
window.location.hash = docPath;
}
});
svg.appendChild(g);
}
}
function renderGraph(filterProject) {
const statsEl = document.getElementById('depmap-stats');
// Filter nodes
const filteredIds = filterProject
? nodeIds.filter(id => nodes[id].project === filterProject || nodes[id].project.startsWith('000-'))
: [...nodeIds];
if (filteredIds.length === 0) {
svgWrap.textContent = '';
const emptyMsg = document.createElement('div');
emptyMsg.className = 'app-depmap-empty';
emptyMsg.textContent = 'No documents in this project.';
svgWrap.appendChild(emptyMsg);
statsEl.textContent = '';
return;
}
// Group by category
const catGroups = {};
for (const id of filteredIds) {
const node = nodes[id];
if (!node) continue;
const cat = (node.type && TYPE_CATEGORIES[node.type]) || 'Other';
if (!catGroups[cat]) catGroups[cat] = [];
catGroups[cat].push(id);
}
// Sort within each category by project then type
for (const cat of Object.keys(catGroups)) {
catGroups[cat].sort((a, b) => {
const na = nodes[a], nb = nodes[b];
return (na.project || '').localeCompare(nb.project || '') || (na.type || '').localeCompare(nb.type || '');
});
}
// Filter edges to visible nodes
const filteredSet = new Set(filteredIds);
const visibleEdges = edges.filter(e => {
if (!filteredSet.has(e.from)) return false;
return filteredIds.some(id => id.startsWith(e.to));
});
if (currentLayout === 'timeline') {
renderTimelineLayout(filteredIds, catGroups, connectedIds, visibleEdges, filteredSet);
} else {
renderCategoryLayout(filteredIds, catGroups, connectedIds, visibleEdges, filteredSet);
}
}
function renderCategoryLayout(filteredIds, catGroups, connectedIds, visibleEdges, filteredSet) {
const statsEl = document.getElementById('depmap-stats');
// Layout constants
const nodeW = 110, nodeH = 44;
const gapX = 16, gapY = 24;
const rowPadTop = 30, rowPadBot = 14, rowGapY = 10;
const labelW = 110, padL = 16, padR = 16, padT = 16;
const maxCols = 8;
// Compute positions with wrapping
const positions = {};
let y = padT;
const rowBands = [];
for (const cat of CATEGORY_ORDER) {
const ids = catGroups[cat];
if (!ids || ids.length === 0) continue;
const numRows = Math.ceil(ids.length / maxCols);
const rowH = rowPadTop + numRows * nodeH + (numRows - 1) * rowGapY + rowPadBot;
rowBands.push({ cat, y, h: rowH });
for (let i = 0; i < ids.length; i++) {
const col = i % maxCols;
const row = Math.floor(i / maxCols);
positions[ids[i]] = {
x: padL + labelW + col * (nodeW + gapX),
y: y + rowPadTop + row * (nodeH + rowGapY),
};
}
y += rowH + gapY;
}
const usedCols = Math.min(maxCols, Math.max(...Object.keys(catGroups).map(c => catGroups[c] ? catGroups[c].length : 0), 1));
const svgW = padL + labelW + usedCols * nodeW + (usedCols - 1) * gapX + padR;
const svgH = y - gapY + padT;
statsEl.textContent = filteredIds.length + ' nodes, ' + visibleEdges.length + ' edges';
// Build SVG
const NS = 'http://www.w3.org/2000/svg';
const svg = document.createElementNS(NS, 'svg');
svg.setAttribute('class', 'app-depmap-svg');
svg.setAttribute('viewBox', '0 0 ' + svgW + ' ' + svgH);
svg.setAttribute('width', svgW);
svg.setAttribute('height', svgH);
// Row backgrounds
for (const band of rowBands) {
const color = CATEGORY_COLORS[band.cat] || CATEGORY_COLORS.Other;
const bg = document.createElementNS(NS, 'rect');
bg.setAttribute('x', 0);
bg.setAttribute('y', band.y);
bg.setAttribute('width', svgW);
bg.setAttribute('height', band.h);
bg.setAttribute('fill', color);
bg.setAttribute('opacity', '0.06');
bg.setAttribute('rx', '4');
svg.appendChild(bg);
const lbl = document.createElementNS(NS, 'text');
lbl.setAttribute('x', padL);
lbl.setAttribute('y', band.y + 18);
lbl.setAttribute('font-size', '11');
lbl.setAttribute('font-weight', '700');
lbl.setAttribute('fill', color);
lbl.textContent = band.cat;
svg.appendChild(lbl);
}
// Edges
for (const e of visibleEdges) {
const fromPos = positions[e.from];
if (!fromPos) continue;
const toId = filteredIds.find(id => id.startsWith(e.to));
if (!toId) continue;
const toPos = positions[toId];
if (!toPos) continue;
const x1 = fromPos.x + nodeW / 2;
const y1 = fromPos.y + nodeH;
const x2 = toPos.x + nodeW / 2;
const y2 = toPos.y;
const dy = Math.abs(y2 - y1) * 0.4;
const color = CATEGORY_COLORS[TYPE_CATEGORIES[nodes[e.from].type] || 'Other'] || CATEGORY_COLORS.Other;
const path = document.createElementNS(NS, 'path');
path.setAttribute('class', 'app-depmap-edge');
path.dataset.from = e.from;
path.dataset.to = toId;
path.setAttribute('d', 'M' + x1 + ',' + y1 + ' C' + x1 + ',' + (y1 + dy) + ' ' + x2 + ',' + (y2 - dy) + ' ' + x2 + ',' + y2);
path.setAttribute('stroke', color);
svg.appendChild(path);
}
// Nodes
renderNodes(svg, filteredIds, positions, connectedIds, NS);
svgWrap.textContent = '';
svgWrap.appendChild(svg);
}
function renderTimelineLayout(filteredIds, catGroups, connectedIds, visibleEdges, filteredSet) {
const statsEl = document.getElementById('depmap-stats');
const bucketResult = computeDateBuckets(filteredIds);
// Fallback to category if no valid dates
if (!bucketResult) {
renderCategoryLayout(filteredIds, catGroups, connectedIds, visibleEdges, filteredSet);
return;
}
const { buckets } = bucketResult;
// Layout constants
const nodeW = 110, nodeH = 44;
const gapX = 16, gapY = 24;
const rowPadTop = 30, rowPadBot = 14, rowGapY = 10;
const labelW = 110, padL = 16, padR = 16, padT = 16;
const colW = nodeW + gapX;
const headerH = 24;
// Total columns = date buckets + optional "No Date"
const hasUndated = filteredIds.some(id => assignNodeToBucket(nodes[id], buckets) === -1);
const totalCols = buckets.length + (hasUndated ? 1 : 0);
// Assign nodes to cells: { catKey: { colIdx: [nodeIds] } }
const cells = {};
for (const id of filteredIds) {
const node = nodes[id];
const cat = (node.type && TYPE_CATEGORIES[node.type]) || 'Other';
const colIdx = assignNodeToBucket(node, buckets);
const actualCol = colIdx === -1 ? buckets.length : colIdx;
if (!cells[cat]) cells[cat] = {};
if (!cells[cat][actualCol]) cells[cat][actualCol] = [];
cells[cat][actualCol].push(id);
}
// Sort within each cell by project then type
for (const cat of Object.keys(cells)) {
for (const col of Object.keys(cells[cat])) {
cells[cat][col].sort((a, b) => {
const na = nodes[a], nb = nodes[b];
return (na.project || '').localeCompare(nb.project || '') || (na.type || '').localeCompare(nb.type || '');
});
}
}
// Compute row heights (expand for stacked nodes)
const positions = {};
let y = padT + headerH;
const rowBands = [];
for (const cat of CATEGORY_ORDER) {
if (!catGroups[cat] || catGroups[cat].length === 0) continue;
// Find max stack in any column for this category
let maxStack = 1;
if (cells[cat]) {
for (const col of Object.keys(cells[cat])) {
maxStack = Math.max(maxStack, cells[cat][col].length);
}
}
const numRows = maxStack;
const rowH = rowPadTop + numRows * nodeH + (numRows - 1) * rowGapY + rowPadBot;
rowBands.push({ cat, y, h: rowH });
// Position nodes
if (cells[cat]) {
for (const [colStr, ids] of Object.entries(cells[cat])) {
const col = parseInt(colStr, 10);
for (let i = 0; i < ids.length; i++) {
positions[ids[i]] = {
x: padL + labelW + col * colW,
y: y + rowPadTop + i * (nodeH + rowGapY),
};
}
}
}
y += rowH + gapY;
}
const svgW = padL + labelW + totalCols * colW + padR;
const svgH = y - gapY + padT;
statsEl.textContent = filteredIds.length + ' nodes, ' + visibleEdges.length + ' edges';
// Build SVG
const NS = 'http://www.w3.org/2000/svg';
const svg = document.createElementNS(NS, 'svg');
svg.setAttribute('class', 'app-depmap-svg');
svg.setAttribute('viewBox', '0 0 ' + svgW + ' ' + svgH);
svg.setAttribute('width', svgW);
svg.setAttribute('height', svgH);
// Column headers
for (let i = 0; i < buckets.length; i++) {
const cx = padL + labelW + i * colW + colW / 2;
const hdr = document.createElementNS(NS, 'text');
hdr.setAttribute('x', cx);
hdr.setAttribute('y', padT + headerH - 6);
hdr.setAttribute('text-anchor', 'middle');
hdr.setAttribute('font-size', '10');
hdr.setAttribute('font-weight', '600');
hdr.setAttribute('fill', 'var(--text-secondary, #666)');
hdr.textContent = buckets[i].label;
svg.appendChild(hdr);
}
// "No Date" header
if (hasUndated) {
const cx = padL + labelW + buckets.length * colW + colW / 2;
const hdr = document.createElementNS(NS, 'text');
hdr.setAttribute('x', cx);
hdr.setAttribute('y', padT + headerH - 6);
hdr.setAttribute('text-anchor', 'middle');
hdr.setAttribute('font-size', '10');
hdr.setAttribute('font-weight', '600');
hdr.setAttribute('fill', '#b1b4b6');
hdr.textContent = 'No Date';
svg.appendChild(hdr);
}
// Column separator lines
for (let i = 1; i < totalCols; i++) {
const lx = padL + labelW + i * colW - gapX / 2;
const line = document.createElementNS(NS, 'line');
line.setAttribute('x1', lx);
line.setAttribute('y1', padT + headerH);
line.setAttribute('x2', lx);
line.setAttribute('y2', svgH - padT);
line.setAttribute('stroke', 'var(--border-main, #e8e8e8)');
line.setAttribute('stroke-width', '1');
line.setAttribute('opacity', '0.3');
svg.appendChild(line);
}
// Row backgrounds
for (const band of rowBands) {
const color = CATEGORY_COLORS[band.cat] || CATEGORY_COLORS.Other;
const bg = document.createElementNS(NS, 'rect');
bg.setAttribute('x', 0);
bg.setAttribute('y', band.y);
bg.setAttribute('width', svgW);
bg.setAttribute('height', band.h);
bg.setAttribute('fill', color);
bg.setAttribute('opacity', '0.06');
bg.setAttribute('rx', '4');
svg.appendChild(bg);
const lbl = document.createElementNS(NS, 'text');
lbl.setAttribute('x', padL);
lbl.setAttribute('y', band.y + 18);
lbl.setAttribute('font-size', '11');
lbl.setAttribute('font-weight', '700');
lbl.setAttribute('fill', color);
lbl.textContent = band.cat;
svg.appendChild(lbl);
}
// Edges
for (const e of visibleEdges) {
const fromPos = positions[e.from];
if (!fromPos) continue;
const toId = filteredIds.find(id => id.startsWith(e.to));
if (!toId) continue;
const toPos = positions[toId];
if (!toPos) continue;
const x1 = fromPos.x + nodeW / 2;
const y1 = fromPos.y + nodeH;
const x2 = toPos.x + nodeW / 2;
const y2 = toPos.y;
const dy = Math.abs(y2 - y1) * 0.4;
const color = CATEGORY_COLORS[TYPE_CATEGORIES[nodes[e.from].type] || 'Other'] || CATEGORY_COLORS.Other;
const path = document.createElementNS(NS, 'path');
path.setAttribute('class', 'app-depmap-edge');
path.dataset.from = e.from;
path.dataset.to = toId;
path.setAttribute('d', 'M' + x1 + ',' + y1 + ' C' + x1 + ',' + (y1 + dy) + ' ' + x2 + ',' + (y2 - dy) + ' ' + x2 + ',' + y2);
path.setAttribute('stroke', color);
svg.appendChild(path);
}
// Nodes
renderNodes(svg, filteredIds, positions, connectedIds, NS);
svgWrap.textContent = '';
svgWrap.appendChild(svg);
}
// Initial render
renderGraph('');
// Filter handler
select.addEventListener('change', () => {
renderGraph(select.value);
});
// Clear active states in sidebar
document.querySelectorAll('#nav-container a').forEach(a => a.classList.remove('active'));
// Scroll to top
document.getElementById('main-content').scrollTo(0, 0);
}
// ============================================
// Table of Contents
// ============================================
function buildTableOfContents(container) {
const toc = document.getElementById('toc');
const tocList = document.getElementById('toc-list');
const headings = container.querySelectorAll('h2, h3');
if (headings.length < 2) {
toc.style.display = 'none';
return;
}
toc.style.display = '';
tocList.innerHTML = '';
headings.forEach(heading => {
const li = document.createElement('li');
const a = document.createElement('a');
a.href = `#${heading.id}`;
a.textContent = heading.textContent;
if (heading.tagName === 'H3') {
a.classList.add('toc-h3');
}
a.addEventListener('click', (e) => {
e.preventDefault();
heading.scrollIntoView({ behavior: 'smooth' });
});
li.appendChild(a);
tocList.appendChild(li);
});
// Setup intersection observer for active state
if (currentTocObserver) {
currentTocObserver.disconnect();
}
currentTocObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const id = entry.target.id;
tocList.querySelectorAll('a').forEach(a => {
a.classList.toggle('active', a.getAttribute('href') === `#${id}`);
});
}
});
}, { rootMargin: '-20% 0px -70% 0px' });
headings.forEach(heading => currentTocObserver.observe(heading));
}
// ============================================
// Navigation Building
// ============================================
function buildNavigation(manifest) {
const container = document.getElementById('nav-container');
let html = '';
// Global documents
if (manifest.global && manifest.global.length > 0) {
html += buildNavSection('Global', manifest.global, 'global');
}
// Global external documents (mention only - not clickable)
if (manifest.globalExternal && manifest.globalExternal.length > 0) {
html += buildExternalNavSection('Global External Documents', manifest.globalExternal);
}
// Global policies (mention only - not clickable)
if (manifest.globalPolicies && manifest.globalPolicies.length > 0) {
html += buildExternalNavSection('Global Policies', manifest.globalPolicies);
}
// Projects
if (manifest.projects) {
manifest.projects.forEach(project => {
html += buildProjectNav(project);
});
}
container.innerHTML = html;
// Add click handlers for section headers
container.querySelectorAll('.app-nav-section-header').forEach(header => {
header.addEventListener('click', () => {
header.classList.toggle('collapsed');
const list = header.nextElementSibling;
list.style.display = header.classList.contains('collapsed') ? 'none' : 'block';
});
});
// Add click handlers for document links
container.querySelectorAll('a[data-path]').forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
const path = link.dataset.path;
window.location.hash = path;
loadDocument(path);
// Update active state
container.querySelectorAll('a').forEach(a => a.classList.remove('active'));
link.classList.add('active');
// Close mobile sidebar
closeMobileSidebar();
});
});
// Add change handlers for version selects
container.querySelectorAll('.app-nav-version-select').forEach(select => {
// Prevent select clicks from triggering the parent link
select.addEventListener('click', (e) => {
e.stopPropagation();
});
select.addEventListener('change', (e) => {
e.stopPropagation();
const newPath = select.value;
// Select is a sibling of the <a>, not nested inside it
const link = select.parentElement.querySelector('a[data-path]');
if (link) {
link.href = '#' + newPath;
link.dataset.path = newPath;
}
window.location.hash = newPath;
loadDocument(newPath);
// Update active state
container.querySelectorAll('a').forEach(a => a.classList.remove('active'));
if (link) link.classList.add('active');
closeMobileSidebar();
});
});
}
// ── Version display helpers ──
function extractVersion(documentId) {
if (!documentId) return null;
const m = documentId.match(/-v(\d+\.\d+)$/);
return m ? m[1] : null;
}
function baseDocId(documentId) {
return documentId ? documentId.replace(/-v\d+(\.\d+)?$/, '') : null;
}
function renderVersionedDocList(docs) {
// Group by base document ID
const groups = {};
const order = [];
docs.forEach(doc => {
const key = baseDocId(doc.documentId) || doc.path;
if (!groups[key]) {
groups[key] = [];
order.push(key);
}
groups[key].push(doc);
});
// Sort each group by version ascending (highest last = default)
Object.values(groups).forEach(group => {
group.sort((a, b) => {
const va = extractVersion(a.documentId) || '0';
const vb = extractVersion(b.documentId) || '0';
return va.localeCompare(vb, undefined, { numeric: true });
});
});
let html = '';
order.forEach(key => {
const group = groups[key];
const latest = group[group.length - 1]; // highest version
const title = latest.title || latest.documentId || 'Untitled';
if (group.length === 1) {
// Single version: static badge
const ver = extractVersion(latest.documentId);
const badge = ver ? ` <span class="app-nav-version">v${ver}</span>` : '';
html += `<li><a href="#${latest.path}" data-path="${latest.path}">${title}${badge}</a></li>`;
} else {
// Multiple versions: link + sibling select (select outside <a> for valid HTML)
let options = '';
group.forEach(doc => {
const ver = extractVersion(doc.documentId) || '?';
const sel = doc === latest ? ' selected' : '';
options += `<option value="${doc.path}"${sel}>v${ver}</option>`;
});
html += `<li class="app-nav-versioned"><a href="#${latest.path}" data-path="${latest.path}">${title}</a><select class="app-nav-version-select">${options}</select></li>`;
}
});
return html;
}
function buildNavSection(title, documents, sectionId) {
if (!documents || documents.length === 0) return '';
// Group by category
const byCategory = {};
documents.forEach(doc => {
const cat = doc.category || 'Other';
if (!byCategory[cat]) byCategory[cat] = [];
byCategory[cat].push(doc);
});
let listHtml = '';
Object.keys(byCategory).forEach(category => {
listHtml += `<li class="app-nav-category">${category}</li>`;
listHtml += renderVersionedDocList(byCategory[category]);
});
return `
<div class="app-nav-section">
<button class="app-nav-section-header">
${title}
<span class="chevron">&#9660;</span>
</button>
<ul class="app-nav-list">${listHtml}</ul>
</div>
`;
}
function buildExternalNavSection(title, files) {
if (!files || files.length === 0) return '';
let listHtml = '';
files.forEach(doc => {
listHtml += `<li class="app-nav-external">${doc.title} <span class="app-nav-file-type">${doc.type || ''}</span></li>`;
});
return `
<div class="app-nav-section">
<button class="app-nav-section-header">
${title}
<span class="chevron">&#9660;</span>
</button>
<ul class="app-nav-list">${listHtml}</ul>
</div>
`;
}
function buildProjectNav(project) {
let listHtml = '';
// Main documents by category
if (project.documents) {
const byCategory = {};
project.documents.forEach(doc => {
const cat = doc.category || 'Other';
if (!byCategory[cat]) byCategory[cat] = [];
byCategory[cat].push(doc);
});
Object.keys(byCategory).forEach(category => {
listHtml += `<li class="app-nav-category">${category}</li>`;
listHtml += renderVersionedDocList(byCategory[category]);
});
}
// Diagrams
if (project.diagrams && project.diagrams.length > 0) {
listHtml += `<li class="app-nav-category">Diagrams</li>`;
listHtml += renderVersionedDocList(project.diagrams);
}
// Decisions (ADRs)
if (project.decisions && project.decisions.length > 0) {
listHtml += `<li class="app-nav-category">Decisions</li>`;
listHtml += renderVersionedDocList(project.decisions);
}
// Wardley Maps
if (project.wardleyMaps && project.wardleyMaps.length > 0) {
listHtml += `<li class="app-nav-category">Wardley Maps</li>`;
listHtml += renderVersionedDocList(project.wardleyMaps);
}
// Data Contracts
if (project.dataContracts && project.dataContracts.length > 0) {
listHtml += `<li class="app-nav-category">Data Contracts</li>`;
listHtml += renderVersionedDocList(project.dataContracts);
}
// Research
if (project.research && project.research.length > 0) {
listHtml += `<li class="app-nav-category">Research</li>`;
listHtml += renderVersionedDocList(project.research);
}
// Reviews
if (project.reviews && project.reviews.length > 0) {
listHtml += `<li class="app-nav-category">Reviews</li>`;
listHtml += renderVersionedDocList(project.reviews);
}
// Vendors
if (project.vendors && project.vendors.length > 0) {
listHtml += `<li class="app-nav-category">Vendors</li>`;
project.vendors.forEach(vendor => {
if (vendor.documents) {
vendor.documents.forEach(doc => {
const ver = extractVersion(doc.documentId);
const badge = ver ? ` <span class="app-nav-version">v${ver}</span>` : '';
listHtml += `<li><a href="#${doc.path}" data-path="${doc.path}">${vendor.name}: ${doc.title}${badge}</a></li>`;
});
}
});
}
// Vendor Profiles
if (project.vendorProfiles && project.vendorProfiles.length > 0) {
listHtml += `<li class="app-nav-category">Vendor Profiles</li>`;
listHtml += renderVersionedDocList(project.vendorProfiles);
}
// Tech Notes
if (project.techNotes && project.techNotes.length > 0) {
listHtml += `<li class="app-nav-category">Tech Notes</li>`;
listHtml += renderVersionedDocList(project.techNotes);
}
// Data Source Profiles
if (project.dataSourceProfiles && project.dataSourceProfiles.length > 0) {
listHtml += `<li class="app-nav-category">Data Source Profiles</li>`;
listHtml += renderVersionedDocList(project.dataSourceProfiles);
}
// External Documents (mention only - not clickable)
if (project.external && project.external.length > 0) {
listHtml += `<li class="app-nav-category">External Documents</li>`;
project.external.forEach(doc => {
listHtml += `<li class="app-nav-external">${doc.title} <span class="app-nav-file-type">${doc.type || ''}</span></li>`;
});
}
const displayName = project.name || project.id;
return `
<div class="app-nav-section">
<button class="app-nav-section-header">
${displayName}
<span class="chevron">&#9660;</span>
</button>
<ul class="app-nav-list">${listHtml}</ul>
</div>
`;
}
// ============================================
// Document Loading
// ============================================
async function loadDocument(path) {
const content = document.getElementById('content');
const toc = document.getElementById('toc');
// Clean up interactive diagram instances before replacing content
destroyPanZoomInstances();
// Hide TOC while loading
toc.style.display = 'none';
// HTML artifacts (e.g. ARC-001-DECK-v1.0.html, AntV Infographic output) —
// mount in an iframe rather than parsing as markdown. Skips TOC, mermaid,
// PlantUML, and link-rewriting which only apply to markdown.
if (path.endsWith('.html')) {
loadHtmlArtifact(path, content);
return;
}
// Show loading
content.innerHTML = `
<div class="app-loading">
<div class="app-spinner"></div>
Loading document...
</div>
`;
try {
let markdown;
// Check cache
if (documentCache.has(path)) {
markdown = documentCache.get(path);
} else {
let response = await fetch(getRawUrl(path));
if (!response.ok) {
const fallback = getFallbackUrl(path);
if (fallback) {
response = await fetch(fallback);
}
}
if (!response.ok) {
throw new Error(response.status === 404 ? 'not_found' : `fetch_error_${response.status}`);
}
markdown = await response.text();
documentCache.set(path, markdown);
}
// Extract title from first H1 or filename
let title = extractTitle(path);
const h1Match = markdown.match(/^#\s+(.+)$/m);
if (h1Match) {
title = h1Match[1];
// Remove the first H1 from markdown to avoid duplicate title
markdown = markdown.replace(/^#\s+.+$/m, '').trimStart();
}
// Build breadcrumb
const parts = path.split('/');
const breadcrumb = parts.slice(0, -1).join(' / ');
// Render markdown
const html = marked.parse(markdown);
content.innerHTML = `
<div class="app-doc-header">
<div class="app-doc-breadcrumb">${breadcrumb}</div>
<h1 class="app-doc-title">${title}</h1>
</div>
<div class="app-markdown">${html}</div>
`;
// Rewrite relative .md links to SPA hash routes
const docDir = path.substring(0, path.lastIndexOf('/') + 1);
content.querySelectorAll('.app-markdown a[href]').forEach(a => {
const href = a.getAttribute('href');
if (!href || href.startsWith('http') || href.startsWith('#') || href.startsWith('mailto:')) return;
if (!href.endsWith('.md') && !href.includes('.md#')) return;
// Resolve relative path against current document directory
const parts = (docDir + href.split('#')[0]).split('/');
const resolved = [];
parts.forEach(p => {
if (p === '..') resolved.pop();
else if (p && p !== '.') resolved.push(p);
});
const fragment = href.includes('#') ? '#' + href.split('#').slice(1).join('#') : '';
const resolvedPath = resolved.join('/') + fragment;
a.href = '#' + resolvedPath;
a.addEventListener('click', function(e) {
e.preventDefault();
window.location.hash = resolvedPath;
loadDocument(resolved.join('/'));
});
});
// Build table of contents
buildTableOfContents(content.querySelector('.app-markdown'));
// Render mermaid diagrams
try { await mermaid.run({ querySelector: '.mermaid' }); } catch (e) { console.warn('Mermaid render error:', e); }
// Render PlantUML diagrams (via PlantUML server)
renderPlantUMLDiagrams();
// Initialize interactive pan/zoom on rendered diagrams
initDiagramPanZoom();
// PlantUML images load async — init pan/zoom once they're ready
const plantumlImages = document.querySelectorAll('.plantuml-diagram img');
if (plantumlImages.length) {
Promise.all(Array.from(plantumlImages).map(img =>
img.complete ? Promise.resolve() : new Promise(resolve => {
img.addEventListener('load', resolve, { once: true });
img.addEventListener('error', resolve, { once: true });
})
)).then(() => initPlantUMLPanZoom());
}
// Scroll to top
document.getElementById('main-content').scrollTo(0, 0);
} catch (error) {
// Build error UI using safe DOM methods
const header = document.createElement('div');
header.className = 'app-doc-header';
const breadcrumb = document.createElement('div');
breadcrumb.className = 'app-doc-breadcrumb';
breadcrumb.textContent = path.split('/').slice(0, -1).join(' / ');
const title = document.createElement('h1');
title.className = 'app-doc-title';
title.textContent = extractTitle(path);
header.appendChild(breadcrumb);
header.appendChild(title);
const notice = document.createElement('div');
notice.style.cssText = 'background:var(--background-light);border-left:4px solid var(--primary-color);padding:1.25rem;margin:1.5rem 0;';
const heading = document.createElement('p');
heading.style.cssText = 'margin:0 0 0.75rem 0;font-weight:700;';
heading.textContent = 'Document not found';
const detail = document.createElement('p');
detail.style.cssText = 'margin:0;color:var(--text-muted);';
const detailText = document.createTextNode('The file ');
const code = document.createElement('code');
code.textContent = path;
const detailText2 = document.createTextNode(' could not be loaded. Check that the file exists and the site has been deployed correctly.');
detail.appendChild(detailText);
detail.appendChild(code);
detail.appendChild(detailText2);
notice.appendChild(heading);
notice.appendChild(detail);
content.replaceChildren(header, notice);
}
}
// Render an HTML artifact (e.g. DECK files) inside an iframe.
// Built with safe DOM methods — no innerHTML, no markdown parsing.
function loadHtmlArtifact(path, content) {
const title = extractTitle(path);
const breadcrumbText = path.split('/').slice(0, -1).join(' / ');
const src = getRawUrl(path);
const header = document.createElement('div');
header.className = 'app-doc-header';
const breadcrumb = document.createElement('div');
breadcrumb.className = 'app-doc-breadcrumb';
breadcrumb.textContent = breadcrumbText;
const titleEl = document.createElement('h1');
titleEl.className = 'app-doc-title';
titleEl.textContent = title;
const newTab = document.createElement('a');
newTab.className = 'app-deck-newtab';
newTab.href = src;
newTab.target = '_blank';
newTab.rel = 'noopener noreferrer';
newTab.textContent = 'Open in new tab ↗';
header.appendChild(breadcrumb);
header.appendChild(titleEl);
header.appendChild(newTab);
const frame = document.createElement('iframe');
frame.className = 'app-deck-frame';
frame.src = src;
frame.title = title;
frame.setAttribute('allowfullscreen', '');
frame.setAttribute('loading', 'lazy');
content.replaceChildren(header, frame);
document.getElementById('main-content').scrollTo(0, 0);
}
// ============================================
// Sidebar Toggle
// ============================================
function setupSidebarToggle() {
const sidebar = document.getElementById('sidebar');
const toggleBtn = document.getElementById('sidebar-toggle');
const expandBtn = document.getElementById('sidebar-expand');
function toggleSidebar() {
sidebar.classList.toggle('collapsed');
const isCollapsed = sidebar.classList.contains('collapsed');
toggleBtn.innerHTML = isCollapsed ? '&#9654;' : '&#9664;';
toggleBtn.setAttribute('aria-label', isCollapsed ? 'Expand sidebar' : 'Collapse sidebar');
}
toggleBtn.addEventListener('click', toggleSidebar);
expandBtn.addEventListener('click', toggleSidebar);
}
// ============================================
// Keyboard Shortcuts
// ============================================
function setupKeyboardShortcuts() {
document.addEventListener('keydown', (e) => {
// Ctrl+K - Focus search
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
e.preventDefault();
document.getElementById('search-input').focus();
}
// Ctrl+B - Toggle sidebar
if ((e.ctrlKey || e.metaKey) && e.key === 'b') {
e.preventDefault();
document.getElementById('sidebar-toggle').click();
}
// Escape - Close search
if (e.key === 'Escape') {
const searchInput = document.getElementById('search-input');
if (document.activeElement === searchInput) {
searchInput.blur();
document.getElementById('search-results').classList.remove('visible');
}
}
});
}
// ============================================
// Interactive Diagram Pan/Zoom
// ============================================
let activePanZoomInstances = [];
function destroyPanZoomInstances() {
activePanZoomInstances.forEach(instance => {
try { instance.destroy(); } catch (e) { /* already destroyed */ }
});
activePanZoomInstances = [];
}
// ============================================
// PlantUML Diagram Rendering
// ============================================
function renderPlantUMLDiagrams() {
const diagrams = document.querySelectorAll('.app-markdown .plantuml-diagram');
if (!diagrams.length) return;
diagrams.forEach(diagram => {
// Skip if already rendered
if (diagram.querySelector('img')) return;
// Get base64-encoded source from data attribute
const encoded = diagram.dataset.plantumlSource;
if (!encoded) return;
// Decode base64 back to raw text
const rawSource = decodeURIComponent(escape(atob(encoded)));
// Build PlantUML server URL
const url = plantumlGetUrl(rawSource, 'svg');
// Replace code block with rendered image
const img = document.createElement('img');
img.src = url;
img.alt = 'PlantUML Diagram';
img.loading = 'lazy';
// Show fallback on error
img.onerror = function() {
while (diagram.firstChild) diagram.removeChild(diagram.firstChild);
const errorP = document.createElement('p');
errorP.style.cssText = 'color:var(--color-error);padding:1rem;';
errorP.textContent = 'PlantUML rendering failed. Check diagram syntax or try ';
const link = document.createElement('a');
link.href = 'https://www.plantuml.com/plantuml/uml/';
link.target = '_blank';
link.rel = 'noopener';
link.textContent = 'plantuml.com';
errorP.appendChild(link);
errorP.appendChild(document.createTextNode('.'));
diagram.appendChild(errorP);
const pre = document.createElement('pre');
const code = document.createElement('code');
code.textContent = rawSource;
pre.appendChild(code);
diagram.appendChild(pre);
};
while (diagram.firstChild) diagram.removeChild(diagram.firstChild);
diagram.appendChild(img);
});
}
function initPlantUMLPanZoom() {
if (typeof svgPanZoom === 'undefined') return;
const diagrams = document.querySelectorAll('.app-markdown .plantuml-diagram');
if (!diagrams.length) return;
diagrams.forEach(diagram => {
const img = diagram.querySelector('img');
if (!img) return;
// Skip if already wrapped
if (diagram.parentElement && diagram.parentElement.classList.contains('plantuml-viewport')) return;
// Create wrapper structure
const container = document.createElement('div');
container.className = 'plantuml-container';
const viewport = document.createElement('div');
viewport.className = 'plantuml-viewport';
viewport.tabIndex = 0;
viewport.setAttribute('role', 'img');
viewport.setAttribute('aria-label', 'Interactive PlantUML diagram — use controls to zoom');
const controls = document.createElement('div');
controls.className = 'plantuml-controls';
// Zoom/fullscreen controls
const buttonDefs = [
{ action: 'zoom-in', title: 'Zoom in', icon: '+' },
{ action: 'zoom-out', title: 'Zoom out', icon: '\u2212' },
{ action: 'reset', title: 'Reset view', icon: '\u21ba' },
{ action: 'fullscreen', title: 'Fullscreen', icon: '\u26f6' }
];
let currentScale = 1;
buttonDefs.forEach(def => {
const button = document.createElement('button');
button.className = 'mermaid-ctrl-btn';
button.dataset.action = def.action;
button.title = def.title;
button.textContent = def.icon;
controls.appendChild(button);
});
// Reparent diagram into viewport
diagram.parentNode.insertBefore(container, diagram);
viewport.appendChild(diagram);
container.appendChild(viewport);
container.appendChild(controls);
// CSS-based zoom for raster/img (svg-pan-zoom needs inline SVG)
function applyScale(scale) {
currentScale = Math.max(0.25, Math.min(10, scale));
img.style.transform = 'scale(' + currentScale + ')';
img.style.transformOrigin = 'center center';
}
controls.addEventListener('click', (e) => {
const btn = e.target.closest('.mermaid-ctrl-btn');
if (!btn) return;
const action = btn.dataset.action;
if (action === 'zoom-in') applyScale(currentScale * 1.3);
else if (action === 'zoom-out') applyScale(currentScale / 1.3);
else if (action === 'reset') applyScale(1);
else if (action === 'fullscreen') {
container.classList.toggle('fullscreen');
setTimeout(() => applyScale(1), 50);
}
});
viewport.addEventListener('keydown', (e) => {
if (e.key === '+' || e.key === '=') { e.preventDefault(); applyScale(currentScale * 1.3); }
else if (e.key === '-') { e.preventDefault(); applyScale(currentScale / 1.3); }
else if (e.key === '0') { e.preventDefault(); applyScale(1); }
else if (e.key === 'f') { e.preventDefault(); container.classList.toggle('fullscreen'); setTimeout(() => applyScale(1), 50); }
else if (e.key === 'Escape' && container.classList.contains('fullscreen')) { e.preventDefault(); container.classList.remove('fullscreen'); setTimeout(() => applyScale(1), 50); }
});
// Scroll-to-zoom
viewport.addEventListener('wheel', (e) => {
e.preventDefault();
applyScale(currentScale * (e.deltaY < 0 ? 1.1 : 0.9));
}, { passive: false });
});
}
function initDiagramPanZoom() {
// Clean up previous instances
destroyPanZoomInstances();
// Check library loaded
if (typeof svgPanZoom === 'undefined') return;
const diagrams = document.querySelectorAll('.app-markdown .mermaid');
if (!diagrams.length) return;
diagrams.forEach(diagram => {
const svg = diagram.querySelector('svg');
if (!svg) return;
// Skip if already wrapped
if (diagram.parentElement && diagram.parentElement.classList.contains('mermaid-viewport')) return;
// Ensure SVG has explicit dimensions
if (!svg.getAttribute('width') || !svg.getAttribute('height')) {
const bbox = svg.getBBox();
if (bbox.width && bbox.height) {
svg.setAttribute('width', bbox.width);
svg.setAttribute('height', bbox.height);
}
}
// Create wrapper structure
const container = document.createElement('div');
container.className = 'mermaid-container';
const viewport = document.createElement('div');
viewport.className = 'mermaid-viewport';
viewport.tabIndex = 0;
viewport.setAttribute('role', 'img');
viewport.setAttribute('aria-label', 'Interactive diagram — scroll to zoom, drag to pan');
const controls = document.createElement('div');
controls.className = 'mermaid-controls';
// Button definitions: action, title, SVG icon path
const buttonDefs = [
{ action: 'zoom-in', title: 'Zoom in (+)', icon: 'line:12,5,12,19|line:5,12,19,12' },
{ action: 'zoom-out', title: 'Zoom out (-)', icon: 'line:5,12,19,12' },
{ action: 'reset', title: 'Reset view (0)', icon: 'path:M3 12a9 9 0 1 0 9-9 4.5 4.5 0 0 0-4.5 4.5|path:M3 3v4.5h4.5' },
{ action: 'fullscreen', title: 'Fullscreen (f)', icon: 'polyline:15 3 21 3 21 9|polyline:9 21 3 21 3 15|line:21,3,14,10|line:3,21,10,14' }
];
buttonDefs.forEach(def => {
const button = document.createElement('button');
button.className = 'mermaid-ctrl-btn';
button.dataset.action = def.action;
button.title = def.title;
const svgEl = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svgEl.setAttribute('viewBox', '0 0 24 24');
def.icon.split('|').forEach(part => {
const [type, data] = part.split(':');
if (type === 'line') {
const [x1, y1, x2, y2] = data.split(',');
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
line.setAttribute('x1', x1); line.setAttribute('y1', y1);
line.setAttribute('x2', x2); line.setAttribute('y2', y2);
svgEl.appendChild(line);
} else if (type === 'path') {
const p = document.createElementNS('http://www.w3.org/2000/svg', 'path');
p.setAttribute('d', data);
svgEl.appendChild(p);
} else if (type === 'polyline') {
const pl = document.createElementNS('http://www.w3.org/2000/svg', 'polyline');
pl.setAttribute('points', data);
svgEl.appendChild(pl);
}
});
button.appendChild(svgEl);
controls.appendChild(button);
});
// Reparent diagram into viewport
diagram.parentNode.insertBefore(container, diagram);
viewport.appendChild(diagram);
container.appendChild(viewport);
container.appendChild(controls);
// Initialize svg-pan-zoom
try {
const panZoomInstance = svgPanZoom(svg, {
fit: true,
center: true,
minZoom: 0.25,
maxZoom: 10,
zoomScaleSensitivity: 0.3,
dblClickZoomEnabled: true,
controlIconsEnabled: false,
mouseWheelZoomEnabled: true,
preventMouseEventsDefault: true
});
activePanZoomInstances.push(panZoomInstance);
// Wire up control buttons
controls.addEventListener('click', (e) => {
const btn = e.target.closest('.mermaid-ctrl-btn');
if (!btn) return;
const action = btn.dataset.action;
if (action === 'zoom-in') {
panZoomInstance.zoomIn();
} else if (action === 'zoom-out') {
panZoomInstance.zoomOut();
} else if (action === 'reset') {
panZoomInstance.resetZoom();
panZoomInstance.resetPan();
panZoomInstance.fit();
panZoomInstance.center();
} else if (action === 'fullscreen') {
container.classList.toggle('fullscreen');
// Resize after layout change
setTimeout(() => panZoomInstance.resize(), 50);
setTimeout(() => {
panZoomInstance.fit();
panZoomInstance.center();
}, 100);
}
});
// Keyboard handlers on viewport
viewport.addEventListener('keydown', (e) => {
if (e.key === '+' || e.key === '=') {
e.preventDefault();
panZoomInstance.zoomIn();
} else if (e.key === '-') {
e.preventDefault();
panZoomInstance.zoomOut();
} else if (e.key === '0') {
e.preventDefault();
panZoomInstance.resetZoom();
panZoomInstance.resetPan();
panZoomInstance.fit();
panZoomInstance.center();
} else if (e.key === 'f') {
e.preventDefault();
container.classList.toggle('fullscreen');
setTimeout(() => panZoomInstance.resize(), 50);
setTimeout(() => {
panZoomInstance.fit();
panZoomInstance.center();
}, 100);
} else if (e.key === 'Escape' && container.classList.contains('fullscreen')) {
e.preventDefault();
e.stopPropagation();
container.classList.remove('fullscreen');
setTimeout(() => panZoomInstance.resize(), 50);
setTimeout(() => {
panZoomInstance.fit();
panZoomInstance.center();
}, 100);
}
});
// Touch pinch-to-zoom
let lastTouchDistance = null;
viewport.addEventListener('touchstart', (e) => {
if (e.touches.length === 2) {
lastTouchDistance = Math.hypot(
e.touches[0].clientX - e.touches[1].clientX,
e.touches[0].clientY - e.touches[1].clientY
);
}
}, { passive: true });
viewport.addEventListener('touchmove', (e) => {
if (e.touches.length === 2 && lastTouchDistance !== null) {
const distance = Math.hypot(
e.touches[0].clientX - e.touches[1].clientX,
e.touches[0].clientY - e.touches[1].clientY
);
const scale = distance / lastTouchDistance;
if (scale > 1.02) {
panZoomInstance.zoomIn();
lastTouchDistance = distance;
} else if (scale < 0.98) {
panZoomInstance.zoomOut();
lastTouchDistance = distance;
}
}
}, { passive: true });
viewport.addEventListener('touchend', () => {
lastTouchDistance = null;
}, { passive: true });
} catch (err) {
// If svg-pan-zoom fails, leave diagram functional without interactivity
console.warn('svg-pan-zoom init failed for diagram:', err);
}
});
}
// Global Escape to exit fullscreen diagrams
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
const fs = document.querySelector('.mermaid-container.fullscreen');
if (fs) {
fs.classList.remove('fullscreen');
const containers = Array.from(document.querySelectorAll('.mermaid-container'));
const idx = containers.indexOf(fs);
if (idx >= 0 && activePanZoomInstances[idx]) {
setTimeout(() => activePanZoomInstances[idx].resize(), 50);
setTimeout(() => {
activePanZoomInstances[idx].fit();
activePanZoomInstances[idx].center();
}, 100);
}
}
const pfs = document.querySelector('.plantuml-container.fullscreen');
if (pfs) {
pfs.classList.remove('fullscreen');
}
}
});
// ============================================
// Initialization
// ============================================
async function init() {
// Update header
document.getElementById('header-title').textContent = `${CONFIG.repo} Documentation`;
// Setup theme toggle
document.getElementById('theme-toggle').addEventListener('click', toggleTheme);
// Listen for system preference changes (only apply if no stored preference)
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
if (!localStorage.getItem('arckit-theme')) {
document.documentElement.setAttribute('data-theme', e.matches ? 'dark' : 'light');
mermaid.initialize({
startOnLoad: false,
theme: e.matches ? 'dark' : 'default',
securityLevel: 'loose',
flowchart: { useMaxWidth: true },
sequence: { useMaxWidth: true }
});
}
});
try {
// Load manifest
const response = await fetch('manifest.json');
if (!response.ok) {
throw new Error('manifest.json not found');
}
manifest = await response.json();
// Populate TYPE_CATEGORIES from manifest (single source of truth: config/doc-types.mjs)
if (manifest.typeCategories) {
TYPE_CATEGORIES = manifest.typeCategories;
}
// Optionally load health data (graceful if missing)
try {
const healthResponse = await fetch('health.json');
if (healthResponse.ok) {
healthData = await healthResponse.json();
}
} catch (e) {
// health.json not available — dashboard renders without health panel
}
// Optionally load session telemetry (graceful if missing).
// Written by arckit-claude/hooks/session-learner.mjs at Stop /
// StopFailure when docs/ exists. See "Plugin Hooks" in CLAUDE.md.
try {
const telemetryResponse = await fetch('telemetry.json');
if (telemetryResponse.ok) {
telemetryData = await telemetryResponse.json();
}
} catch (e) {
// telemetry.json not available — dashboard renders without activity panel
}
// Update footer
if (manifest.generated) {
const date = new Date(manifest.generated);
document.getElementById('footer-generated').textContent =
`Generated: ${date.toLocaleDateString()}`;
}
// Build search index
buildSearchIndex(manifest);
// Build navigation
buildNavigation(manifest);
// Setup search
setupSearch();
// Setup sidebar toggle
setupSidebarToggle();
// Setup keyboard shortcuts
setupKeyboardShortcuts();
// Setup dashboard link
document.getElementById('dashboard-link').addEventListener('click', (e) => {
e.preventDefault();
window.location.hash = 'dashboard';
closeMobileSidebar();
});
// Setup header title link (back to dashboard)
document.getElementById('header-title').addEventListener('click', (e) => {
e.preventDefault();
window.location.hash = 'dashboard';
closeMobileSidebar();
});
// Setup guides link
document.getElementById('guides-link').addEventListener('click', (e) => {
e.preventDefault();
window.location.hash = 'guides';
closeMobileSidebar();
});
// Setup roles link
document.getElementById('roles-link').addEventListener('click', (e) => {
e.preventDefault();
window.location.hash = 'roles';
closeMobileSidebar();
});
// Setup dependency map link
document.getElementById('depmap-link').addEventListener('click', (e) => {
e.preventDefault();
window.location.hash = 'dependency-map';
closeMobileSidebar();
});
// Handle initial hash or load dashboard
if (window.location.hash) {
const path = window.location.hash.slice(1);
if (path === 'dashboard') {
showDashboard(healthData);
} else if (path === 'guides') {
showGuidesIndex();
} else if (path === 'roles') {
showRolesIndex();
} else if (path === 'dependency-map') {
showDependencyMap();
} else {
loadDocument(path);
// Mark as active — check both direct links and version selects
let link = document.querySelector(`a[data-path="${path}"]`);
if (!link) {
// Path might be a non-default version — find its select option
const option = document.querySelector(`.app-nav-version-select option[value="${path}"]`);
if (option) {
option.selected = true;
const select = option.closest('select');
const parentLink = select ? select.parentElement.querySelector('a[data-path]') : null;
if (parentLink) {
parentLink.href = '#' + path;
parentLink.dataset.path = path;
link = parentLink;
}
}
}
if (link) link.classList.add('active');
}
} else {
// Dashboard is the default landing page
window.location.hash = 'dashboard';
showDashboard(healthData);
}
} catch (error) {
document.getElementById('nav-container').innerHTML = `
<div class="app-error">
Failed to load manifest: ${error.message}
</div>
`;
}
}
// Mobile menu toggle (header hamburger + backdrop)
function openMobileSidebar() {
document.getElementById('sidebar').classList.add('open');
document.getElementById('sidebar-backdrop').classList.add('visible');
}
function closeMobileSidebar() {
document.getElementById('sidebar').classList.remove('open');
document.getElementById('sidebar-backdrop').classList.remove('visible');
}
document.getElementById('mobile-menu').addEventListener('click', () => {
const sidebar = document.getElementById('sidebar');
if (sidebar.classList.contains('open')) {
closeMobileSidebar();
} else {
openMobileSidebar();
}
});
document.getElementById('sidebar-backdrop').addEventListener('click', closeMobileSidebar);
// Hash change handler
window.addEventListener('hashchange', () => {
const path = window.location.hash.slice(1);
if (path === 'dashboard') {
showDashboard(healthData);
} else if (path === 'guides') {
showGuidesIndex();
} else if (path === 'roles') {
showRolesIndex();
} else if (path === 'dependency-map') {
showDependencyMap();
} else if (path) {
loadDocument(path);
// Update active state
document.querySelectorAll('#nav-container a').forEach(a => {
a.classList.toggle('active', a.dataset.path === path);
});
}
});
// Initialize on load
document.addEventListener('DOMContentLoaded', init);
</script>
</body>
</html>