@takazudo/zudo-doc 1.0.2 → 1.2.0

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,222 @@
1
+ "use client";
2
+
3
+ /** @jsxRuntime automatic */
4
+ /** @jsxImportSource preact */
5
+ // Use preact hook entrypoints directly — the "react" → "preact/compat" alias
6
+ // lets us consume React-typed components in this Preact app.
7
+ import { useState } from "preact/hooks";
8
+ import type { SidebarNavNode } from "../sidebar/types.js";
9
+ import { INDENT, connectorLeft, ConnectorLines, CategoryLinkIcon } from "../tree-nav-shared/index.js";
10
+ import { ChevronRight } from "../icons/index.js";
11
+
12
+ // site-tree-nav uses wider padding than the narrow sidebar
13
+ const SITE_BASE_PAD = "clamp(0.5rem, 0.8vw, 1rem)";
14
+
15
+ function padLeft(depth: number): string {
16
+ if (depth === 0) return SITE_BASE_PAD;
17
+ return `calc(${depth} * ${INDENT} + 1.25rem + 5px)`;
18
+ }
19
+
20
+ function reorderTree(tree: SidebarNavNode[], order: string[]): SidebarNavNode[] {
21
+ const map = new Map(tree.map((node) => [node.slug, node]));
22
+ const ordered: SidebarNavNode[] = [];
23
+ for (const slug of order) {
24
+ const node = map.get(slug);
25
+ if (node) {
26
+ ordered.push(node);
27
+ map.delete(slug);
28
+ }
29
+ }
30
+ // append unmatched nodes at end
31
+ for (const node of map.values()) {
32
+ ordered.push(node);
33
+ }
34
+ return ordered;
35
+ }
36
+
37
+ export interface SiteTreeNavProps {
38
+ tree: SidebarNavNode[];
39
+ ariaLabel?: string;
40
+ categoryOrder?: string[];
41
+ categoryIgnore?: string[];
42
+ }
43
+
44
+ export function SiteTreeNav({
45
+ tree,
46
+ ariaLabel = "Site index",
47
+ categoryOrder,
48
+ categoryIgnore,
49
+ }: SiteTreeNavProps) {
50
+ let processedTree = tree;
51
+ if (categoryIgnore) {
52
+ const ignoreSet = new Set(categoryIgnore);
53
+ processedTree = processedTree.filter((node) => !ignoreSet.has(node.slug));
54
+ }
55
+ if (categoryOrder) {
56
+ processedTree = reorderTree(processedTree, categoryOrder);
57
+ }
58
+ return (
59
+ <nav
60
+ aria-label={ariaLabel}
61
+ data-site-nav
62
+ className="grid gap-vsp-md"
63
+ style={{
64
+ gridTemplateColumns: "repeat(auto-fill, minmax(min(18rem, 100%), 1fr))",
65
+ }}
66
+ >
67
+ {processedTree.map((node) => (
68
+ <div key={node.slug} className="min-w-0 border border-muted pl-hsp-sm py-vsp-2xs">
69
+ {node.children.length > 0 ? (
70
+ <CategoryNode node={node} depth={0} isLast={true} />
71
+ ) : (
72
+ <LeafNode node={node} depth={0} isLast={true} />
73
+ )}
74
+ </div>
75
+ ))}
76
+ </nav>
77
+ );
78
+ }
79
+ SiteTreeNav.displayName = "SiteTreeNav";
80
+
81
+ function NodeList({ nodes, depth }: { nodes: SidebarNavNode[]; depth: number }) {
82
+ return (
83
+ <>
84
+ {nodes.map((node, index) => {
85
+ const isLast = index === nodes.length - 1;
86
+ return node.children.length > 0 ? (
87
+ <CategoryNode
88
+ key={node.slug}
89
+ node={node}
90
+ depth={depth}
91
+ isLast={isLast}
92
+ />
93
+ ) : (
94
+ <LeafNode
95
+ key={node.slug}
96
+ node={node}
97
+ depth={depth}
98
+ isLast={isLast}
99
+ />
100
+ );
101
+ })}
102
+ </>
103
+ );
104
+ }
105
+
106
+ function CategoryNode({
107
+ node,
108
+ depth,
109
+ isLast,
110
+ }: {
111
+ node: SidebarNavNode;
112
+ depth: number;
113
+ isLast: boolean;
114
+ }) {
115
+ const [open, setOpen] = useState(true);
116
+ const toggle = () => setOpen((prev) => !prev);
117
+ const paddingLeft = padLeft(depth);
118
+
119
+ return (
120
+ <div className={`${depth >= 1 && !isLast ? "relative" : ""}`}>
121
+ {depth >= 1 && !isLast && open && (
122
+ <div
123
+ className="absolute border-l border-dashed border-muted z-local-1"
124
+ style={{
125
+ left: connectorLeft(depth),
126
+ top: 0,
127
+ bottom: 0,
128
+ }}
129
+ />
130
+ )}
131
+ <div className="relative">
132
+ <ConnectorLines
133
+ depth={depth}
134
+ isLast={isLast}
135
+ widthScale={2}
136
+ topPad="calc(0.15rem + var(--spacing-vsp-xs))"
137
+ />
138
+ <div
139
+ className="flex w-full items-center justify-between text-small font-semibold pt-[0.15rem] text-fg"
140
+ style={{ paddingLeft }}
141
+ >
142
+ {node.href ? (
143
+ <a
144
+ href={node.href}
145
+ className="flex-1 flex items-start gap-hsp-xs py-vsp-xs text-fg hover:text-accent hover:underline focus:underline"
146
+ >
147
+ {depth === 0 && (
148
+ <span className="flex h-[1lh] items-center">
149
+ <CategoryLinkIcon className="w-[18px] 2xl:w-[24px]" />
150
+ </span>
151
+ )}
152
+ {node.label}
153
+ </a>
154
+ ) : (
155
+ <button
156
+ type="button"
157
+ onClick={toggle}
158
+ className="flex-1 py-vsp-xs text-left hover:text-accent hover:underline focus:underline"
159
+ >
160
+ {node.label}
161
+ </button>
162
+ )}
163
+ <button
164
+ type="button"
165
+ onClick={toggle}
166
+ className="aspect-square flex items-center justify-center w-[1.75rem] border-y border-l border-muted hover:underline focus:underline"
167
+ aria-expanded={open}
168
+ aria-label={open ? `Collapse ${node.label}` : `Expand ${node.label}`}
169
+ >
170
+ <ChevronRight className={`h-icon-xs w-icon-xs transition-transform duration-150 ${open ? "rotate-90" : ""} text-muted`} />
171
+ </button>
172
+ </div>
173
+ </div>
174
+ {open && (
175
+ <div>
176
+ <NodeList nodes={node.children} depth={depth + 1} />
177
+ </div>
178
+ )}
179
+ </div>
180
+ );
181
+ }
182
+
183
+ function LeafNode({
184
+ node,
185
+ depth,
186
+ isLast,
187
+ }: {
188
+ node: SidebarNavNode;
189
+ depth: number;
190
+ isLast: boolean;
191
+ }) {
192
+ if (!node.href) return null;
193
+ const isRoot = depth === 0;
194
+ const paddingLeft = padLeft(depth);
195
+
196
+ const topPad = isRoot
197
+ ? "calc(var(--spacing-vsp-xs) + 0.15rem)"
198
+ : "var(--spacing-vsp-2xs)";
199
+
200
+ return (
201
+ <div>
202
+ <div className="relative">
203
+ <ConnectorLines depth={depth} isLast={isLast} widthScale={2} topPad={topPad} />
204
+ <a
205
+ href={node.href}
206
+ className={isRoot
207
+ ? "flex items-start gap-hsp-xs py-[calc(var(--spacing-vsp-xs)+0.15rem)] text-small font-semibold text-fg hover:text-accent hover:underline focus:underline"
208
+ : `block py-vsp-2xs ${isLast ? "pb-vsp-xs" : ""} text-small text-fg hover:text-accent hover:underline focus:underline`
209
+ }
210
+ style={{ paddingLeft }}
211
+ >
212
+ {isRoot && (
213
+ <span className="flex h-[1lh] items-center">
214
+ <CategoryLinkIcon className="w-[18px] 2xl:w-[24px]" />
215
+ </span>
216
+ )}
217
+ {node.label}
218
+ </a>
219
+ </div>
220
+ </div>
221
+ );
222
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@takazudo/zudo-doc",
3
- "version": "1.0.2",
3
+ "version": "1.2.0",
4
4
  "type": "module",
5
5
  "description": "zudo-doc framework primitives layer that sits on top of zfb's engine — sidebar, theme, TOC, breadcrumb, layouts, head injection, View Transitions, SSR-skip wrappers (per ADR-003).",
6
6
  "license": "MIT",
@@ -392,6 +392,10 @@
392
392
  "types": "./dist/doc-body-end/index.d.ts",
393
393
  "default": "./dist/doc-body-end/index.js"
394
394
  },
