@gmickel/gno 0.8.4 → 0.8.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gmickel/gno",
3
- "version": "0.8.4",
3
+ "version": "0.8.6",
4
4
  "description": "Local semantic search for your documents. Index Markdown, PDF, and Office files with hybrid BM25 + vector search.",
5
5
  "keywords": [
6
6
  "embeddings",
@@ -54,6 +54,7 @@
54
54
  "website:dev": "cd website && make serve",
55
55
  "website:build": "cd website && make build",
56
56
  "website:demos": "cd website/demos && ./build-demos.sh",
57
+ "build:css": "bunx @tailwindcss/cli -i src/serve/public/globals.css -o src/serve/public/globals.built.css --minify",
57
58
  "serve": "bun src/index.ts serve",
58
59
  "serve:dev": "NODE_ENV=development bun --hot src/index.ts serve",
59
60
  "version:patch": "npm version patch --no-git-tag-version",
@@ -108,6 +109,7 @@
108
109
  },
109
110
  "devDependencies": {
110
111
  "@biomejs/biome": "2.3.10",
112
+ "@tailwindcss/cli": "^4.1.18",
111
113
  "@types/bun": "latest",
112
114
  "@types/react": "^19.2.7",
113
115
  "@types/react-dom": "^19.2.3",
@@ -2,6 +2,14 @@
2
2
 
3
3
  Local web server for GNO search and document browsing.
4
4
 
5
+ ## UI Development
6
+
7
+ **ALWAYS use the `frontend-design` plugin** for any UI component work. This ensures distinctive, high-quality designs that match the "Scholarly Dusk" aesthetic rather than generic AI-generated patterns.
8
+
9
+ ```
10
+ /frontend-design:frontend-design <description of component>
11
+ ```
12
+
5
13
  ## Architecture
6
14
 
7
15
  Uses same **"Ports without DI"** pattern as CLI/MCP (see root CLAUDE.md):
@@ -1,6 +1,7 @@
1
1
  import { useCallback, useEffect, useMemo, useState } from "react";
2
2
  import { createRoot } from "react-dom/client";
3
3
 
4
+ import { HelpButton } from "./components/HelpButton";
4
5
  import { ShortcutHelpModal } from "./components/ShortcutHelpModal";
5
6
  import { CaptureModalProvider } from "./hooks/useCaptureModal";
6
7
  import { useKeyboardShortcuts } from "./hooks/useKeyboardShortcuts";
@@ -57,12 +58,11 @@ function App() {
57
58
  setLocation(to);
58
59
  }, []);
59
60
 
