create-zudo-doc 0.2.9 → 0.2.10

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.
@@ -12,7 +12,7 @@ import type { FeatureModule } from "../compose.js";
12
12
  * feature, the server-side figure/button emission is re-implemented via an
13
13
  * MDX paragraph (p) component override in pages/_mdx-components.ts. When
14
14
  * imageEnlarge is enabled, three injections into the template file install:
15
- * 1. Additional imports: toChildArray + VNode from preact, settings.
15
+ * 1. Additional imports: toChildArray + VNode from preact.
16
16
  * 2. ENLARGE_SVG const + EnlargeableParagraph function definition.
17
17
  * 3. `p: EnlargeableParagraph` entry in the createMdxComponents return map.
18
18
  * When imageEnlarge is OFF, none of these are injected, so the override is
@@ -11,7 +11,7 @@
11
11
  * feature, the server-side figure/button emission is re-implemented via an
12
12
  * MDX paragraph (p) component override in pages/_mdx-components.ts. When
13
13
  * imageEnlarge is enabled, three injections into the template file install:
14
- * 1. Additional imports: toChildArray + VNode from preact, settings.
14
+ * 1. Additional imports: toChildArray + VNode from preact.
15
15
  * 2. ENLARGE_SVG const + EnlargeableParagraph function definition.
16
16
  * 3. `p: EnlargeableParagraph` entry in the createMdxComponents return map.
17
17
  * When imageEnlarge is OFF, none of these are injected, so the override is
