diff --git a/CLAUDE.md b/CLAUDE.md
index c003fd9..5c92953 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -74,7 +74,8 @@ Orchestrator: `scripts/build-vite.js`. Flags: `--local`, `--headless`.
- `src/utils/transform.js` — `transformSubAppHtml()`: URL rewriting, document splitting (head/body/title/body-class), headless transforms
- `src/layouts/Base.astro` — The one document shell: head (opening with the cascade layer order), ``, and a body that opens with the knowledge base CSS inlined (`?inline` import; carries the self-hosted Inter faces) followed by the shadow-DOM compat styles
- `src/utils/css-layers.js` — The cascade-layer contract: `LAYER_ORDER` and `layerSubAppCss()`, which wraps a sub-app stylesheet in the `kb-app` layer. Used by the layout, the build and `transform.js`
-- `src/components/Masthead.astro` — Persistent Knowledge base header + Library/current-app sub-nav (all pages, both modes)
+- `src/components/Masthead.astro` — Persistent Knowledge base header + the navigation (all pages, both modes), scoped to the app being viewed: Library, the current app, then that app's manifest `pages` — a page without a `section` as a link, a section as a dropdown of its pages (`NavPages.astro`). The catalog is the only way between apps, so the bar does not grow with the registry. From 1024px a bar that wraps; below it a compact bar (Library, current app, Menu — only for an app with `pages`) whose Menu button opens a popover sheet of the app's pages. Works without script; `src/scripts/masthead-nav.js` only adds Escape/outside-click/edge-flip to the bar's `` dropdowns — and, embedded, learns the real window from `event.view`, because reframed reports its iframe's document as the masthead's `ownerDocument` and root node. Its `
diff --git a/src/components/NavPages.astro b/src/components/NavPages.astro
new file mode 100644
index 0000000..96f39bc
--- /dev/null
+++ b/src/components/NavPages.astro
@@ -0,0 +1,28 @@
+---
+// src/components/NavPages.astro
+//
+// A list of manifest pages — the body of a section dropdown in the masthead
+// bar and of the same section in the compact menu. The page being viewed
+// stays a link, marked aria-current="page", so it is announced as current
+// without leaving a hole in the keyboard order.
+
+interface Props {
+ pages: { title: string; href: string }[];
+ currentPath: string;
+ label?: string;
+}
+
+const { pages, currentPath, label } = Astro.props;
+---
+
+
+ {pages.map((page) => (
+ -
+ {page.title}
+
+ ))}
+
diff --git a/src/pages/[...path].astro b/src/pages/[...path].astro
index 0541008..a2bd5d3 100644
--- a/src/pages/[...path].astro
+++ b/src/pages/[...path].astro
@@ -15,6 +15,7 @@ import { readFileSync } from 'node:fs';
import { getAppPages } from '../utils/apps.js';
import { BASE_PATH, PATH_PREFIX, isHeadlessBuild } from '../utils/config.js';
import { transformSubAppHtml } from '../utils/transform.js';
+import { routeHref } from '../utils/navigation.js';
import Base from '../layouts/Base.astro';
import Masthead from '../components/Masthead.astro';
@@ -34,8 +35,8 @@ const parts = props.iframe
const pageTitle = title ?? parts?.title ?? app?.name ?? 'Knowledge base';
-// The app's own crumb in the masthead is inert on the app index and a link deeper in.
-const atAppRoot = !props.fileRelDir;
+// The masthead marks the entry for the page being viewed as current.
+const currentPath = routeHref(BASE_PATH, slug, props.fileRelDir);
---
{parts && }
-
+
{props.iframe ? (
diff --git a/src/scripts/masthead-nav.js b/src/scripts/masthead-nav.js
new file mode 100644
index 0000000..09277c7
--- /dev/null
+++ b/src/scripts/masthead-nav.js
@@ -0,0 +1,95 @@
+// src/scripts/masthead-nav.js
+//
+// Progressive enhancement for the masthead bar's section dropdowns ():
+// Escape closes one and returns focus to its toggle, leaving it by keyboard or
+// clicking elsewhere closes it, and a panel that would run off the right edge
+// of the viewport opens right-aligned instead. Without this module the
+// dropdowns still open, close and navigate — the compact menu is a popover and
+// needs none of it.
+//
+// Listeners go on the masthead and on the document it is displayed in, never
+// on `document`: inside a web fragment this module runs in reframed's iframe
+// while the masthead lives in the host's shadow tree, so `document` is the
+// iframe's. The masthead is replaced on every ClientRouter swap, so it is
+// wired again on each astro:page-load.
+
+const OPEN = 'details.kb-nav-dropdown[open]';
+const wiredDocs = new WeakSet();
+/** The window the masthead is actually displayed in; see wireOutsideClicks. */
+let view = window;
+
+function close(details, refocus) {
+ details.open = false;
+ if (refocus) details.querySelector('summary')?.focus();
+}
+
+/** Right-aligns a panel that would otherwise overflow the viewport. */
+function place(details) {
+ const panel = details.querySelector('.kb-nav-panel');
+ if (!panel) return;
+ panel.classList.remove('kb-nav-panel--end');
+ if (!details.open) return;
+ // The reader's viewport, not reframed's hidden iframe (see wireOutsideClicks).
+ const width = view.document.documentElement.clientWidth;
+ if (panel.getBoundingClientRect().right > width) panel.classList.add('kb-nav-panel--end');
+}
+
+/**
+ * Outside clicks, including on an embedding host's own chrome. Inside a web
+ * fragment reframed reports its iframe's document as the masthead's
+ * ownerDocument and root node, so neither names the page the reader clicks
+ * on. The window a click on the masthead really fired in does: the listener
+ * goes on that window's document, and composedPath() sees through the
+ * fragment's open shadow roots. The document outlives the masthead, so it is
+ * wired once and looks the current masthead up on every click.
+ */
+function wireOutsideClicks(doc) {
+ if (!doc || wiredDocs.has(doc)) return;
+ wiredDocs.add(doc);
+ doc.addEventListener('click', (event) => {
+ const masthead = document.getElementById('kb-masthead');
+ if (!masthead) return;
+ const path = event.composedPath();
+ for (const details of masthead.querySelectorAll(OPEN)) {
+ if (!path.includes(details)) close(details, false);
+ }
+ });
+}
+
+function wire() {
+ const masthead = document.getElementById('kb-masthead');
+ if (!masthead || masthead.dataset.kbNavWired) return;
+ masthead.dataset.kbNavWired = 'true';
+
+ masthead.addEventListener('keydown', (event) => {
+ if (event.key !== 'Escape') return;
+ const details = event.target.closest?.(OPEN);
+ if (!details) return;
+ event.preventDefault();
+ close(details, true);
+ });
+
+ // Tabbing out only: a null relatedTarget is also what Safari reports when a
+ // click lands on a link it does not focus, and closing then would hide the
+ // link before the click reaches it. Clicks are handled below.
+ masthead.addEventListener('focusout', (event) => {
+ const details = event.target.closest?.(OPEN);
+ if (details && event.relatedTarget && !details.contains(event.relatedTarget)) close(details, false);
+ });
+
+ // `toggle` does not bubble; capture still reaches the masthead.
+ masthead.addEventListener('toggle', (event) => {
+ if (event.target.matches?.('details.kb-nav-dropdown')) place(event.target);
+ }, true);
+
+ // A dropdown only opens through a click on its summary — Enter and Space
+ // included — so that click is the first chance to learn the real window.
+ masthead.addEventListener('click', (event) => {
+ if (!event.view) return;
+ view = event.view;
+ wireOutsideClicks(view.document);
+ });
+}
+
+wire();
+document.addEventListener('astro:page-load', wire);
diff --git a/src/styles/knowledge-base.css b/src/styles/knowledge-base.css
index ed2cc9d..30bc0db 100644
--- a/src/styles/knowledge-base.css
+++ b/src/styles/knowledge-base.css
@@ -166,6 +166,13 @@ body, wf-body {
background: var(--bg-card);
border-bottom: 1px solid var(--border);
}
+/* An open dropdown must paint over the sub-app below. Only while one is open:
+ a permanent z-index would also lift the masthead over a sub-app's own
+ full-screen overlays (search, lightboxes). */
+.kb-masthead:has(.kb-nav-dropdown[open]) {
+ position: relative;
+ z-index: 1000;
+}
.kb-masthead-inner {
max-width: 72rem;
margin: 0 auto;
@@ -195,29 +202,32 @@ body, wf-body {
margin: 0;
}
-/* Brand wordmark — a flex item ahead of kb-masthead-inner. It used to be
- absolutely positioned "in the left gutter", but the inner column is centred
- at 72rem, so on anything narrower than ~1490px the gutter is smaller than
- the wordmark and it sat on top of the Library link. */
+/* Brand wordmark — out of flow, in the left gutter of the centred 72rem
+ column, so the links sit exactly where they do on the catalog (as a flex
+ item it took its width out of that column and moved every link). It shows
+ only from 90rem, where the gutter is wide enough for it: at ~88rem the
+ column's own padding starts to pass under the wordmark, and below that the
+ wordmark would sit on top of the Library link. */
.kb-masthead-brand {
display: none;
}
-.kb-masthead--compact .kb-masthead-brand {
- display: block;
- font-size: 0.9375rem;
- font-weight: 700;
- letter-spacing: -0.015em;
- color: var(--text-heading);
- white-space: nowrap;
- flex-shrink: 0;
- padding: 0 1rem 0 1.5rem;
-}
-@media (min-width: 768px) {
- .kb-masthead--compact .kb-masthead-brand { padding-left: 2.5rem; }
-}
-/* Below the large-display threshold, hide the brand */
-@media (max-width: 767px) {
- .kb-masthead--compact .kb-masthead-brand { display: none; }
+.kb-masthead--compact { position: relative; }
+@media (min-width: 90rem) {
+ .kb-masthead--compact .kb-masthead-brand {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: 0;
+ display: flex;
+ align-items: center;
+ padding-left: 2.5rem;
+ font-size: 0.9375rem;
+ font-weight: 700;
+ letter-spacing: -0.015em;
+ color: var(--text-heading);
+ white-space: nowrap;
+ pointer-events: none;
+ }
}
.kb-masthead-eyebrow {
@@ -262,14 +272,53 @@ body, wf-body {
color: var(--text-muted);
}
-/* Sub-navigation: Library + the app currently being viewed */
+/* ── Masthead navigation ─────────────────────────────────────────────────────
+ Library, the current app and that app's own entries, built from its
+ manifest (see Masthead.astro). Tablet first: below 1024px only the compact
+ bar shows — Library, the current app and a Menu button opening the sheet —
+ so nothing has to fit a row it cannot. From 1024px the full bar shows
+ instead, and wraps rather than scrolls or clips when an app has more
+ entries than one row holds. */
.kb-masthead-nav {
display: flex;
align-items: center;
gap: 0.25rem;
margin: 0;
- overflow-x: auto;
+ min-width: 0;
+}
+.kb-nav-bar { display: none; }
+.kb-nav-compact {
+ display: flex;
+ align-items: center;
+ gap: 0.25rem;
+ flex: 1;
+ min-width: 0;
}
+@media (min-width: 1024px) {
+ .kb-nav-bar {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0 0.25rem;
+ min-width: 0;
+ }
+ .kb-nav-compact { display: none; }
+}
+.kb-nav-item { position: relative; }
+
+/* The current app: its name, then a chevron marking what follows as its own. */
+.kb-nav-scope {
+ display: flex;
+ align-items: center;
+ min-width: 0;
+}
+.kb-nav-scope-mark {
+ flex-shrink: 0;
+ margin: 0 0.125rem;
+ color: var(--text-hint);
+}
+.kb-nav-app-link { color: var(--text-heading); font-weight: 600; }
+
.kb-masthead-link {
display: inline-flex;
align-items: center;
@@ -282,15 +331,136 @@ body, wf-body {
text-decoration: none;
background: none;
border-bottom: 2px solid transparent;
+ cursor: pointer;
+ list-style: none;
transition: color var(--transition), border-color var(--transition);
}
-a.kb-masthead-link:hover { color: var(--text-heading); }
+.kb-masthead-link::-webkit-details-marker { display: none; }
+:is(a, summary).kb-masthead-link:hover { color: var(--text-heading); }
.kb-masthead-link.active {
color: var(--color-kb-500);
border-bottom-color: var(--color-kb-500);
font-weight: 600;
}
+/* One focus ring for every control in the masthead and the sheet. */
+:is(.kb-masthead-link, .kb-nav-page, .kb-nav-toggle, .kb-nav-close):focus-visible {
+ outline: 2px solid var(--color-kb-500);
+ outline-offset: -2px;
+ border-radius: var(--radius-sm);
+}
+
+.kb-nav-chevron { transition: transform var(--transition); }
+details[open] > summary .kb-nav-chevron { transform: rotate(180deg); }
+
+/* A bar dropdown: one section's pages under its title. */
+.kb-nav-panel {
+ position: absolute;
+ top: 100%;
+ left: 0;
+ width: max-content;
+ min-width: 12rem;
+ max-width: min(22rem, calc(100vw - 2rem));
+ max-height: min(70vh, 32rem);
+ overflow-y: auto;
+ padding: 0.5rem;
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ box-shadow: var(--shadow-lg);
+}
+.kb-nav-panel--end { left: auto; right: 0; }
+
+.kb-nav-section {
+ padding: 0.375rem 0.75rem 0.25rem;
+ font-size: 0.6875rem;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: var(--text-hint);
+}
+.kb-nav-page {
+ display: block;
+ padding: 0.4375rem 0.75rem;
+ font-size: 0.8125rem;
+ line-height: 1.4;
+ color: var(--text-body);
+ text-decoration: none;
+ border-radius: var(--radius-sm);
+ overflow-wrap: anywhere;
+}
+.kb-nav-page:hover { background: var(--bg-strong); color: var(--text-heading); }
+.kb-nav-page.active {
+ background: var(--color-kb-50);
+ color: var(--color-kb-500);
+ font-weight: 600;
+}
+
+/* The compact bar: the current app's name gives way before the Menu button. */
+.kb-nav-crumb { min-width: 0; flex-shrink: 1; }
+.kb-nav-crumb-text { overflow: hidden; text-overflow: ellipsis; }
+.kb-nav-toggle,
+.kb-nav-close {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.4rem;
+ font: inherit;
+ font-size: 0.8125rem;
+ font-weight: 600;
+ color: var(--text-heading);
+ background: none;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ cursor: pointer;
+}
+.kb-nav-toggle {
+ margin-left: auto;
+ flex-shrink: 0;
+ padding: 0.375rem 0.75rem;
+}
+.kb-nav-toggle:hover,
+.kb-nav-close:hover { background: var(--bg-strong); }
+
+/* The menu sheet: a popover, so the browser hides it while closed. Every
+ declaration is scoped to :popover-open — an unscoped `display` here would
+ override that and show the sheet on every page. */
+.kb-nav-sheet:popover-open {
+ display: flex;
+ flex-direction: column;
+ position: fixed;
+ inset: 0 0 0 auto;
+ width: min(22rem, 100vw);
+ height: 100%;
+ max-height: none;
+ margin: 0;
+ padding: 0;
+ overflow-y: auto;
+ color: var(--text-body);
+ background: var(--bg-card);
+ border: 0;
+ border-left: 1px solid var(--border);
+ box-shadow: var(--shadow-lg);
+}
+.kb-nav-sheet::backdrop { background: rgb(27 14 18 / 0.35); }
+.kb-nav-sheet-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 1rem 1rem 0.75rem 1.25rem;
+ border-bottom: 1px solid var(--border);
+}
+.kb-nav-sheet-title {
+ font-size: 0.75rem;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.1em;
+ color: var(--color-kb-500);
+}
+.kb-nav-close { padding: 0.375rem; }
+.kb-nav-tree { padding: 0.5rem; }
+.kb-nav-tree .kb-nav-group { margin: 0.5rem 0; }
+.kb-nav-tree > li:first-child > .kb-nav-group { margin-top: 0; }
+
/* ── Fade-up animation ───────────────────────────────────────────────────── */
@keyframes fade-up {
from { transform: translateY(28px); opacity: 0; }
diff --git a/src/utils/apps.js b/src/utils/apps.js
index 5d9d95e..013f8d7 100644
--- a/src/utils/apps.js
+++ b/src/utils/apps.js
@@ -6,6 +6,7 @@ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
import { join, relative, dirname, resolve } from 'node:path';
import { EXPANSION_FILE, isIframe, readExpansionMap, resolveRegistry } from './registry.js';
import { REGISTRY_FILE } from './config.js';
+import { pageRelDir } from './navigation.js';
/**
* Cached result of loadRegistry, keyed by cwd and invalidated by mtime.
@@ -146,7 +147,7 @@ export function getAppPages(cwd, headless) {
routePath: app.slug,
file: join(appDir, entryPoint),
slug: app.slug,
- fileRelDir: dirname(entryPoint).replace(/\\/g, '/').replace(/^\.$/, ''),
+ fileRelDir: pageRelDir(entryPoint),
appHeadless,
app: card,
title: app.name ?? app.slug,
@@ -156,7 +157,7 @@ export function getAppPages(cwd, headless) {
for (const page of app.pages) {
const file = join(appDir, page.path);
- const fileRelDir = dirname(page.path).replace(/\\/g, '/').replace(/^\.$/, '');
+ const fileRelDir = pageRelDir(page.path);
const routeParts = [app.slug];
if (fileRelDir) routeParts.push(fileRelDir);
diff --git a/src/utils/navigation.js b/src/utils/navigation.js
new file mode 100644
index 0000000..c872d7a
--- /dev/null
+++ b/src/utils/navigation.js
@@ -0,0 +1,108 @@
+// src/utils/navigation.js
+//
+// The masthead's navigation model: every app in the effective registry and,
+// for an app whose kb-docs.json lists `pages`, those pages — ordered by
+// `order`, a `section` gathering its pages into one entry. Built from the same
+// resolved registry the routes come from, so a page a publisher adds to its
+// manifest appears in the menu with no change here.
+//
+// The masthead shows one app's entries at a time — the app being viewed — so
+// the bar stays the same length however many apps the knowledge base holds.
+//
+// The model is computed once per registry, not per page: getStaticPaths emits
+// one entry per HTML file, and handing every one of them a copy of the whole
+// registry is what made the route table grow with apps × pages × apps.
+// Astro renders the masthead per page from this shared value instead.
+
+import { posix } from 'node:path';
+import { isIframe } from './registry.js';
+
+/**
+ * The route directory of a manifest page, relative to its app: '' for a file at
+ * the app root, 'docs/guide' for docs/guide/index.html. The catchall route and
+ * the menu both use it, so a menu link cannot point somewhere no page was built.
+ *
+ * @param {string} path - page path relative to the app directory
+ */
+export function pageRelDir(path) {
+ // Separators first: the platform dirname only splits on '\' on Windows, so
+ // the same path would route differently depending on the build host.
+ return posix.dirname(path.replace(/\\/g, '/')).replace(/^\.$/, '');
+}
+
+/** Absolute URL of an app-relative route directory. Always ends in '/'. */
+export function routeHref(base, slug, relDir = '') {
+ return `${base}/${slug}/${relDir ? `${relDir}/` : ''}`;
+}
+
+/**
+ * Turns already-ordered pages into menu entries: a page without a section is
+ * an entry of its own, and a section is one entry holding its pages, placed
+ * where its first page falls. A manifest that interleaves the two keeps its
+ * reading order as far as grouping allows.
+ */
+function toItems(pages) {
+ const items = [];
+ const sections = new Map();
+ for (const page of pages) {
+ const link = { title: page.title, href: page.href };
+ if (!page.section) {
+ items.push({ type: 'page', ...link });
+ continue;
+ }
+ let section = sections.get(page.section);
+ if (!section) {
+ section = { type: 'section', title: page.section, pages: [] };
+ sections.set(page.section, section);
+ items.push(section);
+ }
+ section.pages.push(link);
+ }
+ return items;
+}
+
+/** @typedef {{ title: string, href: string }} NavLink */
+/** @typedef {{ type: 'page', title: string, href: string } | { type: 'section', title: string, pages: NavLink[] }} NavItem */
+/** @typedef {{ slug: string, name: string, icon: string, href: string, items: NavItem[] }} NavApp */
+
+/**
+ * Builds the navigation model from a resolved registry (see loadRegistry).
+ *
+ * @param {any[]} apps - effective registry entries
+ * @param {string} base - URL base, e.g. '/knowledge-base'
+ * @returns {NavApp[]} one entry per app, in registry order; `items` is empty for an app
+ * with no `pages` manifest (crawled, single-page or iframe). The app
+ * root is always reachable through `href`, so an entry point the
+ * manifest leaves out is not added to `items`.
+ */
+export function buildNavigation(apps, base) {
+ return apps.map((app) => {
+ const href = routeHref(base, app.slug);
+ const entry = { slug: app.slug, name: app.name ?? app.slug, icon: app.icon || 'book-open', href, items: [] };
+ if (isIframe(app) || !Array.isArray(app.pages) || app.pages.length === 0) return entry;
+
+ const ordered = [...app.pages].sort((a, b) => a.order - b.order);
+ entry.items = toItems(ordered.map((page) => ({
+ title: page.title,
+ section: page.section ?? null,
+ href: routeHref(base, app.slug, pageRelDir(page.path)),
+ })));
+ return entry;
+ });
+}
+
+const cache = new WeakMap();
+
+/**
+ * buildNavigation, memoised on the registry array loadRegistry caches.
+ *
+ * @param {any[]} apps
+ * @param {string} base
+ * @returns {NavApp[]}
+ */
+export function navigationFor(apps, base) {
+ let byBase = cache.get(apps);
+ if (!byBase) cache.set(apps, (byBase = new Map()));
+ if (!byBase.has(base)) byBase.set(base, buildNavigation(apps, base));
+ return byBase.get(base);
+}
diff --git a/tests/build-integrity.spec.js b/tests/build-integrity.spec.js
index b226078..2590952 100644
--- a/tests/build-integrity.spec.js
+++ b/tests/build-integrity.spec.js
@@ -2,7 +2,7 @@
* tests/build-integrity.spec.js
*
* Static checks on the built `dist/` output (no browser). Validates that the
- * build pipeline integrated both apps, enumerated every sub-app page, rewrote
+ * build pipeline integrated every fixture app, enumerated every sub-app page, rewrote
* URLs to absolute /{prefix}/{slug}/ paths, marked pages headless, and emitted
* the knowledge base stylesheet at the stable name the sub-app pages reference.
*
@@ -322,34 +322,120 @@ test.describe('Masthead', () => {
}
});
- test('on the catalog, Library is the active crumb and no app crumb is shown', () => {
- const n = nav(read('index.html'));
- expect(n).toContain('aria-current="page"');
- expect(n).toContain('Library');
- // Library is inert here — no self-link back to the page you are on.
- expect(n).not.toMatch(/ html.match(/