@takazudo/zudo-doc 8.0.0 → 8.1.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.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,22 @@ The format is based on Keep a Changelog, and release notes are generated from th
8
8
 
9
9
  No unreleased changes.
10
10
 
11
+ ## [8.1.0] - 2026-10-25
12
+
13
+ ### Features
14
+
15
+ - Add the opt-in `headerBrand` setting (`false | { icon: string }`, default `false`): a decorative square icon painted with the text colour (CSS mask) before the site name in the header. `/`-prefixed paths receive the site `base` once, other URLs are used as-is, and an empty or missing `icon` (or `true`) throws at config resolution. The site name stays the link's accessible name, long names still ellipsize, and theme-pack leading emblems are hidden while an icon is set. (fb3358ca)
16
+ - Add a `data-zd-site-name` attribute to the home hero `<h1>` as a styling hook for the site name. (fb3358ca)
17
+
18
+ ### Bug Fixes
19
+
20
+ - With dynamic page transitions enabled, navigating from a footer link (or any focused link in persisted chrome) now finishes at the top of the destination page on current Chromium builds instead of staying near the footer. A package-owned inline head script settles the browser's queued focus reveal after the persisted DOM move and before the router applies its top, hash or history scroll. Focus, persistence, transitions and scroll restoration are unchanged. Sites that pin inline-script hashes in a Content Security Policy need to add the new script's hash. (524a440f)
21
+ - In `SiteTreeNav`, labels now always start a small gap after the end of their dashed branch. On wide viewports the double-width homepage branches used to run into the label text. (3e6e720f)
22
+
23
+ ### Other Changes
24
+
25
+ - Requires zfb 5.1.2: the `@takazudo/zfb`, `zfb-runtime` and `zfb-md-wasm` peer floors move from `^5.0.1` to `^5.1.2`. zfb 5.1.0 adds an islands build guard: the build now fails if an emitted islands chunk imports the islands entry, because that import would 404 in production. zfb 5.1.1 is a fix release for Windows hosts (bare-package subpath aliases and `pnpm.cmd` spawning) and for projects built from a symlinked path. zfb 5.1.2 ships rebuilt native engine and WASM binaries with no change to the JavaScript API. Chunking is otherwise unchanged. Upgrade the zfb family (and `zfb-adapter-cloudflare` when used) to 5.1.2 together with this release. (6eef9416, 44d7684b)
26
+
11
27
  ## [8.0.0] - 2026-10-24
12
28
 
13
29
  ### Breaking Changes
package/README.md CHANGED
@@ -109,7 +109,7 @@ If your project renders **semi-trusted or user-submitted** HTML in a preview, ov
109
109
 
110
110
  **6.0.0** is the coordinated major release of `@takazudo/zudo-doc`,
111
111
  `@takazudo/zudo-doc-history-server` and `create-zudo-doc`. The current workspace
112
- targets the published **zfb 5.0.1** family (peer floor **^5.0.1**).
112
+ targets the published **zfb 5.1.2** family (peer floor **^5.1.2**).
113
113
  [Upstream #4097](https://github.com/Takazudo/zudo-front-builder/issues/4097) is
114
114
  resolved; its strict published 4.3.1 consumer control passed 3/3 with no skips or
115
115
  retries in the [source-bound verification](../../docs/findings/zfb-4.3.1/verification.md).
package/dist/compiled.css CHANGED
@@ -1864,6 +1864,21 @@ nav[data-zd-toc], div[data-zd-mobile-toc] {
1864
1864
  font-family: var(--zdc-toc-font, var(--zdc-chrome-font, var(--font-sans)));
1865
1865
  }
1866
1866
 
1867
+ [data-header-brand-icon] {
1868
+ display: inline-block;
1869
+ width: 1.1em;
1870
+ height: 1.1em;
1871
+ vertical-align: -.2em;
1872
+ margin-inline-end: var(--spacing-hsp-sm);
1873
+ background-color: currentColor;
1874
+ -webkit-mask: var(--zd-header-brand-icon) center/contain no-repeat;
1875
+ mask: var(--zd-header-brand-icon) center/contain no-repeat;
1876
+ }
1877
+
1878
+ html[data-theme-pack] [data-header-logo][data-header-brand]:before {
1879
+ content: none;
1880
+ }
1881
+
1867
1882
  button[data-zd-pending] {
1868
1883
  opacity: .7;
1869
1884
  pointer-events: none;
@@ -55,6 +55,19 @@ export interface EmptyStringFaviconOrLogoSubject {
55
55
  * object is not an empty string).
56
56
  */
57
57
  export declare function assertNoEmptyStringFaviconOrLogo(settings: EmptyStringFaviconOrLogoSubject): void;
58
+ /** Structural subset of `Settings` {@link assertValidHeaderBrand} reads. */
59
+ export interface HeaderBrandSubject {
60
+ headerBrand?: unknown;
61
+ }
62
+ /**
63
+ * Validate `headerBrand`: `undefined`, `false`, or `{ icon: <non-empty string> }`.
64
+ *
65
+ * The value is typed `false | HeaderBrandConfig`, but JavaScript callers and
66
+ * casted values can still pass `true`, a bare string, an array, or an object
67
+ * whose `icon` is missing/empty — each of which would render a broken mask in
68
+ * every page header. Fail loudly at config resolution, naming the field.
69
+ */
70
+ export declare function assertValidHeaderBrand(settings: HeaderBrandSubject): void;
58
71
  /** Structural subset of `Settings` the zdtp-bundling helpers read. */
59
72
  export interface ZdtpBundlingSubject {
60
73
  designTokenPanel?: boolean;
@@ -32,6 +32,30 @@ function assertNoEmptyStringFaviconOrLogo(settings) {
32
32
  }
33
33
  }
34
34
  }
35
+ function assertValidHeaderBrand(settings) {
36
+ const value = settings.headerBrand;
37
+ if (value === void 0 || value === false) return;
38
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
39
+ throw new TypeError(
40
+ `Invalid headerBrand ${describeValue(value)}: pass false (or omit it) for no icon, or an object { icon: "/img/icon.svg" }.`
41
+ );
42
+ }
43
+ const icon = value.icon;
44
+ if (typeof icon !== "string" || icon === "") {
45
+ throw new TypeError(
46
+ `Invalid headerBrand.icon ${describeValue(icon)}: must be a non-empty string (a "/"-prefixed path, a data: URL, or an absolute URL).`
47
+ );
48
+ }
49
+ }
50
+ function describeValue(value) {
51
+ if (value === void 0) return "undefined";
52
+ if (Array.isArray(value)) return "(array)";
53
+ try {
54
+ return JSON.stringify(value) ?? String(value);
55
+ } catch {
56
+ return String(value);
57
+ }
58
+ }
35
59
  function resolvesBundleZdtp(settings) {
36
60
  return settings.bundleZdtp ?? settings.designTokenPanel === true;
37
61
  }
