5140 lines
204 KiB
HTML
5140 lines
204 KiB
HTML
<!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">☰</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
|
||
<kbd>Ctrl</kbd>+<kbd>B</kbd> Toggle sidebar
|
||
<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">✓ 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 1–4). 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 — ${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">✓</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">✓</span>'
|
||
: '<span class="app-dashboard-cross">✗</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;">✓ All clear — 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">✗</span> ${escapeHtml(label)}: ${count}</li>`;
|
||
}
|
||
});
|
||
if (Object.values(bt).every(v => !v)) {
|
||
healthRightHtml += '<li><span class="app-dashboard-check">✓</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">▼</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">▼</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">▼</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 ? '▶' : '◀';
|
||
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>
|