@signal9/era-ui 1.12.2 → 1.13.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,83 @@
1
+ <script lang="ts">
2
+ import { fly } from 'svelte/transition';
3
+ import * as Command from '../ui/command';
4
+ import Search from '@lucide/svelte/icons/search';
5
+ import { cn } from '../utils/index.js';
6
+ import { getWindowManager } from './wm.svelte.js';
7
+ import { LAYER } from './layers.js';
8
+
9
+ let {
10
+ open = $bindable(false),
11
+ hotkey = true
12
+ }: {
13
+ open?: boolean;
14
+ /** Register Ctrl/Cmd+K to toggle and Escape to close (default true). */
15
+ hotkey?: boolean;
16
+ } = $props();
17
+
18
+ const wm = getWindowManager();
19
+ const apps = $derived(Object.values(wm.apps));
20
+ let query = $state('');
21
+
22
+ $effect(() => {
23
+ if (!hotkey) return;
24
+ const onKey = (e: KeyboardEvent) => {
25
+ if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
26
+ e.preventDefault();
27
+ open = !open;
28
+ } else if (e.key === 'Escape' && open) {
29
+ open = false;
30
+ }
31
+ };
32
+ window.addEventListener('keydown', onKey);
33
+ return () => window.removeEventListener('keydown', onKey);
34
+ });
35
+
36
+ // Reset the query each time it opens so it's fresh.
37
+ $effect(() => {
38
+ if (open) query = '';
39
+ });
40
+
41
+ function launch(appId: string) {
42
+ wm.open(appId);
43
+ open = false;
44
+ }
45
+ </script>
46
+
47
+ {#if open}
48
+ <div
49
+ class={cn('absolute inset-0 flex items-start justify-center bg-(--era-overlay-bg) px-(--era-pad-md) pt-[15vh]', LAYER.launcher)}
50
+ >
51
+ <button class="absolute inset-0 cursor-default" aria-label="Close" onclick={() => (open = false)}
52
+ ></button>
53
+
54
+ <div
55
+ transition:fly={{ y: -12, duration: 160 }}
56
+ class="relative w-full max-w-lg overflow-hidden rounded-(--era-rd-lg) bg-(--era-surface-bg) shadow-(--era-shadow-lg) glass-blur"
57
+ >
58
+ <Command.Root bind:value={query} class="flex max-h-[60vh] flex-col">
59
+ <div class="p-(--era-inset-sm)">
60
+ <Command.Input placeholder="Search apps…" icon={Search} autofocus />
61
+ </div>
62
+ <Command.List>
63
+ <Command.Viewport class="p-(--era-inset-sm)">
64
+ <Command.Empty>No apps found.</Command.Empty>
65
+ <Command.Group>
66
+ <Command.GroupHeading>Apps</Command.GroupHeading>
67
+ <Command.GroupItems>
68
+ {#each apps as app (app.id)}
69
+ <Command.Item value={app.title} onSelect={() => launch(app.id)}>
70
+ {#if app.icon}{@const Icon = app.icon}<Icon
71
+ class="size-(--era-h-sm) shrink-0 text-muted"
72
+ />{/if}
73
+ <span class="truncate">{app.title}</span>
74
+ </Command.Item>
75
+ {/each}
76
+ </Command.GroupItems>
77
+ </Command.Group>
78
+ </Command.Viewport>
79
+ </Command.List>
80
+ </Command.Root>
81
+ </div>
82
+ </div>
83
+ {/if}
@@ -0,0 +1,8 @@
1
+ type $$ComponentProps = {
2
+ open?: boolean;
3
+ /** Register Ctrl/Cmd+K to toggle and Escape to close (default true). */
4
+ hotkey?: boolean;
5
+ };
6
+ declare const Launcher: import("svelte").Component<$$ComponentProps, {}, "open">;
7
+ type Launcher = ReturnType<typeof Launcher>;
8
+ export default Launcher;
@@ -0,0 +1,25 @@
1
+ /**
2
+ * The layer contract for the OS shell.
3
+ *
4
+ * The hard rule desktop UIs get wrong: a window's z-index must NEVER be able to
5
+ * cover a menu, toast, or the launcher. We guarantee that structurally rather
6
+ * than by juggling numbers — the `Desktop` surface is an isolated stacking
7
+ * context (`isolate`), so every window's z-index (1..N, handed out by the
8
+ * WindowManager) is *contained* within it. OS chrome and portalled overlays
9
+ * render OUTSIDE that context, at the document root, so they always paint above
10
+ * the entire desktop no matter how high a window's z climbs.
11
+ *
12
+ * These are the root-level tiers (all above the isolated desktop). bits-ui
13
+ * dialogs/menus/tooltips portal to <body> at z-50; the tiers below are chosen
14
+ * around that: chrome sits under menus, transient system UI sits over them.
15
+ */
16
+ export declare const LAYER: {
17
+ /** Taskbar / dock — chrome, below menus so a taskbar dropdown (z-50) opens over it. */
18
+ readonly taskbar: "z-30";
19
+ /** Slide-over notification center — chrome, same band as the taskbar it hangs off. */
20
+ readonly notificationCenter: "z-40";
21
+ /** Toasts — above bits-ui overlays (z-50) so a notification is visible over a dialog. */
22
+ readonly toast: "z-[55]";
23
+ /** Global launcher / spotlight — the topmost transient surface. */
24
+ readonly launcher: "z-[60]";
25
+ };
@@ -0,0 +1,25 @@
1
+ /**
2
+ * The layer contract for the OS shell.
3
+ *
4
+ * The hard rule desktop UIs get wrong: a window's z-index must NEVER be able to
5
+ * cover a menu, toast, or the launcher. We guarantee that structurally rather
6
+ * than by juggling numbers — the `Desktop` surface is an isolated stacking
7
+ * context (`isolate`), so every window's z-index (1..N, handed out by the
8
+ * WindowManager) is *contained* within it. OS chrome and portalled overlays
9
+ * render OUTSIDE that context, at the document root, so they always paint above
10
+ * the entire desktop no matter how high a window's z climbs.
11
+ *
12
+ * These are the root-level tiers (all above the isolated desktop). bits-ui
13
+ * dialogs/menus/tooltips portal to <body> at z-50; the tiers below are chosen
14
+ * around that: chrome sits under menus, transient system UI sits over them.
15
+ */
16
+ export const LAYER = {
17
+ /** Taskbar / dock — chrome, below menus so a taskbar dropdown (z-50) opens over it. */
18
+ taskbar: 'z-30',
19
+ /** Slide-over notification center — chrome, same band as the taskbar it hangs off. */
20
+ notificationCenter: 'z-40',
21
+ /** Toasts — above bits-ui overlays (z-50) so a notification is visible over a dialog. */
22
+ toast: 'z-[55]',
23
+ /** Global launcher / spotlight — the topmost transient surface. */
24
+ launcher: 'z-[60]'
25
+ };
@@ -0,0 +1,85 @@
1
+ <script lang="ts">
2
+ import { fly } from 'svelte/transition';
3
+ import { Button } from '../ui/button';
4
+ import { Switch } from '../ui/switch';
5
+ import BellOff from '@lucide/svelte/icons/bell-off';
6
+ import { cn } from '../utils/index.js';
7
+ import { getNotifications, type AppNotification } from './notifications.svelte.js';
8
+ import { LAYER } from './layers.js';
9
+
10
+ let { open = $bindable(false) }: { open?: boolean } = $props();
11
+ const notif = getNotifications();
12
+
13
+ const accent: Record<AppNotification['tone'], string> = {
14
+ default: 'bg-(--era-highlight)',
15
+ accent: 'bg-primary',
16
+ success: 'bg-success',
17
+ destructive: 'bg-destructive'
18
+ };
19
+
20
+ // Opening the center counts as seeing everything — clear the unread badge.
21
+ $effect(() => {
22
+ if (open) notif.markAllRead();
23
+ });
24
+
25
+ function ago(ts: number) {
26
+ const s = Math.max(0, Math.round((Date.now() - ts) / 1000));
27
+ if (s < 60) return 'now';
28
+ if (s < 3600) return `${Math.floor(s / 60)}m`;
29
+ if (s < 86400) return `${Math.floor(s / 3600)}h`;
30
+ return `${Math.floor(s / 86400)}d`;
31
+ }
32
+ </script>
33
+
34
+ {#if open}
35
+ <div class={cn('absolute inset-0', LAYER.notificationCenter)}>
36
+ <!-- Click-away scrim (transparent) -->
37
+ <button class="absolute inset-0 cursor-default" aria-label="Close" onclick={() => (open = false)}
38
+ ></button>
39
+
40
+ <aside
41
+ transition:fly={{ x: 320, duration: 200 }}
42
+ class="absolute top-0 right-0 bottom-(--era-h-lg) flex w-80 flex-col border-l border-divider-faded bg-(--era-surface-bg) shadow-(--era-shadow-lg) glass-blur"
43
+ >
44
+ <header
45
+ class="flex shrink-0 items-center gap-(--era-gap) border-b border-divider-faded px-(--era-pad-md) py-(--era-inset-sm)"
46
+ >
47
+ <span class="text-body text-bright">Notifications</span>
48
+ <label class="ml-auto flex cursor-pointer items-center gap-(--era-gap) text-body text-muted">
49
+ <BellOff class="size-(--era-h-xs)" />
50
+ <Switch bind:checked={notif.dnd} aria-label="Do not disturb" />
51
+ </label>
52
+ </header>
53
+
54
+ <div class="scrollbar-none flex min-h-0 flex-1 flex-col gap-(--era-inset-sm) overflow-y-auto p-(--era-pad-md)">
55
+ {#each notif.items as n (n.id)}
56
+ <div
57
+ class="relative flex gap-(--era-inset-sm) overflow-hidden rounded-(--era-rd-md) bg-(--era-surface-bg-elevated) p-(--era-inset-md)"
58
+ >
59
+ <span class={cn('absolute inset-y-0 left-0 w-1', accent[n.tone])}></span>
60
+ {#if n.icon}{@const Icon = n.icon}<Icon
61
+ class="mt-0.5 size-(--era-h-xs) shrink-0 text-muted"
62
+ />{/if}
63
+ <div class="min-w-0 flex-1">
64
+ <div class="flex items-baseline gap-(--era-gap)">
65
+ <p class="min-w-0 flex-1 truncate text-body text-bright">{n.title}</p>
66
+ <span class="shrink-0 text-body tabular-nums text-muted">{ago(n.createdAt)}</span>
67
+ </div>
68
+ {#if n.body}<p class="mt-0.5 text-body break-words text-muted">{n.body}</p>{/if}
69
+ </div>
70
+ </div>
71
+ {:else}
72
+ <p class="mt-8 text-center text-body text-muted">No notifications.</p>
73
+ {/each}
74
+ </div>
75
+
76
+ {#if notif.items.length}
77
+ <footer class="shrink-0 border-t border-divider-faded p-(--era-inset-sm)">
78
+ <Button variant="link" size="sm" class="w-full" onclick={() => notif.clear()}>
79
+ Clear all
80
+ </Button>
81
+ </footer>
82
+ {/if}
83
+ </aside>
84
+ </div>
85
+ {/if}
@@ -0,0 +1,6 @@
1
+ type $$ComponentProps = {
2
+ open?: boolean;
3
+ };
4
+ declare const NotificationCenter: import("svelte").Component<$$ComponentProps, {}, "open">;
5
+ type NotificationCenter = ReturnType<typeof NotificationCenter>;
6
+ export default NotificationCenter;
@@ -0,0 +1,48 @@
1
+ import { type Component } from 'svelte';
2
+ import type { IconProps } from '@lucide/svelte';
3
+ export type NotificationTone = 'default' | 'accent' | 'success' | 'destructive';
4
+ export interface NotificationAction {
5
+ label: string;
6
+ onClick: () => void;
7
+ }
8
+ export interface NotifyOptions {
9
+ title: string;
10
+ body?: string;
11
+ tone?: NotificationTone;
12
+ icon?: Component<IconProps> | null;
13
+ actions?: NotificationAction[];
14
+ /** Auto-dismiss the toast after N ms (history is kept). 0 = sticky. */
15
+ ttl?: number;
16
+ }
17
+ export interface AppNotification extends Required<Omit<NotifyOptions, 'icon' | 'actions'>> {
18
+ id: string;
19
+ icon?: Component<IconProps> | null;
20
+ actions: NotificationAction[];
21
+ createdAt: number;
22
+ read: boolean;
23
+ }
24
+ /**
25
+ * The notification service: one queue that feeds both the transient toast stack
26
+ * and the persistent notification center. Runes-based, shared via context so
27
+ * any app can `notify()` and the Toaster / NotificationCenter render it.
28
+ */
29
+ export declare class NotificationService {
30
+ #private;
31
+ /** Full history, newest first. */
32
+ items: AppNotification[];
33
+ /** Currently-visible toasts (a subset of items), newest first. */
34
+ toasts: AppNotification[];
35
+ /** Do-not-disturb: keep filing to history, suppress toasts. */
36
+ dnd: boolean;
37
+ unread: number;
38
+ notify(opts: NotifyOptions): string;
39
+ /** Hide a toast (keeps it in history). */
40
+ dismiss(id: string): void;
41
+ /** Remove entirely (toast + history). */
42
+ remove(id: string): void;
43
+ markRead(id: string): void;
44
+ markAllRead(): void;
45
+ clear(): void;
46
+ }
47
+ export declare function setNotificationContext(service: NotificationService): NotificationService;
48
+ export declare function getNotifications(): NotificationService;
@@ -0,0 +1,80 @@
1
+ import { getContext, setContext } from 'svelte';
2
+ const DEFAULT_TTL = 5000;
3
+ /**
4
+ * The notification service: one queue that feeds both the transient toast stack
5
+ * and the persistent notification center. Runes-based, shared via context so
6
+ * any app can `notify()` and the Toaster / NotificationCenter render it.
7
+ */
8
+ export class NotificationService {
9
+ /** Full history, newest first. */
10
+ items = $state([]);
11
+ /** Currently-visible toasts (a subset of items), newest first. */
12
+ toasts = $state([]);
13
+ /** Do-not-disturb: keep filing to history, suppress toasts. */
14
+ dnd = $state(false);
15
+ #seq = 0;
16
+ #timers = new Map();
17
+ unread = $derived(this.items.reduce((n, i) => n + (i.read ? 0 : 1), 0));
18
+ notify(opts) {
19
+ const n = {
20
+ id: `n#${++this.#seq}`,
21
+ title: opts.title,
22
+ body: opts.body ?? '',
23
+ tone: opts.tone ?? 'default',
24
+ icon: opts.icon ?? null,
25
+ actions: opts.actions ?? [],
26
+ ttl: opts.ttl ?? DEFAULT_TTL,
27
+ createdAt: Date.now(),
28
+ read: false
29
+ };
30
+ this.items = [n, ...this.items];
31
+ if (!this.dnd) {
32
+ this.toasts = [n, ...this.toasts];
33
+ if (n.ttl > 0) {
34
+ this.#timers.set(n.id, setTimeout(() => this.dismiss(n.id), n.ttl));
35
+ }
36
+ }
37
+ return n.id;
38
+ }
39
+ /** Hide a toast (keeps it in history). */
40
+ dismiss(id) {
41
+ this.toasts = this.toasts.filter((t) => t.id !== id);
42
+ const timer = this.#timers.get(id);
43
+ if (timer) {
44
+ clearTimeout(timer);
45
+ this.#timers.delete(id);
46
+ }
47
+ }
48
+ /** Remove entirely (toast + history). */
49
+ remove(id) {
50
+ this.dismiss(id);
51
+ this.items = this.items.filter((i) => i.id !== id);
52
+ }
53
+ markRead(id) {
54
+ const n = this.items.find((i) => i.id === id);
55
+ if (n)
56
+ n.read = true;
57
+ }
58
+ markAllRead() {
59
+ for (const i of this.items)
60
+ i.read = true;
61
+ }
62
+ clear() {
63
+ this.items = [];
64
+ this.toasts = [];
65
+ for (const t of this.#timers.values())
66
+ clearTimeout(t);
67
+ this.#timers.clear();
68
+ }
69
+ }
70
+ const NOTIF_KEY = Symbol('era-notifications');
71
+ export function setNotificationContext(service) {
72
+ setContext(NOTIF_KEY, service);
73
+ return service;
74
+ }
75
+ export function getNotifications() {
76
+ const s = getContext(NOTIF_KEY);
77
+ if (!s)
78
+ throw new Error('getNotifications() must be used inside a <Desktop>.');
79
+ return s;
80
+ }
@@ -0,0 +1,97 @@
1
+ <script lang="ts">
2
+ import type { Component } from 'svelte';
3
+ import type { HTMLAttributes } from 'svelte/elements';
4
+ import type { IconProps } from '@lucide/svelte';
5
+ import { Button } from '../ui/button';
6
+ import LayoutGrid from '@lucide/svelte/icons/layout-grid';
7
+ import Bell from '@lucide/svelte/icons/bell';
8
+ import { cn } from '../utils/index.js';
9
+ import { getWindowManager } from './wm.svelte.js';
10
+ import { getNotifications } from './notifications.svelte.js';
11
+ import { LAYER } from './layers.js';
12
+
13
+ let {
14
+ pinned = [],
15
+ onLauncher,
16
+ onNotifications,
17
+ class: className,
18
+ ...restProps
19
+ }: HTMLAttributes<HTMLDivElement> & {
20
+ /** Pinned launchers — click spawns that app even when it isn't running. */
21
+ pinned?: { appId: string; title: string; icon?: Component<IconProps> | null }[];
22
+ onLauncher?: () => void;
23
+ onNotifications?: () => void;
24
+ } = $props();
25
+
26
+ const wm = getWindowManager();
27
+ const notif = getNotifications();
28
+
29
+ // Live clock (minute resolution is plenty; tick often enough to feel current).
30
+ let now = $state(new Date());
31
+ $effect(() => {
32
+ const t = setInterval(() => (now = new Date()), 10_000);
33
+ return () => clearInterval(t);
34
+ });
35
+ const time = $derived(now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }));
36
+ </script>
37
+
38
+ <div
39
+ class={cn(
40
+ 'absolute inset-x-0 bottom-0 flex h-(--era-h-lg) items-center gap-(--era-gap) border-t border-divider-faded bg-(--era-surface-bg-elevated) px-(--era-inset-sm) shadow-(--era-shadow-lg) glass-blur',
41
+ LAYER.taskbar,
42
+ className
43
+ )}
44
+ {...restProps}
45
+ >
46
+ <Button icon size="icon" aria-label="Open launcher" onclick={onLauncher}>
47
+ <LayoutGrid class="size-(--era-h-sm)" />
48
+ </Button>
49
+
50
+ {#if pinned.length}
51
+ <div class="flex items-center gap-(--era-gap)">
52
+ {#each pinned as p (p.appId)}
53
+ <Button icon size="icon" aria-label={p.title} onclick={() => wm.open(p.appId)}>
54
+ {#if p.icon}{@const Icon = p.icon}<Icon class="size-(--era-h-sm)" />{/if}
55
+ </Button>
56
+ {/each}
57
+ </div>
58
+ <div class="h-1/2 w-px shrink-0 bg-divider-faded"></div>
59
+ {/if}
60
+
61
+ <!-- Running windows: click focuses, or minimises the focused one. -->
62
+ <div class="scrollbar-none flex min-w-0 flex-1 items-center gap-(--era-gap) overflow-x-auto">
63
+ {#each wm.windows as win (win.id)}
64
+ <button
65
+ type="button"
66
+ onclick={() => wm.toggleMinimize(win.id)}
67
+ class={cn(
68
+ 'era-interactive flex h-(--era-h-md) min-w-0 max-w-48 items-center gap-(--era-gap) rounded-(--era-rd-md) px-(--era-inset-sm) text-body',
69
+ wm.focusedId === win.id
70
+ ? 'bg-hover text-bright'
71
+ : 'text-muted hover:bg-(--era-highlight) hover:text-fg',
72
+ win.state === 'minimized' && 'opacity-60'
73
+ )}
74
+ >
75
+ {#if win.icon}{@const Icon = win.icon}<Icon class="size-(--era-h-xs) shrink-0" />{/if}
76
+ <span class="truncate">{win.title}</span>
77
+ </button>
78
+ {/each}
79
+ </div>
80
+
81
+ <!-- System tray -->
82
+ <div class="ml-auto flex shrink-0 items-center gap-(--era-gap)">
83
+ <span class="px-(--era-inset-sm) text-body tabular-nums text-muted">{time}</span>
84
+ <div class="relative">
85
+ <Button icon size="icon" aria-label="Notifications" onclick={onNotifications}>
86
+ <Bell class="size-(--era-h-sm)" />
87
+ </Button>
88
+ {#if notif.unread > 0}
89
+ <span
90
+ class="absolute -top-0.5 -right-0.5 flex min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[0.65rem] leading-4 text-primary-fg"
91
+ >
92
+ {notif.unread > 99 ? '99+' : notif.unread}
93
+ </span>
94
+ {/if}
95
+ </div>
96
+ </div>
97
+ </div>
@@ -0,0 +1,16 @@
1
+ import type { Component } from 'svelte';
2
+ import type { HTMLAttributes } from 'svelte/elements';
3
+ import type { IconProps } from '@lucide/svelte';
4
+ type $$ComponentProps = HTMLAttributes<HTMLDivElement> & {
5
+ /** Pinned launchers — click spawns that app even when it isn't running. */
6
+ pinned?: {
7
+ appId: string;
8
+ title: string;
9
+ icon?: Component<IconProps> | null;
10
+ }[];
11
+ onLauncher?: () => void;
12
+ onNotifications?: () => void;
13
+ };
14
+ declare const Taskbar: Component<$$ComponentProps, {}, "">;
15
+ type Taskbar = ReturnType<typeof Taskbar>;
16
+ export default Taskbar;
@@ -0,0 +1,57 @@
1
+ <script lang="ts">
2
+ import { Button } from '../ui/button';
3
+ import X from '@lucide/svelte/icons/x';
4
+ import { cn } from '../utils/index.js';
5
+ import { getNotifications, type AppNotification } from './notifications.svelte.js';
6
+
7
+ let { notification: n }: { notification: AppNotification } = $props();
8
+ const notif = getNotifications();
9
+
10
+ const accent: Record<AppNotification['tone'], string> = {
11
+ default: 'bg-(--era-highlight)',
12
+ accent: 'bg-primary',
13
+ success: 'bg-success',
14
+ destructive: 'bg-destructive'
15
+ };
16
+ </script>
17
+
18
+ <div
19
+ class="pointer-events-auto relative flex w-80 gap-(--era-inset-sm) overflow-hidden rounded-(--era-rd-lg) bg-(--era-surface-bg-elevated) p-(--era-pad-md) shadow-(--era-shadow-lg) glass-blur"
20
+ >
21
+ <span class={cn('absolute inset-y-0 left-0 w-1', accent[n.tone])}></span>
22
+ {#if n.icon}
23
+ {@const Icon = n.icon}
24
+ <Icon class="mt-0.5 size-(--era-h-sm) shrink-0 text-muted" />
25
+ {/if}
26
+ <div class="min-w-0 flex-1">
27
+ <div class="flex items-start gap-(--era-gap)">
28
+ <p class="min-w-0 flex-1 truncate text-body text-bright">{n.title}</p>
29
+ <Button
30
+ icon
31
+ size="icon"
32
+ aria-label="Dismiss"
33
+ class="-mt-1 -mr-1 shrink-0"
34
+ onclick={() => notif.dismiss(n.id)}
35
+ >
36
+ <X class="size-(--era-h-xs)" />
37
+ </Button>
38
+ </div>
39
+ {#if n.body}<p class="mt-0.5 text-body break-words text-muted">{n.body}</p>{/if}
40
+ {#if n.actions.length}
41
+ <div class="mt-(--era-inset-sm) flex flex-wrap gap-(--era-gap)">
42
+ {#each n.actions as action (action.label)}
43
+ <Button
44
+ size="xs"
45
+ tone={n.tone === 'default' ? 'default' : n.tone}
46
+ onclick={() => {
47
+ action.onClick();
48
+ notif.dismiss(n.id);
49
+ }}
50
+ >
51
+ {action.label}
52
+ </Button>
53
+ {/each}
54
+ </div>
55
+ {/if}
56
+ </div>
57
+ </div>
@@ -0,0 +1,7 @@
1
+ import { type AppNotification } from './notifications.svelte.js';
2
+ type $$ComponentProps = {
3
+ notification: AppNotification;
4
+ };
5
+ declare const Toast: import("svelte").Component<$$ComponentProps, {}, "">;
6
+ type Toast = ReturnType<typeof Toast>;
7
+ export default Toast;
@@ -0,0 +1,30 @@
1
+ <script lang="ts">
2
+ import { fly } from 'svelte/transition';
3
+ import type { HTMLAttributes } from 'svelte/elements';
4
+ import { cn } from '../utils/index.js';
5
+ import { getNotifications } from './notifications.svelte.js';
6
+ import { LAYER } from './layers.js';
7
+ import Toast from './toast.svelte';
8
+
9
+ let { class: className, ...restProps }: HTMLAttributes<HTMLDivElement> = $props();
10
+ const notif = getNotifications();
11
+ </script>
12
+
13
+ <!-- Toast stack, above the taskbar and above bits-ui overlays (LAYER.toast).
14
+ flex-col-reverse: the newest toast (index 0) sits at the bottom, nearest the
15
+ tray it came from. pointer-events-none on the column so gaps don't block the
16
+ desktop; each toast re-enables its own. -->
17
+ <div
18
+ class={cn(
19
+ 'pointer-events-none absolute right-(--era-pad-md) bottom-(--era-h-lg) flex flex-col-reverse gap-(--era-inset-sm)',
20
+ LAYER.toast,
21
+ className
22
+ )}
23
+ {...restProps}
24
+ >
25
+ {#each notif.toasts as n (n.id)}
26
+ <div transition:fly={{ x: 24, duration: 180 }}>
27
+ <Toast notification={n} />
28
+ </div>
29
+ {/each}
30
+ </div>
@@ -0,0 +1,4 @@
1
+ import type { HTMLAttributes } from 'svelte/elements';
2
+ declare const Toaster: import("svelte").Component<HTMLAttributes<HTMLDivElement>, {}, "">;
3
+ type Toaster = ReturnType<typeof Toaster>;
4
+ export default Toaster;
@@ -0,0 +1,60 @@
1
+ <script lang="ts">
2
+ import * as Pane from '../ui/pane';
3
+ import { Button } from '../ui/button';
4
+ import Minus from '@lucide/svelte/icons/minus';
5
+ import Square from '@lucide/svelte/icons/square';
6
+ import { cn } from '../utils/index.js';
7
+ import { getWindowManager, type AppWindow } from './wm.svelte.js';
8
+
9
+ let { window: win }: { window: AppWindow } = $props();
10
+
11
+ const wm = getWindowManager();
12
+ const focused = $derived(wm.focusedId === win.id);
13
+
14
+ // z-index is set imperatively, NOT via a style="" prop: Pane positions itself
15
+ // by writing element.style.translate, and a whole-attribute style binding would
16
+ // clobber that on every reorder. A directive-style single-property write is safe.
17
+ let ref = $state<HTMLDivElement | null>(null);
18
+ $effect(() => {
19
+ if (ref) ref.style.zIndex = String(win.z);
20
+ });
21
+ </script>
22
+
23
+ <Pane.Root
24
+ bind:ref
25
+ bind:position={win.pos}
26
+ bind:size={win.size}
27
+ resizable={win.resizable}
28
+ onpointerdowncapture={() => wm.focus(win.id)}
29
+ class={cn(
30
+ 'transition-[box-shadow,opacity]',
31
+ // Focused window reads forward; the rest recede (dimmer, flatter).
32
+ focused ? 'ring-1 ring-(--color-primary)' : 'opacity-95 shadow-(--era-shadow)',
33
+ win.state === 'minimized' && 'hidden'
34
+ )}
35
+ >
36
+ <Pane.Handle ondblclick={() => wm.toggleMaximize(win.id)} class="gap-(--era-gap)">
37
+ {#if win.icon}
38
+ {@const Icon = win.icon}
39
+ <Icon class="size-(--era-h-xs) shrink-0 text-muted" />
40
+ {/if}
41
+ <span class={cn('truncate text-body', focused ? 'text-bright' : 'text-muted')}>{win.title}</span>
42
+ <!-- Window controls: data-pane-control so they click instead of dragging the pane. -->
43
+ <div class="ml-auto flex shrink-0 items-center gap-(--era-gap)" data-pane-control>
44
+ <Button icon size="icon" aria-label="Minimize" onclick={() => wm.minimize(win.id)}>
45
+ <Minus class="size-(--era-h-xs)" />
46
+ </Button>
47
+ <Button icon size="icon" aria-label="Maximize" onclick={() => wm.toggleMaximize(win.id)}>
48
+ <Square class="size-(--era-h-xs)" />
49
+ </Button>
50
+ <Pane.Close aria-label="Close" onclick={() => wm.close(win.id)} />
51
+ </div>
52
+ </Pane.Handle>
53
+
54
+ <Pane.Content>
55
+ {#if win.component}
56
+ {@const Body = win.component}
57
+ <Body window={win} {wm} />
58
+ {/if}
59
+ </Pane.Content>
60
+ </Pane.Root>
@@ -0,0 +1,7 @@
1
+ import { type AppWindow } from './wm.svelte.js';
2
+ type $$ComponentProps = {
3
+ window: AppWindow;
4
+ };
5
+ declare const Window: import("svelte").Component<$$ComponentProps, {}, "">;
6
+ type Window = ReturnType<typeof Window>;
7
+ export default Window;