@@ -92,6 +116,7 @@ function warnAmbiguousDropdownCategoryMatch(headerNav, logger = console) {
92
116
  export {
93
117
  assertAgentDocsConsistent,
94
118
  assertNoEmptyStringFaviconOrLogo,
119
+ assertValidHeaderBrand,
95
120
  assertValidSearchMaxBodyLength,
96
121
  assertZdtpBundlingConsistent,
97
122
  resolvesBundleZdtp,
package/dist/config.d.ts CHANGED
@@ -70,7 +70,7 @@ import type { ZfbConfig, BundleConfig, WindConfig } from "@takazudo/zfb/config";
70
70
  import type { ZodType } from "zod";
71
71
  import type { DirectiveVocabulary, PresetTranslations, PresetTagVocabularyEntry } from "./preset.js";
72
72
  import type { ColorScheme } from "./color-scheme-utils.js";
73
- import type { Settings, ColorModeConfig, LocaleConfig, MetaTagsConfig, SiteHeadConfig, TagPlacement, TagGovernanceMode, VersionConfig, FooterConfig, HeaderNavItem, HeaderRightItem, HomeConfig, AssetViewerIndexingConfig, FrontmatterPreviewConfig, BodyFootUtilAreaConfig, HtmlPreviewConfig, FaviconConfig, DateFormatSetting } from "./settings.js";
73
+ import type { Settings, ColorModeConfig, LocaleConfig, MetaTagsConfig, SiteHeadConfig, TagPlacement, TagGovernanceMode, VersionConfig, FooterConfig, HeaderNavItem, HeaderRightItem, HomeConfig, AssetViewerIndexingConfig, FrontmatterPreviewConfig, BodyFootUtilAreaConfig, HtmlPreviewConfig, FaviconConfig, HeaderBrandConfig, DateFormatSetting } from "./settings.js";
74
74
  /** The `settings.claudeResources` block (or `false` when disabled). */
75
75
  type ClaudeResourcesConfig = {
76
76
  claudeDir: string;
@@ -132,6 +132,17 @@ export interface ZudoDocConfig {
132
132
  * @default undefined
133
133
  */
134
134
  favicon?: string | FaviconConfig | false;
135
+ /**
136
+ * Header brand icon rendered before the site name in the header logo
137
+ * anchor, as a decorative `currentColor` CSS mask (`aria-hidden`; the
138
+ * anchor's accessible name stays `siteName`). `false` renders the site name
139
+ * alone. A `HeaderBrandConfig` (`{ icon }`) paints `icon`: a value starting
140
+ * with `/` is prefixed with `base`, anything else (`data:`, `https://…`) is
141
+ * used verbatim. `true`, arrays, other non-objects, and a missing,
142
+ * non-string or empty `icon` throw a `TypeError` at config resolution.
143
+ * @default false
144
+ */
145
+ headerBrand?: false | HeaderBrandConfig;
135
146
  /**
136
147
  * Public URL sub-path prefix mounted in front of every absolute asset URL
137
148
  * (e.g. `"/pj/my-site/"`). `"/"` = root-mounted.
package/dist/config.js CHANGED
@@ -6,6 +6,7 @@ import { defaultColorSchemes } from "./color-schemes-defaults/index.js";
6
6
  import { assertNoCommaInVersionSlugs } from "./version-availability/index.js";
7
7
  import {
8
8
  assertNoEmptyStringFaviconOrLogo,
9
+ assertValidHeaderBrand,
9
10
  assertZdtpBundlingConsistent,
10
11
  assertValidSearchMaxBodyLength,
11
12
  assertAgentDocsConsistent
@@ -79,6 +80,7 @@ function zudoDoc(user = {}) {
79
80
  );
80
81
  assertNoCommaInVersionSlugs(settings.versions);
81
82
  assertNoEmptyStringFaviconOrLogo(settings);
83
+ assertValidHeaderBrand(settings);
82
84
  assertZdtpBundlingConsistent(settings);
83
85
  assertValidSearchMaxBodyLength(settings.searchMaxBodyLength);
84
86
  assertAgentDocsConsistent(settings);
@@ -1,6 +1,7 @@
1
1
  import { jsx, jsxs } from "@takazudo/zfb/zudo-react/jsx-runtime";
2
2
  import { ClientRouter } from "@takazudo/zfb-runtime";
3
3
  import { buildChromeTransitionBootstrap } from "../transitions/chrome-transition-bootstrap.js";
4
+ import { buildPersistedFocusSettleBootstrap } from "../transitions/persisted-focus-settle-bootstrap.js";
4
5
  const DESKTOP_SIDEBAR_ID = "desktop-sidebar";
5
6
  function DocLayout(props) {
6
7
  const {
@@ -71,7 +72,10 @@ function DocLayout(props) {
71
72
  );
72
73
  }
73
74
  if (head !== void 0) headChildren.push(head);
74
- if (enableClientRouter !== false) headChildren.push(/* @__PURE__ */ jsx("script", { rawHtml: buildChromeTransitionBootstrap() }));
75
+ if (enableClientRouter !== false) {
76
+ headChildren.push(/* @__PURE__ */ jsx("script", { rawHtml: buildChromeTransitionBootstrap() }));
77
+ headChildren.push(/* @__PURE__ */ jsx("script", { rawHtml: buildPersistedFocusSettleBootstrap() }));
78
+ }
75
79
  return /* @__PURE__ */ jsxs("html", { ...htmlAttrs, children: [
76
80
  /* @__PURE__ */ jsx("head", { children: headChildren }),
77
81
  /* @__PURE__ */ jsxs("body", { class: "min-h-screen antialiased", children: [
package/dist/features.css CHANGED
@@ -1026,6 +1026,30 @@ nav[data-zd-toc], div[data-zd-mobile-toc] {
1026
1026
 
1027
1027
  /* END --zdc-* chrome component tokens */
1028
1028
 
1029
+ /* ======== Header brand icon (settings.headerBrand) ======== */
1030
+
1031
+ /* Decorative icon inside the site-name anchor. Sized in `em` so it follows a
1032
+ * theme pack's logo font-size inside the fixed-height header; painted as a
1033
+ * currentColor mask so it always matches the site-name ink. The anchor stays
1034
+ * a truncating flow box — the icon is an inline-block run-in, not a flex item. */
1035
+ [data-header-brand-icon] {
1036
+ display: inline-block;
1037
+ width: 1.1em;
1038
+ height: 1.1em;
1039
+ vertical-align: -0.2em;
1040
+ margin-inline-end: var(--spacing-hsp-sm);
1041
+ background-color: currentColor;
1042
+ -webkit-mask: var(--zd-header-brand-icon) center/contain no-repeat;
1043
+ mask: var(--zd-header-brand-icon) center/contain no-repeat;
1044
+ }
1045
+
1046
+ /* A brand icon replaces a theme pack's decorative logo emblem rather than
1047
+ * doubling it. Specificity (0,3,2) beats every pack's `[data-header-logo]::before`
1048
+ * rule (0,2,2); `::after` marks are left alone. */
1049
+ html[data-theme-pack] [data-header-logo][data-header-brand]::before {
1050
+ content: none;
1051
+ }
1052
+
1029
1053
  button[data-zd-pending] {
1030
1054
  opacity: 0.7;
1031
1055
  pointer-events: none;
@@ -2,6 +2,7 @@
2
2
  import type { Child } from "@takazudo/zfb/zudo-react";
3
3
  import type { JSX } from "@takazudo/zfb/zudo-react/jsx-runtime";
4
4
  import type { HeaderNavItem, HeaderRightComponentRegistry, HeaderRightItem, Locale } from "./types.js";
5
+ import type { HeaderBrandConfig } from "../settings.js";
5
6
  /**
6
7
  * Boundary helpers the host injects into `<Header>`. These are the URL
7
8
  * builders the legacy header.tsx used to import from `@/utils/base` —
@@ -92,6 +93,14 @@ export interface HeaderProps {
92
93
  persistKey: string;
93
94
  /** Site-name string shown in the logo anchor (host `settings.siteName`). */
94
95
  siteName: string;
96
+ /**
97
+ * Optional brand icon rendered before `siteName` inside the logo anchor
98
+ * (host `settings.headerBrand`). Omitted or `false` renders the anchor
99
+ * exactly as before. The icon is a decorative `currentColor` CSS mask; an
100
+ * `icon` starting with `/` goes through `urlHelpers.withBase()`, anything
101
+ * else is used verbatim.
102
+ */
103
+ headerBrand?: false | HeaderBrandConfig;
95
104
  /** Header-nav items in render order (host `settings.headerNav`). */
96
105
  headerNav: HeaderNavItem[];
97
106
  /**
@@ -32,6 +32,7 @@ function Header(props) {
32
32
  search,
33
33
  persistKey,
34
34
  siteName,
35
+ headerBrand,
35
36
  headerNav,
36
37
  headerRightItems,
37
38
  headerRightComponents,
@@ -48,6 +49,8 @@ function Header(props) {
48
49
  const pathWithoutVersion = stripCurrentVersionPrefix(pathWithoutBase, currentVersion);
49
50
  const matchPath = pathForMatch(pathWithoutVersion, lang, i18n.defaultLocale);
50
51
  const activeNavPath = computeActiveNavPath(headerNav, matchPath);
52
+ const brandIcon = headerBrand && typeof headerBrand.icon === "string" && headerBrand.icon !== "" ? headerBrand.icon : null;
53
+ const brandIconUrl = brandIcon === null ? null : brandIcon.startsWith("/") && !brandIcon.startsWith("//") ? urlHelpers.withBase(brandIcon) : brandIcon;
51
54
  const rightItemDispatch = createRightItemDispatch(headerRightComponents);
52
55
  const rightItems = headerRightItems.map((item, i) => ({
53
56
  item,
@@ -98,14 +101,27 @@ function Header(props) {
98
101
  // (mobile sidebar contents stay accessible to assistive tech
99
102
  // even without the toggle island).
100
103
  /* @__PURE__ */ jsx(SidebarSlotFallback, { children: sidebarSlot }),
101
- /* @__PURE__ */ jsx(
104
+ /* @__PURE__ */ jsxs(
102
105
  "a",
103
106
  {
104
107
  href: urlHelpers.withBase(isNonDefaultLocale ? `/${lang}/` : "/"),
105
108
  class: "min-w-0 truncate text-title font-bold text-fg hover:underline focus:underline",
106
109
  title: siteName,
107
110
  "data-header-logo": true,
108
- children: siteName
111
+ "data-header-brand": brandIconUrl !== null ? true : void 0,
112
+ children: [
113
+ brandIconUrl !== null && // Painted by features.css as a currentColor mask so it follows the
114
+ // site-name ink; decorative, the anchor's name stays the site name.
115
+ /* @__PURE__ */ jsx(
116
+ "span",
117
+ {
118
+ "aria-hidden": "true",
119
+ "data-header-brand-icon": true,
120
+ style: { "--zd-header-brand-icon": cssUrl(brandIconUrl) }
121
+ }
122
+ ),
123
+ siteName
124
+ ]
109
125
  }
110
126
  ),
111
127
  /* @__PURE__ */ jsxs(
@@ -486,6 +502,9 @@ function renderRightItem(item, index, ctx, dispatch) {
486
502
  }
487
503
  return null;
488
504
  }
505
+ function cssUrl(url) {
506
+ return `url("${url.replace(/[\\"]/g, "\\$&").replace(/[\n\r\f]/g, "\\a ")}")`;
507
+ }
489
508
  export {
490
509
  Header
491
510
  };
@@ -1,7 +1,7 @@
1
1
  /** @jsxRuntime automatic */
2
2
  import type { JSX } from "@takazudo/zfb/zudo-react/jsx-runtime";
3
3
  import type { ChromeContext } from "../factory-context/index.js";
4
- import type { Settings } from "../settings.js";
4
+ import type { HeaderBrandConfig, Settings } from "../settings.js";
5
5
  import type { SidebarNavNode } from "../sidebar/types.js";
6
6
  export interface HeaderWithDefaultsProps {
7
7
  /** Active locale; defaults to the configured defaultLocale. */
@@ -44,6 +44,8 @@ export interface HeaderVersionEntry {
44
44
  /** Settings subset read by {@link createHeaderWithDefaults}. */
45
45
  export interface HeaderWithDefaultsSettings {
46
46
  siteName: string;
47
+ /** Optional header brand icon — forwarded verbatim to `<Header>`. */
48
+ headerBrand?: false | HeaderBrandConfig;
47
49
  /** Site base path — fed to the language-switcher SPA re-wire config. */
48
50
  base: string;
49
51
  /** Trailing-slash policy — fed to the language-switcher SPA re-wire config. */
@@ -194,6 +194,7 @@ function createHeaderWithDefaults(ctx) {
194
194
  languageSwitcher,
195
195
  persistKey,
196
196
  siteName: settings.siteName,
197
+ headerBrand: settings.headerBrand,
197
198
  headerNav: settings.headerNav,
198
199
  headerRightItems,
199
200
  headerRightComponents: ctx.hostBindings.headerRightComponents,
@@ -174,7 +174,7 @@ function createHomePageView(ctx) {
174
174
  }
175
175
  ) : null,
176
176
  /* @__PURE__ */ jsxs("div", { class: "zd-home-copy min-w-0 lg:flex-1", children: [
177
- /* @__PURE__ */ jsx("h1", { class: "text-heading font-bold mb-vsp-2xs zd-wrap-anywhere", children: settings.siteName }),
177
+ /* @__PURE__ */ jsx("h1", { class: "text-heading font-bold mb-vsp-2xs zd-wrap-anywhere", "data-zd-site-name": true, children: settings.siteName }),
178
178
  /* @__PURE__ */ jsx("p", { class: "text-muted text-small mb-vsp-sm", children: rawDescription }),
179
179
  /* @__PURE__ */ jsx("div", { class: "zd-home-links flex flex-wrap items-center justify-center lg:justify-start gap-hsp-md text-small", children: rowContent })
180
180
  ] })
@@ -5,12 +5,12 @@ function IndexList(props) {
5
5
  const width = rankWidth(props.items);
6
6
  return /* @__PURE__ */ jsx("ol", { class: "zd-list-items-no-margin", children: props.items.map((item) => {
7
7
  const rowClass = item.href ? "relative -mt-px first:mt-0 border-y border-muted hover:z-local-1 hover:border-accent focus-within:z-local-1 focus-within:border-accent" : "relative -mt-px first:mt-0 border-y border-muted";
8
- const rankClass = item.href ? "tabular-nums text-heading leading-none text-muted group-hover:text-fg group-focus-visible:text-fg" : "tabular-nums text-heading leading-none text-muted";
8
+ const rankClass = item.href ? "whitespace-nowrap tabular-nums text-heading leading-none text-muted group-hover:text-fg group-focus-visible:text-fg" : "whitespace-nowrap tabular-nums text-heading leading-none text-muted";
9
9
  const labelClass = item.href ? "font-medium text-fg group-hover:text-accent group-hover:underline group-focus-visible:text-accent group-focus-visible:underline" : "font-medium text-fg";
10
10
  const detailClass = item.href ? "mt-vsp-2xs block text-small text-muted group-hover:text-accent group-hover:underline group-focus-visible:text-accent group-focus-visible:underline" : "mt-vsp-2xs block text-small text-muted";
11
11
  const dateClass = item.href ? "mt-vsp-2xs block text-caption text-muted group-hover:text-accent group-hover:underline group-focus-visible:text-accent group-focus-visible:underline" : "mt-vsp-2xs block text-caption text-muted";
12
12
  const rowContent = /* @__PURE__ */ jsxs(Fragment, { children: [
13
- /* @__PURE__ */ jsx("span", { class: rankClass, style: { width: `${width}ch` }, children: item.rank === void 0 ? "" : String(item.rank).padStart(width, "0") }),
13
+ /* @__PURE__ */ jsx("span", { class: rankClass, style: { "min-width": `${width}ch` }, children: item.rank === void 0 ? "" : String(item.rank).padStart(width, "0") }),
14
14
  /* @__PURE__ */ jsxs("span", { class: "min-w-0", children: [
15
15
  /* @__PURE__ */ jsx("span", { class: labelClass, children: item.label }),
16
16
  item.description && /* @__PURE__ */ jsx("span", { class: detailClass, children: item.description }),
package/dist/preset.d.ts CHANGED
@@ -36,7 +36,7 @@
36
36
  import { z } from "zod";
37
37
  import type { DirectiveSpec, ZfbConfig } from "@takazudo/zfb/config";
38
38
  import type { ColorScheme } from "./color-scheme-utils.js";
39
- import type { AssetViewerIndexingConfig, TagVocabularyEntry, FaviconConfig, HomeConfig } from "./settings.js";
39
+ import type { AssetViewerIndexingConfig, TagVocabularyEntry, FaviconConfig, HeaderBrandConfig, HomeConfig } from "./settings.js";
40
40
  /** A single locale's content directory (`settings.locales[code]`). */
41
41
  export interface PresetLocaleConfig {
42
42
  dir: string;
@@ -109,6 +109,9 @@ export interface PresetSettings {
109
109
  /** Favicon links. Same rationale as {@link PresetSettings.logo} — carried
110
110
  * only for `assertNoEmptyStringFaviconOrLogo` (#3474). */
111
111
  favicon?: string | FaviconConfig | false;
112
+ /** Header brand icon. Same rationale as {@link PresetSettings.logo} — carried
113
+ * only for `assertValidHeaderBrand`; rendering happens in the header. */
114
+ headerBrand?: false | HeaderBrandConfig;
112
115
  /** GitHub repository URL used by asset backlink metadata. */
113
116
  githubUrl?: string | false;
114
117
  siteUrl: string;
package/dist/preset.js CHANGED
@@ -2,6 +2,7 @@ import { z } from "zod";
2
2
  import { assertNoCommaInVersionSlugs } from "./version-availability/index.js";
3
3
  import {
4
4
  assertNoEmptyStringFaviconOrLogo,
5
+ assertValidHeaderBrand,
5
6
  assertZdtpBundlingConsistent,
6
7
  assertValidSearchMaxBodyLength,
7
8
  assertAgentDocsConsistent,
@@ -19,6 +20,7 @@ function zudoDocPreset({
19
20
  }) {
20
21
  assertNoCommaInVersionSlugs(settings.versions);
21
22
  assertNoEmptyStringFaviconOrLogo(settings);
23
+ assertValidHeaderBrand(settings);
22
24
  assertZdtpBundlingConsistent(settings);
23
25
  assertValidSearchMaxBodyLength(settings.searchMaxBodyLength);
24
26
  assertAgentDocsConsistent(settings);
@@ -10,6 +10,7 @@ const DEFAULT_SETTINGS = {
10
10
  siteDescription: "",
11
11
  logo: "auto",
12
12
  favicon: void 0,
13
+ headerBrand: false,
13
14
  base: "/",
14
15
  trailingSlash: false,
15
16
  home: { wide: false, introMarkdown: "", sitemapHeading: "" },
@@ -260,6 +260,19 @@ export interface FaviconConfig {
260
260
  png16?: string;
261
261
  ico?: string;
262
262
  }
263
+ /**
264
+ * Opt-in header brand icon rendered before the site name in the header logo
265
+ * anchor. The icon is painted as a `currentColor` CSS mask, so it follows the
266
+ * site-name ink in every color scheme and theme pack; it is decorative
267
+ * (`aria-hidden`) and the anchor's accessible name stays the site name.
268
+ *
269
+ * `icon` values starting with `/` are passed through `withBase()`; anything
270
+ * else (`data:`, `https://…`) is used verbatim. The value must be a non-empty
271
+ * string — anything else throws a `TypeError` at config resolution.
272
+ */
273
+ export interface HeaderBrandConfig {
274
+ icon: string;
275
+ }
263
276
  /**
264
277
  * Site-wide custom `<head>` extras injected into every page via
265
278
  * {@link HeadWithDefaults}. All fields are JSON-serializable (no VNodes or
@@ -341,6 +354,14 @@ export interface Settings {
341
354
  * resolves to the current document — pass `false` or omit instead (#3471).
342
355
  */
343
356
  favicon?: string | FaviconConfig | false;
357
+ /**
358
+ * Header brand icon shown before the site name in the header logo anchor.
359
+ * Omitted or `false` (the default) renders the site name alone, exactly as
360
+ * before; a {@link HeaderBrandConfig} renders its `icon` as a decorative
361
+ * `currentColor` mask. Invalid values (`true`, a non-object, an array, a
362
+ * missing/non-string/empty `icon`) throw a `TypeError` at config resolution.
363
+ */
364
+ headerBrand?: false | HeaderBrandConfig;
344
365
  base: string;
345
366
  trailingSlash: boolean;
346
367
  /** Package-owned home-page layout. Narrow when omitted. */
@@ -9,6 +9,7 @@ import {
9
9
  import {
10
10
  INDENT,
11
11
  connectorLeft,
12
+ connectorLabelStart,
12
13
  ConnectorLines,
13
14
  CategoryLinkIcon
14
15
  } from "../tree-nav-shared/index.js";
@@ -24,9 +25,11 @@ import {
24
25
  import { formatMonthDay } from "../format-date/index.js";
25
26
  import { initialCategoryOpenState, toggleCategoryOpenState } from "./state.js";
26
27
  const SITE_BASE_PAD = "clamp(0.5rem, 0.8vw, 1rem)";
28
+ const CONNECTOR_WIDTH_SCALE = 2;
29
+ const CONNECTOR_LABEL_GAP = "var(--spacing-hsp-xs)";
27
30
  function padLeft(depth) {
28
31
  if (depth === 0) return SITE_BASE_PAD;
29
- return `calc(${depth} * ${INDENT} + 1.25rem + 5px)`;
32
+ return `max(calc(${depth} * ${INDENT} + 1.25rem + 5px), ${connectorLabelStart(depth, CONNECTOR_WIDTH_SCALE, CONNECTOR_LABEL_GAP)})`;
30
33
  }
31
34
  function reorderTree(tree, order) {
32
35
  const map = new Map(tree.map((node) => [node.slug, node]));
@@ -158,7 +161,7 @@ function CategoryNode({
158
161
  {
159
162
  depth,
160
163
  isLast: true,
161
- widthScale: 2,
164
+ widthScale: CONNECTOR_WIDTH_SCALE,
162
165
  topPad: "calc(0.15rem + var(--spacing-vsp-xs))"
163
166
  }
164
167
  ) }),
@@ -167,7 +170,7 @@ function CategoryNode({
167
170
  {
168
171
  depth,
169
172
  isLast: false,
170
- widthScale: 2,
173
+ widthScale: CONNECTOR_WIDTH_SCALE,
171
174
  topPad: "calc(0.15rem + var(--spacing-vsp-xs))"
172
175
  }
173
176
  ) }),
@@ -370,7 +373,7 @@ function LeafNode({
370
373
  {
371
374
  depth,
372
375
  isLast: true,
373
- widthScale: 2,
376
+ widthScale: CONNECTOR_WIDTH_SCALE,
374
377
  topPad
375
378
  }
376
379
  ) }),
@@ -379,7 +382,7 @@ function LeafNode({
379
382
  {
380
383
  depth,
381
384
  isLast: false,
382
- widthScale: 2,
385
+ widthScale: CONNECTOR_WIDTH_SCALE,
383
386
  topPad
384
387
  }
385
388
  ) }),
@@ -0,0 +1,2 @@
1
+ /** Settles a retained persisted focus before the router applies its scroll policy. */
2
+ export declare function buildPersistedFocusSettleBootstrap(): string;
@@ -0,0 +1,22 @@
1
+ import { BEFORE_SWAP_EVENT } from "./page-events.js";
2
+ function buildPersistedFocusSettleBootstrap() {
3
+ return `(function(){
4
+ if(document.__zdPersistedFocusSettle)return;
5
+ document.__zdPersistedFocusSettle=true;
6
+ document.addEventListener(${JSON.stringify(BEFORE_SWAP_EVENT)},function(ev){
7
+ if(typeof ev.swap!=="function")return;
8
+ var originalSwap=ev.swap;
9
+ var wrapped=function(){
10
+ var active=document.activeElement;
11
+ var retained=!!active&&typeof active.closest==="function"&&!!active.closest("[data-zfb-transition-persist]");
12
+ var result=originalSwap.apply(this,arguments);
13
+ if(retained&&active.isConnected&&document.activeElement===active)void document.documentElement.offsetHeight;
14
+ return result;
15
+ };
16
+ try{ev.swap=wrapped;}catch(e){}
17
+ });
18
+ })();`;
19
+ }
20
+ export {
21
+ buildPersistedFocusSettleBootstrap
22
+ };
@@ -4,6 +4,8 @@ export declare const CONNECTOR_OFFSET = "clamp(0.2rem, 0.3vw, 0.5rem)";
4
4
  export declare const CONNECTOR_WIDTH = "clamp(0.4rem, 0.6vw, 1rem)";
5
5
  export declare const BASE_PAD = "clamp(0.4rem, 0.8vw, 1.3rem)";
6
6
  export declare function connectorLeft(depth: number): string;
7
+ /** Left edge a label needs so it starts `gap` after the horizontal connector's endpoint. */
8
+ export declare function connectorLabelStart(depth: number, widthScale: number, gap: string): string;
7
9
  export declare function CategoryLinkIcon({ class: className }: {
8
10
  class?: string;
9
11
  }): import("@takazudo/zfb").Description;
@@ -6,6 +6,12 @@ const BASE_PAD = "clamp(0.4rem, 0.8vw, 1.3rem)";
6
6
  function connectorLeft(depth) {
7
7
  return `calc(${depth} * ${INDENT} + ${CONNECTOR_OFFSET})`;
8
8
  }
9
+ function connectorWidth(widthScale) {
10
+ return widthScale === 1 ? CONNECTOR_WIDTH : `calc(${CONNECTOR_WIDTH} * ${widthScale})`;
11
+ }
12
+ function connectorLabelStart(depth, widthScale, gap) {
13
+ return `calc(${connectorLeft(depth)} + ${connectorWidth(widthScale)} + ${gap})`;
14
+ }
9
15
  const CATEGORY_LINK_PATH = "M5.746 5.74 0 11.49l20.987 20.96C34.126 45.572 41.963 53.45 41.948 53.523c-.012.062-9.456 9.544-20.986 21.07L0 95.55l5.714 5.715c3.142 3.143 5.748 5.715 5.79 5.715s2.63-2.563 5.75-5.696l17.939-18.001c21.867-21.94 29.443-29.599 29.443-29.768 0-.114-.665-.804-5.084-5.275C51.872 40.47 11.71.125 11.565.036 11.525.01 8.906 2.578 5.746 5.74m38.345-.066c-3.132 3.13-5.696 5.71-5.696 5.732-.001.022 2.16 2.185 4.8 4.807 2.641 2.623 8.382 8.338 12.758 12.702 15.38 15.337 23.763 23.641 24.314 24.086.19.153.346.336.346.405 0 .07-1.738 1.847-3.887 3.976a17515 17515 0 0 0-20.35 20.264 19555 19555 0 0 1-17.223 17.158c-.416.409-.757.77-.757.8 0 .083 11.415 11.485 11.457 11.445.235-.22 53.542-53.528 53.542-53.543C103.395 53.472 49.891.02 49.837 0c-.028-.01-2.613 2.543-5.746 5.674";
10
16
  function CategoryLinkIcon({ class: className }) {
11
17
  return /* @__PURE__ */ jsx(
@@ -27,7 +33,7 @@ function ConnectorLines({
27
33
  }) {
28
34
  if (depth === 0) return null;
29
35
  const left = connectorLeft(depth);
30
- const width = widthScale === 1 ? CONNECTOR_WIDTH : `calc(${CONNECTOR_WIDTH} * ${widthScale})`;
36
+ const width = connectorWidth(widthScale);
31
37
  const firstLineMid = `calc(${topPad} + 0.5lh)`;
32
38
  return /* @__PURE__ */ jsxs(Fragment, { children: [
33
39
  /* @__PURE__ */ jsx(
@@ -61,5 +67,6 @@ export {
61
67
  CategoryLinkIcon,
62
68
  ConnectorLines,
63
69
  INDENT,
70
+ connectorLabelStart,
64
71
  connectorLeft
65
72
  };
@@ -60,6 +60,7 @@ import type {
60
60
  Locale,
61
61
  } from "./types.js";
62
62
  import { GitHub as GitHubIcon } from "../icons/index.js";
63
+ import type { HeaderBrandConfig } from "../settings.js";
63
64
 
64
65
  /**
65
66
  * Boundary helpers the host injects into `<Header>`. These are the URL
@@ -168,6 +169,15 @@ export interface HeaderProps {
168
169
  /** Site-name string shown in the logo anchor (host `settings.siteName`). */
169
170
  siteName: string;
170
171
 
172
+ /**
173
+ * Optional brand icon rendered before `siteName` inside the logo anchor
174
+ * (host `settings.headerBrand`). Omitted or `false` renders the anchor
175
+ * exactly as before. The icon is a decorative `currentColor` CSS mask; an
176
+ * `icon` starting with `/` goes through `urlHelpers.withBase()`, anything
177
+ * else is used verbatim.
178
+ */
179
+ headerBrand?: false | HeaderBrandConfig;
180
+
171
181
  /** Header-nav items in render order (host `settings.headerNav`). */
172
182
  headerNav: HeaderNavItem[];
173
183
 
@@ -265,6 +275,7 @@ export function Header(props: HeaderProps): JSX.Element {
265
275
  search,
266
276
  persistKey,
267
277
  siteName,
278
+ headerBrand,
268
279
  headerNav,
269
280
  headerRightItems,
270
281
  headerRightComponents,
@@ -289,6 +300,16 @@ export function Header(props: HeaderProps): JSX.Element {
289
300
  const matchPath = pathForMatch(pathWithoutVersion, lang, i18n.defaultLocale);
290
301
 
291
302
  const activeNavPath = computeActiveNavPath(headerNav, matchPath);
303
+ // Direct `<Header>` consumers and raw route payloads bypass the config-time
304
+ // `assertValidHeaderBrand`, so a malformed value degrades to "no icon".
305
+ const brandIcon = headerBrand && typeof headerBrand.icon === "string" && headerBrand.icon !== ""
306
+ ? headerBrand.icon
307
+ : null;
308
+ const brandIconUrl = brandIcon === null
309
+ ? null
310
+ : brandIcon.startsWith("/") && !brandIcon.startsWith("//")
311
+ ? urlHelpers.withBase(brandIcon)
312
+ : brandIcon;
292
313
  const rightItemDispatch = createRightItemDispatch(headerRightComponents);
293
314
  const rightItems = headerRightItems
294
315
  .map((item, i) => ({
@@ -397,7 +418,17 @@ export function Header(props: HeaderProps): JSX.Element {
397
418
  class="min-w-0 truncate text-title font-bold text-fg hover:underline focus:underline"
398
419
  title={siteName}
399
420
  data-header-logo
421
+ data-header-brand={brandIconUrl !== null ? true : undefined}
400
422
  >
423
+ {brandIconUrl !== null && (
424
+ // Painted by features.css as a currentColor mask so it follows the
425
+ // site-name ink; decorative, the anchor's name stays the site name.
426
+ <span
427
+ aria-hidden="true"
428
+ data-header-brand-icon
429
+ style={{ "--zd-header-brand-icon": cssUrl(brandIconUrl) }}
430
+ />
431
+ )}
401
432
  {siteName}
402
433
  </a>
403
434
 
@@ -908,3 +939,12 @@ function renderRightItem(
908
939
 
909
940
  return null;
910
941
  }
942
+
943
+ /**
944
+ * Serialize a URL as a quoted CSS `url()` token so paths with whitespace,
945
+ * parentheses or quotes survive inside the custom property. Kept local to
946
+ * this module because the header is ejectable (see `eject/index.ts`).
947
+ */
948
+ function cssUrl(url: string): string {
949
+ return `url("${url.replace(/[\\"]/g, "\\$&").replace(/[\n\r\f]/g, "\\a ")}")`;
950
+ }
@@ -12,6 +12,7 @@ import type { ResolvedDateFormats } from "../settings.js";
12
12
  import {
13
13
  INDENT,
14
14
  connectorLeft,
15
+ connectorLabelStart,
15
16
  ConnectorLines,
16
17
  CategoryLinkIcon,
17
18
  } from "../tree-nav-shared/index.js";
@@ -30,9 +31,15 @@ import { initialCategoryOpenState, toggleCategoryOpenState } from "./state.js";
30
31
  // site-tree-nav uses wider padding than the narrow sidebar
31
32
  const SITE_BASE_PAD = "clamp(0.5rem, 0.8vw, 1rem)";
32
33
 
34
+ // The homepage draws its connectors at twice the sidebar width, so on wide
35
+ // viewports the branch endpoint passes the fixed label indent. The label starts
36
+ // at whichever is further right: that indent, or the endpoint plus a gap.
37
+ const CONNECTOR_WIDTH_SCALE = 2;
38
+ const CONNECTOR_LABEL_GAP = "var(--spacing-hsp-xs)";
39
+
33
40
  function padLeft(depth: number): string {
34
41
  if (depth === 0) return SITE_BASE_PAD;
35
- return `calc(${depth} * ${INDENT} + 1.25rem + 5px)`;
42
+ return `max(calc(${depth} * ${INDENT} + 1.25rem + 5px), ${connectorLabelStart(depth, CONNECTOR_WIDTH_SCALE, CONNECTOR_LABEL_GAP)})`;
36
43
  }
37
44
 
38
45
  function reorderTree(tree: SidebarNavNode[], order: string[]): SidebarNavNode[] {
@@ -223,7 +230,7 @@ function CategoryNode({
223
230
  <ConnectorLines
224
231
  depth={depth}
225
232
  isLast={true}
226
- widthScale={2}
233
+ widthScale={CONNECTOR_WIDTH_SCALE}
227
234
  topPad="calc(0.15rem + var(--spacing-vsp-xs))"
228
235
  />
229
236
  )}
@@ -233,7 +240,7 @@ function CategoryNode({
233
240
  <ConnectorLines
234
241
  depth={depth}
235
242
  isLast={false}
236
- widthScale={2}
243
+ widthScale={CONNECTOR_WIDTH_SCALE}
237
244
  topPad="calc(0.15rem + var(--spacing-vsp-xs))"
238
245
  />
239
246
  )}
@@ -495,7 +502,7 @@ function LeafNode({
495
502
  <ConnectorLines
496
503
  depth={depth}
497
504
  isLast={true}
498
- widthScale={2}
505
+ widthScale={CONNECTOR_WIDTH_SCALE}
499
506
  topPad={topPad}
500
507
  />
501
508
  )}
@@ -505,7 +512,7 @@ function LeafNode({
505
512
  <ConnectorLines
506
513
  depth={depth}
507
514
  isLast={false}
508
- widthScale={2}
515
+ widthScale={CONNECTOR_WIDTH_SCALE}
509
516
  topPad={topPad}
510
517
  />
511
518
  )}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@takazudo/zudo-doc",
3
- "version": "8.0.0",
3
+ "version": "8.1.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",
@@ -736,9 +736,9 @@
736
736
  "peerDependencies": {
737
737
  "@modelcontextprotocol/sdk": "1.32.2",
738
738
  "@takazudo/zdtp": "^0.5.2 || ^0.6.0 || ^0.7.0 || ^0.8.0",
739
- "@takazudo/zfb": "^5.0.1",
740
- "@takazudo/zfb-md-wasm": "^5.0.1",
741
- "@takazudo/zfb-runtime": "^5.0.1",
739
+ "@takazudo/zfb": "^5.1.2",
740
+ "@takazudo/zfb-md-wasm": "^5.1.2",
741
+ "@takazudo/zfb-runtime": "^5.1.2",
742
742
  "@takazudo/zudo-doc-history-server": "^8.0.0",
743
743
  "diff": "^8.0.0",
744
744
  "katex": "^0.18.2 || ^0.19.0",
@@ -779,9 +779,9 @@
779
779
  "devDependencies": {
780
780
  "@modelcontextprotocol/sdk": "1.32.2",
781
781
  "@takazudo/mdx-formatter": "1.3.0",
782
- "@takazudo/zfb": "5.0.1",
783
- "@takazudo/zfb-md-wasm": "5.0.1",
784
- "@takazudo/zfb-runtime": "5.0.1",
782
+ "@takazudo/zfb": "5.1.2",
783
+ "@takazudo/zfb-md-wasm": "5.1.2",
784
+ "@takazudo/zfb-runtime": "5.1.2",
785
785
  "@types/fs-extra": "^11.0.4",
786
786
  "@types/minimist": "^1.2.5",
787
787
  "@types/node": "^25.3.5",
@@ -791,7 +791,7 @@
791
791
  "typescript": "^5.0.0",
792
792
  "vitest": "^4.1.0",
793
793
  "zod": "^4.3.6",
794
- "@takazudo/zudo-doc-history-server": "8.0.0"
794
+ "@takazudo/zudo-doc-history-server": "8.1.0"
795
795
  },
796
796
  "scripts": {
797
797
  "gen:search-widget-script": "node scripts/gen-search-widget-script.mjs",