@specglass/theme-default 0.0.10 → 0.0.11

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.
Files changed (45) hide show
  1. package/dist/__tests__/design-tokens.test.d.ts +1 -0
  2. package/dist/__tests__/design-tokens.test.js +107 -0
  3. package/dist/islands/CopyButton.js +2 -6
  4. package/dist/islands/LanguageToggle.d.ts +16 -0
  5. package/dist/islands/LanguageToggle.js +88 -0
  6. package/dist/islands/SearchPalette.js +57 -8
  7. package/dist/islands/ThemeToggle.js +1 -1
  8. package/dist/scripts/code-block-enhancer.js +6 -3
  9. package/dist/themes/notdiamond-dark.json +168 -0
  10. package/dist/themes/notdiamond-light.json +168 -0
  11. package/dist/ui/command.js +2 -2
  12. package/dist/ui/dialog.js +2 -2
  13. package/dist/utils/shiki.d.ts +1 -1
  14. package/dist/utils/shiki.js +5 -3
  15. package/package.json +5 -3
  16. package/src/components/ApiAuth.astro +31 -4
  17. package/src/components/ApiEndpoint.astro +67 -44
  18. package/src/components/ApiNavigation.astro +8 -11
  19. package/src/components/ApiParameters.astro +113 -162
  20. package/src/components/ApiResponse.astro +1 -1
  21. package/src/components/Callout.astro +59 -18
  22. package/src/components/Card.astro +4 -4
  23. package/src/components/CodeBlock.astro +7 -7
  24. package/src/components/CodeBlockGroup.astro +3 -3
  25. package/src/components/CodeExample.astro +183 -0
  26. package/src/components/EditLink.astro +53 -0
  27. package/src/components/Footer.astro +87 -25
  28. package/src/components/Header.astro +63 -7
  29. package/src/components/Sidebar.astro +43 -11
  30. package/src/components/TableOfContents.astro +5 -5
  31. package/src/components/Tabs.astro +51 -20
  32. package/src/islands/CopyButton.tsx +36 -34
  33. package/src/islands/LanguageToggle.tsx +214 -0
  34. package/src/islands/SearchPalette.tsx +121 -39
  35. package/src/islands/ThemeToggle.tsx +45 -48
  36. package/src/layouts/ApiReferencePage.astro +67 -56
  37. package/src/layouts/DocPage.astro +32 -27
  38. package/src/layouts/LandingPage.astro +348 -27
  39. package/src/scripts/code-block-enhancer.ts +8 -3
  40. package/src/styles/global.css +388 -59
  41. package/src/themes/notdiamond-dark.json +168 -0
  42. package/src/themes/notdiamond-light.json +168 -0
  43. package/src/ui/command.tsx +1 -2
  44. package/src/ui/dialog.tsx +8 -5
  45. package/src/utils/shiki.ts +5 -3
@@ -4,6 +4,7 @@
4
4
  *
5
5
  * Children must be <TabItem> components. The first tab is active by default.
6
6
  * Uses inline <script> for tab switching — NOT a React island.
7
+ * Features a smooth animated underline indicator on the active tab.
7
8
  *
8
9
  * Accessible: role="tablist", role="tab", role="tabpanel", aria-selected,
9
10
  * aria-controls, arrow key navigation.
@@ -20,7 +21,8 @@ const tabGroupId = `tabs-${Math.random().toString(36).slice(2, 9)}`;
20
21
 
21
22
  <div class="tabs-container" data-tabs-id={tabGroupId}>
22
23
  <div role="tablist" class="tabs-header" aria-label="Tab options">
23
- <!-- Tab buttons are generated client-side from TabItem children -->
24
+ <!-- Tab buttons and indicator are generated client-side from TabItem children -->
25
+ <span class="tabs-indicator" aria-hidden="true"></span>
24
26
  </div>
25
27
  <div class="tabs-content">
26
28
  <slot />
