@tomehq/theme 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/Shell.tsx ADDED
@@ -0,0 +1,827 @@
1
+ import React, { useState, useEffect, useRef, useCallback } from "react";
2
+ import { THEME_PRESETS, type PresetName } from "./presets.js";
3
+ import { AiChat } from "./AiChat.js";
4
+
5
+ // ── ACCENT PALETTE GENERATION (TOM-12) ───────────────────
6
+ function hexToRgb(hex: string): [number, number, number] | null {
7
+ const m = /^#([0-9a-f]{6})$/i.exec(hex.trim());
8
+ if (!m) return null;
9
+ const n = parseInt(m[1], 16);
10
+ return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
11
+ }
12
+
13
+ function buildAccentOverride(hex: string, isDark: boolean) {
14
+ const rgb = hexToRgb(hex);
15
+ if (!rgb) return null;
16
+ const [r, g, b] = rgb;
17
+ // Dim variant: low-opacity fill
18
+ const acD = `rgba(${r},${g},${b},${isDark ? 0.12 : 0.08})`;
19
+ // Hover variant: slightly lighter in dark, darker in light
20
+ const factor = isDark ? 1.15 : 0.85;
21
+ const tr = Math.min(255, Math.round(r * factor));
22
+ const tg = Math.min(255, Math.round(g * factor));
23
+ const tb = Math.min(255, Math.round(b * factor));
24
+ const acT = `rgb(${tr},${tg},${tb})`;
25
+ return { ac: hex, acD, acT };
26
+ }
27
+
28
+ // ── ICONS ────────────────────────────────────────────────
29
+ const Icon = ({ d, size = 16 }: { d: string; size?: number }) => (
30
+ <svg width={size} height={size} viewBox="0 0 24 24" fill="none"
31
+ stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
32
+ <path d={d} />
33
+ </svg>
34
+ );
35
+
36
+ const SearchIcon = () => <Icon d="M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16ZM21 21l-4.3-4.3" />;
37
+ const ChevRight = () => <Icon d="M9 18l6-6-6-6" size={14} />;
38
+ const ChevDown = () => <Icon d="M6 9l6 6 6-6" size={14} />;
39
+ const CopyIcon = () => <Icon d="M9 9h13v13H9zM5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" size={14} />;
40
+ const CheckIcon = () => <Icon d="M20 6L9 17l-5-5" size={14} />;
41
+ const MenuIcon = () => <Icon d="M3 12h18M3 6h18M3 18h18" size={20} />;
42
+ const XIcon = () => <Icon d="M18 6L6 18M6 6l12 12" size={18} />;
43
+ const MoonIcon = () => <Icon d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />;
44
+ const SunIcon = () => <Icon d="M12 7a5 5 0 1 0 0 10 5 5 0 0 0 0-10Z" />;
45
+ const ArrowLeft = () => <Icon d="M19 12H5M12 19l-7-7 7-7" size={14} />;
46
+ const ArrowRight = () => <Icon d="M5 12h14M12 5l7 7-7 7" size={14} />;
47
+
48
+ // ── PAGEFIND CLIENT (TOM-15) ─────────────────────────────
49
+ let pagefindInstance: any = null;
50
+ const PAGEFIND_PATH = "/_pagefind/pagefind.js";
51
+ async function initPagefind(): Promise<any> {
52
+ if (pagefindInstance) return pagefindInstance;
53
+ try {
54
+ // Dynamic path variable prevents Vite from resolving at build time
55
+ pagefindInstance = await import(/* @vite-ignore */ PAGEFIND_PATH);
56
+ await pagefindInstance.init();
57
+ return pagefindInstance;
58
+ } catch {
59
+ return null;
60
+ }
61
+ }
62
+
63
+ // ── ALGOLIA DOCSEARCH (TOM-16) ──────────────────────────
64
+ let docSearchLoaded: Promise<any> | null = null;
65
+ function loadDocSearch(): Promise<any> {
66
+ if (docSearchLoaded) return docSearchLoaded;
67
+ docSearchLoaded = import("@docsearch/react").catch(() => null);
68
+ return docSearchLoaded;
69
+ }
70
+
71
+ function AlgoliaSearchModal({
72
+ appId,
73
+ apiKey,
74
+ indexName,
75
+ onNavigate,
76
+ onClose,
77
+ }: {
78
+ appId: string;
79
+ apiKey: string;
80
+ indexName: string;
81
+ onNavigate: (id: string) => void;
82
+ onClose: () => void;
83
+ }) {
84
+ const [DocSearchComponent, setDocSearchComponent] = useState<React.ComponentType<any> | null>(null);
85
+ const [loadFailed, setLoadFailed] = useState(false);
86
+
87
+ useEffect(() => {
88
+ loadDocSearch().then((mod) => {
89
+ if (mod && mod.DocSearch) {
90
+ setDocSearchComponent(() => mod.DocSearch);
91
+ } else if (mod && mod.default) {
92
+ setDocSearchComponent(() => mod.default);
93
+ } else {
94
+ setLoadFailed(true);
95
+ }
96
+ });
97
+ }, []);
98
+
99
+ // Extract page ID from a DocSearch result URL
100
+ const extractPageId = useCallback((url: string): string => {
101
+ try {
102
+ const parsed = new URL(url, "http://localhost");
103
+ const pathname = parsed.pathname;
104
+ return pathname
105
+ .replace(/^\//, "")
106
+ .replace(/\/index\.html$/, "")
107
+ .replace(/\.html$/, "")
108
+ || "index";
109
+ } catch {
110
+ return "index";
111
+ }
112
+ }, []);
113
+
114
+ if (loadFailed) {
115
+ return (
116
+ <div onClick={onClose} style={{
117
+ position: "fixed", inset: 0, zIndex: 1000, background: "rgba(0,0,0,0.55)",
118
+ backdropFilter: "blur(6px)", display: "flex", alignItems: "flex-start",
119
+ justifyContent: "center", paddingTop: "12vh",
120
+ }}>
121
+ <div onClick={e => e.stopPropagation()} style={{
122
+ background: "var(--sf)", border: "1px solid var(--bd)", borderRadius: 12,
123
+ width: "100%", maxWidth: 520, boxShadow: "0 24px 80px rgba(0,0,0,0.4)",
124
+ padding: "32px 18px", textAlign: "center", color: "var(--txM)", fontSize: 14,
125
+ }}>
126
+ Algolia DocSearch is not available. Install @docsearch/react to enable it.
127
+ </div>
128
+ </div>
129
+ );
130
+ }
131
+
132
+ if (!DocSearchComponent) {
133
+ // Loading state
134
+ return (
135
+ <div onClick={onClose} style={{
136
+ position: "fixed", inset: 0, zIndex: 1000, background: "rgba(0,0,0,0.55)",
137
+ backdropFilter: "blur(6px)", display: "flex", alignItems: "flex-start",
138
+ justifyContent: "center", paddingTop: "12vh",
139
+ }}>
140
+ <div style={{
141
+ background: "var(--sf)", border: "1px solid var(--bd)", borderRadius: 12,
142
+ width: "100%", maxWidth: 520, boxShadow: "0 24px 80px rgba(0,0,0,0.4)",
143
+ padding: "32px 18px", textAlign: "center", color: "var(--txM)", fontSize: 14,
144
+ }}>
145
+ Loading search...
146
+ </div>
147
+ </div>
148
+ );
149
+ }
150
+
151
+ return (
152
+ <div data-testid="algolia-search-modal">
153
+ <DocSearchComponent
154
+ appId={appId}
155
+ apiKey={apiKey}
156
+ indexName={indexName}
157
+ navigator={{
158
+ navigate({ itemUrl }: { itemUrl: string }) {
159
+ const pageId = extractPageId(itemUrl);
160
+ onNavigate(pageId);
161
+ },
162
+ }}
163
+ hitComponent={({ hit, children }: { hit: { url: string }; children: React.ReactNode }) => (
164
+ <a href={hit.url} onClick={(e: React.MouseEvent) => {
165
+ e.preventDefault();
166
+ const pageId = extractPageId(hit.url);
167
+ onNavigate(pageId);
168
+ }}>
169
+ {children}
170
+ </a>
171
+ )}
172
+ />
173
+ </div>
174
+ );
175
+ }
176
+
177
+ // ── VERSION SWITCHER ICON (TOM-30) ───────────────────────
178
+ const VersionIcon = () => <Icon d="M12 8v4l3 3m6-3a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" size={14} />;
179
+
180
+ // ── LANGUAGE SWITCHER ICON (TOM-34) ──────────────────────
181
+ const GlobeIcon = () => <Icon d="M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18ZM3.6 9h16.8M3.6 15h16.8M12 3a15 15 0 0 1 4 9 15 15 0 0 1-4 9 15 15 0 0 1-4-9 15 15 0 0 1 4-9Z" size={14} />;
182
+
183
+ // ── TOP NAV EXTERNAL LINK ICON ────────────────────────────
184
+ const ExtLinkIcon = () => <Icon d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6M15 3h6v6M10 14L21 3" size={11} />;
185
+
186
+ // ── SHELL COMPONENT ──────────────────────────────────────
187
+ export interface VersioningInfo {
188
+ current: string;
189
+ versions: string[];
190
+ }
191
+
192
+ export interface I18nInfo {
193
+ defaultLocale: string;
194
+ locales: string[];
195
+ localeNames?: Record<string, string>;
196
+ }
197
+
198
+ interface ShellProps {
199
+ config: {
200
+ name: string;
201
+ theme?: { preset?: string; mode?: string; accent?: string; fonts?: { heading?: string; body?: string; code?: string } };
202
+ search?: { provider?: string; appId?: string; apiKey?: string; indexName?: string };
203
+ ai?: { enabled?: boolean; provider?: "openai" | "anthropic" | "custom"; model?: string; apiKeyEnv?: string };
204
+ topNav?: Array<{ label: string; href: string }>;
205
+ [key: string]: unknown;
206
+ };
207
+ navigation: Array<{
208
+ section: string;
209
+ pages: Array<{ title: string; id: string; urlPath: string; icon?: string }>;
210
+ }>;
211
+ currentPageId: string;
212
+ pageHtml?: string;
213
+ pageComponent?: React.ComponentType<{ components?: Record<string, React.ComponentType> }>;
214
+ mdxComponents?: Record<string, React.ComponentType>;
215
+ pageTitle: string;
216
+ pageDescription?: string;
217
+ headings: Array<{ depth: number; text: string; id: string }>;
218
+ onNavigate: (id: string) => void;
219
+ allPages: Array<{ id: string; title: string; description?: string }>;
220
+ versioning?: VersioningInfo;
221
+ currentVersion?: string;
222
+ i18n?: I18nInfo;
223
+ currentLocale?: string;
224
+ docContext?: Array<{ id: string; title: string; content: string }>;
225
+ }
226
+
227
+ export function Shell({
228
+ config, navigation, currentPageId, pageHtml, pageComponent, mdxComponents,
229
+ pageTitle, pageDescription, headings, onNavigate, allPages,
230
+ versioning, currentVersion, i18n, currentLocale, docContext,
231
+ }: ShellProps) {
232
+ const themeMode = config.theme?.mode || "auto";
233
+
234
+ // TOM-12: Initialize dark mode from config.theme.mode + system preference
235
+ const [isDark, setDark] = useState(() => {
236
+ if (themeMode === "dark") return true;
237
+ if (themeMode === "light") return false;
238
+ return window.matchMedia?.("(prefers-color-scheme: dark)").matches ?? true;
239
+ });
240
+
241
+ const [sbOpen, setSb] = useState(true);
242
+ const [searchOpen, setSearch] = useState(false);
243
+ const [versionDropdownOpen, setVersionDropdown] = useState(false);
244
+ const [localeDropdownOpen, setLocaleDropdown] = useState(false);
245
+
246
+ // TOM-30: Determine if viewing an old version
247
+ const isOldVersion = versioning && currentVersion && currentVersion !== versioning.current;
248
+ const [expanded, setExpanded] = useState<string[]>(navigation.map(n => n.section));
249
+ const contentRef = useRef<HTMLDivElement>(null);
250
+ const [wide, setWide] = useState(true);
251
+
252
+ const preset = (config.theme?.preset || "amber") as PresetName;
253
+ const baseTokens = THEME_PRESETS[preset]?.[isDark ? "dark" : "light"] || THEME_PRESETS.amber.dark;
254
+
255
+ // TOM-12: Custom accent color override
256
+ const accentOverride = config.theme?.accent
257
+ ? buildAccentOverride(config.theme.accent, isDark)
258
+ : null;
259
+
260
+ const t = accentOverride
261
+ ? { ...baseTokens, ...accentOverride }
262
+ : baseTokens;
263
+
264
+ // TOM-12: Custom font override
265
+ const presetFonts = THEME_PRESETS[preset]?.fonts || THEME_PRESETS.amber.fonts;
266
+ const fonts = {
267
+ heading: config.theme?.fonts?.heading || presetFonts.heading,
268
+ body: config.theme?.fonts?.body || presetFonts.body,
269
+ code: config.theme?.fonts?.code || presetFonts.code,
270
+ };
271
+
272
+ // TOM-12: Listen to system preference changes when mode is "auto"
273
+ useEffect(() => {
274
+ if (themeMode !== "auto") return;
275
+ const mq = window.matchMedia("(prefers-color-scheme: dark)");
276
+ const handler = (e: MediaQueryListEvent) => setDark(e.matches);
277
+ mq.addEventListener("change", handler);
278
+ return () => mq.removeEventListener("change", handler);
279
+ }, [themeMode]);
280
+
281
+ useEffect(() => {
282
+ const c = () => setWide(window.innerWidth > 1100);
283
+ c(); window.addEventListener("resize", c);
284
+ return () => window.removeEventListener("resize", c);
285
+ }, []);
286
+
287
+ useEffect(() => { contentRef.current?.scrollTo(0, 0); }, [currentPageId]);
288
+
289
+ useEffect(() => {
290
+ const h = (e: KeyboardEvent) => {
291
+ if ((e.metaKey || e.ctrlKey) && e.key === "k") { e.preventDefault(); setSearch(true); }
292
+ if (e.key === "Escape") setSearch(false);
293
+ };
294
+ window.addEventListener("keydown", h);
295
+ return () => window.removeEventListener("keydown", h);
296
+ }, []);
297
+
298
+ // Prev / Next
299
+ const allNavPages = navigation.flatMap(g => g.pages);
300
+ const idx = allNavPages.findIndex(p => p.id === currentPageId);
301
+ const prev = idx > 0 ? allNavPages[idx - 1] : null;
302
+ const next = idx < allNavPages.length - 1 ? allNavPages[idx + 1] : null;
303
+
304
+ const togSec = (s: string) => setExpanded(p => p.includes(s) ? p.filter(x => x !== s) : [...p, s]);
305
+
306
+ const cssVars: Record<string, string> = {
307
+ "--bg": t.bg, "--sf": t.sf, "--sfH": t.sfH, "--bd": t.bd,
308
+ "--tx": t.tx, "--tx2": t.tx2, "--txM": t.txM,
309
+ "--ac": t.ac, "--acD": t.acD, "--acT": t.acT,
310
+ "--cdBg": t.cdBg, "--cdTx": t.cdTx, "--sbBg": t.sbBg, "--hdBg": t.hdBg,
311
+ "--font-heading": `"${fonts.heading}", serif`,
312
+ "--font-body": `"${fonts.body}", sans-serif`,
313
+ "--font-code": `"${fonts.code}", monospace`,
314
+ };
315
+
316
+ const PageComponent = pageComponent;
317
+
318
+ return (
319
+ <div className="tome-grain" style={{ ...cssVars as React.CSSProperties, color: "var(--tx)", background: "var(--bg)", fontFamily: "var(--font-body)", minHeight: "100vh" }}>
320
+ {/* Search Modal (TOM-16: branch on provider) */}
321
+ {searchOpen && config.search?.provider === "algolia" && config.search.appId && config.search.apiKey && config.search.indexName ? (
322
+ <AlgoliaSearchModal
323
+ appId={config.search.appId}
324
+ apiKey={config.search.apiKey}
325
+ indexName={config.search.indexName}
326
+ onNavigate={(id) => { onNavigate(id); setSearch(false); }}
327
+ onClose={() => setSearch(false)}
328
+ />
329
+ ) : searchOpen ? (
330
+ <SearchModal
331
+ allPages={allPages}
332
+ onNavigate={(id) => { onNavigate(id); setSearch(false); }}
333
+ onClose={() => setSearch(false)}
334
+ />
335
+ ) : null}
336
+
337
+ <div style={{ display: "flex", height: "100vh" }}>
338
+ {/* Sidebar */}
339
+ <aside style={{
340
+ width: sbOpen ? 270 : 0, minWidth: sbOpen ? 270 : 0,
341
+ background: "var(--sbBg)", borderRight: "1px solid var(--bd)",
342
+ display: "flex", flexDirection: "column",
343
+ transition: "width .2s, min-width .2s", overflow: "hidden",
344
+ }}>
345
+ <div style={{ padding: "18px 20px", display: "flex", alignItems: "baseline", gap: 6, borderBottom: "1px solid var(--bd)" }}>
346
+ <span style={{ fontFamily: "var(--font-heading)", fontSize: 22, fontWeight: 700, fontStyle: "italic" }}>
347
+ {config.name}
348
+ </span>
349
+ <span style={{ width: 5, height: 5, borderRadius: "50%", background: "var(--ac)", display: "inline-block" }} />
350
+ </div>
351
+
352
+ <div style={{ padding: "12px 14px" }}>
353
+ <button onClick={() => setSearch(true)} style={{
354
+ display: "flex", alignItems: "center", gap: 8, width: "100%",
355
+ background: "var(--cdBg)", border: "1px solid var(--bd)", borderRadius: 2,
356
+ padding: "8px 12px", cursor: "pointer", color: "var(--txM)", fontSize: 12.5,
357
+ fontFamily: "var(--font-body)",
358
+ }}>
359
+ <SearchIcon /><span style={{ flex: 1, textAlign: "left" }}>Search...</span>
360
+ <kbd style={{ fontFamily: "var(--font-code)", fontSize: 9, background: "var(--sf)", border: "1px solid var(--bd)", borderRadius: 2, padding: "2px 6px" }}>{"\u2318K"}</kbd>
361
+ </button>
362
+ </div>
363
+
364
+ <nav style={{ flex: 1, overflow: "auto", padding: "4px 10px 20px" }}>
365
+ {navigation.map(sec => (
366
+ <div key={sec.section} style={{ marginBottom: 8 }}>
367
+ <button onClick={() => togSec(sec.section)} style={{
368
+ display: "flex", alignItems: "center", gap: 6, width: "100%",
369
+ background: "none", border: "none", padding: "8px 10px", cursor: "pointer",
370
+ borderRadius: 2, color: "var(--txM)", fontSize: 10, fontWeight: 600,
371
+ textTransform: "uppercase", letterSpacing: ".1em", fontFamily: "var(--font-code)",
372
+ }}>
373
+ {expanded.includes(sec.section) ? <ChevDown /> : <ChevRight />}{sec.section}
374
+ </button>
375
+ {expanded.includes(sec.section) && <div style={{ marginLeft: 8, borderLeft: "1px solid var(--bd)", paddingLeft: 0 }}>
376
+ {sec.pages.map(p => {
377
+ const active = currentPageId === p.id;
378
+ return (
379
+ <button key={p.id} onClick={() => onNavigate(p.id)} style={{
380
+ display: "flex", alignItems: "center", gap: 10, width: "100%",
381
+ textAlign: "left", background: "none",
382
+ border: "none", borderRadius: 0,
383
+ borderLeft: active ? "2px solid var(--ac)" : "2px solid transparent",
384
+ padding: "7px 14px", cursor: "pointer",
385
+ color: active ? "var(--ac)" : "var(--tx2)", fontSize: 13,
386
+ fontWeight: active ? 500 : 400, fontFamily: "var(--font-body)",
387
+ transition: "all .12s",
388
+ }}>
389
+ {p.title}
390
+ </button>
391
+ );
392
+ })}
393
+ </div>}
394
+ </div>
395
+ ))}
396
+ </nav>
397
+
398
+ <div style={{ padding: "12px 16px", borderTop: "1px solid var(--bd)", display: "flex", alignItems: "center", justifyContent: "space-between" }}>
399
+ {/* TOM-12: Only show toggle when mode is "auto" */}
400
+ {themeMode === "auto" ? (
401
+ <button onClick={() => setDark(d => !d)} style={{ background: "none", border: "none", color: "var(--txM)", cursor: "pointer", display: "flex" }}>
402
+ {isDark ? <SunIcon /> : <MoonIcon />}
403
+ </button>
404
+ ) : <div />}
405
+ <span style={{ fontFamily: "var(--font-code)", fontSize: 10, color: "var(--txM)" }}>v0.1.0</span>
406
+ </div>
407
+ </aside>
408
+
409
+ {/* Main area */}
410
+ <div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" }}>
411
+ {/* Header */}
412
+ <header style={{
413
+ display: "flex", alignItems: "center", gap: 12, padding: "10px 24px",
414
+ borderBottom: "1px solid var(--bd)", background: "var(--hdBg)", backdropFilter: "blur(12px)",
415
+ }}>
416
+ <button onClick={() => setSb(!sbOpen)} style={{ background: "none", border: "none", color: "var(--txM)", cursor: "pointer", display: "flex" }}>
417
+ {sbOpen ? <XIcon /> : <MenuIcon />}
418
+ </button>
419
+ <div style={{ display: "flex", alignItems: "center", gap: 8, fontFamily: "var(--font-code)", fontSize: 11, color: "var(--txM)", letterSpacing: ".03em", flex: 1 }}>
420
+ {navigation.map(s => {
421
+ const f = s.pages.find(p => p.id === currentPageId);
422
+ if (!f) return null;
423
+ return <span key={s.section} style={{ display: "flex", alignItems: "center", gap: 8 }}>
424
+ <span>{s.section}</span><ChevRight /><span style={{ color: "var(--ac)" }}>{f.title}</span>
425
+ </span>;
426
+ })}
427
+ </div>
428
+
429
+ {/* Top Nav Links */}
430
+ {config.topNav && config.topNav.length > 0 && (
431
+ <div style={{ display: "flex", alignItems: "center", gap: 12 }}>
432
+ {config.topNav.map((link) => {
433
+ const isExternal = link.href.startsWith("http") || !link.href.startsWith("#");
434
+ return (
435
+ <a
436
+ key={link.label}
437
+ href={link.href}
438
+ {...(isExternal ? { target: "_blank", rel: "noopener noreferrer" } : {})}
439
+ style={{
440
+ display: "flex", alignItems: "center", gap: 4,
441
+ color: "var(--txM)", textDecoration: "none", fontSize: 12,
442
+ fontFamily: "var(--font-body)", fontWeight: 500,
443
+ transition: "color .15s",
444
+ }}
445
+ onMouseOver={(e) => (e.currentTarget.style.color = "var(--ac)")}
446
+ onMouseOut={(e) => (e.currentTarget.style.color = "var(--txM)")}
447
+ >
448
+ {link.label}
449
+ {isExternal && <ExtLinkIcon />}
450
+ </a>
451
+ );
452
+ })}
453
+ <span style={{ width: 1, height: 16, background: "var(--bd)" }} />
454
+ </div>
455
+ )}
456
+
457
+ {/* TOM-30: Version Switcher */}
458
+ {versioning && (
459
+ <div style={{ position: "relative" }}>
460
+ <button
461
+ data-testid="version-switcher"
462
+ onClick={() => setVersionDropdown(o => !o)}
463
+ style={{
464
+ display: "flex", alignItems: "center", gap: 6,
465
+ background: "var(--sf)", border: "1px solid var(--bd)", borderRadius: 2,
466
+ padding: "5px 10px", cursor: "pointer", color: "var(--tx2)", fontSize: 12,
467
+ fontFamily: "var(--font-code)",
468
+ }}
469
+ >
470
+ <VersionIcon />
471
+ {currentVersion || versioning.current}
472
+ <ChevDown />
473
+ </button>
474
+ {versionDropdownOpen && (
475
+ <div
476
+ data-testid="version-dropdown"
477
+ style={{
478
+ position: "absolute", top: "100%", right: 0, marginTop: 4,
479
+ background: "var(--sf)", border: "1px solid var(--bd)", borderRadius: 2,
480
+ boxShadow: "0 8px 32px rgba(0,0,0,0.2)", overflow: "hidden", zIndex: 100,
481
+ minWidth: 120,
482
+ }}
483
+ >
484
+ {versioning.versions.map(v => (
485
+ <button
486
+ key={v}
487
+ onClick={() => {
488
+ setVersionDropdown(false);
489
+ // Navigate to the version's root page
490
+ const targetUrl = v === versioning.current ? "/" : `/${v}`;
491
+ window.location.href = targetUrl;
492
+ }}
493
+ style={{
494
+ display: "block", width: "100%", textAlign: "left",
495
+ background: v === (currentVersion || versioning.current) ? "var(--acD)" : "none",
496
+ border: "none", padding: "8px 14px", cursor: "pointer",
497
+ color: v === (currentVersion || versioning.current) ? "var(--ac)" : "var(--tx2)",
498
+ fontSize: 12, fontFamily: "var(--font-code)",
499
+ fontWeight: v === versioning.current ? 600 : 400,
500
+ }}
501
+ >
502
+ {v}{v === versioning.current ? " (latest)" : ""}
503
+ </button>
504
+ ))}
505
+ </div>
506
+ )}
507
+ </div>
508
+ )}
509
+
510
+ {/* TOM-34: Language Switcher */}
511
+ {i18n && i18n.locales.length > 1 && (
512
+ <div style={{ position: "relative" }}>
513
+ <button
514
+ data-testid="language-switcher"
515
+ onClick={() => setLocaleDropdown(o => !o)}
516
+ style={{
517
+ display: "flex", alignItems: "center", gap: 6,
518
+ background: "var(--sf)", border: "1px solid var(--bd)", borderRadius: 2,
519
+ padding: "5px 10px", cursor: "pointer", color: "var(--tx2)", fontSize: 12,
520
+ fontFamily: "var(--font-body)",
521
+ }}
522
+ >
523
+ <GlobeIcon />
524
+ {i18n.localeNames?.[currentLocale || i18n.defaultLocale] || currentLocale || i18n.defaultLocale}
525
+ <ChevDown />
526
+ </button>
527
+ {localeDropdownOpen && (
528
+ <div
529
+ data-testid="language-dropdown"
530
+ style={{
531
+ position: "absolute", top: "100%", right: 0, marginTop: 4,
532
+ background: "var(--sf)", border: "1px solid var(--bd)", borderRadius: 2,
533
+ boxShadow: "0 8px 32px rgba(0,0,0,0.2)", overflow: "hidden", zIndex: 100,
534
+ minWidth: 120,
535
+ }}
536
+ >
537
+ {i18n.locales.map(locale => {
538
+ const isActive = locale === (currentLocale || i18n.defaultLocale);
539
+ const displayName = i18n.localeNames?.[locale] || locale;
540
+
541
+ // Compute the target URL: switch locale but keep the same page
542
+ const activeLocale = currentLocale || i18n.defaultLocale;
543
+ // Get the base page path (strip locale prefix from current page ID)
544
+ let basePageId = currentPageId;
545
+ if (activeLocale !== i18n.defaultLocale && currentPageId.startsWith(`${activeLocale}/`)) {
546
+ basePageId = currentPageId.slice(activeLocale.length + 1);
547
+ }
548
+ const targetId = locale === i18n.defaultLocale
549
+ ? basePageId
550
+ : `${locale}/${basePageId}`;
551
+
552
+ return (
553
+ <button
554
+ key={locale}
555
+ onClick={() => {
556
+ setLocaleDropdown(false);
557
+ onNavigate(targetId);
558
+ }}
559
+ style={{
560
+ display: "block", width: "100%", textAlign: "left",
561
+ background: isActive ? "var(--acD)" : "none",
562
+ border: "none", padding: "8px 14px", cursor: "pointer",
563
+ color: isActive ? "var(--ac)" : "var(--tx2)",
564
+ fontSize: 12, fontFamily: "var(--font-body)",
565
+ fontWeight: isActive ? 600 : 400,
566
+ }}
567
+ >
568
+ {displayName}
569
+ </button>
570
+ );
571
+ })}
572
+ </div>
573
+ )}
574
+ </div>
575
+ )}
576
+ </header>
577
+
578
+ {/* TOM-30: Old version banner */}
579
+ {isOldVersion && (
580
+ <div
581
+ data-testid="old-version-banner"
582
+ style={{
583
+ display: "flex", alignItems: "center", justifyContent: "center", gap: 8,
584
+ background: "var(--acD)", borderBottom: "1px solid var(--bd)",
585
+ padding: "8px 24px", fontSize: 13, color: "var(--tx2)",
586
+ }}
587
+ >
588
+ <span>You're viewing docs for {currentVersion}.</span>
589
+ <button
590
+ onClick={() => { window.location.href = "/"; }}
591
+ style={{
592
+ background: "none", border: "none", color: "var(--ac)",
593
+ cursor: "pointer", fontWeight: 600, fontSize: 13,
594
+ fontFamily: "var(--font-body)", textDecoration: "underline",
595
+ }}
596
+ >
597
+ Switch to latest.
598
+ </button>
599
+ </div>
600
+ )}
601
+
602
+ {/* Content + TOC */}
603
+ <div ref={contentRef} style={{ flex: 1, overflow: "auto", display: "flex" }}>
604
+ <main style={{ flex: 1, maxWidth: 760, padding: "40px 48px 80px", margin: "0 auto" }}>
605
+ <h1 style={{ fontFamily: "var(--font-heading)", fontSize: 38, fontWeight: 400, fontStyle: "italic", lineHeight: 1.15, marginBottom: 8 }}>
606
+ {pageTitle}
607
+ </h1>
608
+ {pageDescription && <p style={{ fontSize: 16, color: "var(--tx2)", lineHeight: 1.6, marginBottom: 32 }}>{pageDescription}</p>}
609
+ <div style={{ borderTop: "1px solid var(--bd)", paddingTop: 28 }}>
610
+ {/* TOM-8: Render MDX component or raw HTML */}
611
+ {PageComponent ? (
612
+ <div className="tome-content">
613
+ <PageComponent components={mdxComponents || {}} />
614
+ </div>
615
+ ) : (
616
+ <div
617
+ className="tome-content"
618
+ dangerouslySetInnerHTML={{ __html: pageHtml || "" }}
619
+ />
620
+ )}
621
+ </div>
622
+
623
+ {/* Prev / Next */}
624
+ <div style={{ display: "flex", justifyContent: "space-between", marginTop: 48, paddingTop: 24, borderTop: "1px solid var(--bd)", gap: 16 }}>
625
+ {prev ? (
626
+ <button onClick={() => onNavigate(prev.id)} style={{
627
+ display: "flex", alignItems: "center", gap: 8, background: "none",
628
+ border: "1px solid var(--bd)", borderRadius: 2, padding: "10px 16px",
629
+ cursor: "pointer", color: "var(--tx2)", fontSize: 13, fontFamily: "var(--font-body)",
630
+ transition: "border-color .15s, color .15s",
631
+ }}><ArrowLeft /> {prev.title}</button>
632
+ ) : <div />}
633
+ {next ? (
634
+ <button onClick={() => onNavigate(next.id)} style={{
635
+ display: "flex", alignItems: "center", gap: 8, background: "none",
636
+ border: "1px solid var(--bd)", borderRadius: 2, padding: "10px 16px",
637
+ cursor: "pointer", color: "var(--tx2)", fontSize: 13, fontFamily: "var(--font-body)",
638
+ transition: "border-color .15s, color .15s",
639
+ }}>{next.title} <ArrowRight /></button>
640
+ ) : <div />}
641
+ </div>
642
+ </main>
643
+
644
+ {/* TOC */}
645
+ {headings.length > 0 && wide && (
646
+ <aside style={{ width: 200, padding: "40px 16px 40px 0", position: "sticky", top: 0, alignSelf: "flex-start", flexShrink: 0 }}>
647
+ <div style={{ fontSize: 10, fontWeight: 600, textTransform: "uppercase", letterSpacing: ".1em", color: "var(--txM)", marginBottom: 12, fontFamily: "var(--font-code)" }}>On this page</div>
648
+ <div style={{ borderLeft: "1px solid var(--bd)", paddingLeft: 0 }}>
649
+ {headings.map((h, i) => (
650
+ <a key={i} href={`#${h.id}`} style={{
651
+ display: "block", fontSize: 12, color: "var(--txM)",
652
+ textDecoration: "none", padding: "4px 12px", paddingLeft: 12 + (h.depth - 2) * 12,
653
+ lineHeight: 1.4, transition: "color .12s",
654
+ }}>{h.text}</a>
655
+ ))}
656
+ </div>
657
+ </aside>
658
+ )}
659
+ </div>
660
+ </div>
661
+ </div>
662
+
663
+ {/* TOM-32: AI Chat Widget (BYOK) */}
664
+ {config.ai?.enabled && (
665
+ <AiChat
666
+ provider={config.ai.provider || "anthropic"}
667
+ model={config.ai.model}
668
+ apiKey={typeof __TOME_AI_API_KEY__ !== "undefined" && __TOME_AI_API_KEY__ ? __TOME_AI_API_KEY__ : undefined}
669
+ context={docContext?.map((d) => `## ${d.title}\n${d.content}`).join("\n\n") ?? allPages.map((p) => `- ${p.title}${p.description ? ": " + p.description : ""}`).join("\n")}
670
+ />
671
+ )}
672
+ </div>
673
+ );
674
+ }
675
+
676
+ // ── SEARCH RESULT TYPE (TOM-15) ──────────────────────────
677
+ interface SearchResult {
678
+ id: string;
679
+ title: string;
680
+ excerpt?: string;
681
+ }
682
+
683
+ // ── SEARCH MODAL (TOM-15) ────────────────────────────────
684
+ function SearchModal({ allPages, onNavigate, onClose }: {
685
+ allPages: Array<{ id: string; title: string; description?: string }>;
686
+ onNavigate: (id: string) => void;
687
+ onClose: () => void;
688
+ }) {
689
+ const [q, setQ] = useState("");
690
+ const [results, setResults] = useState<SearchResult[]>([]);
691
+ const [selected, setSelected] = useState(0);
692
+ const [pagefindReady, setPagefindReady] = useState<boolean | null>(null);
693
+ const inputRef = useRef<HTMLInputElement>(null);
694
+ const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
695
+
696
+ // Try to initialize Pagefind on mount
697
+ useEffect(() => {
698
+ initPagefind().then((pf) => setPagefindReady(!!pf));
699
+ setTimeout(() => inputRef.current?.focus(), 50);
700
+ }, []);
701
+
702
+ // Fallback search: filter allPages by title/description
703
+ const fallbackSearch = useCallback((query: string): SearchResult[] => {
704
+ if (!query.trim()) return [];
705
+ const ql = query.toLowerCase();
706
+ return allPages
707
+ .filter(p => p.title.toLowerCase().includes(ql) || (p.description || "").toLowerCase().includes(ql))
708
+ .slice(0, 8)
709
+ .map(p => ({ id: p.id, title: p.title, excerpt: p.description }));
710
+ }, [allPages]);
711
+
712
+ // Search handler: use Pagefind if available, otherwise fallback
713
+ const doSearch = useCallback(async (query: string) => {
714
+ if (!query.trim()) {
715
+ setResults([]);
716
+ setSelected(0);
717
+ return;
718
+ }
719
+
720
+ // Try Pagefind first
721
+ const pf = pagefindInstance;
722
+ if (pf) {
723
+ try {
724
+ const search = await pf.search(query);
725
+ const items: SearchResult[] = [];
726
+ for (const result of search.results.slice(0, 8)) {
727
+ const data = await result.data();
728
+ // Pagefind URLs look like "/index.html" or "/quickstart/index.html"
729
+ // Extract the page ID from the URL path
730
+ const url: string = data.url || "";
731
+ const id = url
732
+ .replace(/^\//, "")
733
+ .replace(/\/index\.html$/, "")
734
+ .replace(/\.html$/, "")
735
+ || "index";
736
+ items.push({
737
+ id,
738
+ title: data.meta?.title || id,
739
+ excerpt: data.excerpt || undefined,
740
+ });
741
+ }
742
+ setResults(items);
743
+ setSelected(0);
744
+ return;
745
+ } catch {
746
+ // Pagefind search failed, fall through to fallback
747
+ }
748
+ }
749
+
750
+ // Fallback: client-side filtering
751
+ setResults(fallbackSearch(query));
752
+ setSelected(0);
753
+ }, [fallbackSearch]);
754
+
755
+ // Debounced search on query change
756
+ useEffect(() => {
757
+ if (debounceRef.current) clearTimeout(debounceRef.current);
758
+ debounceRef.current = setTimeout(() => doSearch(q), 120);
759
+ return () => { if (debounceRef.current) clearTimeout(debounceRef.current); };
760
+ }, [q, doSearch]);
761
+
762
+ // Keyboard navigation
763
+ const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
764
+ if (e.key === "ArrowDown") {
765
+ e.preventDefault();
766
+ setSelected(i => Math.min(i + 1, results.length - 1));
767
+ } else if (e.key === "ArrowUp") {
768
+ e.preventDefault();
769
+ setSelected(i => Math.max(i - 1, 0));
770
+ } else if (e.key === "Enter" && results.length > 0) {
771
+ e.preventDefault();
772
+ onNavigate(results[selected].id);
773
+ }
774
+ }, [results, selected, onNavigate]);
775
+
776
+ return (
777
+ <div onClick={onClose} style={{
778
+ position: "fixed", inset: 0, zIndex: 1000, background: "rgba(0,0,0,0.55)",
779
+ backdropFilter: "blur(6px)", display: "flex", alignItems: "flex-start",
780
+ justifyContent: "center", paddingTop: "12vh",
781
+ }}>
782
+ <div onClick={e => e.stopPropagation()} style={{
783
+ background: "var(--sf)", border: "1px solid var(--bd)", borderRadius: 2,
784
+ width: "100%", maxWidth: 520, boxShadow: "0 24px 80px rgba(0,0,0,0.4)", overflow: "hidden",
785
+ }}>
786
+ <div style={{ display: "flex", alignItems: "center", gap: 10, padding: "14px 18px", borderBottom: "1px solid var(--bd)" }}>
787
+ <SearchIcon />
788
+ <input ref={inputRef} value={q} onChange={e => setQ(e.target.value)}
789
+ onKeyDown={handleKeyDown}
790
+ placeholder="Search documentation..."
791
+ style={{ flex: 1, background: "none", border: "none", outline: "none", color: "var(--tx)", fontSize: 15, fontFamily: "var(--font-body)" }}
792
+ />
793
+ <kbd style={{ fontFamily: "var(--font-code)", fontSize: 10, color: "var(--txM)", background: "var(--cdBg)", padding: "2px 6px", borderRadius: 2, border: "1px solid var(--bd)" }}>ESC</kbd>
794
+ </div>
795
+ {results.length > 0 && <div style={{ padding: 6, maxHeight: 360, overflow: "auto" }}>
796
+ {results.map((r, i) => (
797
+ <button key={r.id + i} onClick={() => onNavigate(r.id)} style={{
798
+ display: "block", width: "100%", textAlign: "left",
799
+ background: i === selected ? "var(--acD)" : "none",
800
+ border: "none", borderRadius: 2, padding: "10px 14px", cursor: "pointer", color: "var(--tx)",
801
+ fontFamily: "var(--font-body)",
802
+ }}
803
+ onMouseEnter={() => setSelected(i)}
804
+ >
805
+ <div style={{ fontWeight: 500, fontSize: 14, marginBottom: 2 }}>{r.title}</div>
806
+ {r.excerpt && <div style={{
807
+ fontSize: 12, color: "var(--txM)", lineHeight: 1.3,
808
+ }} dangerouslySetInnerHTML={{ __html: r.excerpt }} />}
809
+ </button>
810
+ ))}
811
+ </div>}
812
+ {q && !results.length && (
813
+ <div style={{ padding: "32px 18px", textAlign: "center", color: "var(--txM)", fontSize: 14 }}>
814
+ No results found
815
+ </div>
816
+ )}
817
+ {pagefindReady === false && q && results.length > 0 && (
818
+ <div style={{ padding: "6px 18px 10px", fontSize: 11, color: "var(--txM)", textAlign: "center" }}>
819
+ Showing title matches. Build your site for full-text search.
820
+ </div>
821
+ )}
822
+ </div>
823
+ </div>
824
+ );
825
+ }
826
+
827
+ export default Shell;