395
+ "./doc-body-end-islands": {
396
+ "types": "./dist/doc-body-end-islands/index.d.ts",
397
+ "default": "./dist/doc-body-end-islands/index.js"
398
+ },
395
399
  "./doc-history-area": {
396
400
  "types": "./dist/doc-history-area/index.d.ts",
397
401
  "default": "./dist/doc-history-area/index.js"
@@ -511,9 +515,9 @@
511
515
  ],
512
516
  "peerDependencies": {
513
517
  "preact": "^10.29.1",
514
- "@takazudo/zfb": "^0.1.0-next.65",
515
- "@takazudo/zfb-runtime": "^0.1.0-next.65",
516
- "@takazudo/zudo-doc-history-server": "^1.0.1",
518
+ "@takazudo/zfb": "^0.1.0-next.69",
519
+ "@takazudo/zfb-runtime": "^0.1.0-next.69",
520
+ "@takazudo/zudo-doc-history-server": "^1.1.0",
517
521
  "@takazudo/zdtp": "^0.3.3",
518
522
  "shiki": "^4.0.2",
519
523
  "zod": "^4.3.6",
@@ -555,8 +559,8 @@
555
559
  "typescript": "^5.0.0",
556
560
  "vitest": "^4.1.0",
557
561
  "zod": "^4.3.6",
558
- "@takazudo/zfb": "0.1.0-next.65",
559
- "@takazudo/zfb-runtime": "0.1.0-next.65"
562
+ "@takazudo/zfb": "0.1.0-next.69",
563
+ "@takazudo/zfb-runtime": "0.1.0-next.69"
560
564
  },
