@stnd/launcher 1.0.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.
@@ -0,0 +1,1067 @@
1
+ <script>
2
+ /**
3
+ * @stnd/launcher - The Universal Command Palette UI
4
+ *
5
+ * Integrated with the Standard Garden OS aesthetic.
6
+ */
7
+ import Icon from "@stnd/icon/Icon.svelte";
8
+ import Avatar from "@stnd/account/ui/Avatar.svelte";
9
+ import {
10
+ registry,
11
+ palette,
12
+ search,
13
+ computeMatches,
14
+ execute as engineExecute,
15
+ launcherToggle,
16
+ launcherView,
17
+ launcherClose,
18
+ bootLauncher,
19
+ setSize,
20
+ activeView,
21
+ windowSize,
22
+ } from "./launcher.ts";
23
+
24
+ import { onMount, tick } from "svelte";
25
+ import { get } from "svelte/store";
26
+ import { fade, blurFade, scaleBlurFade } from "@stnd/ui/transitions.js";
27
+ import { quintOut } from "svelte/easing";
28
+ import { inject } from "@stnd/store";
29
+ import { registerShortcut } from "@stnd/client/keyboard";
30
+ import Log from "@stnd/log";
31
+
32
+ const log = Log({ scope: "Launcher" });
33
+
34
+ // --- PROPS (all optional — the Launcher is fully self-booting) ---
35
+ // Apps wire services via provide/inject (the Standard OS runtime store).
36
+ // Props exist only for backward compat or one-off overrides.
37
+ let { WelcomeView = null, load = null, hideLogin = false } = $props();
38
+
39
+ // Check global window variable as fallback (set via window.__HIDE_LAUNCHER_LOGIN__)
40
+ let shouldHideLogin = $derived(
41
+ hideLogin ||
42
+ (typeof window !== "undefined" && window.__HIDE_LAUNCHER_LOGIN__),
43
+ );
44
+
45
+ // --- STATE (Synced with Engine) ---
46
+ let pState = $state({
47
+ open: false,
48
+ view: "search",
49
+ query: "",
50
+ contexts: ["global"],
51
+ props: {},
52
+ });
53
+ let registryState = $state({ commands: [], views: {} });
54
+ let searchResults = $state([]);
55
+ let isSearching = $state(false);
56
+ let activeIndex = $state(0);
57
+ let inputEl = $state();
58
+ let launcherEl = $state();
59
+ let itemsContainer = $state();
60
+ let debounceTimer;
61
+ let currentUser = $state(null);
62
+ let dynamicSize = $state(null);
63
+ // --- UI State for header hover ---
64
+ let isHeaderHovered = $state(false);
65
+ let isSearchHovered = $state(false);
66
+ let isInputFocused = $state(false);
67
+ let contentHeight = $state(0);
68
+
69
+ // --- Derived: Current View Title ---
70
+ let currentViewTitle = $derived.by(() => {
71
+ if (pState.view === "search") return "";
72
+ const view = registryState.views[pState.view];
73
+ if (view && view.meta && view.meta.title) return view.meta.title;
74
+ const cmd = registryState.commands.find((c) => c.trigger === pState.view);
75
+ if (cmd && cmd.title) return cmd.title;
76
+ return pState.view || "";
77
+ });
78
+ let showInput = $derived(
79
+ !currentViewTitle || isSearchHovered || isInputFocused,
80
+ );
81
+ // Remote state sync
82
+ let coreActiveView = $state(null);
83
+ let coreWindowSize = $state({ width: "standard", height: "standard" });
84
+
85
+ // Subscribe to user store from the Standard OS runtime store
86
+ $effect(() => {
87
+ const store = inject("user", false);
88
+ if (store && typeof store.subscribe === "function") {
89
+ const unsub = store.subscribe((u) => {
90
+ currentUser = u;
91
+ });
92
+ return unsub;
93
+ } else if (typeof window !== "undefined" && window.__INITIAL_USER__) {
94
+ // Fallback: mutate plain object before assigning to avoid reading
95
+ // the reactive $state proxy inside the effect (would cause a loop)
96
+ const initialUser = window.__INITIAL_USER__;
97
+ if (initialUser && initialUser.isAuthenticated === undefined) {
98
+ initialUser.isAuthenticated = true;
99
+ }
100
+ currentUser = initialUser;
101
+ } else {
102
+ currentUser = null;
103
+ }
104
+ });
105
+
106
+ // Scroll active item into view
107
+ $effect(() => {
108
+ // Dependency on activeIndex to trigger
109
+ activeIndex;
110
+ if (itemsContainer) {
111
+ const activeEl = itemsContainer.querySelector(
112
+ ".std-launcher-item.active",
113
+ );
114
+ if (activeEl) {
115
+ activeEl.scrollIntoView({ block: "nearest", behavior: "smooth" });
116
+ }
117
+ }
118
+ });
119
+
120
+ // --- SYNC WITH ENGINE ---
121
+ onMount(() => {
122
+ // 1. Boot the engine — discovers extensions, registers commands/views.
123
+ // App-specific services (user store, search) are already available
124
+ // via inject() from the Standard OS runtime store.
125
+ bootLauncher();
126
+
127
+ // 2. Subscribe to palette state (open, view, query)
128
+ const unsubPalette = palette.subscribe((v) => {
129
+ pState = v;
130
+ if (v.open) {
131
+ // Focus input when opened
132
+ tick().then(() => inputEl?.focus());
133
+ }
134
+ });
135
+
136
+ const unsubRegistry = registry.subscribe((v) => {
137
+ registryState = v;
138
+ });
139
+
140
+ const unsubAV = activeView.subscribe((v) => (coreActiveView = v));
141
+ const unsubWS = windowSize.subscribe((v) => (coreWindowSize = v));
142
+
143
+ // 3. Register Default Shortcuts
144
+ const shortcuts = [
145
+ registerShortcut({
146
+ key: "k",
147
+ meta: true,
148
+ id: "launcher-toggle-cmd-k",
149
+ action: () => launcherToggle(),
150
+ }),
151
+ registerShortcut({
152
+ key: ":",
153
+ shift: true,
154
+ id: "launcher-toggle-command-mode",
155
+ action: () => launcherToggle("::"),
156
+ }),
157
+ registerShortcut({
158
+ key: "Escape",
159
+ id: "launcher-escape",
160
+ action: () => {
161
+ // Only go back if not in a modal/launcher (handled by their own listeners)
162
+ // But for now, simple history back is the standard behavior
163
+ window.history.back();
164
+ },
165
+ }),
166
+ ];
167
+
168
+ // 4. Keyboard Shortcuts for internal navigation
169
+ const handleInternalKey = (e) => {
170
+ if (!pState.open) return;
171
+
172
+ if (e.key === "Escape") {
173
+ if (ActiveViewComponent && pState.view !== "search") {
174
+ launcherView("search");
175
+ } else {
176
+ launcherClose();
177
+ }
178
+ return;
179
+ }
180
+
181
+ // Only handle arrows/enter if not in a custom view
182
+ if (ActiveViewComponent) return;
183
+
184
+ if (e.key === "ArrowDown") {
185
+ e.preventDefault();
186
+ activeIndex = (activeIndex + 1) % matches.length;
187
+ } else if (e.key === "ArrowUp") {
188
+ e.preventDefault();
189
+ activeIndex =
190
+ (activeIndex - 1 + matches.length) % (matches.length || 1);
191
+ } else if (e.key === "Enter") {
192
+ e.preventDefault();
193
+ if (matches[activeIndex]) {
194
+ execute(matches[activeIndex]);
195
+ }
196
+ }
197
+ };
198
+
199
+ window.addEventListener("keydown", handleInternalKey);
200
+
201
+ // ===================================
202
+ // GLOBAL EVENTS & DELEGATION
203
+ // (Restored for legacy compatibility)
204
+ // ===================================
205
+
206
+ const toggleLauncherHandler = (e) => {
207
+ const initialQuery = e.detail?.initialQuery || "";
208
+ launcherToggle(initialQuery);
209
+ };
210
+
211
+ const launcherViewHandler = (e) => {
212
+ const viewTrigger = e.detail?.viewTrigger;
213
+ if (viewTrigger) {
214
+ launcherView(viewTrigger);
215
+ }
216
+ };
217
+
218
+ const executeLauncherHandler = (e) => {
219
+ const command = e.detail?.command;
220
+ if (command) {
221
+ const cmd = registryState.commands.find((c) => c.trigger === command);
222
+ if (cmd) {
223
+ execute(cmd);
224
+ }
225
+ }
226
+ };
227
+
228
+ window.addEventListener("toggle-launcher", toggleLauncherHandler);
229
+ window.addEventListener("open-launcher", launcherViewHandler);
230
+ window.addEventListener("launcher-execute-command", executeLauncherHandler);
231
+
232
+ // Sync user state via events (in case of store collision or lazy init)
233
+ const handleLogin = (e) => {
234
+ currentUser = e.detail;
235
+ };
236
+ const handleLogout = () => {
237
+ currentUser = null;
238
+ };
239
+ window.addEventListener("user-login", handleLogin);
240
+ window.addEventListener("user-logout", handleLogout);
241
+ window.addEventListener("visitor-login", () => {
242
+ if (window.__INITIAL_USER__) {
243
+ currentUser = window.__INITIAL_USER__;
244
+ if (currentUser && currentUser.isAuthenticated === undefined) {
245
+ currentUser.isAuthenticated = true;
246
+ }
247
+ }
248
+ });
249
+
250
+ // Global Click Delegation (data-launcher-*)
251
+ const dataAttributeHandler = (e) => {
252
+ // Find closest element with data-launcher attributes
253
+ const viewTrigger = e.target.closest("[data-launcher-view]");
254
+ const navTrigger = e.target.closest("[data-launcher-navigator]");
255
+ const promptTrigger = e.target.closest("[data-launcher-prompt]");
256
+ const toggleTrigger = e.target.closest("[data-launcher-toggle]");
257
+
258
+ if (navTrigger) {
259
+ e.preventDefault();
260
+ const url = navTrigger.getAttribute("data-launcher-navigator");
261
+ launcherView("::navigator", { url });
262
+ } else if (viewTrigger) {
263
+ e.preventDefault();
264
+ const trigger = viewTrigger.getAttribute("data-launcher-view");
265
+
266
+ // 1. Try to find a command first (might be an action)
267
+ const item = registryState.commands.find((c) => c.trigger === trigger);
268
+ if (item) {
269
+ execute(item);
270
+ } else {
271
+ // 2. Fallback to opening view directly
272
+ launcherView(trigger);
273
+ }
274
+ } else if (promptTrigger) {
275
+ e.preventDefault();
276
+ const promptText = promptTrigger.getAttribute("data-launcher-prompt");
277
+ if (promptText) launcherToggle(promptText);
278
+ } else if (toggleTrigger) {
279
+ e.preventDefault();
280
+ launcherToggle();
281
+ }
282
+ };
283
+
284
+ document.addEventListener("click", dataAttributeHandler);
285
+
286
+ // Close on navigation (Astro View Transitions)
287
+ const closeOnNavigationHandler = () => {
288
+ // Always reset on navigation, regardless of open state
289
+ palette.set({
290
+ open: false,
291
+ query: "",
292
+ view: "search",
293
+ returnTo: null,
294
+ contexts: ["global"],
295
+ });
296
+ // Also ensure body scroll is unlocked
297
+ document.body.style.overflow = "";
298
+ };
299
+
300
+ document.addEventListener("astro:before-swap", closeOnNavigationHandler);
301
+
302
+ // Additional cleanup after swap completes
303
+ const afterSwapHandler = () => {
304
+ const current = get(palette);
305
+ if (current.open) {
306
+ palette.set({
307
+ open: false,
308
+ query: "",
309
+ view: "search",
310
+ returnTo: null,
311
+ contexts: ["global"],
312
+ });
313
+ }
314
+ document.body.style.overflow = "";
315
+ };
316
+
317
+ document.addEventListener("astro:after-swap", afterSwapHandler);
318
+
319
+ // Fermeture sur mousedown à l'extérieur — gère les iframes qui avalent les événements click
320
+ const handleOutsideMousedown = (e) => {
321
+ if (!get(palette).open) return;
322
+
323
+ // Si le clic se fait sur un élément déclencheur du launcher, on le laisse gérer
324
+ // pour éviter les conflits (ex: le bouton de bascule ou le bouton de connexion).
325
+ const target = e.target;
326
+ if (
327
+ target &&
328
+ typeof target.closest === "function" &&
329
+ (target.closest("[data-launcher-toggle]") ||
330
+ target.closest("[data-launcher-view]") ||
331
+ target.closest("[data-launcher-navigator]") ||
332
+ target.closest("[data-launcher-prompt]"))
333
+ ) {
334
+ return;
335
+ }
336
+
337
+ if (launcherEl && !launcherEl.contains(e.target)) {
338
+ launcherClose();
339
+ }
340
+ };
341
+ document.addEventListener("mousedown", handleOutsideMousedown);
342
+
343
+ return () => {
344
+ window.removeEventListener("keydown", handleInternalKey);
345
+ window.removeEventListener("toggle-launcher", toggleLauncherHandler);
346
+ window.removeEventListener("open-launcher", launcherViewHandler);
347
+ window.removeEventListener(
348
+ "launcher-execute-command",
349
+ executeLauncherHandler,
350
+ );
351
+ window.removeEventListener("user-login", handleLogin);
352
+ window.removeEventListener("user-logout", handleLogout);
353
+ document.removeEventListener("click", dataAttributeHandler);
354
+ document.removeEventListener("mousedown", handleOutsideMousedown);
355
+ document.removeEventListener(
356
+ "astro:before-swap",
357
+ closeOnNavigationHandler,
358
+ );
359
+ document.removeEventListener("astro:after-swap", afterSwapHandler);
360
+ unsubPalette();
361
+ unsubRegistry();
362
+ unsubAV();
363
+ unsubWS();
364
+ shortcuts.forEach((unsub) => unsub && unsub());
365
+ };
366
+ });
367
+
368
+ // --- DERIVED ---
369
+ let matches = $derived(
370
+ computeMatches(
371
+ pState.query,
372
+ registryState.commands,
373
+ searchResults,
374
+ currentUser,
375
+ ),
376
+ );
377
+
378
+ let ActiveViewComponent = $derived.by(() => {
379
+ // 1. Check if it's a folio-provided view
380
+ if (registryState.views[pState.view]) {
381
+ return registryState.views[pState.view];
382
+ }
383
+ // 2. Fallback for search home: prop → ::welcome from registry → null
384
+ if (pState.view === "search" && !pState.query) {
385
+ return WelcomeView || registryState.views["::welcome"] || null;
386
+ }
387
+ return null;
388
+ });
389
+
390
+ // Determine view size from registry meta
391
+ let activeViewSize = $derived.by(() => {
392
+ // 1. Dynamic Override
393
+ if (dynamicSize) return dynamicSize;
394
+
395
+ // 2. Registry / Default
396
+ if (!ActiveViewComponent) return { width: "standard", height: "standard" };
397
+ const cmd = registryState.commands.find(
398
+ (c) => c.component === ActiveViewComponent,
399
+ );
400
+ return (
401
+ cmd?.meta?.size || cmd?.size || { width: "standard", height: "standard" }
402
+ );
403
+ });
404
+
405
+ // Reset dynamic size when view changes
406
+ $effect(() => {
407
+ pState.view;
408
+ dynamicSize = null;
409
+ });
410
+
411
+ // --- ACTIONS ---
412
+ async function performSearch(q) {
413
+ if (!q || q.startsWith("::")) {
414
+ searchResults = [];
415
+ return;
416
+ }
417
+ isSearching = true;
418
+ searchResults = await search(q);
419
+ isSearching = false;
420
+ activeIndex = 0;
421
+ }
422
+
423
+ function execute(item) {
424
+ engineExecute(item, {
425
+ close: launcherClose,
426
+ setView: launcherView,
427
+ views: registryState.views,
428
+ });
429
+ }
430
+
431
+ $effect(() => {
432
+ clearTimeout(debounceTimer);
433
+ if (pState.query && !pState.query.startsWith("::")) {
434
+ debounceTimer = setTimeout(() => performSearch(pState.query), 200);
435
+ } else {
436
+ searchResults = [];
437
+ }
438
+ });
439
+
440
+ // Reset index when query changes
441
+ $effect(() => {
442
+ pState.query;
443
+ activeIndex = 0;
444
+ });
445
+
446
+ // Prevent body scroll when open
447
+ $effect(() => {
448
+ if (pState.open) {
449
+ document.body.style.overflow = "hidden";
450
+ } else {
451
+ document.body.style.overflow = "";
452
+ }
453
+ });
454
+
455
+ // Helper to resolve flexible filter prop to function
456
+ function resolveFilter(f) {
457
+ if (!f) return null;
458
+ if (typeof f === "function") return f;
459
+
460
+ // Allow-list mode (string or array)
461
+ // "::navigator, ::search" or ["::navigator", "::search"]
462
+ const allowed = Array.isArray(f) ? f : f.split(",").map((s) => s.trim());
463
+
464
+ return (trigger) => allowed.includes(trigger);
465
+ }
466
+ </script>
467
+
468
+ {#if pState.open}
469
+ <div
470
+ class="std-launcher-overlay"
471
+ transition:blurFade={{ duration: 500, maxBlur: 20, easing: quintOut }}
472
+ onoutrostart={(e) => (e.currentTarget.style.pointerEvents = "none")}
473
+ >
474
+ <!-- Backdrop -->
475
+ <div
476
+ class="std-launcher-backdrop"
477
+ role="button"
478
+ tabindex="0"
479
+ aria-label="Close launcher"
480
+ onclick={launcherClose}
481
+ onkeydown={(e) => (e.key === "Enter" || e.key === " ") && launcherClose()}
482
+ ></div>
483
+
484
+ <!-- Main Window -->
485
+ <div
486
+ bind:this={launcherEl}
487
+ class="std-launcher {coreWindowSize.width}"
488
+ role="presentation"
489
+ transition:scaleBlurFade={{
490
+ duration: 500,
491
+ maxBlur: 20,
492
+ start: 0.95,
493
+ easing: quintOut,
494
+ }}
495
+ onclick={(e) => e.stopPropagation()}
496
+ >
497
+ <!-- Header -->
498
+ <header class="std-launcher-header">
499
+ <div
500
+ class="std-launcher-header-inner"
501
+ role="presentation"
502
+ data-has-title={currentViewTitle ? "true" : "false"}
503
+ >
504
+ <div
505
+ class="std-launcher-search-area"
506
+ role="search"
507
+ onmouseenter={() => (isSearchHovered = true)}
508
+ onmouseleave={() => (isSearchHovered = false)}
509
+ aria-label="Toggle launcher"
510
+ >
511
+ {#if !showInput}
512
+ <button class="std-launcher-search-icon icon ghost">
513
+ <Icon icon="search" class="text-xs" />
514
+ </button>
515
+ {/if}
516
+
517
+ <input
518
+ bind:this={inputEl}
519
+ value={pState.query}
520
+ oninput={(e) =>
521
+ palette.update((s) => ({
522
+ ...s,
523
+ query: e.target.value,
524
+ view: e.target.value ? "search" : s.view,
525
+ }))}
526
+ onfocus={() => (isInputFocused = true)}
527
+ onblur={() => (isInputFocused = false)}
528
+ class="std-launcher-input"
529
+ class:hidden={!showInput}
530
+ placeholder="Search..."
531
+ autocomplete="off"
532
+ spellcheck="false"
533
+ tabindex="0"
534
+ aria-label="Search commands or signals"
535
+ />
536
+ </div>
537
+ {#if currentViewTitle}
538
+ <div
539
+ class="std-launcher-title mt-0"
540
+ role="heading"
541
+ aria-level="1"
542
+ class:opacity-0={showInput}
543
+ in:fade={{ duration: 500 }}
544
+ out:fade={{ duration: 500 }}
545
+ >
546
+ {currentViewTitle}
547
+ </div>
548
+ {/if}
549
+ {#if pState.view !== "search"}
550
+ <button
551
+ class="std-launcher-back-btn smaller ghost icon text-subtle m-0"
552
+ onclick={() => launcherView("search")}
553
+ aria-label="Go back"
554
+ transition:fade={{ duration: 500 }}
555
+ >
556
+ <Icon icon="back" />
557
+ </button>
558
+ {/if}
559
+ <button
560
+ class="std-launcher-close-btn smaller ghost icon mt-0 text-subtle"
561
+ style="padding:var(--trim) !important;"
562
+ onclick={launcherClose}
563
+ aria-label="Close launcher"
564
+ title="Close"
565
+ >
566
+ <Icon icon="close" />
567
+ </button>
568
+ </div>
569
+ </header>
570
+
571
+ <!-- Body -->
572
+ <div
573
+ bind:this={itemsContainer}
574
+ class="std-launcher-body rhythm mt-0 {coreWindowSize.height}"
575
+ >
576
+ {#if ActiveViewComponent}
577
+ <div
578
+ class="std-launcher-view"
579
+ in:scaleBlurFade={{ duration: 500, easing: quintOut }}
580
+ >
581
+ <ActiveViewComponent
582
+ close={launcherClose}
583
+ back={() => launcherView("search")}
584
+ onStartTyping={(val) => {
585
+ if (val) {
586
+ palette.update((s) => ({ ...s, query: val }));
587
+ }
588
+ inputEl?.focus();
589
+ }}
590
+ {...pState.props}
591
+ {setSize}
592
+ />
593
+ </div>
594
+ {:else if isSearching}
595
+ <div class="std-launcher-empty">Searching...</div>
596
+ {:else if matches.length > 0}
597
+ <div class="no-rhythm flex-col gap-0 p-0 my-half mx-trim">
598
+ {#each matches as item, i (item.id || item.trigger || i)}
599
+ <button
600
+ class="std-launcher-item ghost"
601
+ class:active={i === activeIndex}
602
+ onclickcapture={() => execute(item)}
603
+ onmouseenter={() => (activeIndex = i)}
604
+ >
605
+ <span class="std-launcher-item-icon">
606
+ {#if item.icon}
607
+ <Icon icon={item.icon} />
608
+ {:else}
609
+ <Icon icon="note" width="100%" height="100%" />
610
+ {/if}
611
+ </span>
612
+
613
+ <div class="std-launcher-item-content flex items-center gap-xs">
614
+ <div class="std-launcher-item-title">
615
+ {item.title}
616
+ </div>
617
+ {#if item.desc}
618
+ <div class="std-launcher-item-desc sm:hidden">
619
+ {item.desc}
620
+ </div>
621
+ {/if}
622
+ </div>
623
+
624
+ {#if item.username && item.type === "search-result"}
625
+ <a
626
+ href="/@{item.username}"
627
+ class="std-launcher-item-username"
628
+ aria-label="View {item.username}'s profile"
629
+ onclick={(e) => e.stopPropagation()}
630
+ >
631
+ @{item.username}
632
+ </a>
633
+ {/if}
634
+ </button>
635
+ {/each}
636
+ </div>
637
+ {:else if pState.query}
638
+ <div class="std-launcher-empty">Nothing came up.</div>
639
+ {/if}
640
+ </div>
641
+
642
+ <!-- Footer -->
643
+ <footer class="std-launcher-footer mt-0">
644
+ <div>
645
+ {#if currentUser && currentUser.isAuthenticated}
646
+ <div
647
+ class="std-launcher-user flex items-center gap-xs"
648
+ role="button"
649
+ tabindex="0"
650
+ onclick={() => {
651
+ if (registryState.views["::account-settings"]) {
652
+ launcherView("::account-settings");
653
+ } else if (registryState.views["::settings"]) {
654
+ launcherView("::settings");
655
+ } else {
656
+ launcherView("::my-space");
657
+ }
658
+ }}
659
+ onkeydown={(e) => {
660
+ if (e.key === "Enter" || e.key === " ") {
661
+ if (registryState.views["::account-settings"]) {
662
+ launcherView("::account-settings");
663
+ } else if (registryState.views["::settings"]) {
664
+ launcherView("::settings");
665
+ } else {
666
+ launcherView("::my-space");
667
+ }
668
+ }
669
+ }}
670
+ style="cursor: pointer;"
671
+ >
672
+ <Avatar
673
+ src={currentUser.image}
674
+ name={currentUser.username || currentUser.name}
675
+ email={currentUser.email}
676
+ size="18px"
677
+ border={false}
678
+ />
679
+ <span class="smaller muted">
680
+ {currentUser.name || currentUser.username || currentUser.email}
681
+ </span>
682
+ </div>
683
+ {:else if !shouldHideLogin}
684
+ <button
685
+ class="no-decoration smaller ghost text-subtle"
686
+ onclick={() => launcherView("::login")}
687
+ >
688
+ Login
689
+ </button>
690
+ {/if}
691
+ <div class="std-launcher-mode smaller inline hidden">
692
+ {ActiveViewComponent ? "SYSTEM VIEW" : "COMMAND MODE"}
693
+ </div>
694
+ </div>
695
+
696
+ <div class="std-launcher-hints sm:hidden hidden">
697
+ <span><kbd>↑</kbd> <kbd>↓</kbd> navigate</span>
698
+ <span><kbd>esc</kbd> close</span>
699
+ </div>
700
+ </footer>
701
+ </div>
702
+ </div>
703
+ {/if}
704
+
705
+ <style>
706
+ /* Overlay */
707
+ .std-launcher-overlay {
708
+ position: fixed;
709
+ inset: 0;
710
+ display: flex;
711
+ align-items: flex-start;
712
+ justify-content: center;
713
+ z-index: 5000;
714
+ margin: 0;
715
+ padding-top: var(--launcher-top, 15vh);
716
+ font-family: var(--font-interface, sans-serif);
717
+ &:has(.std-launcher-body.fill) {
718
+ padding-top: var(--gap-body);
719
+ }
720
+ }
721
+
722
+ .std-launcher-backdrop {
723
+ position: absolute;
724
+ inset: 0;
725
+ background: var(--color-glass);
726
+ backdrop-filter: var(--filter-glass);
727
+ overflow: hidden;
728
+ }
729
+
730
+ .std-launcher-backdrop::after {
731
+ content: "";
732
+ position: absolute;
733
+ inset: 0;
734
+ pointer-events: none;
735
+ transform: translateZ(0);
736
+ will-change: opacity;
737
+ }
738
+
739
+ /* Main Container */
740
+ .std-launcher {
741
+ position: relative;
742
+ width: 100%;
743
+ max-width: 400px;
744
+ font-family: var(--font-interface);
745
+ background: var(--color-surface-high);
746
+ border-radius: var(--radius);
747
+ border: 0;
748
+ box-shadow: var(--shadow-raised), var(--shadow-xl);
749
+ transition: all var(--transition-fast);
750
+ overflow: hidden;
751
+
752
+ * {
753
+ text-shadow: none !important;
754
+ }
755
+
756
+ &.compact {
757
+ max-width: 50vw;
758
+ }
759
+ &.standard {
760
+ max-width: 60vw;
761
+ }
762
+
763
+ &.large {
764
+ max-width: 70vw;
765
+ }
766
+ &.fill {
767
+ max-width: calc(100% - (var(--gap-body) * 2));
768
+ }
769
+ }
770
+
771
+ /* Header */
772
+ .std-launcher-header {
773
+ padding: 0 var(--space-d2);
774
+ box-shadow: var(--shadow);
775
+ background-color: var(--color-surface-high);
776
+ text-shadow: none !important;
777
+ box-shadow: var(--shadow-raised), var(--shadow-glow);
778
+ position: relative;
779
+ z-index: 1;
780
+ transition: all var(--transition-fast);
781
+ }
782
+
783
+ .std-launcher-header-inner {
784
+ display: flex;
785
+ align-items: center;
786
+ justify-content: flex-start;
787
+ position: relative;
788
+ width: 100%;
789
+ min-height: 2.5em;
790
+ }
791
+
792
+ .std-launcher-back-btn {
793
+ position: absolute;
794
+ right: calc(var(--space) * 1.25);
795
+ top: 50%;
796
+ transform: translateY(-50%);
797
+ background: transparent;
798
+ border: none;
799
+ color: var(--color-muted);
800
+ cursor: pointer;
801
+ padding: var(--space-d4, 0.25rem);
802
+ border-radius: var(--radius);
803
+ display: flex;
804
+ align-items: center;
805
+ justify-content: center;
806
+ transition: all var(--transition-fast, 0.15s ease);
807
+ z-index: 4;
808
+ }
809
+ .std-launcher-back-btn:hover {
810
+ color: var(--color-foreground);
811
+ background: var(--color-surface-highest);
812
+ }
813
+
814
+ .std-launcher-search-area {
815
+ display: flex;
816
+ flex: 1;
817
+ align-items: center;
818
+ position: relative;
819
+ z-index: 3;
820
+ /* Ensure it takes full height to capture hover */
821
+ align-self: stretch;
822
+ }
823
+
824
+ .std-launcher-input.hidden {
825
+ display: none;
826
+ }
827
+
828
+ .opacity-0 {
829
+ opacity: 0;
830
+ pointer-events: none;
831
+ transition: opacity var(--transition-fast);
832
+ }
833
+
834
+ .std-launcher-title {
835
+ position: absolute;
836
+ left: 50%;
837
+ transform: translateX(-50%);
838
+ font-size: var(--size-sm);
839
+ color: var(--color-muted);
840
+ font-weight: 600;
841
+ font-family: var(--font-interface, sans-serif);
842
+ pointer-events: none;
843
+ white-space: nowrap;
844
+ z-index: 2;
845
+ transition: opacity var(--transition-fast);
846
+ }
847
+
848
+ .std-launcher-search-area .std-launcher-input {
849
+ width: 100%;
850
+ }
851
+
852
+ .std-launcher-input {
853
+ flex: 1;
854
+ background: transparent;
855
+ border: none;
856
+ outline: none !important;
857
+ font-weight: 600;
858
+ font-family: var(--font-interface, sans-serif);
859
+ font-size: var(--size-sm);
860
+ color: var(--color-muted);
861
+ caret-color: var(--color-accent);
862
+ margin: 0;
863
+ padding: 0 var(--space) 0 0;
864
+ box-shadow: none;
865
+ border-radius: 0 !important;
866
+ text-shadow: none !important;
867
+ z-index: 3;
868
+ min-width: 0;
869
+ opacity: 1;
870
+ transition: opacity var(--transition-fast);
871
+ }
872
+
873
+ .std-launcher-close-btn {
874
+ position: absolute;
875
+ right: var(--space-d3, 0.5rem);
876
+ background: transparent;
877
+ border: none;
878
+ color: var(--color-muted);
879
+ cursor: pointer;
880
+ padding: var(--space-d4, 0.25rem);
881
+ border-radius: var(--radius);
882
+ display: flex;
883
+ align-items: center;
884
+ justify-content: center;
885
+ transition: all var(--transition-fast, 0.15s ease);
886
+ z-index: 4;
887
+ }
888
+ .std-launcher-close-btn:hover {
889
+ color: var(--color-foreground);
890
+ background: var(--color-surface-highest);
891
+ }
892
+
893
+ /* Body */
894
+ .std-launcher-body {
895
+ overflow-y: auto;
896
+ transition: all var(--transition-fast);
897
+ }
898
+
899
+ .std-launcher-view > :first-child {
900
+ padding: var(--space);
901
+ }
902
+
903
+ .std-launcher-body.standard {
904
+ max-height: 30vh;
905
+ }
906
+
907
+ .std-launcher-body.large {
908
+ max-height: 70vh;
909
+ }
910
+
911
+ .std-launcher-body.fill {
912
+ max-height: calc(100% - (var(--gap-body) * 2)) !important;
913
+ }
914
+
915
+ /* List Items */
916
+ .std-launcher-item {
917
+ display: flex;
918
+ min-height: var(--space);
919
+ align-items: center;
920
+ gap: var(--leading);
921
+ width: 100%;
922
+ padding: var(--trim) var(--leading) !important;
923
+ background: transparent;
924
+ margin: 0 !important;
925
+ border: none;
926
+ border-radius: var(--radius);
927
+ cursor: pointer;
928
+
929
+ text-align: left;
930
+ transition: all var(--transition-fast);
931
+ box-shadow: none;
932
+ }
933
+
934
+ .std-launcher-item-title {
935
+ color: var(--color-muted);
936
+ font-size: var(--size-sm);
937
+ text-overflow: ellipsis;
938
+ white-space: nowrap;
939
+ overflow: hidden;
940
+ text-box-trim: none;
941
+ text-shadow: none !important;
942
+ }
943
+
944
+ .std-launcher-item-desc {
945
+ font-size: var(--size-2xs);
946
+ color: var(--color-subtle);
947
+ font-weight: 400;
948
+ text-align: left;
949
+ text-shadow: none !important;
950
+ white-space: nowrap;
951
+ overflow: hidden;
952
+ text-overflow: ellipsis;
953
+ }
954
+
955
+ @media (max-width: 640px) {
956
+ .std-launcher-item-desc {
957
+ display: none;
958
+ }
959
+ }
960
+
961
+ .std-launcher-item:hover,
962
+ .std-launcher-item.active {
963
+ background: var(--color-surface-highest);
964
+ box-shadow: var(--shadow);
965
+ .std-launcher-item-icon {
966
+ color: var(--color-accent);
967
+ }
968
+ .std-launcher-item-title {
969
+ color: var(--color-accent);
970
+ }
971
+
972
+ .std-launcher-item-desc {
973
+ color: var(--color-foreground);
974
+ }
975
+ }
976
+
977
+ .std-launcher-item:active {
978
+ box-shadow: var(--shadow-inset);
979
+ background: var(--color-surface-low);
980
+ }
981
+ .std-launcher-item-content {
982
+ flex: 1;
983
+ min-width: 0;
984
+ }
985
+
986
+ .std-launcher-item-username {
987
+ color: var(--color-accent);
988
+ font-size: var(--scale-xs);
989
+ text-decoration: none;
990
+ white-space: nowrap;
991
+ flex-shrink: 0;
992
+ }
993
+
994
+ .std-launcher-item-icon {
995
+ display: flex;
996
+ align-items: center;
997
+ justify-content: center;
998
+ width: 1rem;
999
+ height: 1rem;
1000
+ flex-shrink: 0;
1001
+ opacity: 0.5;
1002
+
1003
+ filter: blur(0.2px);
1004
+ transition: all var(--duration-fast) var(--ease-snappy);
1005
+ }
1006
+
1007
+ .std-launcher-empty {
1008
+ padding: var(--space-2) 0;
1009
+ text-align: center;
1010
+ color: var(--color-subtle);
1011
+ font-style: italic;
1012
+ font-size: var(--scale-d2);
1013
+ }
1014
+
1015
+ /* Footer */
1016
+ .std-launcher-footer {
1017
+ display: flex;
1018
+ justify-content: space-between;
1019
+ align-items: center;
1020
+ padding: var(--space-d2) var(--space-d2);
1021
+ font-size: var(--size-xs);
1022
+ color: var(--color-muted);
1023
+ border-top: var(--border);
1024
+ background-color: var(--color-surface-high);
1025
+
1026
+ box-shadow: var(--shadow-raised);
1027
+ }
1028
+
1029
+ .std-launcher-user {
1030
+ display: flex;
1031
+ align-items: center;
1032
+ gap: var(--space-d4);
1033
+ color: var(--color-foreground-muted);
1034
+ padding: var(--space-d4);
1035
+ border-radius: var(--radius);
1036
+ transition: all var(--transition-fast);
1037
+ }
1038
+
1039
+ .std-launcher-user:hover {
1040
+ background: var(--color-surface-highest);
1041
+ color: var(--color-foreground);
1042
+ }
1043
+
1044
+ .std-launcher-user span {
1045
+ max-width: 150px;
1046
+ white-space: nowrap;
1047
+ overflow: hidden;
1048
+ text-overflow: ellipsis;
1049
+ }
1050
+
1051
+ .std-launcher-hints {
1052
+ display: flex;
1053
+ gap: 1rem;
1054
+ }
1055
+
1056
+ /* Mobile: Bottom Sheet */
1057
+ @media (max-width: 640px) {
1058
+ .std-launcher-overlay {
1059
+ align-items: flex-start;
1060
+ padding-top: var(--launcher-mobile-top, 1rem);
1061
+ }
1062
+ .std-launcher {
1063
+ margin-inline: 1rem;
1064
+ max-width: 100% !important;
1065
+ }
1066
+ }
1067
+ </style>