@svadmin/ui 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@svadmin/ui",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "description": "Pre-built admin UI components — AdminApp, AutoTable, AutoForm, Sidebar, Layout",
5
5
  "type": "module",
6
6
  "sideEffects": [
package/src/app.css CHANGED
@@ -34,6 +34,12 @@
34
34
  --chart-3: oklch(0.398 0.07 227.392);
35
35
  --chart-4: oklch(0.828 0.189 84.429);
36
36
  --chart-5: oklch(0.769 0.188 70.08);
37
+ --success: oklch(0.6 0.2 145);
38
+ --success-foreground: oklch(0.985 0 0);
39
+ --warning: oklch(0.75 0.18 85);
40
+ --warning-foreground: oklch(0.3 0.08 60);
41
+ --info: oklch(0.6 0.15 250);
42
+ --info-foreground: oklch(0.985 0 0);
37
43
  --sidebar: oklch(1 0 0 / 80%);
38
44
  --sidebar-foreground: oklch(0.13 0.02 265);
39
45
  --sidebar-primary: oklch(0.488 0.243 264.376);
@@ -69,6 +75,12 @@
69
75
  --chart-3: oklch(0.769 0.188 70.08);
70
76
  --chart-4: oklch(0.627 0.265 303.9);
71
77
  --chart-5: oklch(0.645 0.246 16.439);
78
+ --success: oklch(0.65 0.2 150);
79
+ --success-foreground: oklch(0.985 0 0);
80
+ --warning: oklch(0.8 0.15 85);
81
+ --warning-foreground: oklch(0.25 0.05 60);
82
+ --info: oklch(0.65 0.15 250);
83
+ --info-foreground: oklch(0.985 0 0);
72
84
  --sidebar: oklch(0.16 0.015 265 / 80%);
73
85
  --sidebar-foreground: oklch(0.985 0 0);
74
86
  --sidebar-primary: oklch(0.585 0.233 264);
@@ -146,6 +158,12 @@ body {
146
158
  --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
147
159
  --color-sidebar-border: var(--sidebar-border);
148
160
  --color-sidebar-ring: var(--sidebar-ring);
161
+ --color-success: var(--success);
162
+ --color-success-foreground: var(--success-foreground);
163
+ --color-warning: var(--warning);
164
+ --color-warning-foreground: var(--warning-foreground);
165
+ --color-info: var(--info);
166
+ --color-info-foreground: var(--info-foreground);
149
167
  --radius-sm: calc(var(--radius) - 4px);
150
168
  --radius-md: calc(var(--radius) - 2px);
151
169
  --radius-lg: var(--radius);
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Component Registry — Svelte Context-based DI for component injection.
3
+ *
4
+ * Allows users to override any registered component via the `components`
5
+ * prop on `AdminApp`, while providing sensible defaults.
6
+ */
7
+ import { getContext, setContext, type Component } from 'svelte';
8
+
9
+ // ─── Overridable component slots ────────────────────────────────
10
+ export interface ComponentRegistry {
11
+ // Layout primitives
12
+ Layout: Component<any>;
13
+ Sidebar: Component<any>;
14
+ Header: Component<any>;
15
+
16
+ // Pages
17
+ LoginPage: Component<any>;
18
+ AutoTable: Component<any>;
19
+ AutoForm: Component<any>;
20
+ ShowPage: Component<any>;
21
+
22
+ // Shadcn primitives
23
+ Button: Component<any>;
24
+ Input: Component<any>;
25
+ Badge: Component<any>;
26
+ Skeleton: Component<any>;
27
+ }
28
+
29
+ const REGISTRY_KEY = 'svadmin:components';
30
+
31
+ /** Set the component registry in context (called by AdminApp). */
32
+ export function setComponentRegistry(registry: ComponentRegistry): void {
33
+ setContext(REGISTRY_KEY, registry);
34
+ }
35
+
36
+ /** Retrieve the full component registry from context. */
37
+ export function getComponentRegistry(): ComponentRegistry {
38
+ return getContext<ComponentRegistry>(REGISTRY_KEY);
39
+ }
40
+
41
+ /** Retrieve a single component by name from the registry. */
42
+ export function useComponent<K extends keyof ComponentRegistry>(
43
+ name: K,
44
+ ): ComponentRegistry[K] {
45
+ return getComponentRegistry()[name];
46
+ }
@@ -0,0 +1,180 @@
1
+ <script lang="ts">
2
+ import { getResources, getChatProvider } from '@svadmin/core';
3
+ import { navigate } from '@svadmin/core/router';
4
+ import { t } from '@svadmin/core/i18n';
5
+ import { toggleTheme } from '@svadmin/core';
6
+ import { Command } from 'cmdk-sv';
7
+ import * as Dialog from './ui/dialog/index.js';
8
+ import { Search, LayoutDashboard, Plus, Sun, FileText, Sparkles, Loader2, Send } from 'lucide-svelte';
9
+ import MarkdownRenderer from './MarkdownRenderer.svelte';
10
+
11
+ let { open = $bindable(false) } = $props<{ open?: boolean }>();
12
+ let searchValue = $state('');
13
+
14
+ const resources = getResources();
15
+ const provider = $derived(getChatProvider());
16
+
17
+ // AI State
18
+ let aiMode = $state(false);
19
+ let aiResponse = $state('');
20
+ let isPredicting = $state(false);
21
+ let abortController: AbortController | null = null;
22
+
23
+ function close() {
24
+ open = false;
25
+ searchValue = '';
26
+ aiMode = false;
27
+ aiResponse = '';
28
+ if (abortController) {
29
+ abortController.abort();
30
+ abortController = null;
31
+ }
32
+ }
33
+
34
+ function act(fn: () => void) {
35
+ fn();
36
+ close();
37
+ }
38
+
39
+ async function askAI() {
40
+ if (!provider || !searchValue.trim()) return;
41
+
42
+ aiMode = true;
43
+ isPredicting = true;
44
+ aiResponse = '';
45
+
46
+ if (abortController) abortController.abort();
47
+ abortController = new AbortController();
48
+
49
+ try {
50
+ const result = provider.sendMessage(
51
+ [{ id: 'cmd', role: 'user', content: searchValue, timestamp: Date.now() }],
52
+ { signal: abortController.signal }
53
+ );
54
+
55
+ if (Symbol.asyncIterator in result) {
56
+ for await (const chunk of result as AsyncIterable<string>) {
57
+ aiResponse += chunk;
58
+ }
59
+ } else {
60
+ aiResponse = await result;
61
+ }
62
+ } catch (err: unknown) {
63
+ if (err instanceof Error && err.name !== 'AbortError') {
64
+ aiResponse = 'Failed to get response from AI.';
65
+ }
66
+ } finally {
67
+ isPredicting = false;
68
+ }
69
+ }
70
+
71
+ function handleKeyDown(e: KeyboardEvent) {
72
+ if (e.key === 'Enter' && searchValue.trim() && !aiMode) {
73
+ // If pressing enter on an active search, cmdk usually handles selection.
74
+ // But if there's no selection or we want to force AI, we can intercept.
75
+ // For simplicity, we trigger AI on Ctrl+Enter
76
+ if (e.ctrlKey || e.metaKey) {
77
+ e.preventDefault();
78
+ askAI();
79
+ }
80
+ }
81
+ }
82
+
83
+ const itemClass = 'relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50';
84
+ </script>
85
+
86
+ <Dialog.Dialog bind:open onOpenChange={(v: boolean) => !v && close()}>
87
+ <Dialog.DialogContent class="overflow-hidden p-0 sm:max-w-[600px] border-none shadow-2xl">
88
+ <Command.Root
89
+ shouldFilter={!aiMode}
90
+ class="flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground [&_[data-cmdk-group-heading]]:text-muted-foreground [&_[data-cmdk-group-heading]]:px-2 [&_[data-cmdk-group-heading]]:py-1.5 [&_[data-cmdk-group-heading]]:text-xs [&_[data-cmdk-group-heading]]:font-semibold [&_[data-cmdk-group-heading]]:uppercase [&_[data-cmdk-group-heading]]:tracking-wider"
91
+ >
92
+ <div class="flex items-center border-b px-3 h-12 relative" data-cmdk-input-wrapper="">
93
+ {#if aiMode}
94
+ <Sparkles class="mr-2 h-4 w-4 shrink-0 text-warning animate-pulse" />
95
+ {:else}
96
+ <Search class="mr-2 h-4 w-4 shrink-0 opacity-50" />
97
+ {/if}
98
+ <Command.Input
99
+ bind:value={searchValue}
100
+ onkeydown={handleKeyDown}
101
+ placeholder={aiMode ? "Ask AI anything..." : "Search commands or ask AI (Ctrl+Enter)..."}
102
+ class="flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50"
103
+ />
104
+ {#if searchValue.trim() && !aiMode}
105
+ <button
106
+ class="absolute right-3 flex items-center gap-1 text-[10px] font-medium text-warning bg-warning/10 hover:bg-warning/20 px-2 py-1 rounded transition-colors"
107
+ onclick={askAI}
108
+ >
109
+ <Sparkles class="h-3 w-3" />
110
+ <kbd class="font-sans">Ctrl+Enter</kbd> to Ask AI
111
+ </button>
112
+ {/if}
113
+ </div>
114
+
115
+ {#if !aiMode}
116
+ <Command.List class="max-h-[360px] overflow-y-auto overflow-x-hidden p-1">
117
+ <Command.Empty class="py-6 text-center text-sm text-muted-foreground">
118
+ No standard commands found. Press <kbd class="px-1 py-0.5 rounded bg-muted border font-sans text-[10px]">Ctrl+Enter</kbd> to ask AI.
119
+ </Command.Empty>
120
+
121
+ <!-- Navigation -->
122
+ <Command.Group heading={t('common.home')}>
123
+ <Command.Item value="home" onSelect={() => act(() => navigate('/'))} class={itemClass}>
124
+ <LayoutDashboard class="h-4 w-4 text-muted-foreground" />
125
+ {t('common.home')}
126
+ </Command.Item>
127
+ {#each resources as r}
128
+ <Command.Item value={r.name} onSelect={() => act(() => navigate(`/${r.name}`))} class={itemClass}>
129
+ <FileText class="h-4 w-4 text-muted-foreground" />
130
+ {r.label}
131
+ </Command.Item>
132
+ {/each}
133
+ </Command.Group>
134
+
135
+ <Command.Separator class="mx-1 my-1 h-px bg-border" />
136
+
137
+ <!-- Actions -->
138
+ <Command.Group heading={t('common.actions')}>
139
+ {#each resources as r}
140
+ <Command.Item value={`create-${r.name}`} onSelect={() => act(() => navigate(`/${r.name}/create`))} class={itemClass}>
141
+ <Plus class="h-4 w-4 text-muted-foreground" />
142
+ {t('common.create')} {r.label}
143
+ </Command.Item>
144
+ {/each}
145
+ <Command.Item value="toggle-theme" onSelect={() => act(() => toggleTheme())} class={itemClass}>
146
+ <Sun class="h-4 w-4 text-muted-foreground" />
147
+ {t('common.toggleTheme')}
148
+ </Command.Item>
149
+ </Command.Group>
150
+ </Command.List>
151
+ {:else}
152
+ <!-- AI Response Area -->
153
+ <div class="h-[360px] max-h-[360px] overflow-y-auto p-4 bg-muted/20">
154
+ <div class="flex items-start gap-3">
155
+ <div class="mt-0.5 w-8 h-8 rounded-full bg-warning/10 text-warning flex items-center justify-center shrink-0">
156
+ <Sparkles class="h-4 w-4" />
157
+ </div>
158
+ <div class="flex-1 w-full text-sm">
159
+ {#if !aiResponse && isPredicting}
160
+ <div class="flex items-center gap-2 text-muted-foreground mt-1.5">
161
+ <Loader2 class="h-4 w-4 animate-spin" /> Thinking...
162
+ </div>
163
+ {:else}
164
+ <MarkdownRenderer content={aiResponse} streaming={isPredicting} />
165
+ {/if}
166
+ </div>
167
+ </div>
168
+ </div>
169
+ <div class="p-2 border-t flex justify-end gap-2 bg-muted/10">
170
+ <button
171
+ class="text-xs px-3 py-1.5 rounded-md hover:bg-muted transition-colors text-muted-foreground"
172
+ onclick={() => { aiMode = false; searchValue = ''; }}
173
+ >
174
+ Clear & Return
175
+ </button>
176
+ </div>
177
+ {/if}
178
+ </Command.Root>
179
+ </Dialog.DialogContent>
180
+ </Dialog.Dialog>
@@ -7,6 +7,7 @@
7
7
  import { t } from '@svadmin/core/i18n';
8
8
  import { navigate } from '@svadmin/core/router';
9
9
  import { QueryClient, QueryClientProvider } from '@tanstack/svelte-query';
10
+ import { setComponentRegistry, type ComponentRegistry } from '../component-registry.svelte.js';
10
11
  import Layout from './Layout.svelte';
11
12
  import AutoTable from './AutoTable.svelte';
12
13
  import AutoForm from './AutoForm.svelte';
@@ -18,6 +19,12 @@
18
19
  import UpdatePasswordPage from './UpdatePasswordPage.svelte';
19
20
  import ConfigErrorScreen from './ConfigErrorScreen.svelte';
20
21
  import DevTools from './DevTools.svelte';
22
+ import Sidebar from './Sidebar.svelte';
23
+ import Header from './Header.svelte';
24
+ import { Button } from './ui/button/index.js';
25
+ import { Input } from './ui/input/index.js';
26
+ import { Badge } from './ui/badge/index.js';
27
+ import { Skeleton } from './ui/skeleton/index.js';
21
28
  import { initRouter, getRoute, getParams } from '../router-state.svelte.js';
22
29
 
23
30
  interface Props {
@@ -30,6 +37,8 @@
30
37
  defaultTheme?: ThemeMode;
31
38
  dashboard?: Snippet;
32
39
  loginPage?: Snippet;
40
+ /** Override default components via DI */
41
+ components?: Partial<ComponentRegistry>;
33
42
  }
34
43
 
35
44
  let {
@@ -42,8 +51,22 @@
42
51
  defaultTheme,
43
52
  dashboard,
44
53
  loginPage,
54
+ components: userComponents,
45
55
  }: Props = $props();
46
56
 
57
+ // Default component registry
58
+ const defaultComponents: ComponentRegistry = {
59
+ Layout, Sidebar, Header,
60
+ LoginPage, AutoTable, AutoForm, ShowPage,
61
+ Button: Button as unknown as ComponentRegistry['Button'],
62
+ Input: Input as unknown as ComponentRegistry['Input'],
63
+ Badge: Badge as unknown as ComponentRegistry['Badge'],
64
+ Skeleton: Skeleton as unknown as ComponentRegistry['Skeleton'],
65
+ };
66
+
67
+ // Merge user overrides and set context
68
+ setComponentRegistry({ ...defaultComponents, ...userComponents });
69
+
47
70
  // Resolve router provider (default to hash)
48
71
  const resolvedRouter = $derived(routerProvider ?? createHashRouterProvider());
49
72
 
@@ -0,0 +1,65 @@
1
+ <script lang="ts">
2
+ import { Badge } from './ui/badge/index.js';
3
+ import { TrendingUp, TrendingDown, AlertTriangle } from 'lucide-svelte';
4
+ import TooltipButton from './TooltipButton.svelte';
5
+
6
+ interface Props {
7
+ /** The actual current value */
8
+ value: number;
9
+ /** The baseline/expected value to compare against */
10
+ baseline: number;
11
+ /** Threshold percentage to trigger an anomaly (e.g. 0.2 means 20% diff) */
12
+ threshold?: number;
13
+ /** Optional label formatting */
14
+ formatter?: (val: number) => string;
15
+ /** If true, lower is better (e.g. latency). Default false (higher is better e.g. revenue). */
16
+ lowerIsBetter?: boolean;
17
+ class?: string;
18
+ }
19
+
20
+ let {
21
+ value,
22
+ baseline,
23
+ threshold = 0.2,
24
+ formatter = (v) => v.toString(),
25
+ lowerIsBetter = false,
26
+ class: className = ''
27
+ }: Props = $props();
28
+
29
+ // Calculate percentage difference
30
+ const diff = $derived(baseline === 0 ? 0 : (value - baseline) / Math.abs(baseline));
31
+ const isAnomaly = $derived(Math.abs(diff) >= threshold);
32
+
33
+ // Determine sentiment (good, bad, neutral)
34
+ const isGood = $derived(
35
+ !isAnomaly ? null :
36
+ (lowerIsBetter ? diff < 0 : diff > 0)
37
+ );
38
+
39
+ const percentLabel = $derived(`${Math.abs(diff * 100).toFixed(1)}%`);
40
+ </script>
41
+
42
+ {#if !isAnomaly}
43
+ <span class="text-muted-foreground text-sm font-medium {className}">
44
+ {formatter(value)}
45
+ </span>
46
+ {:else}
47
+ <TooltipButton
48
+ tooltip={`Baseline: ${formatter(baseline)} (${diff > 0 ? '+' : '-'}${percentLabel})`}
49
+ variant="ghost"
50
+ size="sm"
51
+ class="p-0 h-auto hover:bg-transparent"
52
+ >
53
+ <Badge
54
+ variant={isGood ? 'default' : 'destructive'}
55
+ class="flex items-center gap-1 font-mono text-xs px-1.5 py-0 {isGood ? 'bg-success hover:bg-success/90 text-success-foreground' : ''} {className}"
56
+ >
57
+ {#if diff > 0}
58
+ <TrendingUp class="h-3 w-3" />
59
+ {:else}
60
+ <TrendingDown class="h-3 w-3" />
61
+ {/if}
62
+ {formatter(value)}
63
+ </Badge>
64
+ </TooltipButton>
65
+ {/if}
@@ -34,10 +34,10 @@
34
34
  // Action semantic color mapping
35
35
  function getActionColor(action: string) {
36
36
  switch (action) {
37
- case 'create': return 'bg-green-500';
38
- case 'update': return 'bg-blue-500';
39
- case 'delete': return 'bg-red-500';
40
- default: return 'bg-slate-400';
37
+ case 'create': return 'bg-success';
38
+ case 'update': return 'bg-info';
39
+ case 'delete': return 'bg-destructive';
40
+ default: return 'bg-muted-foreground';
41
41
  }
42
42
  }
43
43
  </script>
@@ -224,7 +224,7 @@
224
224
  </TooltipButton>
225
225
  <h1 class="text-xl font-semibold text-foreground">{pageTitle}</h1>
226
226
  {#if isDirty}
227
- <Badge variant="outline" class="border-amber-200 bg-amber-50 text-amber-700">{t('common.unsaved')}</Badge>
227
+ <Badge variant="outline" class="border-warning/30 bg-warning/10 text-warning-foreground">{t('common.unsaved')}</Badge>
228
228
  {/if}
229
229
  </div>
230
230
 
@@ -523,7 +523,7 @@
523
523
  {#if cellRenderer && field}
524
524
  {@render cellRenderer({ field, value: cell.getValue(), record })}
525
525
  {:else if field?.type === 'boolean'}
526
- <span class="inline-block h-2 w-2 rounded-full {cell.getValue() ? 'bg-green-500' : 'bg-muted-foreground/30'}"></span>
526
+ <span class="inline-block h-2 w-2 rounded-full {cell.getValue() ? 'bg-success' : 'bg-muted-foreground/30'}"></span>
527
527
  {:else if field?.type === 'date' && cell.getValue()}
528
528
  {new Date(cell.getValue() as string).toLocaleDateString()}
529
529
  {:else if field?.type === 'tags' && Array.isArray(cell.getValue())}
@@ -635,7 +635,7 @@
635
635
  {#if cellRenderer}
636
636
  {@render cellRenderer({ field, value, record })}
637
637
  {:else if field.type === 'boolean'}
638
- <span class="inline-block h-2 w-2 rounded-full {value ? 'bg-green-500' : 'bg-muted-foreground/30'}"></span>
638
+ <span class="inline-block h-2 w-2 rounded-full {value ? 'bg-success' : 'bg-muted-foreground/30'}"></span>
639
639
  {:else if field.type === 'date' && value}
640
640
  {new Date(value as string).toLocaleDateString()}
641
641
  {:else if field.type === 'tags' && Array.isArray(value)}
@@ -0,0 +1,163 @@
1
+ <script lang="ts">
2
+ import { getChatProvider, getChatContext } from '@svadmin/core';
3
+ import { Sparkles, X, ChevronRight, Check, RefreshCcw } from 'lucide-svelte';
4
+ import { fade, slide } from 'svelte/transition';
5
+ import { Button } from './ui/button/index.js';
6
+ import MarkdownRenderer from './MarkdownRenderer.svelte';
7
+ import TooltipButton from './TooltipButton.svelte';
8
+
9
+ let { open = $bindable(false) } = $props<{ open?: boolean }>();
10
+
11
+ const provider = $derived(getChatProvider());
12
+ const ctx = $derived(getChatContext());
13
+
14
+ let isPredicting = $state(false);
15
+ let insightText = $state('');
16
+ let abortController: AbortController | null = null;
17
+ let hasFetchedForContext = $state('');
18
+
19
+ // Automatically request insights when the context changes
20
+ $effect(() => {
21
+ if (!open || !provider) return;
22
+
23
+ // Create a context hash to avoid refetching for the same page
24
+ const currentHash = `${ctx.currentResource}-${ctx.currentView}-${ctx.selectedRecordId}`;
25
+ if (hasFetchedForContext === currentHash) return;
26
+
27
+ hasFetchedForContext = currentHash;
28
+ getInsights();
29
+ });
30
+
31
+ async function getInsights() {
32
+ if (!provider) return;
33
+
34
+ isPredicting = true;
35
+ insightText = '';
36
+
37
+ if (abortController) abortController.abort();
38
+ abortController = new AbortController();
39
+
40
+ const prompt = `Analyze the current context and provide 3 quick insights or context-aware suggestions for the admin user.
41
+ Resource: ${ctx.currentResource}
42
+ View: ${ctx.currentView}
43
+ Record ID: ${ctx.selectedRecordId || 'None'}
44
+ Keep it concise, professional, and use bullet points.`;
45
+
46
+ try {
47
+ const result = provider.sendMessage(
48
+ [{ id: 'insights', role: 'system', content: prompt, timestamp: Date.now() }],
49
+ { signal: abortController.signal }
50
+ );
51
+
52
+ if (Symbol.asyncIterator in result) {
53
+ for await (const chunk of result as AsyncIterable<string>) {
54
+ insightText += chunk;
55
+ }
56
+ } else {
57
+ insightText = await result;
58
+ }
59
+ } catch (err: unknown) {
60
+ if (err instanceof Error && err.name !== 'AbortError') {
61
+ insightText = 'Failed to generate insights for this context.';
62
+ }
63
+ } finally {
64
+ isPredicting = false;
65
+ }
66
+ }
67
+ </script>
68
+
69
+ {#if open}
70
+ <div
71
+ class="fixed right-0 top-14 bottom-0 w-80 bg-card border-l shadow-2xl z-40 flex flex-col"
72
+ transition:slide={{ axis: 'x', duration: 300 }}
73
+ >
74
+ <!-- Header -->
75
+ <div class="h-14 flex items-center justify-between px-4 border-b bg-muted/20">
76
+ <div class="flex items-center gap-2 font-medium">
77
+ <Sparkles class="h-4 w-4 text-primary" />
78
+ Copilot Sidebar
79
+ </div>
80
+ <TooltipButton tooltip="Close panel" variant="ghost" size="icon-sm" onclick={() => open = false}>
81
+ <X class="h-4 w-4" />
82
+ </TooltipButton>
83
+ </div>
84
+
85
+ <!-- Context Meta -->
86
+ <div class="px-4 py-3 border-b bg-muted/10 text-xs text-muted-foreground flex flex-col gap-1">
87
+ <div class="flex items-center gap-2">
88
+ <span class="w-16">Resource:</span>
89
+ <span class="font-mono text-foreground">{ctx.currentResource || 'Dashboard'}</span>
90
+ </div>
91
+ <div class="flex items-center gap-2">
92
+ <span class="w-16">View:</span>
93
+ <span class="font-mono text-foreground capitalize">{ctx.currentView || 'List'}</span>
94
+ </div>
95
+ {#if ctx.selectedRecordId}
96
+ <div class="flex items-center gap-2">
97
+ <span class="w-16">Record ID:</span>
98
+ <span class="font-mono text-foreground">{ctx.selectedRecordId}</span>
99
+ </div>
100
+ {/if}
101
+ </div>
102
+
103
+ <!-- Content Area -->
104
+ <div class="flex-1 overflow-y-auto p-4 space-y-6">
105
+
106
+ <!-- Smart Insights Section -->
107
+ <div>
108
+ <h4 class="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-3 flex items-center justify-between">
109
+ Smart Insights
110
+ {#if isPredicting}
111
+ <span class="flex items-center gap-1 text-[10px] text-primary normal-case font-medium">
112
+ <div class="h-1 w-1 bg-primary rounded-full animate-bounce delay-75"></div>
113
+ <div class="h-1 w-1 bg-primary rounded-full animate-bounce delay-150"></div>
114
+ <div class="h-1 w-1 bg-primary rounded-full animate-bounce delay-300"></div>
115
+ </span>
116
+ {:else}
117
+ <Button variant="ghost" size="icon-sm" class="h-5 w-5 hover:bg-muted" onclick={getInsights}>
118
+ <RefreshCcw class="h-3 w-3" />
119
+ </Button>
120
+ {/if}
121
+ </h4>
122
+
123
+ <div class="text-sm">
124
+ {#if !insightText && !isPredicting}
125
+ <div class="text-muted-foreground italic text-center py-4 bg-muted/30 rounded-lg border border-dashed">
126
+ No insights loaded.
127
+ </div>
128
+ {:else}
129
+ <div class="bg-primary/5 p-3 rounded-xl border border-primary/10 prose prose-sm dark:prose-invert prose-p:my-1 prose-ul:my-1 prose-li:my-0.5">
130
+ <MarkdownRenderer content={insightText} streaming={isPredicting} />
131
+ </div>
132
+ {/if}
133
+ </div>
134
+ </div>
135
+
136
+ <!-- Quick Actions (Example static definitions for the copilot) -->
137
+ <div>
138
+ <h4 class="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-3">
139
+ Suggested Actions
140
+ </h4>
141
+ <div class="space-y-2">
142
+ {#if ctx.currentView === 'list'}
143
+ <button class="w-full flex items-center gap-2 text-left p-2 rounded-lg hover:bg-muted border border-transparent hover:border-border transition-colors text-sm">
144
+ <div class="bg-background rounded shadow-sm p-1"><Check class="h-3.5 w-3.5 text-muted-foreground" /></div>
145
+ <span>Generate monthly report</span>
146
+ </button>
147
+ <button class="w-full flex items-center gap-2 text-left p-2 rounded-lg hover:bg-muted border border-transparent hover:border-border transition-colors text-sm">
148
+ <div class="bg-background rounded shadow-sm p-1"><Check class="h-3.5 w-3.5 text-muted-foreground" /></div>
149
+ <span>Find anomalies in {ctx.currentResource}</span>
150
+ </button>
151
+ {:else if ctx.currentView === 'edit' || ctx.currentView === 'create'}
152
+ <button class="w-full flex items-center gap-2 text-left p-2 rounded-lg hover:bg-muted border border-transparent hover:border-border transition-colors text-sm">
153
+ <div class="bg-background rounded shadow-sm p-1"><Check class="h-3.5 w-3.5 text-muted-foreground" /></div>
154
+ <span>Auto-fill form from context</span>
155
+ </button>
156
+ {:else}
157
+ <div class="text-muted-foreground text-sm py-2 text-center">No precise actions for this view</div>
158
+ {/if}
159
+ </div>
160
+ </div>
161
+ </div>
162
+ </div>
163
+ {/if}
@@ -34,14 +34,14 @@
34
34
  const locale = $derived(getLocale());
35
35
 
36
36
  // Auto-trace state changes in dev mode via Svelte 5 $inspect
37
- if (import.meta.env?.DEV) {
37
+ if ((import.meta as any).env?.DEV) {
38
38
  $inspect({ path, theme, colorTheme, locale, resourceCount: resources.length });
39
39
  }
40
40
  </script>
41
41
 
42
42
  <svelte:window onkeydown={handleKeydown} />
43
43
 
44
- {#if import.meta.env.DEV}
44
+ {#if (import.meta as any).env?.DEV}
45
45
  {#if visible}
46
46
  <div
47
47
  class="fixed bottom-0 right-4 z-[9999] w-[420px] max-w-[95vw] rounded-t-xl border border-b-0 bg-card shadow-2xl text-[0.8125rem] overflow-hidden"
@@ -221,7 +221,7 @@
221
221
  {#if multiVal.length > 0}
222
222
  <div class="flex flex-wrap gap-1 mt-1">
223
223
  {#each multiVal as v}
224
- {@const label = field.options?.find((o: any) => o.value === v)?.label ?? String(v)}
224
+ {@const label = field.options?.find((o: { label: string; value: string | number }) => o.value === v)?.label ?? String(v)}
225
225
  <Badge variant="secondary" class="gap-1">
226
226
  {label}
227
227
  <button type="button" onclick={() => toggleMulti(v)} class="ml-0.5 rounded-sm hover:text-destructive hover:bg-destructive/10 transition-colors" aria-label={t('common.clear')}>×</button>
@@ -21,7 +21,7 @@
21
21
  }>();
22
22
  </script>
23
23
 
24
- <header class="sticky top-0 z-30 flex h-14 w-full shrink-0 items-center justify-between bg-white/60 dark:bg-slate-900/60 backdrop-blur-md px-4 md:px-6">
24
+ <header class="sticky top-0 z-30 flex h-14 w-full shrink-0 items-center justify-between bg-card/60 backdrop-blur-md px-4 md:px-6">
25
25
  <div class="flex items-center gap-4">
26
26
  {#if children}
27
27
  {@render children()}
@@ -0,0 +1,98 @@
1
+ <script lang="ts">
2
+ import { getChatProvider } from '@svadmin/core';
3
+ import { Sparkles, RefreshCcw, Loader2 } from 'lucide-svelte';
4
+ import { Button } from './ui/button/index.js';
5
+ import MarkdownRenderer from './MarkdownRenderer.svelte';
6
+
7
+ interface Props {
8
+ title?: string;
9
+ context: string;
10
+ autoFetch?: boolean;
11
+ class?: string;
12
+ }
13
+
14
+ let { title = 'AI Insights', context, autoFetch = true, class: className = '' }: Props = $props();
15
+
16
+ const provider = $derived(getChatProvider());
17
+
18
+ let isPredicting = $state(false);
19
+ let insightText = $state('');
20
+ let abortController: AbortController | null = null;
21
+ let hasFetchedForContext = $state('');
22
+
23
+ $effect(() => {
24
+ if (autoFetch && context && provider && hasFetchedForContext !== context) {
25
+ hasFetchedForContext = context;
26
+ getInsights();
27
+ }
28
+ });
29
+
30
+ export async function getInsights() {
31
+ if (!provider || !context) return;
32
+
33
+ isPredicting = true;
34
+ insightText = '';
35
+
36
+ if (abortController) abortController.abort();
37
+ abortController = new AbortController();
38
+
39
+ const prompt = `Based on the following data/context, generate 3 concise, highly professional insights.
40
+ Use bullet points. Do not include introductory text like "Here are the insights".
41
+
42
+ Data Context:
43
+ ${context}`;
44
+
45
+ try {
46
+ const result = provider.sendMessage(
47
+ [{ id: 'insights', role: 'user', content: prompt, timestamp: Date.now() }],
48
+ { signal: abortController.signal }
49
+ );
50
+
51
+ if (Symbol.asyncIterator in result) {
52
+ for await (const chunk of result as AsyncIterable<string>) {
53
+ insightText += chunk;
54
+ }
55
+ } else {
56
+ insightText = await result;
57
+ }
58
+ } catch (err: unknown) {
59
+ if (err instanceof Error && err.name !== 'AbortError') {
60
+ insightText = 'Failed to generate insights.';
61
+ }
62
+ } finally {
63
+ isPredicting = false;
64
+ }
65
+ }
66
+ </script>
67
+
68
+ <div class="rounded-xl border bg-card text-card-foreground shadow-sm flex flex-col {className}">
69
+ <div class="flex items-center justify-between p-4 border-b">
70
+ <div class="flex items-center gap-2 font-medium">
71
+ <Sparkles class="h-4 w-4 text-amber-500" />
72
+ {title}
73
+ </div>
74
+ <Button variant="ghost" size="icon-sm" class="h-6 w-6" onclick={getInsights} disabled={isPredicting || !provider}>
75
+ <RefreshCcw class="h-3 w-3 {isPredicting ? 'animate-spin' : ''}" />
76
+ </Button>
77
+ </div>
78
+
79
+ <div class="p-4 flex-1">
80
+ {#if !provider}
81
+ <div class="text-sm text-muted-foreground italic flex items-center justify-center h-full">
82
+ ChatProvider not configured.
83
+ </div>
84
+ {:else if isPredicting && !insightText}
85
+ <div class="flex items-center justify-center gap-2 text-sm text-muted-foreground h-full py-4">
86
+ <Loader2 class="h-4 w-4 animate-spin" /> Analyzing data...
87
+ </div>
88
+ {:else if !insightText}
89
+ <div class="text-sm text-muted-foreground italic flex items-center justify-center h-full">
90
+ No insights available. Click refresh to analyze.
91
+ </div>
92
+ {:else}
93
+ <div class="text-sm prose prose-sm dark:prose-invert prose-p:my-1 prose-ul:my-1 prose-li:my-0.5">
94
+ <MarkdownRenderer content={insightText} streaming={isPredicting} />
95
+ </div>
96
+ {/if}
97
+ </div>
98
+ </div>
@@ -62,7 +62,7 @@
62
62
  let collapsed = $state(false);
63
63
  </script>
64
64
 
65
- <div role="application" onkeydown={(e) => { if ((e.ctrlKey || e.metaKey) && e.key === 'k') { e.preventDefault(); commandOpen = true; } }}>
65
+ <svelte:window onkeydown={(e) => { if ((e.ctrlKey || e.metaKey) && e.key === 'k') { e.preventDefault(); commandOpen = true; } }} />
66
66
  {#if loading}
67
67
  <div class="flex h-screen" in:fade={{ duration: 150 }}>
68
68
  <div class="hidden md:block w-64 bg-sidebar/80 p-4 space-y-4">
@@ -83,7 +83,7 @@
83
83
  </div>
84
84
  </div>
85
85
  {:else}
86
- <div class="flex h-screen bg-slate-50 dark:bg-slate-950" in:fade={{ duration: 200, delay: 50 }}>
86
+ <div class="flex h-screen bg-background" in:fade={{ duration: 200, delay: 50 }}>
87
87
  <!-- Desktop sidebar -->
88
88
  <div class="hidden md:block">
89
89
  <Sidebar {collapsed} {identity} {title} onToggle={() => collapsed = !collapsed} onLogout={handleLogout} />
@@ -128,4 +128,3 @@
128
128
  <ChatDialog />
129
129
  <Toast />
130
130
  {/if}
131
- </div>
@@ -16,9 +16,9 @@
16
16
  }>();
17
17
 
18
18
  const statusConfigs = {
19
- connected: { variant: 'default' as const, dotClass: 'bg-emerald-500', label: 'Live', icon: Wifi },
20
- connecting: { variant: 'secondary' as const, dotClass: 'bg-amber-500', label: 'Connecting...', icon: Loader2 as typeof Wifi },
21
- disconnected: { variant: 'destructive' as const, dotClass: 'bg-red-500', label: 'Offline', icon: WifiOff },
19
+ connected: { variant: 'default' as const, dotClass: 'bg-success', label: 'Live', icon: Wifi },
20
+ connecting: { variant: 'secondary' as const, dotClass: 'bg-warning', label: 'Connecting...', icon: Loader2 as typeof Wifi },
21
+ disconnected: { variant: 'destructive' as const, dotClass: 'bg-destructive', label: 'Offline', icon: WifiOff },
22
22
  } as const;
23
23
  type StatusKey = 'connected' | 'connecting' | 'disconnected';
24
24
  const cfg = $derived(statusConfigs[status as StatusKey]);
@@ -1,8 +1,12 @@
1
1
  <script lang="ts">
2
+ // @ts-expect-error - missing types
2
3
  import { Marked } from 'marked';
4
+ // @ts-expect-error - missing types
3
5
  import { markedHighlight } from 'marked-highlight';
6
+ // @ts-expect-error - missing types
4
7
  import hljs from 'highlight.js';
5
8
  import 'highlight.js/styles/github-dark.css'; // or your preferred theme
9
+ // @ts-expect-error - missing types
6
10
  import DOMPurify from 'isomorphic-dompurify';
7
11
  import { Check, Copy } from 'lucide-svelte';
8
12
  import { Button } from './ui/button/index.js';
@@ -22,7 +26,7 @@
22
26
  const marked = new Marked(
23
27
  markedHighlight({
24
28
  langPrefix: 'hljs language-',
25
- highlight(code, lang) {
29
+ highlight(code: string, lang: string) {
26
30
  const language = hljs.getLanguage(lang) ? lang : 'plaintext';
27
31
  return hljs.highlight(code, { language }).value;
28
32
  }
@@ -65,7 +69,7 @@
65
69
  pres.forEach(pre => {
66
70
  pre.classList.add('enhanced');
67
71
  const wrapper = document.createElement('div');
68
- wrapper.className = 'code-block-wrapper group relative my-4 rounded-md bg-zinc-950';
72
+ wrapper.className = 'code-block-wrapper group relative my-4 rounded-md bg-foreground/95';
69
73
 
70
74
  const header = document.createElement('div');
71
75
  header.className = 'flex items-center justify-between px-4 py-2 text-xs text-zinc-400 border-b border-zinc-800';
@@ -43,8 +43,8 @@
43
43
 
44
44
  const strengthColor = $derived(
45
45
  strength < 30 ? 'bg-destructive' :
46
- strength < 60 ? 'bg-amber-500' :
47
- strength < 80 ? 'bg-blue-500' : 'bg-emerald-500'
46
+ strength < 60 ? 'bg-warning' :
47
+ strength < 80 ? 'bg-info' : 'bg-success'
48
48
  );
49
49
 
50
50
  const strengthLabel = $derived(
@@ -130,7 +130,7 @@
130
130
  {/if}
131
131
 
132
132
  {#if pwSuccess}
133
- <Alert.Root class="border-green-500/30 bg-green-500/5 text-green-600 dark:text-green-400">
133
+ <Alert.Root class="border-success/30 bg-success/5 text-success">
134
134
  <CheckCircle class="h-4 w-4" />
135
135
  <Alert.Description>{t('profile.passwordChanged')}</Alert.Description>
136
136
  </Alert.Root>
@@ -0,0 +1,158 @@
1
+ <script lang="ts">
2
+ import { Input } from './ui/input/index.js';
3
+ import type { ChatProvider } from '@svadmin/core';
4
+
5
+ interface Props {
6
+ /** The bound value of the input */
7
+ value: string;
8
+ /** The chat provider to generate suggestions */
9
+ provider?: ChatProvider;
10
+ /** Context given to the AI to help it predict the text */
11
+ context?: string;
12
+ /** Standard input props */
13
+ placeholder?: string;
14
+ class?: string;
15
+ id?: string;
16
+ disabled?: boolean;
17
+ name?: string;
18
+ type?: string;
19
+ }
20
+
21
+ let {
22
+ value = $bindable(),
23
+ provider,
24
+ context = '',
25
+ placeholder = '',
26
+ class: className = '',
27
+ id,
28
+ disabled = false,
29
+ name,
30
+ type = 'text'
31
+ }: Props = $props();
32
+
33
+ let suggestion = $state('');
34
+ let isPredicting = $state(false);
35
+ let abortController: AbortController | null = null;
36
+ let predictTimer: ReturnType<typeof setTimeout> | null = null;
37
+
38
+ // The ghost text shows the suggestion, but only the part *after* what the user has typed
39
+ // It only displays if the suggestion actually starts with the current value
40
+ const ghostText = $derived.by(() => {
41
+ if (!suggestion || !value) return '';
42
+ const lowerVal = value.toLowerCase();
43
+ const lowerSugg = suggestion.toLowerCase();
44
+
45
+ if (lowerSugg.startsWith(lowerVal) && suggestion.length > value.length) {
46
+ // Preserve original casing from the suggestion but append only the suffix
47
+ return value + suggestion.slice(value.length);
48
+ }
49
+ return '';
50
+ });
51
+
52
+ const suffixText = $derived(ghostText ? ghostText.slice(value.length) : '');
53
+
54
+ async function predict() {
55
+ if (!provider || !value.trim()) {
56
+ suggestion = '';
57
+ return;
58
+ }
59
+
60
+ isPredicting = true;
61
+ if (abortController) {
62
+ abortController.abort();
63
+ }
64
+ abortController = new AbortController();
65
+
66
+ try {
67
+ const prompt = `You are an autocomplete engine. The user is typing: "${value}".
68
+ Context: ${context}.
69
+ Provide ONLY the most likely completion of the exact phrase the user is typing.
70
+ Include the user's input in your returned string.
71
+ Do not include any other text, Markdown formatting, quotes, or explanations.`;
72
+
73
+ const result = provider.sendMessage(
74
+ [{ id: 'prompt', role: 'user', content: prompt, timestamp: Date.now() }],
75
+ { signal: abortController.signal }
76
+ );
77
+
78
+ suggestion = '';
79
+
80
+ // Handle streaming or Promise responses seamlessly
81
+ if (Symbol.asyncIterator in result) {
82
+ for await (const chunk of result as AsyncIterable<string>) {
83
+ suggestion += chunk;
84
+ }
85
+ } else {
86
+ suggestion = await result;
87
+ }
88
+
89
+ // Clean up the suggestion
90
+ suggestion = suggestion.trim().replace(/^["']|["']$/g, '');
91
+ } catch (err: unknown) {
92
+ if (err instanceof Error && err.name !== 'AbortError') {
93
+ suggestion = '';
94
+ }
95
+ } finally {
96
+ isPredicting = false;
97
+ }
98
+ }
99
+
100
+ function handleInput(e: Event) {
101
+ const target = e.target as HTMLInputElement;
102
+ value = target.value;
103
+
104
+ // Reset suggestion immediately if it no longer matches
105
+ if (suggestion && !suggestion.toLowerCase().startsWith(value.toLowerCase())) {
106
+ suggestion = '';
107
+ }
108
+
109
+ if (predictTimer) clearTimeout(predictTimer);
110
+
111
+ // Debounce prediction
112
+ predictTimer = setTimeout(() => {
113
+ predict();
114
+ }, 500);
115
+ }
116
+
117
+ function handleKeyDown(e: KeyboardEvent) {
118
+ if (e.key === 'Tab' && suffixText) {
119
+ e.preventDefault(); // Prevent standard tab navigation
120
+ value = ghostText;
121
+ suggestion = ''; // Clear suggestion once accepted
122
+ }
123
+ }
124
+
125
+ </script>
126
+
127
+ <div class="relative w-full {className}">
128
+ <!-- Ghost Text Layer (positioned exactly beneath the actual text) -->
129
+ {#if suffixText}
130
+ <div
131
+ class="absolute inset-0 pointer-events-none flex items-center px-3 text-sm font-medium"
132
+ aria-hidden="true"
133
+ >
134
+ <span class="opacity-0">{value}</span>
135
+ <span class="text-muted-foreground/40">{suffixText}</span>
136
+ </div>
137
+ {/if}
138
+
139
+ <!-- Actual Input Layer -->
140
+ <Input
141
+ {id}
142
+ {name}
143
+ {type}
144
+ {disabled}
145
+ {placeholder}
146
+ value={value}
147
+ oninput={handleInput}
148
+ onkeydown={handleKeyDown}
149
+ class="bg-transparent relative z-10 w-full"
150
+ />
151
+
152
+ <!-- Loading indicator -->
153
+ {#if isPredicting}
154
+ <div class="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none">
155
+ <div class="h-1.5 w-1.5 rounded-full bg-primary/50 animate-pulse"></div>
156
+ </div>
157
+ {/if}
158
+ </div>
@@ -7,10 +7,10 @@
7
7
 
8
8
  const colorMap: Record<ColorVariant, string> = {
9
9
  primary: 'bg-primary/10 text-primary',
10
- success: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400',
11
- warning: 'bg-amber-500/10 text-amber-600 dark:text-amber-400',
12
- danger: 'bg-red-500/10 text-red-600 dark:text-red-400',
13
- info: 'bg-blue-500/10 text-blue-600 dark:text-blue-400',
10
+ success: 'bg-success/10 text-success',
11
+ warning: 'bg-warning/10 text-warning',
12
+ danger: 'bg-destructive/10 text-destructive',
13
+ info: 'bg-info/10 text-info',
14
14
  };
15
15
 
16
16
  const variantMap: Record<StyleVariant, string> = {
@@ -60,7 +60,7 @@
60
60
  <p class="text-xl font-semibold text-foreground">{value}</p>
61
61
  {#if trend}
62
62
  <span
63
- class="text-xs font-medium {trend.value >= 0 ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-600 dark:text-red-400'}"
63
+ class="text-xs font-medium {trend.value >= 0 ? 'text-success' : 'text-destructive'}"
64
64
  >
65
65
  {trend.value >= 0 ? '↑' : '↓'}{Math.abs(trend.value)}%
66
66
  {#if trend.label}
@@ -0,0 +1,119 @@
1
+ <script lang="ts">
2
+ import { Mic, MicOff, Loader2 } from 'lucide-svelte';
3
+ import TooltipButton from './TooltipButton.svelte';
4
+
5
+ // Web Speech API type declarations
6
+ interface SpeechRecognitionEvent extends Event {
7
+ results: SpeechRecognitionResultList;
8
+ }
9
+ interface SpeechRecognitionErrorEvent extends Event {
10
+ error: string;
11
+ }
12
+ interface SpeechRecognitionInstance extends EventTarget {
13
+ continuous: boolean;
14
+ interimResults: boolean;
15
+ lang: string;
16
+ onstart: (() => void) | null;
17
+ onresult: ((event: SpeechRecognitionEvent) => void) | null;
18
+ onerror: ((event: SpeechRecognitionErrorEvent) => void) | null;
19
+ onend: (() => void) | null;
20
+ start(): void;
21
+ stop(): void;
22
+ }
23
+
24
+ interface Props {
25
+ /** Called when a speech recognition result is finalized */
26
+ onresult?: (text: string) => void;
27
+ /** Called when the engine starts listening */
28
+ onstart?: () => void;
29
+ /** Called when the engine stops listening */
30
+ onend?: () => void;
31
+ /** Standard button size */
32
+ size?: 'default' | 'sm' | 'lg' | 'icon' | 'icon-sm';
33
+ /** Custom class for the button */
34
+ class?: string;
35
+ }
36
+
37
+ let {
38
+ onresult,
39
+ onstart,
40
+ onend,
41
+ size = 'icon',
42
+ class: className = ''
43
+ }: Props = $props();
44
+
45
+ let isListening = $state(false);
46
+ let notSupported = $state(false);
47
+ let recognition: SpeechRecognitionInstance | null = null;
48
+
49
+ // Initialize SpeechRecognition on mount
50
+ $effect(() => {
51
+ const SpeechRecognitionCtor = (
52
+ (globalThis as Record<string, unknown>).SpeechRecognition ||
53
+ (globalThis as Record<string, unknown>).webkitSpeechRecognition
54
+ ) as (new () => SpeechRecognitionInstance) | undefined;
55
+
56
+ if (!SpeechRecognitionCtor) {
57
+ notSupported = true;
58
+ return;
59
+ }
60
+
61
+ recognition = new SpeechRecognitionCtor();
62
+ recognition.continuous = false;
63
+ recognition.interimResults = false;
64
+ recognition.lang = navigator.language || 'en-US';
65
+
66
+ recognition.onstart = () => {
67
+ isListening = true;
68
+ if (onstart) onstart();
69
+ };
70
+
71
+ recognition.onresult = (event: SpeechRecognitionEvent) => {
72
+ const transcript = Array.from(event.results)
73
+ .map((result: SpeechRecognitionResult) => result[0])
74
+ .map((result: SpeechRecognitionAlternative) => result.transcript)
75
+ .join('');
76
+
77
+ if (onresult && transcript) {
78
+ onresult(transcript);
79
+ }
80
+ };
81
+
82
+ recognition.onerror = (event: SpeechRecognitionErrorEvent) => {
83
+ console.warn('SpeechRecognition error:', event.error);
84
+ isListening = false;
85
+ if (onend) onend();
86
+ };
87
+
88
+ recognition.onend = () => {
89
+ isListening = false;
90
+ if (onend) onend();
91
+ };
92
+ });
93
+
94
+ function toggleListening() {
95
+ if (!recognition) return;
96
+
97
+ if (isListening) {
98
+ recognition.stop();
99
+ } else {
100
+ recognition.start();
101
+ }
102
+ }
103
+ </script>
104
+
105
+ <TooltipButton
106
+ tooltip={notSupported ? "Voice input not supported in this browser" : (isListening ? "Stop listening" : "Start voice input")}
107
+ variant={isListening ? "destructive" : "ghost"}
108
+ {size}
109
+ class="{isListening ? 'animate-pulse' : ''} {className}"
110
+ onclick={toggleListening}
111
+ disabled={notSupported}
112
+ >
113
+ {#if isListening}
114
+ <Loader2 class="h-4 w-4 animate-spin -ml-1 mr-1.5 inline-block sm:hidden" />
115
+ <MicOff class="h-4 w-4" />
116
+ {:else}
117
+ <Mic class="h-4 w-4" />
118
+ {/if}
119
+ </TooltipButton>
@@ -3,11 +3,11 @@
3
3
  let { value, trueIcon = '✓', falseIcon = '✗' }: Props = $props();
4
4
  </script>
5
5
  {#if value}
6
- <span class="inline-flex items-center justify-center w-6 h-6 rounded-full text-sm bg-emerald-100 text-emerald-600 dark:bg-emerald-500/20 dark:text-emerald-400">
6
+ <span class="inline-flex items-center justify-center w-6 h-6 rounded-full text-sm bg-success/10 text-success">
7
7
  {trueIcon}
8
8
  </span>
9
9
  {:else}
10
- <span class="inline-flex items-center justify-center w-6 h-6 rounded-full text-sm bg-red-50 text-red-600 dark:bg-red-500/20 dark:text-red-400">
10
+ <span class="inline-flex items-center justify-center w-6 h-6 rounded-full text-sm bg-destructive/10 text-destructive">
11
11
  {falseIcon}
12
12
  </span>
13
13
  {/if}
@@ -7,7 +7,7 @@
7
7
  }>();
8
8
 
9
9
  const label = $derived(
10
- options?.find((o: any) => o.value === value)?.label ?? (value != null ? String(value) : '—')
10
+ options?.find((o: { label: string; value: string | number }) => o.value === value)?.label ?? (value != null ? String(value) : '—')
11
11
  );
12
12
  </script>
13
13
 
@@ -9,8 +9,8 @@
9
9
  variant: {
10
10
  default: "bg-card text-foreground",
11
11
  destructive: "text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
12
- warning: "border-amber-500/50 text-amber-700 dark:text-amber-400 bg-amber-50 dark:bg-amber-950/20 [&>svg]:text-current",
13
- success: "border-emerald-500/50 text-emerald-700 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-950/20 [&>svg]:text-current",
12
+ warning: "border-warning/50 text-warning-foreground bg-warning/10 [&>svg]:text-current",
13
+ success: "border-success/50 text-success bg-success/10 [&>svg]:text-current",
14
14
  },
15
15
  },
16
16
  defaultVariants: { variant: "default" },
@@ -53,11 +53,19 @@
53
53
  children,
54
54
  ...restProps
55
55
  }: ButtonProps = $props();
56
+
57
+ let anchorRef: HTMLAnchorElement | null = $state(null);
58
+ let buttonRef: HTMLButtonElement | null = $state(null);
59
+
60
+ $effect(() => {
61
+ if (href && anchorRef) ref = anchorRef as any;
62
+ else if (!href && buttonRef) ref = buttonRef as any;
63
+ });
56
64
  </script>
57
65
 
58
66
  {#if href}
59
67
  <a
60
- bind:this={ref}
68
+ bind:this={anchorRef}
61
69
  data-slot="button"
62
70
  class={cn(buttonVariants({ variant, size }), className)}
63
71
  href={disabled ? undefined : href}
@@ -70,7 +78,7 @@
70
78
  </a>
71
79
  {:else}
72
80
  <button
73
- bind:this={ref}
81
+ bind:this={buttonRef}
74
82
  data-slot="button"
75
83
  class={cn(buttonVariants({ variant, size }), className)}
76
84
  {type}
@@ -6,7 +6,7 @@
6
6
  let { class: className, ...restProps }: HTMLAttributes<HTMLSpanElement> = $props();
7
7
  </script>
8
8
 
9
- <span aria-hidden data-slot="pagination-ellipsis" class={cn("flex size-9 items-center justify-center", className)} {...restProps}>
9
+ <span aria-hidden="true" data-slot="pagination-ellipsis" class={cn("flex size-9 items-center justify-center", className)} {...restProps}>
10
10
  <Ellipsis class="size-4" />
11
11
  <span class="sr-only">More pages</span>
12
12
  </span>
@@ -43,6 +43,7 @@
43
43
  <!-- Overlay -->
44
44
  <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
45
45
  <div
46
+ role="presentation"
46
47
  class="fixed inset-0 z-50 bg-black/80 animate-in fade-in-0"
47
48
  onclick={close}
48
49
  ></div>
package/src/index.ts CHANGED
@@ -46,7 +46,14 @@ export { default as StepsForm } from './components/StepsForm.svelte';
46
46
  export { default as InfiniteList } from './components/InfiniteList.svelte';
47
47
  export { default as ComboboxField } from './components/ComboboxField.svelte';
48
48
  export { default as PasswordInput } from './components/PasswordInput.svelte';
49
- export { default as ChatDialog } from './components/ChatDialog.svelte';
49
+ export { default as ChatDialog } from "./components/ChatDialog.svelte";
50
+ export { default as MarkdownRenderer } from "./components/MarkdownRenderer.svelte";
51
+ export { default as SmartSuggest } from "./components/SmartSuggest.svelte";
52
+ export { default as AICommandBar } from "./components/AICommandBar.svelte";
53
+ export { default as CopilotPanel } from "./components/CopilotPanel.svelte";
54
+ export { default as InsightCard } from "./components/InsightCard.svelte";
55
+ export { default as AnomalyBadge } from "./components/AnomalyBadge.svelte";
56
+ export { default as VoiceInput } from "./components/VoiceInput.svelte";
50
57
  // Field display components
51
58
  export { default as NumberField } from './components/fields/NumberField.svelte';
52
59
  export { default as DateField } from './components/fields/DateField.svelte';
@@ -102,6 +109,12 @@ export { Command } from './components/ui/command/index.js';
102
109
  // Utils
103
110
  export { cn } from './utils';
104
111
 
112
+ // Component Registry (DI)
113
+ export {
114
+ setComponentRegistry, getComponentRegistry, useComponent,
115
+ type ComponentRegistry,
116
+ } from './component-registry.svelte';
117
+
105
118
  // Field component registry
106
119
  export {
107
120
  builtinDisplayComponents, registerDisplayComponent,