@@ -21,15 +21,17 @@
21
21
  export const imageEnlargeFeature = () => ({
22
22
  name: "imageEnlarge",
23
23
  injections: [
24
- // 1. Import additions: toChildArray + VNode from preact, settings.
24
+ // 1. Import additions: toChildArray + VNode from preact.
25
25
  // Inserted AFTER the `// @slot:mdx-components:enlarge-imports` anchor.
26
+ // NOTE: `settings` is NOT injected here — the base template already
27
+ // imports it (#2172); injecting it again caused a duplicate ES-module
28
+ // lexical binding.
26
29
  {
27
30
  file: "pages/_mdx-components.ts",
28
31
  anchor: "// @slot:mdx-components:enlarge-imports",
29
32
  position: "after",
30
33
  content: `import { toChildArray } from "preact";
31
- import type { VNode } from "preact";
32
- import { settings } from "@/config/settings";`,
34
+ import type { VNode } from "preact";`,
33
35
  },
34
36
  // 2. ENLARGE_SVG const + EnlargeableParagraph function.
35
37
  // Inserted AFTER the `// @slot:mdx-components:enlarge-defs` anchor
package/dist/scaffold.js CHANGED
@@ -178,7 +178,7 @@ export async function scaffold(choices) {
178
178
  await fs.outputFile(path.join(targetDir, "zfb.config.ts"), zfbConfigContent);
179
179
  const pkg = generatePackageJson(choices);
180
180
  await fs.outputFile(path.join(targetDir, "package.json"), JSON.stringify(pkg, null, 2) + "\n");
181
- await fs.outputFile(path.join(targetDir, ".gitignore"), [
181
+ const gitignoreLines = [
182
182
  "# Build output",
183
183
  "node_modules",
184
184
  "dist",
@@ -201,7 +201,24 @@ export async function scaffold(choices) {
201
201
  "# Cloudflare Wrangler",
202
202
  ".wrangler/",
203
203
  "",
204
- ].join("\n"));
204
+ ];
205
+ // The doc-lookup skill is only generated when skillSymlinker is selected
206
+ // (the setup-doc-skill.sh script and `setup:doc-skill` npm script are gated
207
+ // on the same feature above), so only emit its ignore entries then —
208
+ // otherwise they would be dead rules that could silently hide an unrelated
209
+ // skill a user later installs under a matching name. The skill name is
210
+ // deterministic (always `<projectName>-wisdom`, matching DEFAULT_SKILL_NAME
211
+ // in scripts/setup-doc-skill.sh and the package name), so these entries match
212
+ // the directory the script creates. The docs-ja symlink only exists for i18n
213
+ // projects (the script creates it conditionally), so gate that line on i18n.
214
+ if (choices.features.includes("skillSymlinker")) {
215
+ gitignoreLines.push("# Generated doc-lookup skill", `.claude/skills/${choices.projectName}-wisdom/SKILL.md`, `.claude/skills/${choices.projectName}-wisdom/docs`);
216
+ if (choices.features.includes("i18n")) {
217
+ gitignoreLines.push(`.claude/skills/${choices.projectName}-wisdom/docs-ja`);
218
+ }
219
+ gitignoreLines.push("");
220
+ }
221
+ await fs.outputFile(path.join(targetDir, ".gitignore"), gitignoreLines.join("\n"));
205
222
  // Emit an .npmrc exempting undici-types from pnpm's trust-downgrade policy.
206
223
  // External pnpm supply-chain quirk (pnpm >= 10.21, https://github.com/pnpm/pnpm/issues/8889):
207
224
  // when a consumer enables `trust-policy=no-downgrade` (off by default, but a
@@ -304,22 +321,36 @@ function generatePackageJson(choices) {
304
321
  // themeDark on CodeHighlightConfig, --shiki-light/--shiki-dark, #1067) plus
305
322
  // stricter build-start validation that rejects unknown theme names. next.48:
306
323
  // re-export @takazudo/zfb/config from the zfb-shim.d.ts type shim — type-only
307
- // fix, additive. next.49 (current pin): client-router WebKit bfcache fix —
324
+ // fix, additive. next.49: client-router WebKit bfcache fix —
308
325
  // re-sync the history index + originalLocation on a bfcache restore so
309
326
  // browser Back after an SPA navigation returns to the previous page instead
310
- // of skipping an entry — runtime-only bug fix, additive. A fresh scaffold
327
+ // of skipping an entry — runtime-only bug fix, additive. next.50 (current
328
+ // pin): client-router fix to commit the SPA history entry BEFORE the View
329
+ // Transition, so on WebKit/iOS a single browser Back after an SPA navigation
330
+ // creates a distinct history entry instead of falling off the site —
331
+ // runtime-only bug fix, additive. next.51 (current pin): additive public-API
332
+ // surface — VNode/VNodeArray/VNodeObject are now exported from
333
+ // "@takazudo/zfb" (#972) — plus removal of the no-op linkValidation.allowExternal
334
+ // knob (#925); both are non-breaking for a fresh scaffold. A fresh scaffold
311
335
  // sets no explicit codeHighlight.theme so the default still applies. No
312
336
  // consumer-facing / CLI breaking change.
313
- "@takazudo/zfb": "0.1.0-next.49",
314
- "@takazudo/zfb-runtime": "0.1.0-next.49",
337
+ "@takazudo/zfb": "0.1.0-next.51",
338
+ "@takazudo/zfb-runtime": "0.1.0-next.51",
315
339
  // zfb-adapter-cloudflare — required for any route with `prerender = false`.
316
340
  // Pinned in lockstep with @takazudo/zfb.
317
- "@takazudo/zfb-adapter-cloudflare": "0.1.0-next.49",
341
+ "@takazudo/zfb-adapter-cloudflare": "0.1.0-next.51",
318
342
  // @takazudo/zudo-doc — published from this monorepo via
319
343
  // .github/workflows/publish-zudo-doc.yml. The pin here is bumped in
320
344
  // lockstep by scripts/release-create-zudo-doc.sh whenever zudo-doc's
321
345
  // version moves, so a fresh scaffold pulls the version we just published.
322
- "@takazudo/zudo-doc": "^0.2.9",
346
+ // RELEASE DEPENDENCY (zudolab/zudo-doc#2188): the base template's
347
+ // global.css now `@import`s `@takazudo/zudo-doc/content.css`, an export
348
+ // added alongside this pin. The pinned range MUST resolve to a PUBLISHED
349
+ // version that ships that export — published 0.2.9 does NOT. check-pin-parity
350
+ // ties this pin to packages/zudo-doc's version, so the lockstep release
351
+ // bumps both together; do not cut a create-zudo-doc release until the
352
+ // matching @takazudo/zudo-doc version (with content.css) is on npm.
353
+ "@takazudo/zudo-doc": "^0.2.10",
323
354
  // zod — used by the generated zfb.config.ts. zfb-config-gen emits
324
355
  // `import { z } from "zod"` for the content-collection schema +
325
356
  // `z.toJSONSchema(...)` conversion. Without this dep, the consumer
@@ -374,7 +405,7 @@ function generatePackageJson(choices) {
374
405
  // @takazudo/zudo-doc/integrations/doc-history which in turn imports
375
406
  // @takazudo/zudo-doc-history-server/git-history. Without this dep the
376
407
  // plugin host fails at init with ERR_MODULE_NOT_FOUND — W8A (#1739).
377
- deps["@takazudo/zudo-doc-history-server"] = "^0.2.9";
408
+ deps["@takazudo/zudo-doc-history-server"] = "^0.2.10";
378
409
  // W7A (#1736): doc-history-plugin.mjs spawns `tsx -e <inline-script>` to
379
410
  // run the v2 runtime in a TS-aware Node subprocess; without tsx the
380
411
  // plugin's preBuild step exits with ENOENT before zfb finishes config
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-zudo-doc",
3
- "version": "0.2.9",
3
+ "version": "0.2.10",
4
4
  "description": "Create a new zudo-doc documentation site",
5
5
  "license": "MIT",
6
6
  "author": "Takeshi Takatsudo",
@@ -35,6 +35,7 @@ import { settings } from "@/config/settings";
35
35
  import AiChatModal from "@/components/ai-chat-modal";
36
36
  import ClientRouterBootstrap from "@/components/client-router-bootstrap";
37
37
  import ImageEnlarge, { ImageEnlargeSsrFallback } from "@/components/image-enlarge";
38
+ import MermaidEnlarge, { MermaidEnlargeSsrFallback } from "@/components/mermaid-enlarge";
38
39
  import { PageLoadingOverlay } from "@takazudo/zudo-doc/page-loading";
39
40
  // @slot:body-end-islands:imports
40
41
 
@@ -49,6 +50,7 @@ import { PageLoadingOverlay } from "@takazudo/zudo-doc/page-loading";
49
50
  (ClientRouterBootstrap as { displayName?: string }).displayName =
50
51
  "ClientRouterBootstrap";
51
52
  (ImageEnlarge as { displayName?: string }).displayName = "ImageEnlarge";
53
+ (MermaidEnlarge as { displayName?: string }).displayName = "MermaidEnlarge";
52
54
  // @slot:body-end-islands:display-names
53
55
 
54
56
  /**
@@ -159,6 +161,20 @@ export function BodyEndIslands({
159
161
  }) as unknown as VNode)
160
162
  : null;
161
163
 
164
+ // Gated on `settings.mermaid`. Mirrors the imageEnlarge block: the SSR
165
+ // fallback is an empty, closed `<dialog class="zd-mermaid-dialog ...">` so
166
+ // the dist HTML carries one dialog from the start and hydration (when="idle")
167
+ // swaps in the real component. Unlike images (SSR-wrapped by the MDX paragraph
168
+ // override), mermaid renders client-side, so this island injects the enlarge
169
+ // button into each rendered diagram container itself.
170
+ const mermaidEnlarge = settings.mermaid
171
+ ? (Island({
172
+ when: "idle",
173
+ ssrFallback: <MermaidEnlargeSsrFallback />,
174
+ children: <MermaidEnlarge />,
175
+ }) as unknown as VNode)
176
+ : null;
177
+
162
178
  return (
163
179
  <>
164
180
  {/* Pure SSR — no Island wrap. The component emits its overlay div,
@@ -168,6 +184,7 @@ export function BodyEndIslands({
168
184
  {clientRouterBootstrap}
169
185
  {aiAssistant}
170
186
  {imageEnlarge}
187
+ {mermaidEnlarge}
171
188
  {/* @slot:body-end-islands:extra-islands */}
172
189
  </>
173
190
  );
@@ -0,0 +1,490 @@
1
+ "use client";
2
+
3
+ // Mermaid-enlarge island — adds an "enlarge" affordance to client-rendered
4
+ // mermaid diagrams plus a Google-Maps-style zoom/pan dialog.
5
+ //
6
+ // Unlike images (which the MDX paragraph override SSR-wraps in
7
+ // `<figure class="zd-enlargeable">` with the enlarge button already in the
8
+ // markup — see pages/_mdx-components.ts), mermaid diagrams render CLIENT-SIDE:
9
+ // the mermaid init script (packages/zudo-doc/src/code-syntax/mermaid-init-script.ts)
10
+ // imports mermaid from a CDN, runs `mermaid.run()`, then sets
11
+ // `data-mermaid-rendered` on each `.mermaid` container and injects the `<svg>`.
12
+ // So there is no server markup to wrap — this island must INJECT the enlarge
13
+ // button into each diagram container after it renders.
14
+ //
15
+ // Lifecycle coupling with the mermaid init script:
16
+ // * Primary trigger: a MutationObserver on the content scope watching for the
17
+ // `data-mermaid-rendered` attribute appearing (and the `<svg>` child).
18
+ // * SPA hops: AFTER_NAVIGATE_EVENT re-scans the (swapped) content body.
19
+ // * Theme/tweak re-render: the init script's `reinitMermaid` REMOVES and
20
+ // regenerates the `<svg>` (debounced 300ms). The button lives on the
21
+ // `.mermaid` CONTAINER (which persists), and the dedupe guard is keyed by
22
+ // the container — so the button is neither dropped nor duplicated when the
23
+ // svg is regenerated. If the dialog is open during a re-render, the open
24
+ // diagram's fresh `<svg>` is re-cloned.
25
+
26
+ // Use `preact/compat` so the bundle resolves to Preact's React-shim at runtime
27
+ // (zfb's esbuild step doesn't alias bare `react` to `preact/compat`). Mirrors
28
+ // image-enlarge.tsx.
29
+ import type { JSX } from "preact";
30
+ import { useState, useEffect, useRef, useCallback } from "preact/compat";
31
+ import { AFTER_NAVIGATE_EVENT } from "@takazudo/zudo-doc/transitions";
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Shared dialog shell constants
35
+ //
36
+ // The hydrated component and the SSR fallback below render into the same Island
37
+ // container, so they MUST agree on class string and inline style — otherwise
38
+ // the dist HTML and the post-hydration DOM disagree and the first interaction
39
+ // flashes. Sourcing both from the same constants closes that drift gap.
40
+ //
41
+ // The dialog itself is intentionally transform-FREE (centered via
42
+ // position:fixed; inset:0; margin:auto). The zoom/pan transform lives on an
43
+ // INNER wrapper — a transform on the `<dialog>` would establish a containing
44
+ // block for its `position: fixed` descendants, re-anchoring the fixed close
45
+ // button to the dialog corner instead of the viewport. Mirrors image-enlarge.
46
+ //
47
+ // z-modal / backdrop:z-modal-backdrop are defense-in-depth for the SPA-swap
48
+ // window: a still-open showModal() dialog can lose top-layer promotion when the
49
+ // page body is swapped, so the explicit modal-tier z-index keeps it above all
50
+ // chrome. Intentionally redundant in the normal top-layer case.
51
+ // ---------------------------------------------------------------------------
52
+ const DIALOG_CLASS =
53
+ "zd-mermaid-dialog z-modal mx-auto h-[90vh] max-h-[90vh] w-[90vw] max-w-[90vw] overflow-hidden border border-muted bg-surface p-0 backdrop:z-modal-backdrop";
54
+ const DIALOG_STYLE = {
55
+ position: "fixed",
56
+ inset: "0",
57
+ margin: "auto",
58
+ } as const;
59
+
60
+ // Selector for the content-scope root. The button injector scans within this
61
+ // scope with `.querySelectorAll(".mermaid")` — no deeper constant needed.
62
+ const CONTENT_SCOPE_SELECTOR = "main .zd-content";
63
+
64
+ // The diagram svg is a DIRECT child of the `.mermaid` container; the injected
65
+ // enlarge button's own icon svg is a grandchild. Selecting `:scope > svg` (not a
66
+ // descendant `svg`) so we never pick up the button icon — which matters during a
67
+ // theme/tweak re-render, when the diagram svg is briefly removed and a bare
68
+ // `querySelector("svg")` would fall back to the button's icon.
69
+ const DIAGRAM_SVG_SELECTOR = ":scope > svg";
70
+
71
+ // Container-keyed dedupe marker. Set on the `.mermaid` container (which persists
72
+ // across theme/tweak re-renders) once its enlarge button is injected, so the
73
+ // re-render that regenerates the inner `<svg>` doesn't drop or duplicate it.
74
+ const BTN_INJECTED_ATTR = "data-mermaid-enlarge-ready";
75
+
76
+ // Zoom step + clamps. scale 1 = diagram fits the dialog (contain).
77
+ const ZOOM_STEP = 1.25;
78
+ const MIN_SCALE = 1;
79
+ const MAX_SCALE = 4;
80
+
81
+ // The enlarge button's 4-corner-arrows icon (same shape as ENLARGE_SVG in
82
+ // pages/_mdx-components.ts) is injected as an innerHTML string in injectButton()
83
+ // below — the button itself is created via document.createElement because the
84
+ // mermaid container is plain DOM, not part of this island's render tree.
85
+
86
+ // Toolbar icons. currentColor + aria-hidden so they inherit the toolbar button
87
+ // color and are skipped by assistive tech (the buttons carry aria-labels).
88
+ function PlusIcon() {
89
+ return (
90
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true" focusable="false">
91
+ <line x1="12" y1="5" x2="12" y2="19" />
92
+ <line x1="5" y1="12" x2="19" y2="12" />
93
+ </svg>
94
+ );
95
+ }
96
+
97
+ function MinusIcon() {
98
+ return (
99
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true" focusable="false">
100
+ <line x1="5" y1="12" x2="19" y2="12" />
101
+ </svg>
102
+ );
103
+ }
104
+
105
+ function PanIcon() {
106
+ return (
107
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round" strokeLinecap="round" aria-hidden="true" focusable="false">
108
+ <path d="M9 11V5.5a1.5 1.5 0 0 1 3 0V11" />
109
+ <path d="M12 11V4.5a1.5 1.5 0 0 1 3 0V11" />
110
+ <path d="M15 11V6a1.5 1.5 0 0 1 3 0v6.5a6.5 6.5 0 0 1-6.5 6.5h-1a6 6 0 0 1-4.6-2.16l-2.2-2.86a1.5 1.5 0 0 1 2.3-1.92L9 13" />
111
+ <path d="M9 11V8a1.5 1.5 0 0 0-3 0v5" />
112
+ </svg>
113
+ );
114
+ }
115
+
116
+ interface OpenDiagram {
117
+ /** The live `.mermaid` container being shown — used to re-clone on re-render. */
118
+ container: HTMLElement;
119
+ /** The cloned `<svg>` outerHTML to render inside the pan viewport. */
120
+ svgHtml: string;
121
+ }
122
+
123
+ export default function MermaidEnlarge() {
124
+ const [open, setOpen] = useState<OpenDiagram | null>(null);
125
+
126
+ // Zoom/pan state. scale 1 = diagram fits the dialog (contain); translate 0,0.
127
+ const [scale, setScale] = useState(1);
128
+ const [translate, setTranslate] = useState({ x: 0, y: 0 });
129
+ const [panActive, setPanActive] = useState(false);
130
+
131
+ const dialogRef = useRef<HTMLDialogElement>(null);
132
+ const innerRef = useRef<HTMLDivElement>(null);
133
+ // Pointer-drag bookkeeping (refs so the handlers don't re-create on each move).
134
+ const dragState = useRef<{
135
+ dragging: boolean;
136
+ startX: number;
137
+ startY: number;
138
+ originX: number;
139
+ originY: number;
140
+ }>({ dragging: false, startX: 0, startY: 0, originX: 0, originY: 0 });
141
+
142
+ // -----------------------------------------------------------------------
143
+ // Button injection: scan the content scope, inject one enlarge button into
144
+ // each RENDERED diagram container, and add `.zd-mermaid-enlargeable` (which
145
+ // makes the container position:relative so the absolutely-positioned button
146
+ // anchors to it).
147
+ // -----------------------------------------------------------------------
148
+ useEffect(() => {
149
+ let mutationObserver: MutationObserver | null = null;
150
+
151
+ function injectButton(container: HTMLElement) {
152
+ // Container-keyed guard: the theme/tweak re-render regenerates the inner
153
+ // <svg> but the container (and this marker) persists.
154
+ if (container.hasAttribute(BTN_INJECTED_ATTR)) return;
155
+ // Only inject once the diagram has actually rendered — the button is
156
+ // meaningless before there's an <svg> to enlarge.
157
+ const rendered =
158
+ container.hasAttribute("data-mermaid-rendered") ||
159
+ container.querySelector(DIAGRAM_SVG_SELECTOR) !== null;
160
+ if (!rendered) return;
161
+
162
+ container.setAttribute(BTN_INJECTED_ATTR, "");
163
+ container.classList.add("zd-mermaid-enlargeable");
164
+
165
+ const btn = document.createElement("button");
166
+ btn.type = "button";
167
+ btn.className = "zd-enlarge-btn";
168
+ btn.setAttribute("aria-label", "Enlarge diagram");
169
+ // 4-corner-arrows icon (matches ENLARGE_SVG used by image-enlarge).
170
+ btn.innerHTML =
171
+ '<svg viewBox="0 0 38.99 38.99" fill="currentColor" focusable="false" aria-hidden="true">' +
172
+ '<polygon points="16.2 13.74 5.92 3.47 11.2 3.47 11.2 0 3.47 0 0 0 0 3.47 0 11.2 3.47 11.2 3.47 5.92 13.74 16.2 16.2 13.74" />' +
173
+ '<polygon points="25.24 16.2 35.52 5.92 35.52 11.2 38.99 11.2 38.99 3.47 38.99 0 35.52 0 27.79 0 27.79 3.47 33.07 3.47 22.79 13.74 25.24 16.2" />' +
174
+ '<polygon points="22.79 25.24 33.07 35.52 27.79 35.52 27.79 38.99 35.52 38.99 38.99 38.99 38.99 35.52 38.99 27.79 35.52 27.79 35.52 33.07 25.24 22.79 22.79 25.24" />' +
175
+ '<polygon points="13.74 22.79 3.47 33.07 3.47 27.79 0 27.79 0 35.52 0 38.99 3.47 38.99 11.2 38.99 11.2 35.52 5.92 35.52 16.2 25.24 13.74 22.79" />' +
176
+ "</svg>";
177
+ container.appendChild(btn);
178
+ }
179
+
180
+ function scan() {
181
+ const scope = document.querySelector(CONTENT_SCOPE_SELECTOR);
182
+ if (!scope) return;
183
+ scope
184
+ .querySelectorAll<HTMLElement>(".mermaid")
185
+ .forEach((el) => injectButton(el));
186
+ }
187
+
188
+ function startObserving() {
189
+ const scope = document.querySelector(CONTENT_SCOPE_SELECTOR);
190
+ if (scope) {
191
+ // Watch for: new `.mermaid` containers (childList), the
192
+ // `data-mermaid-rendered` attribute flipping on (attributes), and the
193
+ // svg child appearing (childList subtree). Any of these means a diagram
194
+ // is now eligible for the button.
195
+ mutationObserver = new MutationObserver(() => scan());
196
+ mutationObserver.observe(scope, {
197
+ childList: true,
198
+ subtree: true,
199
+ attributes: true,
200
+ attributeFilter: ["data-mermaid-rendered"],
201
+ });
202
+ }
203
+ scan();
204
+ }
205
+
206
+ function handleAfterNavigate() {
207
+ mutationObserver?.disconnect();
208
+ mutationObserver = null;
209
+ startObserving();
210
+ }
211
+
212
+ startObserving();
213
+ document.addEventListener(AFTER_NAVIGATE_EVENT, handleAfterNavigate);
214
+
215
+ return () => {
216
+ mutationObserver?.disconnect();
217
+ document.removeEventListener(AFTER_NAVIGATE_EVENT, handleAfterNavigate);
218
+ };
219
+ }, []);
220
+
221
+ // -----------------------------------------------------------------------
222
+ // Delegated open handler: a click on an injected `.zd-enlarge-btn` inside a
223
+ // `.zd-mermaid-enlargeable` clones that diagram's `<svg>` into the dialog.
224
+ // Mirrors image-enlarge's document-level delegated click.
225
+ // -----------------------------------------------------------------------
226
+ useEffect(() => {
227
+ function handleDocumentClick(e: MouseEvent) {
228
+ const target = e.target as Element;
229
+ const container = target.closest(".zd-mermaid-enlargeable") as HTMLElement | null;
230
+ if (!container) return;
231
+ if (!target.closest(".zd-enlarge-btn")) return;
232
+ const svg = container.querySelector(DIAGRAM_SVG_SELECTOR);
233
+ if (!svg) return;
234
+ // Reset zoom/pan on every open.
235
+ setScale(1);
236
+ setTranslate({ x: 0, y: 0 });
237
+ setPanActive(false);
238
+ setOpen({ container, svgHtml: svg.outerHTML });
239
+ }
240
+ document.addEventListener("click", handleDocumentClick);
241
+ return () => document.removeEventListener("click", handleDocumentClick);
242
+ }, []);
243
+
244
+ // -----------------------------------------------------------------------
245
+ // Re-clone the fresh `<svg>` if the underlying diagram re-renders while the
246
+ // dialog is open (theme/tweak flip removes & regenerates the svg).
247
+ // -----------------------------------------------------------------------
248
+ useEffect(() => {
249
+ if (!open) return;
250
+ const { container } = open;
251
+ const observer = new MutationObserver(() => {
252
+ const svg = container.querySelector(DIAGRAM_SVG_SELECTOR);
253
+ if (svg && svg.outerHTML !== open.svgHtml) {
254
+ setOpen({ container, svgHtml: svg.outerHTML });
255
+ }
256
+ });
257
+ observer.observe(container, { childList: true, subtree: true });
258
+ return () => observer.disconnect();
259
+ }, [open]);
260
+
261
+ const handleClose = useCallback(() => setOpen(null), []);
262
+
263
+ // -----------------------------------------------------------------------
264
+ // Dialog open/close sync (inlined from useModalDialog — template projects
265
+ // do not ship src/hooks/use-modal-dialog.ts, so the hook is not available).
266
+ // -----------------------------------------------------------------------
267
+ const isOpen = open !== null;
268
+
269
+ // Sync dialog open/close with state.
270
+ useEffect(() => {
271
+ const dialog = dialogRef.current;
272
+ if (!dialog) return;
273
+ if (isOpen && !dialog.open) {
274
+ dialog.showModal();
275
+ } else if (!isOpen && dialog.open) {
276
+ dialog.close();
277
+ }
278
+ }, [isOpen]);
279
+
280
+ // Fire handleClose when the dialog is closed natively (Escape key).
281
+ useEffect(() => {
282
+ const dialog = dialogRef.current;
283
+ if (!dialog) return;
284
+ function onDialogClose() {
285
+ if (isOpen) handleClose();
286
+ }
287
+ dialog.addEventListener("close", onDialogClose);
288
+ return () => dialog.removeEventListener("close", onDialogClose);
289
+ }, [isOpen, handleClose]);
290
+
291
+ // Close on SPA navigation when dialog is open.
292
+ useEffect(() => {
293
+ function handleNavigation() {
294
+ const dialog = dialogRef.current;
295
+ if (dialog?.open) {
296
+ dialog.close();
297
+ handleClose();
298
+ }
299
+ }
300
+ document.addEventListener(AFTER_NAVIGATE_EVENT, handleNavigation);
301
+ return () => document.removeEventListener(AFTER_NAVIGATE_EVENT, handleNavigation);
302
+ }, [handleClose]);
303
+
304
+ // Backdrop-click handler: close when the click target is the dialog itself.
305
+ // Preact-native event type so the template does not depend on @types/react
306
+ // being present in the scaffolded project.
307
+ function handleBackdropClick(e: JSX.TargetedMouseEvent<HTMLDialogElement>): void {
308
+ const dialog = dialogRef.current;
309
+ if (!dialog) return;
310
+ if (e.target === dialog) dialog.close();
311
+ }
312
+
313
+ // -----------------------------------------------------------------------
314
+ // Zoom controls
315
+ // -----------------------------------------------------------------------
316
+ const zoomIn = useCallback(() => {
317
+ setScale((s) => Math.min(MAX_SCALE, s * ZOOM_STEP));
318
+ }, []);
319
+
320
+ const zoomOut = useCallback(() => {
321
+ setScale((s) => {
322
+ const next = Math.max(MIN_SCALE, s / ZOOM_STEP);
323
+ // At full width, recenter (translate has no meaning when not zoomed in)
324
+ // and turn off pan mode.
325
+ if (next <= MIN_SCALE) {
326
+ setTranslate({ x: 0, y: 0 });
327
+ setPanActive(false);
328
+ }
329
+ return next;
330
+ });
331
+ }, []);
332
+
333
+ const togglePan = useCallback(() => {
334
+ setPanActive((p) => !p);
335
+ }, []);
336
+
337
+ // Clamp a candidate translate so the diagram can't be dragged fully
338
+ // offscreen — keep the scaled content overlapping the viewport. The max
339
+ // offset on each axis is half the overflow (scaled size minus base size).
340
+ const clampTranslate = useCallback(
341
+ (x: number, y: number, s: number) => {
342
+ const inner = innerRef.current;
343
+ if (!inner) return { x, y };
344
+ const rect = inner.getBoundingClientRect();
345
+ // rect already reflects the current transform; derive the un-scaled base
346
+ // from the applied scale so the bound is stable across repeated drags.
347
+ const baseW = rect.width / s;
348
+ const baseH = rect.height / s;
349
+ const maxX = Math.max(0, (baseW * s - baseW) / 2);
350
+ const maxY = Math.max(0, (baseH * s - baseH) / 2);
351
+ return {
352
+ x: Math.max(-maxX, Math.min(maxX, x)),
353
+ y: Math.max(-maxY, Math.min(maxY, y)),
354
+ };
355
+ },
356
+ [],
357
+ );
358
+
359
+ // -----------------------------------------------------------------------
360
+ // Pan drag (pointer events) — active only when pan mode is on and zoomed in.
361
+ // -----------------------------------------------------------------------
362
+ const onPointerDown = useCallback(
363
+ (e: PointerEvent) => {
364
+ if (!panActive || scale <= MIN_SCALE) return;
365
+ const d = dragState.current;
366
+ d.dragging = true;
367
+ d.startX = e.clientX;
368
+ d.startY = e.clientY;
369
+ d.originX = translate.x;
370
+ d.originY = translate.y;
371
+ (e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId);
372
+ },
373
+ [panActive, scale, translate],
374
+ );
375
+
376
+ const onPointerMove = useCallback(
377
+ (e: PointerEvent) => {
378
+ const d = dragState.current;
379
+ if (!d.dragging) return;
380
+ const nextX = d.originX + (e.clientX - d.startX);
381
+ const nextY = d.originY + (e.clientY - d.startY);
382
+ setTranslate(clampTranslate(nextX, nextY, scale));
383
+ },
384
+ [scale, clampTranslate],
385
+ );
386
+
387
+ const onPointerUp = useCallback((e: PointerEvent) => {
388
+ const d = dragState.current;
389
+ if (!d.dragging) return;
390
+ d.dragging = false;
391
+ (e.currentTarget as HTMLElement).releasePointerCapture?.(e.pointerId);
392
+ }, []);
393
+
394
+ const zoomed = scale > MIN_SCALE;
395
+ const atMax = scale >= MAX_SCALE;
396
+
397
+ return (
398
+ <dialog
399
+ ref={dialogRef}
400
+ onClick={handleBackdropClick}
401
+ aria-label="Enlarged diagram"
402
+ className={DIALOG_CLASS}
403
+ style={DIALOG_STYLE}
404
+ >
405
+ {open && (
406
+ <>
407
+ <div
408
+ className="zd-mermaid-viewport"
409
+ // Pointer handlers live on the viewport so a drag started anywhere
410
+ // over the diagram pans it. `touch-action: none` (in CSS) lets the
411
+ // pointer events fire on touch without the browser scrolling.
412
+ onPointerDown={onPointerDown}
413
+ onPointerMove={onPointerMove}
414
+ onPointerUp={onPointerUp}
415
+ onPointerCancel={onPointerUp}
416
+ data-pan-active={panActive && zoomed ? "" : undefined}
417
+ >
418
+ <div
419
+ ref={innerRef}
420
+ className="zd-mermaid-transform"
421
+ style={{
422
+ transform: `translate(${translate.x}px, ${translate.y}px) scale(${scale})`,
423
+ transformOrigin: "center",
424
+ }}
425
+ // The cloned mermaid svg is trusted markup produced by the local
426
+ // mermaid render; re-cloned here verbatim.
427
+ dangerouslySetInnerHTML={{ __html: open.svgHtml }}
428
+ />
429
+ </div>
430
+
431
+ <div className="zd-mermaid-toolbar" role="toolbar" aria-label="Diagram zoom controls">
432
+ <button
433
+ type="button"
434
+ className="zd-mermaid-tool-btn"
435
+ aria-label="Zoom in"
436
+ onClick={zoomIn}
437
+ disabled={atMax}
438
+ >
439
+ <PlusIcon />
440
+ </button>
441
+ <button
442
+ type="button"
443
+ className="zd-mermaid-tool-btn"
444
+ aria-label="Zoom out"
445
+ onClick={zoomOut}
446
+ disabled={!zoomed}
447
+ >
448
+ <MinusIcon />
449
+ </button>
450
+ <button
451
+ type="button"
452
+ className="zd-mermaid-tool-btn"
453
+ aria-label="Toggle pan mode"
454
+ aria-pressed={panActive}
455
+ onClick={togglePan}
456
+ disabled={!zoomed}
457
+ >
458
+ <PanIcon />
459
+ </button>
460
+ </div>
461
+
462
+ <button
463
+ type="button"
464
+ onClick={() => dialogRef.current?.close()}
465
+ className="zd-enlarge-dialog-close"
466
+ aria-label="Close enlarged diagram"
467
+ >
468
+ <svg viewBox="0 0 161.03 161.03" fill="currentColor" aria-hidden="true" focusable="false">
469
+ <polygon points="161.03 10.27 150.76 0 80.51 70.24 10.27 0 0 10.27 70.24 80.51 0 150.76 10.27 161.03 80.51 90.78 150.76 161.03 161.03 150.76 90.78 80.51 161.03 10.27" />
470
+ </svg>
471
+ </button>
472
+ </>
473
+ )}
474
+ </dialog>
475
+ );
476
+ }
477
+
478
+ /**
479
+ * Static SSR fallback for the {@link MermaidEnlarge} island.
480
+ *
481
+ * The body-end Island wrapper renders this on the server so the dist HTML
482
+ * carries an empty, closed `<dialog class="zd-mermaid-dialog ...">` even before
483
+ * hydration (a `<dialog>` without `open` is `display:none` per UA stylesheet).
484
+ * The classes and inline style come from the shared `DIALOG_CLASS` /
485
+ * `DIALOG_STYLE` constants so the SSR fallback cannot drift from the hydrated
486
+ * `<dialog>` above. Mirrors `ImageEnlargeSsrFallback`.
487
+ */
488
+ export function MermaidEnlargeSsrFallback() {
489
+ return <dialog aria-label="Enlarged diagram" className={DIALOG_CLASS} style={DIALOG_STYLE} />;
490
+ }
@@ -35,6 +35,14 @@
35
35
 
36
36
  @import "@takazudo/zudo-doc/safelist.css";
37
37
 
38
+ /* Content typography (`.zd-content` rules + admonitions + mermaid layout).
39
+ Shipped by @takazudo/zudo-doc as the single source of truth — do not inline
40
+ these rules here (they used to live here and drifted; see
41
+ zudolab/zudo-doc#2188). The @layer zd-flow order it relies on is declared
42
+ above (line 11); the safelist import above generates the component utilities
43
+ its headings/links/etc. depend on. */
44
+ @import "@takazudo/zudo-doc/content.css";
45
+
38
46
  /* ========================================
39
47
  * Tailwind v4 content sources
40
48
  *
@@ -290,410 +298,6 @@ body {
290
298
  }
291
299
  }
292
300
 
293
- /* ========================================
294
- * Content typography (.zd-content)
295
- *
296
- * Direct element styling — no prose plugin.
297
- * Uses :where() for zero specificity (easy to override).
298
- * Font size pattern inspired by zpaper: paired size + line-height.
299
- * ======================================== */
300
-
301
- .zd-content {
302
- color: var(--color-fg);
303
- font-size: var(--text-body);
304
- line-height: var(--leading-relaxed);
305
- }
306
-
307
- /* ── Flow spacing (vertical rhythm) ── */
308
-
309
- /* In the `zd-flow` cascade layer (declared at the top of this file) so the
310
- * unlayered `tailwindcss/utilities` import wins over it: unlayered declarations
311
- * always beat layered ones regardless of specificity or source order. Both this
312
- * rule and a `mt-*` utility have specificity (0,1,0); without the layer this
313
- * rule comes later in source order and silently kills every `mt-*` on a
314
- * `.zd-content` direct child. zd-flow sits ABOVE zd-preflight so this rule still
315
- * beats preflight's `* { margin: 0 }` reset and the .zd-content rhythm is
316
- * preserved for plain content blocks. The layer is scoped to ONLY this flow rule
317
- * — every other `.zd-content` author rule (links, code, lists, headings, the
318
- * `:first-child` reset, etc.) stays unlayered so utilities do NOT flip those.
319
- * See zudolab/zudo-doc#2082 / #2109. */
320
- @layer zd-flow {
321
- .zd-content > :where(* + *) {
322
- margin-top: var(--flow-space, var(--spacing-vsp-md));
323
- }
324
- }
325
-
326
- /* ── Headings ── */
327
-
328
- .zd-content :where(h2) {
329
- font-size: var(--text-title);
330
- font-weight: var(--font-weight-bold);
331
- line-height: var(--leading-tight);
332
- margin-top: var(--spacing-vsp-2xl);
333
- margin-bottom: var(--spacing-vsp-xs);
334
- padding-top: var(--spacing-vsp-sm);
335
- border-top: 3px solid transparent;
336
- border-image: linear-gradient(to right, var(--color-fg), transparent) 1;
337
- }
338
-
339
- .zd-content :where(h3) {
340
- font-size: var(--text-body);
341
- font-weight: var(--font-weight-bold);
342
- line-height: var(--leading-snug);
343
- margin-top: var(--spacing-vsp-xl);
344
- margin-bottom: var(--spacing-vsp-xs);
345
- padding-top: var(--spacing-vsp-xs);
346
- border-top: 2px solid transparent;
347
- border-image: linear-gradient(to right, var(--color-muted), transparent) 1;
348
- }
349
-
350
- .zd-content :where(h4) {
351
- font-size: var(--text-body);
352
- font-weight: var(--font-weight-semibold);
353
- line-height: var(--leading-snug);
354
- margin-top: var(--spacing-vsp-lg);
355
- margin-bottom: var(--spacing-vsp-xs);
356
- padding-top: var(--spacing-vsp-xs);
357
- border-top: 1px solid transparent;
358
- border-image: linear-gradient(to right, var(--color-muted), transparent) 1;
359
- }
360
-
361
- .zd-content :where(h5, h6) {
362
- font-size: var(--text-small);
363
- font-weight: var(--font-weight-semibold);
364
- line-height: var(--leading-snug);
365
- margin-top: var(--spacing-vsp-md);
366
- margin-bottom: var(--spacing-vsp-2xs);
367
- }
368
-
369
- /* ── Heading auto-links ── */
370
-
371
- .zd-content :where(h2, h3, h4, h5, h6) .hash-link {
372
- text-decoration: none;
373
- margin-left: var(--spacing-hsp-sm);
374
- opacity: 0;
375
- transition: opacity 0.15s;
376
- }
377
-
378
- .zd-content :where(h2, h3, h4, h5, h6) .hash-link::after {
379
- content: "#";
380
- color: var(--color-accent);
381
- }
382
-
383
- .zd-content :where(h2, h3, h4, h5, h6):hover .hash-link {
384
- opacity: 1;
385
- }
386
-
387
- .zd-content :where(h2, h3, h4, h5, h6) .hash-link:focus-visible {
388
- opacity: 1;
389
- outline: 2px solid var(--color-accent);
390
- outline-offset: 2px;
391
- }
392
-
393
- /* Tighten spacing after headings */
394
- .zd-content :where(h2 + *, h3 + *, h4 + *, h5 + *, h6 + *) {
395
- margin-top: 0;
396
- }
397
-
398
- /* ── Paragraphs ── */
399
-
400
- .zd-content :where(p) {
401
- margin-top: 0;
402
- margin-bottom: var(--spacing-vsp-md);
403
- }
404
-
405
- /* ── Links ── */
406
-
407
- .zd-content :where(a:not(.block):not([data-site-nav] *)) {
408
- color: var(--color-accent);
409
- text-decoration: underline;
410
- }
411
-
412
- .zd-content :where(a:not(.block):not([data-site-nav] *):hover) {
413
- color: var(--color-accent-hover);
414
- }
415
-
416
- /* ── Bold / Strong ── */
417
-
418
- .zd-content :where(strong) {
419
- font-weight: var(--font-weight-bold);
420
- color: var(--color-fg);
421
- }
422
-
423
- /* ── Lists ── */
424
-
425
- .zd-content :where(ul) {
426
- list-style-type: disc;
427
- padding-left: var(--spacing-hsp-xl);
428
- margin-bottom: var(--spacing-vsp-md);
429
- }
430
-
431
- .zd-content :where(ol) {
432
- list-style-type: decimal;
433
- padding-left: var(--spacing-hsp-xl);
434
- margin-bottom: var(--spacing-vsp-md);
435
- }
436
-
437
- .zd-content :where(li) {
438
- margin-bottom: var(--spacing-vsp-xs);
439
- }
440
-
441
- .zd-content :where(li::marker) {
442
- color: var(--color-muted);
443
- }
444
-
445
- .zd-content :where(li > ul, li > ol) {
446
- margin-top: var(--spacing-vsp-xs);
447
- margin-bottom: 0;
448
- }
449
-
450
- /* ── Blockquotes ── */
451
-
452
- .zd-content :where(blockquote) {
453
- border-left: 3px solid var(--color-muted);
454
- padding-left: var(--spacing-hsp-lg);
455
- color: var(--color-muted);
456
- font-style: italic;
457
- margin-bottom: var(--spacing-vsp-md);
458
- }
459
-
460
- .zd-content :where(blockquote p) {
461
- margin-bottom: var(--spacing-vsp-xs);
462
- }
463
-
464
- /* ── Inline code ── */
465
-
466
- .zd-content :where(code:not(pre code)) {
467
- font-size: var(--text-small);
468
- font-weight: var(--font-weight-medium);
469
- font-family: var(--font-mono);
470
- background-color: var(--color-code-bg);
471
- color: var(--color-code-fg);
472
- border-radius: var(--radius-DEFAULT);
473
- padding: 2px var(--spacing-hsp-xs);
474
- }
475
-
476
- /* ── Code blocks (pre) ── */
477
-
478
- .zd-content :where(pre) {
479
- font-size: var(--text-small);
480
- line-height: var(--leading-relaxed);
481
- border: 1px solid var(--color-muted);
482
- padding: var(--spacing-vsp-sm) var(--spacing-hsp-lg);
483
- margin-bottom: var(--spacing-vsp-md);
484
- overflow-x: auto;
485
- }
486
-
487
- .zd-content :where(pre code) {
488
- background: transparent;
489
- padding: 0;
490
- border: none;
491
- color: inherit;
492
- font-size: inherit;
493
- }
494
-
495
- /* ── Tables ── */
496
-
497
- .zd-content :where(table) {
498
- width: 100%;
499
- border-collapse: collapse;
500
- font-size: var(--text-small);
501
- margin-bottom: var(--spacing-vsp-md);
502
- }
503
-
504
- .zd-content :where(th),
505
- .zd-content :where(td) {
506
- padding: var(--spacing-vsp-xs) var(--spacing-hsp-md);
507
- border-bottom: 1px solid var(--color-muted);
508
- overflow-wrap: break-word;
509
- }
510
-
511
- .zd-content :where(th) {
512
- font-weight: var(--font-weight-semibold);
513
- text-align: left;
514
- border-bottom-width: 2px;
515
- }
516
-
517
- /* ── Horizontal rules ── */
518
-
519
- .zd-content :where(hr) {
520
- border: none;
521
- border-top: 1px solid var(--color-muted);
522
- margin: var(--spacing-vsp-xl) 0;
523
- }
524
-
525
- /* ── Images ── */
526
-
527
- .zd-content :where(img) {
528
- max-width: 100%;
529
- height: auto;
530
- }
531
-
532
- /* ── Definition lists ── */
533
-
534
- .zd-content :where(dt) {
535
- font-weight: var(--font-weight-semibold);
536
- margin-top: var(--spacing-vsp-md);
537
- }
538
-
539
- .zd-content :where(dd) {
540
- padding-left: var(--spacing-hsp-xl);
541
- margin-bottom: var(--spacing-vsp-xs);
542
- }
543
-
544
- /* ========================================
545
- * Admonitions ([data-admonition])
546
- *
547
- * Variant→color mapping: note→accent, tip→success, info→info,
548
- * warning→warning, danger→danger. Background tint uses
549
- * `color-mix(in srgb, var(--color-X) 12%, var(--color-bg))` for an opaque
550
- * per-theme tinted backdrop. Class names are dynamic (admonition-${variant})
551
- * so Tailwind cannot scan them; rules must live here explicitly.
552
- * See zudolab/zudo-doc#1357 / #1444 / #1456.
553
- * ======================================== */
554
-
555
- [data-admonition] {
556
- border-left: 4px solid var(--color-muted);
557
- /* Asymmetric padding mirrors the Astro reference: generous top, snug bottom. */
558
- padding: var(--spacing-vsp-md) var(--spacing-hsp-lg) var(--spacing-vsp-2xs);
559
- background-color: color-mix(in srgb, var(--color-muted) 12%, var(--color-bg));
560
- border-radius: 0 var(--radius-DEFAULT) var(--radius-DEFAULT) 0;
561
- }
562
-
563
- .admonition-title {
564
- font-weight: var(--font-weight-semibold);
565
- font-size: var(--text-small);
566
- margin-bottom: var(--spacing-vsp-2xs);
567
- }
568
-
569
- /* Variant icon — emoji rendered via ::before so the stub markup stays
570
- * variant-agnostic and the icon vocabulary lives next to its color rule. */
571
- .admonition-title::before {
572
- margin-right: var(--spacing-hsp-2xs);
573
- }
574
-
575
- .admonition-body > :first-child {
576
- margin-top: 0;
577
- }
578
-
579
- .admonition-body > :last-child {
580
- margin-bottom: 0;
581
- }
582
-
583
- [data-admonition="note"],
584
- .admonition-note {
585
- border-left-color: var(--color-accent);
586
- background-color: color-mix(in srgb, var(--color-accent) 12%, var(--color-bg));
587
- }
588
-
589
- [data-admonition="note"] .admonition-title,
590
- .admonition-note .admonition-title {
591
- color: var(--color-accent);
592
- }
593
-
594
- [data-admonition="note"] .admonition-title::before,
595
- .admonition-note .admonition-title::before {
596
- content: "📝";
597
- }
598
-
599
- [data-admonition="tip"],
600
- .admonition-tip {
601
- border-left-color: var(--color-success);
602
- background-color: color-mix(in srgb, var(--color-success) 12%, var(--color-bg));
603
- }
604
-
605
- [data-admonition="tip"] .admonition-title,
606
- .admonition-tip .admonition-title {
607
- color: var(--color-success);
608
- }
609
-
610
- [data-admonition="tip"] .admonition-title::before,
611
- .admonition-tip .admonition-title::before {
612
- content: "💡";
613
- }
614
-
615
- [data-admonition="info"],
616
- .admonition-info {
617
- border-left-color: var(--color-info);
618
- background-color: color-mix(in srgb, var(--color-info) 12%, var(--color-bg));
619
- }
620
-
621
- [data-admonition="info"] .admonition-title,
622
- .admonition-info .admonition-title {
623
- color: var(--color-info);
624
- }
625
-
626
- [data-admonition="info"] .admonition-title::before,
627
- .admonition-info .admonition-title::before {
628
- content: "ℹ️";
629
- }
630
-
631
- [data-admonition="warning"],
632
- .admonition-warning {
633
- border-left-color: var(--color-warning);
634
- background-color: color-mix(in srgb, var(--color-warning) 12%, var(--color-bg));
635
- }
636
-
637
- [data-admonition="warning"] .admonition-title,
638
- .admonition-warning .admonition-title {
639
- color: var(--color-warning);
640
- }
641
-
642
- [data-admonition="warning"] .admonition-title::before,
643
- .admonition-warning .admonition-title::before {
644
- content: "⚠️";
645
- }
646
-
647
- [data-admonition="danger"],
648
- .admonition-danger {
649
- border-left-color: var(--color-danger);
650
- background-color: color-mix(in srgb, var(--color-danger) 12%, var(--color-bg));
651
- }
652
-
653
- [data-admonition="danger"] .admonition-title,
654
- .admonition-danger .admonition-title {
655
- color: var(--color-danger);
656
- }
657
-
658
- [data-admonition="danger"] .admonition-title::before,
659
- .admonition-danger .admonition-title::before {
660
- content: "🚨";
661
- }
662
-
663
- /* github-alerts [!IMPORTANT] → magenta/accent-adjacent (p5 = magenta palette slot) */
664
- [data-admonition="important"],
665
- .admonition-important {
666
- border-left-color: var(--color-p5);
667
- background-color: color-mix(in srgb, var(--color-p5) 12%, var(--color-bg));
668
- }
669
-
670
- [data-admonition="important"] .admonition-title,
671
- .admonition-important .admonition-title {
672
- color: var(--color-p5);
673
- }
674
-
675
- [data-admonition="important"] .admonition-title::before,
676
- .admonition-important .admonition-title::before {
677
- content: "❗";
678
- }
679
-
680
- /* github-alerts [!CAUTION] → danger-adjacent (high severity, distinct from danger/🚨) */
681
- [data-admonition="caution"],
682
- .admonition-caution {
683
- border-left-color: var(--color-danger);
684
- background-color: color-mix(in srgb, var(--color-danger) 12%, var(--color-bg));
685
- }
686
-
687
- [data-admonition="caution"] .admonition-title,
688
- .admonition-caution .admonition-title {
689
- color: var(--color-danger);
690
- }
691
-
692
- [data-admonition="caution"] .admonition-title::before,
693
- .admonition-caution .admonition-title::before {
694
- content: "⛔";
695
- }
696
-
697
301
  /* ========================================
698
302
  * Code block buttons (copy + word wrap)
699
303
  * ======================================== */
@@ -901,25 +505,6 @@ pre[class^="syntect-"] .line .highlighted-word {
901
505
  margin: 0;
902
506
  }
903
507
 
904
- /* ========================================
905
- * Mermaid diagrams
906
- * ======================================== */
907
-
908
- .zd-content .mermaid {
909
- @apply my-vsp-lg flex justify-center;
910
- }
911
-
912
- .zd-content .mermaid svg {
913
- max-width: 100%;
914
- height: auto;
915
- overflow: visible;
916
- }
917
-
918
- /* Prevent mermaid edge labels from clipping text (foreignObject uses overflow:hidden) */
919
- .zd-content .mermaid foreignObject {
920
- overflow: visible;
921
- }
922
-
923
508
  /* KaTeX math equations */
924
509
  .katex {
925
510
  font-size: 1.1em;
@@ -1271,4 +856,145 @@ dialog.zd-enlarge-dialog::backdrop {
1271
856
  }
1272
857
  }
1273
858
 
859
+ /* ========================================
860
+ * Mermaid enlarge (.zd-mermaid-enlargeable) — issue #2176 / #2178
861
+ *
862
+ * Unlike images (SSR-wrapped in figure.zd-enlargeable), mermaid diagrams render
863
+ * client-side, so the mermaid-enlarge island injects the `.zd-enlarge-btn` into
864
+ * each rendered `.mermaid` container and adds `.zd-mermaid-enlargeable` (which
865
+ * makes the container position:relative so the button anchors to it). The
866
+ * button + close-button visuals are SHARED with image-enlarge above
867
+ * (`.zd-enlarge-btn`, `.zd-enlarge-dialog-close`).
868
+ * ======================================== */
869
+
870
+ .zd-mermaid-enlargeable {
871
+ position: relative;
872
+ border: 1px solid transparent;
873
+ border-radius: var(--radius-DEFAULT);
874
+ transition: border-color var(--default-transition-duration);
875
+ }
876
+
877
+ /* The diagram is always enlargeable once the button is injected — show a subtle
878
+ * border affordance, accent on hover (mirrors the image-enlarge affordance). */
879
+ .zd-mermaid-enlargeable:has(.zd-enlarge-btn) {
880
+ cursor: pointer;
881
+ border-color: var(--color-muted);
882
+ }
883
+
884
+ .zd-mermaid-enlargeable:has(.zd-enlarge-btn):hover {
885
+ border-color: var(--color-accent);
886
+ }
887
+
888
+ /* Zoom/pan dialog. The `<dialog>` stays transform-free (see DIALOG_STYLE in
889
+ * mermaid-enlarge.tsx); the transform lives on .zd-mermaid-transform. */
890
+ dialog.zd-mermaid-dialog::backdrop {
891
+ background: color-mix(in oklch, var(--color-overlay) 80%, transparent);
892
+ }
893
+
894
+ /* Pan viewport — fills the dialog and clips the (possibly zoomed) diagram.
895
+ * touch-action:none so pointer-drag pans on touch without the browser
896
+ * intercepting the gesture for scrolling. */
897
+ .zd-mermaid-viewport {
898
+ width: 100%;
899
+ height: 100%;
900
+ overflow: hidden;
901
+ display: flex;
902
+ align-items: center;
903
+ justify-content: center;
904
+ touch-action: none;
905
+ }
906
+
907
+ .zd-mermaid-viewport[data-pan-active] {
908
+ cursor: grab;
909
+ }
910
+
911
+ .zd-mermaid-viewport[data-pan-active]:active {
912
+ cursor: grabbing;
913
+ }
914
+
915
+ .zd-mermaid-transform {
916
+ width: 100%;
917
+ height: 100%;
918
+ display: flex;
919
+ align-items: center;
920
+ justify-content: center;
921
+ will-change: transform;
922
+ }
923
+
924
+ /* SVG sizing — fit the cloned mermaid svg inside the dialog like
925
+ * object-fit:contain. The svg fills the viewport box (100% x 100%) and its
926
+ * viewBox content is letterboxed/centered by the default
927
+ * preserveAspectRatio="xMidYMid meet". A TALL diagram (e.g. a state diagram) no
928
+ * longer gets forced to full dialog width with `height:auto` driving the height
929
+ * far past the viewport via its aspect ratio — it now shrinks to fit the height
930
+ * instead. max-width/max-height !important neutralize the inline px sizing
931
+ * mermaid bakes onto the svg (without it the diagram renders tiny). Scoped to
932
+ * .zd-mermaid-transform (the cloned-diagram wrapper) so it does NOT hit the
933
+ * toolbar / close-button icon svgs that also live inside the dialog. */
934
+ .zd-mermaid-transform svg {
935
+ max-width: 100% !important;
936
+ max-height: 100% !important;
937
+ width: 100%;
938
+ height: 100%;
939
+ }
940
+
941
+ /* Edge labels (e.g. "Yes"/"No") render inside a tightly-sized <foreignObject>;
942
+ * when the cloned diagram is upscaled to fill the dialog, sub-pixel overflow
943
+ * makes the browser clip the last glyph at the foreignObject's right edge.
944
+ * Letting the foreignObject overflow render keeps the full label visible. */
945
+ .zd-mermaid-transform svg foreignObject {
946
+ overflow: visible;
947
+ }
948
+
949
+ /* Toolbar — bottom-center pill of zoom/pan controls. */
950
+ .zd-mermaid-toolbar {
951
+ position: absolute;
952
+ bottom: var(--spacing-image-overlay-inset);
953
+ left: 50%;
954
+ transform: translateX(-50%);
955
+ display: flex;
956
+ gap: var(--spacing-hsp-2xs);
957
+ padding: var(--spacing-hsp-2xs);
958
+ border: 1px solid var(--color-muted);
959
+ border-radius: var(--radius-DEFAULT);
960
+ background: color-mix(in oklch, var(--color-surface) 92%, transparent);
961
+ z-index: var(--z-index-local-2);
962
+ }
963
+
964
+ .zd-mermaid-tool-btn {
965
+ display: flex;
966
+ align-items: center;
967
+ justify-content: center;
968
+ width: var(--spacing-icon-lg);
969
+ height: var(--spacing-icon-lg);
970
+ padding: var(--spacing-vsp-2xs);
971
+ border: none;
972
+ border-radius: var(--radius-DEFAULT);
973
+ background: transparent;
974
+ color: var(--color-fg);
975
+ cursor: pointer;
976
+ transition:
977
+ background-color var(--default-transition-duration),
978
+ opacity var(--default-transition-duration);
979
+ }
980
+
981
+ .zd-mermaid-tool-btn:hover:not([disabled]) {
982
+ background: color-mix(in oklch, var(--color-muted) 25%, transparent);
983
+ }
984
+
985
+ .zd-mermaid-tool-btn[aria-pressed="true"] {
986
+ background: color-mix(in oklch, var(--color-accent) 30%, transparent);
987
+ color: var(--color-accent);
988
+ }
989
+
990
+ .zd-mermaid-tool-btn[disabled] {
991
+ opacity: 0.4;
992
+ cursor: default;
993
+ }
994
+
995
+ .zd-mermaid-tool-btn > svg {
996
+ width: var(--spacing-icon-md);
997
+ height: var(--spacing-icon-md);
998
+ }
999
+
1274
1000
  /* @slot:global-css:feature-styles */