Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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), `<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
- `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 `<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
- `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
- `src/components/AppCard.astro`, `src/components/AppIcon.astro` β€” Catalog card and its icon
- `src/templates/shadow-compat.js` β€” Shadow-DOM design-token styles, injected into the body by the layout
- `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
Expand All @@ -101,7 +102,7 @@ The registry file is `apps.json` by default; `KB_REGISTRY` points the build at a

### Two Modes

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.
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.

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

Expand Down Expand Up @@ -144,8 +145,9 @@ Apps registered in `apps.json` must comply with:
Self-contained Playwright E2E β€” `npm test` auto-starts everything (no external gateway):

1. **:3000 fragment** β€” `scripts/setup-test-apps.mjs` writes a hermetic `apps.json` that
registers the vendored `tests/fixtures/docs-example.kb-docs.tar.gz` (two apps)
(slugs `user-guide` + `guide-mirror`, for cross-app nav), an iframe entry pinned
registers the vendored `tests/fixtures/docs-example.kb-docs.tar.gz` (three apps)
(slugs `user-guide` crawled, `guide-mirror` + `handbook` with `pages` manifests β€” one
section and two β€” for cross-app nav and the app-scoped masthead), an iframe entry pinned
`"headless": false`, and the generated single-page bundle fixture β€” with the real
mermaid bundle, copied from the root `mermaid` devDependency (pinned to the version
`actions/` vendors) and gitignored. `build:headless` builds it;
Expand All @@ -172,6 +174,11 @@ Every embedded test runs twice, as Playwright projects `chromium` (:4201) and
(`tests/fixtures/single-page-bundle/` β†’ two apps).
- `transform.spec.js` β€” unit tests for `transformSubAppHtml()` and `layerSubAppCss()`: the
malformed and hostile documents no fixture app happens to ship.
- `navigation.spec.js` β€” unit tests for `buildNavigation()`: registry order, `order`/`section`
grouping, an entry point the manifest omits, iframe entries. `build-integrity.spec.js`
asserts the rendered menu (only the current app's entries, manifest titles, `aria-current`, every link built);
`standalone.spec.js` the responsive switch at 320–1920px with no overflow and keyboard use;
`web-fragment.spec.js` the dropdown and sheet inside the fragment, across a swap.
- `css-isolation.spec.js` β€” Library β†’ app β†’ Library β†’ app in both embeddings leaves the
catalog and masthead computed styles identical to first load; a leak injected on purpose
(the fixture stylesheets plus a hostile layered one appended to the fragment head) applies
Expand Down
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@ build time it:
1. Obtains each registered app's built output (`dist/`) β€” from a **GitHub Release
artifact** (`kb-docs.tar.gz`), a **local repo**, or a **prebuilt** tarball/dir.
2. Rewrites every page's URLs to absolute `/knowledge-base/{slug}/…` paths and
re-hosts each document under a persistent **masthead** (branding + Library /
current-app navigation), the same in both modes.
re-hosts each document under a persistent **masthead** (branding + navigation to
the Library and, inside an app, every page its `kb-docs.json` lists, collapsing
to a Menu button below tablet width), the same in both modes.
3. Generates a **catalog landing page** listing all registered apps.
4. Produces a single `dist/` served by **nginx** in Docker, or embedded into a
host app as a **web fragment**.
Expand Down Expand Up @@ -203,7 +204,7 @@ then open <http://localhost:4321/knowledge-base/>. The committed `apps.json`
already registers it as an `optional` entry, so nothing breaks when it is absent.

The repo ships an `apps.json` that registers the **vendored docs-example fixture**
twice (`user-guide`, `guide-mirror`), an iframe entry, a **single-page bundle
three times (`user-guide`, `guide-mirror`, `handbook`), an iframe entry, a **single-page bundle
fixture** (`platform-overview`, `release-process`), and the optional example repo
above β€” so the build and tests are hermetic out of the box. Replace it with your
own apps for a real deployment.
Expand Down
4 changes: 2 additions & 2 deletions contract/ARTIFACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,10 +83,10 @@ title derived from the document.

| Field | Required | Rules |
|---|---|---|
| `title` | βœ… | 1–128 characters. Shown in navigation. |
| `title` | βœ… | 1–128 characters. Shown in the masthead while the app is viewed, after the app's name. |
| `path` | βœ… | Path to the HTML file, relative to `<slug>/`. Must exist in the archive. |
| `order` | βœ… | Integer β‰₯ 0. Lower sorts higher. |
| `section` | ☐ | ≀ 64 characters. Group heading to display above this page. |
| `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. |

### Icon set

