@tomehq/theme 0.6.4 → 0.7.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 CHANGED
@@ -342,11 +342,13 @@ interface ShellProps {
342
342
  config: {
343
343
  name: string;
344
344
  theme?: { preset?: string; mode?: string; accent?: string; fonts?: { heading?: string; body?: string; code?: string } };
345
- search?: { provider?: string; appId?: string; apiKey?: string; indexName?: string };
345
+ search?: { provider?: string; ai?: boolean; appId?: string; apiKey?: string; indexName?: string };
346
346
  ai?: { enabled?: boolean; provider?: "openai" | "anthropic" | "custom"; model?: string; apiKeyEnv?: string };
347
347
  toc?: { enabled?: boolean; depth?: number };
348
348
  topNav?: Array<{ label: string; href: string }>;
349
349
  banner?: { text: string; link?: string; dismissible?: boolean };
350
+ branding?: { powered?: boolean };
351
+ feedback?: { enabled?: boolean; textInput?: boolean };
350
352
  socialLinks?: Array<{ platform: string; url: string; label?: string; icon?: string }>;
351
353
  [key: string]: unknown;
352
354
  };
@@ -366,6 +368,7 @@ interface ShellProps {
366
368
  lastUpdated?: string;
367
369
  changelogEntries?: Array<{ version: string; date?: string; url?: string; sections: Array<{ type: string; items: string[] }> }>;
368
370
  apiManifest?: any;
371
+ asyncApiManifest?: any;
369
372
  apiBaseUrl?: string;
370
373
  apiPlayground?: boolean;
371
374
  apiAuth?: { type: "bearer" | "apiKey"; header?: string };
@@ -375,6 +378,7 @@ interface ShellProps {
375
378
  showPlayground?: boolean;
376
379
  playgroundAuth?: { type: "bearer" | "apiKey"; header?: string };
377
380
  }>;
381
+ AsyncApiReferenceComponent?: React.ComponentType<{ manifest: any }>;
378
382
  onNavigate: (id: string) => void;
379
383
  allPages: Array<{ id: string; title: string; description?: string }>;
380
384
  versioning?: VersioningInfo;
@@ -397,7 +401,7 @@ interface ShellProps {
397
401
  export function Shell({
398
402
  config, navigation, currentPageId, pageHtml, pageComponent, mdxComponents,
399
403
  pageTitle, pageDescription, headings, tocEnabled = true, editUrl, lastUpdated, changelogEntries,
400
- apiManifest, apiBaseUrl, apiPlayground, apiAuth, ApiReferenceComponent, onNavigate, allPages,
404
+ apiManifest, asyncApiManifest, apiBaseUrl, apiPlayground, apiAuth, ApiReferenceComponent, AsyncApiReferenceComponent, onNavigate, allPages,
401
405
  versioning, currentVersion, i18n, currentLocale, docContext, basePath = "", isDraft, dir: dirProp, overrides,
402
406
  }: ShellProps) {
403
407
  // RTL support: resolve text direction from prop, i18n.localeDirs, or default to "ltr"
@@ -421,6 +425,9 @@ export function Shell({
421
425
  const [localeDropdownOpen, setLocaleDropdown] = useState(false);
422
426
  const [zoomSrc, setZoomSrc] = useState<string | null>(null);
423
427
  const [feedbackGiven, setFeedbackGiven] = useState<Record<string, boolean>>({});
428
+ const [feedbackRating, setFeedbackRating] = useState<Record<string, "up" | "down">>({});
429
+ const [feedbackComment, setFeedbackComment] = useState("");
430
+ const [feedbackSubmitted, setFeedbackSubmitted] = useState<Record<string, boolean>>({});
424
431
  const [bannerDismissed, setBannerDismissed] = useState(() => {
425
432
  if (!config.banner?.text) return true;
426
433
  try {
@@ -726,6 +733,12 @@ export function Shell({
726
733
  onNavigate={(id) => { onNavigate(id); setSearch(false); }}
727
734
  onClose={() => setSearch(false)}
728
735
  mobile={mobile}
736
+ aiSearch={config.search?.ai && config.ai?.enabled ? {
737
+ provider: config.ai.provider || "anthropic",
738
+ model: config.ai.model,
739
+ apiKey: (window as any).__TOME_AI_API_KEY__ || undefined,
740
+ context: docContext?.map((d) => `## ${d.title}\n${d.content}`).join("\n\n"),
741
+ } : undefined}
729
742
  />
730
743
  ) : null}
731
744
 
@@ -860,7 +873,7 @@ export function Shell({
860
873
  {isDark ? <SunIcon /> : <MoonIcon />}
861
874
  </button>
862
875
  ) : <div />}
863
- <span style={{ fontSize: 11, color: "var(--txM)", letterSpacing: 0.2 }}>Built with {"\u2661"} by Tome</span>
876
+ {config.branding?.powered !== false && <span style={{ fontSize: 11, color: "var(--txM)", letterSpacing: 0.2 }}>Built with {"\u2661"} by Tome</span>}
864
877
  <span style={{ fontFamily: "var(--font-code)", fontSize: 10, color: "var(--txM)" }}>{typeof __TOME_VERSION__ !== "undefined" && __TOME_VERSION__ ? `v${__TOME_VERSION__}` : "v0.1.0"}</span>
865
878
  </div>
866
879
  </aside>
@@ -1125,7 +1138,7 @@ export function Shell({
1125
1138
 
1126
1139
  {/* Content + TOC */}
1127
1140
  <div ref={contentRef} style={{ flex: 1, overflow: "auto", display: "flex" }}>
1128
- <main style={{ flex: 1, maxWidth: mobile ? "100%" : apiManifest ? 1100 : 760, padding: mobile ? "24px 16px 60px" : "40px 48px 80px", margin: "0 auto", minWidth: 0 }}>
1141
+ <main style={{ flex: 1, maxWidth: mobile ? "100%" : (apiManifest || asyncApiManifest) ? 1100 : 760, padding: mobile ? "24px 16px 60px" : "40px 48px 80px", margin: "0 auto", minWidth: 0 }}>
1129
1142
  {breadcrumbs.length > 0 && (
1130
1143
  <nav aria-label="Breadcrumbs" data-testid="breadcrumbs" style={{
1131
1144
  display: "flex", alignItems: "center", gap: 6,
@@ -1164,9 +1177,19 @@ export function Shell({
1164
1177
  )}
1165
1178
  {pageDescription && <p style={{ fontSize: 16, color: "var(--tx2)", lineHeight: 1.6, marginBottom: 32 }}>{pageDescription}</p>}
1166
1179
  <div style={{ borderTop: "1px solid var(--bd)", paddingTop: 28 }}>
1167
- {/* TOM-19: API Reference page */}
1168
- {apiManifest && ApiReferenceComponent ? (
1169
- <ApiReferenceComponent manifest={apiManifest} baseUrl={apiBaseUrl} showPlayground={apiPlayground} playgroundAuth={apiAuth} />
1180
+ {/* TOM-19 + TOM-66: API Reference page (OpenAPI + AsyncAPI) */}
1181
+ {(apiManifest || asyncApiManifest) && (ApiReferenceComponent || AsyncApiReferenceComponent) ? (
1182
+ <>
1183
+ {apiManifest && ApiReferenceComponent && (
1184
+ <ApiReferenceComponent manifest={apiManifest} baseUrl={apiBaseUrl} showPlayground={apiPlayground} playgroundAuth={apiAuth} />
1185
+ )}
1186
+ {asyncApiManifest && AsyncApiReferenceComponent && (
1187
+ <>
1188
+ {apiManifest && <div style={{ borderTop: "1px solid var(--bd)", margin: "40px 0" }} />}
1189
+ <AsyncApiReferenceComponent manifest={asyncApiManifest} />
1190
+ </>
1191
+ )}
1192
+ </>
1170
1193
  ) : /* TOM-49: Changelog page type */
1171
1194
  changelogEntries && changelogEntries.length > 0 ? (
1172
1195
  <ChangelogView entries={changelogEntries} />
@@ -1225,21 +1248,90 @@ export function Shell({
1225
1248
  )}
1226
1249
 
1227
1250
  {/* Feedback widget */}
1228
- <div style={{ display: "flex", alignItems: "center", gap: 12, marginTop: 24, padding: "12px 0" }}>
1229
- {feedbackGiven[currentPageId] ? (
1251
+ {config.feedback?.enabled !== false && (
1252
+ <div data-testid="feedback-widget" style={{ marginTop: 24, padding: "12px 0" }}>
1253
+ {feedbackSubmitted[currentPageId] ? (
1254
+ <span style={{ fontSize: 13, color: "var(--txM)", fontFamily: "var(--font-body)" }}>Thanks for your feedback!</span>
1255
+ ) : feedbackGiven[currentPageId] && config.feedback?.textInput ? (
1256
+ <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
1257
+ <span style={{ fontSize: 13, color: "var(--txM)", fontFamily: "var(--font-body)" }}>Any additional feedback? (optional)</span>
1258
+ <div style={{ display: "flex", gap: 8 }}>
1259
+ <input
1260
+ data-testid="feedback-text-input"
1261
+ type="text"
1262
+ value={feedbackComment}
1263
+ onChange={e => setFeedbackComment(e.target.value)}
1264
+ placeholder="Tell us more..."
1265
+ style={{
1266
+ flex: 1, padding: "6px 10px", fontSize: 13, fontFamily: "var(--font-body)",
1267
+ background: "var(--sf)", border: "1px solid var(--bd)", borderRadius: 2,
1268
+ color: "var(--tx)", outline: "none",
1269
+ }}
1270
+ onKeyDown={e => {
1271
+ if (e.key === "Enter") {
1272
+ (window as any).__tome?.trackFeedback(currentPageId, feedbackRating[currentPageId], feedbackComment);
1273
+ try { localStorage.setItem(`tome-feedback-${currentPageId}`, feedbackRating[currentPageId]); } catch {}
1274
+ setFeedbackSubmitted(prev => ({ ...prev, [currentPageId]: true }));
1275
+ setFeedbackComment("");
1276
+ }
1277
+ }}
1278
+ />
1279
+ <button
1280
+ data-testid="feedback-submit"
1281
+ onClick={() => {
1282
+ (window as any).__tome?.trackFeedback(currentPageId, feedbackRating[currentPageId], feedbackComment);
1283
+ try { localStorage.setItem(`tome-feedback-${currentPageId}`, feedbackRating[currentPageId]); } catch {}
1284
+ setFeedbackSubmitted(prev => ({ ...prev, [currentPageId]: true }));
1285
+ setFeedbackComment("");
1286
+ }}
1287
+ style={{
1288
+ background: "none", border: "1px solid var(--bd)", borderRadius: 2,
1289
+ padding: "6px 14px", cursor: "pointer", fontSize: 13, color: "var(--txM)",
1290
+ }}
1291
+ >Submit</button>
1292
+ <button
1293
+ onClick={() => {
1294
+ (window as any).__tome?.trackFeedback(currentPageId, feedbackRating[currentPageId]);
1295
+ try { localStorage.setItem(`tome-feedback-${currentPageId}`, feedbackRating[currentPageId]); } catch {}
1296
+ setFeedbackSubmitted(prev => ({ ...prev, [currentPageId]: true }));
1297
+ }}
1298
+ style={{
1299
+ background: "none", border: "none", cursor: "pointer", fontSize: 13, color: "var(--txM)",
1300
+ }}
1301
+ >Skip</button>
1302
+ </div>
1303
+ </div>
1304
+ ) : feedbackGiven[currentPageId] ? (
1230
1305
  <span style={{ fontSize: 13, color: "var(--txM)", fontFamily: "var(--font-body)" }}>Thanks for your feedback!</span>
1231
1306
  ) : (
1232
- <>
1307
+ <div style={{ display: "flex", alignItems: "center", gap: 12 }}>
1233
1308
  <span style={{ fontSize: 13, color: "var(--txM)", fontFamily: "var(--font-body)" }}>Was this helpful?</span>
1234
- <button onClick={() => { setFeedbackGiven(prev => ({ ...prev, [currentPageId]: true })); try { localStorage.setItem(`tome-feedback-${currentPageId}`, "up"); } catch {} }} style={{
1309
+ <button data-testid="feedback-up" onClick={() => {
1310
+ setFeedbackRating(prev => ({ ...prev, [currentPageId]: "up" }));
1311
+ setFeedbackGiven(prev => ({ ...prev, [currentPageId]: true }));
1312
+ if (!config.feedback?.textInput) {
1313
+ (window as any).__tome?.trackFeedback(currentPageId, "up");
1314
+ try { localStorage.setItem(`tome-feedback-${currentPageId}`, "up"); } catch {}
1315
+ setFeedbackSubmitted(prev => ({ ...prev, [currentPageId]: true }));
1316
+ }
1317
+ }} style={{
1235
1318
  background: "none", border: "1px solid var(--bd)", borderRadius: 2, padding: "4px 10px", cursor: "pointer", fontSize: 13, color: "var(--txM)", transition: "border-color .15s",
1236
1319
  }}>👍</button>
1237
- <button onClick={() => { setFeedbackGiven(prev => ({ ...prev, [currentPageId]: true })); try { localStorage.setItem(`tome-feedback-${currentPageId}`, "down"); } catch {} }} style={{
1320
+ <button data-testid="feedback-down" onClick={() => {
1321
+ setFeedbackRating(prev => ({ ...prev, [currentPageId]: "down" }));
1322
+ setFeedbackGiven(prev => ({ ...prev, [currentPageId]: true }));
1323
+ if (!config.feedback?.textInput) {
1324
+ (window as any).__tome?.trackFeedback(currentPageId, "down");
1325
+ try { localStorage.setItem(`tome-feedback-${currentPageId}`, "down"); } catch {}
1326
+ setFeedbackSubmitted(prev => ({ ...prev, [currentPageId]: true }));
1327
+ }
1328
+ }} style={{
1238
1329
  background: "none", border: "1px solid var(--bd)", borderRadius: 2, padding: "4px 10px", cursor: "pointer", fontSize: 13, color: "var(--txM)", transition: "border-color .15s",
1239
1330
  }}>👎</button>
1240
- </>
1331
+ </div>
1241
1332
  )}
1242
1333
  </div>
1334
+ )}
1243
1335
 
1244
1336
  {/* Prev / Next link cards */}
1245
1337
  <div style={{ display: "grid", gridTemplateColumns: mobile ? "1fr" : "1fr 1fr", marginTop: 24, paddingTop: 32, paddingBottom: 40, borderTop: "1px solid var(--bd)", gap: 16 }}>
@@ -1368,18 +1460,22 @@ interface SearchResult {
1368
1460
  }
1369
1461
 
1370
1462
  // ── SEARCH MODAL (TOM-15) ────────────────────────────────
1371
- function SearchModal({ allPages, onNavigate, onClose, mobile }: {
1463
+ function SearchModal({ allPages, onNavigate, onClose, mobile, aiSearch }: {
1372
1464
  allPages: Array<{ id: string; title: string; description?: string }>;
1373
1465
  onNavigate: (id: string) => void;
1374
1466
  onClose: () => void;
1375
1467
  mobile?: boolean;
1468
+ aiSearch?: { provider: "openai" | "anthropic" | "custom"; model?: string; apiKey?: string; context?: string };
1376
1469
  }) {
1377
1470
  const [q, setQ] = useState("");
1378
1471
  const [results, setResults] = useState<SearchResult[]>([]);
1379
1472
  const [selected, setSelected] = useState(0);
1380
1473
  const [pagefindReady, setPagefindReady] = useState<boolean | null>(null);
1474
+ const [aiAnswer, setAiAnswer] = useState<string | null>(null);
1475
+ const [aiLoading, setAiLoading] = useState(false);
1381
1476
  const inputRef = useRef<HTMLInputElement>(null);
1382
1477
  const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
1478
+ const aiDebounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
1383
1479
 
1384
1480
  // Try to initialize Pagefind on mount
1385
1481
  useEffect(() => {
@@ -1429,6 +1525,8 @@ function SearchModal({ allPages, onNavigate, onClose, mobile }: {
1429
1525
  }
1430
1526
  setResults(items);
1431
1527
  setSelected(0);
1528
+ // Track search query in analytics
1529
+ (window as any).__tome?.trackSearch(query, items.length);
1432
1530
  return;
1433
1531
  } catch {
1434
1532
  // Pagefind search failed, fall through to fallback
@@ -1436,8 +1534,11 @@ function SearchModal({ allPages, onNavigate, onClose, mobile }: {
1436
1534
  }
1437
1535
 
1438
1536
  // Fallback: client-side filtering
1439
- setResults(fallbackSearch(query));
1537
+ const fallbackResults = fallbackSearch(query);
1538
+ setResults(fallbackResults);
1440
1539
  setSelected(0);
1540
+ // Track search query in analytics
1541
+ (window as any).__tome?.trackSearch(query, fallbackResults.length);
1441
1542
  }, [fallbackSearch]);
1442
1543
 
1443
1544
  // Debounced search on query change
@@ -1447,6 +1548,42 @@ function SearchModal({ allPages, onNavigate, onClose, mobile }: {
1447
1548
  return () => { if (debounceRef.current) clearTimeout(debounceRef.current); };
1448
1549
  }, [q, doSearch]);
1449
1550
 
1551
+ // AI-enhanced search: fire after longer debounce for synthesized answer
1552
+ useEffect(() => {
1553
+ if (!aiSearch?.apiKey || !q.trim() || q.length < 3) {
1554
+ setAiAnswer(null);
1555
+ return;
1556
+ }
1557
+ if (aiDebounceRef.current) clearTimeout(aiDebounceRef.current);
1558
+ aiDebounceRef.current = setTimeout(async () => {
1559
+ setAiLoading(true);
1560
+ try {
1561
+ const { callAiProvider, buildSystemPrompt, getDefaultModel } = await import("./ai-api.js");
1562
+ const model = aiSearch.model || getDefaultModel(aiSearch.provider);
1563
+ // Build context from search results + full doc context
1564
+ const searchContext = results.slice(0, 5).map(r => `Page: ${r.title}\n${r.excerpt || ""}`).join("\n\n");
1565
+ const fullContext = aiSearch.context ? `${searchContext}\n\n---\n\n${aiSearch.context}` : searchContext;
1566
+ const sysPrompt = buildSystemPrompt(
1567
+ fullContext,
1568
+ "You are a documentation search assistant. Answer the user's question concisely (2-3 sentences max) based on the documentation. Cite specific page names when relevant. If you can't answer from the docs, say so briefly."
1569
+ );
1570
+ const answer = await callAiProvider(
1571
+ aiSearch.provider,
1572
+ [{ role: "user", content: q }],
1573
+ aiSearch.apiKey || "",
1574
+ model,
1575
+ sysPrompt,
1576
+ );
1577
+ setAiAnswer(answer);
1578
+ } catch {
1579
+ setAiAnswer(null);
1580
+ } finally {
1581
+ setAiLoading(false);
1582
+ }
1583
+ }, 500);
1584
+ return () => { if (aiDebounceRef.current) clearTimeout(aiDebounceRef.current); };
1585
+ }, [q, results, aiSearch]);
1586
+
1450
1587
  // Keyboard navigation
1451
1588
  const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
1452
1589
  if (e.key === "ArrowDown") {
@@ -1483,6 +1620,22 @@ function SearchModal({ allPages, onNavigate, onClose, mobile }: {
1483
1620
  />
1484
1621
  <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>
1485
1622
  </div>
1623
+ {/* AI Answer Card */}
1624
+ {aiSearch && q.length >= 3 && (aiLoading || aiAnswer) && (
1625
+ <div style={{
1626
+ padding: "12px 18px", borderBottom: "1px solid var(--bd)",
1627
+ background: "var(--acT)", fontSize: 13, lineHeight: 1.5, color: "var(--tx)",
1628
+ }}>
1629
+ <div style={{ fontSize: 10, fontWeight: 600, textTransform: "uppercase" as const, letterSpacing: 0.5, color: "var(--ac)", marginBottom: 6 }}>
1630
+ AI Answer
1631
+ </div>
1632
+ {aiLoading ? (
1633
+ <div style={{ color: "var(--txM)", fontStyle: "italic" }}>Thinking...</div>
1634
+ ) : aiAnswer ? (
1635
+ <div>{aiAnswer}</div>
1636
+ ) : null}
1637
+ </div>
1638
+ )}
1486
1639
  {results.length > 0 && <div style={{ padding: 6, maxHeight: mobile ? "none" : 360, overflow: "auto", flex: mobile ? 1 : undefined }}>
1487
1640
  {results.map((r, i) => (
1488
1641
  <button key={r.id + i} onClick={() => onNavigate(r.id)} style={{
@@ -0,0 +1,42 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { buildSystemPrompt, getDefaultModel } from "../ai-api.js";
3
+
4
+ describe("buildSystemPrompt", () => {
5
+ it("returns base instruction when no context", () => {
6
+ const prompt = buildSystemPrompt();
7
+ expect(prompt).toContain("documentation assistant");
8
+ });
9
+
10
+ it("includes context when provided", () => {
11
+ const prompt = buildSystemPrompt("Some doc content");
12
+ expect(prompt).toContain("Some doc content");
13
+ expect(prompt).toContain("Documentation:");
14
+ });
15
+
16
+ it("truncates long context", () => {
17
+ const longContext = "x".repeat(200000);
18
+ const prompt = buildSystemPrompt(longContext);
19
+ expect(prompt.length).toBeLessThan(200000);
20
+ expect(prompt).toContain("[Documentation truncated...]");
21
+ });
22
+
23
+ it("uses custom instruction when provided", () => {
24
+ const prompt = buildSystemPrompt("docs", "You are a search assistant.");
25
+ expect(prompt).toContain("You are a search assistant.");
26
+ expect(prompt).not.toContain("documentation assistant");
27
+ });
28
+ });
29
+
30
+ describe("getDefaultModel", () => {
31
+ it("returns gpt-4o-mini for openai", () => {
32
+ expect(getDefaultModel("openai")).toBe("gpt-4o-mini");
33
+ });
34
+
35
+ it("returns claude model for anthropic", () => {
36
+ expect(getDefaultModel("anthropic")).toContain("claude");
37
+ });
38
+
39
+ it("returns claude model for custom provider", () => {
40
+ expect(getDefaultModel("custom")).toContain("claude");
41
+ });
42
+ });
@@ -0,0 +1,205 @@
1
+ import { describe, it, expect, vi, beforeEach, beforeAll } from "vitest";
2
+ import React from "react";
3
+ import { render, screen, fireEvent, act } from "@testing-library/react";
4
+ import Shell from "../Shell.js";
5
+
6
+ // jsdom doesn't provide matchMedia
7
+ beforeAll(() => {
8
+ Object.defineProperty(window, "matchMedia", {
9
+ writable: true,
10
+ value: vi.fn().mockImplementation((query: string) => ({
11
+ matches: query.includes("dark"),
12
+ media: query,
13
+ onchange: null,
14
+ addListener: vi.fn(),
15
+ removeListener: vi.fn(),
16
+ addEventListener: vi.fn(),
17
+ removeEventListener: vi.fn(),
18
+ dispatchEvent: vi.fn(),
19
+ })),
20
+ });
21
+ });
22
+
23
+ // ── Mock the ai-api module ──────────────────────────────
24
+
25
+ vi.mock("../ai-api.js", () => ({
26
+ callAiProvider: vi.fn().mockResolvedValue("The configuration page explains how to set up your project."),
27
+ buildSystemPrompt: vi.fn().mockReturnValue("system prompt"),
28
+ getDefaultModel: vi.fn().mockReturnValue("gpt-4o-mini"),
29
+ }));
30
+
31
+ // ── Minimal Shell props ─────────────────────────────────
32
+
33
+ const baseConfig = {
34
+ name: "Test Docs",
35
+ theme: { preset: "amber", mode: "auto" },
36
+ toc: { enabled: false },
37
+ search: { provider: "local", ai: true },
38
+ ai: { enabled: true, provider: "anthropic" as const },
39
+ };
40
+
41
+ const navigation = [{
42
+ section: "Docs",
43
+ pages: [
44
+ { id: "index", title: "Introduction", urlPath: "/" },
45
+ { id: "config", title: "Configuration", urlPath: "/config" },
46
+ { id: "theming", title: "Theming", urlPath: "/theming" },
47
+ ],
48
+ }];
49
+
50
+ const docContext = [
51
+ { id: "index", title: "Introduction", content: "Welcome to the documentation." },
52
+ { id: "config", title: "Configuration", content: "Configure your site with tome.config.js." },
53
+ { id: "theming", title: "Theming", content: "Choose from 10 theme presets." },
54
+ ];
55
+
56
+ const allPages = [
57
+ { id: "index", title: "Introduction", description: "Welcome to the docs" },
58
+ { id: "config", title: "Configuration", description: "How to configure" },
59
+ { id: "theming", title: "Theming", description: "Customize your theme" },
60
+ ];
61
+
62
+ function renderShell(configOverrides = {}) {
63
+ (window as any).__TOME_AI_API_KEY__ = "test-key";
64
+
65
+ return render(
66
+ <Shell
67
+ config={{ ...baseConfig, ...configOverrides }}
68
+ navigation={navigation}
69
+ currentPageId="index"
70
+ pageHtml="<h1>Introduction</h1>"
71
+ pageTitle="Introduction"
72
+ headings={[]}
73
+ allPages={allPages}
74
+ onNavigate={() => {}}
75
+ docContext={docContext}
76
+ />
77
+ );
78
+ }
79
+
80
+ // ── Setup ────────────────────────────────────────────────
81
+
82
+ beforeEach(() => {
83
+ localStorage.clear();
84
+ (window as any).__tome = {
85
+ trackSearch: vi.fn(),
86
+ trackFeedback: vi.fn(),
87
+ };
88
+ });
89
+
90
+ // ── Tests ────────────────────────────────────────────────
91
+
92
+ describe("AI Search in SearchModal", () => {
93
+ it("opens search modal with Cmd+K", async () => {
94
+ renderShell();
95
+ await act(async () => {
96
+ fireEvent.keyDown(document, { key: "k", metaKey: true });
97
+ });
98
+ expect(screen.getByPlaceholderText("Search documentation...")).toBeInTheDocument();
99
+ });
100
+
101
+ it("shows search results for keyword queries", async () => {
102
+ renderShell();
103
+
104
+ // Open search
105
+ await act(async () => {
106
+ fireEvent.keyDown(document, { key: "k", metaKey: true });
107
+ });
108
+
109
+ const input = screen.getByPlaceholderText("Search documentation...");
110
+
111
+ // Type a query that matches a page title
112
+ await act(async () => {
113
+ fireEvent.change(input, { target: { value: "Configuration" } });
114
+ });
115
+
116
+ // Wait for debounce
117
+ await act(async () => {
118
+ await new Promise(r => setTimeout(r, 200));
119
+ });
120
+
121
+ // Should show keyword results (fallback since Pagefind not available in tests)
122
+ // "Configuration" appears in sidebar too, so check that search results area has it
123
+ expect(screen.getAllByText("Configuration").length).toBeGreaterThanOrEqual(1);
124
+ });
125
+
126
+ it("does not show AI answer card when search.ai is false", async () => {
127
+ renderShell({ search: { provider: "local", ai: false } });
128
+
129
+ await act(async () => {
130
+ fireEvent.keyDown(document, { key: "k", metaKey: true });
131
+ });
132
+
133
+ const input = screen.getByPlaceholderText("Search documentation...");
134
+ await act(async () => {
135
+ fireEvent.change(input, { target: { value: "how to configure" } });
136
+ });
137
+
138
+ await act(async () => {
139
+ await new Promise(r => setTimeout(r, 600));
140
+ });
141
+
142
+ expect(screen.queryByText("AI Answer")).not.toBeInTheDocument();
143
+ });
144
+
145
+ it("does not show AI answer for very short queries", async () => {
146
+ renderShell();
147
+
148
+ await act(async () => {
149
+ fireEvent.keyDown(document, { key: "k", metaKey: true });
150
+ });
151
+
152
+ const input = screen.getByPlaceholderText("Search documentation...");
153
+ await act(async () => {
154
+ fireEvent.change(input, { target: { value: "hi" } });
155
+ });
156
+
157
+ await act(async () => {
158
+ await new Promise(r => setTimeout(r, 600));
159
+ });
160
+
161
+ // Query too short (< 3 chars), no AI answer
162
+ expect(screen.queryByText("AI Answer")).not.toBeInTheDocument();
163
+ });
164
+
165
+ it("shows AI answer card when query is long enough and AI is enabled", async () => {
166
+ renderShell();
167
+
168
+ await act(async () => {
169
+ fireEvent.keyDown(document, { key: "k", metaKey: true });
170
+ });
171
+
172
+ const input = screen.getByPlaceholderText("Search documentation...");
173
+ await act(async () => {
174
+ fireEvent.change(input, { target: { value: "how to configure my project" } });
175
+ });
176
+
177
+ // Wait for both keyword debounce (120ms) and AI debounce (500ms)
178
+ await act(async () => {
179
+ await new Promise(r => setTimeout(r, 700));
180
+ });
181
+
182
+ // AI answer card should appear with the mocked response
183
+ expect(screen.getByText("AI Answer")).toBeInTheDocument();
184
+ expect(screen.getByText("The configuration page explains how to set up your project.")).toBeInTheDocument();
185
+ });
186
+
187
+ it("tracks search queries in analytics", async () => {
188
+ renderShell();
189
+
190
+ await act(async () => {
191
+ fireEvent.keyDown(document, { key: "k", metaKey: true });
192
+ });
193
+
194
+ const input = screen.getByPlaceholderText("Search documentation...");
195
+ await act(async () => {
196
+ fireEvent.change(input, { target: { value: "Configuration" } });
197
+ });
198
+
199
+ await act(async () => {
200
+ await new Promise(r => setTimeout(r, 200));
201
+ });
202
+
203
+ expect((window as any).__tome.trackSearch).toHaveBeenCalled();
204
+ });
205
+ });