@kbve/droid 0.0.6 → 0.1.2
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/droid.mjs +2312 -74
- package/index.d.ts +32 -0
- package/lib/agents/api-types.d.ts +121 -0
- package/lib/agents/api-types.d.ts.map +1 -0
- package/lib/agents/auth/init.d.ts +8 -0
- package/lib/agents/auth/init.d.ts.map +1 -0
- package/lib/agents/cache.d.ts +9 -0
- package/lib/agents/cache.d.ts.map +1 -0
- package/lib/agents/client/callGuildVault.d.ts +3 -0
- package/lib/agents/client/callGuildVault.d.ts.map +1 -0
- package/lib/agents/client/callJson.d.ts +3 -0
- package/lib/agents/client/callJson.d.ts.map +1 -0
- package/lib/agents/config.d.ts +36 -0
- package/lib/agents/config.d.ts.map +1 -0
- package/lib/agents/constants.d.ts +11 -0
- package/lib/agents/constants.d.ts.map +1 -0
- package/lib/agents/createAgents.d.ts +4 -0
- package/lib/agents/createAgents.d.ts.map +1 -0
- package/lib/agents/ctx.d.ts +46 -0
- package/lib/agents/ctx.d.ts.map +1 -0
- package/lib/agents/discord/bot.d.ts +21 -0
- package/lib/agents/discord/bot.d.ts.map +1 -0
- package/lib/agents/discord/guilds.d.ts +8 -0
- package/lib/agents/discord/guilds.d.ts.map +1 -0
- package/lib/agents/formDrafts.d.ts +5 -0
- package/lib/agents/formDrafts.d.ts.map +1 -0
- package/lib/agents/generated/agents-schema.d.ts +73 -0
- package/lib/agents/generated/agents-schema.d.ts.map +1 -0
- package/lib/agents/generated/discordsh-agents-schema.d.ts +71 -0
- package/lib/agents/generated/discordsh-agents-schema.d.ts.map +1 -0
- package/lib/agents/github/backfill.d.ts +23 -0
- package/lib/agents/github/backfill.d.ts.map +1 -0
- package/lib/agents/github/events.d.ts +10 -0
- package/lib/agents/github/events.d.ts.map +1 -0
- package/lib/agents/github/pat.d.ts +13 -0
- package/lib/agents/github/pat.d.ts.map +1 -0
- package/lib/agents/github/repoConfig.d.ts +25 -0
- package/lib/agents/github/repoConfig.d.ts.map +1 -0
- package/lib/agents/github/tokens.d.ts +22 -0
- package/lib/agents/github/tokens.d.ts.map +1 -0
- package/lib/agents/github/webhook.d.ts +37 -0
- package/lib/agents/github/webhook.d.ts.map +1 -0
- package/lib/agents/index.d.ts +8 -0
- package/lib/agents/index.d.ts.map +1 -0
- package/lib/agents/state/atoms.d.ts +122 -0
- package/lib/agents/state/atoms.d.ts.map +1 -0
- package/lib/agents/types.d.ts +65 -0
- package/lib/agents/types.d.ts.map +1 -0
- 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 +5 -302
- 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 +13 -3636
- 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 +105 -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 +23 -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 +25 -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 +10 -0
- package/lib/workers/ws-worker.d.ts.map +1 -0
- package/lib/workers/ws-worker.js +5 -273
- package/main.js +5711 -0
- package/package.json +18 -6
- package/src/index.ts +41 -13
- package/src/lib/agents/api-types.ts +170 -0
- package/src/lib/agents/auth/init.ts +200 -0
- package/src/lib/agents/cache.ts +127 -0
- package/src/lib/agents/client/callGuildVault.ts +71 -0
- package/src/lib/agents/client/callJson.ts +64 -0
- package/src/lib/agents/config.ts +83 -0
- package/src/lib/agents/constants.ts +11 -0
- package/src/lib/agents/createAgents.ts +62 -0
- package/src/lib/agents/ctx.ts +63 -0
- package/src/lib/agents/discord/bot.ts +169 -0
- package/src/lib/agents/discord/guilds.ts +106 -0
- package/src/lib/agents/formDrafts.ts +55 -0
- package/src/lib/agents/generated/agents-schema.ts +155 -0
- package/src/lib/agents/generated/discordsh-agents-schema.ts +96 -0
- package/src/lib/agents/github/backfill.ts +126 -0
- package/src/lib/agents/github/events.ts +131 -0
- package/src/lib/agents/github/pat.ts +150 -0
- package/src/lib/agents/github/repoConfig.ts +240 -0
- package/src/lib/agents/github/tokens.ts +222 -0
- package/src/lib/agents/github/webhook.ts +349 -0
- package/src/lib/agents/index.ts +35 -0
- package/src/lib/agents/state/atoms.ts +122 -0
- package/src/lib/agents/types.ts +83 -0
- package/src/lib/api.ts +71 -0
- package/src/lib/droid.spec.ts +14 -19
- package/src/lib/droid.ts +48 -4
- package/src/lib/gateway/SupabaseGateway.ts +236 -0
- package/src/lib/gateway/WorkerCommunication.ts +132 -0
- package/src/lib/gateway/WorkerPool.ts +304 -0
- package/src/lib/gateway/capabilities.spec.ts +144 -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 +139 -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 +73 -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 +148 -0
- package/src/lib/state/bento.spec.ts +76 -0
- package/src/lib/state/bento.ts +51 -0
- package/src/lib/state/index.ts +98 -0
- package/src/lib/state/overlay-manager.ts +125 -0
- package/src/lib/state/profile-sync.ts +214 -0
- package/src/lib/state/profile.ts +155 -0
- package/src/lib/state/router.ts +24 -0
- package/src/lib/state/staff.ts +128 -0
- package/src/lib/state/sync-bus.ts +109 -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.spec.ts +101 -0
- package/src/lib/state/welcome-toast.ts +76 -0
- package/src/lib/sw-recovery.spec.ts +165 -0
- package/src/lib/sw-recovery.ts +141 -0
- package/src/lib/types/bento.spec.ts +116 -0
- package/src/lib/types/bento.ts +94 -55
- package/src/lib/types/event-schemas.spec.ts +120 -0
- package/src/lib/types/event-types.spec.ts +96 -0
- package/src/lib/types/event-types.ts +113 -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 +64 -37
- package/src/lib/workers/db-worker.ts +105 -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 +506 -327
- package/src/lib/workers/supabase-db-worker.ts +243 -0
- package/src/lib/workers/supabase-shared-worker.ts +607 -0
- package/src/lib/workers/validate.ts +64 -0
- package/src/lib/workers/ws-worker.spec.ts +41 -0
- package/src/lib/workers/ws-worker.ts +255 -31
- 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 +2 -1125
- package/comlink.js +0 -253
- package/index.js +0 -2972
- package/reference.js +0 -259
- package/src/types.d.ts +0 -31
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import {
|
|
2
|
+
clearProfileCache,
|
|
3
|
+
setProfileCache,
|
|
4
|
+
type DroidProfile,
|
|
5
|
+
} from './profile';
|
|
6
|
+
import { clearStaffPermsCache, setStaffPermsCache } from './staff';
|
|
7
|
+
|
|
8
|
+
const CHANNEL_NAME = 'kbve-droid-sync';
|
|
9
|
+
|
|
10
|
+
export type SyncBusMessage =
|
|
11
|
+
| { type: 'profile-refresh'; profile: DroidProfile }
|
|
12
|
+
| { type: 'profile-clear' }
|
|
13
|
+
| {
|
|
14
|
+
type: 'staff-refresh';
|
|
15
|
+
user_id: string;
|
|
16
|
+
bitmask: number;
|
|
17
|
+
}
|
|
18
|
+
| { type: 'staff-clear' };
|
|
19
|
+
|
|
20
|
+
let channel: BroadcastChannel | null = null;
|
|
21
|
+
let installed = false;
|
|
22
|
+
|
|
23
|
+
function getChannel(): BroadcastChannel | null {
|
|
24
|
+
if (typeof BroadcastChannel === 'undefined') return null;
|
|
25
|
+
if (!channel) {
|
|
26
|
+
try {
|
|
27
|
+
channel = new BroadcastChannel(CHANNEL_NAME);
|
|
28
|
+
} catch {
|
|
29
|
+
channel = null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return channel;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Subscribe to cross-context cache refresh messages. The service
|
|
37
|
+
* worker / shared worker / other tabs can post `SyncBusMessage`
|
|
38
|
+
* values to update the persistent stores. localStorage `storage`
|
|
39
|
+
* events already handle cross-tab sync for the persistentAtom
|
|
40
|
+
* itself; this bus exists so workers (which have no localStorage)
|
|
41
|
+
* can drive writes through.
|
|
42
|
+
*/
|
|
43
|
+
const noop = (): void => {
|
|
44
|
+
/* no-op cleanup */
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export function installSyncBusListener(): () => void {
|
|
48
|
+
if (installed) return noop;
|
|
49
|
+
const bc = getChannel();
|
|
50
|
+
if (!bc) return noop;
|
|
51
|
+
installed = true;
|
|
52
|
+
|
|
53
|
+
const onMessage = (event: MessageEvent<SyncBusMessage>) => {
|
|
54
|
+
const msg = event.data;
|
|
55
|
+
if (!msg || typeof msg !== 'object') return;
|
|
56
|
+
switch (msg.type) {
|
|
57
|
+
case 'profile-refresh':
|
|
58
|
+
if (msg.profile?.user_id) setProfileCache(msg.profile);
|
|
59
|
+
break;
|
|
60
|
+
case 'profile-clear':
|
|
61
|
+
clearProfileCache();
|
|
62
|
+
break;
|
|
63
|
+
case 'staff-refresh':
|
|
64
|
+
if (msg.user_id && typeof msg.bitmask === 'number') {
|
|
65
|
+
setStaffPermsCache(msg.user_id, msg.bitmask);
|
|
66
|
+
}
|
|
67
|
+
break;
|
|
68
|
+
case 'staff-clear':
|
|
69
|
+
clearStaffPermsCache();
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
bc.addEventListener('message', onMessage);
|
|
75
|
+
|
|
76
|
+
return () => {
|
|
77
|
+
bc.removeEventListener('message', onMessage);
|
|
78
|
+
installed = false;
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function broadcastProfileRefresh(profile: DroidProfile): void {
|
|
83
|
+
if (!profile?.user_id) return;
|
|
84
|
+
getChannel()?.postMessage({
|
|
85
|
+
type: 'profile-refresh',
|
|
86
|
+
profile,
|
|
87
|
+
} satisfies SyncBusMessage);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function broadcastProfileClear(): void {
|
|
91
|
+
getChannel()?.postMessage({
|
|
92
|
+
type: 'profile-clear',
|
|
93
|
+
} satisfies SyncBusMessage);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function broadcastStaffRefresh(userId: string, bitmask: number): void {
|
|
97
|
+
if (!userId) return;
|
|
98
|
+
getChannel()?.postMessage({
|
|
99
|
+
type: 'staff-refresh',
|
|
100
|
+
user_id: userId,
|
|
101
|
+
bitmask,
|
|
102
|
+
} satisfies SyncBusMessage);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function broadcastStaffClear(): void {
|
|
106
|
+
getChannel()?.postMessage({
|
|
107
|
+
type: 'staff-clear',
|
|
108
|
+
} satisfies SyncBusMessage);
|
|
109
|
+
}
|
|
@@ -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,101 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { showWelcomeToast } from './welcome-toast';
|
|
3
|
+
import { $auth, AuthPresets, resetAuth, setAuth } from './auth';
|
|
4
|
+
import { $toasts } from './toasts';
|
|
5
|
+
|
|
6
|
+
const SHOWN_KEY = 'kbve:welcome-shown';
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
sessionStorage.clear();
|
|
10
|
+
$toasts.set({});
|
|
11
|
+
$auth.set({
|
|
12
|
+
tone: 'loading',
|
|
13
|
+
flags: AuthPresets.LOADING,
|
|
14
|
+
name: '',
|
|
15
|
+
username: undefined,
|
|
16
|
+
avatar: undefined,
|
|
17
|
+
id: '',
|
|
18
|
+
error: undefined,
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
vi.useRealTimers();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe('showWelcomeToast', () => {
|
|
27
|
+
it('emits a "Welcome back, <name>" success toast for an authed user', () => {
|
|
28
|
+
setAuth({ tone: 'auth', name: 'Alice' });
|
|
29
|
+
showWelcomeToast();
|
|
30
|
+
const toasts = Object.values($toasts.get());
|
|
31
|
+
expect(toasts).toHaveLength(1);
|
|
32
|
+
expect(toasts[0]).toMatchObject({
|
|
33
|
+
message: 'Welcome back, Alice',
|
|
34
|
+
severity: 'success',
|
|
35
|
+
});
|
|
36
|
+
expect(sessionStorage.getItem(SHOWN_KEY)).toBe('1');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('emits a generic info toast for an anon visitor', () => {
|
|
40
|
+
setAuth({ tone: 'anon' });
|
|
41
|
+
showWelcomeToast();
|
|
42
|
+
const toasts = Object.values($toasts.get());
|
|
43
|
+
expect(toasts[0]).toMatchObject({
|
|
44
|
+
message: 'Welcome!',
|
|
45
|
+
severity: 'info',
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('does not emit twice in the same session', () => {
|
|
50
|
+
setAuth({ tone: 'anon' });
|
|
51
|
+
showWelcomeToast();
|
|
52
|
+
showWelcomeToast();
|
|
53
|
+
expect(Object.values($toasts.get())).toHaveLength(1);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('emits when auth resolves after a delay (subscription path)', () => {
|
|
57
|
+
showWelcomeToast();
|
|
58
|
+
expect(Object.values($toasts.get())).toHaveLength(0);
|
|
59
|
+
|
|
60
|
+
setAuth({ tone: 'auth', name: 'Bob' });
|
|
61
|
+
const toasts = Object.values($toasts.get());
|
|
62
|
+
expect(toasts).toHaveLength(1);
|
|
63
|
+
expect(toasts[0]).toMatchObject({ message: 'Welcome back, Bob' });
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('falls back to a generic toast after the 10s timeout when auth never resolves', () => {
|
|
67
|
+
vi.useFakeTimers();
|
|
68
|
+
showWelcomeToast();
|
|
69
|
+
expect(Object.values($toasts.get())).toHaveLength(0);
|
|
70
|
+
|
|
71
|
+
vi.advanceTimersByTime(10_000);
|
|
72
|
+
const toasts = Object.values($toasts.get());
|
|
73
|
+
expect(toasts).toHaveLength(1);
|
|
74
|
+
expect(toasts[0]).toMatchObject({
|
|
75
|
+
message: 'Welcome!',
|
|
76
|
+
severity: 'info',
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('marks shown without emitting when tone is error', () => {
|
|
81
|
+
setAuth({ tone: 'error', error: 'boom' });
|
|
82
|
+
showWelcomeToast();
|
|
83
|
+
expect(Object.values($toasts.get())).toHaveLength(0);
|
|
84
|
+
expect(sessionStorage.getItem(SHOWN_KEY)).toBe('1');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('treats sessionStorage failures as not-shown without throwing', () => {
|
|
88
|
+
const original = Storage.prototype.getItem;
|
|
89
|
+
Storage.prototype.getItem = () => {
|
|
90
|
+
throw new Error('private mode');
|
|
91
|
+
};
|
|
92
|
+
try {
|
|
93
|
+
expect(() => {
|
|
94
|
+
resetAuth();
|
|
95
|
+
showWelcomeToast();
|
|
96
|
+
}).not.toThrow();
|
|
97
|
+
} finally {
|
|
98
|
+
Storage.prototype.getItem = original;
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -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
|
+
}
|