Expand Down
17 changes: 9 additions & 8 deletions scripts/setup-test-apps.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@
*
* The committed apps.json is this script's output; the Playwright webServer runs
* it before every build so the registry stays in sync. It registers:
* β€’ the vendored docs-example fixture, whose manifest declares two apps β€”
* `user-guide` (crawled) and `guide-mirror` (a `pages` manifest) β€” so the
* suite can exercise the landing catalog, cross-app navigation and both
* routing paths from one artifact,
* β€’ the vendored docs-example fixture, whose manifest declares three apps β€”
* `user-guide` (crawled), `guide-mirror` (a `pages` manifest with one
* section) and `handbook` (a `pages` manifest with two sections) β€” so the
* suite can exercise the landing catalog, cross-app navigation, both
* routing paths and the app-scoped masthead from one artifact,
* β€’ an iframe entry (issue #10),
* β€’ a markdown bundle holding two docs (issue #35).
* No network, no GITHUB_TOKEN, no sibling repo or per-app build toolchain.
Expand Down Expand Up @@ -198,12 +199,12 @@ const bundleRoot = writeSinglePageBundle();

// ── Registry ─────────────────────────────────────────────────────────────────

// Two slugs from the same artifact: a primary app and a "mirror" so cross-app
// navigation (clicking from one app's card to another) is testable.
// Three slugs from the same artifact: a primary app and two "mirrors" so
// cross-app navigation (clicking from one app's card to another) is testable.
const apps = [
{
// One artifact, two apps: `user-guide` (crawled) and `guide-mirror` (which
// carries a `pages` manifest). Their names, descriptions and slugs live in
// One artifact, three apps: `user-guide` (crawled), and `guide-mirror` and
// `handbook` (which carry `pages` manifests). Their names, descriptions and slugs live in
// the artifact's kb-docs.json, not here β€” the registry only says where the
// artifact comes from.
prebuilt: artifact,
Expand Down
152 changes: 122 additions & 30 deletions src/components/Masthead.astro
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,30 @@
//
// Persistent Knowledge base masthead. Rendered by every page through Base.astro
// (landing catalog, packaged sub-app pages, iframe entries) in both headless and
// standalone builds, so the branding and the way back to the catalog never
// standalone builds, so the branding and the way around the knowledge base never
// disappear once a user opens a documentation app.
//
// Sub-navigation:
// β€’ "Library" β†’ the catalog of all documentation sites
// β€’ a dynamic second entry naming the app currently being viewed
// The entry matching the current location is inert text with aria-current="page"
// rather than a link to itself.
// Navigation is scoped to the app being viewed, built from its manifest
// (src/utils/navigation.js): "Library", the app's name, then the app's own
// entries β€” a page without a section as a link, a section as a dropdown of its
// pages. The catalog is the way between apps, so the bar does not grow with the
// number of apps. A page added to a manifest shows up here with no change to
// this file.
//
// Two renderings of the same entries, switched by CSS at the tablet breakpoint:
// β€’ the bar β€” wide screens; sections are <details> dropdowns
// β€’ the menu β€” tablet and narrower; a "Menu" button opening a popover
// sheet with the app's pages, sections as headed groups
// Both work without script: <details> and the Popover API bring their own
// keyboard handling, and the popover its own Escape and light dismiss. The
// script below only adds Escape and outside-click to the bar's dropdowns.
//
// The page being viewed stays a link with aria-current="page"; the app and the
// section holding it are marked aria-current="true".
import AppIcon from './AppIcon.astro';
import NavPages from './NavPages.astro';
import { loadRegistry } from '../utils/apps.js';
import { navigationFor } from '../utils/navigation.js';

interface App {
slug: string;
Expand All @@ -20,18 +35,26 @@ interface App {
}

interface Props {
/** The app being viewed, or undefined on the catalog. Not the whole registry:
the masthead names one app, and a per-page copy of every app's metadata is
what Astro would otherwise serialise into every route (#51). */
/** The app being viewed, or undefined on the catalog. */
activeApp?: App;
base?: string;
/** True when the current page is the active app's own index (its crumb is then inert). */
appRoot?: boolean;
/** Absolute URL of the page being viewed, e.g. /knowledge-base/app/docs/. Defaults to the catalog. */
currentPath?: string;
}

const { activeApp, base = '/knowledge-base', appRoot = false } = Astro.props;
const { activeApp, base = '/knowledge-base' } = Astro.props;
const libraryHref = `${base}/`;
const currentPath = Astro.props.currentPath ?? libraryHref;

const onLibrary = !activeApp;
const app = activeApp && navigationFor(loadRegistry(process.cwd()), base).find((entry) => entry.slug === activeApp.slug);
const items = app?.items ?? [];

const holdsCurrent = (item: (typeof items)[number]) =>
item.type === 'page' ? item.href === currentPath : item.pages.some((page) => page.href === currentPath);
/** The app's own link is the current page only when no entry below it is. */
const appIsPage = !!app && app.href === currentPath && !items.some(holdsCurrent);
const menuLabel = app ? `${app.name} pages` : undefined;
---

{/* `kb-shell` is the fence in knowledge-base.css: a sub-app stylesheet that
Expand Down Expand Up @@ -62,26 +85,95 @@ const onLibrary = !activeApp;
maintained and deployed β€” one consistent experience.
</p>

<nav class="kb-masthead-nav" aria-label="Knowledge base sections">
{onLibrary
? <span class="kb-masthead-link active" aria-current="page">Library</span>
: <a class="kb-masthead-link" href={`${base}/`}>Library</a>
}
<nav class="kb-masthead-nav" aria-label="Knowledge base">
{/* The bar: wide screens. Items wrap onto a second row rather than scroll or clip. */}
<ul class="kb-nav-bar" role="list">
<li>
<a class:list={['kb-masthead-link', { active: onLibrary }]} href={libraryHref} aria-current={onLibrary ? 'page' : undefined}>Library</a>
</li>
{app && (
<li class="kb-nav-scope">
<a class:list={['kb-masthead-link', 'kb-nav-app-link', { active: appIsPage }]} href={app.href} aria-current={appIsPage ? 'page' : 'true'}>
<AppIcon icon={app.icon} size={14} />
<span>{app.name}</span>
</a>
{items.length > 0 && (
<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>
)}
</li>
)}
{items.map((item) => (
<li class="kb-nav-item">
{item.type === 'page' ? (
<a class:list={['kb-masthead-link', { active: holdsCurrent(item) }]} href={item.href} aria-current={holdsCurrent(item) ? 'page' : undefined}>{item.title}</a>
) : (
/* `name` makes the dropdowns exclusive: opening one closes the other. */
<details class="kb-nav-dropdown" name="kb-nav-dropdown">
<summary class:list={['kb-masthead-link', { active: holdsCurrent(item) }]} aria-current={holdsCurrent(item) ? 'true' : undefined}>
<span>{item.title}</span>
<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>
</summary>
<div class="kb-nav-panel">
<NavPages pages={item.pages} currentPath={currentPath} label={item.title} />
</div>
</details>
)}
</li>
))}
</ul>

{activeApp && (appRoot
? (
<span class="kb-masthead-link active" aria-current="page">
<AppIcon icon={activeApp.icon || 'book-open'} size={14} />
<span>{activeApp.name}</span>
</span>
)
: (
<a class="kb-masthead-link active" href={`${base}/${activeApp.slug}/`}>
<AppIcon icon={activeApp.icon || 'book-open'} size={14} />
<span>{activeApp.name}</span>
{/* The compact bar: tablet and narrower. Where you are, and the app's menu. */}
<div class="kb-nav-compact">
<a class:list={['kb-masthead-link', { active: onLibrary }]} href={libraryHref} aria-current={onLibrary ? 'page' : undefined}>Library</a>
{app && (
<a class:list={['kb-masthead-link', 'kb-nav-app-link', 'kb-nav-crumb', { active: appIsPage }]} href={app.href} aria-current={appIsPage ? 'page' : 'true'}>
<AppIcon icon={app.icon} size={14} />
<span class="kb-nav-crumb-text">{app.name}</span>
</a>
)
)}
)}
{items.length > 0 && (
<button type="button" class="kb-nav-toggle" popovertarget="kb-nav-menu" aria-label={menuLabel}>
<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>
<span>Menu</span>
</button>
)}
</div>
</nav>
</div>

{/* The menu sheet: the current app's pages. A popover: the top layer keeps it
above any sub-app stacking context, and the browser supplies Escape, light
dismiss and the toggle's aria-expanded. */}
{items.length > 0 && (
<div id="kb-nav-menu" class="kb-nav-sheet" popover>
<div class="kb-nav-sheet-head">
<p class="kb-nav-sheet-title">{app!.name}</p>
<button type="button" class="kb-nav-close" popovertarget="kb-nav-menu" popovertargetaction="hide" aria-label="Close menu">
<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>
</button>
</div>
<nav aria-label={menuLabel}>
<ul class="kb-nav-tree" role="list">
{items.map((item) => (
<li>
{item.type === 'page' ? (
<a class:list={['kb-nav-page', { active: holdsCurrent(item) }]} href={item.href} aria-current={holdsCurrent(item) ? 'page' : undefined}>{item.title}</a>
) : (
<div class="kb-nav-group">
<p class="kb-nav-section" aria-hidden="true">{item.title}</p>
<NavPages pages={item.pages} currentPath={currentPath} label={item.title} />
</div>
)}
</li>
))}
</ul>
</nav>
</div>
)}

{/* Inside the masthead, not after it: Astro renders a component script where
the component is, and the masthead's next sibling is the sub-app's content. */}
<script>
import '../scripts/masthead-nav.js';
</script>
</div>
Loading
Loading