@tomehq/theme 0.6.3 → 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
@@ -36,8 +36,6 @@ const Icon = ({ d, size = 16 }: { d: string; size?: number }) => (
36
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
37
  const ChevRight = () => <Icon d="M9 18l6-6-6-6" size={14} />;
38
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
39
  const MenuIcon = () => <Icon d="M3 12h18M3 6h18M3 18h18" size={20} />;
42
40
  const XIcon = () => <Icon d="M18 6L6 18M6 6l12 12" size={18} />;
43
41
  const MoonIcon = () => <Icon d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />;
@@ -344,11 +342,13 @@ interface ShellProps {
344
342
  config: {
345
343
  name: string;
346
344
  theme?: { preset?: string; mode?: string; accent?: string; fonts?: { heading?: string; body?: string; code?: string } };
347
- search?: { provider?: string; appId?: string; apiKey?: string; indexName?: string };
345
+ search?: { provider?: string; ai?: boolean; appId?: string; apiKey?: string; indexName?: string };
348
346
  ai?: { enabled?: boolean; provider?: "openai" | "anthropic" | "custom"; model?: string; apiKeyEnv?: string };
349
347
  toc?: { enabled?: boolean; depth?: number };
350
348
  topNav?: Array<{ label: string; href: string }>;
351
349
  banner?: { text: string; link?: string; dismissible?: boolean };
350
+ branding?: { powered?: boolean };
351
+ feedback?: { enabled?: boolean; textInput?: boolean };
352
352
  socialLinks?: Array<{ platform: string; url: string; label?: string; icon?: string }>;
353
353
  [key: string]: unknown;
354
354
  };
@@ -368,6 +368,7 @@ interface ShellProps {
368
368
  lastUpdated?: string;
369
369
  changelogEntries?: Array<{ version: string; date?: string; url?: string; sections: Array<{ type: string; items: string[] }> }>;
370
370
  apiManifest?: any;
371
+ asyncApiManifest?: any;
371
372
  apiBaseUrl?: string;
372
373
  apiPlayground?: boolean;
373
374
  apiAuth?: { type: "bearer" | "apiKey"; header?: string };
@@ -377,6 +378,7 @@ interface ShellProps {
377
378
  showPlayground?: boolean;
378
379
  playgroundAuth?: { type: "bearer" | "apiKey"; header?: string };
379
380
  }>;
381
+ AsyncApiReferenceComponent?: React.ComponentType<{ manifest: any }>;
380
382
  onNavigate: (id: string) => void;
381
383
  allPages: Array<{ id: string; title: string; description?: string }>;
382
384
  versioning?: VersioningInfo;
@@ -399,7 +401,7 @@ interface ShellProps {
399
401
  export function Shell({
400
402
  config, navigation, currentPageId, pageHtml, pageComponent, mdxComponents,
401
403
  pageTitle, pageDescription, headings, tocEnabled = true, editUrl, lastUpdated, changelogEntries,
402
- apiManifest, apiBaseUrl, apiPlayground, apiAuth, ApiReferenceComponent, onNavigate, allPages,
404
+ apiManifest, asyncApiManifest, apiBaseUrl, apiPlayground, apiAuth, ApiReferenceComponent, AsyncApiReferenceComponent, onNavigate, allPages,
403
405
  versioning, currentVersion, i18n, currentLocale, docContext, basePath = "", isDraft, dir: dirProp, overrides,
404
406
  }: ShellProps) {
405
407
  // RTL support: resolve text direction from prop, i18n.localeDirs, or default to "ltr"
@@ -423,6 +425,9 @@ export function Shell({
423
425
  const [localeDropdownOpen, setLocaleDropdown] = useState(false);
424
426
  const [zoomSrc, setZoomSrc] = useState<string | null>(null);
425
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>>({});
426
431
  const [bannerDismissed, setBannerDismissed] = useState(() => {
427
432
  if (!config.banner?.text) return true;
428
433
  try {
@@ -728,6 +733,12 @@ export function Shell({
728
733
  onNavigate={(id) => { onNavigate(id); setSearch(false); }}
729
734
  onClose={() => setSearch(false)}
730
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}
731
742
  />
732
743
  ) : null}
733
744
 
@@ -862,7 +873,7 @@ export function Shell({
862
873
  {isDark ? <SunIcon /> : <MoonIcon />}
863
874
  </button>
864
875
  ) : <div />}
865
- <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>}
866
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>
867
878
  </div>
868
879
  </aside>
@@ -1127,7 +1138,7 @@ export function Shell({
1127
1138
 
1128
1139
  {/* Content + TOC */}
1129
1140
  <div ref={contentRef} style={{ flex: 1, overflow: "auto", display: "flex" }}>
1130
- <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 }}>
1131
1142
  {breadcrumbs.length > 0 && (
1132
1143
  <nav aria-label="Breadcrumbs" data-testid="breadcrumbs" style={{
1133
1144
  display: "flex", alignItems: "center", gap: 6,
@@ -1166,9 +1177,19 @@ export function Shell({
1166
1177
  )}
1167
1178
  {pageDescription && <p style={{ fontSize: 16, color: "var(--tx2)", lineHeight: 1.6, marginBottom: 32 }}>{pageDescription}</p>}
1168
1179
  <div style={{ borderTop: "1px solid var(--bd)", paddingTop: 28 }}>
1169
- {/* TOM-19: API Reference page */}
1170
- {apiManifest && ApiReferenceComponent ? (
1171
- <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
+ </>
1172
1193
  ) : /* TOM-49: Changelog page type */
1173
1194
  changelogEntries && changelogEntries.length > 0 ? (
1174
1195
  <ChangelogView entries={changelogEntries} />
@@ -1227,21 +1248,90 @@ export function Shell({
1227
1248
  )}
1228
1249
 
1229
1250
  {/* Feedback widget */}
1230
- <div style={{ display: "flex", alignItems: "center", gap: 12, marginTop: 24, padding: "12px 0" }}>
1231
- {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] ? (
1232
1305
  <span style={{ fontSize: 13, color: "var(--txM)", fontFamily: "var(--font-body)" }}>Thanks for your feedback!</span>
1233
1306
  ) : (
1234
- <>
1307
+ <div style={{ display: "flex", alignItems: "center", gap: 12 }}>
1235
1308
  <span style={{ fontSize: 13, color: "var(--txM)", fontFamily: "var(--font-body)" }}>Was this helpful?</span>
1236
- <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={{
1237
1318
  background: "none", border: "1px solid var(--bd)", borderRadius: 2, padding: "4px 10px", cursor: "pointer", fontSize: 13, color: "var(--txM)", transition: "border-color .15s",
1238
1319
  }}>👍</button>
1239
- <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={{
1240
1329
  background: "none", border: "1px solid var(--bd)", borderRadius: 2, padding: "4px 10px", cursor: "pointer", fontSize: 13, color: "var(--txM)", transition: "border-color .15s",
1241
1330
  }}>👎</button>
1242
- </>
1331
+ </div>
1243
1332
  )}
1244
1333
  </div>
1334
+ )}
1245
1335
 
1246
1336
  {/* Prev / Next link cards */}
1247
1337
  <div style={{ display: "grid", gridTemplateColumns: mobile ? "1fr" : "1fr 1fr", marginTop: 24, paddingTop: 32, paddingBottom: 40, borderTop: "1px solid var(--bd)", gap: 16 }}>
@@ -1370,18 +1460,22 @@ interface SearchResult {
1370
1460
  }
1371
1461
 
1372
1462
  // ── SEARCH MODAL (TOM-15) ────────────────────────────────
1373
- function SearchModal({ allPages, onNavigate, onClose, mobile }: {
1463
+ function SearchModal({ allPages, onNavigate, onClose, mobile, aiSearch }: {
1374
1464
  allPages: Array<{ id: string; title: string; description?: string }>;
1375
1465
  onNavigate: (id: string) => void;
1376
1466
  onClose: () => void;
1377
1467
  mobile?: boolean;
1468
+ aiSearch?: { provider: "openai" | "anthropic" | "custom"; model?: string; apiKey?: string; context?: string };
1378
1469
  }) {
1379
1470
  const [q, setQ] = useState("");
1380
1471
  const [results, setResults] = useState<SearchResult[]>([]);
1381
1472
  const [selected, setSelected] = useState(0);
1382
1473
  const [pagefindReady, setPagefindReady] = useState<boolean | null>(null);
1474
+ const [aiAnswer, setAiAnswer] = useState<string | null>(null);
1475
+ const [aiLoading, setAiLoading] = useState(false);
1383
1476
  const inputRef = useRef<HTMLInputElement>(null);
1384
1477
  const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
1478
+ const aiDebounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
1385
1479
 
1386
1480
  // Try to initialize Pagefind on mount
1387
1481
  useEffect(() => {
@@ -1431,6 +1525,8 @@ function SearchModal({ allPages, onNavigate, onClose, mobile }: {
1431
1525
  }
1432
1526
  setResults(items);
1433
1527
  setSelected(0);
1528
+ // Track search query in analytics
1529
+ (window as any).__tome?.trackSearch(query, items.length);
1434
1530
  return;
1435
1531
  } catch {
1436
1532
  // Pagefind search failed, fall through to fallback
@@ -1438,8 +1534,11 @@ function SearchModal({ allPages, onNavigate, onClose, mobile }: {
1438
1534
  }
1439
1535
 
1440
1536
  // Fallback: client-side filtering
1441
- setResults(fallbackSearch(query));
1537
+ const fallbackResults = fallbackSearch(query);
1538
+ setResults(fallbackResults);
1442
1539
  setSelected(0);
1540
+ // Track search query in analytics
1541
+ (window as any).__tome?.trackSearch(query, fallbackResults.length);
1443
1542
  }, [fallbackSearch]);
1444
1543
 
1445
1544
  // Debounced search on query change
@@ -1449,6 +1548,42 @@ function SearchModal({ allPages, onNavigate, onClose, mobile }: {
1449
1548
  return () => { if (debounceRef.current) clearTimeout(debounceRef.current); };
1450
1549
  }, [q, doSearch]);
1451
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
+
1452
1587
  // Keyboard navigation
1453
1588
  const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
1454
1589
  if (e.key === "ArrowDown") {
@@ -1485,6 +1620,22 @@ function SearchModal({ allPages, onNavigate, onClose, mobile }: {
1485
1620
  />
1486
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>
1487
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
+ )}
1488
1639
  {results.length > 0 && <div style={{ padding: 6, maxHeight: mobile ? "none" : 360, overflow: "auto", flex: mobile ? 1 : undefined }}>
1489
1640
  {results.map((r, i) => (
1490
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
+ });