create-zudo-doc 0.2.4 → 0.2.5

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/constants.js CHANGED
@@ -217,7 +217,7 @@ export const FEATURES = [
217
217
  value: "tagGovernance",
218
218
  label: "Tag governance",
219
219
  hint: "Vocabulary-aware tag audit + suggest scripts",
220
- default: true,
220
+ default: false,
221
221
  cliFlag: "tag-governance",
222
222
  },
223
223
  {
@@ -7,5 +7,61 @@
7
7
  */
8
8
  export const docHistoryFeature = () => ({
9
9
  name: "docHistory",
10
- injections: [],
10
+ injections: [
11
+ {
12
+ // Diff-viewer CSS — the DocHistory island's side-by-side diff markup
13
+ // (.diff-row / .diff-line-*) is styled only here; without this block
14
+ // scaffolded projects render the diff viewer unstyled (#2081). Values
15
+ // mirror the showcase src/styles/global.css (per-line separators at
16
+ // the 15% muted mix, #2077).
17
+ file: "src/styles/global.css",
18
+ anchor: "/* @slot:global-css:feature-styles */",
19
+ content: `/* ========================================
20
+ * Doc History Diff Viewer (side-by-side)
21
+ * ======================================== */
22
+
23
+ .diff-row {
24
+ border-bottom: 1px solid color-mix(in oklch, var(--color-muted) 15%, transparent);
25
+ }
26
+
27
+ .diff-line-num {
28
+ font-family: var(--font-mono, ui-monospace, monospace);
29
+ font-size: var(--text-caption);
30
+ line-height: 1.5;
31
+ padding: 0 var(--spacing-hsp-xs);
32
+ text-align: right;
33
+ color: var(--color-muted);
34
+ user-select: none;
35
+ vertical-align: top;
36
+ border-right: 1px solid color-mix(in oklch, var(--color-muted) 15%, transparent);
37
+ }
38
+
39
+ .diff-line-content {
40
+ font-family: var(--font-mono, ui-monospace, monospace);
41
+ font-size: var(--text-caption);
42
+ line-height: 1.5;
43
+ padding: 0 var(--spacing-hsp-sm);
44
+ white-space: pre-wrap;
45
+ word-break: break-all;
46
+ vertical-align: top;
47
+ }
48
+
49
+ /* Left column right border to separate the two sides */
50
+ .diff-row td:nth-child(2) {
51
+ border-right: 2px solid var(--color-muted);
52
+ }
53
+
54
+ .diff-line-added {
55
+ background-color: color-mix(in oklch, var(--color-success) 15%, transparent);
56
+ }
57
+
58
+ .diff-line-removed {
59
+ background-color: color-mix(in oklch, var(--color-danger) 15%, transparent);
60
+ }
61
+
62
+ .diff-line-empty {
63
+ background-color: color-mix(in oklch, var(--color-muted) 8%, transparent);
64
+ }`,
65
+ },
66
+ ],
11
67
  });
package/dist/preset.d.ts CHANGED
@@ -17,6 +17,15 @@ export interface PresetHeaderRightTriggerItem {
17
17
  trigger: PresetHeaderRightTriggerName;
18
18
  }
19
19
  export type PresetHeaderRightItem = PresetHeaderRightComponentItem | PresetHeaderRightTriggerItem;
20
+ export interface PresetMetaTagsConfig {
21
+ description?: boolean;
22
+ keywords?: string | false;
23
+ ogImage?: string | false;
24
+ ogSiteName?: boolean;
25
+ twitterCard?: "summary" | "summary_large_image" | false;
26
+ twitterSite?: string;
27
+ twitterCreator?: string;
28
+ }
20
29
  export interface PresetJson {
21
30
  projectName?: string;
22
31
  defaultLang?: string;
@@ -31,6 +40,7 @@ export interface PresetJson {
31
40
  cjkFriendly?: boolean;
32
41
  packageManager?: "pnpm" | "npm" | "yarn" | "bun";
33
42
  headerRightItems?: PresetHeaderRightItem[];
43
+ metaTags?: PresetMetaTagsConfig;
34
44
  }
35
45
  export declare function loadPreset(pathOrStdin: string): PartialChoices;
36
46
  export declare function validatePreset(json: unknown): string | null;
package/dist/preset.js CHANGED
@@ -112,6 +112,36 @@ export function validatePreset(json) {
112
112
  }
113
113
  }
114
114
  }
