@farming-labs/nuxt-theme 0.2.38 → 0.2.39

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": "@farming-labs/nuxt-theme",
3
- "version": "0.2.38",
3
+ "version": "0.2.39",
4
4
  "description": "Nuxt/Vue UI components for @farming-labs/docs — layout, sidebar, TOC, search, and theme toggle",
5
5
  "keywords": [
6
6
  "docs",
@@ -88,7 +88,7 @@
88
88
  },
89
89
  "dependencies": {
90
90
  "sugar-high": "^0.9.5",
91
- "@farming-labs/docs": "0.2.38"
91
+ "@farming-labs/docs": "0.2.39"
92
92
  },
93
93
  "peerDependencies": {
94
94
  "nuxt": ">=3.0.0",
@@ -3,23 +3,41 @@
3
3
  * Omni command palette — same behavior as website/components/ui/omni-command-palette.tsx
4
4
  * and Astro SearchDialog: recents when empty, /api/docs search, keyboard nav, click to navigate.
5
5
  */
6
- import { ref, computed, watch, onMounted, nextTick } from "vue";
6
+ import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from "vue";
7
7
  import { navigateTo } from "#app";
8
8
  import { useRoute } from "vue-router";
9
9
 
10
10
  const STORAGE_KEY = "fd:omni:recents";
11
11
  const MAX_RECENTS = 8;
12
12
  const DEBOUNCE_MS = 150;
13
+ const BREADCRUMB_SEPARATOR = "\u00a0\u00a0>\u00a0\u00a0";
14
+ const FILTER_LABELS = {
15
+ all: "All",
16
+ pages: "Pages",
17
+ inside: "Inside pages",
18
+ } as const;
19
+ const FILTER_OPTIONS = ["all", "pages", "inside"] as const;
13
20
 
14
21
  const emit = defineEmits<{ (e: "close"): void }>();
15
22
  const route = useRoute();
16
23
 
24
+ type SearchFilter = keyof typeof FILTER_LABELS;
25
+
17
26
  const query = ref("");
18
- const currentResults = ref<{ content: string; url: string; description?: string }[]>([]);
27
+ const currentResults = ref<
28
+ { content: string; url: string; description?: string; section?: string; type?: string }[]
29
+ >([]);
19
30
  const loading = ref(false);
20
31
  const activeIndex = ref(0);
32
+ const filter = ref<SearchFilter>("all");
33
+ const filterOpen = ref(false);
21
34
  const inputEl = ref<HTMLInputElement | null>(null);
22
35
  let debounceTimer: ReturnType<typeof setTimeout> | null = null;
36
+ let abortController: AbortController | null = null;
37
+ const searchCache = new Map<
38
+ string,
39
+ { content: string; url: string; description?: string; section?: string; type?: string }[]
40
+ >();
23
41
 
24
42
  interface RecentEntry {
25
43
  id: string;
@@ -60,20 +78,174 @@ function withLang(url: string): string {
60
78
  }
61
79
  }
62
80
 
81
+ function breadcrumbForUrl(url: string): string {
82
+ try {
83
+ const parsed = new URL(url, "https://farming-labs.local");
84
+ const parts = parsed.pathname
85
+ .split("/")
86
+ .filter(Boolean)
87
+ .map((part) =>
88
+ decodeURIComponent(part)
89
+ .replace(/[-_]+/g, " ")
90
+ .replace(/\b\w/g, (char) => char.toUpperCase()),
91
+ );
92
+ return parts.length ? parts.join(BREADCRUMB_SEPARATOR) : "Docs";
93
+ } catch {
94
+ return "Docs";
95
+ }
96
+ }
97
+
98
+ function displayLabelForResult(result: { content: string; section?: string; type?: string }): string {
99
+ if (result.section) return result.section;
100
+ const parts = result.content.split(/\s+[—–]\s+/).map((part) => part.trim()).filter(Boolean);
101
+ return result.type === "heading" && parts.length > 1 ? (parts[parts.length - 1] ?? result.content) : result.content;
102
+ }
103
+
104
+ function normalizeSearchPhrase(value?: string): string {
105
+ return (value ?? "").toLowerCase().replace(/[?!.,;:]+$/g, "").replace(/\s+/g, " ").trim();
106
+ }
107
+
108
+ function escapeRegExp(value: string): string {
109
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
110
+ }
111
+
112
+ function literalMatchPriority(searchQuery: string, value?: string): number {
113
+ const q = normalizeSearchPhrase(searchQuery);
114
+ const text = normalizeSearchPhrase(value);
115
+ if (!q || !text) return 0;
116
+ if (text === q) return 2;
117
+
118
+ const boundary = "[^\\p{L}\\p{N}]";
119
+ return new RegExp(`(^|${boundary})${escapeRegExp(q)}(?=$|${boundary})`, "u").test(text)
120
+ ? 1
121
+ : 0;
122
+ }
123
+
124
+ function tokenizeLiteralQuery(searchQuery: string): string[] {
125
+ return Array.from(
126
+ new Set(
127
+ searchQuery
128
+ .toLowerCase()
129
+ .replace(/[^\p{L}\p{N}@/_:.-]+/gu, " ")
130
+ .split(/\s+/)
131
+ .map((word) => word.replace(/^[^\p{L}\p{N}@]+|[^\p{L}\p{N}]+$/gu, ""))
132
+ .filter((word) => word.length > 1),
133
+ ),
134
+ );
135
+ }
136
+
137
+ function isLiteralLookupQuery(searchQuery: string): boolean {
138
+ const q = normalizeSearchPhrase(searchQuery);
139
+ const words = tokenizeLiteralQuery(q);
140
+ return words.length > 0 && words.length <= 3 && words.join(" ") === q;
141
+ }
142
+
143
+ function hasDistinctSearchSection(result: {
144
+ content: string;
145
+ section?: string;
146
+ type?: string;
147
+ }): boolean {
148
+ if (result.type === "page") return false;
149
+ if (!result.section) return true;
150
+ const title = (result.content ?? "").split(/\s+[—–]\s+/)[0] ?? "";
151
+ return normalizeSearchPhrase(result.section) !== normalizeSearchPhrase(title);
152
+ }
153
+
154
+ function insideLiteralPriority(
155
+ result: { content: string; description?: string; section?: string; type?: string },
156
+ searchQuery: string,
157
+ ): number {
158
+ if (!hasDistinctSearchSection(result) || !isLiteralLookupQuery(searchQuery)) return 0;
159
+ return Math.max(
160
+ literalMatchPriority(searchQuery, result.section),
161
+ literalMatchPriority(searchQuery, result.description),
162
+ );
163
+ }
164
+
165
+ function sortResultsForFilter(
166
+ results: { content: string; url: string; description?: string; section?: string; type?: string; score?: number }[],
167
+ ) {
168
+ const q = query.value.trim();
169
+ if (!q) return results;
170
+ return results
171
+ .map((result, index) => ({ result, index }))
172
+ .sort((a, b) => {
173
+ const aLiteralPriority = insideLiteralPriority(a.result, q);
174
+ const bLiteralPriority = insideLiteralPriority(b.result, q);
175
+ const literalDelta = bLiteralPriority - aLiteralPriority;
176
+ if (literalDelta) return literalDelta;
177
+ if (aLiteralPriority > 0 && bLiteralPriority > 0) return a.index - b.index;
178
+
179
+ const scoreDelta = (b.result.score ?? 0) - (a.result.score ?? 0);
180
+ if (scoreDelta) return scoreDelta;
181
+
182
+ return a.index - b.index;
183
+ })
184
+ .map(({ result }) => result);
185
+ }
186
+
187
+ function escapeHtml(value: string): string {
188
+ return value.replace(/[&<>"']/g, (char) => {
189
+ if (char === "&") return "&amp;";
190
+ if (char === "<") return "&lt;";
191
+ if (char === ">") return "&gt;";
192
+ if (char === '"') return "&quot;";
193
+ return "&#39;";
194
+ });
195
+ }
196
+
197
+ function highlightSnippet(text: string): string {
198
+ const q = query.value.trim();
199
+ if (!q) return escapeHtml(text);
200
+ let html = "";
201
+ let lastIndex = 0;
202
+ const regex = new RegExp(escapeRegExp(q), "gi");
203
+ let match: RegExpExecArray | null;
204
+
205
+ while ((match = regex.exec(text)) !== null) {
206
+ html += escapeHtml(text.slice(lastIndex, match.index));
207
+ html += `<mark class="omni-highlight">${escapeHtml(match[0])}</mark>`;
208
+ lastIndex = match.index + match[0].length;
209
+ }
210
+
211
+ return html + escapeHtml(text.slice(lastIndex));
212
+ }
213
+
214
+ const visibleResults = computed(() => {
215
+ if (filter.value === "pages") return currentResults.value.filter((result) => result.type === "page");
216
+ if (filter.value === "inside") {
217
+ return sortResultsForFilter(currentResults.value.filter((result) => result.type !== "page"));
218
+ }
219
+ return sortResultsForFilter(currentResults.value);
220
+ });
221
+
63
222
  const allItems = computed(() => {
64
223
  const q = query.value.trim();
65
- if (q && currentResults.value.length) return currentResults.value.map((r) => ({ id: r.url, label: r.content, url: r.url, subtitle: r.description ?? "Page" }));
224
+ if (q && visibleResults.value.length) {
225
+ return visibleResults.value.map((r) => ({
226
+ id: r.url,
227
+ label: displayLabelForResult(r),
228
+ url: r.url,
229
+ subtitle: breadcrumbForUrl(r.url),
230
+ description: r.description,
231
+ descriptionHtml: r.description ? highlightSnippet(r.description) : "",
232
+ }));
233
+ }
66
234
  return recentsList.value.map((r) => ({ id: r.id, label: r.label, url: r.url, subtitle: "Recently viewed" }));
67
235
  });
68
236
 
69
237
  const showRecents = computed(() => !query.value.trim());
70
- const showDocs = computed(() => !!query.value.trim() && currentResults.value.length > 0);
238
+ const showDocs = computed(() => !!query.value.trim() && visibleResults.value.length > 0);
71
239
  const showEmpty = computed(() => {
72
- if (query.value.trim()) return currentResults.value.length === 0 && !loading.value;
240
+ if (query.value.trim()) return visibleResults.value.length === 0 && !loading.value;
73
241
  return recentsList.value.length === 0;
74
242
  });
75
243
  const emptyText = computed(() =>
76
- query.value.trim() ? "No results found. Try a different query." : "Type to search the docs, or browse recent items.",
244
+ query.value.trim()
245
+ ? currentResults.value.length > 0
246
+ ? `No ${FILTER_LABELS[filter.value].toLowerCase()} results found.`
247
+ : "No results found. Try a different query."
248
+ : "Type to search the docs, or browse recent items.",
77
249
  );
78
250
 
79
251
  function loadRecents() {
@@ -85,6 +257,12 @@ function close() {
85
257
  emit("close");
86
258
  }
87
259
 
260
+ function updateFilter(nextFilter: SearchFilter) {
261
+ filter.value = nextFilter;
262
+ filterOpen.value = false;
263
+ activeIndex.value = 0;
264
+ }
265
+
88
266
  function executeItem(item: { url: string; label?: string; content?: string }) {
89
267
  const label = item.label ?? item.content ?? item.url;
90
268
  const localizedUrl = withLang(item.url);
@@ -130,24 +308,51 @@ function onInput() {
130
308
  loading.value = false;
131
309
  currentResults.value = [];
132
310
  activeIndex.value = 0;
311
+ filterOpen.value = false;
312
+ abortController?.abort();
133
313
  if (!q) return;
134
314
  if (debounceTimer) clearTimeout(debounceTimer);
315
+ const requestUrl = withLang(`/api/docs?query=${encodeURIComponent(q)}`);
316
+ const cached = searchCache.get(requestUrl);
317
+ if (cached) {
318
+ currentResults.value = cached;
319
+ return;
320
+ }
135
321
  debounceTimer = setTimeout(async () => {
322
+ const controller = new AbortController();
323
+ abortController = controller;
136
324
  loading.value = true;
137
325
  try {
138
- const res = await fetch(withLang(`/api/docs?query=${encodeURIComponent(q)}`));
326
+ const res = await fetch(requestUrl, { signal: controller.signal });
139
327
  const data = res.ok ? await res.json() : [];
140
- currentResults.value = Array.isArray(data) ? data : [];
328
+ if (controller.signal.aborted) return;
329
+ const nextResults = Array.isArray(data) ? data : [];
330
+ if (searchCache.size >= 20) {
331
+ const firstKey = searchCache.keys().next().value;
332
+ if (firstKey) searchCache.delete(firstKey);
333
+ }
334
+ searchCache.set(requestUrl, nextResults);
335
+ currentResults.value = nextResults;
141
336
  activeIndex.value = 0;
142
337
  } catch {
338
+ if (controller.signal.aborted) return;
143
339
  currentResults.value = [];
144
340
  } finally {
145
- loading.value = false;
341
+ if (abortController === controller) {
342
+ abortController = null;
343
+ loading.value = false;
344
+ }
146
345
  }
147
346
  }, DEBOUNCE_MS);
148
347
  }
149
348
 
150
349
  watch(query, onInput);
350
+ watch(filter, () => {
351
+ activeIndex.value = 0;
352
+ });
353
+ watch(visibleResults, () => {
354
+ if (activeIndex.value >= visibleResults.value.length) activeIndex.value = 0;
355
+ });
151
356
 
152
357
  function handleKeydown(e: KeyboardEvent) {
153
358
  if (e.key === "Escape") {
@@ -183,16 +388,6 @@ function onRowClick(item: { url: string; label?: string; content?: string }) {
183
388
  executeItem(item);
184
389
  }
185
390
 
186
- function onExternalClick(e: Event, url: string) {
187
- e.preventDefault();
188
- e.stopPropagation();
189
- try {
190
- window.open(withLang(url), "_blank", "noopener,noreferrer");
191
- } catch {
192
- window.location.href = withLang(url);
193
- }
194
- }
195
-
196
391
  function onRowMouseEnter(container: "recent" | "docs", index: number) {
197
392
  activeIndex.value = index;
198
393
  }
@@ -204,6 +399,12 @@ onMounted(() => {
204
399
  inputEl.value?.focus();
205
400
  });
206
401
  });
402
+
403
+ onBeforeUnmount(() => {
404
+ if (typeof document !== "undefined") document.body.style.overflow = "";
405
+ if (debounceTimer) clearTimeout(debounceTimer);
406
+ abortController?.abort();
407
+ });
207
408
  </script>
208
409
 
209
410
  <template>
@@ -230,16 +431,12 @@ onMounted(() => {
230
431
  role="combobox"
231
432
  aria-expanded="true"
232
433
  aria-controls="fd-omni-listbox"
233
- placeholder="Search documentation…"
434
+ placeholder="Search"
234
435
  autocomplete="off"
235
436
  @keydown="handleKeydown"
236
437
  />
237
- <kbd class="omni-kbd">⌘ K</kbd>
238
438
  <button type="button" aria-label="Close" class="omni-close-btn" @click="close">
239
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
240
- <path d="M18 6 6 18" />
241
- <path d="m6 6 12 12" />
242
- </svg>
439
+ ESC
243
440
  </button>
244
441
  </div>
245
442
  </div>
@@ -267,35 +464,10 @@ onMounted(() => {
267
464
  @click="onRowClick(r)"
268
465
  @mouseenter="onRowMouseEnter('recent', i)"
269
466
  >
270
- <div class="omni-item-icon">
271
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
272
- <path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
273
- <path d="M14 2v4a2 2 0 0 0 2 2h4" />
274
- </svg>
275
- </div>
276
467
  <div class="omni-item-text">
277
- <div class="omni-item-label">{{ r.label }}</div>
278
468
  <div class="omni-item-subtitle">Recently viewed</div>
469
+ <div class="omni-item-label">{{ r.label }}</div>
279
470
  </div>
280
- <a
281
- :href="r.url"
282
- class="omni-item-ext"
283
- title="Open in new tab"
284
- target="_blank"
285
- rel="noopener noreferrer"
286
- @click.prevent="onExternalClick($event, r.url)"
287
- >
288
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
289
- <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
290
- <polyline points="15 3 21 3 21 9" />
291
- <line x1="10" y1="14" x2="21" y2="3" />
292
- </svg>
293
- </a>
294
- <span class="omni-item-chevron" aria-hidden="true">
295
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
296
- <polyline points="9 18 15 12 9 6" />
297
- </svg>
298
- </span>
299
471
  </div>
300
472
  </div>
301
473
  </div>
@@ -304,8 +476,8 @@ onMounted(() => {
304
476
  <div class="omni-group-label">Documentation</div>
305
477
  <div id="fd-omni-docs-items" class="omni-group-items">
306
478
  <div
307
- v-for="(r, i) in currentResults"
308
- :key="r.url"
479
+ v-for="(r, i) in allItems"
480
+ :key="r.id"
309
481
  class="omni-item"
310
482
  :class="{ 'omni-item-active': showDocs && i === activeIndex }"
311
483
  :data-url="r.url"
@@ -315,35 +487,15 @@ onMounted(() => {
315
487
  @click="onRowClick(r)"
316
488
  @mouseenter="onRowMouseEnter('docs', i)"
317
489
  >
318
- <div class="omni-item-icon">
319
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
320
- <path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
321
- <path d="M14 2v4a2 2 0 0 0 2 2h4" />
322
- </svg>
323
- </div>
324
490
  <div class="omni-item-text">
325
- <div class="omni-item-label">{{ r.content }}</div>
326
- <div class="omni-item-subtitle">{{ r.description ?? "Page" }}</div>
491
+ <div class="omni-item-subtitle">{{ r.subtitle }}</div>
492
+ <div class="omni-item-label">{{ r.label }}</div>
493
+ <div
494
+ v-if="r.description"
495
+ class="omni-item-description"
496
+ v-html="r.descriptionHtml"
497
+ />
327
498
  </div>
328
- <a
329
- :href="r.url"
330
- class="omni-item-ext"
331
- title="Open in new tab"
332
- target="_blank"
333
- rel="noopener noreferrer"
334
- @click.prevent="onExternalClick($event, r.url)"
335
- >
336
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
337
- <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
338
- <polyline points="15 3 21 3 21 9" />
339
- <line x1="10" y1="14" x2="21" y2="3" />
340
- </svg>
341
- </a>
342
- <span class="omni-item-chevron" aria-hidden="true">
343
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
344
- <polyline points="9 18 15 12 9 6" />
345
- </svg>
346
- </span>
347
499
  </div>
348
500
  </div>
349
501
  </div>
@@ -363,28 +515,53 @@ onMounted(() => {
363
515
  <div class="omni-footer-inner">
364
516
  <div class="omni-footer-hints">
365
517
  <span class="omni-footer-hint">
366
- <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
367
- <polyline points="9 18 15 12 9 6" />
518
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
519
+ <path d="m9 10-5 5 5 5" />
520
+ <path d="M20 4v7a4 4 0 0 1-4 4H4" />
368
521
  </svg>
369
522
  to select
370
523
  </span>
371
524
  <span class="omni-footer-hint">
372
- <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
373
- <path d="M18 15l-6-6-6 6" />
525
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
526
+ <path d="m18 15-6-6-6 6" />
374
527
  </svg>
375
- <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
376
- <path d="M6 9l6 6 6-6" />
528
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
529
+ <path d="m6 9 6 6 6-6" />
377
530
  </svg>
378
531
  to navigate
379
532
  </span>
380
533
  <span class="omni-footer-hint omni-footer-hint-desktop">
381
- <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
382
- <path d="M18 6 6 18" />
383
- <path d="m6 6 12 12" />
384
- </svg>
534
+ <span class="omni-kbd-sm">ESC</span>
385
535
  to close
386
536
  </span>
387
537
  </div>
538
+ <div class="omni-footer-filter">
539
+ <span class="omni-filter-label">Filter</span>
540
+ <button
541
+ type="button"
542
+ class="omni-filter-button"
543
+ :aria-expanded="filterOpen"
544
+ @click="filterOpen = !filterOpen"
545
+ >
546
+ {{ FILTER_LABELS[filter] }}
547
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
548
+ <path d="M6 9l6 6 6-6" />
549
+ </svg>
550
+ </button>
551
+ <div v-if="filterOpen" class="omni-filter-menu" role="group" aria-label="Search filter">
552
+ <button
553
+ v-for="option in FILTER_OPTIONS"
554
+ :key="option"
555
+ type="button"
556
+ :aria-pressed="filter === option"
557
+ class="omni-filter-option"
558
+ :class="{ 'omni-filter-option-active': filter === option }"
559
+ @click="updateFilter(option)"
560
+ >
561
+ {{ FILTER_LABELS[option] }}
562
+ </button>
563
+ </div>
564
+ </div>
388
565
  </div>
389
566
  </div>
390
567
  </div>
@@ -605,6 +605,7 @@ figure.shiki:has(figcaption) > div:first-child,
605
605
  }
606
606
 
607
607
  .omni-group-label {
608
+ display: block !important;
608
609
  letter-spacing: 0.14em;
609
610
  color: color-mix(in srgb, var(--color-fd-foreground) 72%, transparent) !important;
610
611
  }
@@ -605,6 +605,7 @@ figure.shiki:has(figcaption) > div:first-child,
605
605
  }
606
606
 
607
607
  .omni-group-label {
608
+ display: block !important;
608
609
  letter-spacing: 0.14em;
609
610
  color: color-mix(in srgb, var(--color-fd-foreground) 72%, transparent) !important;
610
611
  }
package/styles/omni.css CHANGED
@@ -1,20 +1,28 @@
1
1
  /* ═══════════════════════════════════════════════════════════════════
2
- * Omni Command Palette — core styles (clone of website/components/ui/omni-command-palette)
3
- * Uses same CSS variables as docs so it auto-adapts to every theme.
2
+ * Omni Command Palette — core styles
3
+ * Uses fumadocs CSS variables so it auto-adapts to every theme.
4
4
  * ═══════════════════════════════════════════════════════════════════ */
5
5
 
6
6
  @keyframes omni-fade-in {
7
- from { opacity: 0; }
8
- to { opacity: 1; }
7
+ from {
8
+ opacity: 0;
9
+ }
10
+ to {
11
+ opacity: 1;
12
+ }
9
13
  }
10
14
  @keyframes omni-fade-out {
11
- from { opacity: 1; }
12
- to { opacity: 0; }
15
+ from {
16
+ opacity: 1;
17
+ }
18
+ to {
19
+ opacity: 0;
20
+ }
13
21
  }
14
22
  @keyframes omni-scale-in {
15
23
  from {
16
24
  opacity: 0;
17
- transform: translateX(-50%) scale(0.96) translateY(-4px);
25
+ transform: translateX(-50%) scale(0.98) translateY(-6px);
18
26
  }
19
27
  to {
20
28
  opacity: 1;
@@ -28,13 +36,19 @@
28
36
  }
29
37
  to {
30
38
  opacity: 0;
31
- transform: translateX(-50%) scale(0.96) translateY(-4px);
39
+ transform: translateX(-50%) scale(0.98) translateY(-6px);
32
40
  }
33
41
  }
42
+
34
43
  @keyframes omni-spin {
35
- from { transform: rotate(0deg); }
36
- to { transform: rotate(360deg); }
44
+ from {
45
+ transform: rotate(0deg);
46
+ }
47
+ to {
48
+ transform: rotate(360deg);
49
+ }
37
50
  }
51
+
38
52
  .omni-spin {
39
53
  animation: omni-spin 1s linear infinite;
40
54
  }
@@ -42,38 +56,44 @@
42
56
  .omni-overlay {
43
57
  position: fixed;
44
58
  inset: 0;
45
- z-index: 100;
46
- background: rgba(0, 0, 0, 0.55);
47
- backdrop-filter: blur(4px);
59
+ z-index: 50;
60
+ background: rgba(0, 0, 0, 0.2);
61
+ backdrop-filter: blur(2px);
48
62
  animation: omni-fade-in 150ms ease-out;
49
63
  }
64
+
50
65
  .omni-content {
66
+ --omni-content-top: clamp(5rem, 16vh, 7rem);
51
67
  position: fixed;
52
- z-index: 101;
53
- top: 18%;
68
+ z-index: 51;
69
+ top: var(--omni-content-top);
54
70
  left: 50%;
55
71
  transform: translateX(-50%);
56
- width: min(720px, calc(100% - 32px));
57
- border-radius: var(--radius, 0.75rem);
58
- border: 1px solid var(--color-fd-border, #262626);
59
- background: var(--color-fd-popover, #0c0c0c);
60
- color: var(--color-fd-foreground, #fafafa);
61
- box-shadow:
62
- 0 24px 60px -12px rgba(0, 0, 0, 0.5),
63
- 0 0 0 1px rgba(255, 255, 255, 0.04);
72
+ display: flex;
73
+ flex-direction: column;
74
+ width: min(720px, calc(100% - 1rem));
75
+ border-radius: 0.75rem;
76
+ border: 1px solid color-mix(in srgb, var(--color-fd-border, #d4d4d4) 70%, transparent);
77
+ background: var(--color-fd-popover, #fafafa);
78
+ color: var(--color-fd-popover-foreground, var(--color-fd-foreground, #272727));
79
+ font-family: var(--font-sans, system-ui, -apple-system, BlinkMacSystemFont, "SF Pro Text",
80
+ ui-sans-serif, sans-serif);
81
+ box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
64
82
  outline: none;
65
- overflow: hidden;
83
+ overscroll-behavior: contain;
66
84
  animation: omni-scale-in 200ms cubic-bezier(0.16, 1, 0.3, 1);
67
85
  }
86
+
68
87
  @media (max-width: 639px) {
69
88
  .omni-content {
70
- top: 10%;
71
- width: calc(100% - 24px);
72
- max-height: 75vh;
89
+ --omni-content-top: 1rem;
90
+ top: var(--omni-content-top);
91
+ width: calc(100% - 1rem);
92
+ max-height: calc(100vh - 2rem);
73
93
  }
74
- .omni-kbd { display: none; }
75
94
  }
76
95
 
96
+ /* Header */
77
97
  .omni-header {
78
98
  border-bottom: 1px solid var(--color-fd-border, #262626);
79
99
  }
@@ -81,90 +101,120 @@
81
101
  display: flex;
82
102
  align-items: center;
83
103
  gap: 0.5rem;
84
- padding: 0.625rem 0.875rem;
104
+ padding: 0.75rem;
85
105
  }
86
106
  .omni-search-icon {
87
107
  color: var(--color-fd-muted-foreground, #a3a3a3);
88
108
  flex-shrink: 0;
89
109
  }
110
+ .omni-search-icon svg {
111
+ width: 1.25rem;
112
+ height: 1.25rem;
113
+ }
90
114
  .omni-search-input {
115
+ width: 0;
91
116
  flex: 1;
92
117
  background: transparent;
93
118
  outline: none;
94
- font-size: 0.875rem;
95
- line-height: 1.25rem;
96
- color: var(--color-fd-foreground, #fafafa);
119
+ font-size: 1.125rem;
120
+ line-height: 1.75rem;
121
+ color: var(--color-fd-popover-foreground, var(--color-fd-foreground, #272727));
97
122
  border: none;
98
123
  }
99
124
  .omni-search-input::placeholder {
100
125
  color: var(--color-fd-muted-foreground, #a3a3a3);
101
126
  }
102
127
  .omni-kbd {
103
- border-radius: 0.25rem;
104
- background: var(--color-fd-muted, #262626);
105
- padding: 0.125rem 0.375rem;
106
- font-size: 10px;
128
+ border-radius: 0.375rem;
129
+ border: 1px solid var(--color-fd-border, #d4d4d4);
130
+ background: transparent;
131
+ padding: 0.375rem 0.5rem;
132
+ font-size: 0.75rem;
107
133
  color: var(--color-fd-muted-foreground, #a3a3a3);
108
- font-family: inherit;
109
- line-height: 1.4;
134
+ font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
135
+ "Liberation Mono", "Courier New", monospace);
136
+ line-height: 1rem;
110
137
  }
111
138
  .omni-close-btn {
112
- margin-left: 0.25rem;
113
- border-radius: 0.25rem;
114
- padding: 0.25rem;
139
+ display: inline-flex;
140
+ align-items: center;
141
+ justify-content: center;
142
+ gap: 0.25rem;
143
+ min-width: 2.5rem;
144
+ border-radius: 0.375rem;
145
+ border: 1px solid var(--color-fd-border, #d4d4d4);
146
+ padding: 0.375rem 0.5rem;
115
147
  color: var(--color-fd-muted-foreground, #a3a3a3);
116
- transition: background 120ms;
148
+ font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
149
+ "Liberation Mono", "Courier New", monospace);
150
+ font-size: 0.75rem;
151
+ line-height: 1rem;
152
+ font-weight: 500;
153
+ transition:
154
+ background 120ms,
155
+ color 120ms;
117
156
  cursor: pointer;
118
- border: none;
119
157
  background: none;
120
158
  }
121
159
  .omni-close-btn:hover {
122
- background: var(--color-fd-muted, #262626);
160
+ color: var(--color-fd-accent-foreground, var(--color-fd-foreground, #171717));
161
+ background: var(--color-fd-accent, #e5e5e5);
162
+ }
163
+ .omni-close-btn svg {
164
+ display: none;
123
165
  }
124
166
 
167
+ /* Body / listbox */
125
168
  .omni-body {
126
- max-height: 60vh;
169
+ flex: 1 1 auto;
170
+ min-height: 0;
171
+ max-height: min(540px, calc(100vh - var(--omni-content-top, 7rem) - 8rem));
127
172
  overflow: auto;
128
- padding: 0.25rem;
173
+ overscroll-behavior: contain;
174
+ padding: 0.5rem;
175
+ }
176
+ .omni-body::-webkit-scrollbar {
177
+ width: 6px;
178
+ }
179
+ .omni-body::-webkit-scrollbar-track {
180
+ background: transparent;
129
181
  }
130
- .omni-body::-webkit-scrollbar { width: 6px; }
131
- .omni-body::-webkit-scrollbar-track { background: transparent; }
132
182
  .omni-body::-webkit-scrollbar-thumb {
133
183
  background: var(--color-fd-muted, #262626);
134
184
  border-radius: 3px;
135
185
  }
136
186
 
187
+ /* Loading */
137
188
  .omni-loading {
138
189
  display: flex;
139
190
  align-items: center;
140
191
  gap: 0.5rem;
141
- padding: 0.5rem 0.75rem;
192
+ padding: 0.75rem 0.625rem;
142
193
  color: var(--color-fd-muted-foreground, #a3a3a3);
143
- font-size: 0.75rem;
194
+ font-size: 0.875rem;
144
195
  }
196
+
197
+ /* Groups */
145
198
  .omni-group {
146
- padding: 0.25rem 0;
199
+ padding: 0;
147
200
  }
148
201
  .omni-group-label {
149
- padding: 0.25rem 0.75rem;
150
- font-size: 10px;
151
- text-transform: uppercase;
152
- letter-spacing: 0.06em;
153
- color: var(--color-fd-muted-foreground, #a3a3a3);
154
- font-weight: 500;
202
+ display: none;
155
203
  }
156
204
  .omni-group-items {
157
205
  display: flex;
158
206
  flex-direction: column;
207
+ gap: 0.25rem;
159
208
  }
160
209
 
210
+ /* Items */
161
211
  .omni-item {
162
- display: flex;
212
+ position: relative;
213
+ display: block;
163
214
  width: 100%;
164
- align-items: center;
165
- gap: 0.75rem;
166
- border-radius: calc(var(--radius, 0.75rem) - 4px);
167
- padding: 0.5rem 0.75rem;
215
+ flex-shrink: 0;
216
+ border-radius: 0.5rem;
217
+ padding: 0.75rem 0.875rem;
168
218
  text-align: left;
169
219
  font-size: 0.875rem;
170
220
  line-height: 1.25rem;
@@ -176,81 +226,112 @@
176
226
  }
177
227
  .omni-item:hover,
178
228
  .omni-item-active {
179
- background: color-mix(in srgb, var(--color-fd-accent, #262626) 60%, transparent);
229
+ background: var(--color-fd-accent, rgba(209, 209, 209, 0.5));
180
230
  }
181
231
  .omni-item-active {
182
- color: var(--color-fd-accent-foreground, #fafafa);
232
+ color: var(--color-fd-accent-foreground, #171717);
183
233
  }
184
234
  .omni-item-disabled {
185
235
  opacity: 0.5;
186
236
  cursor: not-allowed;
187
237
  }
238
+
188
239
  .omni-item-icon {
189
- flex-shrink: 0;
190
- color: var(--color-fd-muted-foreground, #a3a3a3);
240
+ display: none;
191
241
  }
192
242
  .omni-item-active .omni-item-icon {
193
243
  color: var(--color-fd-accent-foreground, #fafafa);
194
244
  }
195
- .omni-item-text { flex: 1; min-width: 0; }
245
+ .omni-item-text {
246
+ min-width: 0;
247
+ }
196
248
  .omni-item-label {
249
+ min-width: 0;
197
250
  overflow: hidden;
198
251
  text-overflow: ellipsis;
199
252
  white-space: nowrap;
253
+ color: var(--color-fd-popover-foreground, var(--color-fd-foreground, #272727));
254
+ font-weight: 500;
255
+ line-height: 1.35;
200
256
  }
201
257
  .omni-item-subtitle {
258
+ min-width: 0;
202
259
  overflow: hidden;
203
260
  text-overflow: ellipsis;
204
261
  white-space: nowrap;
205
262
  font-size: 0.75rem;
263
+ line-height: 1rem;
206
264
  color: var(--color-fd-muted-foreground, #a3a3a3);
207
- opacity: 0.8;
265
+ opacity: 1;
208
266
  }
209
267
  .omni-item-active .omni-item-subtitle {
210
- color: var(--color-fd-accent-foreground, #fafafa);
211
- opacity: 0.7;
268
+ color: var(--color-fd-muted-foreground, #737373);
269
+ opacity: 1;
270
+ }
271
+ .omni-item-description {
272
+ min-width: 0;
273
+ overflow: hidden;
274
+ display: -webkit-box;
275
+ -webkit-line-clamp: 3;
276
+ -webkit-box-orient: vertical;
277
+ margin-top: 0.625rem;
278
+ padding-left: 0.75rem;
279
+ border-left: 1px solid color-mix(in srgb, var(--color-fd-border, #d4d4d4) 70%, transparent);
280
+ color: color-mix(in srgb, var(--color-fd-popover-foreground, #272727) 82%, transparent);
281
+ font-size: 0.875rem;
282
+ line-height: 1.5rem;
283
+ font-weight: 400;
212
284
  }
213
285
  .omni-item-badge {
214
- color: var(--color-fd-muted-foreground, #a3a3a3);
215
- flex-shrink: 0;
286
+ display: none;
216
287
  }
217
288
  .omni-item-ext {
218
- position: relative;
219
- z-index: 2;
220
- display: inline-flex;
221
- align-items: center;
222
- justify-content: center;
223
- padding: 0.25rem;
224
- border-radius: 0.25rem;
225
- color: var(--color-fd-muted-foreground, #a3a3a3);
226
- flex-shrink: 0;
227
- transition: color 100ms, background 100ms;
228
- text-decoration: none;
289
+ display: none;
229
290
  }
230
291
  .omni-item-ext:hover {
231
292
  color: var(--color-fd-foreground, #fafafa);
232
293
  background: var(--color-fd-muted, #262626);
233
294
  }
234
- .omni-item-chevron {
235
- width: 0.875rem;
236
- height: 0.875rem;
237
- margin-left: 0.25rem;
295
+ .omni-item-shortcuts {
296
+ display: none;
297
+ align-items: center;
298
+ gap: 0.25rem;
299
+ font-size: 10px;
238
300
  color: var(--color-fd-muted-foreground, #a3a3a3);
239
- opacity: 0;
240
- transition: opacity 120ms;
241
- flex-shrink: 0;
242
301
  }
243
- .omni-item:hover .omni-item-chevron {
244
- opacity: 1;
302
+ @media (min-width: 640px) {
303
+ .omni-item-shortcuts {
304
+ display: flex;
305
+ }
306
+ }
307
+ .omni-kbd-sm {
308
+ border-radius: 0.25rem;
309
+ background: var(--color-fd-muted, #262626);
310
+ padding: 0.125rem 0.25rem;
311
+ font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
312
+ "Liberation Mono", "Courier New", monospace);
313
+ }
314
+ .omni-item-chevron {
315
+ display: none;
245
316
  }
317
+ /* Highlight */
246
318
  .omni-highlight {
247
- border-radius: 2px;
248
- background: color-mix(in srgb, var(--color-fd-primary, #6366f1) 30%, transparent);
249
- padding: 0 2px;
250
- color: inherit;
319
+ border-radius: 0;
320
+ background: transparent;
321
+ padding: 0;
322
+ color: var(--color-fd-primary, #ca8a04);
323
+ text-decoration: underline;
324
+ text-underline-offset: 2px;
325
+ }
326
+
327
+ /* Empty states */
328
+ .omni-empty-group {
329
+ padding: 0.5rem 0.75rem;
330
+ font-size: 0.75rem;
331
+ color: var(--color-fd-muted-foreground, #a3a3a3);
251
332
  }
252
333
  .omni-empty {
253
- padding: 2rem 0.75rem;
334
+ padding: 1.5rem 0.75rem;
254
335
  text-align: center;
255
336
  font-size: 0.875rem;
256
337
  color: var(--color-fd-muted-foreground, #a3a3a3);
@@ -265,28 +346,109 @@
265
346
  border-radius: 9999px;
266
347
  background: var(--color-fd-muted, #262626);
267
348
  }
349
+
350
+ /* Footer */
268
351
  .omni-footer {
269
352
  border-top: 1px solid var(--color-fd-border, #262626);
353
+ background: color-mix(in srgb, var(--color-fd-secondary, #f5f5f5) 50%, transparent);
270
354
  }
271
355
  .omni-footer-inner {
272
356
  display: flex;
273
357
  align-items: center;
274
358
  justify-content: space-between;
275
- padding: 0.5rem 0.75rem;
359
+ gap: 0.75rem;
360
+ padding: 0.625rem 0.75rem;
276
361
  font-size: 0.75rem;
277
362
  color: var(--color-fd-muted-foreground, #a3a3a3);
278
363
  }
279
364
  .omni-footer-hints {
280
365
  display: flex;
281
366
  align-items: center;
282
- gap: 1rem;
367
+ flex-wrap: wrap;
368
+ gap: 0.75rem;
283
369
  }
284
370
  .omni-footer-hint {
285
371
  display: flex;
286
372
  align-items: center;
287
373
  gap: 0.25rem;
374
+ white-space: nowrap;
375
+ }
376
+ .omni-footer-hint svg {
377
+ box-sizing: border-box;
378
+ width: 1.375rem;
379
+ height: 1.375rem;
380
+ padding: 0.25rem;
381
+ border-radius: 0.375rem;
382
+ border: 1px solid var(--color-fd-border, #d4d4d4);
383
+ background: color-mix(in srgb, var(--color-fd-muted, #e5e5e5) 70%, transparent);
384
+ color: var(--color-fd-muted-foreground, #737373);
385
+ flex-shrink: 0;
386
+ }
387
+ .omni-footer-hint-desktop {
388
+ display: none;
389
+ }
390
+ .omni-footer-filter {
391
+ position: relative;
392
+ display: inline-flex;
393
+ align-items: center;
394
+ gap: 0.5rem;
395
+ margin-left: auto;
396
+ white-space: nowrap;
288
397
  }
289
- .omni-footer-hint-desktop { display: none; }
290
398
  @media (min-width: 640px) {
291
- .omni-footer-hint-desktop { display: flex; }
399
+ .omni-footer-hint-desktop {
400
+ display: flex;
401
+ }
402
+ }
403
+ .omni-filter-label {
404
+ color: var(--color-fd-muted-foreground, #a3a3a3);
405
+ }
406
+ .omni-filter-value {
407
+ display: inline-flex;
408
+ align-items: center;
409
+ gap: 0.25rem;
410
+ color: var(--color-fd-popover-foreground, var(--color-fd-foreground, #272727));
411
+ }
412
+ .omni-filter-button {
413
+ display: inline-flex;
414
+ align-items: center;
415
+ gap: 0.25rem;
416
+ border: 0;
417
+ background: transparent;
418
+ color: var(--color-fd-popover-foreground, var(--color-fd-foreground, #272727));
419
+ font: inherit;
420
+ cursor: pointer;
421
+ padding: 0;
422
+ }
423
+ .omni-filter-button:hover {
424
+ color: var(--color-fd-accent-foreground, var(--color-fd-foreground, #171717));
425
+ }
426
+ .omni-filter-menu {
427
+ position: absolute;
428
+ right: 0;
429
+ bottom: calc(100% + 0.5rem);
430
+ z-index: 1;
431
+ width: 10rem;
432
+ border: 1px solid var(--color-fd-border, #d4d4d4);
433
+ border-radius: 0.5rem;
434
+ background: var(--color-fd-popover, #fafafa);
435
+ padding: 0.25rem;
436
+ box-shadow: 0 16px 32px -20px rgba(0, 0, 0, 0.45);
437
+ }
438
+ .omni-filter-option {
439
+ display: flex;
440
+ width: 100%;
441
+ align-items: center;
442
+ border: 0;
443
+ border-radius: 0.375rem;
444
+ background: transparent;
445
+ color: var(--color-fd-popover-foreground, var(--color-fd-foreground, #272727));
446
+ cursor: pointer;
447
+ font: inherit;
448
+ padding: 0.5rem 0.625rem;
449
+ text-align: left;
450
+ }
451
+ .omni-filter-option:hover,
452
+ .omni-filter-option-active {
453
+ background: var(--color-fd-accent, rgba(209, 209, 209, 0.5));
292
454
  }
@@ -841,6 +841,56 @@ code:not(pre code) {
841
841
  color: var(--color-fd-foreground) !important;
842
842
  }
843
843
 
844
+ /* ─── Omni Command Palette — pixel-border theme ──────────────────── */
845
+
846
+ .omni-content {
847
+ border-radius: 0 !important;
848
+ border: 2px solid var(--color-fd-border, hsl(0 0% 15%)) !important;
849
+ box-shadow:
850
+ 4px 4px 0 0 var(--color-fd-border, hsl(0 0% 15%)),
851
+ 0 24px 60px -12px rgba(0, 0, 0, 0.5) !important;
852
+ }
853
+
854
+ .omni-item,
855
+ .omni-kbd,
856
+ .omni-kbd-sm,
857
+ .omni-close-btn,
858
+ .omni-empty-icon {
859
+ border-radius: 0 !important;
860
+ }
861
+
862
+ .omni-kbd,
863
+ .omni-kbd-sm {
864
+ border: 1px solid var(--color-fd-border, hsl(0 0% 15%)) !important;
865
+ }
866
+
867
+ .omni-group-label,
868
+ .omni-footer-inner {
869
+ font-family: var(--fd-font-mono, ui-monospace, monospace) !important;
870
+ }
871
+
872
+ .omni-search-input {
873
+ font-family: var(--fd-font-sans, system-ui, -apple-system, sans-serif) !important;
874
+ }
875
+
876
+ .omni-group-label {
877
+ letter-spacing: 0.08em !important;
878
+ }
879
+
880
+ .omni-footer-hint svg {
881
+ border-radius: 0 !important;
882
+ }
883
+
884
+ .omni-filter-menu,
885
+ .omni-filter-option {
886
+ border-radius: 0 !important;
887
+ }
888
+
889
+ .omni-highlight {
890
+ background: color-mix(in srgb, var(--color-fd-primary, #6366f1) 25%, transparent) !important;
891
+ border-radius: 0 !important;
892
+ }
893
+
844
894
  /* ─── Feedback (pixel-border theme) ──────────────────────────────── */
845
895
 
846
896
  .fd-feedback-input,