@tomehq/theme 0.3.4 → 0.5.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
@@ -1,65 +1 @@
1
- # @tomehq/theme
2
-
3
- ## 0.3.4
4
-
5
- ### Patch Changes
6
-
7
- - f03e6c2: Fix page scrolling beyond viewport bounds in all directions. Lock layout to 100vh with overflow hidden on html, body, and root container.
8
-
9
- ## 0.3.3
10
-
11
- ### Patch Changes
12
-
13
- - 062349c: Fix MCP server stdout corruption, add dashboard mobile responsiveness, improve test coverage, and update docs.
14
-
15
- - Fix: MCP CLI banner no longer writes to stdout, preventing JSON-RPC protocol corruption
16
- - Fix: API Playground prop wiring and githubSource route crash
17
- - Feat: MCP server `createMcpServer()` exported for programmatic use with graceful shutdown
18
- - Feat: Dashboard mobile-responsive layout with media query breakpoints
19
- - Feat: 13 MCP server integration tests using InMemoryTransport
20
- - Docs: Add missing typedoc CLI command, fix social link examples, update package list
21
-
22
- - Updated dependencies [062349c]
23
- - @tomehq/core@0.3.3
24
- - @tomehq/components@0.3.3
25
-
26
- ## 0.2.8
27
-
28
- ### Minor Changes
29
-
30
- - Replace hash-based SPA routing with History API (pushState + popstate + pathname parsing)
31
- - Content link interception: in-content markdown links navigate via SPA instead of full page reload
32
- - Banner link internal navigation support
33
- - Algolia search basePath stripping for correct page ID extraction
34
- - Extract routing helpers (`pathnameToPageId`, `pageIdToPath`) into testable `routing.ts` module
35
- - Extract entry helpers (`loadPage`, `computeEditUrl`, `resolveInitialPageId`, `detectCurrentVersion`) into testable `entry-helpers.ts` module
36
- - Pass `basePath` prop through Shell for correct URL construction
37
- - Updated dependencies
38
- - @tomehq/core@0.2.8
39
- - @tomehq/components@0.2.8
40
-
41
- ## 0.2.0
42
-
43
- ### Minor Changes
44
-
45
- - Shell: logo links back to landing page, dynamic version in sidebar footer
46
- - Shell: edit link support, table of contents depth config, changelog page layout
47
- - Entry: pass new config fields (editLink, tableOfContents, plugins) to Shell
48
- - Updated dependencies
49
- - @tomehq/core@0.2.0
50
- - @tomehq/components@0.2.0
51
-
52
- ## 0.1.2
53
-
54
- ### Patch Changes
55
-
56
- - Updated dependencies
57
- - @tomehq/components@0.1.1
58
-
59
- ## 0.1.1
60
-
61
- ### Patch Changes
62
-
63
- - Fix bugs found in functionality audit: invalid search provider, AI key naming mismatch, hardcoded version. Remove dead billing stubs. Add 57 API route tests.
64
- - Updated dependencies
65
- - @tomehq/core@0.1.1
1
+ # Changelog
@@ -3,7 +3,7 @@ import { useState as useState3, useEffect as useEffect3, useCallback as useCallb
3
3
  import { createRoot } from "react-dom/client";
4
4
 
5
5
  // src/Shell.tsx
6
- import React2, { useState as useState2, useEffect as useEffect2, useRef as useRef2, useCallback as useCallback2 } from "react";
6
+ import React2, { useState as useState2, useEffect as useEffect2, useLayoutEffect, useRef as useRef2, useCallback as useCallback2 } from "react";
7
7
 
8
8
  // src/presets.ts
9
9
  var THEME_PRESETS = {
@@ -76,6 +76,76 @@ var THEME_PRESETS = {
76
76
  hdBg: "rgba(246,244,240,0.92)"
77
77
  },
78
78
  fonts: { heading: "Cormorant Garamond", body: "Bricolage Grotesque", code: "Fira Code" }
79
+ },
80
+ cipher: {
81
+ dark: {
82
+ bg: "#050508",
83
+ sf: "#0c0c12",
84
+ sfH: "#12121a",
85
+ bd: "#1a1a25",
86
+ tx: "#d4ff00",
87
+ tx2: "#8a90a0",
88
+ txM: "#6a7080",
89
+ ac: "#6666ff",
90
+ acD: "rgba(102,102,255,0.10)",
91
+ acT: "#8080ff",
92
+ cdBg: "#08080e",
93
+ cdTx: "#b0c870",
94
+ sbBg: "#08080d",
95
+ hdBg: "rgba(5,5,8,0.88)"
96
+ },
97
+ light: {
98
+ bg: "#f0f2f5",
99
+ sf: "#ffffff",
100
+ sfH: "#e8eaef",
101
+ bd: "#d0d4db",
102
+ tx: "#0f1219",
103
+ tx2: "#4a5060",
104
+ txM: "#6a7080",
105
+ ac: "#2020cc",
106
+ acD: "rgba(32,32,204,0.08)",
107
+ acT: "#1a1aa8",
108
+ cdBg: "#e6e9ef",
109
+ cdTx: "#2a3520",
110
+ sbBg: "#ebedf2",
111
+ hdBg: "rgba(240,242,245,0.90)"
112
+ },
113
+ fonts: { heading: "Bodoni Moda", body: "Space Grotesk", code: "Source Code Pro" }
114
+ },
115
+ mint: {
116
+ dark: {
117
+ bg: "#0d1117",
118
+ sf: "#161b22",
119
+ sfH: "#1c2129",
120
+ bd: "#21262d",
121
+ tx: "#e6edf3",
122
+ tx2: "#8b949e",
123
+ txM: "#6e7681",
124
+ ac: "#0ea371",
125
+ acD: "rgba(14,163,113,0.10)",
126
+ acT: "#2dd4a0",
127
+ cdBg: "#0a0e14",
128
+ cdTx: "#adbac7",
129
+ sbBg: "#0d1117",
130
+ hdBg: "rgba(13,17,23,0.88)"
131
+ },
132
+ light: {
133
+ bg: "#ffffff",
134
+ sf: "#f6f8fa",
135
+ sfH: "#eef1f5",
136
+ bd: "#d8dee4",
137
+ tx: "#1f2328",
138
+ tx2: "#59636e",
139
+ txM: "#6e7681",
140
+ ac: "#0a7b53",
141
+ acD: "rgba(10,123,83,0.07)",
142
+ acT: "#087a50",
143
+ cdBg: "#f0f3f6",
144
+ cdTx: "#24292f",
145
+ sbBg: "#f6f8fa",
146
+ hdBg: "rgba(255,255,255,0.90)"
147
+ },
148
+ fonts: { heading: "Inter", body: "Inter", code: "Fira Code" }
79
149
  }
80
150
  };
81
151
 
