Skip to content

Commit d347606

Browse files
feat(masthead): navigation built from the documentation manifests
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 <details> 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 <noreply@anthropic.com>
1 parent 8312482 commit d347606

17 files changed

Lines changed: 1043 additions & 101 deletions

‎CLAUDE.md‎

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,8 @@ Orchestrator: `scripts/build-vite.js`. Flags: `--local`, `--headless`.
7474
- `src/utils/transform.js` — `transformSubAppHtml()`: URL rewriting, document splitting (head/body/title/body-class), headless transforms
7575
- `src/layouts/Base.astro` — The one document shell: head (opening with the cascade layer order), `<ClientRouter />`, 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
7676
- `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`
77-
- `src/components/Masthead.astro` — Persistent Knowledge base header + Library/current-app sub-nav (all pages, both modes)
77+
- `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 `<details>` 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 `<script>` sits inside the masthead: Astro renders a component script in place, and the masthead's next sibling must be the sub-app content
78+
- `src/utils/navigation.js` — The masthead's model, built once per resolved registry (`navigationFor`), never passed through `getStaticPaths` props. `pageRelDir()`/`routeHref()` are shared with `apps.js`, so a menu link cannot point where no route was built
7879
- `src/components/AppCard.astro`, `src/components/AppIcon.astro` — Catalog card and its icon
7980
- `src/templates/shadow-compat.js` — Shadow-DOM design-token styles, injected into the body by the layout
8081
- `src/scripts/embedded-transitions.js` — Loaded by the layout; inert standalone. Inside a web fragment it runs Astro's view transition on the host document (the iframe's is never painted) and replaces Astro's swap with one that targets reframed's `wf-html`/`wf-head`/`wf-body`, because the default swap nests a new `wf-html` per navigation and leaks every stylesheet. Its head diff never moves a reused node: on a pierced page a `<link>` already moved once by reframed's portal falls out of the applied stylesheets when moved again. It also imports the fetched head and body into the host document before swapping them in: reframed routes a script into its iframe only through host-realm DOM methods, and the router's page is parsed in the iframe, so Astro's `script.replaceWith()` re-run would otherwise execute every swapped-in sub-app script in the host window
@@ -101,7 +102,7 @@ The registry file is `apps.json` by default; `KB_REGISTRY` points the build at a
101102

102103
### Two Modes
103104

104-
Both modes render the same document: the masthead (`Masthead.astro`) — branding plus the Library / current-app sub-navigation — on every page, and nothing else chrome-like. There is no fixed top bar and no app switcher; the masthead is the navigation.
105+
Both modes render the same document: the masthead (`Masthead.astro`) — branding plus navigation to the Library and, inside an app, that app's manifest pages — on every page, and nothing else chrome-like. There is no fixed top bar; the masthead is the navigation.
105106

106107
**Non-headless** (standalone): Plain knowledge base pages. Navigation is Astro's `<ClientRouter />` (view transitions).
107108

@@ -144,8 +145,9 @@ Apps registered in `apps.json` must comply with:
144145
Self-contained Playwright E2E — `npm test` auto-starts everything (no external gateway):
145146

146147
1. **:3000 fragment** — `scripts/setup-test-apps.mjs` writes a hermetic `apps.json` that
147-
registers the vendored `tests/fixtures/docs-example.kb-docs.tar.gz` (two apps)
148-
(slugs `user-guide` + `guide-mirror`, for cross-app nav), an iframe entry pinned
148+
registers the vendored `tests/fixtures/docs-example.kb-docs.tar.gz` (three apps)
149+
(slugs `user-guide` crawled, `guide-mirror` + `handbook` with `pages` manifests — one
150+
section and two — for cross-app nav and the app-scoped masthead), an iframe entry pinned
149151
`"headless": false`, and the generated single-page bundle fixture — with the real
150152
mermaid bundle, copied from the root `mermaid` devDependency (pinned to the version
151153
`actions/` vendors) and gitignored. `build:headless` builds it;
@@ -172,6 +174,11 @@ Every embedded test runs twice, as Playwright projects `chromium` (:4201) and
172174
(`tests/fixtures/single-page-bundle/` → two apps).
173175
- `transform.spec.js` — unit tests for `transformSubAppHtml()` and `layerSubAppCss()`: the
174176
malformed and hostile documents no fixture app happens to ship.
177+
- `navigation.spec.js` — unit tests for `buildNavigation()`: registry order, `order`/`section`
178+
grouping, an entry point the manifest omits, iframe entries. `build-integrity.spec.js`
179+
asserts the rendered menu (only the current app's entries, manifest titles, `aria-current`, every link built);
180+
`standalone.spec.js` the responsive switch at 320–1920px with no overflow and keyboard use;
181+
`web-fragment.spec.js` the dropdown and sheet inside the fragment, across a swap.
175182
- `css-isolation.spec.js` — Library → app → Library → app in both embeddings leaves the
176183
catalog and masthead computed styles identical to first load; a leak injected on purpose
177184
(the fixture stylesheets plus a hostile layered one appended to the fragment head) applies

‎README.md‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,9 @@ build time it:
1414
1. Obtains each registered app's built output (`dist/`) — from a **GitHub Release
1515
artifact** (`kb-docs.tar.gz`), a **local repo**, or a **prebuilt** tarball/dir.
1616
2. Rewrites every page's URLs to absolute `/knowledge-base/{slug}/…` paths and
17-
re-hosts each document under a persistent **masthead** (branding + Library /
18-
current-app navigation), the same in both modes.
17+
re-hosts each document under a persistent **masthead** (branding + navigation to
18+
the Library and, inside an app, every page its `kb-docs.json` lists, collapsing
19+
to a Menu button below tablet width), the same in both modes.
1920
3. Generates a **catalog landing page** listing all registered apps.
2021
4. Produces a single `dist/` served by **nginx** in Docker, or embedded into a
2122
host app as a **web fragment**.
@@ -203,7 +204,7 @@ then open <http://localhost:4321/knowledge-base/>. The committed `apps.json`
203204
already registers it as an `optional` entry, so nothing breaks when it is absent.
204205

205206
The repo ships an `apps.json` that registers the **vendored docs-example fixture**
206-
twice (`user-guide`, `guide-mirror`), an iframe entry, a **single-page bundle
207+
three times (`user-guide`, `guide-mirror`, `handbook`), an iframe entry, a **single-page bundle
207208
fixture** (`platform-overview`, `release-process`), and the optional example repo
208209
above — so the build and tests are hermetic out of the box. Replace it with your
209210
own apps for a real deployment.

‎contract/ARTIFACT.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,10 +83,10 @@ title derived from the document.
8383

8484
| Field | Required | Rules |
8585
|---|---|---|
86-
| `title` | ✅ | 1–128 characters. Shown in navigation. |
86+
| `title` | ✅ | 1–128 characters. Shown in the masthead while the app is viewed, after the app's name. |
8787
| `path` | ✅ | Path to the HTML file, relative to `<slug>/`. Must exist in the archive. |
8888
| `order` | ✅ | Integer ≥ 0. Lower sorts higher. |
89-
| `section` | ☐ | ≤ 64 characters. Group heading to display above this page. |
89+
| `section` | ☐ | ≤ 64 characters. Groups this page under one masthead dropdown of that name, placed where its first page falls; without it the page is a masthead link of its own. |
9090

9191
### Icon set
9292

‎scripts/setup-test-apps.mjs‎

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,11 @@
66
*
77
* The committed apps.json is this script's output; the Playwright webServer runs
88
* it before every build so the registry stays in sync. It registers:
9-
* • the vendored docs-example fixture, whose manifest declares two apps —
10-
* `user-guide` (crawled) and `guide-mirror` (a `pages` manifest) — so the
11-
* suite can exercise the landing catalog, cross-app navigation and both
12-
* routing paths from one artifact,
9+
* • the vendored docs-example fixture, whose manifest declares three apps —
10+
* `user-guide` (crawled), `guide-mirror` (a `pages` manifest with one
11+
* section) and `handbook` (a `pages` manifest with two sections) — so the
12+
* suite can exercise the landing catalog, cross-app navigation, both
13+
* routing paths and the app-scoped masthead from one artifact,
1314
* • an iframe entry (issue #10),
1415
* • a markdown bundle holding two docs (issue #35).
1516
* No network, no GITHUB_TOKEN, no sibling repo or per-app build toolchain.
@@ -198,12 +199,12 @@ const bundleRoot = writeSinglePageBundle();
198199

199200
// ── Registry ─────────────────────────────────────────────────────────────────
200201

201-
// Two slugs from the same artifact: a primary app and a "mirror" so cross-app
202-
// navigation (clicking from one app's card to another) is testable.
202+
// Three slugs from the same artifact: a primary app and two "mirrors" so
203+
// cross-app navigation (clicking from one app's card to another) is testable.
203204
const apps = [
204205
{
205-
// One artifact, two apps: `user-guide` (crawled) and `guide-mirror` (which
206-
// carries a `pages` manifest). Their names, descriptions and slugs live in
206+
// One artifact, three apps: `user-guide` (crawled), and `guide-mirror` and
207+
// `handbook` (which carry `pages` manifests). Their names, descriptions and slugs live in
207208
// the artifact's kb-docs.json, not here — the registry only says where the
208209
// artifact comes from.
209210
prebuilt: artifact,

‎src/components/Masthead.astro‎

Lines changed: 122 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,30 @@
33
//
44
// Persistent Knowledge base masthead. Rendered by every page through Base.astro
55
// (landing catalog, packaged sub-app pages, iframe entries) in both headless and
6-
// standalone builds, so the branding and the way back to the catalog never
6+
// standalone builds, so the branding and the way around the knowledge base never
77
// disappear once a user opens a documentation app.
88
//
9-
// Sub-navigation:
10-
// • "Library" → the catalog of all documentation sites
11-
// • a dynamic second entry naming the app currently being viewed
12-
// The entry matching the current location is inert text with aria-current="page"
13-
// rather than a link to itself.
9+
// Navigation is scoped to the app being viewed, built from its manifest
10+
// (src/utils/navigation.js): "Library", the app's name, then the app's own
11+
// entries — a page without a section as a link, a section as a dropdown of its
12+
// pages. The catalog is the way between apps, so the bar does not grow with the
13+
// number of apps. A page added to a manifest shows up here with no change to
14+
// this file.
15+
//
16+
// Two renderings of the same entries, switched by CSS at the tablet breakpoint:
17+
// • the bar — wide screens; sections are <details> dropdowns
18+
// • the menu — tablet and narrower; a "Menu" button opening a popover
19+
// sheet with the app's pages, sections as headed groups
20+
// Both work without script: <details> and the Popover API bring their own
21+
// keyboard handling, and the popover its own Escape and light dismiss. The
22+
// script below only adds Escape and outside-click to the bar's dropdowns.
23+
//
24+
// The page being viewed stays a link with aria-current="page"; the app and the
25+
// section holding it are marked aria-current="true".
1426
import AppIcon from './AppIcon.astro';
27+
import NavPages from './NavPages.astro';
28+
import { loadRegistry } from '../utils/apps.js';
29+
import { navigationFor } from '../utils/navigation.js';
1530
1631
interface App {
1732
slug: string;
@@ -20,18 +35,26 @@ interface App {
2035
}
2136
2237
interface Props {
23-
/** The app being viewed, or undefined on the catalog. Not the whole registry:
24-
the masthead names one app, and a per-page copy of every app's metadata is
25-
what Astro would otherwise serialise into every route (#51). */
38+
/** The app being viewed, or undefined on the catalog. */
2639
activeApp?: App;
2740
base?: string;
28-
/** True when the current page is the active app's own index (its crumb is then inert). */
29-
appRoot?: boolean;
41+
/** Absolute URL of the page being viewed, e.g. /knowledge-base/app/docs/. Defaults to the catalog. */
42+
currentPath?: string;
3043
}
3144
32-
const { activeApp, base = '/knowledge-base', appRoot = false } = Astro.props;
45+
const { activeApp, base = '/knowledge-base' } = Astro.props;
46+
const libraryHref = `${base}/`;
47+
const currentPath = Astro.props.currentPath ?? libraryHref;
3348
3449
const onLibrary = !activeApp;
50+
const app = activeApp && navigationFor(loadRegistry(process.cwd()), base).find((entry) => entry.slug === activeApp.slug);
51+
const items = app?.items ?? [];
52+
53+
const holdsCurrent = (item: (typeof items)[number]) =>
54+
item.type === 'page' ? item.href === currentPath : item.pages.some((page) => page.href === currentPath);
55+
/** The app's own link is the current page only when no entry below it is. */
56+
const appIsPage = !!app && app.href === currentPath && !items.some(holdsCurrent);
57+
const menuLabel = app ? `${app.name} pages` : undefined;
3558
---
3659

3760
{/* `kb-shell` is the fence in knowledge-base.css: a sub-app stylesheet that
@@ -62,26 +85,95 @@ const onLibrary = !activeApp;
6285
maintained and deployed — one consistent experience.
6386
</p>
6487

65-
<nav class="kb-masthead-nav" aria-label="Knowledge base sections">
66-
{onLibrary
67-
? <span class="kb-masthead-link active" aria-current="page">Library</span>
68-
: <a class="kb-masthead-link" href={`${base}/`}>Library</a>
69-
}
88+
<nav class="kb-masthead-nav" aria-label="Knowledge base">
89+
{/* The bar: wide screens. Items wrap onto a second row rather than scroll or clip. */}
90+
<ul class="kb-nav-bar" role="list">
91+
<li>
92+
<a class:list={['kb-masthead-link', { active: onLibrary }]} href={libraryHref} aria-current={onLibrary ? 'page' : undefined}>Library</a>
93+
</li>
94+
{app && (
95+
<li class="kb-nav-scope">
96+
<a class:list={['kb-masthead-link', 'kb-nav-app-link', { active: appIsPage }]} href={app.href} aria-current={appIsPage ? 'page' : 'true'}>
97+
<AppIcon icon={app.icon} size={14} />
98+
<span>{app.name}</span>
99+
</a>
100+
{items.length > 0 && (
101+
<svg class="kb-nav-scope-mark" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="9 6 15 12 9 18"/></svg>
102+
)}
103+
</li>
104+
)}
105+
{items.map((item) => (
106+
<li class="kb-nav-item">
107+
{item.type === 'page' ? (
108+
<a class:list={['kb-masthead-link', { active: holdsCurrent(item) }]} href={item.href} aria-current={holdsCurrent(item) ? 'page' : undefined}>{item.title}</a>
109+
) : (
110+
/* `name` makes the dropdowns exclusive: opening one closes the other. */
111+
<details class="kb-nav-dropdown" name="kb-nav-dropdown">
112+
<summary class:list={['kb-masthead-link', { active: holdsCurrent(item) }]} aria-current={holdsCurrent(item) ? 'true' : undefined}>
113+
<span>{item.title}</span>
114+
<svg class="kb-nav-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg>
115+
</summary>
116+
<div class="kb-nav-panel">
117+
<NavPages pages={item.pages} currentPath={currentPath} label={item.title} />
118+
</div>
119+
</details>
120+
)}
121+
</li>
122+
))}
123+
</ul>
70124

71-
{activeApp && (appRoot
72-
? (
73-
<span class="kb-masthead-link active" aria-current="page">
74-
<AppIcon icon={activeApp.icon || 'book-open'} size={14} />
75-
<span>{activeApp.name}</span>
76-
</span>
77-
)
78-
: (
79-
<a class="kb-masthead-link active" href={`${base}/${activeApp.slug}/`}>
80-
<AppIcon icon={activeApp.icon || 'book-open'} size={14} />
81-
<span>{activeApp.name}</span>
125+
{/* The compact bar: tablet and narrower. Where you are, and the app's menu. */}
126+
<div class="kb-nav-compact">
127+
<a class:list={['kb-masthead-link', { active: onLibrary }]} href={libraryHref} aria-current={onLibrary ? 'page' : undefined}>Library</a>
128+
{app && (
129+
<a class:list={['kb-masthead-link', 'kb-nav-app-link', 'kb-nav-crumb', { active: appIsPage }]} href={app.href} aria-current={appIsPage ? 'page' : 'true'}>
130+
<AppIcon icon={app.icon} size={14} />
131+
<span class="kb-nav-crumb-text">{app.name}</span>
82132
</a>
83-
)
84-
)}
133+
)}
134+
{items.length > 0 && (
135+
<button type="button" class="kb-nav-toggle" popovertarget="kb-nav-menu" aria-label={menuLabel}>
136+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
137+
<span>Menu</span>
138+
</button>
139+
)}
140+
</div>
85141
</nav>
86142
</div>
143+
144+
{/* The menu sheet: the current app's pages. A popover: the top layer keeps it
145+
above any sub-app stacking context, and the browser supplies Escape, light
146+
dismiss and the toggle's aria-expanded. */}
147+
{items.length > 0 && (
148+
<div id="kb-nav-menu" class="kb-nav-sheet" popover>
149+
<div class="kb-nav-sheet-head">
150+
<p class="kb-nav-sheet-title">{app!.name}</p>
151+
<button type="button" class="kb-nav-close" popovertarget="kb-nav-menu" popovertargetaction="hide" aria-label="Close menu">
152+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
153+
</button>
154+
</div>
155+
<nav aria-label={menuLabel}>
156+
<ul class="kb-nav-tree" role="list">
157+
{items.map((item) => (
158+
<li>
159+
{item.type === 'page' ? (
160+
<a class:list={['kb-nav-page', { active: holdsCurrent(item) }]} href={item.href} aria-current={holdsCurrent(item) ? 'page' : undefined}>{item.title}</a>
161+
) : (
162+
<div class="kb-nav-group">
163+
<p class="kb-nav-section" aria-hidden="true">{item.title}</p>
164+
<NavPages pages={item.pages} currentPath={currentPath} label={item.title} />
165+
</div>
166+
)}
167+
</li>
168+
))}
169+
</ul>
170+
</nav>
171+
</div>
172+
)}
173+
174+
{/* Inside the masthead, not after it: Astro renders a component script where
175+
the component is, and the masthead's next sibling is the sub-app's content. */}
176+
<script>
177+
import '../scripts/masthead-nav.js';
178+
</script>
87179
</div>

0 commit comments

Comments
 (0)