From d347606b54eb773b61e5009f6b4da008ce057eb2 Mon Sep 17 00:00:00 2001 From: Oto Macenauer Date: Tue, 29 Sep 2026 22:05:24 +0200 Subject: [PATCH] feat(masthead): navigation built from the documentation manifests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The masthead's navigation is scoped to the app being viewed: Library, the app's name, then that app's own entries from its kb-docs.json `pages` — a page without a `section` as a link, a section as a dropdown of its pages, placed where its first page falls in `order`. A publisher adds a page to the menu without a knowledge base change, and the bar does not grow with the number of apps: the catalog is the way between them. The model is built once per resolved registry (src/utils/navigation.js) and shares route derivation with apps.js, so every menu link resolves to a built page. Tablet first: below 1024px a compact bar (Library, current app, and a Menu button for an app with manifest pages) opens a popover sheet of the app's pages; from 1024px a wrapping bar with
dropdowns. Both work without script; masthead-nav.js adds Escape, outside-click and edge-flip to the dropdowns, taking the real window from event.view because reframed reports its iframe's document as the masthead's ownerDocument. The current page carries aria-current="page", the app and section holding it aria-current="true". The brand wordmark on app pages moves out of flow into the left gutter, shown from 90rem where the gutter holds it: as a flex item it took its width out of the centred column and moved every link away from where it sits on the catalog. The vendored docs-example fixture gains a third app, `handbook`, with a two-section manifest, so the app-scoped menu and exclusive dropdowns are exercised and visible in a local build. Closes #111 Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 15 +- README.md | 7 +- contract/ARTIFACT.md | 4 +- scripts/setup-test-apps.mjs | 17 +- src/components/Masthead.astro | 152 ++++++++++++--- src/components/NavPages.astro | 28 +++ src/pages/[...path].astro | 7 +- src/scripts/masthead-nav.js | 95 +++++++++ src/styles/knowledge-base.css | 216 ++++++++++++++++++--- src/utils/apps.js | 5 +- src/utils/navigation.js | 108 +++++++++++ tests/build-integrity.spec.js | 130 ++++++++++--- tests/css-isolation.spec.js | 4 +- tests/fixtures/docs-example.kb-docs.tar.gz | Bin 4445669 -> 5937955 bytes tests/navigation.spec.js | 99 ++++++++++ tests/standalone.spec.js | 199 ++++++++++++++++++- tests/web-fragment.spec.js | 58 ++++++ 17 files changed, 1043 insertions(+), 101 deletions(-) create mode 100644 src/components/NavPages.astro create mode 100644 src/scripts/masthead-nav.js create mode 100644 src/utils/navigation.js create mode 100644 tests/navigation.spec.js 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; +--- + + 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(/