All checks were successful
PR checks / checks (pull_request) Successful in 9m36s
PLAN.md §13 phase 10, with four decisions of record — D47-D50, taking the count
to fifty. Three were straightforward; the CSP turned into the phase's real work,
because the thing meant to be a configuration flag was broken in a dependency and
broken silently.
D47 — search reaches the marketing pages, and the header gets a box.
Base.astro marks its <main> as a Pagefind body, so all ten join the index the
docs already query, and Search.astro opens it in a <dialog>. Nothing is fetched
until the dialog is opened (the bundle is 120 kB and these pages otherwise ship
almost no JavaScript). Pagefind titles a result from the first <h1>, and these
pages have editorial ones — "The app for a deployment you already use" — so the
index is given the page's short name instead. applyBrand.mjs now re-indexes after
a rewrite, closing a note phase 2 left for this phase.
D48 — the CSP is a real response header, sent by the container. Not a <meta>,
which ignores frame-ancestors, and not advice for someone's reverse proxy, which
puts the strictest promise in §6 outside what this repo tests. Three things
fought it, all the same shape — correct build, broken page, no error:
* Astro does not hash <script is:inline>, and Starlight ships six per docs
page, so the first build with CSP on had a strict header and a dead theme
switcher. The hashes are now generated into src/config/cspHashes.mjs and
checkCsp.mjs verifies every inline block against its own page's policy.
* Expressive Code writes ~3,700 inline style ATTRIBUTES, which cannot be
hashed, hence style-src-attr 'unsafe-inline' — scoped to that directive, so
script-src is untouched.
* @astrojs/node matched a request to a policy with pathname.includes(), a
substring test: /modules/ was served /docs/modules/building-a-module's
policy and rendered with its own stylesheet refused. scripts/serve.mjs keeps
the same _headers.json and matches by equality; test/headers.test.mjs starts
the server and reads the responses, because nothing that reads dist/ can see
this.
D49 — robots.txt allows everything and names the sitemap (there was no way to
find it: no robots.txt, and D9 rules out a search console). D50 — Organization
and SoftwareApplication, no ratings and no docs-wide Article markup.
checkA11y.mjs is the eleventh check: seven structural rules over all fifty pages,
verified by breaking each in turn. The walk at 390/768/1280 found no overflow
anywhere, the CSP violations above, a 17x17 consent checkbox (WCAG 2.2 SC 2.5.8
wants 24), and a skip link that moved the scroll but not the focus.
npm run verify is green: fourteen steps, both test suites, all eleven checks.
Co-Authored-By: Claude <noreply@anthropic.com>
280 lines
9.9 KiB
Plaintext
280 lines
9.9 KiB
Plaintext
---
|
|
/**
|
|
* Site search for the marketing pages (D47).
|
|
*
|
|
* The documentation has had search since phase 1 — Starlight builds a Pagefind index at the
|
|
* end of every build and puts a box in its own header. The marketing pages were outside it
|
|
* twice over: not indexed, so a reader searching "Teams" in the docs found the architecture
|
|
* page and never the feature page; and with no box, so a reader who arrived on the homepage
|
|
* had a four-item nav and no way to ask a question.
|
|
*
|
|
* D47 closed both. `Base.astro` marks its `<main>` as a Pagefind body, which puts the ten
|
|
* marketing pages in the same index the docs already query, and this is the box.
|
|
*
|
|
* ── Why it is built this way ────────────────────────────────────────────────
|
|
* The marketing pages ship almost no JavaScript, and Pagefind's own UI bundle is 120 kB
|
|
* before the index and the WASM. Loading that on a homepage so that some visitors can
|
|
* search would be a poor trade, so **nothing is fetched until the dialog is opened** —
|
|
* the button is inert markup, and the first open injects the stylesheet and the script.
|
|
* Opening search a second time costs nothing more.
|
|
*
|
|
* `<dialog>` rather than a hand-built overlay: the browser gives us the focus trap, the
|
|
* inert background, Escape-to-close and the top layer for free, and every one of those is
|
|
* a thing an accessibility pass would otherwise have to find missing.
|
|
*
|
|
* In `astro dev` there is no `/pagefind/` — the index is written by the build. Rather than
|
|
* fail silently, the dialog says so.
|
|
*/
|
|
---
|
|
|
|
<div class="site-search">
|
|
<button type="button" class="site-search__open" data-search-open aria-haspopup="dialog">
|
|
<svg aria-hidden="true" focusable="false" viewBox="0 0 20 20" width="16" height="16">
|
|
<circle cx="9" cy="9" r="6" fill="none" stroke="currentColor" stroke-width="2"></circle>
|
|
<line x1="13.5" y1="13.5" x2="18" y2="18" stroke="currentColor" stroke-width="2" stroke-linecap="round"></line>
|
|
</svg>
|
|
<span>Search</span>
|
|
<kbd aria-hidden="true">/</kbd>
|
|
</button>
|
|
|
|
<dialog class="site-search__dialog" data-search-dialog aria-label="Search this site">
|
|
<div class="site-search__panel">
|
|
<div class="site-search__head">
|
|
<h2 class="site-search__title">Search</h2>
|
|
<button type="button" class="site-search__close" data-search-close>Close</button>
|
|
</div>
|
|
<div data-search-mount></div>
|
|
<p class="site-search__note" data-search-note hidden>
|
|
Search is built with the site, so it is not available in the dev server. Run
|
|
<code>npm run build && npm start</code> to try it.
|
|
</p>
|
|
</div>
|
|
</dialog>
|
|
</div>
|
|
|
|
<script>
|
|
const dialog = document.querySelector<HTMLDialogElement>('[data-search-dialog]');
|
|
const mount = document.querySelector<HTMLElement>('[data-search-mount]');
|
|
const note = document.querySelector<HTMLElement>('[data-search-note]');
|
|
|
|
if (dialog && mount) {
|
|
let loaded: Promise<void> | null = null;
|
|
|
|
/**
|
|
* Pagefind's UI bundle is an IIFE that hangs `PagefindUI` off `window`, so it is a
|
|
* `<script src>` and not a dynamic `import()`. Both are covered by `script-src 'self'`
|
|
* (D48); the WASM the index needs is why that directive also carries
|
|
* `'wasm-unsafe-eval'`.
|
|
*/
|
|
const load = () =>
|
|
(loaded ??= new Promise<void>((resolve, reject) => {
|
|
const css = document.createElement('link');
|
|
css.rel = 'stylesheet';
|
|
css.href = '/pagefind/pagefind-ui.css';
|
|
document.head.append(css);
|
|
|
|
const js = document.createElement('script');
|
|
js.src = '/pagefind/pagefind-ui.js';
|
|
js.onload = () => {
|
|
new (window as any).PagefindUI({
|
|
element: mount,
|
|
showSubResults: true,
|
|
showImages: false,
|
|
// `resetStyles: false` was tried and is wrong here. Pagefind's reset is what
|
|
// styles its own input and buttons; without it they fall back to user-agent
|
|
// defaults, which on this ground meant black text typed into a dark field and
|
|
// a Clear button with a 1990s `outset` border. The palette is bound to our
|
|
// tokens below instead, which is the supported way round.
|
|
translations: {
|
|
placeholder: 'Search the site and documentation',
|
|
zero_results: 'Nothing found for [SEARCH_TERM]',
|
|
},
|
|
});
|
|
resolve();
|
|
};
|
|
js.onerror = () => reject(new Error('pagefind-ui.js did not load'));
|
|
document.head.append(js);
|
|
}).catch((error) => {
|
|
// A dev server, or a build served without its index. Say which.
|
|
if (note) note.hidden = false;
|
|
loaded = null;
|
|
throw error;
|
|
}));
|
|
|
|
const open = () => {
|
|
// Deliberately not awaited: the dialog should appear at once and fill in, rather
|
|
// than the button seeming dead for as long as the bundle takes.
|
|
load().catch(() => {});
|
|
if (!dialog.open) dialog.showModal();
|
|
window.setTimeout(() => {
|
|
dialog.querySelector<HTMLInputElement>('input[type="text"]')?.focus();
|
|
}, 50);
|
|
};
|
|
|
|
document
|
|
.querySelectorAll<HTMLButtonElement>('[data-search-open]')
|
|
.forEach((button) => button.addEventListener('click', open));
|
|
|
|
document
|
|
.querySelectorAll<HTMLButtonElement>('[data-search-close]')
|
|
.forEach((button) => button.addEventListener('click', () => dialog.close()));
|
|
|
|
// Clicking the backdrop closes it. `<dialog>` reports backdrop clicks as clicks on the
|
|
// dialog itself, so the test is whether the click landed outside the panel's box.
|
|
dialog.addEventListener('click', (event) => {
|
|
if (event.target !== dialog) return;
|
|
const box = dialog.getBoundingClientRect();
|
|
const outside =
|
|
event.clientX < box.left ||
|
|
event.clientX > box.right ||
|
|
event.clientY < box.top ||
|
|
event.clientY > box.bottom;
|
|
if (outside) dialog.close();
|
|
});
|
|
|
|
/**
|
|
* `/` and Ctrl/⌘-K, the two the documentation already answers to — the shortcut a
|
|
* reader learns in the docs should work on the way back out.
|
|
*/
|
|
document.addEventListener('keydown', (event) => {
|
|
if (dialog.open) return;
|
|
const target = event.target as HTMLElement | null;
|
|
const typing =
|
|
target?.isContentEditable ||
|
|
['INPUT', 'TEXTAREA', 'SELECT'].includes(target?.tagName ?? '');
|
|
if (typing) return;
|
|
|
|
if (event.key === '/' || ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k')) {
|
|
event.preventDefault();
|
|
open();
|
|
}
|
|
});
|
|
}
|
|
</script>
|
|
|
|
<style>
|
|
.site-search__open {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
padding: 0.4rem 0.7rem;
|
|
border: 1px solid var(--line);
|
|
border-radius: var(--radius-pill);
|
|
background: transparent;
|
|
color: var(--muted);
|
|
font: inherit;
|
|
font-size: 0.92rem;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.site-search__open:hover {
|
|
color: var(--ink);
|
|
border-color: var(--gold);
|
|
}
|
|
|
|
.site-search__open kbd {
|
|
padding: 0 0.35rem;
|
|
border: 1px solid var(--line);
|
|
border-radius: 4px;
|
|
font: inherit;
|
|
font-size: 0.78rem;
|
|
line-height: 1.4;
|
|
}
|
|
|
|
/* Narrow viewports get the icon alone: the header has a lockup and four links to fit,
|
|
and "Search" beside a magnifier is the word the icon already says. */
|
|
@media (max-width: 46rem) {
|
|
.site-search__open span,
|
|
.site-search__open kbd {
|
|
display: none;
|
|
}
|
|
|
|
.site-search__open {
|
|
padding: 0.45rem;
|
|
}
|
|
}
|
|
|
|
.site-search__dialog {
|
|
width: min(46rem, calc(100vw - 2rem));
|
|
margin-inline: auto;
|
|
margin-block-start: min(12vh, 6rem);
|
|
padding: 0;
|
|
border: 1px solid var(--line);
|
|
border-radius: var(--radius-panel);
|
|
background: var(--panel-flat);
|
|
color: var(--ink);
|
|
}
|
|
|
|
.site-search__dialog::backdrop {
|
|
background: var(--scrim);
|
|
}
|
|
|
|
.site-search__panel {
|
|
padding: 1.1rem 1.25rem 1.4rem;
|
|
}
|
|
|
|
.site-search__head {
|
|
display: flex;
|
|
align-items: baseline;
|
|
justify-content: space-between;
|
|
gap: 1rem;
|
|
margin-bottom: 0.85rem;
|
|
}
|
|
|
|
.site-search__title {
|
|
margin: 0;
|
|
font-size: 1.05rem;
|
|
letter-spacing: 0.02em;
|
|
}
|
|
|
|
.site-search__close {
|
|
border: 0;
|
|
background: transparent;
|
|
color: var(--muted);
|
|
font: inherit;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.site-search__close:hover {
|
|
color: var(--ink);
|
|
}
|
|
|
|
.site-search__note {
|
|
margin: 0.75rem 0 0;
|
|
color: var(--muted);
|
|
font-size: 0.92rem;
|
|
}
|
|
|
|
/* Pagefind ships its own palette; these bind it to the site's tokens so the dialog is
|
|
not a differently-coloured window sitting on the page. */
|
|
.site-search__panel :global(.pagefind-ui) {
|
|
--pagefind-ui-primary: var(--ink);
|
|
--pagefind-ui-text: var(--ink);
|
|
--pagefind-ui-background: var(--panel-flat);
|
|
--pagefind-ui-border: var(--line);
|
|
--pagefind-ui-tag: var(--bg);
|
|
--pagefind-ui-border-width: 1px;
|
|
--pagefind-ui-border-radius: var(--radius-input);
|
|
--pagefind-ui-font: inherit;
|
|
}
|
|
|
|
.site-search__panel :global(.pagefind-ui__result-link) {
|
|
color: var(--gold);
|
|
}
|
|
|
|
/* The match highlight. Pagefind marks matched terms with <mark>, and the user-agent
|
|
default for that is black on pure yellow — legible, and a hole punched through the
|
|
palette on every result. Gold at low opacity reads as a highlight against this ground
|
|
without becoming the loudest thing on the page.
|
|
|
|
`.pagefind-ui--reset` is in the selector because Pagefind's reset declares
|
|
`.pagefind-ui--reset mark { all: revert }`, which is the same specificity as a plain
|
|
descendant rule and is injected after this stylesheet — so it won on order and put the
|
|
yellow back. One more class is enough; `!important` is not needed and would be a worse
|
|
way to say the same thing. */
|
|
.site-search__panel :global(.pagefind-ui--reset mark) {
|
|
background: color-mix(in srgb, var(--gold) 26%, transparent);
|
|
color: var(--ink);
|
|
}
|
|
</style>
|