@tomehq/theme 0.2.8 → 0.3.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/dist/entry.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  entry_default
3
- } from "./chunk-45M5UIAB.js";
3
+ } from "./chunk-GR2WCRGK.js";
4
4
  export {
5
5
  entry_default as default
6
6
  };
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@ import {
3
3
  Shell,
4
4
  THEME_PRESETS,
5
5
  entry_default
6
- } from "./chunk-45M5UIAB.js";
6
+ } from "./chunk-GR2WCRGK.js";
7
7
  export {
8
8
  AiChat,
9
9
  entry_default as App,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tomehq/theme",
3
- "version": "0.2.8",
3
+ "version": "0.3.0",
4
4
  "description": "Tome default theme and React app shell",
5
5
  "type": "module",
6
6
  "main": "./src/index.tsx",
@@ -9,8 +9,8 @@
9
9
  "./entry": "./src/entry.tsx"
10
10
  },
11
11
  "dependencies": {
12
- "@tomehq/components": "0.2.8",
13
- "@tomehq/core": "0.2.8"
12
+ "@tomehq/components": "0.3.0",
13
+ "@tomehq/core": "0.3.0"
14
14
  },
15
15
  "peerDependencies": {
16
16
  "react": "^18.0.0 || ^19.0.0",
package/src/Shell.tsx CHANGED
@@ -359,6 +359,8 @@ export function Shell({
359
359
  const isOldVersion = versioning && currentVersion && currentVersion !== versioning.current;
360
360
  const [expanded, setExpanded] = useState<string[]>(navigation.map(n => n.section));
361
361
  const contentRef = useRef<HTMLDivElement>(null);
362
+ const htmlContentRef = useRef<HTMLDivElement>(null);
363
+ const lastHtmlRef = useRef<string>("");
362
364
  const [wide, setWide] = useState(() => typeof window !== "undefined" && window.innerWidth > 1100);
363
365
 
364
366
  const preset = (config.theme?.preset || "amber") as PresetName;
@@ -531,6 +533,17 @@ export function Shell({
531
533
  // Reset active heading when page changes
532
534
  useEffect(() => { setActiveHeadingId(""); }, [currentPageId]);
533
535
 
536
+ // Set HTML content via ref so React doesn't re-set innerHTML on re-renders
537
+ // (scroll-spy state changes would otherwise destroy client-side mermaid SVGs)
538
+ useEffect(() => {
539
+ if (!htmlContentRef.current || !pageHtml) return;
540
+ const stripped = pageHtml.replace(/<h1[^>]*>[\s\S]*?<\/h1>\s*/, "");
541
+ if (stripped !== lastHtmlRef.current) {
542
+ htmlContentRef.current.innerHTML = stripped;
543
+ lastHtmlRef.current = stripped;
544
+ }
545
+ }, [pageHtml]);
546
+
534
547
  // Smooth scroll handler for TOC links
535
548
  const scrollToHeading = useCallback((e: React.MouseEvent<HTMLAnchorElement>, id: string) => {
536
549
  e.preventDefault();
@@ -584,7 +597,7 @@ export function Shell({
584
597
  display: "flex", alignItems: "center", justifyContent: "center", gap: 12,
585
598
  background: "var(--ac)", color: "#fff", padding: "8px 16px",
586
599
  fontSize: 13, fontFamily: "var(--font-body)", fontWeight: 500, textAlign: "center",
587
- width: "100%", boxSizing: "border-box",
600
+ width: "100vw", boxSizing: "border-box",
588
601
  }}>
589
602
  {config.banner.link ? (
590
603
  <a
@@ -977,7 +990,7 @@ export function Shell({
977
990
  ) : (
978
991
  <div
979
992
  className="tome-content"
980
- dangerouslySetInnerHTML={{ __html: (pageHtml || "").replace(/<h1[^>]*>[\s\S]*?<\/h1>\s*/, "") }}
993
+ ref={htmlContentRef}
981
994
  />
982
995
  )}
983
996
  </div>
package/src/entry.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import React, { useState, useEffect, useCallback } from "react";
1
+ import React, { useState, useEffect, useCallback, useRef } from "react";
2
2
  import { createRoot } from "react-dom/client";
3
3
  import { Shell } from "./Shell.js";
4
4
  import { pathnameToPageId as _pathnameToPageId, pageIdToPath as _pageIdToPath } from "./routing.js";
@@ -75,6 +75,27 @@ const contentStyles = `
75
75
  .tome-mermaid { margin: 1.2em 0; text-align: center; overflow-x: auto; }
76
76
  .tome-mermaid svg { max-width: 100%; height: auto; overflow: visible; }
77
77
  .tome-mermaid svg .nodeLabel { overflow: visible; white-space: nowrap; }
78
+ /* Ensure mermaid text meets WCAG AA contrast in light mode */
79
+ /* Mermaid v11 uses foreignObject with inline-styled spans — !important needed */
80
+ html:not(.dark) .tome-mermaid svg .nodeLabel,
81
+ html:not(.dark) .tome-mermaid svg .nodeLabel span,
82
+ html:not(.dark) .tome-mermaid svg .nodeLabel div,
83
+ html:not(.dark) .tome-mermaid svg foreignObject div,
84
+ html:not(.dark) .tome-mermaid svg foreignObject span { color: #1a1a1a !important; }
85
+ html:not(.dark) .tome-mermaid svg .edgeLabel,
86
+ html:not(.dark) .tome-mermaid svg .edgeLabel span { color: #333 !important; }
87
+ html:not(.dark) .tome-mermaid svg text { fill: #1a1a1a !important; }
88
+ html:not(.dark) .tome-mermaid svg .node rect,
89
+ html:not(.dark) .tome-mermaid svg .node polygon { stroke: #555 !important; }
90
+ /* Dark mode: force bright text in mermaid nodes for readability */
91
+ html.dark .tome-mermaid svg .nodeLabel,
92
+ html.dark .tome-mermaid svg .nodeLabel span,
93
+ html.dark .tome-mermaid svg .nodeLabel div,
94
+ html.dark .tome-mermaid svg foreignObject div,
95
+ html.dark .tome-mermaid svg foreignObject span { color: #f0f0f0 !important; }
96
+ html.dark .tome-mermaid svg .edgeLabel,
97
+ html.dark .tome-mermaid svg .edgeLabel span { color: #ddd !important; }
98
+ html.dark .tome-mermaid svg text { fill: #f0f0f0 !important; }
78
99
 
79
100
  /* Mobile responsive content */
80
101
  @media (max-width: 767px) {
@@ -201,7 +222,23 @@ function App() {
201
222
  return () => window.removeEventListener("popstate", onPopState);
202
223
  }, [currentPageId, navigateTo]);
203
224
 
204
- // Mermaid diagram rendering: load from CDN and render .tome-mermaid elements
225
+ // Mermaid diagram rendering: load from CDN and render .tome-mermaid elements.
226
+ // Also re-renders when theme changes (dark ↔ light) so colors stay correct.
227
+ const mermaidModuleRef = useRef<any>(null);
228
+ const [mermaidTheme, setMermaidTheme] = useState(() =>
229
+ typeof document !== "undefined" && document.documentElement.classList.contains("dark") ? "dark" : "light"
230
+ );
231
+
232
+ // Watch for dark class changes on <html> to trigger mermaid re-render
233
+ useEffect(() => {
234
+ const observer = new MutationObserver(() => {
235
+ const isDark = document.documentElement.classList.contains("dark");
236
+ setMermaidTheme(isDark ? "dark" : "light");
237
+ });
238
+ observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
239
+ return () => observer.disconnect();
240
+ }, []);
241
+
205
242
  useEffect(() => {
206
243
  const els = document.querySelectorAll(".tome-mermaid[data-mermaid]");
207
244
  if (els.length === 0) return;
@@ -211,11 +248,12 @@ function App() {
211
248
 
212
249
  (async () => {
213
250
  try {
214
- // Load mermaid from CDN (ESM) — works in all browsers, no bundler dependency
215
- const { default: mermaid } = await import(/* @vite-ignore */ MERMAID_CDN);
251
+ if (!mermaidModuleRef.current) {
252
+ mermaidModuleRef.current = (await import(/* @vite-ignore */ MERMAID_CDN)).default;
253
+ }
254
+ const mermaid = mermaidModuleRef.current;
216
255
  if (cancelled) return;
217
- const isDark = document.documentElement.classList.contains("dark");
218
- // Resolve CSS variable to concrete font name — mermaid can't resolve CSS vars for text measurement
256
+ const isDark = mermaidTheme === "dark";
219
257
  const resolvedFont = getComputedStyle(document.documentElement).getPropertyValue("--font-body").trim() || "sans-serif";
220
258
  mermaid.initialize({
221
259
  startOnLoad: false,
@@ -226,22 +264,14 @@ function App() {
226
264
 
227
265
  for (let i = 0; i < els.length; i++) {
228
266
  const el = els[i] as HTMLElement;
229
- if (el.querySelector("svg")) continue; // already rendered
230
267
  const encoded = el.getAttribute("data-mermaid");
231
268
  if (!encoded) continue;
232
269
  try {
233
270
  const code = atob(encoded);
271
+ // Render new SVG off-screen first, then swap — avoids white flash on theme change
234
272
  const { svg } = await mermaid.render(`tome-mermaid-${i}-${Date.now()}`, code);
235
273
  if (!cancelled) {
236
- // Sanitize SVG to prevent XSS from mermaid-rendered content
237
- try {
238
- // @ts-ignore — CDN dynamic import for browser-only sanitization
239
- const DOMPurify = (await import(/* @vite-ignore */ "https://cdn.jsdelivr.net/npm/dompurify@3/dist/purify.es.mjs")).default;
240
- el.innerHTML = DOMPurify.sanitize(svg, { USE_PROFILES: { svg: true, svgFilters: true } });
241
- } catch {
242
- // DOMPurify unavailable — render without sanitization (acceptable for trusted content)
243
- el.innerHTML = svg;
244
- }
274
+ el.innerHTML = svg;
245
275
  }
246
276
  } catch (err) {
247
277
  console.warn("[tome] Mermaid render failed:", err);
@@ -259,7 +289,7 @@ function App() {
259
289
  })();
260
290
 
261
291
  return () => { cancelled = true; };
262
- }, [pageData, loading]);
292
+ }, [pageData, loading, mermaidTheme]);
263
293
 
264
294
  const allPages = routes.map((r: any) => ({
265
295
  id: r.id,
@@ -272,9 +302,10 @@ function App() {
272
302
  const currentVersion = detectCurrentVersion(currentRoute, versions);
273
303
  const editUrl = computeEditUrl(config.editLink, currentRoute?.filePath);
274
304
 
275
- // KaTeX CSS: inject stylesheet when math is enabled
305
+ // KaTeX CSS: inject stylesheet when math is enabled or math placeholders exist
276
306
  useEffect(() => {
277
- if (!(config as any).math) return;
307
+ const hasMathPlaceholders = document.querySelectorAll(".tome-math[data-math]").length > 0;
308
+ if (!(config as any).math && !hasMathPlaceholders) return;
278
309
  const id = "tome-katex-css";
279
310
  if (document.getElementById(id)) return;
280
311
  const link = document.createElement("link");
@@ -283,7 +314,41 @@ function App() {
283
314
  link.href = "https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css";
284
315
  link.crossOrigin = "anonymous";
285
316
  document.head.appendChild(link);
286
- }, []);
317
+ }, [pageData, loading]);
318
+
319
+ // Client-side KaTeX rendering for MDX math placeholders (.tome-math[data-math])
320
+ useEffect(() => {
321
+ const els = document.querySelectorAll(".tome-math[data-math]");
322
+ if (els.length === 0) return;
323
+ let cancelled = false;
324
+
325
+ const KATEX_CDN = "https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.mjs";
326
+
327
+ (async () => {
328
+ try {
329
+ const katex = (await import(/* @vite-ignore */ KATEX_CDN)).default;
330
+ if (cancelled) return;
331
+ for (const el of els) {
332
+ const encoded = el.getAttribute("data-math");
333
+ if (!encoded) continue;
334
+ try {
335
+ const tex = atob(encoded);
336
+ const isBlock = el.classList.contains("tome-math-block");
337
+ katex.render(tex, el as HTMLElement, {
338
+ displayMode: isBlock,
339
+ throwOnError: false,
340
+ });
341
+ } catch (err) {
342
+ console.warn("[tome] KaTeX render failed:", err);
343
+ }
344
+ }
345
+ } catch (err) {
346
+ console.warn("[tome] Failed to load KaTeX from CDN:", err);
347
+ }
348
+ })();
349
+
350
+ return () => { cancelled = true; };
351
+ }, [pageData, loading]);
287
352
 
288
353
  return (
289
354
  <>