115
+ if (p.metaTags !== undefined) {
116
+ if (typeof p.metaTags !== "object" || p.metaTags === null || Array.isArray(p.metaTags)) {
117
+ return `"metaTags" must be an object in preset`;
118
+ }
119
+ const mt = p.metaTags;
120
+ if (mt.description !== undefined && typeof mt.description !== "boolean") {
121
+ return `"metaTags.description" must be a boolean`;
122
+ }
123
+ if (mt.keywords !== undefined && mt.keywords !== false && typeof mt.keywords !== "string") {
124
+ return `"metaTags.keywords" must be a string or false`;
125
+ }
126
+ if (mt.ogImage !== undefined && mt.ogImage !== false && typeof mt.ogImage !== "string") {
127
+ return `"metaTags.ogImage" must be a string or false`;
128
+ }
129
+ if (mt.ogSiteName !== undefined && typeof mt.ogSiteName !== "boolean") {
130
+ return `"metaTags.ogSiteName" must be a boolean`;
131
+ }
132
+ if (mt.twitterCard !== undefined &&
133
+ mt.twitterCard !== false &&
134
+ mt.twitterCard !== "summary" &&
135
+ mt.twitterCard !== "summary_large_image") {
136
+ return `"metaTags.twitterCard" must be "summary", "summary_large_image", or false`;
137
+ }
138
+ if (mt.twitterSite !== undefined && typeof mt.twitterSite !== "string") {
139
+ return `"metaTags.twitterSite" must be a string`;
140
+ }
141
+ if (mt.twitterCreator !== undefined && typeof mt.twitterCreator !== "string") {
142
+ return `"metaTags.twitterCreator" must be a string`;
143
+ }
144
+ }
115
145
  // Cross-field validation