60
- // Global keyboard shortcuts
61
+ // Global keyboard shortcuts (single-key, GitHub/Gmail pattern)
61
62
  const shortcuts = useMemo(
62
63
  () => [
63
64
  {
64
- key: "k",
65
- meta: true,
65
+ key: "/",
66
66
  action: () => {
67
67
  // Focus search input on current page or navigate to search
68
68
  const searchInput = document.querySelector<HTMLInputElement>(
@@ -77,8 +77,7 @@ function App() {
77
77
  },
78
78
  },
79
79
  {
80
- key: "/",
81
- meta: true,
80
+ key: "?",
82
81
  action: () => setShortcutHelpOpen(true),
83
82
  },
84
83
  ],
@@ -94,6 +93,7 @@ function App() {
94
93
  return (
95
94
  <CaptureModalProvider>
96
95
  <Page navigate={navigate} />
96
+ <HelpButton onClick={() => setShortcutHelpOpen(true)} />
97
97
  <ShortcutHelpModal
98
98
  onOpenChange={setShortcutHelpOpen}
99
99
  open={shortcutHelpOpen}
@@ -0,0 +1,475 @@
1
+ /**
2
+ * AIModelSelector - Vacuum tube display for LLM preset selection.
3
+ *
4
+ * Design: "Tube Display" - Evokes vintage radio tuners and oscilloscope
5
+ * selectors. The current model glows warmly in an amber display window,
6
+ * suggesting analog warmth in a digital interface.
7
+ *
8
+ * Uses Old Gold (secondary) to clearly distinguish from search/primary
9
+ * actions - this controls AI answer generation only.
10
+ */
11
+
12
+ import {
13
+ AlertCircle,
14
+ Check,
15
+ ChevronDown,
16
+ Download,
17
+ Loader2,
18
+ Sparkles,
19
+ } from "lucide-react";
20
+ import { useCallback, useEffect, useRef, useState } from "react";
21
+
22
+ import { apiFetch } from "../hooks/use-api";
23
+ import { cn } from "../lib/utils";
24
+
25
+ interface Preset {
26
+ id: string;
27
+ name: string;
28
+ embed: string;
29
+ rerank: string;
30
+ gen: string;
31
+ active: boolean;
32
+ }
33
+
34
+ interface PresetsResponse {
35
+ presets: Preset[];
36
+ activePreset: string;
37
+ capabilities: Capabilities;
38
+ }
39
+
40
+ interface Capabilities {
41
+ bm25: boolean;
42
+ vector: boolean;
43
+ hybrid: boolean;
44
+ answer: boolean;
45
+ }
46
+
47
+ interface SetPresetResponse {
48
+ success: boolean;
49
+ activePreset: string;
50
+ capabilities: Capabilities;
51
+ }
52
+
53
+ interface DownloadProgress {
54
+ downloadedBytes: number;
55
+ totalBytes: number;
56
+ percent: number;
57
+ }
58
+
59
+ interface DownloadStatus {
60
+ active: boolean;
61
+ currentType: string | null;
62
+ progress: DownloadProgress | null;
63
+ completed: string[];
64
+ failed: Array<{ type: string; error: string }>;
65
+ startedAt: number | null;
66
+ }
67
+
68
+ // Extract readable model name from preset name
69
+ const SIZE_REGEX = /~[\d.]+GB/;
70
+
71
+ function extractBaseName(name: string): string {
72
+ return name.split("(")[0].trim();
73
+ }
74
+
75
+ function extractSize(name: string): string | null {
76
+ const match = name.match(SIZE_REGEX);
77
+ return match ? match[0] : null;
78
+ }
79
+
80
+ export function AIModelSelector() {
81
+ const [presets, setPresets] = useState<Preset[]>([]);
82
+ const [activeId, setActiveId] = useState<string>("");
83
+ const [loading, setLoading] = useState(true);
84
+ const [switching, setSwitching] = useState(false);
85
+ const [error, setError] = useState<string | null>(null);
86
+ const [modelsNeeded, setModelsNeeded] = useState(false);
87
+ const [open, setOpen] = useState(false);
88
+
89
+ // Download state
90
+ const [downloading, setDownloading] = useState(false);
91
+ const [downloadStatus, setDownloadStatus] = useState<DownloadStatus | null>(
92
+ null
93
+ );
94
+ const pollInterval = useRef<ReturnType<typeof setInterval> | null>(null);
95
+ const containerRef = useRef<HTMLDivElement>(null);
96
+
97
+ // Click outside to close
98
+ useEffect(() => {
99
+ function handleClickOutside(e: MouseEvent) {
100
+ if (
101
+ containerRef.current &&
102
+ !containerRef.current.contains(e.target as Node)
103
+ ) {
104
+ setOpen(false);
105
+ }
106
+ }
107
+ document.addEventListener("mousedown", handleClickOutside);
108
+ return () => document.removeEventListener("mousedown", handleClickOutside);
109
+ }, []);
110
+
111
+ // Check capabilities
112
+ const checkCapabilities = useCallback((caps: Capabilities) => {
113
+ if (!caps.answer) {
114
+ setError("AI model not loaded");
115
+ setModelsNeeded(true);
116
+ } else {
117
+ setError(null);
118
+ setModelsNeeded(false);
119
+ }
120
+ }, []);
121
+
122
+ // Poll download status
123
+ const pollStatus = useCallback(async () => {
124
+ const { data } = await apiFetch<DownloadStatus>("/api/models/status");
125
+ if (data) {
126
+ setDownloadStatus(data);
127
+
128
+ if (!data.active && downloading) {
129
+ setDownloading(false);
130
+ if (pollInterval.current) {
131
+ clearInterval(pollInterval.current);
132
+ pollInterval.current = null;
133
+ }
134
+
135
+ // Refresh presets
136
+ const { data: presetsData } =
137
+ await apiFetch<PresetsResponse>("/api/presets");
138
+ if (presetsData) {
139
+ checkCapabilities(presetsData.capabilities);
140
+ }
141
+
142
+ if (data.failed.length > 0) {
143
+ setError(`Failed: ${data.failed.map((f) => f.type).join(", ")}`);
144
+ }
145
+ }
146
+ }
147
+ }, [downloading, checkCapabilities]);
148
+
149
+ // Initial load
150
+ useEffect(() => {
151
+ void apiFetch<PresetsResponse>("/api/presets").then(({ data }) => {
152
+ if (data) {
153
+ setPresets(data.presets);
154
+ setActiveId(data.activePreset);
155
+ checkCapabilities(data.capabilities);
156
+ }
157
+ setLoading(false);
158
+ });
159
+
160
+ void apiFetch<DownloadStatus>("/api/models/status").then(({ data }) => {
161
+ if (data?.active) {
162
+ setDownloading(true);
163
+ setDownloadStatus(data);
164
+ }
165
+ });
166
+ }, [checkCapabilities]);
167
+
168
+ // Polling
169
+ useEffect(() => {
170
+ if (downloading && !pollInterval.current) {
171
+ pollInterval.current = setInterval(pollStatus, 1000);
172
+ }
173
+ return () => {
174
+ if (pollInterval.current) {
175
+ clearInterval(pollInterval.current);
176
+ pollInterval.current = null;
177
+ }
178
+ };
179
+ }, [downloading, pollStatus]);
180
+
181
+ const activePreset = presets.find((p) => p.id === activeId);
182
+
183
+ const handleSelect = async (id: string) => {
184
+ if (id === activeId || switching || downloading) return;
185
+
186
+ setSwitching(true);
187
+ setError(null);
188
+
189
+ const { data, error: fetchError } = await apiFetch<SetPresetResponse>(
190
+ "/api/presets",
191
+ {
192
+ method: "POST",
193
+ headers: { "Content-Type": "application/json" },
194
+ body: JSON.stringify({ presetId: id }),
195
+ }
196
+ );
197
+
198
+ setSwitching(false);
199
+
200
+ if (fetchError) {
201
+ setError(fetchError);
202
+ return;
203
+ }
204
+
205
+ if (data?.success) {
206
+ setActiveId(data.activePreset);
207
+ checkCapabilities(data.capabilities);
208
+ setOpen(false);
209
+ }
210
+ };
211
+
212
+ const handleDownload = async () => {
213
+ if (downloading) return;
214
+
215
+ setDownloading(true);
216
+ setError(null);
217
+
218
+ const { error: fetchError } = await apiFetch("/api/models/pull", {
219
+ method: "POST",
220
+ });
221
+
222
+ if (fetchError) {
223
+ setError(fetchError);
224
+ setDownloading(false);
225
+ return;
226
+ }
227
+
228
+ void pollStatus();
229
+ };
230
+
231
+ // Loading skeleton
232
+ if (loading) {
233
+ return (
234
+ <div className="flex items-center gap-2">
235
+ <span className="font-mono text-[9px] uppercase tracking-widest text-muted-foreground/40">
236
+ AI Model
237
+ </span>
238
+ <div
239
+ className={cn(
240
+ "h-7 w-24 rounded",
241
+ "bg-[hsl(var(--secondary)/0.1)]",
242
+ "animate-pulse"
243
+ )}
244
+ />
245
+ </div>
246
+ );
247
+ }
248
+
249
+ if (presets.length === 0) return null;
250
+
251
+ const displayName = activePreset
252
+ ? extractBaseName(activePreset.name)
253
+ : "Select";
254
+
255
+ return (
256
+ <div className="relative" ref={containerRef}>
257
+ {/* Label */}
258
+ <div className="flex items-center gap-2">
259
+ <span className="font-mono text-[9px] uppercase tracking-widest text-muted-foreground/40">
260
+ AI Model
261
+ </span>
262
+
263
+ {/* Tube Display Button */}
264
+ <button
265
+ className={cn(
266
+ "group relative flex items-center gap-2 px-3 py-1.5",
267
+ "rounded border",
268
+ // Tube display aesthetic - warm amber glow
269
+ "border-[hsl(var(--secondary)/0.3)]",
270
+ "bg-gradient-to-b from-[hsl(var(--secondary)/0.08)] to-[hsl(var(--secondary)/0.04)]",
271
+ // Inner glow effect
272
+ "shadow-[inset_0_1px_1px_hsl(var(--secondary)/0.1),inset_0_-1px_1px_hsl(var(--background)/0.5)]",
273
+ // Hover: warm up the tube
274
+ "hover:border-[hsl(var(--secondary)/0.5)]",
275
+ "hover:bg-gradient-to-b hover:from-[hsl(var(--secondary)/0.12)] hover:to-[hsl(var(--secondary)/0.06)]",
276
+ "hover:shadow-[inset_0_1px_2px_hsl(var(--secondary)/0.15),0_0_12px_-4px_hsl(var(--secondary)/0.3)]",
277
+ // Transition
278
+ "transition-all duration-300",
279
+ // Focus
280
+ "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[hsl(var(--secondary)/0.5)]",
281
+ // States
282
+ switching && "opacity-70 pointer-events-none",
283
+ open &&
284
+ "border-[hsl(var(--secondary)/0.6)] shadow-[0_0_16px_-4px_hsl(var(--secondary)/0.4)]"
285
+ )}
286
+ disabled={switching}
287
+ onClick={() => setOpen(!open)}
288
+ type="button"
289
+ >
290
+ {/* Status indicator */}
291
+ {switching ? (
292
+ <Loader2 className="size-3 animate-spin text-[hsl(var(--secondary))]" />
293
+ ) : downloading ? (
294
+ <Loader2 className="size-3 animate-spin text-[hsl(var(--secondary)/0.7)]" />
295
+ ) : error || modelsNeeded ? (
296
+ <AlertCircle className="size-3 text-amber-500" />
297
+ ) : (
298
+ <Sparkles className="size-3 text-[hsl(var(--secondary)/0.7)] transition-colors group-hover:text-[hsl(var(--secondary))]" />
299
+ )}
300
+
301
+ {/* Model name - nixie tube style */}
302
+ <span
303
+ className={cn(
304
+ "font-mono text-xs tracking-wide",
305
+ "text-[hsl(var(--secondary)/0.9)]",
306
+ "transition-colors duration-300",
307
+ "group-hover:text-[hsl(var(--secondary))]",
308
+ // Subtle text glow on hover
309
+ "group-hover:drop-shadow-[0_0_4px_hsl(var(--secondary)/0.5)]"
310
+ )}
311
+ >
312
+ {downloading
313
+ ? "Downloading..."
314
+ : switching
315
+ ? "Loading..."
316
+ : displayName}
317
+ </span>
318
+
319
+ <ChevronDown
320
+ className={cn(
321
+ "size-3 text-[hsl(var(--secondary)/0.5)]",
322
+ "transition-transform duration-200",
323
+ open && "rotate-180"
324
+ )}
325
+ />
326
+ </button>
327
+ </div>
328
+
329
+ {/* Dropdown Panel */}
330
+ {open && (
331
+ <div
332
+ className={cn(
333
+ "absolute top-full right-0 z-50 mt-2",
334
+ "min-w-[240px] rounded-md border p-1",
335
+ // Panel styling - instrument panel aesthetic
336
+ "border-[hsl(var(--secondary)/0.2)]",
337
+ "bg-card/95 backdrop-blur-sm",
338
+ "shadow-[0_8px_32px_-8px_hsl(var(--secondary)/0.2),0_0_1px_hsl(var(--secondary)/0.1)]",
339
+ // Entrance animation
340
+ "animate-in fade-in-0 zoom-in-95 slide-in-from-top-2",
341
+ "duration-200"
342
+ )}
343
+ >
344
+ {/* Download progress */}
345
+ {downloading && downloadStatus && (
346
+ <div className="mb-2 space-y-2 rounded bg-[hsl(var(--secondary)/0.05)] p-2">
347
+ <div className="flex items-center justify-between">
348
+ <span className="font-mono text-[10px] text-muted-foreground">
349
+ {downloadStatus.currentType || "Preparing..."}
350
+ </span>
351
+ <span className="font-mono text-[10px] text-[hsl(var(--secondary)/0.7)]">
352
+ {downloadStatus.progress?.percent.toFixed(0) ?? 0}%
353
+ </span>
354
+ </div>
355
+ {/* Vintage meter bar */}
356
+ <div className="relative h-1.5 overflow-hidden rounded-full bg-muted/50">
357
+ <div
358
+ className={cn(
359
+ "absolute inset-y-0 left-0 rounded-full",
360
+ "bg-gradient-to-r from-[hsl(var(--secondary)/0.6)] to-[hsl(var(--secondary))]",
361
+ "shadow-[0_0_8px_hsl(var(--secondary)/0.5)]",
362
+ "transition-all duration-300"
363
+ )}
364
+ style={{ width: `${downloadStatus.progress?.percent ?? 0}%` }}
365
+ />
366
+ </div>
367
+ {downloadStatus.completed.length > 0 && (
368
+ <p className="font-mono text-[9px] text-muted-foreground/60">
369
+ Done: {downloadStatus.completed.join(", ")}
370
+ </p>
371
+ )}
372
+ </div>
373
+ )}
374
+
375
+ {/* Preset options */}
376
+ <div className="space-y-0.5">
377
+ {presets.map((preset) => {
378
+ const isActive = preset.id === activeId;
379
+ const baseName = extractBaseName(preset.name);
380
+ const size = extractSize(preset.name);
381
+
382
+ return (
383
+ <button
384
+ className={cn(
385
+ "group/item flex w-full items-center justify-between gap-3",
386
+ "rounded px-3 py-2.5",
387
+ "transition-all duration-150",
388
+ // Base
389
+ "text-muted-foreground",
390
+ // Hover
391
+ !isActive &&
392
+ "hover:bg-[hsl(var(--secondary)/0.08)] hover:text-foreground",
393
+ // Active state
394
+ isActive && [
395
+ "bg-[hsl(var(--secondary)/0.1)]",
396
+ "text-[hsl(var(--secondary))]",
397
+ ],
398
+ // Disabled
399
+ (switching || downloading) &&
400
+ "pointer-events-none opacity-50"
401
+ )}
402
+ disabled={switching || downloading}
403
+ key={preset.id}
404
+ onClick={() => handleSelect(preset.id)}
405
+ type="button"
406
+ >
407
+ <div className="flex flex-col items-start gap-0.5">
408
+ <span
409
+ className={cn(
410
+ "font-medium text-sm",
411
+ isActive && "text-[hsl(var(--secondary))]"
412
+ )}
413
+ >
414
+ {baseName}
415
+ </span>
416
+ {size && (
417
+ <span className="font-mono text-[10px] text-muted-foreground/60">
418
+ {size}
419
+ </span>
420
+ )}
421
+ </div>
422
+
423
+ {isActive && (
424
+ <Check className="size-4 text-[hsl(var(--secondary))]" />
425
+ )}
426
+ </button>
427
+ );
428
+ })}
429
+ </div>
430
+
431
+ {/* Error / Download prompt */}
432
+ {(error || modelsNeeded) && !downloading && (
433
+ <>
434
+ <div className="my-1 border-t border-border/50" />
435
+ <div className="space-y-2 p-2">
436
+ {error && (
437
+ <p className="font-mono text-[10px] text-amber-500">
438
+ {error}
439
+ </p>
440
+ )}
441
+ {modelsNeeded && (
442
+ <button
443
+ className={cn(
444
+ "flex w-full items-center justify-center gap-2",
445
+ "rounded border px-3 py-2",
446
+ "border-[hsl(var(--secondary)/0.3)]",
447
+ "bg-[hsl(var(--secondary)/0.05)]",
448
+ "font-medium text-[hsl(var(--secondary))] text-xs",
449
+ "transition-all duration-200",
450
+ "hover:border-[hsl(var(--secondary)/0.5)]",
451
+ "hover:bg-[hsl(var(--secondary)/0.1)]",
452
+ "hover:shadow-[0_0_12px_-4px_hsl(var(--secondary)/0.3)]"
453
+ )}
454
+ onClick={handleDownload}
455
+ type="button"
456
+ >
457
+ <Download className="size-3.5" />
458
+ Download Models
459
+ </button>
460
+ )}
461
+ </div>
462
+ </>
463
+ )}
464
+
465
+ {/* Footer note */}
466
+ <div className="mt-1 border-t border-border/30 px-3 py-2">
467
+ <p className="font-mono text-[9px] text-muted-foreground/50">
468
+ Controls AI answer generation only
469
+ </p>
470
+ </div>
471
+ </div>
472
+ )}
473
+ </div>
474
+ );
475
+ }
@@ -6,12 +6,11 @@
6
6
  * - Triggers parent's onClick handler
7
7
  * - Subtle hover animation
8
8
  *
9
- * Note: Modal and Cmd+N shortcut are managed at App level for single instance.
9
+ * Note: Modal and 'n' shortcut are managed at App level for single instance.
10
10
  */
11
11
 
12
12
  import { PenIcon } from "lucide-react";
13
13
 
14
- import { modKey } from "../hooks/useKeyboardShortcuts";
15
14
  import { cn } from "../lib/utils";
16
15
  import { Button } from "./ui/button";
17
16
  import {
@@ -48,11 +47,11 @@ export function CaptureButton({ className, onClick }: CaptureButtonProps) {
48
47
  size="icon"
49
48
  >
50
49
  <PenIcon className="size-6 transition-transform duration-200 group-hover:rotate-[-8deg]" />
51
- <span className="sr-only">New note ({modKey}+N)</span>
50
+ <span className="sr-only">New note (N)</span>
52
51
  </Button>
53
52
  </TooltipTrigger>
54
53
  <TooltipContent side="left">
55
- <p>New note ({modKey}N)</p>
54
+ <p>New note (N)</p>
56
55
  </TooltipContent>
57
56
  </Tooltip>
58
57
  </TooltipProvider>
@@ -0,0 +1,105 @@
1
+ /**
2
+ * HelpButton - A scholar's reference mark for keyboard shortcuts.
3
+ *
4
+ * Design: "Marginalia" - Like a faded notation in an old manuscript
5
+ * that reveals itself when the reader's attention draws near.
6
+ *
7
+ * Uses Old Gold (secondary) to distinguish from primary actions,
8
+ * evoking warm candlelight on aged paper.
9
+ */
10
+
11
+ import { cn } from "../lib/utils";
12
+ import {
13
+ Tooltip,
14
+ TooltipContent,
15
+ TooltipProvider,
16
+ TooltipTrigger,
17
+ } from "./ui/tooltip";
18
+
19
+ export interface HelpButtonProps {
20
+ /** Additional CSS classes */
21
+ className?: string;
22
+ /** Callback when button clicked */
23
+ onClick: () => void;
24
+ }
25
+
26
+ export function HelpButton({ className, onClick }: HelpButtonProps) {
27
+ return (
28
+ <TooltipProvider delayDuration={400}>
29
+ <Tooltip>
30
+ <TooltipTrigger asChild>
31
+ <button
32
+ aria-label="Keyboard shortcuts"
33
+ className={cn(
34
+ // Position: margin notation, slightly inset
35
+ "group fixed left-4 bottom-4 z-50",
36
+ // Size: small, refined - like a superscript
37
+ "flex size-7 items-center justify-center",
38
+ // Base state: faded marginalia
39
+ "rounded-sm border border-transparent",
40
+ "bg-transparent text-muted-foreground/30",
41
+ // The reference mark itself - serif typography
42
+ "font-serif text-sm italic",
43
+ // Hover: ink freshens, warm gold emerges
44
+ "hover:text-[hsl(var(--secondary))]",
45
+ "hover:border-[hsl(var(--secondary)/0.2)]",
46
+ "hover:bg-[hsl(var(--secondary)/0.05)]",
47
+ // Subtle ink-bleed glow on hover
48
+ "hover:shadow-[0_0_12px_-4px_hsl(var(--secondary)/0.4)]",
49
+ // Refined transition - slow reveal like turning a page
50
+ "transition-all duration-500 ease-out",
51
+ // Focus: accessible but subtle
52
+ "focus-visible:text-[hsl(var(--secondary))]",
53
+ "focus-visible:outline-none focus-visible:ring-1",
54
+ "focus-visible:ring-[hsl(var(--secondary)/0.5)]",
55
+ "focus-visible:ring-offset-1 focus-visible:ring-offset-background",
56
+ // Cursor
57
+ "cursor-pointer",
58
+ className
59
+ )}
60
+ onClick={onClick}
61
+ type="button"
62
+ >
63
+ {/* The mark: a serif question mark, styled like manuscript notation */}
64
+ <span
65
+ className={cn(
66
+ "select-none",
67
+ // Subtle lift on hover
68
+ "transition-transform duration-300",
69
+ "group-hover:-translate-y-px group-hover:scale-105"
70
+ )}
71
+ >
72
+ ?
73
+ </span>
74
+ </button>
75
+ </TooltipTrigger>
76
+ <TooltipContent
77
+ className={cn(
78
+ // Scholarly tooltip styling
79
+ "border-[hsl(var(--secondary)/0.2)] bg-card/95",
80
+ "shadow-[0_4px_20px_-4px_hsl(var(--secondary)/0.15)]",
81
+ "backdrop-blur-sm"
82
+ )}
83
+ side="right"
84
+ sideOffset={8}
85
+ >
86
+ <p className="flex items-center gap-2.5 font-sans text-sm">
87
+ <span className="text-muted-foreground">Shortcuts</span>
88
+ <kbd
89
+ className={cn(
90
+ "inline-flex min-w-[1.25rem] items-center justify-center",
91
+ "rounded border px-1.5 py-0.5",
92
+ "border-[hsl(var(--secondary)/0.3)] bg-[hsl(var(--secondary)/0.1)]",
93
+ "font-serif text-[11px] italic text-[hsl(var(--secondary))]",
94
+ // Subtle pressed effect
95
+ "shadow-[inset_0_1px_2px_hsl(var(--background)/0.3)]"
96
+ )}
97
+ >
98
+ ?
99
+ </kbd>
100
+ </p>
101
+ </TooltipContent>
102
+ </Tooltip>
103
+ </TooltipProvider>
104
+ );
105
+ }