561
565
  "scripts": {
562
566
  "build": "tsup && tsc -p tsconfig.build.json",
@@ -10,7 +10,7 @@ import {
10
10
  HeadWithDefaults,
11
11
  HeaderWithDefaults,
12
12
  FooterWithDefaults,
13
- BodyEndIslandsStub,
13
+ BodyEndIslands,
14
14
  composeMetaTitle,
15
15
  } from "./_chrome.js";
16
16
 
@@ -31,7 +31,7 @@ export default function NotFoundPage(): JSX.Element {
31
31
  sidebarOverride={<></>}
32
32
  headerOverride={<HeaderWithDefaults lang={locale} />}
33
33
  footerOverride={<FooterWithDefaults lang={locale} />}
34
- bodyEndComponents={<BodyEndIslandsStub basePath={settings.base ?? "/"} />}
34
+ bodyEndComponents={<BodyEndIslands basePath={settings.base ?? "/"} />}
35
35
  enableClientRouter={settings.dynamicPageTransition}
36
36
  >
37
37
  <div class="min-h-[60vh] flex flex-col items-center justify-center px-hsp-2xl py-vsp-xl">
@@ -27,7 +27,7 @@
27
27
  // bindings — is A2's job (#2363). With `packageOwnedRoutes` off this module is
28
28
  // never evaluated; it only needs to typecheck and import.
29
29
 
30
- import type { JSX, VNode } from "preact";
30
+ import type { JSX, VNode, ComponentChildren } from "preact";
31
31
  import {
32
32
  settings,
33
33
  defaultLocale,
@@ -52,6 +52,7 @@ import {
52
52
  firstRoutedHref,
53
53
  collectTags,
54
54
  stableDocs,
55
+ colorSchemes,
55
56
  } from "./_context.js";
56
57
  import type { CategoryMeta, DocNavNode } from "./_docs-helpers.js";
57
58
 
@@ -67,6 +68,7 @@ import { createFooterWithDefaults } from "@takazudo/zudo-doc/footer-with-default
67
68
  import { createSidebarWithDefaults } from "@takazudo/zudo-doc/sidebar-with-defaults";
68
69
  import { createSidebarPrepaint } from "@takazudo/zudo-doc/sidebar-prepaint";
69
70
  import { createDocBodyEnd } from "@takazudo/zudo-doc/doc-body-end";
71
+ import { createBodyEndIslands } from "@takazudo/zudo-doc/doc-body-end-islands";
70
72
  import { createDocPager } from "@takazudo/zudo-doc/doc-pager";
71
73
  import { createDocPageShell } from "@takazudo/zudo-doc/doc-page-shell";
72
74
  import { createDocContentHeader } from "@takazudo/zudo-doc/doc-content-header";
@@ -92,6 +94,11 @@ import {
92
94
  getThemeDefaultMode as getThemeDefaultModeBase,
93
95
  } from "@takazudo/zudo-doc/nav-data-prep";
94
96
  import { buildSidebarForSection } from "@takazudo/zudo-doc/sidebar-utils";
97
+ import { Details } from "@takazudo/zudo-doc/details";
98
+ import {
99
+ HtmlPreviewWrapper,
100
+ type HtmlPreviewWrapperProps,
101
+ } from "@takazudo/zudo-doc/html-preview-wrapper";
95
102
 
96
103
  // ---------------------------------------------------------------------------
97
104
  // Package-default host-only bindings (see module header).
@@ -123,12 +130,12 @@ const docHistoryMeta: Record<string, never> = {};
123
130
  * tree. The project `sidebars` config is host-bound (A2). */
124
131
  const sidebarsConfig: Record<string, never> = {};
125
132
 
126
- /** Package no-op body-end islands. The host's `BodyEndIslands` wires project
127
- * island bootstraps (client-router / design-token-panel) that cannot live in
128
- * the package; the package default renders nothing. */
129
- function BodyEndIslandsStub(_props: { basePath: string }): JSX.Element {
130
- return <></>;
131
- }
133
+ /** Package-default body-end islands the package-island subset of the host's
134
+ * `BodyEndIslands` (AiChatModal / ImageEnlarge / MermaidEnlarge), reconstructed
135
+ * from the serializable virtual-module `settings` flags (#2406 / #2401(c)).
136
+ * The host-owned bootstraps (client-router / design-token-panel) cannot live
137
+ * in the package, so they are NOT included here — A2/S4 re-homes those. */
138
+ const BodyEndIslands = createBodyEndIslands({ settings });
132
139
 
133
140
  /** Package no-op DocHistory island stub — renders an empty fragment (the
134
141
  * `DocHistoryComponent` contract requires a VNode, not null). */
@@ -157,14 +164,38 @@ function SearchWidgetBound(props: {
157
164
 
158
165
  export const composeMetaTitle = createComposeMetaTitle(settings.siteName);
159
166
 
167
+ // Scheme-selection: resolve the active color scheme(s) from the host
168
+ // `colorSchemes` map supplied via the virtual module. Falls back to
169
+ // `DEFAULT_SCHEME` for any missing key or when `colorSchemes` is absent.
170
+ // `generateCssCustomProperties` is called in single-scheme mode
171
+ // (`settings.colorMode` is false); `generateLightDarkCssProperties` in
172
+ // light/dark mode. Both delegate to DEFAULT_SCHEME when `colorSchemes` is null.
173
+ function resolveHostScheme(key: string): ColorScheme {
174
+ if (!colorSchemes) return DEFAULT_SCHEME;
175
+ return colorSchemes[key] ?? DEFAULT_SCHEME;
176
+ }
177
+
160
178
  export const HeadWithDefaults = createHeadWithDefaults({
161
179
  settings,
162
180
  composeMetaTitle,
163
181
  withBase,
164
182
  absoluteUrl,
165
- generateCssCustomProperties: () => generateCssCustomProperties(DEFAULT_SCHEME),
166
- generateLightDarkCssProperties: () =>
167
- generateLightDarkCssProperties(DEFAULT_SCHEME, DEFAULT_SCHEME),
183
+ // Called only in single-scheme mode (colorMode false): resolve via settings.colorScheme.
184
+ generateCssCustomProperties: () =>
185
+ generateCssCustomProperties(resolveHostScheme(settings.colorScheme)),
186
+ // Called only in light/dark mode (colorMode truthy): resolve the pair.
187
+ generateLightDarkCssProperties: () => {
188
+ const cm = settings.colorMode;
189
+ if (cm) {
190
+ return generateLightDarkCssProperties(
191
+ resolveHostScheme(cm.lightScheme),
192
+ resolveHostScheme(cm.darkScheme),
193
+ );
194
+ }
195
+ // Should not be reached (head-with-defaults only calls this when colorMode
196
+ // is truthy), but guard the contract by falling back to DEFAULT_SCHEME.
197
+ return generateLightDarkCssProperties(DEFAULT_SCHEME, DEFAULT_SCHEME);
198
+ },
168
199
  });
169
200
 
170
201
  // ---------------------------------------------------------------------------
@@ -267,7 +298,7 @@ export const SidebarWithDefaults = createSidebarWithDefaults({
267
298
  });
268
299
 
269
300
  const SidebarPrepaint = createSidebarPrepaint({ sidebarToggle: settings.sidebarToggle });
270
- const DocBodyEnd = createDocBodyEnd({ settings, BodyEndIslands: BodyEndIslandsStub });
301
+ const DocBodyEnd = createDocBodyEnd({ settings, BodyEndIslands });
271
302
  const DocPager = createDocPager({ t });
272
303
 
273
304
  // ---------------------------------------------------------------------------
@@ -371,6 +402,49 @@ const SiteTreeNavWrapper = createSiteTreeNavWrapper({
371
402
  getCategoryOrder,
372
403
  });
373
404
 
405
+ /** HtmlPreview MDX binding (package default).
406
+ *
407
+ * Mirrors the host stub (`pages/_mdx-components.ts`): the global preview
408
+ * config is `settings.htmlPreview`, a SERIALIZABLE setting that DOES ride in
409
+ * the route-context virtual module, so the package can read it directly (no
410
+ * host config needed). `HtmlPreviewWrapper` wraps the bare preview in
411
+ * `<Island when="visible">`, which SSR-renders the preview tree — so an
412
+ * injected docs page using `<HtmlPreview html="…" />` renders correct HTML.
413
+ *
414
+ * Hydration caveat: the interactive code-toggle hydrates only if zfb's islands
415
+ * scanner reaches the `@takazudo/zfb` <Island> import inside
416
+ * `HtmlPreviewWrapper`. The esbuild islands bundler does NOT walk imports of
417
+ * modules whose realpath is under `node_modules` (the same gap the routes
418
+ * plugin stages around — see `plugins/routes.ts`; upstream report:
419
+ * Takazudo/zudo-front-builder, "islands bundler skips addVirtualModule /
420
+ * import-walk for node_modules route realpaths"). SSR output is unaffected. */
421
+ function HtmlPreviewBound(props: HtmlPreviewWrapperProps): JSX.Element {
422
+ return HtmlPreviewWrapper({
423
+ globalConfig: settings.htmlPreview ?? null,
424
+ ...props,
425
+ }) as JSX.Element;
426
+ }
427
+
428
+ /** Island MDX binding (package default) — an SSR pass-through that renders its
429
+ * children, deliberately NOT the real `@takazudo/zfb` <Island>.
430
+ *
431
+ * Why a pass-through and not `Island → @takazudo/zfb` (the sub-issue's literal
432
+ * ask): the MDX corpus never uses `<Island>` as a real render tag (only inside
433
+ * changelog prose), and the host stub itself uses this same pass-through. The
434
+ * real zfb <Island> emits `data-zfb-island="<firstChildTagName>"` for generic
435
+ * MDX children, i.e. a hydration marker pointing at a non-existent manifest
436
+ * entry — and the islands scanner can't register it anyway from a package
437
+ * (node_modules) import (upstream report above). A pass-through renders the
438
+ * server-renderable children correctly with no bogus marker, which is the
439
+ * complete and correct behaviour for the corpus. `when` is ignored at SSR
440
+ * time (it only matters to the client hydration runtime). */
441
+ function IslandPassthrough(props: {
442
+ when?: "load" | "idle" | "visible" | "media";
443
+ children?: ComponentChildren;
444
+ }): ComponentChildren {
445
+ return props.children ?? null;
446
+ }
447
+
374
448
  export function createMdxComponentsBound(lang: string = defaultLocale) {
375
449
  return createMdxComponents({
376
450
  settings,
@@ -380,9 +454,22 @@ export function createMdxComponentsBound(lang: string = defaultLocale) {
380
454
  CategoryTreeNav: CategoryTreeNavWrapper as never,
381
455
  SiteTreeNav: SiteTreeNavWrapper as never,
382
456
  },
383
- // PresetGenerator and other showcase-only slots resolve to a package stub
384
- // (render nothing) they are project-bound and not in the package.
457
+ // Package-owned content components wired here so an INJECTED docs route
458
+ // (packageOwnedRoutes, no host `pages/` stub) renders MDX using these tags
459
+ // without the "MDX requires '<X>' to be passed via the 'components' prop"
460
+ // error (sub-issue #2390 / supersedes #2377).
461
+ // - Details → fully functional (pure <details>, no client JS).
462
+ // - HtmlPreview → SSR-renders; see HtmlPreviewBound for the hydration
463
+ // caveat tied to the node_modules islands-scanner gap.
464
+ // - Island → SSR pass-through; see IslandPassthrough.
465
+ // PresetGenerator stays a package stub (render nothing): it is the
466
+ // showcase's project-bound interactive island and downstream projects stub
467
+ // it. The showcase keeps its host-owned docs catch-all routes (allowlisted)
468
+ // so its real PresetGenerator still renders.
385
469
  extras: {
470
+ Details: Details as never,
471
+ HtmlPreview: HtmlPreviewBound as never,
472
+ Island: IslandPassthrough as never,
386
473
  PresetGenerator: (_props: unknown) => null,
387
474
  },
388
475
  });
@@ -434,7 +521,7 @@ export const VersionsPageView = createVersionsPageView({
434
521
  HeadWithDefaults,
435
522
  HeaderWithDefaults,
436
523
  FooterWithDefaults,
437
- BodyEndIslands: BodyEndIslandsStub,
524
+ BodyEndIslands,
438
525
  },
439
526
  });
440
527
 
@@ -452,15 +539,16 @@ const tagPages = createTagPages({
452
539
  HeadWithDefaults,
453
540
  HeaderWithDefaults,
454
541
  FooterWithDefaults,
455
- BodyEndIslands: BodyEndIslandsStub,
542
+ BodyEndIslands,
456
543
  DocHistoryArea: DocHistoryArea as never,
457
544
  },
458
545
  });
459
546
 
460
547
  export const { collectTagMapForLocale, TagDetailPageView, TagsIndexPageView } = tagPages;
461
548
 
462
- // Re-export the locale-aware site-tree wrapper for the index entrypoints.
463
- export { SiteTreeNavWrapper, BodyEndIslandsStub };
549
+ // Re-export the locale-aware site-tree wrapper + the package-default body-end
550
+ // islands for the index entrypoints.
551
+ export { SiteTreeNavWrapper, BodyEndIslands };
464
552
 
465
553
  // Bring the doc-route-entries builder + types into the chrome surface so the
466
554
  // doc entrypoints import a single module.
@@ -34,6 +34,7 @@
34
34
  import { routeContext } from "virtual:zudo-doc-route-context";
35
35
 
36
36
  import type { Settings } from "@takazudo/zudo-doc/settings";
37
+ import type { ColorScheme } from "@takazudo/zudo-doc/color-scheme-utils";
37
38
  import type { FactoryI18n } from "@takazudo/zudo-doc/factory-context";
38
39
  import { makeUrlHelpers, type UrlHelpers } from "@takazudo/zudo-doc/url-helpers";
39
40
  import {
@@ -84,6 +85,7 @@ export interface RouteContextPayload {
84
85
  settings: Settings;
85
86
  translations: Record<string, Record<string, string>>;
86
87
  tagVocabulary: readonly TagVocabularyEntry[];
88
+ colorSchemes: Record<string, ColorScheme> | null;
87
89
  }
88
90
 
89
91
  /** The serializable route-context (from the virtual module). */
@@ -91,6 +93,9 @@ export const ctx = routeContext as unknown as RouteContextPayload;
91
93
  export const settings: Settings = ctx.settings;
92
94
  const translations = ctx.translations;
93
95
  const tagVocabulary = ctx.tagVocabulary;
96
+ /** Host color-scheme palette map. `null` when not supplied — `_chrome.tsx`
97
+ * falls back to `DEFAULT_SCHEME` in that case. */
98
+ export const colorSchemes: Record<string, ColorScheme> | null = ctx.colorSchemes;
94
99
 
95
100
  // ---------------------------------------------------------------------------
96
101
  // i18n — reconstruct a FactoryI18n from settings + translations (Decision 1).
@@ -4,12 +4,16 @@
4
4
  // — so this declaration gives the package route entrypoints a typed import.
5
5
  //
6
6
  // The payload is SERIALIZABLE DATA ONLY (ADR Decision 1): `settings`,
7
- // `translations`, `tagVocabulary`. `_context.ts` narrows it to the concrete
8
- // `RouteContextPayload` at the seam.
7
+ // `translations`, `tagVocabulary`, `colorSchemes`. `_context.ts` narrows it
8
+ // to the concrete `RouteContextPayload` at the seam.
9
9
  declare module "virtual:zudo-doc-route-context" {
10
10
  export const routeContext: {
11
11
  settings: unknown;
12
12
  translations: Record<string, Record<string, string>>;
13
13
  tagVocabulary: ReadonlyArray<Record<string, unknown>>;
14
+ /** Host color-scheme palette map. `null` when the caller did not pass
15
+ * `colorSchemes` to `zudoDocPreset` — `_chrome.tsx` falls back to
16
+ * `DEFAULT_SCHEME` in that case. */
17
+ colorSchemes: Record<string, unknown> | null;
14
18
  };
15
19
  }
@@ -28,7 +28,7 @@ import {
28
28
  HeadWithDefaults,
29
29
  HeaderWithDefaults,
30
30
  FooterWithDefaults,
31
- BodyEndIslandsStub,
31
+ BodyEndIslands,
32
32
  composeMetaTitle,
33
33
  } from "./_chrome.js";
34
34
 
@@ -67,7 +67,7 @@ export default function IndexPage(): JSX.Element {
67
67
  sidebarOverride={<></>}
68
68
  headerOverride={<HeaderWithDefaults lang={locale} currentPath={withBase("/")} />}
69
69
  footerOverride={<FooterWithDefaults lang={locale} />}
70
- bodyEndComponents={<BodyEndIslandsStub basePath={settings.base ?? "/"} />}
70
+ bodyEndComponents={<BodyEndIslands basePath={settings.base ?? "/"} />}
71
71
  enableClientRouter={settings.dynamicPageTransition}
72
72
  >
73
73
  <div class="flex justify-center mb-vsp-xl">
@@ -28,7 +28,7 @@ import {
28
28
  HeadWithDefaults,
29
29
  HeaderWithDefaults,
30
30
  FooterWithDefaults,
31
- BodyEndIslandsStub,
31
+ BodyEndIslands,
32
32
  composeMetaTitle,
33
33
  } from "./_chrome.js";
34
34
 
@@ -83,7 +83,7 @@ export default function LocaleIndexPage({ params }: PageArgs): JSX.Element {
83
83
  sidebarOverride={<></>}
84
84
  headerOverride={<HeaderWithDefaults lang={locale} currentPath={withBase(`/${locale}/`)} />}
85
85
  footerOverride={<FooterWithDefaults lang={locale} />}
86
- bodyEndComponents={<BodyEndIslandsStub basePath={settings.base ?? "/"} />}
86
+ bodyEndComponents={<BodyEndIslands basePath={settings.base ?? "/"} />}
87
87
  enableClientRouter={settings.dynamicPageTransition}
88
88
  >
89
89
  <div class="flex justify-center mb-vsp-xl">