116
146
  if (p.colorSchemeMode === "single" && (p.lightScheme || p.darkScheme)) {
117
147
  return `lightScheme/darkScheme are only valid with colorSchemeMode "light-dark"`;
@@ -150,6 +180,9 @@ export function presetToChoices(json) {
150
180
  if (json.headerRightItems !== undefined) {
151
181
  choices.headerRightItems = json.headerRightItems;
152
182
  }
183
+ if (json.metaTags !== undefined) {
184
+ choices.metaTags = json.metaTags;
185
+ }
153
186
  if (json.features) {
154
187
  // Warn about unrecognized feature names
155
188
  for (const name of json.features) {
package/dist/prompts.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { PresetHeaderRightItem } from "./preset.js";
1
+ import type { PresetHeaderRightItem, PresetMetaTagsConfig } from "./preset.js";
2
2
  export interface UserChoices {
3
3
  projectName: string;
4
4
  defaultLang: string;
@@ -14,6 +14,7 @@ export interface UserChoices {
14
14
  cjkFriendly?: boolean;
15
15
  packageManager: "pnpm" | "npm" | "yarn" | "bun";
16
16
  headerRightItems?: PresetHeaderRightItem[];
17
+ metaTags?: PresetMetaTagsConfig;
17
18
  }
18
19
  export interface PartialChoices {
19
20
  projectName?: string;
@@ -30,5 +31,6 @@ export interface PartialChoices {
30
31
  cjkFriendly?: boolean;
31
32
  packageManager?: "pnpm" | "npm" | "yarn" | "bun";
32
33
  headerRightItems?: PresetHeaderRightItem[];
34
+ metaTags?: PresetMetaTagsConfig;
33
35
  }
34
36
  export declare function runPrompts(prefilled?: PartialChoices): Promise<UserChoices>;
package/dist/prompts.js CHANGED
@@ -241,5 +241,6 @@ export async function runPrompts(prefilled = {}) {
241
241
  cjkFriendly: prefilled.cjkFriendly,
242
242
  packageManager,
243
243
  headerRightItems: prefilled.headerRightItems,
244
+ metaTags: prefilled.metaTags,
244
245
  };
245
246
  }
package/dist/scaffold.js CHANGED
@@ -283,16 +283,18 @@ function generatePackageJson(choices) {
283
283
  // (Takazudo/zudo-front-builder#1030) — and the data-file skip warning
284
284
  // (e.g. for `_category_.json`) now respects the collection's
285
285
  // include/exclude globs (#1032). No consumer-facing breaking change.
286
- "@takazudo/zfb": "0.1.0-next.41",
287
- "@takazudo/zfb-runtime": "0.1.0-next.41",
286
+ // next.42/next.43: release-tooling + formatter-glob fixes only (npm-publish
287
+ // idempotency, gitignored-artifact excludes). No consumer-facing change.
288
+ "@takazudo/zfb": "0.1.0-next.43",
289
+ "@takazudo/zfb-runtime": "0.1.0-next.43",
288
290
  // zfb-adapter-cloudflare — required for any route with `prerender = false`.
289
291
  // Pinned in lockstep with @takazudo/zfb.
290
- "@takazudo/zfb-adapter-cloudflare": "0.1.0-next.41",
292
+ "@takazudo/zfb-adapter-cloudflare": "0.1.0-next.43",
291
293
  // @takazudo/zudo-doc — published from this monorepo via
292
294
  // .github/workflows/publish-zudo-doc.yml. The pin here is bumped in
293
295
  // lockstep by scripts/release-create-zudo-doc.sh whenever zudo-doc's
294
296
  // version moves, so a fresh scaffold pulls the version we just published.
295
- "@takazudo/zudo-doc": "^0.2.4",
297
+ "@takazudo/zudo-doc": "^0.2.5",
296
298
  // zod — used by the generated zfb.config.ts. zfb-config-gen emits
297
299
  // `import { z } from "zod"` for the content-collection schema +
298
300
  // `z.toJSONSchema(...)` conversion. Without this dep, the consumer
@@ -346,7 +348,7 @@ function generatePackageJson(choices) {
346
348
  // @takazudo/zudo-doc/integrations/doc-history which in turn imports
347
349
  // @takazudo/zudo-doc-history-server/git-history. Without this dep the
348
350
  // plugin host fails at init with ERR_MODULE_NOT_FOUND — W8A (#1739).
349
- deps["@takazudo/zudo-doc-history-server"] = "^0.2.4";
351
+ deps["@takazudo/zudo-doc-history-server"] = "^0.2.5";
350
352
  // W7A (#1736): doc-history-plugin.mjs spawns `tsx -e <inline-script>` to
351
353
  // run the v2 runtime in a TS-aware Node subprocess; without tsx the
352
354
  // plugin's preBuild step exits with ENOENT before zfb finishes config
@@ -16,6 +16,7 @@ export function generateSettingsFile(choices) {
16
16
  lines.push(` TagPlacement,`);
17
17
  lines.push(` TagGovernanceMode,`);
18
18
  lines.push(` TagVocabularyEntry,`);
19
+ lines.push(` MetaTagsConfig,`);
19
20
  lines.push(`} from "./settings-types";`);
20
21
  lines.push(`import type {`);
21
22
  lines.push(` HeaderNavItem,`);
@@ -29,6 +30,7 @@ export function generateSettingsFile(choices) {
29
30
  lines.push(` BodyFootUtilAreaConfig,`);
30
31
  lines.push(` TagPlacement,`);
31
32
  lines.push(` TagGovernanceMode,`);
33
+ lines.push(` MetaTagsConfig,`);
32
34
  lines.push(`} from "./settings-types";`);
33
35
  lines.push(``);
34
36
  lines.push(`export const settings = {`);
@@ -59,6 +61,34 @@ export function generateSettingsFile(choices) {
59
61
  lines.push(` githubUrl: false as string | false,`);
60
62
  }
61
63
  lines.push(` siteUrl: "" as string,`);
64
+ lines.push(` metaTags: {`);
65
+ if (choices.metaTags) {
66
+ const mt = choices.metaTags;
67
+ lines.push(` description: ${mt.description !== undefined ? mt.description : true},`);
68
+ lines.push(` keywords: ${mt.keywords !== undefined ? JSON.stringify(mt.keywords) : false},`);
69
+ lines.push(` ogImage: ${mt.ogImage !== undefined ? JSON.stringify(mt.ogImage) : false},`);
70
+ lines.push(` ogSiteName: ${mt.ogSiteName !== undefined ? mt.ogSiteName : true},`);
71
+ if (mt.twitterCard) {
72
+ lines.push(` twitterCard: ${JSON.stringify(mt.twitterCard)},`);
73
+ if (mt.twitterSite) {
74
+ lines.push(` twitterSite: ${JSON.stringify(mt.twitterSite)},`);
75
+ }
76
+ if (mt.twitterCreator) {
77
+ lines.push(` twitterCreator: ${JSON.stringify(mt.twitterCreator)},`);
78
+ }
79
+ }
80
+ else {
81
+ lines.push(` twitterCard: false,`);
82
+ }
83
+ }
84
+ else {
85
+ lines.push(` description: true,`);
86
+ lines.push(` keywords: false,`);
87
+ lines.push(` ogImage: false,`);
88
+ lines.push(` ogSiteName: true,`);
89
+ lines.push(` twitterCard: false,`);
90
+ }
91
+ lines.push(` } satisfies MetaTagsConfig as MetaTagsConfig,`);
62
92
  lines.push(` docsDir: "src/content/docs",`);
63
93
  lines.push(` defaultLocale: ${JSON.stringify(choices.defaultLang ?? "en")} as const,`);
64
94
  if (choices.features.includes("i18n")) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-zudo-doc",
3
- "version": "0.2.4",
3
+ "version": "0.2.5",
4
4
  "description": "Create a new zudo-doc documentation site",
5
5
  "license": "MIT",
6
6
  "author": "Takeshi Takatsudo",
@@ -40,10 +40,11 @@
40
40
  import type { ComponentChildren } from "preact";
41
41
  // @slot:mdx-components:enlarge-imports
42
42
  import { htmlOverrides } from "@takazudo/zudo-doc/content";
43
- import { HtmlPreviewWrapper } from "@takazudo/zudo-doc/html-preview-wrapper";
43
+ import { HtmlPreviewWrapper, type HtmlPreviewWrapperProps } from "@takazudo/zudo-doc/html-preview-wrapper";
44
44
  import { Tabs } from "@takazudo/zudo-doc/code-syntax";
45
45
  import { TabItem } from "@takazudo/zudo-doc/tab-item";
46
46
  import { defaultLocale, type Locale } from "@/config/i18n";
47
+ import { settings } from "@/config/settings";
47
48
  import { withBase } from "@/utils/base";
48
49
  import { CategoryNavWrapper } from "./lib/_category-nav";
49
50
  import { CategoryTreeNavWrapper } from "./lib/_category-tree-nav";
@@ -120,6 +121,9 @@ function IslandWrapper(props: {
120
121
  }
121
122
 
122
123
  // @slot:mdx-components:enlarge-defs
124
+ const HtmlPreviewWithGlobalConfig = (props: HtmlPreviewWrapperProps) =>
125
+ HtmlPreviewWrapper({ globalConfig: settings.htmlPreview ?? null, ...props });
126
+
123
127
  /**
124
128
  * Build a locale-aware MDX components map for the given locale.
125
129
  *
@@ -164,7 +168,7 @@ export function createMdxComponents(lang: Locale | string = defaultLocale) {
164
168
  // site. withBase() is generic — any configured base value works.
165
169
  img: ContentImg,
166
170
  // @slot:mdx-components:enlarge-p-entry
167
- HtmlPreview: HtmlPreviewWrapper,
171
+ HtmlPreview: HtmlPreviewWithGlobalConfig,
168
172
  // Admonitions — real typed Preact components (src/components/content/
169
173
  // content-admonition.tsx) emitting the `.admonition` / `data-admonition`
170
174
  // structure the design-system CSS targets. The `directives` map in
@@ -176,7 +176,9 @@ export function DocPageShell(props: DocPageShellProps): JSX.Element {
176
176
  return (
177
177
  <DocLayoutWithDefaults
178
178
  title={composeMetaTitle(title)}
179
- description={description}
179
+ // Plain <meta name="description"> is emitted by DocLayout from this prop —
180
+ // gate it here alongside the og:description gate inside HeadWithDefaults (#2078)
181
+ description={settings.metaTags.description ? description : undefined}
180
182
  head={<HeadWithDefaults title={title} description={description} canonical={canonical} />}
181
183
  lang={locale}
182
184
  noindex={settings.noindex}
@@ -63,6 +63,10 @@ export interface HeadWithDefaultsProps {
63
63
  * (the legacy Astro layout produced both shapes; the zfb host has to
64
64
  * compose them itself).
65
65
  *
66
+ * og:title is always emitted — it is the unconditional DocHead contract
67
+ * (OgTags always emits og:title regardless of settings). All other tags
68
+ * are gated by settings.metaTags.
69
+ *
66
70
  * Pure SSR — no state, no client-only imports. Intended for use as:
67
71
  * head={<HeadWithDefaults title={title} description={description} canonical={canonical} />}
68
72
  * on every DocLayoutWithDefaults call site in the host pages.
@@ -72,6 +76,8 @@ export function HeadWithDefaults({
72
76
  description,
73
77
  canonical,
74
78
  }: HeadWithDefaultsProps): JSX.Element {
79
+ const { metaTags } = settings;
80
+
75
81
  // og:image / twitter:image must be absolute URLs — crawlers silently drop
76
82
  // relative og:image values. absoluteUrl joins siteUrl (no trailing slash) +
77
83
  // the base-prefixed asset path, and returns undefined when siteUrl is empty
@@ -80,7 +86,10 @@ export function HeadWithDefaults({
80
86
  // TwitterCard already gate their image emission on the prop being defined;
81
87
  // the og:image:* companion tags below are gated explicitly because they
82
88
  // would dangle without the parent og:image.
83
- const ogImageUrl = absoluteUrl(withBase("/img/ogp.png"));
89
+ const ogImageUrl =
90
+ metaTags.ogImage !== false
91
+ ? absoluteUrl(withBase(metaTags.ogImage))
92
+ : undefined;
84
93
 
85
94
  // Resolve the palette CSS body once per page render (the v2 component
86
95
  // is pure SSR — no caching needed).
@@ -93,12 +102,15 @@ export function HeadWithDefaults({
93
102
  <>
94
103
  <OgTags
95
104
  title={composeMetaTitle(title)}
96
- description={description}
105
+ description={metaTags.description ? description : undefined}
97
106
  ogType="website"
98
107
  ogUrl={canonical}
99
108
  ogImage={ogImageUrl}
100
- ogSiteName={settings.siteName}
109
+ ogSiteName={metaTags.ogSiteName ? settings.siteName : undefined}
101
110
  />
111
+ {metaTags.keywords !== false && metaTags.keywords.length > 0 && (
112
+ <meta name="keywords" content={metaTags.keywords} />
113
+ )}
102
114
  {/* og:image:width / og:image:height / og:image:alt — not in OgTags API;
103
115
  emitted here directly to avoid expanding the shared HeadProps surface.
104
116
  Standard 1200×630 social preview dimensions. Gated on ogImageUrl so
@@ -110,7 +122,14 @@ export function HeadWithDefaults({
110
122
  <meta property="og:image:alt" content={composeMetaTitle(title)} />
111
123
  </>
112
124
  )}
113
- <TwitterCard card="summary_large_image" image={ogImageUrl} />
125
+ {metaTags.twitterCard !== false && (
126
+ <TwitterCard
127
+ card={metaTags.twitterCard}
128
+ image={ogImageUrl}
129
+ site={metaTags.twitterSite}
130
+ creator={metaTags.twitterCreator}
131
+ />
132
+ )}
114
133
  <ColorSchemeProvider cssText={cssText} colorMode={colorMode} />
115
134
  {/* Pre-paint inline script: restore persisted sidebar width to
116
135
  --zd-sidebar-w on :root before first paint, so a reload after
@@ -160,3 +160,20 @@ export interface VersionConfig {
160
160
  /** Banner text shown on versioned pages (e.g., "unmaintained", "unreleased") */
161
161
  banner?: "unmaintained" | "unreleased" | false;
162
162
  }
163
+
164
+ export interface MetaTagsConfig {
165
+ /** Emit <meta name="description">. Default true. */
166
+ description: boolean;
167
+ /** Emit <meta name="keywords"> with a comma-separated string. false = omit. Default false. */
168
+ keywords: string | false;
169
+ /** og:image (and twitter:image) path. false = omit. Default false. Showcase: '/img/ogp.png'. */
170
+ ogImage: string | false;
171
+ /** Emit og:site_name. Default true (preserves current og:site_name). */
172
+ ogSiteName: boolean;
173
+ /** TwitterCard type. false = omit entire twitter:card block. Default false. Showcase: 'summary_large_image'. */
174
+ twitterCard: "summary" | "summary_large_image" | false;
175
+ /** twitter:site handle (e.g. '@yourbrand'). Optional. */
176
+ twitterSite?: string;
177
+ /** twitter:creator handle. Optional. */
178
+ twitterCreator?: string;
179
+ }
@@ -1,4 +1,15 @@
1
- @import "tailwindcss/preflight";
1
+ /* Cascade-layer order for the flow-space fix (zudolab/zudo-doc#2082 / #2109):
2
+ * zd-preflight — Tailwind's preflight reset (incl. `* { margin: 0 }`)
3
+ * zd-flow — the .zd-content flow-space margin-top rule (below)
4
+ * (unlayered) — `tailwindcss/utilities` (mt-* etc.) — highest priority
5
+ * Unlayered always beats any layer, so `mt-*` utilities win over the flow
6
+ * rule; the flow rule (zd-flow) sits above zd-preflight so it still beats the
7
+ * preflight `margin: 0` reset and the .zd-content vertical rhythm is preserved.
8
+ * Preflight is the de-facto lowest-priority origin already, so moving it into
9
+ * the lowest layer is observably inert for every other author rule (which stay
10
+ * unlayered and continue to outrank it). */
11
+ @layer zd-preflight, zd-flow;
12
+ @import "tailwindcss/preflight" layer(zd-preflight);
2
13
  @import "tailwindcss/utilities";
3
14
 
4
15
  /* ========================================
@@ -263,8 +274,21 @@ body {
263
274
 
264
275
  /* ── Flow spacing (vertical rhythm) ── */
265
276
 
266
- .zd-content > :where(* + *) {
267
- margin-top: var(--flow-space, var(--spacing-vsp-md));
277
+ /* In the `zd-flow` cascade layer (declared at the top of this file) so the
278
+ * unlayered `tailwindcss/utilities` import wins over it: unlayered declarations
279
+ * always beat layered ones regardless of specificity or source order. Both this
280
+ * rule and a `mt-*` utility have specificity (0,1,0); without the layer this
281
+ * rule comes later in source order and silently kills every `mt-*` on a
282
+ * `.zd-content` direct child. zd-flow sits ABOVE zd-preflight so this rule still
283
+ * beats preflight's `* { margin: 0 }` reset and the .zd-content rhythm is
284
+ * preserved for plain content blocks. The layer is scoped to ONLY this flow rule
285
+ * — every other `.zd-content` author rule (links, code, lists, headings, the
286
+ * `:first-child` reset, etc.) stays unlayered so utilities do NOT flip those.
287
+ * See zudolab/zudo-doc#2082 / #2109. */
288
+ @layer zd-flow {
289
+ .zd-content > :where(* + *) {
290
+ margin-top: var(--flow-space, var(--spacing-vsp-md));
291
+ }
268
292
  }
269
293
 
270
294
  /* ── Headings ── */
@@ -938,7 +962,17 @@ pre[class^="syntect-"] .line .highlighted-word {
938
962
  * article, TOC, etc.). The twelve ::view-transition-{old,new,group}(<name>)
939
963
  * rules disable animation for all four chrome layers. The group pseudo must be
940
964
  * neutralised too — even when old/new are static the group container can still
941
- * produce a geometry-morph animation when snapshot size/position differs. */
965
+ * produce a geometry-morph animation when snapshot size/position differs.
966
+ *
967
+ * Entry/exit cross-fade (zudolab/zudo-doc#2072): animation: none is only
968
+ * correct when the chrome element exists on BOTH pages of a navigation.
969
+ * When it exists on one side only (docs page with sidebar → top page
970
+ * without), the named group holds a single snapshot — frozen at full
971
+ * opacity for the whole transition, then dropped abruptly at finish. The
972
+ * :only-child rules below detect that one-sided case and cross-fade the
973
+ * lone snapshot in sync with the root content fade (spec-standard
974
+ * entry/exit pattern; :only-child outranks the animation: none rules via
975
+ * the extra pseudo-class specificity). */
942
976
 
943
977
  /* Chrome extraction — assign view-transition-name from data-zfb-transition-persist */
944
978
  [data-zfb-transition-persist^="header-"] { view-transition-name: zfb-header; }
@@ -960,6 +994,22 @@ pre[class^="syntect-"] .line .highlighted-word {
960
994
  ::view-transition-new(zfb-sidebar-toggle),
961
995
  ::view-transition-group(zfb-sidebar-toggle) { animation: none; }
962
996
 
997
+ /* Entry/exit: chrome element present on one side only (#2072).
998
+ * Exit — lone old snapshot fades out with the content cross-fade. */
999
+ ::view-transition-old(zfb-header):only-child,
1000
+ ::view-transition-old(zfb-sidebar):only-child,
1001
+ ::view-transition-old(zfb-footer):only-child,
1002
+ ::view-transition-old(zfb-sidebar-toggle):only-child {
1003
+ animation: 150ms ease-in both contentFadeOut;
1004
+ }
1005
+ /* Entry — lone new snapshot fades in with the content cross-fade. */
1006
+ ::view-transition-new(zfb-header):only-child,
1007
+ ::view-transition-new(zfb-sidebar):only-child,
1008
+ ::view-transition-new(zfb-footer):only-child,
1009
+ ::view-transition-new(zfb-sidebar-toggle):only-child {
1010
+ animation: 300ms ease-out both contentFadeIn;
1011
+ }
1012
+
963
1013
  /* Root cross-fade rules — animate only the non-chrome snapshot */
964
1014
  ::view-transition-old(root) {
965
1015
  animation: 150ms ease-in both contentFadeOut;
@@ -968,6 +1018,27 @@ pre[class^="syntect-"] .line .highlighted-word {
968
1018
  animation: 300ms ease-out both contentFadeIn;
969
1019
  }
970
1020
 
1021
+ /* Reduced motion: collapse all view-transition animation to an instant
1022
+ * swap (zudolab/zudo-doc#2086). With every snapshot animation at none the
1023
+ * transition settles immediately — no cross-fade, no translateY slide.
1024
+ * Covers the root cross-fade and the #2072 :only-child entry/exit rules;
1025
+ * the both-sides chrome layers are already animation: none above. Equal
1026
+ * specificity per selector, but later in source order, so these win. */
1027
+ @media (prefers-reduced-motion: reduce) {
1028
+ ::view-transition-old(root),
1029
+ ::view-transition-new(root),
1030
+ ::view-transition-old(zfb-header):only-child,
1031
+ ::view-transition-old(zfb-sidebar):only-child,
1032
+ ::view-transition-old(zfb-footer):only-child,
1033
+ ::view-transition-old(zfb-sidebar-toggle):only-child,
1034
+ ::view-transition-new(zfb-header):only-child,
1035
+ ::view-transition-new(zfb-sidebar):only-child,
1036
+ ::view-transition-new(zfb-footer):only-child,
1037
+ ::view-transition-new(zfb-sidebar-toggle):only-child {
1038
+ animation: none;
1039
+ }
1040
+ }
1041
+
971
1042
  /* Version-switcher responsive visibility (moved out of the component to
972
1043
  * avoid <style>-inside-<div> HTML5 content-model violation; required when
973
1044
  * the i18nVersion feature ships a <VersionSwitcher> wrapped in