@dogsbay/docs-layout 0.2.0-beta.10 → 0.2.0-beta.101

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,107 @@
1
+ ---
2
+ /**
3
+ * Client-rendered drop-in replacement for SidebarNavTree.
4
+ *
5
+ * Emits an empty placeholder with the metadata the hydration script
6
+ * needs (nav-url to fetch, current-path to highlight, basePath for
7
+ * version/locale filtering). The actual tree DOM is rendered by
8
+ * `docs-nav-client.ts` after a single fetch of `nav.json` (cached
9
+ * once per session — subsequent navigations re-highlight without
10
+ * re-fetching, courtesy of view transitions).
11
+ *
12
+ * The placeholder shows a few skeleton rows so the layout doesn't
13
+ * shift when the real tree pops in. Skeleton uses the same width as
14
+ * the sidebar so visual jump is minimal even on a slow connection.
15
+ *
16
+ * Trade-offs vs SidebarNavTree:
17
+ * - HTML per page: ~200 bytes (this placeholder + a tiny script
18
+ * tag) vs ~600 KB+ for the SSR tree at scale.
19
+ * - No-JS users see only the skeleton + the `<noscript>` fallback
20
+ * link. A `sitemap-index.xml` link covers no-JS navigation —
21
+ * note `emitSitemapFiles` only runs when `site.url` is a valid
22
+ * http(s) URL, so a site without one has no sitemap for this
23
+ * fallback to reach.
24
+ * - First paint waits for the JS bundle + the JSON fetch. On a 4G
25
+ * connection that's typically <200 ms; the skeleton fills the
26
+ * space until then.
27
+ *
28
+ * See plans/client-rendered-nav.md.
29
+ */
30
+ interface Props {
31
+ /** Current page's URL pathname; the script uses it to mark the active item. */
32
+ currentPath: string;
33
+ /**
34
+ * URL prefix the host serves under (combined `urlBase` + `basePath`).
35
+ * The script joins this with `/_dogsbay/nav.json` to locate the
36
+ * fetchable nav tree; also threaded to the version/locale filter
37
+ * so multi-axis sites work the same as SSR.
38
+ */
39
+ basePath?: string;
40
+ /** Current source's product/namespace, if multi-product site. */
41
+ namespace?: string;
42
+ /** Current source's version axis value, if multi-version site. */
43
+ version?: string;
44
+ /** Current source's locale axis value, if multi-locale site. */
45
+ locale?: string;
46
+ }
47
+
48
+ const { currentPath, basePath = "", namespace, version, locale } = Astro.props;
49
+ const navUrl = `${basePath}/_dogsbay/nav.json`;
50
+ ---
51
+
52
+ {/*
53
+ `role="navigation"` is REQUIRED here, not decoration: `aria-label` and
54
+ `aria-busy` are prohibited on a generic div (axe: aria-prohibited-attr,
55
+ serious) because a role-less element has no accessible name to label.
56
+ Giving the nav container its real role makes both attributes legal and
57
+ makes the landmark discoverable — it was previously neither.
58
+ */}
59
+ <div
60
+ id="docs-nav-root"
61
+ role="navigation"
62
+ data-nav-url={navUrl}
63
+ data-current-path={currentPath}
64
+ data-base-path={basePath}
65
+ data-namespace={namespace ?? ""}
66
+ data-version={version ?? ""}
67
+ data-locale={locale ?? ""}
68
+ aria-busy="true"
69
+ aria-label="Documentation navigation"
70
+ >
71
+ <ul class="flex min-w-0 flex-col" data-sidebar="nav-tree" data-level="0">
72
+ {[0, 1, 2, 3, 4, 5].map((i) => (
73
+ <li>
74
+ <div
75
+ class="flex h-8 w-full items-center gap-2 rounded-md px-2"
76
+ aria-hidden="true"
77
+ >
78
+ <div class="h-2 w-2 rounded-full bg-sidebar-foreground/10" />
79
+ <div
80
+ class="h-2 rounded bg-sidebar-foreground/10"
81
+ style={`width: ${50 + ((i * 17) % 35)}%;`}
82
+ />
83
+ </div>
84
+ </li>
85
+ ))}
86
+ </ul>
87
+ <noscript>
88
+ <p class="px-2 py-1.5 text-sm text-sidebar-foreground/70">
89
+ JavaScript is required to render the sidebar. Use the
90
+ {/* sitemap-index.xml, not sitemap.xml — Dogsbay emits the
91
+ sitemap-index / sitemap-0 pair directly (see emitSitemapFiles);
92
+ `sitemap.xml` has never existed, so this no-JS fallback link
93
+ 404'd on every site. */}
94
+ <a href={`${basePath}/sitemap-index.xml`} class="underline">sitemap</a>
95
+ to browse all pages.
96
+ </p>
97
+ </noscript>
98
+ </div>
99
+
100
+ <script>
101
+ // Single import — Astro/Vite bundles this into one shared chunk
102
+ // referenced from every page that uses DocsNavClient. Browsers
103
+ // cache the chunk, so the nav script ships once per session
104
+ // regardless of how many pages the user visits.
105
+ import { hydrateDocsNav } from "./docs-nav-client.ts";
106
+ hydrateDocsNav();
107
+ </script>
package/src/DocsToc.astro CHANGED
@@ -33,7 +33,7 @@ const filtered = headings.filter(h => h.depth >= minDepth && h.depth <= maxDepth
33
33
 
34
34
  {filtered.length > 0 && (
35
35
  <nav class:list={["text-sm", className]} aria-label="Table of contents">
36
- <div class="text-xs font-semibold uppercase text-muted-foreground">{title}</div>
36
+ {title && <div class="text-xs font-semibold uppercase text-muted-foreground">{title}</div>}
37
37
  <ul class="mt-2 space-y-1">
38
38
  {filtered.map(h => (
39
39
  <li>
@@ -28,10 +28,26 @@ import type { TaxonomyDisplay } from "@dogsbay/types";
28
28
 
29
29
  interface Props {
30
30
  /**
31
- * Path prefix where Pagefind's index lives. Default: "/pagefind/".
32
- * Override when a site uses a non-root base path (e.g. "/docs/pagefind/").
31
+ * Path where Pagefind's index lives, e.g. `/pagefind/` or
32
+ * `/<repo>/pagefind/` for subpath-mounted deploys. NO DEFAULT
33
+ * a host-root default would silently 404 on subpath deploys
34
+ * (GH Pages project pages, multi-mount Cloudflare). The
35
+ * format-astro emitter passes the combined-prefix-aware URL;
36
+ * manual instantiations must do the same. When undefined, the
37
+ * search dialog throws on first open with a clear console error.
33
38
  */
34
39
  pagefindUrl?: string;
40
+ /**
41
+ * URL of the site's `nav.json` (typically `${basePath}/_dogsbay/nav.json`).
42
+ * Used to render hierarchical facets in document order: when the
43
+ * `category` facet (or any taxonomy flagged `hierarchical: true`)
44
+ * is segment-encoded — i.e. its values are individual path segments
45
+ * not slash-joined — the tree shape is derived from nav. Without
46
+ * `navUrl`, hierarchical segment-encoded facets fall back to a flat
47
+ * list (still functional, just no tree structure). Slash-encoded
48
+ * taxonomies don't need nav and build trees from their values directly.
49
+ */
50
+ navUrl?: string;
35
51
  /** Placeholder text for the search input */
36
52
  placeholder?: string;
37
53
  /**
@@ -41,19 +57,33 @@ interface Props {
41
57
  * back to slugs when undefined.
42
58
  */
43
59
  taxonomyDisplay?: Record<string, TaxonomyDisplay>;
60
+ /**
61
+ * Current page's product (namespace) and version. On a multi-product /
62
+ * versioned site, search opens PRE-SCOPED to these — a Calico 3.32 page
63
+ * searches Calico 3.32 by default. The scope is seeded as normal facet
64
+ * selections, so the reader can untick them to search wider.
65
+ */
66
+ scopeProduct?: string;
67
+ scopeVersion?: string;
44
68
  }
45
69
 
46
70
  const {
47
- pagefindUrl = "/pagefind/",
71
+ pagefindUrl,
72
+ navUrl,
48
73
  placeholder = "Search docs...",
49
74
  taxonomyDisplay,
75
+ scopeProduct,
76
+ scopeVersion,
50
77
  } = Astro.props;
51
78
  ---
52
79
 
53
80
  <dialog
54
81
  data-search-dialog
55
82
  data-pagefind-url={pagefindUrl}
83
+ data-nav-url={navUrl}
56
84
  data-taxonomy-display={taxonomyDisplay ? JSON.stringify(taxonomyDisplay) : ""}
85
+ data-scope-product={scopeProduct ?? ""}
86
+ data-scope-version={scopeVersion ?? ""}
57
87
  class="fixed left-1/2 top-[10vh] z-50 w-[calc(100vw-2rem)] max-w-4xl -translate-x-1/2 rounded-xl border border-border bg-popover p-0 text-popover-foreground shadow-2xl backdrop:bg-black/40 backdrop:backdrop-blur-sm"
58
88
  >
59
89
  <form method="dialog" class="flex flex-col">
@@ -156,13 +186,19 @@ const {
156
186
  shapeFacets,
157
187
  resolveFacetLabel,
158
188
  resolveFacetTitle,
189
+ sortFacetNames,
159
190
  filterStateToUrlParams,
160
191
  parseFiltersFromUrl,
161
192
  filterStateToPagefindFilters,
162
193
  toggleFilter,
163
194
  countActiveFilters,
195
+ buildFacetTree,
196
+ computeTreeState,
197
+ toggleTreeNode,
164
198
  type FacetMap,
199
+ type FacetTreeNode,
165
200
  type FilterState,
201
+ type NavLike,
166
202
  type TaxonomyDisplayMap,
167
203
  } from "./search-facets.js";
168
204
 
@@ -177,15 +213,23 @@ const {
177
213
  meta: { title?: string };
178
214
  sub_results?: Array<{ title: string; url: string; excerpt: string }>;
179
215
  };
216
+ // Filter values match what filterStateToPagefindFilters emits:
217
+ // each facet wrapped in `{any: [...]}` for OR-within-facet semantics.
218
+ // Pagefind also accepts other operator shapes (`all`/`none`/`not`,
219
+ // bare strings, bare arrays) but we only emit the `any` form.
220
+ type PagefindFilterValue = { any: string[] };
180
221
  type PagefindModule = {
181
222
  search(
182
223
  query: string,
183
- options?: { filters?: Record<string, string[]> },
224
+ options?: { filters?: Record<string, PagefindFilterValue> },
184
225
  ): Promise<{ results: PagefindResult[] }>;
185
226
  filters(): Promise<Record<string, Record<string, number>>>;
186
227
  };
187
228
 
188
229
  const dialog = document.querySelector<HTMLDialogElement>("[data-search-dialog]");
230
+ // Current page's product/version — search opens pre-scoped to these.
231
+ const scopeProduct = dialog?.dataset.scopeProduct || "";
232
+ const scopeVersion = dialog?.dataset.scopeVersion || "";
189
233
  const trigger = document.querySelector<HTMLButtonElement>("[data-search-trigger]");
190
234
  const input = dialog?.querySelector<HTMLInputElement>("[data-search-input]");
191
235
  const resultsBox = dialog?.querySelector<HTMLDivElement>("[data-search-results]");
@@ -204,6 +248,15 @@ const {
204
248
  let filters: FilterState = {};
205
249
  let availableFacets: FacetMap = {};
206
250
 
251
+ // Nav data cached after first fetch — used to derive the
252
+ // segment-encoded hierarchical-facet tree (the auto-`category`
253
+ // case). Null while pending or absent. The fetch fires lazily
254
+ // the first time renderFacets() encounters a hierarchical facet,
255
+ // not eagerly on dialog open, so sites without hierarchical
256
+ // facets never pay for it.
257
+ let navData: NavLike[] | null = null;
258
+ let loadingNav: Promise<void> | null = null;
259
+
207
260
  // Display config baked into a data attribute by the Astro
208
261
  // template — parsed lazily.
209
262
  const taxonomyDisplay: TaxonomyDisplayMap = (() => {
@@ -218,7 +271,22 @@ const {
218
271
  async function ensurePagefindLoaded() {
219
272
  if (pagefind) return;
220
273
  if (loadingPagefind) return loadingPagefind;
221
- const url = (dialog!.dataset.pagefindUrl ?? "/pagefind/") + "pagefind.js";
274
+ // pagefindUrl is required a "/pagefind/" fallback would
275
+ // silently 404 on subpath-mounted deploys (GH Pages project
276
+ // pages, multi-mount Cloudflare). The emitter always passes
277
+ // a combined-prefix-aware value via data-pagefind-url. If it's
278
+ // missing the page wasn't built through format-astro and the
279
+ // caller forgot to pass it.
280
+ const dataUrl = dialog!.dataset.pagefindUrl;
281
+ if (!dataUrl) {
282
+ console.error(
283
+ "[dogsbay] SearchDialog: pagefindUrl prop missing. " +
284
+ "Pass the combined-prefix path (e.g. '/<base>/pagefind/') " +
285
+ "from your DocsLayout instantiation.",
286
+ );
287
+ return;
288
+ }
289
+ const url = dataUrl + "pagefind.js";
222
290
  loadingPagefind = (async () => {
223
291
  try {
224
292
  const mod = (await import(/* @vite-ignore */ url)) as PagefindModule;
@@ -237,6 +305,49 @@ const {
237
305
  return loadingPagefind;
238
306
  }
239
307
 
308
+ /**
309
+ * Lazy nav.json fetch, kicked off the first time a hierarchical
310
+ * facet is rendered. Mirrors the pattern in `docs-nav-client.ts` —
311
+ * one fetch per session, `same-origin` credentials so cookied
312
+ * mounts work, module-level cache.
313
+ *
314
+ * When the fetch resolves we re-call `renderFacets()` so the
315
+ * previously-flat segment-encoded facet upgrades to its tree
316
+ * shape — without this, a slow nav fetch would leave the facets
317
+ * stuck on the flat fallback until the user toggled a filter.
318
+ *
319
+ * Network errors are logged and swallowed; the helper's flat
320
+ * fallback keeps the dialog functional.
321
+ */
322
+ async function ensureNavLoaded() {
323
+ if (navData !== null) return;
324
+ if (loadingNav) return loadingNav;
325
+ const url = dialog!.dataset.navUrl;
326
+ if (!url) return; // navUrl not provided — flat fallback stays
327
+ loadingNav = (async () => {
328
+ try {
329
+ const res = await fetch(url, { credentials: "same-origin" });
330
+ if (!res.ok) throw new Error(`nav.json fetch failed: ${res.status}`);
331
+ const data = (await res.json()) as unknown;
332
+ navData = Array.isArray(data) ? (data as NavLike[]) : [];
333
+ // Re-render so hierarchical segment-encoded facets pick up
334
+ // the nav shape. Cheap — no Pagefind round-trip, just a
335
+ // DOM rebuild from `availableFacets`.
336
+ if (Object.keys(availableFacets).length > 0) {
337
+ renderFacets();
338
+ }
339
+ } catch (err) {
340
+ console.warn(
341
+ "[dogsbay] failed to load nav.json (hierarchical facets fall back to flat list):",
342
+ err,
343
+ );
344
+ // Mark as loaded-with-empty so we don't keep retrying.
345
+ navData = [];
346
+ }
347
+ })();
348
+ return loadingNav;
349
+ }
350
+
240
351
  function escapeHtml(s: string): string {
241
352
  return s
242
353
  .replace(/&/g, "&amp;")
@@ -313,54 +424,171 @@ const {
313
424
  resultsBox!.innerHTML = html;
314
425
  }
315
426
 
427
+ /**
428
+ * Hierarchical-facet caches, rebuilt on every renderFacets() so
429
+ * they stay aligned with the current filter state + available
430
+ * facets. The trees are the source for `value → node` lookups
431
+ * during click handling (parent click expands selection to all
432
+ * descendants — needs the node to know what to add).
433
+ */
434
+ const facetTrees = new Map<string, FacetTreeNode[]>();
435
+ const facetNodesByValue = new Map<string, Map<string, FacetTreeNode>>();
436
+
437
+ /**
438
+ * Decide whether a facet renders as a tree. `category` defaults
439
+ * to hierarchical (auto-derived path segments are the canonical
440
+ * use case); explicit override via `taxonomyDisplay[name].hierarchical`
441
+ * wins both ways.
442
+ */
443
+ function isHierarchicalFacet(name: string): boolean {
444
+ const flag = taxonomyDisplay[name]?.hierarchical;
445
+ if (typeof flag === "boolean") return flag;
446
+ return name === "category";
447
+ }
448
+
449
+ /**
450
+ * Flatten a tree into a `value → node` map so the click handler
451
+ * can look up the clicked node without re-walking the tree.
452
+ */
453
+ function indexTree(nodes: FacetTreeNode[]): Map<string, FacetTreeNode> {
454
+ const out = new Map<string, FacetTreeNode>();
455
+ const visit = (n: FacetTreeNode): void => {
456
+ out.set(n.value, n);
457
+ for (const c of n.children) visit(c);
458
+ };
459
+ for (const r of nodes) visit(r);
460
+ return out;
461
+ }
462
+
463
+ /**
464
+ * Recursive HTML for one tree node. Synthetic parents (`hasValue:
465
+ * false`) render as a clickable group header — checking them
466
+ * still cascades to descendants via toggleTreeNode. Indent scales
467
+ * with `node.depth`.
468
+ */
469
+ function renderTreeNode(name: string, node: FacetTreeNode): string {
470
+ const state = computeTreeState(node, name, filters);
471
+ const id = `facet-${name}-${node.value}`.replace(/[^a-z0-9-]/gi, "-");
472
+ const indent = node.depth * 12;
473
+ const label = escapeHtml(node.label);
474
+ const countText = node.hasValue ? `${node.count}` : "";
475
+ const stateAttr =
476
+ state === "checked" ? "checked" : state === "indeterminate" ? 'data-tree-indeterminate="true"' : "";
477
+ const childrenHtml = node.children
478
+ .map((c) => renderTreeNode(name, c))
479
+ .join("");
480
+ return `
481
+ <li>
482
+ <label
483
+ for="${id}"
484
+ class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1 hover:bg-accent"
485
+ style="padding-left: ${0.5 + indent / 16}rem"
486
+ >
487
+ <input
488
+ type="checkbox"
489
+ id="${id}"
490
+ data-facet-name="${escapeHtml(name)}"
491
+ data-facet-value="${escapeHtml(node.value)}"
492
+ data-tree-node="true"
493
+ ${state === "checked" ? "checked" : ""}
494
+ ${state === "indeterminate" ? 'data-tree-indeterminate="true"' : ""}
495
+ class="size-3.5 rounded border-border accent-primary"
496
+ />
497
+ <span class="flex-1 truncate">${label}</span>
498
+ <span class="text-xs text-muted-foreground">${countText}</span>
499
+ </label>
500
+ ${childrenHtml ? `<ul class="space-y-0.5">${childrenHtml}</ul>` : ""}
501
+ </li>
502
+ `;
503
+ }
504
+
316
505
  /**
317
506
  * Build the facets sidebar. Runs once after Pagefind discovers
318
507
  * filters, then again whenever filter state changes (so checkbox
319
508
  * `checked` reflects current selections). When the corpus has no
320
509
  * filters, the sidebar stays hidden — single-column layout.
510
+ *
511
+ * Branches per facet: hierarchical taxonomies (and the built-in
512
+ * `category` default) render as a tree; flat taxonomies keep the
513
+ * original checkbox-list shape. The hierarchical path falls back
514
+ * to a flat list automatically when segment-encoded values lack a
515
+ * `nav` source — preserves render-ability until Phase 3 wires the
516
+ * nav.json fetch.
321
517
  */
322
518
  function renderFacets() {
323
- const facetNames = Object.keys(availableFacets);
519
+ const facetNames = sortFacetNames(
520
+ Object.keys(availableFacets),
521
+ taxonomyDisplay,
522
+ );
324
523
  if (facetNames.length === 0) {
325
524
  facetsBox!.classList.add("hidden");
326
525
  return;
327
526
  }
328
527
  facetsBox!.classList.remove("hidden");
329
528
 
529
+ facetTrees.clear();
530
+ facetNodesByValue.clear();
531
+
330
532
  const activeCount = countActiveFilters(filters);
331
533
  const clearAll = activeCount > 0
332
534
  ? `<button type="button" data-clear-filters class="mb-3 w-full rounded-md border border-border px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-foreground">Clear all (${activeCount})</button>`
333
535
  : "";
334
536
 
537
+ let needsNav = false;
335
538
  const groups = facetNames
336
539
  .map((name) => {
337
540
  const entries = availableFacets[name];
338
541
  const title = escapeHtml(resolveFacetTitle(name));
339
- const items = entries
340
- .map((entry) => {
341
- const checked = (filters[name] ?? []).includes(entry.value);
342
- const label = escapeHtml(
343
- resolveFacetLabel(name, entry.value, taxonomyDisplay),
344
- );
345
- const id = `facet-${name}-${entry.value}`.replace(/[^a-z0-9-]/gi, "-");
346
- return `
347
- <li>
348
- <label for="${id}" class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1 hover:bg-accent">
349
- <input
350
- type="checkbox"
351
- id="${id}"
352
- data-facet-name="${escapeHtml(name)}"
353
- data-facet-value="${escapeHtml(entry.value)}"
354
- ${checked ? "checked" : ""}
355
- class="size-3.5 rounded border-border accent-primary"
356
- />
357
- <span class="flex-1 truncate">${label}</span>
358
- <span class="text-xs text-muted-foreground">${entry.count}</span>
359
- </label>
360
- </li>
361
- `;
362
- })
363
- .join("");
542
+ const hierarchical = isHierarchicalFacet(name);
543
+
544
+ let items: string;
545
+ if (hierarchical) {
546
+ // Every hierarchical facet wants nav.json — slash-encoded
547
+ // for sort order at each depth (so docs follow nav rather
548
+ // than count-desc), segment-encoded for the tree structure
549
+ // itself. Without nav, slash-encoded still renders a tree
550
+ // (using values alone) but in count-desc order, which is
551
+ // wrong on corpora where one branch dwarfs the rest
552
+ // (openshift's rest_api section). Fire ensureNavLoaded()
553
+ // any time a hierarchical facet renders with navData still
554
+ // null — when it resolves, renderFacets re-runs and
555
+ // upgrades the sort.
556
+ if (navData === null) needsNav = true;
557
+ const tree = buildFacetTree(name, entries, {
558
+ display: taxonomyDisplay,
559
+ nav: navData ?? undefined,
560
+ });
561
+ facetTrees.set(name, tree);
562
+ facetNodesByValue.set(name, indexTree(tree));
563
+ items = tree.map((n) => renderTreeNode(name, n)).join("");
564
+ } else {
565
+ items = entries
566
+ .map((entry) => {
567
+ const checked = (filters[name] ?? []).includes(entry.value);
568
+ const label = escapeHtml(
569
+ resolveFacetLabel(name, entry.value, taxonomyDisplay),
570
+ );
571
+ const id = `facet-${name}-${entry.value}`.replace(/[^a-z0-9-]/gi, "-");
572
+ return `
573
+ <li>
574
+ <label for="${id}" class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1 hover:bg-accent">
575
+ <input
576
+ type="checkbox"
577
+ id="${id}"
578
+ data-facet-name="${escapeHtml(name)}"
579
+ data-facet-value="${escapeHtml(entry.value)}"
580
+ ${checked ? "checked" : ""}
581
+ class="size-3.5 rounded border-border accent-primary"
582
+ />
583
+ <span class="flex-1 truncate">${label}</span>
584
+ <span class="text-xs text-muted-foreground">${entry.count}</span>
585
+ </label>
586
+ </li>
587
+ `;
588
+ })
589
+ .join("");
590
+ }
591
+
364
592
  return `
365
593
  <fieldset class="mb-3">
366
594
  <legend class="mb-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground">${title}</legend>
@@ -371,6 +599,20 @@ const {
371
599
  .join("");
372
600
 
373
601
  facetsBox!.innerHTML = clearAll + groups;
602
+
603
+ // `indeterminate` is a JS property, not an HTML attribute —
604
+ // can't set via innerHTML. Walk new checkboxes and set it
605
+ // post-paint so the visual tri-state matches state.
606
+ const indet = facetsBox!.querySelectorAll<HTMLInputElement>(
607
+ 'input[type="checkbox"][data-tree-indeterminate="true"]',
608
+ );
609
+ for (const el of Array.from(indet)) el.indeterminate = true;
610
+
611
+ // Lazy nav fetch — fired only when at least one hierarchical
612
+ // segment-encoded facet rendered with the flat fallback. The
613
+ // fetch's resolution handler calls renderFacets() again so the
614
+ // facet upgrades to its real tree shape without user action.
615
+ if (needsNav) void ensureNavLoaded();
374
616
  }
375
617
 
376
618
  async function runSearch(query: string) {
@@ -425,9 +667,22 @@ const {
425
667
  const fromUrl = parseFiltersFromUrl(new URLSearchParams(window.location.search));
426
668
  input!.value = fromUrl.query;
427
669
  filters = fromUrl.filters;
670
+ const hadUrlState = fromUrl.query.length > 0 || countActiveFilters(filters) > 0;
671
+ // Auto-scope: a FRESH open (no filters/query carried in the URL) on a
672
+ // multi-product/versioned site starts scoped to the CURRENT product +
673
+ // version. Seeded as ordinary facet selections, so the reader can
674
+ // untick "Product: calico" / "Version: 3.32" to search wider. A URL
675
+ // that already carries state wins (shared/roundtripped searches).
676
+ if (!hadUrlState) {
677
+ if (scopeProduct) filters.product = [scopeProduct];
678
+ if (scopeVersion) filters.version = [scopeVersion];
679
+ }
428
680
  renderFacets();
429
681
 
430
- const hasInitial = input!.value.length > 0 || countActiveFilters(filters) > 0;
682
+ // Run immediately only when there's a query or the state came from the
683
+ // URL. A fresh open shows the empty prompt with the scope pre-ticked —
684
+ // results appear (scoped) as soon as the reader types.
685
+ const hasInitial = hadUrlState;
431
686
  if (hasInitial) {
432
687
  runSearch(input!.value);
433
688
  } else {
@@ -475,13 +730,26 @@ const {
475
730
  });
476
731
 
477
732
  // Facet checkbox toggling — event delegation on the sidebar.
733
+ // Tree nodes route through toggleTreeNode (parent click expands
734
+ // to all descendants); flat checkboxes use toggleFilter as before.
735
+ // We compute state from the pre-click filter, NOT from the
736
+ // checkbox's post-click `checked` value — the browser has already
737
+ // flipped it by the time `change` fires, so reading it would
738
+ // invert our toggle direction for indeterminate parents.
478
739
  facetsBox.addEventListener("change", (e) => {
479
740
  const target = e.target as HTMLInputElement;
480
741
  if (target.tagName !== "INPUT" || target.type !== "checkbox") return;
481
742
  const name = target.dataset.facetName;
482
743
  const value = target.dataset.facetValue;
483
744
  if (!name || !value) return;
484
- filters = toggleFilter(filters, name, value);
745
+ if (target.dataset.treeNode === "true") {
746
+ const node = facetNodesByValue.get(name)?.get(value);
747
+ if (!node) return;
748
+ const currentState = computeTreeState(node, name, filters);
749
+ filters = toggleTreeNode(filters, name, node, currentState);
750
+ } else {
751
+ filters = toggleFilter(filters, name, value);
752
+ }
485
753
  renderFacets();
486
754
  runSearch(input!.value);
487
755
  syncUrl();
package/src/TagList.astro CHANGED
@@ -72,8 +72,13 @@ const chips = buildChips(tags, { indexPath, prefixes, labels });
72
72
  */
73
73
  const PALETTE: Record<TagPaletteName, string> = {
74
74
  blue: "border-blue-500/40 bg-blue-500/15 text-blue-700 dark:text-blue-300",
75
+ // amber-800, not -700, in light mode: amber-700 on amber-500/15 is
76
+ // 4.48:1 — under 4.5:1 by a hair, and failing regardless of the
77
+ // opacity bug below. -800 gives 6.32:1. Every other colour clears it
78
+ // at -700 (4.76–8.60:1). Dark mode is unaffected: text-*-300 on the
79
+ // same tint over a dark page is 9–12:1 across the palette.
75
80
  amber:
76
- "border-amber-500/40 bg-amber-500/15 text-amber-700 dark:text-amber-300",
81
+ "border-amber-500/40 bg-amber-500/15 text-amber-800 dark:text-amber-300",
77
82
  emerald:
78
83
  "border-emerald-500/40 bg-emerald-500/15 text-emerald-700 dark:text-emerald-300",
79
84
  violet:
@@ -108,7 +113,17 @@ const DEFAULT_CLASS =
108
113
  >
109
114
  {chip.prefixLabel ? (
110
115
  <Fragment>
111
- <span class="opacity-70">{chip.prefixLabel}:</span>
116
+ {/*
117
+ No opacity here. `opacity-70` on the prefix blended the
118
+ text 30% toward the chip background and put EVERY colour
119
+ in the palette below 4.5:1 — measured 2.75–3.95:1, worst
120
+ amber, best slate. It is 12px text, so 4.5:1 applies.
121
+
122
+ The prefix/leaf distinction is carried by font-weight
123
+ instead: the leaf below is font-semibold against the
124
+ anchor's font-medium. Weight costs no contrast.
125
+ */}
126
+ <span>{chip.prefixLabel}:</span>
112
127
  <span class="font-semibold">{chip.leafLabel}</span>
113
128
  </Fragment>
114
129
  ) : (
@@ -37,6 +37,9 @@ const currentLabel = currentRow?.entry.label ?? currentRow?.entry.id ?? "Version
37
37
  {currentRow?.entry.eol && (
38
38
  <span class="ml-1 rounded bg-muted px-1 text-[10px] uppercase text-muted-foreground">EOL</span>
39
39
  )}
40
+ {currentRow?.entry.prerelease && (
41
+ <span class="ml-1 rounded bg-muted px-1 text-[10px] uppercase text-muted-foreground">Pre</span>
42
+ )}
40
43
  <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="ml-1 transition-transform"><polyline points="6 9 12 15 18 9"/></svg>
41
44
  </summary>
42
45
  <ul class="absolute right-0 z-50 mt-1 min-w-[10rem] rounded-md border border-border bg-popover p-1 text-sm shadow-md">
@@ -59,6 +62,9 @@ const currentLabel = currentRow?.entry.label ?? currentRow?.entry.id ?? "Version
59
62
  {row.entry.eol && (
60
63
  <span class="rounded bg-muted px-1 text-[10px] uppercase text-muted-foreground">EOL</span>
61
64
  )}
65
+ {row.entry.prerelease && (
66
+ <span class="rounded bg-muted px-1 text-[10px] uppercase text-muted-foreground">Pre</span>
67
+ )}
62
68
  {row.entry.default && !row.isCurrent && (
63
69
  <span class="text-[10px] text-muted-foreground">default</span>
64
70
  )}