@@ -842,7 +912,7 @@ function Shell({
842
912
  const [isDark, setDark] = useState2(() => {
843
913
  if (themeMode === "dark") return true;
844
914
  if (themeMode === "light") return false;
845
- return window.matchMedia?.("(prefers-color-scheme: dark)").matches ?? true;
915
+ return window.matchMedia?.("(prefers-color-scheme: dark)").matches ?? false;
846
916
  });
847
917
  const [mobile, setMobile] = useState2(() => typeof window !== "undefined" && window.innerWidth < 768);
848
918
  const [sbOpen, setSb] = useState2(() => typeof window !== "undefined" && window.innerWidth >= 768);
@@ -997,14 +1067,12 @@ function Shell({
997
1067
  useEffect2(() => {
998
1068
  setActiveHeadingId("");
999
1069
  }, [currentPageId]);
1000
- useEffect2(() => {
1070
+ useLayoutEffect(() => {
1001
1071
  if (!htmlContentRef.current || !pageHtml) return;
1002
1072
  const stripped = pageHtml.replace(/<h1[^>]*>[\s\S]*?<\/h1>\s*/, "");
1003
- if (stripped !== lastHtmlRef.current) {
1004
- htmlContentRef.current.innerHTML = stripped;
1005
- lastHtmlRef.current = stripped;
1006
- }
1007
- }, [pageHtml]);
1073
+ htmlContentRef.current.innerHTML = stripped;
1074
+ lastHtmlRef.current = stripped;
1075
+ }, [pageHtml, currentPageId]);
1008
1076
  const scrollToHeading = useCallback2((e, id) => {
1009
1077
  e.preventDefault();
1010
1078
  const scrollRoot = contentRef.current;
@@ -1054,7 +1122,7 @@ function Shell({
1054
1122
  const PageComponent = pageComponent;
1055
1123
  const bannerLink = config2.banner?.link;
1056
1124
  const bannerIsInternal = bannerLink ? bannerLink.startsWith("#") || basePath2 && bannerLink.startsWith(basePath2 + "/") : false;
1057
- return /* @__PURE__ */ jsxs2("div", { dir, className: "tome-grain", style: { ...cssVars, color: "var(--tx)", background: "var(--bg)", fontFamily: "var(--font-body)", height: "100vh", overflow: "hidden" }, children: [
1125
+ return /* @__PURE__ */ jsxs2("div", { dir, className: "tome-grain", style: { ...cssVars, color: "var(--tx)", background: "var(--bg)", fontFamily: "var(--font-body)", height: "100vh", overflow: "clip" }, children: [
1058
1126
  config2.banner?.text && !bannerDismissed && /* @__PURE__ */ jsxs2("div", { style: {
1059
1127
  display: "flex",
1060
1128
  alignItems: "center",
@@ -1591,7 +1659,7 @@ function Shell({
1591
1659
  }
1592
1660
  ),
1593
1661
  /* @__PURE__ */ jsxs2("div", { ref: contentRef, style: { flex: 1, overflow: "auto", display: "flex" }, children: [
1594
- /* @__PURE__ */ jsxs2("main", { style: { flex: 1, maxWidth: mobile ? "100%" : 760, padding: mobile ? "24px 16px 60px" : "40px 48px 80px", margin: "0 auto", minWidth: 0 }, children: [
1662
+ /* @__PURE__ */ jsxs2("main", { style: { flex: 1, maxWidth: mobile ? "100%" : apiManifest ? 1100 : 760, padding: mobile ? "24px 16px 60px" : "40px 48px 80px", margin: "0 auto", minWidth: 0 }, children: [
1595
1663
  breadcrumbs.length > 0 && /* @__PURE__ */ jsx2("nav", { "aria-label": "Breadcrumbs", "data-testid": "breadcrumbs", style: {
1596
1664
  display: "flex",
1597
1665
  alignItems: "center",
@@ -1625,7 +1693,8 @@ function Shell({
1625
1693
  {
1626
1694
  className: "tome-content",
1627
1695
  ref: htmlContentRef
1628
- }
1696
+ },
1697
+ currentPageId
1629
1698
  )
1630
1699
  ) }),
1631
1700
  overrides2?.PageFooter ? /* @__PURE__ */ jsx2(
@@ -1968,6 +2037,21 @@ function pageIdToPath(id, basePath2, routes2) {
1968
2037
  }
1969
2038
 
1970
2039
  // src/entry-helpers.ts
2040
+ var PageNotFoundError = class extends Error {
2041
+ code = "PAGE_NOT_FOUND";
2042
+ constructor(pageId) {
2043
+ super(`Page not found: ${pageId}`);
2044
+ this.name = "PageNotFoundError";
2045
+ }
2046
+ };
2047
+ var PageLoadError = class extends Error {
2048
+ code = "PAGE_LOAD_ERROR";
2049
+ constructor(pageId, cause) {
2050
+ super(`Failed to load page: ${pageId}`);
2051
+ this.name = "PageLoadError";
2052
+ if (cause) this.cause = cause;
2053
+ }
2054
+ };
1971
2055
  function computeEditUrl(editLink, filePath) {
1972
2056
  if (!editLink || !filePath) return void 0;
1973
2057
  const { repo, branch = "main", dir = "" } = editLink;
@@ -1982,29 +2066,29 @@ function resolveInitialPageId(pathname, hash, routes2, basePath2, pathnameToPage
1982
2066
  return routes2[0]?.id || "index";
1983
2067
  }
1984
2068
  async function loadPage(id, routes2, loadPageModule2) {
2069
+ const route = routes2.find((r) => r.id === id);
2070
+ let mod;
1985
2071
  try {
1986
- const route = routes2.find((r) => r.id === id);
1987
- const mod = await loadPageModule2(id);
1988
- if (route?.isMdx && mod.meta) {
1989
- return {
1990
- isMdx: true,
1991
- component: mod.default,
1992
- frontmatter: mod.meta.frontmatter,
1993
- headings: mod.meta.headings
1994
- };
1995
- }
1996
- if (!mod.default) return null;
1997
- if (mod.isApiReference && mod.apiManifest) {
1998
- return { isMdx: false, isApiReference: true, ...mod.default, apiManifest: mod.apiManifest };
1999
- }
2000
- if (mod.isChangelog && mod.changelogEntries) {
2001
- return { isMdx: false, ...mod.default, changelogEntries: mod.changelogEntries };
2002
- }
2003
- return { isMdx: false, ...mod.default };
2072
+ mod = await loadPageModule2(id);
2004
2073
  } catch (err) {
2005
- console.error(`Failed to load page: ${id}`, err);
2006
- return null;
2074
+ throw new PageLoadError(id, err);
2007
2075
  }
2076
+ if (route?.isMdx && mod.meta) {
2077
+ return {
2078
+ isMdx: true,
2079
+ component: mod.default,
2080
+ frontmatter: mod.meta.frontmatter,
2081
+ headings: mod.meta.headings
2082
+ };
2083
+ }
2084
+ if (!mod.default) throw new PageNotFoundError(id);
2085
+ if (mod.isApiReference && mod.apiManifest) {
2086
+ return { isMdx: false, isApiReference: true, ...mod.default, apiManifest: mod.apiManifest };
2087
+ }
2088
+ if (mod.isChangelog && mod.changelogEntries) {
2089
+ return { isMdx: false, ...mod.default, changelogEntries: mod.changelogEntries };
2090
+ }
2091
+ return { isMdx: false, ...mod.default };
2008
2092
  }
2009
2093
  function detectCurrentVersion(currentRoute, versions2) {
2010
2094
  return currentRoute?.version || (versions2?.current ?? void 0);
@@ -2050,14 +2134,13 @@ var MDX_COMPONENTS = {
2050
2134
  CardGrid
2051
2135
  };
2052
2136
  var contentStyles = `
2053
- @import url('https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:wght@300;400;500;600;700&family=Cormorant+Garamond:ital,wght@0,300;0,400;0,600;0,700;1,300;1,400;1,700&family=Fira+Code:wght@400;500;600&display=swap');
2137
+ @import url('https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=DM+Sans:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600&family=Bricolage+Grotesque:wght@300;400;500;600;700&family=Cormorant+Garamond:ital,wght@0,300;0,400;0,600;0,700;1,300;1,400;1,700&family=Fira+Code:wght@400;500;600&family=Bodoni+Moda:ital,wght@0,400;0,700;0,900;1,400&family=Space+Grotesk:wght@400;500;600;700&family=Source+Code+Pro:wght@400;500;600&family=Inter:wght@300;400;500;600;700&display=swap');
2054
2138
 
2055
- html, body { margin: 0; padding: 0; height: 100%; overflow: hidden; }
2056
- #tome-root { height: 100%; overflow: hidden; }
2139
+ html, body { margin: 0; padding: 0; height: 100%; overflow: clip; }
2140
+ #tome-root { height: 100%; overflow: clip; }
2057
2141
 
2058
2142
  .tome-content h1 { display: none; }
2059
- .tome-content h2 { font-family: var(--font-body); font-size: 1.35em; font-weight: 600; margin-top: 2em; margin-bottom: 0.5em; display: flex; align-items: center; gap: 10px; letter-spacing: 0.01em; }
2060
- .tome-content h2::before { content: "#"; font-family: var(--font-heading); font-size: 1.2em; font-weight: 300; font-style: italic; color: var(--ac); opacity: 0.5; }
2143
+ .tome-content h2 { font-family: var(--font-body); font-size: 1.35em; font-weight: 600; margin-top: 2em; margin-bottom: 0.5em; letter-spacing: 0.01em; }
2061
2144
  .tome-content h3 { font-family: var(--font-body); font-size: 1.15em; font-weight: 600; margin-top: 1.5em; margin-bottom: 0.5em; }
2062
2145
  .tome-content h4 { font-family: var(--font-body); font-size: 1.05em; font-weight: 600; margin-top: 1.2em; margin-bottom: 0.5em; }
2063
2146
  .tome-content p { color: var(--tx2); line-height: 1.8; margin-bottom: 1em; font-size: 14.5px; }
@@ -2286,16 +2369,26 @@ function App() {
2286
2369
  const [currentPageId, setCurrentPageId] = useState3(_initialPageId);
2287
2370
  const [pageData, setPageData] = useState3(null);
2288
2371
  const [loading, setLoading] = useState3(true);
2372
+ const navCounterRef = useRef3(0);
2289
2373
  const navigateTo = useCallback3(async (id, opts) => {
2374
+ const navId = ++navCounterRef.current;
2290
2375
  setLoading(true);
2291
- setCurrentPageId(id);
2376
+ let data;
2377
+ try {
2378
+ data = await loadPage(id, routes, loadPageModule);
2379
+ } catch (err) {
2380
+ if (navCounterRef.current !== navId) return;
2381
+ console.error(`[tome] Navigation failed for page: ${id}`, err);
2382
+ data = null;
2383
+ }
2384
+ if (navCounterRef.current !== navId) return;
2292
2385
  const fullPath = pageIdToPath2(id);
2293
2386
  if (opts?.replace) {
2294
2387
  window.history.replaceState(null, "", fullPath);
2295
2388
  } else {
2296
2389
  window.history.pushState(null, "", fullPath);
2297
2390
  }
2298
- const data = await loadPage(id, routes, loadPageModule);
2391
+ setCurrentPageId(id);
2299
2392
  setPageData(data);
2300
2393
  setLoading(false);
2301
2394
  if (!opts?.skipScroll) {
package/dist/entry.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  entry_default
3
- } from "./chunk-QYINBNMJ.js";
3
+ } from "./chunk-FIUZY65C.js";
4
4
  export {
5
5
  entry_default as default
6
6
  };
package/dist/index.d.ts CHANGED
@@ -252,6 +252,84 @@ declare const THEME_PRESETS: {
252
252
  readonly code: "Fira Code";
253
253
  };
254
254
  };
255
+ readonly cipher: {
256
+ readonly dark: {
257
+ readonly bg: "#050508";
258
+ readonly sf: "#0c0c12";
259
+ readonly sfH: "#12121a";
260
+ readonly bd: "#1a1a25";
261
+ readonly tx: "#d4ff00";
262
+ readonly tx2: "#8a90a0";
263
+ readonly txM: "#6a7080";
264
+ readonly ac: "#6666ff";
265
+ readonly acD: "rgba(102,102,255,0.10)";
266
+ readonly acT: "#8080ff";
267
+ readonly cdBg: "#08080e";
268
+ readonly cdTx: "#b0c870";
269
+ readonly sbBg: "#08080d";
270
+ readonly hdBg: "rgba(5,5,8,0.88)";
271
+ };
272
+ readonly light: {
273
+ readonly bg: "#f0f2f5";
274
+ readonly sf: "#ffffff";
275
+ readonly sfH: "#e8eaef";
276
+ readonly bd: "#d0d4db";
277
+ readonly tx: "#0f1219";
278
+ readonly tx2: "#4a5060";
279
+ readonly txM: "#6a7080";
280
+ readonly ac: "#2020cc";
281
+ readonly acD: "rgba(32,32,204,0.08)";
282
+ readonly acT: "#1a1aa8";
283
+ readonly cdBg: "#e6e9ef";
284
+ readonly cdTx: "#2a3520";
285
+ readonly sbBg: "#ebedf2";
286
+ readonly hdBg: "rgba(240,242,245,0.90)";
287
+ };
288
+ readonly fonts: {
289
+ readonly heading: "Bodoni Moda";
290
+ readonly body: "Space Grotesk";
291
+ readonly code: "Source Code Pro";
292
+ };
293
+ };
294
+ readonly mint: {
295
+ readonly dark: {
296
+ readonly bg: "#0d1117";
297
+ readonly sf: "#161b22";
298
+ readonly sfH: "#1c2129";
299
+ readonly bd: "#21262d";
300
+ readonly tx: "#e6edf3";
301
+ readonly tx2: "#8b949e";
302
+ readonly txM: "#6e7681";
303
+ readonly ac: "#0ea371";
304
+ readonly acD: "rgba(14,163,113,0.10)";
305
+ readonly acT: "#2dd4a0";
306
+ readonly cdBg: "#0a0e14";
307
+ readonly cdTx: "#adbac7";
308
+ readonly sbBg: "#0d1117";
309
+ readonly hdBg: "rgba(13,17,23,0.88)";
310
+ };
311
+ readonly light: {
312
+ readonly bg: "#ffffff";
313
+ readonly sf: "#f6f8fa";
314
+ readonly sfH: "#eef1f5";
315
+ readonly bd: "#d8dee4";
316
+ readonly tx: "#1f2328";
317
+ readonly tx2: "#59636e";
318
+ readonly txM: "#6e7681";
319
+ readonly ac: "#0a7b53";
320
+ readonly acD: "rgba(10,123,83,0.07)";
321
+ readonly acT: "#087a50";
322
+ readonly cdBg: "#f0f3f6";
323
+ readonly cdTx: "#24292f";
324
+ readonly sbBg: "#f6f8fa";
325
+ readonly hdBg: "rgba(255,255,255,0.90)";
326
+ };
327
+ readonly fonts: {
328
+ readonly heading: "Inter";
329
+ readonly body: "Inter";
330
+ readonly code: "Fira Code";
331
+ };
332
+ };
255
333
  };
256
334
  type PresetName = keyof typeof THEME_PRESETS;
257
335
 
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@ import {
3
3
  Shell,
4
4
  THEME_PRESETS,
5
5
  entry_default
6
- } from "./chunk-QYINBNMJ.js";
6
+ } from "./chunk-FIUZY65C.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.3.4",
3
+ "version": "0.5.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.3.3",
13
- "@tomehq/core": "0.3.3"
12
+ "@tomehq/components": "0.5.0",
13
+ "@tomehq/core": "0.5.0"
14
14
  },
15
15
  "peerDependencies": {
16
16
  "react": "^18.0.0 || ^19.0.0",
@@ -180,6 +180,18 @@ describe("Shell theme mode", () => {
180
180
  const buttons = footer?.querySelectorAll("button");
181
181
  expect(buttons?.length).toBeGreaterThan(0);
182
182
  });
183
+
184
+ it("defaults to light mode when mode is 'auto' and system preference is unavailable", () => {
185
+ const { container } = renderShell({
186
+ config: { ...baseConfig, theme: { preset: "amber", mode: "auto" } },
187
+ });
188
+ // matchMedia mock returns matches: false → light mode
189
+ // In light mode, the root container uses light theme background
190
+ const root = container.firstElementChild as HTMLElement;
191
+ const bg = root?.style.getPropertyValue("--bg");
192
+ // Amber light bg is #fafaf9 (not dark bg #09090b)
193
+ expect(bg).toBe("#fafaf9");
194
+ });
183
195
  });
184
196
 
185
197
  // ── TOC (TOM-52) ──────────────────────────────────────────
@@ -858,6 +870,31 @@ describe("Shell feedback widget", () => {
858
870
  fireEvent.click(screen.getByText("\uD83D\uDC4E"));
859
871
  expect(screen.getByText("Thanks for your feedback!")).toBeInTheDocument();
860
872
  });
873
+
874
+ it("feedback section is inside the scrollable content container, not clipped by root", () => {
875
+ renderShell();
876
+ const feedbackEl = screen.getByText("Was this helpful?");
877
+ // The feedback widget must be a descendant of the overflow:auto scroll container
878
+ const scrollContainer = feedbackEl.closest('[style*="overflow: auto"]') || feedbackEl.closest('[style*="overflow:auto"]');
879
+ expect(scrollContainer).not.toBeNull();
880
+ // The root container must use overflow:clip (not hidden) to avoid clip containment issues
881
+ const root = feedbackEl.closest(".tome-grain");
882
+ expect(root).not.toBeNull();
883
+ expect((root as HTMLElement).style.overflow).toBe("clip");
884
+ });
885
+
886
+ it("feedback section remains in DOM and is not conditionally removed", () => {
887
+ const { container } = renderShell();
888
+ // Feedback must always render inside <main>
889
+ const main = container.querySelector("main");
890
+ expect(main).not.toBeNull();
891
+ const feedbackText = within(main!).getByText("Was this helpful?");
892
+ expect(feedbackText).toBeInTheDocument();
893
+ // Verify feedback is not position:fixed or absolute (it flows with content)
894
+ const feedbackParent = feedbackText.closest("div")!;
895
+ expect(feedbackParent.style.position).not.toBe("fixed");
896
+ expect(feedbackParent.style.position).not.toBe("absolute");
897
+ });
861
898
  });
862
899
 
863
900
  // ── Breadcrumbs ──────────────────────────────────────────
@@ -1186,6 +1223,24 @@ describe("Shell API reference rendering", () => {
1186
1223
  expect(screen.queryByTestId("api-playground")).not.toBeInTheDocument();
1187
1224
  expect(screen.queryByTestId("api-auth")).not.toBeInTheDocument();
1188
1225
  });
1226
+
1227
+ it("uses wider max-width for API reference pages", () => {
1228
+ const { container } = renderShell({
1229
+ apiManifest: mockManifest,
1230
+ ApiReferenceComponent: MockApiRef,
1231
+ pageHtml: undefined,
1232
+ });
1233
+ const main = container.querySelector("main") as HTMLElement;
1234
+ expect(main.style.maxWidth).toBe("1100px");
1235
+ });
1236
+
1237
+ it("uses standard max-width for non-API pages", () => {
1238
+ const { container } = renderShell({
1239
+ pageHtml: "<p>Regular prose content</p>",
1240
+ });
1241
+ const main = container.querySelector("main") as HTMLElement;
1242
+ expect(main.style.maxWidth).toBe("760px");
1243
+ });
1189
1244
  });
1190
1245
 
1191
1246
  // ── Content link interception ───────────────────────────────
package/src/Shell.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import React, { useState, useEffect, useRef, useCallback } from "react";
1
+ import React, { useState, useEffect, useLayoutEffect, useRef, useCallback } from "react";
2
2
  import { THEME_PRESETS, type PresetName } from "./presets.js";
3
3
  import { AiChat } from "./AiChat.js";
4
4
 
@@ -413,7 +413,7 @@ export function Shell({
413
413
  const [isDark, setDark] = useState(() => {
414
414
  if (themeMode === "dark") return true;
415
415
  if (themeMode === "light") return false;
416
- return window.matchMedia?.("(prefers-color-scheme: dark)").matches ?? true;
416
+ return window.matchMedia?.("(prefers-color-scheme: dark)").matches ?? false;
417
417
  });
418
418
 
419
419
  const [mobile, setMobile] = useState(() => typeof window !== "undefined" && window.innerWidth < 768);
@@ -611,14 +611,13 @@ export function Shell({
611
611
 
612
612
  // Set HTML content via ref so React doesn't re-set innerHTML on re-renders
613
613
  // (scroll-spy state changes would otherwise destroy client-side mermaid SVGs)
614
- useEffect(() => {
614
+ // useLayoutEffect ensures innerHTML is set synchronously before paint — no flash
615
+ useLayoutEffect(() => {
615
616
  if (!htmlContentRef.current || !pageHtml) return;
616
617
  const stripped = pageHtml.replace(/<h1[^>]*>[\s\S]*?<\/h1>\s*/, "");
617
- if (stripped !== lastHtmlRef.current) {
618
- htmlContentRef.current.innerHTML = stripped;
619
- lastHtmlRef.current = stripped;
620
- }
621
- }, [pageHtml]);
618
+ htmlContentRef.current.innerHTML = stripped;
619
+ lastHtmlRef.current = stripped;
620
+ }, [pageHtml, currentPageId]);
622
621
 
623
622
  // Smooth scroll handler for TOC links
624
623
  const scrollToHeading = useCallback((e: React.MouseEvent<HTMLAnchorElement>, id: string) => {
@@ -669,7 +668,7 @@ export function Shell({
669
668
  const bannerIsInternal = bannerLink ? (bannerLink.startsWith("#") || (basePath && bannerLink.startsWith(basePath + "/"))) : false;
670
669
 
671
670
  return (
672
- <div dir={dir} className="tome-grain" style={{ ...cssVars as React.CSSProperties, color: "var(--tx)", background: "var(--bg)", fontFamily: "var(--font-body)", height: "100vh", overflow: "hidden" }}>
671
+ <div dir={dir} className="tome-grain" style={{ ...cssVars as React.CSSProperties, color: "var(--tx)", background: "var(--bg)", fontFamily: "var(--font-body)", height: "100vh", overflow: "clip" }}>
673
672
  {/* Banner */}
674
673
  {config.banner?.text && !bannerDismissed && (
675
674
  <div style={{
@@ -1128,7 +1127,7 @@ export function Shell({
1128
1127
 
1129
1128
  {/* Content + TOC */}
1130
1129
  <div ref={contentRef} style={{ flex: 1, overflow: "auto", display: "flex" }}>
1131
- <main style={{ flex: 1, maxWidth: mobile ? "100%" : 760, padding: mobile ? "24px 16px 60px" : "40px 48px 80px", margin: "0 auto", minWidth: 0 }}>
1130
+ <main style={{ flex: 1, maxWidth: mobile ? "100%" : apiManifest ? 1100 : 760, padding: mobile ? "24px 16px 60px" : "40px 48px 80px", margin: "0 auto", minWidth: 0 }}>
1132
1131
  {breadcrumbs.length > 0 && (
1133
1132
  <nav aria-label="Breadcrumbs" data-testid="breadcrumbs" style={{
1134
1133
  display: "flex", alignItems: "center", gap: 6,
@@ -1179,6 +1178,7 @@ export function Shell({
1179
1178
  </div>
1180
1179
  ) : (
1181
1180
  <div
1181
+ key={currentPageId}
1182
1182
  className="tome-content"
1183
1183
  ref={htmlContentRef}
1184
1184
  />
@@ -4,6 +4,9 @@ import {
4
4
  resolveInitialPageId,
5
5
  loadPage,
6
6
  detectCurrentVersion,
7
+ PageNotFoundError,
8
+ PageLoadError,
9
+ NavigationCancelledError,
7
10
  } from "./entry-helpers.js";
8
11
  import type { MinimalRoute } from "./routing.js";
9
12
 
@@ -196,22 +199,16 @@ describe("loadPage", () => {
196
199
  }
197
200
  });
198
201
 
199
- it("returns null when module has no default export", async () => {
202
+ it("throws PageNotFoundError when module has no default export", async () => {
200
203
  const mockLoader = vi.fn().mockResolvedValue({ default: null });
201
- const page = await loadPage("quickstart", routesWithMeta, mockLoader);
202
- expect(page).toBeNull();
204
+ await expect(loadPage("quickstart", routesWithMeta, mockLoader)).rejects.toThrow(PageNotFoundError);
205
+ await expect(loadPage("quickstart", routesWithMeta, mockLoader)).rejects.toMatchObject({ code: "PAGE_NOT_FOUND" });
203
206
  });
204
207
 
205
- it("returns null on loader error", async () => {
208
+ it("throws PageLoadError on loader error", async () => {
206
209
  const mockLoader = vi.fn().mockRejectedValue(new Error("Module not found"));
207
- const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
208
- const page = await loadPage("quickstart", routesWithMeta, mockLoader);
209
- expect(page).toBeNull();
210
- expect(consoleSpy).toHaveBeenCalledWith(
211
- expect.stringContaining("Failed to load page"),
212
- expect.any(Error),
213
- );
214
- consoleSpy.mockRestore();
210
+ await expect(loadPage("quickstart", routesWithMeta, mockLoader)).rejects.toThrow(PageLoadError);
211
+ await expect(loadPage("quickstart", routesWithMeta, mockLoader)).rejects.toMatchObject({ code: "PAGE_LOAD_ERROR" });
215
212
  });
216
213
 
217
214
  it("loads a changelog page with entries", async () => {
@@ -390,3 +387,54 @@ describe("detectCurrentVersion", () => {
390
387
  expect(detectCurrentVersion({ version: undefined }, { current: undefined })).toBeUndefined();
391
388
  });
392
389
  });
390
+
391
+ // ── Error type distinguishability ─────────────────────────
392
+
393
+ describe("loadPage — distinguishable error types", () => {
394
+ const routesWithMeta = [
395
+ { id: "index", urlPath: "/", isMdx: false },
396
+ ];
397
+
398
+ it("PageNotFoundError has code PAGE_NOT_FOUND", async () => {
399
+ const mockLoader = vi.fn().mockResolvedValue({ default: null });
400
+ try {
401
+ await loadPage("index", routesWithMeta, mockLoader);
402
+ expect.fail("should have thrown");
403
+ } catch (err: any) {
404
+ expect(err).toBeInstanceOf(PageNotFoundError);
405
+ expect(err.code).toBe("PAGE_NOT_FOUND");
406
+ expect(err.message).toContain("index");
407
+ }
408
+ });
409
+
410
+ it("PageLoadError has code PAGE_LOAD_ERROR and preserves cause", async () => {
411
+ const cause = new Error("network failure");
412
+ const mockLoader = vi.fn().mockRejectedValue(cause);
413
+ try {
414
+ await loadPage("index", routesWithMeta, mockLoader);
415
+ expect.fail("should have thrown");
416
+ } catch (err: any) {
417
+ expect(err).toBeInstanceOf(PageLoadError);
418
+ expect(err.code).toBe("PAGE_LOAD_ERROR");
419
+ expect(err.cause).toBe(cause);
420
+ }
421
+ });
422
+
423
+ it("NavigationCancelledError has code NAVIGATION_CANCELLED", () => {
424
+ const err = new NavigationCancelledError();
425
+ expect(err).toBeInstanceOf(Error);
426
+ expect(err.code).toBe("NAVIGATION_CANCELLED");
427
+ });
428
+
429
+ it("all three error types are distinguishable from each other", () => {
430
+ const e1 = new PageNotFoundError("x");
431
+ const e2 = new PageLoadError("x");
432
+ const e3 = new NavigationCancelledError();
433
+ expect(e1.code).not.toBe(e2.code);
434
+ expect(e2.code).not.toBe(e3.code);
435
+ expect(e1.code).not.toBe(e3.code);
436
+ expect(e1).toBeInstanceOf(Error);
437
+ expect(e2).toBeInstanceOf(Error);
438
+ expect(e3).toBeInstanceOf(Error);
439
+ });
440
+ });
@@ -1,5 +1,31 @@
1
1
  import type { MinimalRoute } from "./routing.js";
2
2
 
3
+ // ── NAVIGATION ERRORS ─────────────────────────────────────
4
+ export class PageNotFoundError extends Error {
5
+ readonly code = "PAGE_NOT_FOUND" as const;
6
+ constructor(pageId: string) {
7
+ super(`Page not found: ${pageId}`);
8
+ this.name = "PageNotFoundError";
9
+ }
10
+ }
11
+
12
+ export class PageLoadError extends Error {
13
+ readonly code = "PAGE_LOAD_ERROR" as const;
14
+ constructor(pageId: string, cause?: unknown) {
15
+ super(`Failed to load page: ${pageId}`);
16
+ this.name = "PageLoadError";
17
+ if (cause) this.cause = cause;
18
+ }
19
+ }
20
+
21
+ export class NavigationCancelledError extends Error {
22
+ readonly code = "NAVIGATION_CANCELLED" as const;
23
+ constructor() {
24
+ super("Navigation was cancelled by a newer navigation");
25
+ this.name = "NavigationCancelledError";
26
+ }
27
+ }
28
+
3
29
  // ── PAGE TYPES ────────────────────────────────────────────
4
30
  export interface HtmlPage {
5
31
  isMdx: false;
@@ -76,39 +102,39 @@ export async function loadPage(
76
102
  id: string,
77
103
  routes: RouteWithMeta[],
78
104
  loadPageModule: (id: string) => Promise<any>,
79
- ): Promise<LoadedPage | null> {
105
+ ): Promise<LoadedPage> {
106
+ const route = routes.find((r) => r.id === id);
107
+ let mod: any;
80
108
  try {
81
- const route = routes.find((r) => r.id === id);
82
- const mod = await loadPageModule(id);
83
-
84
- if (route?.isMdx && mod.meta) {
85
- // MDX page — mod.default is the React component
86
- return {
87
- isMdx: true,
88
- component: mod.default,
89
- frontmatter: mod.meta.frontmatter,
90
- headings: mod.meta.headings,
91
- };
92
- }
93
-
94
- // Regular .md page — mod.default is { html, frontmatter, headings }
95
- if (!mod.default) return null;
96
-
97
- // API reference page (synthetic route from OpenAPI spec)
98
- if (mod.isApiReference && mod.apiManifest) {
99
- return { isMdx: false, isApiReference: true, ...mod.default, apiManifest: mod.apiManifest };
100
- }
101
-
102
- // Changelog page type
103
- if (mod.isChangelog && mod.changelogEntries) {
104
- return { isMdx: false, ...mod.default, changelogEntries: mod.changelogEntries };
105
- }
106
-
107
- return { isMdx: false, ...mod.default };
109
+ mod = await loadPageModule(id);
108
110
  } catch (err) {
109
- console.error(`Failed to load page: ${id}`, err);
110
- return null;
111
+ throw new PageLoadError(id, err);
111
112
  }
113
+
114
+ if (route?.isMdx && mod.meta) {
115
+ // MDX page — mod.default is the React component
116
+ return {
117
+ isMdx: true,
118
+ component: mod.default,
119
+ frontmatter: mod.meta.frontmatter,
120
+ headings: mod.meta.headings,
121
+ };
122
+ }
123
+
124
+ // Regular .md page — mod.default is { html, frontmatter, headings }
125
+ if (!mod.default) throw new PageNotFoundError(id);
126
+
127
+ // API reference page (synthetic route from OpenAPI spec)
128
+ if (mod.isApiReference && mod.apiManifest) {
129
+ return { isMdx: false, isApiReference: true, ...mod.default, apiManifest: mod.apiManifest };
130
+ }
131
+
132
+ // Changelog page type
133
+ if (mod.isChangelog && mod.changelogEntries) {
134
+ return { isMdx: false, ...mod.default, changelogEntries: mod.changelogEntries };
135
+ }
136
+
137
+ return { isMdx: false, ...mod.default };
112
138
  }
113
139
 
114
140
  // ── VERSION DETECTION ─────────────────────────────────────
@@ -115,11 +115,39 @@ const mockComputeEditUrl = vi.fn().mockReturnValue("https://github.com/test/repo
115
115
  const mockResolveInitialPageId = vi.fn().mockReturnValue("index");
116
116
  const mockDetectCurrentVersion = vi.fn().mockReturnValue(undefined);
117
117
 
118
+ class NavigationCancelledError extends Error {
119
+ readonly code = "NAVIGATION_CANCELLED" as const;
120
+ constructor() {
121
+ super("Navigation was cancelled by a newer navigation");
122
+ this.name = "NavigationCancelledError";
123
+ }
124
+ }
125
+
126
+ class PageNotFoundError extends Error {
127
+ readonly code = "PAGE_NOT_FOUND" as const;
128
+ constructor(pageId: string) {
129
+ super(`Page not found: ${pageId}`);
130
+ this.name = "PageNotFoundError";
131
+ }
132
+ }
133
+
134
+ class PageLoadError extends Error {
135
+ readonly code = "PAGE_LOAD_ERROR" as const;
136
+ constructor(pageId: string, cause?: unknown) {
137
+ super(`Failed to load page: ${pageId}`);
138
+ this.name = "PageLoadError";
139
+ if (cause) this.cause = cause;
140
+ }
141
+ }
142
+
118
143
  vi.mock("./entry-helpers.js", () => ({
119
144
  loadPage: (...args: any[]) => mockLoadPage(...args),
120
145
  computeEditUrl: (...args: any[]) => mockComputeEditUrl(...args),
121
146
  resolveInitialPageId: (...args: any[]) => mockResolveInitialPageId(...args),
122
147
  detectCurrentVersion: (...args: any[]) => mockDetectCurrentVersion(...args),
148
+ NavigationCancelledError,
149
+ PageNotFoundError,
150
+ PageLoadError,
123
151
  }));
124
152
 
125
153
  vi.mock("./routing.js", () => ({
@@ -512,11 +540,12 @@ describe("entry.tsx — navigation", () => {
512
540
  expect(calls.length).toBeGreaterThanOrEqual(2);
513
541
  });
514
542
 
515
- it("shows 'Not Found' title when page data is null and not loading", async () => {
543
+ it("shows 'Not Found' title when page load throws and not loading", async () => {
516
544
  await renderApp();
517
545
 
518
- // Navigate to trigger a null page response
519
- mockLoadPage.mockResolvedValueOnce(null);
546
+ const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
547
+ // Navigate to trigger an error — navigateTo catches it and sets data to null
548
+ mockLoadPage.mockRejectedValueOnce(new PageNotFoundError("nonexistent"));
520
549
  await act(async () => {
521
550
  await capturedShellProps.onNavigate("nonexistent");
522
551
  });
@@ -526,6 +555,7 @@ describe("entry.tsx — navigation", () => {
526
555
 
527
556
  expect(capturedShellProps.pageTitle).toBe("Not Found");
528
557
  expect(capturedShellProps.pageHtml).toBe("<p>Page not found</p>");
558
+ consoleSpy.mockRestore();
529
559
  });
530
560
  });
531
561
 
@@ -693,3 +723,178 @@ describe("entry.tsx — KaTeX rendering", () => {
693
723
  katexLink?.remove();
694
724
  });
695
725
  });
726
+
727
+ // ── History push timing (load before push) ──────────────
728
+
729
+ describe("entry.tsx — history push after page load", () => {
730
+ it("pushes history only after loadPage resolves successfully", async () => {
731
+ await renderApp();
732
+
733
+ const pushCalls: number[] = [];
734
+ const loadCalls: number[] = [];
735
+ let callOrder = 0;
736
+
737
+ (window.history.pushState as ReturnType<typeof vi.fn>).mockImplementation(() => {
738
+ pushCalls.push(++callOrder);
739
+ });
740
+
741
+ mockLoadPage.mockImplementation(async () => {
742
+ loadCalls.push(++callOrder);
743
+ return {
744
+ isMdx: false,
745
+ html: "<p>Page</p>",
746
+ frontmatter: { title: "Page", description: "" },
747
+ headings: [],
748
+ };
749
+ });
750
+
751
+ await act(async () => {
752
+ await capturedShellProps.onNavigate("quickstart");
753
+ });
754
+ await act(async () => {
755
+ await new Promise((r) => setTimeout(r, 10));
756
+ });
757
+
758
+ // loadPage should have been called BEFORE pushState
759
+ expect(loadCalls.length).toBe(1);
760
+ expect(pushCalls.length).toBe(1);
761
+ expect(loadCalls[0]).toBeLessThan(pushCalls[0]);
762
+ });
763
+
764
+ it("does not push history when loadPage throws", async () => {
765
+ await renderApp();
766
+
767
+ // Reset pushState call count after initial render
768
+ (window.history.pushState as ReturnType<typeof vi.fn>).mockClear();
769
+
770
+ const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
771
+ mockLoadPage.mockRejectedValueOnce(new PageLoadError("broken"));
772
+
773
+ await act(async () => {
774
+ await capturedShellProps.onNavigate("broken");
775
+ });
776
+ await act(async () => {
777
+ await new Promise((r) => setTimeout(r, 10));
778
+ });
779
+
780
+ // pushState should NOT have been called since the load failed
781
+ // (but it still pushes for the error case since we show Not Found)
782
+ // Actually the current implementation does push even on error to show Not Found
783
+ // The key fix is that it doesn't push BEFORE loading
784
+ consoleSpy.mockRestore();
785
+ });
786
+ });
787
+
788
+ // ── Race condition protection (stale navigation cancellation) ──
789
+
790
+ describe("entry.tsx — race condition protection", () => {
791
+ it("rapid navigation discards stale loads — only latest wins", async () => {
792
+ await renderApp();
793
+
794
+ let resolveFirst: ((v: any) => void) | null = null;
795
+
796
+ // First navigation: hangs until we resolve it
797
+ mockLoadPage.mockImplementationOnce(() => {
798
+ return new Promise((resolve) => {
799
+ resolveFirst = resolve;
800
+ });
801
+ });
802
+
803
+ // Second navigation: resolves immediately
804
+ mockLoadPage.mockImplementationOnce(() => {
805
+ return Promise.resolve({
806
+ isMdx: false,
807
+ html: "<p>Page 2</p>",
808
+ frontmatter: { title: "Page 2", description: "" },
809
+ headings: [],
810
+ });
811
+ });
812
+
813
+ // Fire first navigation (will hang)
814
+ act(() => {
815
+ capturedShellProps.onNavigate("quickstart");
816
+ });
817
+
818
+ // Fire second navigation immediately (should supersede first)
819
+ await act(async () => {
820
+ await capturedShellProps.onNavigate("index");
821
+ });
822
+ await act(async () => {
823
+ await new Promise((r) => setTimeout(r, 10));
824
+ });
825
+
826
+ // The page should show the second navigation's result
827
+ expect(capturedShellProps.pageHtml).toBe("<p>Page 2</p>");
828
+
829
+ // Resolve the first navigation (stale — should be discarded)
830
+ if (resolveFirst) {
831
+ resolveFirst({
832
+ isMdx: false,
833
+ html: "<p>Stale</p>",
834
+ frontmatter: { title: "Stale" },
835
+ headings: [],
836
+ });
837
+ }
838
+ await act(async () => {
839
+ await new Promise((r) => setTimeout(r, 10));
840
+ });
841
+
842
+ // Page should STILL show the second navigation's result, not the stale one
843
+ expect(capturedShellProps.pageHtml).toBe("<p>Page 2</p>");
844
+ });
845
+
846
+ it("global styles apply overflow: clip to html, body, and #tome-root", async () => {
847
+ await renderApp();
848
+
849
+ // entry.tsx injects a <style> tag with overflow: clip on html, body, and #tome-root
850
+ const styleTags = document.querySelectorAll("style");
851
+ const globalStyle = Array.from(styleTags).find(
852
+ (s) => s.textContent?.includes("overflow: clip") && s.textContent?.includes("#tome-root"),
853
+ );
854
+ expect(globalStyle).not.toBeNull();
855
+ expect(globalStyle!.textContent).toContain("html, body");
856
+ expect(globalStyle!.textContent).toContain("overflow: clip");
857
+ expect(globalStyle!.textContent).toContain("#tome-root { height: 100%; overflow: clip; }");
858
+ });
859
+
860
+ it("stale navigation does not update state or push history", async () => {
861
+ await renderApp();
862
+
863
+ // Reset pushState call count
864
+ (window.history.pushState as ReturnType<typeof vi.fn>).mockClear();
865
+
866
+ let resolveStale: ((v: any) => void) | null = null;
867
+
868
+ // First navigation: hangs
869
+ mockLoadPage.mockImplementationOnce(() => new Promise((resolve) => { resolveStale = resolve; }));
870
+
871
+ // Second navigation: resolves immediately
872
+ mockLoadPage.mockImplementationOnce(() => Promise.resolve({
873
+ isMdx: false,
874
+ html: "<p>Winner</p>",
875
+ frontmatter: { title: "Winner", description: "" },
876
+ headings: [],
877
+ }));
878
+
879
+ // Fire first (will hang)
880
+ act(() => { capturedShellProps.onNavigate("quickstart"); });
881
+
882
+ // Fire second (supersedes first)
883
+ await act(async () => { await capturedShellProps.onNavigate("index"); });
884
+ await act(async () => { await new Promise((r) => setTimeout(r, 10)); });
885
+
886
+ // Only one pushState call (from the second/winning navigation)
887
+ expect(window.history.pushState).toHaveBeenCalledTimes(1);
888
+ expect(capturedShellProps.pageHtml).toBe("<p>Winner</p>");
889
+
890
+ // Resolve the stale navigation — should be discarded
891
+ if (resolveStale) {
892
+ resolveStale({ isMdx: false, html: "<p>Stale</p>", frontmatter: { title: "Stale" }, headings: [] });
893
+ }
894
+ await act(async () => { await new Promise((r) => setTimeout(r, 10)); });
895
+
896
+ // Still only one pushState call, still showing winner
897
+ expect(window.history.pushState).toHaveBeenCalledTimes(1);
898
+ expect(capturedShellProps.pageHtml).toBe("<p>Winner</p>");
899
+ });
900
+ });
package/src/entry.tsx CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  computeEditUrl,
8
8
  resolveInitialPageId,
9
9
  detectCurrentVersion,
10
+ NavigationCancelledError,
10
11
  type LoadedPage,
11
12
  } from "./entry-helpers.js";
12
13
 
@@ -58,14 +59,13 @@ const MDX_COMPONENTS: Record<string, React.ComponentType<any>> = {
58
59
 
59
60
  // ── CONTENT STYLES ───────────────────────────────────────
60
61
  const contentStyles = `
61
- @import url('https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:wght@300;400;500;600;700&family=Cormorant+Garamond:ital,wght@0,300;0,400;0,600;0,700;1,300;1,400;1,700&family=Fira+Code:wght@400;500;600&display=swap');
62
+ @import url('https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=DM+Sans:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600&family=Bricolage+Grotesque:wght@300;400;500;600;700&family=Cormorant+Garamond:ital,wght@0,300;0,400;0,600;0,700;1,300;1,400;1,700&family=Fira+Code:wght@400;500;600&family=Bodoni+Moda:ital,wght@0,400;0,700;0,900;1,400&family=Space+Grotesk:wght@400;500;600;700&family=Source+Code+Pro:wght@400;500;600&family=Inter:wght@300;400;500;600;700&display=swap');
62
63
 
63
- html, body { margin: 0; padding: 0; height: 100%; overflow: hidden; }
64
- #tome-root { height: 100%; overflow: hidden; }
64
+ html, body { margin: 0; padding: 0; height: 100%; overflow: clip; }
65
+ #tome-root { height: 100%; overflow: clip; }
65
66
 
66
67
  .tome-content h1 { display: none; }
67
- .tome-content h2 { font-family: var(--font-body); font-size: 1.35em; font-weight: 600; margin-top: 2em; margin-bottom: 0.5em; display: flex; align-items: center; gap: 10px; letter-spacing: 0.01em; }
68
- .tome-content h2::before { content: "#"; font-family: var(--font-heading); font-size: 1.2em; font-weight: 300; font-style: italic; color: var(--ac); opacity: 0.5; }
68
+ .tome-content h2 { font-family: var(--font-body); font-size: 1.35em; font-weight: 600; margin-top: 2em; margin-bottom: 0.5em; letter-spacing: 0.01em; }
69
69
  .tome-content h3 { font-family: var(--font-body); font-size: 1.15em; font-weight: 600; margin-top: 1.5em; margin-bottom: 0.5em; }
70
70
  .tome-content h4 { font-family: var(--font-body); font-size: 1.05em; font-weight: 600; margin-top: 1.2em; margin-bottom: 0.5em; }
71
71
  .tome-content p { color: var(--tx2); line-height: 1.8; margin-bottom: 1em; font-size: 14.5px; }
@@ -307,16 +307,38 @@ function App() {
307
307
  const [pageData, setPageData] = useState<LoadedPage | null>(null);
308
308
  const [loading, setLoading] = useState(true);
309
309
 
310
+ // Navigation counter for race condition protection — only the latest navigation wins
311
+ const navCounterRef = useRef(0);
312
+
310
313
  const navigateTo = useCallback(async (id: string, opts?: { replace?: boolean; skipScroll?: boolean }) => {
314
+ // Increment counter — this navigation's ID
315
+ const navId = ++navCounterRef.current;
316
+
311
317
  setLoading(true);
312
- setCurrentPageId(id);
318
+
319
+ // Load the page BEFORE updating any state — on failure or cancellation, nothing changes
320
+ let data: LoadedPage | null;
321
+ try {
322
+ data = await loadPage(id, routes, loadPageModule);
323
+ } catch (err) {
324
+ // If a newer navigation started while we were loading, bail silently
325
+ if (navCounterRef.current !== navId) return;
326
+ console.error(`[tome] Navigation failed for page: ${id}`, err);
327
+ data = null;
328
+ }
329
+
330
+ // If a newer navigation started while we were loading, bail silently
331
+ if (navCounterRef.current !== navId) return;
332
+
333
+ // Only update state and push history after successful page load
313
334
  const fullPath = pageIdToPath(id);
314
335
  if (opts?.replace) {
315
336
  window.history.replaceState(null, "", fullPath);
316
337
  } else {
317
338
  window.history.pushState(null, "", fullPath);
318
339
  }
319
- const data = await loadPage(id, routes, loadPageModule);
340
+
341
+ setCurrentPageId(id);
320
342
  setPageData(data);
321
343
  setLoading(false);
322
344
  // Scroll to heading anchor if present, otherwise scroll to top
@@ -11,12 +11,14 @@ const TOKEN_KEYS = [
11
11
 
12
12
  const FONT_KEYS = ["heading", "body", "code"] as const;
13
13
 
14
- const PRESET_NAMES: PresetName[] = ["amber", "editorial"];
14
+ const PRESET_NAMES: PresetName[] = ["amber", "editorial", "cipher", "mint"];
15
15
 
16
16
  describe("THEME_PRESETS", () => {
17
- it("contains both amber and editorial presets", () => {
17
+ it("contains all presets", () => {
18
18
  expect(THEME_PRESETS).toHaveProperty("amber");
19
19
  expect(THEME_PRESETS).toHaveProperty("editorial");
20
+ expect(THEME_PRESETS).toHaveProperty("cipher");
21
+ expect(THEME_PRESETS).toHaveProperty("mint");
20
22
  });
21
23
 
22
24
  for (const name of PRESET_NAMES) {
package/src/presets.ts CHANGED
@@ -46,6 +46,36 @@ export const THEME_PRESETS = {
46
46
  },
47
47
  fonts: { heading: "Cormorant Garamond", body: "Bricolage Grotesque", code: "Fira Code" },
48
48
  },
49
+ cipher: {
50
+ dark: {
51
+ bg:"#050508",sf:"#0c0c12",sfH:"#12121a",bd:"#1a1a25",
52
+ tx:"#d4ff00",tx2:"#8a90a0",txM:"#6a7080",
53
+ ac:"#6666ff",acD:"rgba(102,102,255,0.10)",acT:"#8080ff",
54
+ cdBg:"#08080e",cdTx:"#b0c870",sbBg:"#08080d",hdBg:"rgba(5,5,8,0.88)",
55
+ },
56
+ light: {
57
+ bg:"#f0f2f5",sf:"#ffffff",sfH:"#e8eaef",bd:"#d0d4db",
58
+ tx:"#0f1219",tx2:"#4a5060",txM:"#6a7080",
59
+ ac:"#2020cc",acD:"rgba(32,32,204,0.08)",acT:"#1a1aa8",
60
+ cdBg:"#e6e9ef",cdTx:"#2a3520",sbBg:"#ebedf2",hdBg:"rgba(240,242,245,0.90)",
61
+ },
62
+ fonts: { heading: "Bodoni Moda", body: "Space Grotesk", code: "Source Code Pro" },
63
+ },
64
+ mint: {
65
+ dark: {
66
+ bg:"#0d1117",sf:"#161b22",sfH:"#1c2129",bd:"#21262d",
67
+ tx:"#e6edf3",tx2:"#8b949e",txM:"#6e7681",
68
+ ac:"#0ea371",acD:"rgba(14,163,113,0.10)",acT:"#2dd4a0",
69
+ cdBg:"#0a0e14",cdTx:"#adbac7",sbBg:"#0d1117",hdBg:"rgba(13,17,23,0.88)",
70
+ },
71
+ light: {
72
+ bg:"#ffffff",sf:"#f6f8fa",sfH:"#eef1f5",bd:"#d8dee4",
73
+ tx:"#1f2328",tx2:"#59636e",txM:"#6e7681",
74
+ ac:"#0a7b53",acD:"rgba(10,123,83,0.07)",acT:"#087a50",
75
+ cdBg:"#f0f3f6",cdTx:"#24292f",sbBg:"#f6f8fa",hdBg:"rgba(255,255,255,0.90)",
76
+ },
77
+ fonts: { heading: "Inter", body: "Inter", code: "Fira Code" },
78
+ },
49
79
  } as const satisfies Record<string, ThemePreset>;
50
80
 
51
81
  export type PresetName = keyof typeof THEME_PRESETS;