@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.
- package/dist/era-ui.css +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/os/desktop.svelte +58 -0
- package/dist/os/desktop.svelte.d.ts +17 -0
- package/dist/os/index.d.ts +10 -0
- package/dist/os/index.js +14 -0
- package/dist/os/launcher.svelte +83 -0
- package/dist/os/launcher.svelte.d.ts +8 -0
- package/dist/os/layers.d.ts +25 -0
- package/dist/os/layers.js +25 -0
- package/dist/os/notification-center.svelte +85 -0
- package/dist/os/notification-center.svelte.d.ts +6 -0
- package/dist/os/notifications.svelte.d.ts +48 -0
- package/dist/os/notifications.svelte.js +80 -0
- package/dist/os/taskbar.svelte +97 -0
- package/dist/os/taskbar.svelte.d.ts +16 -0
- package/dist/os/toast.svelte +57 -0
- package/dist/os/toast.svelte.d.ts +7 -0
- package/dist/os/toaster.svelte +30 -0
- package/dist/os/toaster.svelte.d.ts +4 -0
- package/dist/os/window.svelte +60 -0
- package/dist/os/window.svelte.d.ts +7 -0
- package/dist/os/wm.svelte.d.ts +99 -0
- package/dist/os/wm.svelte.js +172 -0
- package/dist/ui/pane/pane-root.svelte +5 -0
- package/package.json +1 -1
|
@@ -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,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,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>
|