@kbve/droid 0.0.6 → 0.1.1
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/comlink.js +1 -7
- package/droid.mjs +191 -61
- package/index.d.ts +29 -0
- package/index.js +9054 -2763
- package/lib/gateway/SupabaseGateway.d.ts +38 -0
- package/lib/gateway/SupabaseGateway.d.ts.map +1 -0
- package/lib/gateway/WorkerCommunication.d.ts +39 -0
- package/lib/gateway/WorkerCommunication.d.ts.map +1 -0
- package/lib/gateway/WorkerPool.d.ts +55 -0
- package/lib/gateway/WorkerPool.d.ts.map +1 -0
- package/lib/gateway/capabilities.d.ts +23 -0
- package/lib/gateway/capabilities.d.ts.map +1 -0
- package/lib/gateway/index.d.ts +9 -0
- package/lib/gateway/index.d.ts.map +1 -0
- package/lib/gateway/strategies/DirectStrategy.d.ts +52 -0
- package/lib/gateway/strategies/DirectStrategy.d.ts.map +1 -0
- package/lib/gateway/strategies/SharedWorkerStrategy.d.ts +46 -0
- package/lib/gateway/strategies/SharedWorkerStrategy.d.ts.map +1 -0
- package/lib/gateway/strategies/WebWorkerStrategy.d.ts +45 -0
- package/lib/gateway/strategies/WebWorkerStrategy.d.ts.map +1 -0
- package/lib/gateway/types.d.ts +81 -0
- package/lib/gateway/types.d.ts.map +1 -0
- package/lib/workers/canvas-ui-renderer.d.ts +25 -0
- package/lib/workers/canvas-ui-renderer.d.ts.map +1 -0
- package/lib/workers/canvas-worker.d.ts +14 -0
- package/lib/workers/canvas-worker.d.ts.map +1 -0
- package/lib/workers/canvas-worker.js +291 -145
- package/lib/workers/data.d.ts +40 -0
- package/lib/workers/data.d.ts.map +1 -0
- package/lib/workers/db-worker.d.ts +69 -0
- package/lib/workers/db-worker.d.ts.map +1 -0
- package/lib/workers/db-worker.js +2680 -2147
- package/lib/workers/events.d.ts +11 -0
- package/lib/workers/events.d.ts.map +1 -0
- package/lib/workers/flexbuilder.d.ts +4 -0
- package/lib/workers/flexbuilder.d.ts.map +1 -0
- package/lib/workers/init.d.ts +11 -0
- package/lib/workers/init.d.ts.map +1 -0
- package/lib/workers/main.d.ts +107 -0
- package/lib/workers/main.d.ts.map +1 -0
- package/lib/workers/supabase-db-worker.d.ts +2 -0
- package/lib/workers/supabase-db-worker.d.ts.map +1 -0
- package/lib/workers/supabase-db-worker.js +9337 -0
- package/lib/workers/supabase-shared-worker.d.ts +2 -0
- package/lib/workers/supabase-shared-worker.d.ts.map +1 -0
- package/lib/workers/supabase-shared-worker.js +12635 -0
- package/lib/workers/tools.d.ts +4 -0
- package/lib/workers/tools.d.ts.map +1 -0
- package/lib/workers/validate.d.ts +15 -0
- package/lib/workers/validate.d.ts.map +1 -0
- package/lib/workers/ws-worker.d.ts +11 -0
- package/lib/workers/ws-worker.d.ts.map +1 -0
- package/lib/workers/ws-worker.js +174 -115
- package/main.js +4875 -0
- package/package.json +17 -6
- package/reference.js +688 -228
- package/src/index.ts +38 -13
- package/src/lib/droid.spec.ts +14 -19
- package/src/lib/droid.ts +48 -4
- package/src/lib/gateway/SupabaseGateway.ts +183 -0
- package/src/lib/gateway/WorkerCommunication.ts +132 -0
- package/src/lib/gateway/WorkerPool.ts +304 -0
- package/src/lib/gateway/capabilities.ts +80 -0
- package/src/lib/gateway/index.ts +15 -0
- package/src/lib/gateway/strategies/DirectStrategy.ts +443 -0
- package/src/lib/gateway/strategies/SharedWorkerStrategy.ts +278 -0
- package/src/lib/gateway/strategies/WebWorkerStrategy.ts +278 -0
- package/src/lib/gateway/types.ts +112 -0
- package/src/lib/mod/mod-manager.ts +13 -8
- package/src/lib/mod/module/bento/mod-bento.worker.ts +1 -1
- package/src/lib/mod/module/supabase/mod-supabase.spec.ts +21 -25
- package/src/lib/mod/module/supabase/mod-supabase.ts +68 -0
- package/src/lib/mod/module/supabase/mod-supabase.worker.ts +1 -56
- package/src/lib/state/auth.spec.ts +58 -0
- package/src/lib/state/auth.ts +30 -0
- package/src/lib/state/index.ts +34 -0
- package/src/lib/state/overlay-manager.ts +125 -0
- package/src/lib/state/router.ts +24 -0
- package/src/lib/state/theme-sync.ts +75 -0
- package/src/lib/state/toasts.spec.ts +106 -0
- package/src/lib/state/toasts.ts +34 -0
- package/src/lib/state/ui.spec.ts +148 -0
- package/src/lib/state/ui.ts +45 -0
- package/src/lib/state/welcome-toast.ts +76 -0
- package/src/lib/types/bento.spec.ts +116 -0
- package/src/lib/types/bento.ts +94 -55
- package/src/lib/types/event-types.spec.ts +93 -0
- package/src/lib/types/event-types.ts +39 -3
- package/src/lib/types/modules.ts +12 -5
- package/src/lib/types/ui-event-types.ts +66 -0
- package/src/lib/workers/canvas-ui-renderer.ts +266 -0
- package/src/lib/workers/canvas-worker.ts +94 -9
- package/src/lib/workers/data.ts +59 -36
- package/src/lib/workers/db-worker.ts +79 -32
- package/src/lib/workers/events.spec.ts +208 -0
- package/src/lib/workers/events.ts +27 -14
- package/src/lib/workers/flexbuilder.ts +2 -2
- package/src/lib/workers/init.ts +25 -17
- package/src/lib/workers/main.ts +510 -316
- package/src/lib/workers/supabase-db-worker.ts +221 -0
- package/src/lib/workers/supabase-shared-worker.ts +572 -0
- package/src/lib/workers/validate.ts +64 -0
- package/src/lib/workers/ws-worker.ts +156 -25
- package/src/setup-vitest.ts +4 -2
- package/src/types.ts +30 -0
- package/src/vite-env.d.ts +1 -0
- package/workers/main.js +10 -1123
- package/src/types.d.ts +0 -31
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { map } from 'nanostores';
|
|
2
|
+
|
|
3
|
+
export type AuthTone = 'loading' | 'auth' | 'anon' | 'error';
|
|
4
|
+
|
|
5
|
+
export interface AuthState {
|
|
6
|
+
tone: AuthTone;
|
|
7
|
+
name: string;
|
|
8
|
+
avatar: string | undefined;
|
|
9
|
+
id: string;
|
|
10
|
+
error: string | undefined;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const DEFAULT_AUTH: AuthState = {
|
|
14
|
+
tone: 'loading',
|
|
15
|
+
name: '',
|
|
16
|
+
avatar: undefined,
|
|
17
|
+
id: '',
|
|
18
|
+
error: undefined,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export const $auth = map<AuthState>({ ...DEFAULT_AUTH });
|
|
22
|
+
|
|
23
|
+
export function setAuth(state: Partial<AuthState>) {
|
|
24
|
+
const current = $auth.get();
|
|
25
|
+
$auth.set({ ...current, ...state });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function resetAuth() {
|
|
29
|
+
$auth.set({ ...DEFAULT_AUTH, tone: 'anon' });
|
|
30
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export {
|
|
2
|
+
$auth,
|
|
3
|
+
setAuth,
|
|
4
|
+
resetAuth,
|
|
5
|
+
type AuthTone,
|
|
6
|
+
type AuthState,
|
|
7
|
+
} from './auth';
|
|
8
|
+
|
|
9
|
+
export { $currentPath, bootRouter } from './router';
|
|
10
|
+
|
|
11
|
+
export {
|
|
12
|
+
$activeTooltip,
|
|
13
|
+
$drawerOpen,
|
|
14
|
+
$modalId,
|
|
15
|
+
openTooltip,
|
|
16
|
+
closeTooltip,
|
|
17
|
+
openDrawer,
|
|
18
|
+
closeDrawer,
|
|
19
|
+
openModal,
|
|
20
|
+
closeModal,
|
|
21
|
+
} from './ui';
|
|
22
|
+
|
|
23
|
+
export { $toasts, addToast, removeToast } from './toasts';
|
|
24
|
+
|
|
25
|
+
export { showWelcomeToast } from './welcome-toast';
|
|
26
|
+
|
|
27
|
+
export {
|
|
28
|
+
syncThemeToDexie,
|
|
29
|
+
broadcastThemeChange,
|
|
30
|
+
observeThemeChanges,
|
|
31
|
+
} from './theme-sync';
|
|
32
|
+
|
|
33
|
+
export { OverlayManager } from './overlay-manager';
|
|
34
|
+
export type { RenderPath } from './overlay-manager';
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import type { Remote } from 'comlink';
|
|
2
|
+
import type { CanvasWorkerAPI } from '../workers/canvas-worker';
|
|
3
|
+
import { addToast, removeToast } from './toasts';
|
|
4
|
+
import { openTooltip, closeTooltip, openModal, closeModal } from './ui';
|
|
5
|
+
import type {
|
|
6
|
+
ToastPayload,
|
|
7
|
+
TooltipPayload,
|
|
8
|
+
ModalPayload,
|
|
9
|
+
} from '../types/ui-event-types';
|
|
10
|
+
|
|
11
|
+
export type RenderPath = 'dom' | 'canvas' | 'auto';
|
|
12
|
+
|
|
13
|
+
interface OverlayManagerConfig {
|
|
14
|
+
preferredPath: RenderPath;
|
|
15
|
+
canvasWorker?: Remote<CanvasWorkerAPI>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Unified overlay manager that routes to DOM or Canvas rendering.
|
|
20
|
+
*
|
|
21
|
+
* - 'dom': Uses nanostores → React components (Phases 1–6)
|
|
22
|
+
* - 'canvas': Uses CanvasUIRenderer in canvas-worker (Phase 9)
|
|
23
|
+
* - 'auto': Uses canvas if OffscreenCanvas is available AND a canvas worker
|
|
24
|
+
* is bound, otherwise falls back to DOM
|
|
25
|
+
*/
|
|
26
|
+
export class OverlayManager {
|
|
27
|
+
private path: RenderPath;
|
|
28
|
+
private canvasWorker: Remote<CanvasWorkerAPI> | null;
|
|
29
|
+
private canvasBound = false;
|
|
30
|
+
|
|
31
|
+
constructor(config: OverlayManagerConfig) {
|
|
32
|
+
this.path = config.preferredPath;
|
|
33
|
+
this.canvasWorker = config.canvasWorker ?? null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
private get effectivePath(): 'dom' | 'canvas' {
|
|
37
|
+
if (this.path === 'canvas' && this.canvasBound && this.canvasWorker) {
|
|
38
|
+
return 'canvas';
|
|
39
|
+
}
|
|
40
|
+
if (this.path === 'auto' && this.canvasBound && this.canvasWorker) {
|
|
41
|
+
return 'canvas';
|
|
42
|
+
}
|
|
43
|
+
return 'dom';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Bind a <canvas> element for off-thread overlay rendering.
|
|
48
|
+
* Must be called before canvas path can be used.
|
|
49
|
+
*/
|
|
50
|
+
async bindCanvas(
|
|
51
|
+
canvasEl: HTMLCanvasElement,
|
|
52
|
+
dbGet: (key: string) => Promise<string | null>,
|
|
53
|
+
): Promise<void> {
|
|
54
|
+
if (!this.canvasWorker) {
|
|
55
|
+
console.warn('[OverlayManager] No canvas worker available');
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const offscreen = canvasEl.transferControlToOffscreen();
|
|
59
|
+
await this.canvasWorker.bindUICanvas(offscreen, dbGet);
|
|
60
|
+
this.canvasBound = true;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ── Toast ──
|
|
64
|
+
|
|
65
|
+
toast(payload: ToastPayload): void {
|
|
66
|
+
if (this.effectivePath === 'canvas') {
|
|
67
|
+
void this.canvasWorker?.addCanvasToast(payload);
|
|
68
|
+
}
|
|
69
|
+
// Always update DOM state (nanostores) so event listeners and
|
|
70
|
+
// any DOM-rendered components stay in sync
|
|
71
|
+
addToast(payload);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
dismissToast(id: string): void {
|
|
75
|
+
if (this.effectivePath === 'canvas') {
|
|
76
|
+
void this.canvasWorker?.removeCanvasToast(id);
|
|
77
|
+
}
|
|
78
|
+
removeToast(id);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ── Tooltip ──
|
|
82
|
+
|
|
83
|
+
showTooltip(payload: TooltipPayload): void {
|
|
84
|
+
if (this.effectivePath === 'canvas') {
|
|
85
|
+
void this.canvasWorker?.showCanvasTooltip(payload);
|
|
86
|
+
}
|
|
87
|
+
openTooltip(payload.id);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
hideTooltip(id?: string): void {
|
|
91
|
+
if (this.effectivePath === 'canvas') {
|
|
92
|
+
void this.canvasWorker?.showCanvasTooltip(null);
|
|
93
|
+
}
|
|
94
|
+
closeTooltip(id);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ── Modal ──
|
|
98
|
+
|
|
99
|
+
showModal(payload: ModalPayload): void {
|
|
100
|
+
if (this.effectivePath === 'canvas') {
|
|
101
|
+
void this.canvasWorker?.showCanvasModal(payload);
|
|
102
|
+
}
|
|
103
|
+
openModal(payload.id);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
hideModal(id?: string): void {
|
|
107
|
+
if (this.effectivePath === 'canvas') {
|
|
108
|
+
void this.canvasWorker?.showCanvasModal(null);
|
|
109
|
+
}
|
|
110
|
+
closeModal(id);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Switch rendering path at runtime */
|
|
114
|
+
setPath(path: RenderPath): void {
|
|
115
|
+
this.path = path;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Unbind canvas and clean up */
|
|
119
|
+
async destroy(): Promise<void> {
|
|
120
|
+
if (this.canvasBound && this.canvasWorker) {
|
|
121
|
+
await this.canvasWorker.unbindUICanvas();
|
|
122
|
+
this.canvasBound = false;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { atom } from 'nanostores';
|
|
2
|
+
|
|
3
|
+
export const $currentPath = atom('/');
|
|
4
|
+
|
|
5
|
+
let _routerBooted = false;
|
|
6
|
+
|
|
7
|
+
export function bootRouter(initial?: string): void {
|
|
8
|
+
if (_routerBooted) return;
|
|
9
|
+
_routerBooted = true;
|
|
10
|
+
|
|
11
|
+
if (initial) $currentPath.set(initial);
|
|
12
|
+
|
|
13
|
+
if (typeof window === 'undefined') return;
|
|
14
|
+
|
|
15
|
+
$currentPath.set(window.location.pathname);
|
|
16
|
+
|
|
17
|
+
document.addEventListener('astro:page-load', () => {
|
|
18
|
+
$currentPath.set(window.location.pathname);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
window.addEventListener('popstate', () => {
|
|
22
|
+
$currentPath.set(window.location.pathname);
|
|
23
|
+
});
|
|
24
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { Remote } from 'comlink';
|
|
2
|
+
import type { LocalStorageAPI } from '../workers/db-worker';
|
|
3
|
+
|
|
4
|
+
const THEME_VARS = [
|
|
5
|
+
'accent',
|
|
6
|
+
'accent-low',
|
|
7
|
+
'accent-high',
|
|
8
|
+
'bg',
|
|
9
|
+
'bg-accent',
|
|
10
|
+
'text',
|
|
11
|
+
'text-accent',
|
|
12
|
+
'border',
|
|
13
|
+
'white',
|
|
14
|
+
'black',
|
|
15
|
+
] as const;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Read resolved CSS custom property values from the document root
|
|
19
|
+
* and persist them to Dexie so workers can access theme colors.
|
|
20
|
+
*/
|
|
21
|
+
export async function syncThemeToDexie(
|
|
22
|
+
api: Remote<LocalStorageAPI>,
|
|
23
|
+
): Promise<void> {
|
|
24
|
+
const root = document.documentElement;
|
|
25
|
+
const styles = getComputedStyle(root);
|
|
26
|
+
const mode = root.dataset['theme'] ?? 'dark';
|
|
27
|
+
|
|
28
|
+
await api.dbSet('theme:mode', mode);
|
|
29
|
+
|
|
30
|
+
for (const name of THEME_VARS) {
|
|
31
|
+
const value = styles.getPropertyValue(`--sl-color-${name}`).trim();
|
|
32
|
+
if (value) {
|
|
33
|
+
await api.dbSet(`theme:${name}`, value);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Broadcast a theme-sync event so workers re-read colors from Dexie.
|
|
40
|
+
*/
|
|
41
|
+
export function broadcastThemeChange(): void {
|
|
42
|
+
try {
|
|
43
|
+
const bc = new BroadcastChannel('kbve_theme');
|
|
44
|
+
bc.postMessage({ type: 'theme-sync', timestamp: Date.now() });
|
|
45
|
+
bc.close();
|
|
46
|
+
} catch {
|
|
47
|
+
// BroadcastChannel not available — workers won't get live updates
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Observe theme attribute changes and auto-sync.
|
|
53
|
+
* Call once from main() after Dexie is initialized.
|
|
54
|
+
*/
|
|
55
|
+
export function observeThemeChanges(api: Remote<LocalStorageAPI>): void {
|
|
56
|
+
const observer = new MutationObserver(async (mutations) => {
|
|
57
|
+
for (const mutation of mutations) {
|
|
58
|
+
if (
|
|
59
|
+
mutation.type === 'attributes' &&
|
|
60
|
+
mutation.attributeName === 'data-theme'
|
|
61
|
+
) {
|
|
62
|
+
await syncThemeToDexie(api);
|
|
63
|
+
broadcastThemeChange();
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
observer.observe(document.documentElement, {
|
|
69
|
+
attributes: true,
|
|
70
|
+
attributeFilter: ['data-theme'],
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// Initial sync
|
|
74
|
+
void syncThemeToDexie(api);
|
|
75
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { $toasts, addToast, removeToast } from './toasts';
|
|
3
|
+
import { DroidEvents } from '../workers/events';
|
|
4
|
+
import type { ToastPayload } from '../types/ui-event-types';
|
|
5
|
+
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
$toasts.set({});
|
|
8
|
+
// Reset the toast queue
|
|
9
|
+
(window as Record<string, unknown>).__kbveToastQueue = undefined;
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
const makeToast = (
|
|
13
|
+
id: string,
|
|
14
|
+
overrides?: Partial<ToastPayload>,
|
|
15
|
+
): ToastPayload => ({
|
|
16
|
+
id,
|
|
17
|
+
message: `Toast ${id}`,
|
|
18
|
+
severity: 'info',
|
|
19
|
+
...overrides,
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe('addToast', () => {
|
|
23
|
+
it('adds toast to the nanostore', () => {
|
|
24
|
+
addToast(makeToast('t1'));
|
|
25
|
+
expect($toasts.get()).toHaveProperty('t1');
|
|
26
|
+
expect($toasts.get()['t1'].message).toBe('Toast t1');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('adds multiple toasts', () => {
|
|
30
|
+
addToast(makeToast('t1'));
|
|
31
|
+
addToast(makeToast('t2', { severity: 'error' }));
|
|
32
|
+
|
|
33
|
+
const state = $toasts.get();
|
|
34
|
+
expect(Object.keys(state)).toHaveLength(2);
|
|
35
|
+
expect(state['t2'].severity).toBe('error');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('emits toast-added event', () => {
|
|
39
|
+
const handler = vi.fn();
|
|
40
|
+
DroidEvents.on('toast-added', handler);
|
|
41
|
+
|
|
42
|
+
const toast = makeToast('t1');
|
|
43
|
+
addToast(toast);
|
|
44
|
+
|
|
45
|
+
expect(handler).toHaveBeenCalledWith(toast);
|
|
46
|
+
DroidEvents.off('toast-added', handler);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('pushes to the pre-mount queue', () => {
|
|
50
|
+
addToast(makeToast('t1'));
|
|
51
|
+
|
|
52
|
+
const queue = (window as Record<string, unknown>)
|
|
53
|
+
.__kbveToastQueue as ToastPayload[];
|
|
54
|
+
expect(queue).toHaveLength(1);
|
|
55
|
+
expect(queue[0].id).toBe('t1');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('does not push to queue when queue is drained (null)', () => {
|
|
59
|
+
(window as Record<string, unknown>).__kbveToastQueue = null;
|
|
60
|
+
|
|
61
|
+
addToast(makeToast('t1'));
|
|
62
|
+
|
|
63
|
+
expect((window as Record<string, unknown>).__kbveToastQueue).toBeNull();
|
|
64
|
+
// But the nanostore should still have it
|
|
65
|
+
expect($toasts.get()).toHaveProperty('t1');
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe('removeToast', () => {
|
|
70
|
+
it('removes toast from the nanostore', () => {
|
|
71
|
+
addToast(makeToast('t1'));
|
|
72
|
+
addToast(makeToast('t2'));
|
|
73
|
+
|
|
74
|
+
removeToast('t1');
|
|
75
|
+
|
|
76
|
+
expect($toasts.get()).not.toHaveProperty('t1');
|
|
77
|
+
expect($toasts.get()).toHaveProperty('t2');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('emits toast-removed event', () => {
|
|
81
|
+
const handler = vi.fn();
|
|
82
|
+
DroidEvents.on('toast-removed', handler);
|
|
83
|
+
|
|
84
|
+
addToast(makeToast('t1'));
|
|
85
|
+
removeToast('t1');
|
|
86
|
+
|
|
87
|
+
expect(handler).toHaveBeenCalledWith({ id: 't1' });
|
|
88
|
+
DroidEvents.off('toast-removed', handler);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('removes toast from the pre-mount queue', () => {
|
|
92
|
+
addToast(makeToast('t1'));
|
|
93
|
+
addToast(makeToast('t2'));
|
|
94
|
+
|
|
95
|
+
removeToast('t1');
|
|
96
|
+
|
|
97
|
+
const queue = (window as Record<string, unknown>)
|
|
98
|
+
.__kbveToastQueue as ToastPayload[];
|
|
99
|
+
expect(queue).toHaveLength(1);
|
|
100
|
+
expect(queue[0].id).toBe('t2');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('handles removing a non-existent toast gracefully', () => {
|
|
104
|
+
expect(() => removeToast('non-existent')).not.toThrow();
|
|
105
|
+
});
|
|
106
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { map } from 'nanostores';
|
|
2
|
+
import { DroidEvents } from '../workers/events';
|
|
3
|
+
import type { ToastPayload } from '../types/ui-event-types';
|
|
4
|
+
|
|
5
|
+
export const $toasts = map<Record<string, ToastPayload>>({});
|
|
6
|
+
|
|
7
|
+
// Returns the pre-mount toast buffer, or null if the ToastContainer
|
|
8
|
+
// has already mounted and drained it (future toasts reach the
|
|
9
|
+
// container via nanostores + the CustomEvent bridge instead).
|
|
10
|
+
function getToastQueue(): ToastPayload[] | null {
|
|
11
|
+
if (window.__kbveToastQueue === null) return null;
|
|
12
|
+
if (!window.__kbveToastQueue) window.__kbveToastQueue = [];
|
|
13
|
+
return window.__kbveToastQueue;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function addToast(payload: ToastPayload): void {
|
|
17
|
+
$toasts.set({ ...$toasts.get(), [payload.id]: payload });
|
|
18
|
+
getToastQueue()?.push(payload);
|
|
19
|
+
DroidEvents.emit('toast-added', payload);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function removeToast(id: string): void {
|
|
23
|
+
const current = { ...$toasts.get() };
|
|
24
|
+
delete current[id];
|
|
25
|
+
$toasts.set(current);
|
|
26
|
+
|
|
27
|
+
const q = getToastQueue();
|
|
28
|
+
if (q) {
|
|
29
|
+
const idx = q.findIndex((t) => t.id === id);
|
|
30
|
+
if (idx !== -1) q.splice(idx, 1);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
DroidEvents.emit('toast-removed', { id });
|
|
34
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
$activeTooltip,
|
|
4
|
+
$drawerOpen,
|
|
5
|
+
$modalId,
|
|
6
|
+
openTooltip,
|
|
7
|
+
closeTooltip,
|
|
8
|
+
openDrawer,
|
|
9
|
+
closeDrawer,
|
|
10
|
+
openModal,
|
|
11
|
+
closeModal,
|
|
12
|
+
} from './ui';
|
|
13
|
+
import { DroidEvents } from '../workers/events';
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
$activeTooltip.set(null);
|
|
17
|
+
$drawerOpen.set(false);
|
|
18
|
+
$modalId.set(null);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe('Tooltip state', () => {
|
|
22
|
+
it('openTooltip sets the active tooltip', () => {
|
|
23
|
+
openTooltip('tip-1');
|
|
24
|
+
expect($activeTooltip.get()).toBe('tip-1');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('openTooltip emits tooltip-opened event', () => {
|
|
28
|
+
const handler = vi.fn();
|
|
29
|
+
DroidEvents.on('tooltip-opened', handler);
|
|
30
|
+
|
|
31
|
+
openTooltip('tip-2');
|
|
32
|
+
|
|
33
|
+
expect(handler).toHaveBeenCalledWith({ id: 'tip-2' });
|
|
34
|
+
DroidEvents.off('tooltip-opened', handler);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('closeTooltip clears the active tooltip', () => {
|
|
38
|
+
openTooltip('tip-1');
|
|
39
|
+
closeTooltip();
|
|
40
|
+
expect($activeTooltip.get()).toBeNull();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('closeTooltip emits tooltip-closed event', () => {
|
|
44
|
+
const handler = vi.fn();
|
|
45
|
+
DroidEvents.on('tooltip-closed', handler);
|
|
46
|
+
|
|
47
|
+
openTooltip('tip-1');
|
|
48
|
+
closeTooltip();
|
|
49
|
+
|
|
50
|
+
expect(handler).toHaveBeenCalledWith({ id: 'tip-1' });
|
|
51
|
+
DroidEvents.off('tooltip-closed', handler);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('closeTooltip with mismatched id does nothing', () => {
|
|
55
|
+
openTooltip('tip-1');
|
|
56
|
+
closeTooltip('tip-2');
|
|
57
|
+
expect($activeTooltip.get()).toBe('tip-1');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('closeTooltip with matching id clears tooltip', () => {
|
|
61
|
+
openTooltip('tip-1');
|
|
62
|
+
closeTooltip('tip-1');
|
|
63
|
+
expect($activeTooltip.get()).toBeNull();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('closeTooltip does not emit when no tooltip is active', () => {
|
|
67
|
+
const handler = vi.fn();
|
|
68
|
+
DroidEvents.on('tooltip-closed', handler);
|
|
69
|
+
|
|
70
|
+
closeTooltip();
|
|
71
|
+
|
|
72
|
+
expect(handler).not.toHaveBeenCalled();
|
|
73
|
+
DroidEvents.off('tooltip-closed', handler);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe('Drawer state', () => {
|
|
78
|
+
it('openDrawer sets drawer open and clears tooltip', () => {
|
|
79
|
+
openTooltip('tip-1');
|
|
80
|
+
openDrawer();
|
|
81
|
+
expect($drawerOpen.get()).toBe(true);
|
|
82
|
+
expect($activeTooltip.get()).toBeNull();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('closeDrawer sets drawer closed', () => {
|
|
86
|
+
openDrawer();
|
|
87
|
+
closeDrawer();
|
|
88
|
+
expect($drawerOpen.get()).toBe(false);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe('Modal state', () => {
|
|
93
|
+
it('openModal sets the modal id', () => {
|
|
94
|
+
openModal('modal-1');
|
|
95
|
+
expect($modalId.get()).toBe('modal-1');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('openModal closes drawer and tooltip', () => {
|
|
99
|
+
openDrawer();
|
|
100
|
+
openTooltip('tip-1');
|
|
101
|
+
openModal('modal-1');
|
|
102
|
+
expect($drawerOpen.get()).toBe(false);
|
|
103
|
+
expect($activeTooltip.get()).toBeNull();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('openModal emits modal-opened event', () => {
|
|
107
|
+
const handler = vi.fn();
|
|
108
|
+
DroidEvents.on('modal-opened', handler);
|
|
109
|
+
|
|
110
|
+
openModal('modal-1');
|
|
111
|
+
|
|
112
|
+
expect(handler).toHaveBeenCalledWith({ id: 'modal-1' });
|
|
113
|
+
DroidEvents.off('modal-opened', handler);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('closeModal clears the modal id', () => {
|
|
117
|
+
openModal('modal-1');
|
|
118
|
+
closeModal();
|
|
119
|
+
expect($modalId.get()).toBeNull();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('closeModal emits modal-closed event', () => {
|
|
123
|
+
const handler = vi.fn();
|
|
124
|
+
DroidEvents.on('modal-closed', handler);
|
|
125
|
+
|
|
126
|
+
openModal('modal-1');
|
|
127
|
+
closeModal();
|
|
128
|
+
|
|
129
|
+
expect(handler).toHaveBeenCalledWith({ id: 'modal-1' });
|
|
130
|
+
DroidEvents.off('modal-closed', handler);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('closeModal with mismatched id does nothing', () => {
|
|
134
|
+
openModal('modal-1');
|
|
135
|
+
closeModal('modal-2');
|
|
136
|
+
expect($modalId.get()).toBe('modal-1');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('closeModal does not emit when no modal is active', () => {
|
|
140
|
+
const handler = vi.fn();
|
|
141
|
+
DroidEvents.on('modal-closed', handler);
|
|
142
|
+
|
|
143
|
+
closeModal();
|
|
144
|
+
|
|
145
|
+
expect(handler).not.toHaveBeenCalled();
|
|
146
|
+
DroidEvents.off('modal-closed', handler);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { atom } from 'nanostores';
|
|
2
|
+
import { DroidEvents } from '../workers/events';
|
|
3
|
+
|
|
4
|
+
export const $activeTooltip = atom<string | null>(null);
|
|
5
|
+
export const $drawerOpen = atom(false);
|
|
6
|
+
export const $modalId = atom<string | null>(null);
|
|
7
|
+
|
|
8
|
+
export function openTooltip(id: string) {
|
|
9
|
+
$activeTooltip.set(id);
|
|
10
|
+
DroidEvents.emit('tooltip-opened', { id });
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function closeTooltip(id?: string) {
|
|
14
|
+
if (id && $activeTooltip.get() !== id) return;
|
|
15
|
+
const closedId = $activeTooltip.get();
|
|
16
|
+
$activeTooltip.set(null);
|
|
17
|
+
if (closedId) {
|
|
18
|
+
DroidEvents.emit('tooltip-closed', { id: closedId });
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function openDrawer() {
|
|
23
|
+
$drawerOpen.set(true);
|
|
24
|
+
$activeTooltip.set(null);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function closeDrawer() {
|
|
28
|
+
$drawerOpen.set(false);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function openModal(id: string) {
|
|
32
|
+
$modalId.set(id);
|
|
33
|
+
$drawerOpen.set(false);
|
|
34
|
+
$activeTooltip.set(null);
|
|
35
|
+
DroidEvents.emit('modal-opened', { id });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function closeModal(id?: string) {
|
|
39
|
+
if (id && $modalId.get() !== id) return;
|
|
40
|
+
const closedId = $modalId.get();
|
|
41
|
+
$modalId.set(null);
|
|
42
|
+
if (closedId) {
|
|
43
|
+
DroidEvents.emit('modal-closed', { id: closedId });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { $auth } from './auth';
|
|
2
|
+
import { addToast } from './toasts';
|
|
3
|
+
|
|
4
|
+
const SHOWN_KEY = 'kbve:welcome-shown';
|
|
5
|
+
|
|
6
|
+
function hasShown(): boolean {
|
|
7
|
+
try {
|
|
8
|
+
return sessionStorage.getItem(SHOWN_KEY) === '1';
|
|
9
|
+
} catch {
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function markShown(): void {
|
|
15
|
+
try {
|
|
16
|
+
sessionStorage.setItem(SHOWN_KEY, '1');
|
|
17
|
+
} catch {
|
|
18
|
+
/* private browsing / storage full */
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function showWelcomeToast(): void {
|
|
23
|
+
if (hasShown()) return;
|
|
24
|
+
|
|
25
|
+
const tryShow = (): boolean => {
|
|
26
|
+
const auth = $auth.get();
|
|
27
|
+
if (auth.tone === 'auth' && auth.name) {
|
|
28
|
+
markShown();
|
|
29
|
+
addToast({
|
|
30
|
+
id: `welcome-${Date.now()}`,
|
|
31
|
+
message: `Welcome back, ${auth.name}`,
|
|
32
|
+
severity: 'success',
|
|
33
|
+
duration: 4000,
|
|
34
|
+
});
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
if (auth.tone === 'anon') {
|
|
38
|
+
markShown();
|
|
39
|
+
addToast({
|
|
40
|
+
id: `welcome-${Date.now()}`,
|
|
41
|
+
message: 'Welcome!',
|
|
42
|
+
severity: 'info',
|
|
43
|
+
duration: 3000,
|
|
44
|
+
});
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
if (auth.tone === 'error') {
|
|
48
|
+
markShown();
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
return false;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
if (tryShow()) return;
|
|
55
|
+
|
|
56
|
+
// Auth still loading — subscribe and wait (10s timeout)
|
|
57
|
+
const timeoutId = setTimeout(() => {
|
|
58
|
+
unsub();
|
|
59
|
+
if (!hasShown()) {
|
|
60
|
+
markShown();
|
|
61
|
+
addToast({
|
|
62
|
+
id: `welcome-${Date.now()}`,
|
|
63
|
+
message: 'Welcome!',
|
|
64
|
+
severity: 'info',
|
|
65
|
+
duration: 3000,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}, 10_000);
|
|
69
|
+
|
|
70
|
+
const unsub = $auth.subscribe(() => {
|
|
71
|
+
if ($auth.get().tone === 'loading') return;
|
|
72
|
+
clearTimeout(timeoutId);
|
|
73
|
+
unsub();
|
|
74
|
+
tryShow();
|
|
75
|
+
});
|
|
76
|
+
}
|