create-zudo-doc 0.2.14 → 0.2.16

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.
package/dist/cli.d.ts CHANGED
@@ -22,6 +22,7 @@ export interface CliArgs {
22
22
  tauri?: boolean;
23
23
  tauriDev?: boolean;
24
24
  footerNavGroup?: boolean;
25
+ dynamicPageTransition?: boolean;
25
26
  footerCopyright?: boolean;
26
27
  changelog?: boolean;
27
28
  tagGovernance?: boolean;
package/dist/constants.js CHANGED
@@ -199,6 +199,13 @@ export const FEATURES = [
199
199
  default: true,
200
200
  cliFlag: "image-enlarge",
201
201
  },
202
+ {
203
+ value: "dynamicPageTransition",
204
+ label: "Dynamic page transition",
205
+ hint: "SPA-style page transition with history handling",
206
+ default: true,
207
+ cliFlag: "dynamic-page-transition",
208
+ },
202
209
  {
203
210
  value: "footerCopyright",
204
211
  label: "Footer copyright",
@@ -0,0 +1,21 @@
1
+ import type { FeatureModule } from "../compose.js";
2
+ /**
3
+ * Dynamic page transition feature.
4
+ *
5
+ * When enabled, wires the SPA client-side router (ClientRouterBootstrap
6
+ * island) and the page-loading overlay (PageLoadingOverlay, pure SSR) into
7
+ * body-end-islands, and injects the View-Transitions CSS + page-loading
8
+ * overlay CSS into global.css.
9
+ *
10
+ * Bug fix (#2265): the page-loading overlay CSS (.page-loading-overlay /
11
+ * .page-loading-spinner / @keyframes page-loading-spin / [data-zd-nav-pending])
12
+ * was never reaching scaffolded projects because it lived unconditionally in
13
+ * the host global.css but had no corresponding injection for the template.
14
+ * Gating this feature makes both the JS island and the CSS land together.
15
+ *
16
+ * The ClientRouterBootstrap component lives in
17
+ * templates/features/dynamicPageTransition/files/src/components/ and is
18
+ * byte-identical to the host src/components/client-router-bootstrap.tsx.
19
+ * The feature-template drift checker enforces this parity automatically.
20
+ */
21
+ export declare const dynamicPageTransitionFeature: FeatureModule;
@@ -0,0 +1,240 @@
1
+ /**
2
+ * Dynamic page transition feature.
3
+ *
4
+ * When enabled, wires the SPA client-side router (ClientRouterBootstrap
5
+ * island) and the page-loading overlay (PageLoadingOverlay, pure SSR) into
6
+ * body-end-islands, and injects the View-Transitions CSS + page-loading
7
+ * overlay CSS into global.css.
8
+ *
9
+ * Bug fix (#2265): the page-loading overlay CSS (.page-loading-overlay /
10
+ * .page-loading-spinner / @keyframes page-loading-spin / [data-zd-nav-pending])
11
+ * was never reaching scaffolded projects because it lived unconditionally in
12
+ * the host global.css but had no corresponding injection for the template.
13
+ * Gating this feature makes both the JS island and the CSS land together.
14
+ *
15
+ * The ClientRouterBootstrap component lives in
16
+ * templates/features/dynamicPageTransition/files/src/components/ and is
17
+ * byte-identical to the host src/components/client-router-bootstrap.tsx.
18
+ * The feature-template drift checker enforces this parity automatically.
19
+ */
20
+ export const dynamicPageTransitionFeature = () => ({
21
+ name: "dynamicPageTransition",
22
+ injections: [
23
+ // 1. Import ClientRouterBootstrap and PageLoadingOverlay.
24
+ // Inserted AFTER the `// @slot:body-end-islands:imports` anchor.
25
+ {
26
+ file: "pages/lib/_body-end-islands.tsx",
27
+ anchor: "// @slot:body-end-islands:imports",
28
+ position: "after",
29
+ content: `import ClientRouterBootstrap from "@/components/client-router-bootstrap";
30
+ import { PageLoadingOverlay } from "@takazudo/zudo-doc/page-loading";`,
31
+ },
32
+ // 2. Stable island marker name for ClientRouterBootstrap.
33
+ // Inserted AFTER the `// @slot:body-end-islands:display-names` anchor.
34
+ {
35
+ file: "pages/lib/_body-end-islands.tsx",
36
+ anchor: "// @slot:body-end-islands:display-names",
37
+ position: "after",
38
+ content: `(ClientRouterBootstrap as { displayName?: string }).displayName =
39
+ "ClientRouterBootstrap";`,
40
+ },
41
+ // 3. Gated island mount + PageLoadingOverlay.
42
+ // Inserted AFTER the `{/* @slot:body-end-islands:extra-islands */}` anchor.
43
+ // Gated on `settings.dynamicPageTransition` — mirrors the designTokenPanel
44
+ // and aiAssistant gating patterns in the same file.
45
+ // ClientRouterBootstrap: hydrates on "load" so the SPA-router click
46
+ // intercept is registered as soon as the islands runtime mounts the marker
47
+ // (zudolab/zudo-doc#1524 W7A fix). PageLoadingOverlay: pure SSR — the
48
+ // component emits its overlay div and the inline script that wires
49
+ // zfb:before-preparation / zfb:after-swap listeners at runtime.
50
+ {
51
+ file: "pages/lib/_body-end-islands.tsx",
52
+ anchor: "{/* @slot:body-end-islands:extra-islands */}",
53
+ position: "after",
54
+ content: ` {settings.dynamicPageTransition ? (
55
+ <>
56
+ {/* Pure SSR — no Island wrap. The component emits its overlay div,
57
+ inline styles, and a small inline script that self-wires
58
+ zfb:before-preparation / zfb:after-swap listeners at runtime. */}
59
+ <PageLoadingOverlay />
60
+ {Island({
61
+ when: "load",
62
+ children: <ClientRouterBootstrap />,
63
+ }) as unknown as VNode}
64
+ </>
65
+ ) : null}`,
66
+ },
67
+ // 4. View-Transitions CSS (chrome extraction + cross-fade + reduced motion).
68
+ // Injected AFTER `/* @slot:global-css:feature-styles */` in global.css.
69
+ // These rules are required for SPA page transitions via ClientRouter.
70
+ {
71
+ file: "src/styles/global.css",
72
+ anchor: "/* @slot:global-css:feature-styles */",
73
+ position: "after",
74
+ content: `/* Root cross-fade for same-document View Transitions (Strategy B).
75
+ *
76
+ * @view-transition { navigation: auto; } (Strategy A, cross-document) is
77
+ * deleted — Strategy B uses <ClientRouter /> which drives same-document
78
+ * transitions via document.startViewTransition on each navigation.
79
+ *
80
+ * Chrome extraction via view-transition-name (Strategy B+, zudolab/zudo-doc#1558):
81
+ * <header>, <aside id="desktop-sidebar">, <footer>, and the desktop-sidebar-toggle
82
+ * button each carry a data-zfb-transition-persist attribute whose value is keyed
83
+ * by locale and nav-section. The four attribute selectors below assign a stable
84
+ * view-transition-name based on the attribute value prefix, extracting those
85
+ * elements from the root snapshot into their own named layers:
86
+ * - header-{lang} → zfb-header
87
+ * - sidebar-{locale}-… → zfb-sidebar
88
+ * - footer-{lang} → zfb-footer
89
+ * - desktop-sidebar-toggle → zfb-sidebar-toggle
90
+ *
91
+ * Once extracted, the root cross-fade animates only non-chrome content (main,
92
+ * article, TOC, etc.). The twelve ::view-transition-{old,new,group}(<name>)
93
+ * rules disable animation for all four chrome layers. The group pseudo must be
94
+ * neutralised too — even when old/new are static the group container can still
95
+ * produce a geometry-morph animation when snapshot size/position differs.
96
+ *
97
+ * Entry/exit cross-fade (zudolab/zudo-doc#2072): animation: none is only
98
+ * correct when the chrome element exists on BOTH pages of a navigation.
99
+ * When it exists on one side only (docs page with sidebar → top page
100
+ * without), the named group holds a single snapshot — frozen at full
101
+ * opacity for the whole transition, then dropped abruptly at finish. The
102
+ * :only-child rules below detect that one-sided case and cross-fade the
103
+ * lone snapshot in sync with the root content fade (spec-standard
104
+ * entry/exit pattern; :only-child outranks the animation: none rules via
105
+ * the extra pseudo-class specificity). */
106
+
107
+ /* Chrome extraction — assign view-transition-name from data-zfb-transition-persist */
108
+ [data-zfb-transition-persist^="header-"] { view-transition-name: zfb-header; }
109
+ [data-zfb-transition-persist^="sidebar-"] { view-transition-name: zfb-sidebar; }
110
+ [data-zfb-transition-persist^="footer-"] { view-transition-name: zfb-footer; }
111
+ [data-zfb-transition-persist="desktop-sidebar-toggle"] { view-transition-name: zfb-sidebar-toggle; }
112
+
113
+ /* Disable animation for all four chrome layers (old, new, and group) */
114
+ ::view-transition-old(zfb-header),
115
+ ::view-transition-new(zfb-header),
116
+ ::view-transition-group(zfb-header),
117
+ ::view-transition-old(zfb-sidebar),
118
+ ::view-transition-new(zfb-sidebar),
119
+ ::view-transition-group(zfb-sidebar),
120
+ ::view-transition-old(zfb-footer),
121
+ ::view-transition-new(zfb-footer),
122
+ ::view-transition-group(zfb-footer),
123
+ ::view-transition-old(zfb-sidebar-toggle),
124
+ ::view-transition-new(zfb-sidebar-toggle),
125
+ ::view-transition-group(zfb-sidebar-toggle) { animation: none; }
126
+
127
+ /* Entry/exit: chrome element present on one side only (#2072).
128
+ * Exit — lone old snapshot fades out with the content cross-fade. */
129
+ ::view-transition-old(zfb-header):only-child,
130
+ ::view-transition-old(zfb-sidebar):only-child,
131
+ ::view-transition-old(zfb-footer):only-child,
132
+ ::view-transition-old(zfb-sidebar-toggle):only-child {
133
+ animation: 150ms ease-in both contentFadeOut;
134
+ }
135
+ /* Entry — lone new snapshot fades in with the content cross-fade. */
136
+ ::view-transition-new(zfb-header):only-child,
137
+ ::view-transition-new(zfb-sidebar):only-child,
138
+ ::view-transition-new(zfb-footer):only-child,
139
+ ::view-transition-new(zfb-sidebar-toggle):only-child {
140
+ animation: 300ms ease-out both contentFadeIn;
141
+ }
142
+
143
+ /* Root cross-fade rules — animate only the non-chrome snapshot */
144
+ ::view-transition-old(root) {
145
+ animation: 150ms ease-in both contentFadeOut;
146
+ }
147
+ ::view-transition-new(root) {
148
+ animation: 300ms ease-out both contentFadeIn;
149
+ }
150
+
151
+ /* Reduced motion: collapse all view-transition animation to an instant
152
+ * swap (zudolab/zudo-doc#2086). With every snapshot animation at none the
153
+ * transition settles immediately — no cross-fade, no translateY slide.
154
+ * Covers the root cross-fade and the #2072 :only-child entry/exit rules;
155
+ * the both-sides chrome layers are already animation: none above. Equal
156
+ * specificity per selector, but later in source order, so these win.
157
+ * ::view-transition-group(root) must be neutralized too (zudolab/zzmod#845):
158
+ * the UA animates the root group for its full duration even when the old/new
159
+ * root snapshots are at animation: none, leaving a brief frozen,
160
+ * non-interactive frame. The chrome groups are already animation: none in the
161
+ * unconditional chrome block above, so only the root group is added here. */
162
+ @media (prefers-reduced-motion: reduce) {
163
+ ::view-transition-old(root),
164
+ ::view-transition-new(root),
165
+ ::view-transition-group(root),
166
+ ::view-transition-old(zfb-header):only-child,
167
+ ::view-transition-old(zfb-sidebar):only-child,
168
+ ::view-transition-old(zfb-footer):only-child,
169
+ ::view-transition-old(zfb-sidebar-toggle):only-child,
170
+ ::view-transition-new(zfb-header):only-child,
171
+ ::view-transition-new(zfb-sidebar):only-child,
172
+ ::view-transition-new(zfb-footer):only-child,
173
+ ::view-transition-new(zfb-sidebar-toggle):only-child {
174
+ animation: none;
175
+ }
176
+ }
177
+
178
+ /* Page-loading overlay CSS — moved out of PageLoadingOverlay's inline
179
+ * <style> block to avoid the <style>-inside-<body> HTML5 content-model
180
+ * violation flagged by html-validate (same pattern as version-switcher
181
+ * fix in zudolab/zudo-doc#1505; see W2A confirm zudolab/zudo-doc#1543). */
182
+ .page-loading-overlay {
183
+ position: fixed;
184
+ inset: 0;
185
+ /* Full-screen blocking load overlay — modal tier (was a raw z-index:9999).
186
+ It sits below the transient \`drag\` tier (sidebar-resizer ghost), which is
187
+ fine: a full page-load overlay and an active pointer-drag never coexist. */
188
+ z-index: var(--z-index-modal);
189
+ display: flex;
190
+ align-items: center;
191
+ justify-content: center;
192
+ background: color-mix(in oklch, var(--color-overlay) 60%, transparent);
193
+ opacity: 0;
194
+ pointer-events: none;
195
+ transition: opacity 150ms ease-out;
196
+ }
197
+
198
+ .page-loading-overlay[data-visible] {
199
+ opacity: 1;
200
+ }
201
+
202
+ a[data-zd-nav-pending],
203
+ button[data-zd-nav-pending] {
204
+ color: var(--color-accent);
205
+ }
206
+
207
+ .page-loading-spinner {
208
+ width: 48px;
209
+ height: 48px;
210
+ border: 5px solid var(--color-fg, #fff);
211
+ border-bottom-color: transparent;
212
+ border-radius: 50%;
213
+ display: inline-block;
214
+ box-sizing: border-box;
215
+ animation: page-loading-spin 1s linear infinite;
216
+ }
217
+
218
+ @media (min-width: 1024px) {
219
+ .page-loading-spinner {
220
+ width: 64px;
221
+ height: 64px;
222
+ border-width: 6px;
223
+ }
224
+ }
225
+
226
+ @keyframes page-loading-spin {
227
+ 0% { transform: rotate(0deg); }
228
+ 100% { transform: rotate(360deg); }
229
+ }
230
+
231
+ @media (prefers-reduced-motion: reduce) {
232
+ .page-loading-spinner {
233
+ animation: none;
234
+ border-bottom-color: var(--color-fg, #fff);
235
+ opacity: 0.5;
236
+ }
237
+ }`,
238
+ },
239
+ ],
240
+ });
@@ -19,6 +19,7 @@ import { versioningFeature } from "./versioning.js";
19
19
  import { tauriFeature } from "./tauri.js";
20
20
  import { tauriDevFeature } from "./tauri-dev.js";
21
21
  import { imageEnlargeFeature } from "./image-enlarge.js";
22
+ import { dynamicPageTransitionFeature } from "./dynamic-page-transition.js";
22
23
  import { tagGovernanceFeature } from "./tag-governance.js";
23
24
  import { footerTaglistFeature } from "./footer-taglist.js";
24
25
  import { bodyFootUtilFeature } from "./body-foot-util.js";
@@ -43,6 +44,7 @@ export const featureModules = {
43
44
  tauri: tauriFeature,
44
45
  tauriDev: tauriDevFeature,
45
46
  imageEnlarge: imageEnlargeFeature,
47
+ dynamicPageTransition: dynamicPageTransitionFeature,
46
48
  // skillSymlinker — handled in scaffold.ts
47
49
  // claudeSkills — handled in scaffold.ts (copies zudo-doc-* skills from monorepo)
48
50
  tagGovernance: tagGovernanceFeature,
package/dist/scaffold.js CHANGED
@@ -434,20 +434,24 @@ function generatePackageJson(choices) {
434
434
  // surface — VNode/VNodeArray/VNodeObject are now exported from
435
435
  // "@takazudo/zfb" (#972) — plus removal of the no-op linkValidation.allowExternal
436
436
  // knob (#925); both are non-breaking for a fresh scaffold. A fresh scaffold
437
- // sets no explicit codeHighlight.theme so the default still applies. No
437
+ // sets codeHighlight.themeLight/themeDark for dual-theme syntect output. No
438
438
  // consumer-facing / CLI breaking change. next.52: adds the
439
439
  // `ClientRouter({ preserveHtmlAttrs })` option (zfb#1104) — consumers can
440
440
  // declare runtime `<html>` attribute names to preserve across SPA swaps so
441
441
  // e.g. `data-sidebar-hidden` / `data-theme` survive (zudolab/zudo-doc#2200).
442
- // Additive, non-breaking for a fresh scaffold. next.53 (current pin):
442
+ // Additive, non-breaking for a fresh scaffold. next.53:
443
443
  // zfb-content GFM-autolink fix — terminate the autolink path at CJK
444
444
  // boundaries (zfb#1105). Content-rendering bug fix, additive; relevant for
445
445
  // CJK (e.g. Japanese) docs. No consumer-facing / CLI breaking change.
446
- "@takazudo/zfb": "0.1.0-next.53",
447
- "@takazudo/zfb-runtime": "0.1.0-next.53",
446
+ // next.54 (current pin): bug-fix + perf release — cross-OS CSS hash
447
+ // stability, multi-valued response headers (e.g. multiple Set-Cookie),
448
+ // supplementary-plane CJK reading-time, plus CLI/server/runtime hardening
449
+ // and render perf passes. No consumer-facing / CLI breaking change.
450
+ "@takazudo/zfb": "0.1.0-next.54",
451
+ "@takazudo/zfb-runtime": "0.1.0-next.54",
448
452
  // zfb-adapter-cloudflare — required for any route with `prerender = false`.
449
453
  // Pinned in lockstep with @takazudo/zfb.
450
- "@takazudo/zfb-adapter-cloudflare": "0.1.0-next.53",
454
+ "@takazudo/zfb-adapter-cloudflare": "0.1.0-next.54",
451
455
  // @takazudo/zudo-doc — published from this monorepo via
452
456
  // .github/workflows/publish-zudo-doc.yml. The pin here is bumped in
453
457
  // lockstep by scripts/release-create-zudo-doc.sh whenever zudo-doc's
@@ -459,7 +463,7 @@ function generatePackageJson(choices) {
459
463
  // ties this pin to packages/zudo-doc's version, so the lockstep release
460
464
  // bumps both together; do not cut a create-zudo-doc release until the
461
465
  // matching @takazudo/zudo-doc version (with content.css) is on npm.
462
- "@takazudo/zudo-doc": "^0.2.14",
466
+ "@takazudo/zudo-doc": "^0.2.16",
463
467
  // zod — used by the generated zfb.config.ts. zfb-config-gen emits
464
468
  // `import { z } from "zod"` for the content-collection schema +
465
469
  // `z.toJSONSchema(...)` conversion. Without this dep, the consumer
@@ -513,7 +517,7 @@ function generatePackageJson(choices) {
513
517
  // @takazudo/zudo-doc/integrations/doc-history which in turn imports
514
518
  // @takazudo/zudo-doc-history-server/git-history. Without this dep the
515
519
  // plugin host fails at init with ERR_MODULE_NOT_FOUND — W8A (#1739).
516
- deps["@takazudo/zudo-doc-history-server"] = "^0.2.14";
520
+ deps["@takazudo/zudo-doc-history-server"] = "^0.2.16";
517
521
  // W7A (#1736): doc-history-plugin.mjs spawns `tsx -e <inline-script>` to
518
522
  // run the v2 runtime in a TS-aware Node subprocess; without tsx the
519
523
  // plugin's preBuild step exits with ENOENT before zfb finishes config
@@ -192,6 +192,12 @@ export function generateSettingsFile(choices) {
192
192
  else {
193
193
  lines.push(` imageEnlarge: false as boolean,`);
194
194
  }
195
+ if (choices.features.includes("dynamicPageTransition")) {
196
+ lines.push(` dynamicPageTransition: true as boolean,`);
197
+ }
198
+ else {
199
+ lines.push(` dynamicPageTransition: false as boolean,`);
200
+ }
195
201
  lines.push(` htmlPreview: undefined as HtmlPreviewConfig | undefined,`);
196
202
  if (choices.features.includes("versioning")) {
197
203
  lines.push(` versions: [] satisfies VersionConfig[] as VersionConfig[] | false,`);
@@ -251,6 +257,13 @@ export function generateSettingsFile(choices) {
251
257
  }
252
258
  lines.push(` headerNav: [`);
253
259
  lines.push(` { label: "Getting Started", path: "/docs/getting-started", categoryMatch: "getting-started" },`);
260
+ // The "claude" categoryMatch is load-bearing beyond the header link: getCategoryOrder()
261
+ // derives the satellite-grouping prefixes from headerNav, so without this entry
262
+ // groupSatelliteNodes() never nests claude-md/claude-skills/... under the "claude"
263
+ // overview node and they spread out as separate top-level cards on the index sitemap.
264
+ if (choices.features.includes("claudeResources")) {
265
+ lines.push(` { label: "Claude", path: "/docs/claude", categoryMatch: "claude" },`);
266
+ }
254
267
  if (choices.features.includes("changelog")) {
255
268
  lines.push(` { label: "Changelog", path: "/docs/changelog", categoryMatch: "changelog" },`);
256
269
  }
@@ -190,6 +190,14 @@ export function generateZfbConfig(choices) {
190
190
  lines.push(` },`);
191
191
  lines.push(` base: settings.base,`);
192
192
  lines.push(` trailingSlash: settings.trailingSlash,`);
193
+ // codeHighlight — dual-theme syntect output so CSS can resolve tokens via
194
+ // light-dark(var(--shiki-light), var(--shiki-dark)) and code blocks follow
195
+ // the active light/dark toggle with no client JS. Theme names are syntect
196
+ // built-ins shipped with zfb (not Shiki names). Matches the host config.
197
+ lines.push(` codeHighlight: {`);
198
+ lines.push(` themeLight: "base16-ocean.light",`);
199
+ lines.push(` themeDark: "base16-ocean.dark",`);
200
+ lines.push(` },`);
193
201
  // markdown.features block — mirrors the zfb next.13 opt-in model.
194
202
  //
195
203
  // Value-shape rule (empirically verified against the next.13 Rust loader):
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-zudo-doc",
3
- "version": "0.2.14",
3
+ "version": "0.2.16",
4
4
  "description": "Create a new zudo-doc documentation site",
5
5
  "license": "MIT",
6
6
  "author": "Takeshi Takatsudo",
@@ -33,10 +33,8 @@ import { Island } from "@takazudo/zfb";
33
33
  import { settings } from "@/config/settings";
34
34
 
35
35
  import AiChatModal from "@/components/ai-chat-modal";
36
- import ClientRouterBootstrap from "@/components/client-router-bootstrap";
37
36
  import ImageEnlarge, { ImageEnlargeSsrFallback } from "@/components/image-enlarge";
38
37
  import MermaidEnlarge, { MermaidEnlargeSsrFallback } from "@/components/mermaid-enlarge";
39
- import { PageLoadingOverlay } from "@takazudo/zudo-doc/page-loading";
40
38
  // @slot:body-end-islands:imports
41
39
 
42
40
  // Set explicit `displayName` on each default-exported island so zfb's
@@ -47,8 +45,6 @@ import { PageLoadingOverlay } from "@takazudo/zudo-doc/page-loading";
47
45
  // function names by default, but the explicit assignment is a
48
46
  // belt-and-braces guard for production minification regressions.
49
47
  (AiChatModal as { displayName?: string }).displayName = "AiChatModal";
50
- (ClientRouterBootstrap as { displayName?: string }).displayName =
51
- "ClientRouterBootstrap";
52
48
  (ImageEnlarge as { displayName?: string }).displayName = "ImageEnlarge";
53
49
  (MermaidEnlarge as { displayName?: string }).displayName = "MermaidEnlarge";
54
50
  // @slot:body-end-islands:display-names
@@ -104,16 +100,6 @@ export function BodyEndIslands({
104
100
  basePath,
105
101
  aiChatBodyLabel = DEFAULT_AI_CHAT_BODY_LABEL,
106
102
  }: BodyEndIslandsProps): JSX.Element {
107
- // Hydrates first (when="load") so the SPA-router click intercept is
108
- // registered as soon as the islands runtime mounts the marker. The
109
- // component renders nothing visually — the island bundle's top-level
110
- // `import "@takazudo/zfb-runtime/client-router"` is what actually
111
- // wires up the router (zudolab/zudo-doc#1524 W7A fix).
112
- const clientRouterBootstrap = Island({
113
- when: "load",
114
- children: <ClientRouterBootstrap />,
115
- }) as unknown as VNode;
116
-
117
103
  // Gated on `settings.aiAssistant` (zudolab/zudo-doc#2058): when the AI
118
104
  // assistant feature is off, neither the AiChatModal island marker nor the
119
105
  // sr-only "AI Assistant" landmark heading should reach the SSG output —
@@ -177,11 +163,6 @@ export function BodyEndIslands({
177
163
 
178
164
  return (
179
165
  <>
180
- {/* Pure SSR — no Island wrap. The component emits its overlay div,
181
- inline styles, and a small inline script that self-wires
182
- zfb:before-preparation / zfb:after-swap listeners at runtime. */}
183
- <PageLoadingOverlay />
184
- {clientRouterBootstrap}
185
166
  {aiAssistant}
186
167
  {imageEnlarge}
187
168
  {mermaidEnlarge}
@@ -296,6 +296,10 @@ body {
296
296
  button {
297
297
  cursor: pointer;
298
298
  }
299
+ :focus-visible {
300
+ outline: 2px solid var(--color-accent);
301
+ outline-offset: 2px;
302
+ }
299
303
  }
300
304
 
301
305
  /* ========================================
@@ -558,104 +562,6 @@ pre[class^="syntect-"] .line .highlighted-word {
558
562
  }
559
563
  }
560
564
 
561
- /* Root cross-fade for same-document View Transitions (Strategy B).
562
- *
563
- * @view-transition { navigation: auto; } (Strategy A, cross-document) is
564
- * deleted — Strategy B uses <ClientRouter /> which drives same-document
565
- * transitions via document.startViewTransition on each navigation.
566
- *
567
- * Chrome extraction via view-transition-name (Strategy B+, zudolab/zudo-doc#1558):
568
- * <header>, <aside id="desktop-sidebar">, <footer>, and the desktop-sidebar-toggle
569
- * button each carry a data-zfb-transition-persist attribute whose value is keyed
570
- * by locale and nav-section. The four attribute selectors below assign a stable
571
- * view-transition-name based on the attribute value prefix, extracting those
572
- * elements from the root snapshot into their own named layers:
573
- * - header-{lang} → zfb-header
574
- * - sidebar-{locale}-… → zfb-sidebar
575
- * - footer-{lang} → zfb-footer
576
- * - desktop-sidebar-toggle → zfb-sidebar-toggle
577
- *
578
- * Once extracted, the root cross-fade animates only non-chrome content (main,
579
- * article, TOC, etc.). The twelve ::view-transition-{old,new,group}(<name>)
580
- * rules disable animation for all four chrome layers. The group pseudo must be
581
- * neutralised too — even when old/new are static the group container can still
582
- * produce a geometry-morph animation when snapshot size/position differs.
583
- *
584
- * Entry/exit cross-fade (zudolab/zudo-doc#2072): animation: none is only
585
- * correct when the chrome element exists on BOTH pages of a navigation.
586
- * When it exists on one side only (docs page with sidebar → top page
587
- * without), the named group holds a single snapshot — frozen at full
588
- * opacity for the whole transition, then dropped abruptly at finish. The
589
- * :only-child rules below detect that one-sided case and cross-fade the
590
- * lone snapshot in sync with the root content fade (spec-standard
591
- * entry/exit pattern; :only-child outranks the animation: none rules via
592
- * the extra pseudo-class specificity). */
593
-
594
- /* Chrome extraction — assign view-transition-name from data-zfb-transition-persist */
595
- [data-zfb-transition-persist^="header-"] { view-transition-name: zfb-header; }
596
- [data-zfb-transition-persist^="sidebar-"] { view-transition-name: zfb-sidebar; }
597
- [data-zfb-transition-persist^="footer-"] { view-transition-name: zfb-footer; }
598
- [data-zfb-transition-persist="desktop-sidebar-toggle"] { view-transition-name: zfb-sidebar-toggle; }
599
-
600
- /* Disable animation for all four chrome layers (old, new, and group) */
601
- ::view-transition-old(zfb-header),
602
- ::view-transition-new(zfb-header),
603
- ::view-transition-group(zfb-header),
604
- ::view-transition-old(zfb-sidebar),
605
- ::view-transition-new(zfb-sidebar),
606
- ::view-transition-group(zfb-sidebar),
607
- ::view-transition-old(zfb-footer),
608
- ::view-transition-new(zfb-footer),
609
- ::view-transition-group(zfb-footer),
610
- ::view-transition-old(zfb-sidebar-toggle),
611
- ::view-transition-new(zfb-sidebar-toggle),
612
- ::view-transition-group(zfb-sidebar-toggle) { animation: none; }
613
-
614
- /* Entry/exit: chrome element present on one side only (#2072).
615
- * Exit — lone old snapshot fades out with the content cross-fade. */
616
- ::view-transition-old(zfb-header):only-child,
617
- ::view-transition-old(zfb-sidebar):only-child,
618
- ::view-transition-old(zfb-footer):only-child,
619
- ::view-transition-old(zfb-sidebar-toggle):only-child {
620
- animation: 150ms ease-in both contentFadeOut;
621
- }
622
- /* Entry — lone new snapshot fades in with the content cross-fade. */
623
- ::view-transition-new(zfb-header):only-child,
624
- ::view-transition-new(zfb-sidebar):only-child,
625
- ::view-transition-new(zfb-footer):only-child,
626
- ::view-transition-new(zfb-sidebar-toggle):only-child {
627
- animation: 300ms ease-out both contentFadeIn;
628
- }
629
-
630
- /* Root cross-fade rules — animate only the non-chrome snapshot */
631
- ::view-transition-old(root) {
632
- animation: 150ms ease-in both contentFadeOut;
633
- }
634
- ::view-transition-new(root) {
635
- animation: 300ms ease-out both contentFadeIn;
636
- }
637
-
638
- /* Reduced motion: collapse all view-transition animation to an instant
639
- * swap (zudolab/zudo-doc#2086). With every snapshot animation at none the
640
- * transition settles immediately — no cross-fade, no translateY slide.
641
- * Covers the root cross-fade and the #2072 :only-child entry/exit rules;
642
- * the both-sides chrome layers are already animation: none above. Equal
643
- * specificity per selector, but later in source order, so these win. */
644
- @media (prefers-reduced-motion: reduce) {
645
- ::view-transition-old(root),
646
- ::view-transition-new(root),
647
- ::view-transition-old(zfb-header):only-child,
648
- ::view-transition-old(zfb-sidebar):only-child,
649
- ::view-transition-old(zfb-footer):only-child,
650
- ::view-transition-old(zfb-sidebar-toggle):only-child,
651
- ::view-transition-new(zfb-header):only-child,
652
- ::view-transition-new(zfb-sidebar):only-child,
653
- ::view-transition-new(zfb-footer):only-child,
654
- ::view-transition-new(zfb-sidebar-toggle):only-child {
655
- animation: none;
656
- }
657
- }
658
-
659
565
  /* Version-switcher responsive visibility (moved out of the component to
660
566
  * avoid <style>-inside-<div> HTML5 content-model violation; required when
661
567
  * the i18nVersion feature ships a <VersionSwitcher> wrapped in