@@ -34,15 +36,29 @@ const tabGroupId = `tabs-${Math.random().toString(36).slice(2, 9)}`;
34
36
  */
35
37
  .tabs-container {
36
38
  margin: 1.5rem 0;
37
- border: 1px solid var(--tabs-border, #e5e7eb);
39
+ border: 1px solid var(--tabs-border, oklch(0.92 0.004 286.32));
38
40
  border-radius: 0.5rem;
39
41
  overflow: hidden;
40
42
  }
41
43
 
42
44
  .tabs-header {
43
45
  display: flex;
44
- border-bottom: 1px solid var(--tabs-border, #e5e7eb);
45
- background-color: var(--tabs-header-bg, #f9fafb);
46
+ position: relative;
47
+ border-bottom: 1px solid var(--tabs-border, oklch(0.92 0.004 286.32));
48
+ background-color: var(--tabs-header-bg, oklch(0.967 0.001 286.375));
49
+ }
50
+
51
+ /* Animated underline indicator */
52
+ .tabs-indicator {
53
+ position: absolute;
54
+ bottom: -1px;
55
+ height: 2px;
56
+ background-color: var(--color-primary, oklch(0.82 0.24 135));
57
+ transition:
58
+ left 150ms ease,
59
+ width 150ms ease;
60
+ pointer-events: none;
61
+ border-radius: 1px;
46
62
  }
47
63
 
48
64
  .tabs-header [role="tab"] {
@@ -52,26 +68,21 @@ const tabGroupId = `tabs-${Math.random().toString(36).slice(2, 9)}`;
52
68
  cursor: pointer;
53
69
  font-size: 0.875rem;
54
70
  font-weight: 500;
55
- color: var(--tabs-text, #6b7280);
56
- border-bottom: 2px solid transparent;
57
- transition:
58
- color 0.15s,
59
- border-color 0.15s;
71
+ color: var(--tabs-text, oklch(0.552 0.016 285.938));
72
+ transition: color 0.15s;
60
73
  white-space: nowrap;
61
74
  }
62
75
 
63
76
  .tabs-header [role="tab"]:hover {
64
- color: var(--tabs-text-hover, #374151);
77
+ color: var(--tabs-text-hover, oklch(0.374 0.02 264));
65
78
  }
66
79
 
67
80
  .tabs-header [role="tab"][aria-selected="true"] {
68
- color: var(--tabs-text-active, #111827);
69
- border-bottom-color: var(--tabs-active-border, #3b82f6);
70
- background-color: var(--tabs-active-bg, transparent);
81
+ color: var(--tabs-text-active, oklch(0.141 0.005 285.823));
71
82
  }
72
83
 
73
84
  .tabs-header [role="tab"]:focus-visible {
74
- outline: 2px solid var(--tabs-focus-ring, #3b82f6);
85
+ outline: 2px solid var(--color-primary, oklch(0.82 0.24 135));
75
86
  outline-offset: -2px;
76
87
  }
77
88
 
@@ -89,19 +100,24 @@ const tabGroupId = `tabs-${Math.random().toString(36).slice(2, 9)}`;
89
100
 
90
101
  /* Dark mode — remap CSS vars to dark surface colors */
91
102
  .dark .tabs-container {
92
- --tabs-border: oklch(0.303 0.034 264.376);
93
- --tabs-header-bg: oklch(0.179 0.04 264.376);
94
- --tabs-text: oklch(0.708 0.014 264.364);
103
+ --tabs-border: oklch(0.274 0.006 286.033);
104
+ --tabs-header-bg: oklch(0.185 0.025 265);
105
+ --tabs-text: oklch(0.705 0.015 286.067);
95
106
  --tabs-text-hover: oklch(0.85 0.01 264);
96
107
  --tabs-text-active: oklch(0.95 0.005 264);
97
108
  }
98
109
  </style>
99
110
 
100
111
  <script>
101
- document.addEventListener("DOMContentLoaded", () => {
112
+ function initTabs() {
102
113
  document.querySelectorAll<HTMLDivElement>("[data-tabs-id]").forEach((container) => {
114
+ // Skip already-initialized containers
115
+ if (container.dataset.tabsInit) return;
116
+ container.dataset.tabsInit = "true";
117
+
103
118
  const tablist = container.querySelector<HTMLDivElement>('[role="tablist"]');
104
119
  const contentArea = container.querySelector<HTMLDivElement>(".tabs-content");
120
+ const indicator = container.querySelector<HTMLSpanElement>(".tabs-indicator");
105
121
  if (!tablist || !contentArea) return;
106
122
 
107
123
  const tabItems = contentArea.querySelectorAll<HTMLDivElement>(".tab-item");
@@ -123,7 +139,7 @@ const tabGroupId = `tabs-${Math.random().toString(36).slice(2, 9)}`;
123
139
  button.setAttribute("aria-selected", index === 0 ? "true" : "false");
124
140
  button.tabIndex = index === 0 ? 0 : -1;
125
141
  button.textContent = label;
126
- tablist.appendChild(button);
142
+ tablist.insertBefore(button, indicator);
127
143
 
128
144
  // Set up panel
129
145
  item.id = panelId;
@@ -139,6 +155,14 @@ const tabGroupId = `tabs-${Math.random().toString(36).slice(2, 9)}`;
139
155
  // Tab switching
140
156
  const tabs = tablist.querySelectorAll<HTMLButtonElement>('[role="tab"]');
141
157
 
158
+ /** Move the underline indicator to the active tab */
159
+ function moveIndicator(index: number) {
160
+ if (!indicator || !tabs[index]) return;
161
+ const tab = tabs[index];
162
+ indicator.style.left = `${tab.offsetLeft}px`;
163
+ indicator.style.width = `${tab.offsetWidth}px`;
164
+ }
165
+
142
166
  function activateTab(index: number) {
143
167
  tabs.forEach((tab, i) => {
144
168
  const isActive = i === index;
@@ -148,6 +172,7 @@ const tabGroupId = `tabs-${Math.random().toString(36).slice(2, 9)}`;
148
172
  tabItems.forEach((item, i) => {
149
173
  item.classList.toggle("active", i === index);
150
174
  });
175
+ moveIndicator(index);
151
176
  }
152
177
 
153
178
  tabs.forEach((tab, index) => {
@@ -180,6 +205,12 @@ const tabGroupId = `tabs-${Math.random().toString(36).slice(2, 9)}`;
180
205
  tabs[nextIndex].focus();
181
206
  }
182
207
  });
208
+
209
+ // Initial indicator position (after DOM layout)
210
+ requestAnimationFrame(() => moveIndicator(0));
183
211
  });
184
- });
212
+ }
213
+
214
+ initTabs();
215
+ document.addEventListener("astro:after-swap", initTabs);
185
216
  </script>
@@ -44,7 +44,7 @@ export function CopyButton({ code }: CopyButtonProps) {
44
44
  onClick={handleCopy}
45
45
  aria-label={copied ? "Copied!" : "Copy code"}
46
46
  title={copied ? "Copied!" : "Copy code"}
47
- className="sg-copy-btn"
47
+ className={`sg-copy-btn${copied ? " sg-copy-btn-copied" : ""}`}
48
48
  style={{
49
49
  position: "absolute",
50
50
  top: "0.5rem",
@@ -63,39 +63,41 @@ export function CopyButton({ code }: CopyButtonProps) {
63
63
  transition: "opacity 150ms ease, background-color 150ms ease, color 150ms ease",
64
64
  }}
65
65
  >
66
- {copied ? (
67
- /* Checkmark icon */
68
- <svg
69
- xmlns="http://www.w3.org/2000/svg"
70
- width="16"
71
- height="16"
72
- viewBox="0 0 24 24"
73
- fill="none"
74
- stroke="currentColor"
75
- strokeWidth="2"
76
- strokeLinecap="round"
77
- strokeLinejoin="round"
78
- style={{ color: "#22c55e" }}
79
- >
80
- <polyline points="20 6 9 17 4 12" />
81
- </svg>
82
- ) : (
83
- /* Copy icon */
84
- <svg
85
- xmlns="http://www.w3.org/2000/svg"
86
- width="16"
87
- height="16"
88
- viewBox="0 0 24 24"
89
- fill="none"
90
- stroke="currentColor"
91
- strokeWidth="2"
92
- strokeLinecap="round"
93
- strokeLinejoin="round"
94
- >
95
- <rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
96
- <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
97
- </svg>
98
- )}
66
+ {/* Copy icon (visible by default) */}
67
+ <svg
68
+ xmlns="http://www.w3.org/2000/svg"
69
+ width="16"
70
+ height="16"
71
+ viewBox="0 0 24 24"
72
+ fill="none"
73
+ stroke="currentColor"
74
+ strokeWidth="2"
75
+ strokeLinecap="round"
76
+ strokeLinejoin="round"
77
+ className="sg-copy-icon"
78
+ aria-hidden="true"
79
+ >
80
+ <rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
81
+ <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
82
+ </svg>
83
+ {/* Checkmark icon (visible when copied) */}
84
+ <svg
85
+ xmlns="http://www.w3.org/2000/svg"
86
+ width="16"
87
+ height="16"
88
+ viewBox="0 0 24 24"
89
+ fill="none"
90
+ stroke="currentColor"
91
+ strokeWidth="2"
92
+ strokeLinecap="round"
93
+ strokeLinejoin="round"
94
+ className="sg-copy-check"
95
+ style={{ color: "#22c55e" }}
96
+ aria-hidden="true"
97
+ >
98
+ <polyline points="20 6 9 17 4 12" />
99
+ </svg>
99
100
  </button>
100
101
  );
101
102
  }
103
+
@@ -0,0 +1,214 @@
1
+ import { useState, useEffect, useCallback, useRef } from "react";
2
+ import { cn } from "../lib/utils";
3
+
4
+ /**
5
+ * LanguageToggle — Global language selector for code examples.
6
+ *
7
+ * Broadcasts the same `ndocs-language-change` CustomEvent that CodeTabs
8
+ * uses, so all code tab groups on the page (and future pages) sync to
9
+ * the selected language. Persists preference in localStorage.
10
+ *
11
+ * Designed for `client:idle` hydration in the site header.
12
+ */
13
+
14
+ // Must match CodeTabs constants
15
+ const LANGUAGE_CHANGE_EVENT = "ndocs-language-change";
16
+ const DEFAULT_SYNC_KEY = "sdk-language";
17
+ const STORAGE_KEY = `ndocs-preferred-${DEFAULT_SYNC_KEY}`;
18
+
19
+ /** A selectable language option */
20
+ interface LanguageOption {
21
+ /** Display label (e.g., "Python") */
22
+ label: string;
23
+ /** Language key matching CodeTabs (e.g., "python") */
24
+ value: string;
25
+ }
26
+
27
+ const DEFAULT_LANGUAGES: LanguageOption[] = [
28
+ { label: "Python", value: "python" },
29
+ { label: "Node.js", value: "nodejs" },
30
+ { label: "Go", value: "go" },
31
+ { label: "cURL", value: "curl" },
32
+ ];
33
+
34
+ interface LanguageToggleProps {
35
+ /** Available languages. Defaults to Python/Node.js/Go/cURL */
36
+ languages?: LanguageOption[];
37
+ /** Sync key — must match CodeTabs syncKey. Default: "sdk-language" */
38
+ syncKey?: string;
39
+ }
40
+
41
+ function LanguageToggle({
42
+ languages = DEFAULT_LANGUAGES,
43
+ syncKey = DEFAULT_SYNC_KEY,
44
+ }: LanguageToggleProps) {
45
+ const storageKey = `ndocs-preferred-${syncKey}`;
46
+ const [selected, setSelected] = useState(languages[0]?.value ?? "");
47
+ const [isOpen, setIsOpen] = useState(false);
48
+ const dropdownRef = useRef<HTMLDivElement>(null);
49
+
50
+ // Restore stored preference after hydration
51
+ useEffect(() => {
52
+ try {
53
+ const stored = localStorage.getItem(storageKey);
54
+ if (stored && languages.some((l) => l.value === stored)) {
55
+ setSelected(stored);
56
+ }
57
+ } catch { /* noop */ }
58
+ }, [storageKey, languages]);
59
+
60
+ // Listen for external language changes
61
+ useEffect(() => {
62
+ const handler = (e: Event) => {
63
+ const detail = (e as CustomEvent).detail;
64
+ if (detail?.syncKey === syncKey && languages.some((l) => l.value === detail.language)) {
65
+ setSelected(detail.language);
66
+ }
67
+ };
68
+ window.addEventListener(LANGUAGE_CHANGE_EVENT, handler);
69
+ return () => window.removeEventListener(LANGUAGE_CHANGE_EVENT, handler);
70
+ }, [syncKey, languages]);
71
+
72
+ // Close dropdown on outside click
73
+ useEffect(() => {
74
+ if (!isOpen) return;
75
+ const handler = (e: MouseEvent) => {
76
+ if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
77
+ setIsOpen(false);
78
+ }
79
+ };
80
+ document.addEventListener("mousedown", handler);
81
+ return () => document.removeEventListener("mousedown", handler);
82
+ }, [isOpen]);
83
+
84
+ // Close on Escape
85
+ useEffect(() => {
86
+ if (!isOpen) return;
87
+ const handler = (e: KeyboardEvent) => {
88
+ if (e.key === "Escape") setIsOpen(false);
89
+ };
90
+ document.addEventListener("keydown", handler);
91
+ return () => document.removeEventListener("keydown", handler);
92
+ }, [isOpen]);
93
+
94
+ const handleSelect = useCallback(
95
+ (value: string) => {
96
+ setSelected(value);
97
+ setIsOpen(false);
98
+ try {
99
+ localStorage.setItem(storageKey, value);
100
+ } catch { /* noop */ }
101
+ window.dispatchEvent(
102
+ new CustomEvent(LANGUAGE_CHANGE_EVENT, {
103
+ detail: { syncKey, language: value },
104
+ }),
105
+ );
106
+ },
107
+ [storageKey, syncKey],
108
+ );
109
+
110
+ const selectedLabel = languages.find((l) => l.value === selected)?.label ?? selected;
111
+
112
+ return (
113
+ <div ref={dropdownRef} className="relative">
114
+ <button
115
+ type="button"
116
+ onClick={() => setIsOpen(!isOpen)}
117
+ className={cn(
118
+ "inline-flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium rounded-lg",
119
+ "border border-border/50 bg-surface text-text-muted",
120
+ "hover:bg-surface-2 hover:text-text transition-colors duration-150",
121
+ "focus:outline-none focus-visible:ring-2 focus-visible:ring-primary",
122
+ )}
123
+ aria-label={`Language: ${selectedLabel}`}
124
+ aria-expanded={isOpen}
125
+ aria-haspopup="listbox"
126
+ >
127
+ <svg
128
+ xmlns="http://www.w3.org/2000/svg"
129
+ width="12"
130
+ height="12"
131
+ viewBox="0 0 24 24"
132
+ fill="none"
133
+ stroke="currentColor"
134
+ strokeWidth="2"
135
+ strokeLinecap="round"
136
+ strokeLinejoin="round"
137
+ className="shrink-0"
138
+ aria-hidden="true"
139
+ >
140
+ <polyline points="16 18 22 12 16 6" />
141
+ <polyline points="8 6 2 12 8 18" />
142
+ </svg>
143
+ <span>{selectedLabel}</span>
144
+ <svg
145
+ xmlns="http://www.w3.org/2000/svg"
146
+ width="10"
147
+ height="10"
148
+ viewBox="0 0 24 24"
149
+ fill="none"
150
+ stroke="currentColor"
151
+ strokeWidth="2.5"
152
+ strokeLinecap="round"
153
+ strokeLinejoin="round"
154
+ className={cn("shrink-0 transition-transform duration-150", isOpen && "rotate-180")}
155
+ aria-hidden="true"
156
+ >
157
+ <polyline points="6 9 12 15 18 9" />
158
+ </svg>
159
+ </button>
160
+
161
+ {isOpen && (
162
+ <div
163
+ role="listbox"
164
+ aria-label="Select language"
165
+ className={cn(
166
+ "absolute right-0 top-full mt-1.5 z-50 min-w-32",
167
+ "rounded-xl border border-border/50 bg-surface shadow-xl shadow-black/20",
168
+ "py-1 animate-in fade-in-0 zoom-in-95 duration-150",
169
+ )}
170
+ >
171
+ {languages.map((lang) => (
172
+ <button
173
+ key={lang.value}
174
+ type="button"
175
+ role="option"
176
+ aria-selected={lang.value === selected}
177
+ className={cn(
178
+ "flex items-center w-full px-3 py-1.5 text-xs text-left rounded-md mx-auto",
179
+ "hover:bg-surface-2 transition-colors duration-150",
180
+ lang.value === selected
181
+ ? "text-text font-medium"
182
+ : "text-text-muted",
183
+ )}
184
+ onClick={() => handleSelect(lang.value)}
185
+ >
186
+ {lang.value === selected && (
187
+ <svg
188
+ xmlns="http://www.w3.org/2000/svg"
189
+ width="12"
190
+ height="12"
191
+ viewBox="0 0 24 24"
192
+ fill="none"
193
+ stroke="currentColor"
194
+ strokeWidth="2.5"
195
+ strokeLinecap="round"
196
+ strokeLinejoin="round"
197
+ className="mr-2 shrink-0"
198
+ aria-hidden="true"
199
+ >
200
+ <polyline points="20 6 9 17 4 12" />
201
+ </svg>
202
+ )}
203
+ {lang.value !== selected && <span className="w-[12px] mr-2" />}
204
+ {lang.label}
205
+ </button>
206
+ ))}
207
+ </div>
208
+ )}
209
+ </div>
210
+ );
211
+ }
212
+
213
+ export { LanguageToggle };
214
+ export type { LanguageOption, LanguageToggleProps };
@@ -6,6 +6,7 @@ import {
6
6
  CommandInput,
7
7
  CommandList,
8
8
  CommandEmpty,
9
+ CommandGroup,
9
10
  CommandItem,
10
11
  } from "../ui/command";
11
12
 
@@ -50,11 +51,50 @@ interface PagefindAPI {
50
51
 
51
52
  const DEBOUNCE_MS = 150;
52
53
  const MAX_RESULTS = 8;
54
+ const MAX_RECENT = 5;
55
+ const RECENT_STORAGE_KEY = "ndocs-recent-searches";
56
+
57
+ /** Load recent searches from localStorage */
58
+ function loadRecentSearches(): string[] {
59
+ try {
60
+ const stored = localStorage.getItem(RECENT_STORAGE_KEY);
61
+ if (stored) return JSON.parse(stored);
62
+ } catch { /* noop */ }
63
+ return [];
64
+ }
65
+
66
+ /** Save a query to recent searches */
67
+ function saveRecentSearch(query: string) {
68
+ try {
69
+ const recents = loadRecentSearches().filter((q) => q !== query);
70
+ recents.unshift(query);
71
+ localStorage.setItem(
72
+ RECENT_STORAGE_KEY,
73
+ JSON.stringify(recents.slice(0, MAX_RECENT)),
74
+ );
75
+ } catch { /* noop */ }
76
+ }
77
+
78
+ /** Group results by section (first URL segment) */
79
+ function groupResults(results: PagefindResult[]): Map<string, PagefindResult[]> {
80
+ const groups = new Map<string, PagefindResult[]>();
81
+ for (const result of results) {
82
+ // Extract section from URL: /docs/getting-started → "Docs"
83
+ const segments = result.url.replace(/^\//, "").split("/");
84
+ const section = segments.length > 1
85
+ ? segments[0].charAt(0).toUpperCase() + segments[0].slice(1).replace(/-/g, " ")
86
+ : "Pages";
87
+ if (!groups.has(section)) groups.set(section, []);
88
+ groups.get(section)!.push(result);
89
+ }
90
+ return groups;
91
+ }
53
92
 
54
93
  export function SearchPalette() {
55
94
  const [open, setOpen] = useState(false);
56
95
  const [query, setQuery] = useState("");
57
96
  const [results, setResults] = useState<PagefindResult[]>([]);
97
+ const [recentSearches, setRecentSearches] = useState<string[]>([]);
58
98
  const [isLoading, setIsLoading] = useState(false);
59
99
  const [isUnavailable, setIsUnavailable] = useState(false);
60
100
  const pagefindRef = useRef<PagefindAPI | null>(null);
@@ -140,6 +180,9 @@ export function SearchPalette() {
140
180
  // Reset state when closing
141
181
  const handleOpenChange = useCallback((isOpen: boolean) => {
142
182
  setOpen(isOpen);
183
+ if (isOpen) {
184
+ setRecentSearches(loadRecentSearches());
185
+ }
143
186
  if (!isOpen) {
144
187
  setTimeout(() => {
145
188
  setQuery("");
@@ -151,10 +194,22 @@ export function SearchPalette() {
151
194
  // Navigate to a search result
152
195
  const handleSelect = useCallback(
153
196
  (url: string) => {
197
+ if (query.trim()) {
198
+ saveRecentSearch(query.trim());
199
+ }
154
200
  handleOpenChange(false);
155
201
  window.location.href = url;
156
202
  },
157
- [handleOpenChange],
203
+ [handleOpenChange, query],
204
+ );
205
+
206
+ // Handle selecting a recent search
207
+ const handleRecentSelect = useCallback(
208
+ (recentQuery: string) => {
209
+ setQuery(recentQuery);
210
+ performSearch(recentQuery);
211
+ },
212
+ [performSearch],
158
213
  );
159
214
 
160
215
  // Cleanup debounce on unmount
@@ -173,9 +228,9 @@ export function SearchPalette() {
173
228
  type="button"
174
229
  onClick={() => setOpen(true)}
175
230
  className={cn(
176
- "inline-flex items-center gap-2 px-3 py-1.5 text-sm rounded-md",
177
- "border border-border bg-surface text-text-muted",
178
- "hover:bg-hover-bg hover:text-text transition-colors",
231
+ "inline-flex items-center gap-2 px-3 py-1.5 text-[0.8125rem] rounded-lg",
232
+ "border border-border/50 bg-surface text-text-muted",
233
+ "hover:bg-surface-2 hover:text-text transition-colors",
179
234
  "focus:outline-none focus-visible:ring-2 focus-visible:ring-primary",
180
235
  )}
181
236
  aria-label="Search documentation (⌘K)"
@@ -200,8 +255,8 @@ export function SearchPalette() {
200
255
  <kbd
201
256
  className={cn(
202
257
  "hidden sm:inline-flex items-center gap-0.5",
203
- "rounded border border-border bg-surface-raised px-1.5 py-0.5",
204
- "font-mono text-[10px] text-text-muted",
258
+ "rounded border border-border/50 bg-surface-2 px-1.5 py-0.5",
259
+ "font-mono text-[0.625rem] text-text-muted",
205
260
  )}
206
261
  >
207
262
  ⌘K
@@ -241,44 +296,71 @@ export function SearchPalette() {
241
296
  </CommandEmpty>
242
297
  )}
243
298
 
244
- {/* Prompt state */}
299
+ {/* Recent searches — shown when no query */}
245
300
  {!isUnavailable && !isLoading && query.trim().length === 0 && results.length === 0 && (
246
- <div className="px-4 py-6 text-center text-sm text-text-muted">
247
- Type to search documentation…
248
- </div>
301
+ <>
302
+ {recentSearches.length > 0 ? (
303
+ <CommandGroup heading="Recent searches">
304
+ {recentSearches.map((recent) => (
305
+ <CommandItem
306
+ key={recent}
307
+ value={`recent-${recent}`}
308
+ onSelect={() => handleRecentSelect(recent)}
309
+ className="flex items-center gap-2 px-3 py-2 text-sm"
310
+ >
311
+ <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-text-muted shrink-0" aria-hidden="true">
312
+ <path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
313
+ <path d="M3 3v5h5" />
314
+ <path d="M12 7v5l4 2" />
315
+ </svg>
316
+ <span className="text-text-muted">{recent}</span>
317
+ </CommandItem>
318
+ ))}
319
+ </CommandGroup>
320
+ ) : (
321
+ <div className="px-4 py-6 text-center text-sm text-text-muted">
322
+ Type to search documentation…
323
+ </div>
324
+ )}
325
+ </>
249
326
  )}
250
327
 
251
- {/* Results */}
328
+ {/* Grouped results */}
252
329
  {!isUnavailable &&
253
- results.map((result) => (
254
- <CommandItem
255
- key={result.id}
256
- value={result.id}
257
- onSelect={() => handleSelect(result.url)}
258
- className="flex flex-col items-start gap-1 px-3 py-2.5"
259
- >
260
- {/* Page title */}
261
- <div className="font-medium text-text">
262
- {result.meta?.title ?? "Untitled"}
263
- </div>
264
-
265
- {/* Sub-result heading */}
266
- {result.sub_results?.[0]?.title &&
267
- result.sub_results[0].title !== result.meta?.title && (
268
- <div className="text-xs text-text-muted">
269
- {result.sub_results[0].title}
330
+ results.length > 0 &&
331
+ Array.from(groupResults(results)).map(([section, sectionResults]) => (
332
+ <CommandGroup key={section} heading={section}>
333
+ {sectionResults.map((result) => (
334
+ <CommandItem
335
+ key={result.id}
336
+ value={result.id}
337
+ onSelect={() => handleSelect(result.url)}
338
+ className="flex flex-col items-start gap-1 px-3 py-2.5"
339
+ >
340
+ {/* Page title */}
341
+ <div className="font-medium text-text">
342
+ {result.meta?.title ?? "Untitled"}
270
343
  </div>
271
- )}
272
-
273
- {/* Content snippet */}
274
- <div
275
- className="text-xs text-text-muted line-clamp-2 [&_mark]:bg-primary/20 [&_mark]:text-text [&_mark]:rounded-sm [&_mark]:px-0.5"
276
- dangerouslySetInnerHTML={{
277
- __html:
278
- result.sub_results?.[0]?.excerpt ?? result.excerpt ?? "",
279
- }}
280
- />
281
- </CommandItem>
344
+
345
+ {/* Sub-result heading */}
346
+ {result.sub_results?.[0]?.title &&
347
+ result.sub_results[0].title !== result.meta?.title && (
348
+ <div className="text-xs text-text-muted">
349
+ {result.sub_results[0].title}
350
+ </div>
351
+ )}
352
+
353
+ {/* Content snippet */}
354
+ <div
355
+ className="text-xs text-text-muted line-clamp-2 [&_mark]:bg-primary/20 [&_mark]:text-text [&_mark]:rounded-sm [&_mark]:px-0.5"
356
+ dangerouslySetInnerHTML={{
357
+ __html:
358
+ result.sub_results?.[0]?.excerpt ?? result.excerpt ?? "",
359
+ }}
360
+ />
361
+ </CommandItem>
362
+ ))}
363
+ </CommandGroup>
282
364
  ))}
283
365
  </CommandList>
284
366