rune-lab 0.4.8-alpha.2 → 0.4.8

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.
Files changed (28) hide show
  1. package/dist/RuneProvider.svelte +2 -1
  2. package/dist/i18n/paraglide/README.md +1 -1
  3. package/dist/kernel/src/context/accessors.test.d.ts +1 -0
  4. package/dist/kernel/src/context/accessors.test.js +68 -0
  5. package/dist/kernel/src/context/app.svelte.js +7 -1
  6. package/dist/kernel/src/context/stores.svelte.d.ts +5 -3
  7. package/dist/kernel/src/context/stores.svelte.js +49 -7
  8. package/dist/kernel/src/persistence/createConfigStore.svelte.d.ts +12 -12
  9. package/dist/kernel/src/persistence/createConfigStore.svelte.js +1 -1
  10. package/dist/kernel/src/persistence/drivers.d.ts +13 -0
  11. package/dist/kernel/src/persistence/drivers.js +18 -0
  12. package/dist/kernel/src/registry/mod.js +5 -1
  13. package/dist/mod.js +1 -1
  14. package/dist/runes/layout/src/ResourceSelector.svelte +7 -7
  15. package/dist/runes/layout/src/ResourceSelector.svelte.d.ts +1 -1
  16. package/dist/runes/layout/src/language.svelte.d.ts +7 -3
  17. package/dist/runes/layout/src/theme.svelte.d.ts +5 -2
  18. package/dist/runes/palettes/src/commands/store.svelte.d.ts +3 -3
  19. package/dist/runes/palettes/src/notifications/Toaster.svelte +13 -4
  20. package/dist/runes/palettes/src/notifications/store.svelte.d.ts +11 -2
  21. package/dist/runes/palettes/src/notifications/store.svelte.js +55 -4
  22. package/dist/runes/palettes/src/notifications/store.test.d.ts +1 -0
  23. package/dist/runes/palettes/src/notifications/store.test.js +83 -0
  24. package/dist/runes/plugins/money/src/currency.svelte.d.ts +1 -1
  25. package/dist/runes/plugins/money/src/exchange-rate.svelte.d.ts +1 -2
  26. package/dist/runes/plugins/money/src/exchange-rate.svelte.js +76 -83
  27. package/dist/runes/plugins/money/src/useMoney.js +15 -0
  28. package/package.json +3 -3
@@ -3,6 +3,7 @@
3
3
  import {
4
4
  RUNE_LAB_CONTEXT,
5
5
  localStorageDriver,
6
+ namespaced,
6
7
  initializeStores,
7
8
  defineRune,
8
9
  createAppStore,
@@ -43,7 +44,7 @@
43
44
  }>();
44
45
 
45
46
  const initialPlugins = untrack(() => plugins);
46
- const initialPersistence = untrack(() => config.persistence ?? localStorageDriver);
47
+ const initialPersistence = untrack(() => namespaced(config.persistence ?? localStorageDriver, "rl:"));
47
48
 
48
49
  // 0. Create and provide the built-in AppStore
49
50
  const appStore = createAppStore();
@@ -4,7 +4,7 @@
4
4
  > strings.
5
5
 
6
6
  Compiled from:
7
- `/home/yrrrrrf/docs/lab/code/typescript/rune-lab/src/package/src/lib/i18n/project.inlang`
7
+ `/home/yrrrrrf/Documents/lab/code/typescript/rune-lab/src/package/src/lib/i18n/project.inlang`
8
8
 
9
9
  ## What is this folder?
10
10
 
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,68 @@
1
+ import { describe, expect, it, vi } from "vite-plus/test";
2
+ // Mock Svelte's getContext so we can test the behavior when a context is not present
3
+ // without triggering Svelte's lifecycle_outside_component error.
4
+ vi.mock("svelte", () => ({
5
+ getContext: () => undefined,
6
+ }));
7
+ import {
8
+ getCommandStore,
9
+ getCurrencyStore,
10
+ getLanguageStore,
11
+ getLayoutStore,
12
+ getShortcutStore,
13
+ getThemeStore,
14
+ getToastStore,
15
+ } from "./stores.svelte.js";
16
+ import { getAppStore } from "./app.svelte.js";
17
+ import { getExchangeRateStore } from "../../../runes/plugins/money/src/exchange-rate.svelte.js";
18
+ import { useMoney } from "../../../runes/plugins/money/src/useMoney.js";
19
+ describe("Fail-fast context accessors", () => {
20
+ it("should throw a helpful error for getAppStore", () => {
21
+ expect(() => getAppStore()).toThrow(/getAppStore\(\) found no AppStore/i);
22
+ });
23
+ it("should throw a helpful error for getLayoutStore", () => {
24
+ expect(() => getLayoutStore()).toThrow(
25
+ /getLayoutStore\(\) found no LayoutStore.*LayoutPlugin/i,
26
+ );
27
+ });
28
+ it("should throw a helpful error for getLanguageStore", () => {
29
+ expect(() => getLanguageStore()).toThrow(
30
+ /getLanguageStore\(\) found no LanguageStore.*LayoutPlugin/i,
31
+ );
32
+ });
33
+ it("should throw a helpful error for getThemeStore", () => {
34
+ expect(() => getThemeStore()).toThrow(
35
+ /getThemeStore\(\) found no ThemeStore.*LayoutPlugin/i,
36
+ );
37
+ });
38
+ it("should throw a helpful error for getShortcutStore", () => {
39
+ expect(() => getShortcutStore()).toThrow(
40
+ /getShortcutStore\(\) found no ShortcutStore.*PalettesPlugin/i,
41
+ );
42
+ });
43
+ it("should throw a helpful error for getCommandStore", () => {
44
+ expect(() => getCommandStore()).toThrow(
45
+ /getCommandStore\(\) found no CommandStore.*PalettesPlugin/i,
46
+ );
47
+ });
48
+ it("should throw a helpful error for getToastStore", () => {
49
+ expect(() => getToastStore()).toThrow(
50
+ /getToastStore\(\) found no ToastStore.*PalettesPlugin/i,
51
+ );
52
+ });
53
+ it("should throw a helpful error for getCurrencyStore", () => {
54
+ expect(() => getCurrencyStore()).toThrow(
55
+ /getCurrencyStore\(\) found no CurrencyStore.*MoneyPlugin/i,
56
+ );
57
+ });
58
+ it("should throw a helpful error for getExchangeRateStore", () => {
59
+ expect(() => getExchangeRateStore()).toThrow(
60
+ /getExchangeRateStore\(\) found no ExchangeRateStore.*MoneyPlugin/i,
61
+ );
62
+ });
63
+ it("should throw a helpful error for useMoney", () => {
64
+ expect(() => useMoney()).toThrow(
65
+ /useMoney\(\) found no CurrencyStore.*MoneyPlugin/i,
66
+ );
67
+ });
68
+ });
@@ -85,5 +85,11 @@ export function createAppStore() {
85
85
  return new AppStore();
86
86
  }
87
87
  export function getAppStore() {
88
- return getContext(RUNE_LAB_CONTEXT.app);
88
+ const store = getContext(RUNE_LAB_CONTEXT.app);
89
+ if (!store) {
90
+ throw new Error(
91
+ "[rune-lab] getAppStore() found no AppStore. Did you wrap your application in <RuneProvider>?",
92
+ );
93
+ }
94
+ return store;
89
95
  }
@@ -26,8 +26,10 @@ export interface IToastStore {
26
26
  warn(msg: string): void;
27
27
  info(msg: string): void;
28
28
  dismiss(id: string): void;
29
+ pause(id: string): void;
30
+ resume(id: string): void;
29
31
  }
30
- export interface ICurrencyStore extends ConfigStore<Currency> {
32
+ export interface ICurrencyStore extends ConfigStore<Currency, "code"> {
31
33
  canConvert: boolean;
32
34
  convertAmount(amount: number, fromCode: string, toCode?: string): number;
33
35
  addCurrency(meta: Currency, dineroDef?: unknown): void;
@@ -36,8 +38,8 @@ export interface ICurrencyStore extends ConfigStore<Currency> {
36
38
  * Store context accessors
37
39
  */
38
40
  export declare function getLayoutStore(): LayoutStore;
39
- export declare function getLanguageStore(): ConfigStore<Language>;
40
- export declare function getThemeStore(): ConfigStore<Theme>;
41
+ export declare function getLanguageStore(): ConfigStore<Language, "code">;
42
+ export declare function getThemeStore(): ConfigStore<Theme, "name">;
41
43
  export declare function getShortcutStore(): ShortcutStore;
42
44
  export declare function getCommandStore(): ICommandStore;
43
45
  export declare function getToastStore(): IToastStore;
@@ -4,25 +4,67 @@ import { RUNE_LAB_CONTEXT } from "./context.js";
4
4
  * Store context accessors
5
5
  */
6
6
  export function getLayoutStore() {
7
- return getContext(RUNE_LAB_CONTEXT.layout);
7
+ const store = getContext(RUNE_LAB_CONTEXT.layout);
8
+ if (!store) {
9
+ throw new Error(
10
+ "[rune-lab] getLayoutStore() found no LayoutStore. Did you register LayoutPlugin in <RuneProvider plugins={[…]}>?",
11
+ );
12
+ }
13
+ return store;
8
14
  }
9
15
  export function getLanguageStore() {
10
- return getContext(RUNE_LAB_CONTEXT.language);
16
+ const store = getContext(RUNE_LAB_CONTEXT.language);
17
+ if (!store) {
18
+ throw new Error(
19
+ "[rune-lab] getLanguageStore() found no LanguageStore. Did you register LayoutPlugin in <RuneProvider plugins={[…]}>?",
20
+ );
21
+ }
22
+ return store;
11
23
  }
12
24
  export function getThemeStore() {
13
- return getContext(RUNE_LAB_CONTEXT.theme);
25
+ const store = getContext(RUNE_LAB_CONTEXT.theme);
26
+ if (!store) {
27
+ throw new Error(
28
+ "[rune-lab] getThemeStore() found no ThemeStore. Did you register LayoutPlugin in <RuneProvider plugins={[…]}>?",
29
+ );
30
+ }
31
+ return store;
14
32
  }
15
33
  export function getShortcutStore() {
16
- return getContext(RUNE_LAB_CONTEXT.shortcut);
34
+ const store = getContext(RUNE_LAB_CONTEXT.shortcut);
35
+ if (!store) {
36
+ throw new Error(
37
+ "[rune-lab] getShortcutStore() found no ShortcutStore. Did you register PalettesPlugin in <RuneProvider plugins={[…]}>?",
38
+ );
39
+ }
40
+ return store;
17
41
  }
18
42
  export function getCommandStore() {
19
- return getContext(RUNE_LAB_CONTEXT.commands);
43
+ const store = getContext(RUNE_LAB_CONTEXT.commands);
44
+ if (!store) {
45
+ throw new Error(
46
+ "[rune-lab] getCommandStore() found no CommandStore. Did you register PalettesPlugin in <RuneProvider plugins={[…]}>?",
47
+ );
48
+ }
49
+ return store;
20
50
  }
21
51
  export function getToastStore() {
22
- return getContext(RUNE_LAB_CONTEXT.toast);
52
+ const store = getContext(RUNE_LAB_CONTEXT.toast);
53
+ if (!store) {
54
+ throw new Error(
55
+ "[rune-lab] getToastStore() found no ToastStore. Did you register PalettesPlugin in <RuneProvider plugins={[…]}>?",
56
+ );
57
+ }
58
+ return store;
23
59
  }
24
60
  export function getCurrencyStore() {
25
- return getContext(RUNE_LAB_CONTEXT.currency);
61
+ const store = getContext(RUNE_LAB_CONTEXT.currency);
62
+ if (!store) {
63
+ throw new Error(
64
+ "[rune-lab] getCurrencyStore() found no CurrencyStore. Did you register MoneyPlugin in <RuneProvider plugins={[…]}>?",
65
+ );
66
+ }
67
+ return store;
26
68
  }
27
69
  /**
28
70
  * Standard layout shortcuts
@@ -1,16 +1,16 @@
1
1
  import type { PersistenceDriver } from "./types.js";
2
- export type ConfigStore<T = unknown> = {
3
- current: unknown;
2
+ export type ConfigStore<T, K extends keyof T = keyof T> = {
3
+ current: T[K];
4
4
  available: T[];
5
- set: (id: unknown) => void;
6
- get: (id: unknown) => T | undefined;
7
- getProp: <K extends keyof T>(prop: K, id?: unknown) => T[K] | undefined;
8
- addItems: (newItems: T[]) => void;
5
+ set(id: T[K]): void;
6
+ get(id: T[K]): T | undefined;
7
+ getProp<P extends keyof T>(prop: P, id?: T[K]): T[P] | undefined;
8
+ addItems(newItems: T[]): void;
9
9
  /**
10
10
  * Register a callback to fire when the current item changes.
11
11
  * Returns an unsubscribe function.
12
12
  */
13
- onChange: (cb: (newId: unknown, oldId: unknown) => void) => () => void;
13
+ onChange: (cb: (newId: T[K], oldId: T[K]) => void) => () => void;
14
14
  /**
15
15
  * Inject (or replace) the persistence driver at runtime.
16
16
  * Call this inside your plugin factory so the real driver
@@ -20,7 +20,7 @@ export type ConfigStore<T = unknown> = {
20
20
  */
21
21
  setDriver: (driver: PersistenceDriver) => void;
22
22
  };
23
- export interface ConfigStoreOptions<T = unknown> {
23
+ export interface ConfigStoreOptions<T, K extends keyof T = keyof T> {
24
24
  /** Array of available items */
25
25
  items: readonly T[];
26
26
  /** Storage key used by the persistence driver */
@@ -28,7 +28,7 @@ export interface ConfigStoreOptions<T = unknown> {
28
28
  /** Display name for logging (e.g., "Theme", "Language") */
29
29
  displayName: string;
30
30
  /** Key to use as identifier (e.g., "code", "name") */
31
- idKey: keyof T;
31
+ idKey: K;
32
32
  /** Icon for logs */
33
33
  icon?: string;
34
34
  /** Persistence driver */
@@ -37,6 +37,6 @@ export interface ConfigStoreOptions<T = unknown> {
37
37
  /**
38
38
  * Creates a reactive configuration store with persistence
39
39
  */
40
- export declare function createConfigStore<T = unknown>(
41
- options: ConfigStoreOptions<T>,
42
- ): ConfigStore<T>;
40
+ export declare function createConfigStore<T, K extends keyof T>(
41
+ options: ConfigStoreOptions<T, K>,
42
+ ): ConfigStore<T, K>;
@@ -16,7 +16,7 @@ class ConfigStoreImpl {
16
16
  options;
17
17
  this.#driver = driver;
18
18
  this.available = [...items];
19
- this.current = items[0]?.[idKey] ?? "";
19
+ this.current = items[0][idKey];
20
20
  const saved = this.#driver.get(storageKey);
21
21
  // Only load saved if it actually exists in our available items
22
22
  if (saved && this.get(saved)) {
@@ -7,6 +7,19 @@ export declare const createCookieDriver: (options?: {
7
7
  path?: string;
8
8
  maxAge?: number;
9
9
  sameSite?: "Lax" | "Strict" | "None";
10
+ secure?: boolean;
10
11
  }) => PersistenceDriver;
11
12
  /** Default cookie driver singleton (path='/') */
12
13
  export declare const cookieDriver: PersistenceDriver;
14
+ /**
15
+ * Wraps a persistence driver so all keys are prefixed with the given string.
16
+ * Use this to namespace storage keys and avoid cross-app collisions.
17
+ *
18
+ * @param driver - The underlying persistence driver to wrap
19
+ * @param prefix - The prefix to prepend to all keys (e.g., "rl:")
20
+ * @returns A new PersistenceDriver that transparently namespaces keys
21
+ */
22
+ export declare function namespaced(
23
+ driver: PersistenceDriver,
24
+ prefix: string,
25
+ ): PersistenceDriver;
@@ -72,6 +72,9 @@ export const createCookieDriver = (options = {}) => ({
72
72
  if (options.sameSite) {
73
73
  cookie += `; samesite=${options.sameSite}`;
74
74
  }
75
+ if (options.secure) {
76
+ cookie += "; Secure";
77
+ }
75
78
  document.cookie = cookie;
76
79
  },
77
80
  remove: (key) => {
@@ -85,3 +88,18 @@ export const createCookieDriver = (options = {}) => ({
85
88
  export const cookieDriver = createCookieDriver({
86
89
  path: "/",
87
90
  });
91
+ /**
92
+ * Wraps a persistence driver so all keys are prefixed with the given string.
93
+ * Use this to namespace storage keys and avoid cross-app collisions.
94
+ *
95
+ * @param driver - The underlying persistence driver to wrap
96
+ * @param prefix - The prefix to prepend to all keys (e.g., "rl:")
97
+ * @returns A new PersistenceDriver that transparently namespaces keys
98
+ */
99
+ export function namespaced(driver, prefix) {
100
+ return {
101
+ get: (key) => driver.get(`${prefix}${key}`),
102
+ set: (key, value) => driver.set(`${prefix}${key}`, value),
103
+ remove: (key) => driver.remove(`${prefix}${key}`),
104
+ };
105
+ }
@@ -1,3 +1,4 @@
1
+ import { createInMemoryDriver } from "../persistence/drivers.js";
1
2
  /**
2
3
  * The global store registry.
3
4
  * Pre-populated with built-in stores by RuneProvider.
@@ -127,7 +128,10 @@ export function initializeStores(
127
128
  if (entry.conditional && !(entry.conditional in pluginConfig)) {
128
129
  continue;
129
130
  }
130
- const store = entry.factory(pluginConfig, driver, stores);
131
+ const effectiveDriver = entry.noPersistence
132
+ ? createInMemoryDriver()
133
+ : driver;
134
+ const store = entry.factory(pluginConfig, effectiveDriver, stores);
131
135
  if (store === null && entry.optional) {
132
136
  continue; // Optional store chose not to create
133
137
  }
package/dist/mod.js CHANGED
@@ -4,4 +4,4 @@ export * from "./kernel/src/mod.js";
4
4
  export * from "./runes/layout/src/mod.js";
5
5
  export * from "./runes/palettes/src/mod.js";
6
6
  export * from "./runes/plugins/money/src/mod.js";
7
- export const version = () => "0.4.8-alpha.1";
7
+ export const version = () => "0.4.8";
@@ -6,7 +6,7 @@
6
6
 
7
7
  interface Props {
8
8
  /** ConfigStore instance to bind to */
9
- store: ConfigStore<T>;
9
+ store: ConfigStore<T, keyof T>;
10
10
  /** Key to use as identifier (e.g., "name", "code") */
11
11
  idKey: string;
12
12
  /** Optional subset of IDs to show */
@@ -35,7 +35,7 @@
35
35
  }: Props = $props();
36
36
 
37
37
  const userDictionary =
38
- getContext<Record<string, any>>("rl:dictionary") ?? {};
38
+ getContext<Record<string, (...args: unknown[]) => string>>("rl:dictionary") ?? {};
39
39
 
40
40
  /**
41
41
  * Resolve a label for an item through the message chain:
@@ -43,11 +43,11 @@
43
43
  * 2. Paraglide messages
44
44
  * 3. Raw key fallback
45
45
  */
46
- function resolveLabel(item: any): string {
47
- const key = String(item[idKey]);
46
+ function resolveLabel(item: T): string {
47
+ const key = String((item as Record<string, unknown>)[idKey]);
48
48
  if (typeof userDictionary[key] === "function")
49
49
  return userDictionary[key]();
50
- if (typeof (m as any)[key] === "function") return (m as any)[key]();
50
+ if (typeof (m as Record<string, (...args: unknown[]) => string>)[key] === "function") return (m as Record<string, (...args: unknown[]) => string>)[key]();
51
51
  return key;
52
52
  }
53
53
 
@@ -55,8 +55,8 @@
55
55
 
56
56
  let available = $derived(
57
57
  filterKeys.length > 0
58
- ? store.available.filter((item: any) =>
59
- filterKeys.includes(String(item[idKey])),
58
+ ? store.available.filter((item: T) =>
59
+ filterKeys.includes(String((item as Record<string, unknown>)[idKey])),
60
60
  )
61
61
  : store.available,
62
62
  );
@@ -3,7 +3,7 @@ import { type Snippet } from "svelte";
3
3
  declare function $$render<T>(): {
4
4
  props: {
5
5
  /** ConfigStore instance to bind to */
6
- store: ConfigStore<T>;
6
+ store: ConfigStore<T, keyof T>;
7
7
  /** Key to use as identifier (e.g., "name", "code") */
8
8
  idKey: string;
9
9
  /** Optional subset of IDs to show */
@@ -1,5 +1,4 @@
1
1
  import {
2
- type ConfigStore,
3
2
  createConfigStore,
4
3
  getLanguageStore,
5
4
  } from "../../../kernel/src/mod.js";
@@ -49,6 +48,11 @@ export declare const LANGUAGES: readonly [{
49
48
  readonly code: "vi";
50
49
  readonly flag: "🇻🇳";
51
50
  }];
52
- export declare const languageStore: ConfigStore<Language>;
53
- export type LanguageStore = ReturnType<typeof createConfigStore<Language>>;
51
+ export declare const languageStore: import("../../../mod.ts").ConfigStore<
52
+ Language,
53
+ "code"
54
+ >;
55
+ export type LanguageStore = ReturnType<
56
+ typeof createConfigStore<Language, "code">
57
+ >;
54
58
  export { getLanguageStore };
@@ -5,6 +5,9 @@ export type { Theme };
5
5
  * Resolver to get the display name of a theme in the current locale
6
6
  */
7
7
  export declare const getThemeName: (option: Theme) => string;
8
- export declare const themeStore: ConfigStore<Theme>;
9
- export type ThemeStore = ReturnType<typeof createConfigStore<Theme>>;
8
+ export declare const themeStore: import("../../../mod.ts").ConfigStore<
9
+ Theme,
10
+ "name"
11
+ >;
12
+ export type ThemeStore = ReturnType<typeof createConfigStore<Theme, "name">>;
10
13
  export { getThemeStore };
@@ -9,9 +9,9 @@ export interface CommandServices {
9
9
  appStore: unknown;
10
10
  apiStore: unknown;
11
11
  toastStore: IToastStore;
12
- themeStore: ConfigStore<unknown>;
13
- languageStore: ConfigStore<unknown>;
14
- currencyStore: ConfigStore<unknown>;
12
+ themeStore: ConfigStore<Record<string, unknown>, string>;
13
+ languageStore: ConfigStore<Record<string, unknown>, string>;
14
+ currencyStore: ConfigStore<Record<string, unknown>, string>;
15
15
  }
16
16
  export declare class CommandStore {
17
17
  #private;
@@ -1,7 +1,5 @@
1
1
  <script lang="ts">
2
2
  import { getToastStore, portal } from "../../../../kernel/src/mod.js";
3
- import { flip } from "svelte/animate";
4
- import { fade, fly } from "svelte/transition";
5
3
 
6
4
  const toastStore = getToastStore();
7
5
 
@@ -28,7 +26,7 @@
28
26
  "bg-amber-500/10 border-amber-500/20 text-amber-600 dark:text-amber-400",
29
27
  iconColor: "text-amber-500",
30
28
  iconPath:
31
- "M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z", // Proper warning icon
29
+ "M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z",
32
30
  },
33
31
  error: {
34
32
  colors: "bg-red-500/10 border-red-500/20 text-red-600 dark:text-red-400",
@@ -39,6 +37,15 @@
39
37
  };
40
38
  </script>
41
39
 
40
+ <style>
41
+ @media (prefers-reduced-motion: reduce) {
42
+ .toast-item {
43
+ animation: none !important;
44
+ transition: none !important;
45
+ }
46
+ }
47
+ </style>
48
+
42
49
  <div
43
50
  use:portal
44
51
  class="toast toast-end toast-bottom z-[9999] p-4 gap-3 pointer-events-none"
@@ -47,8 +54,10 @@
47
54
  {@const styles = typeDetails[toast.type] || typeDetails.info}
48
55
 
49
56
  <div
50
- class="pointer-events-auto relative flex w-full max-w-sm items-start gap-4 overflow-hidden rounded-xl border p-4 shadow-lg backdrop-blur-xl transition-all duration-300 sm:min-w-[320px] {styles.colors} animate-in fade-in slide-in-from-bottom-4 zoom-in-95 ease-out data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=closed]:slide-out-to-right-full"
57
+ class="toast-item pointer-events-auto relative flex w-full max-w-sm items-start gap-4 overflow-hidden rounded-xl border p-4 shadow-lg backdrop-blur-xl transition-all duration-300 sm:min-w-[320px] {styles.colors} animate-in fade-in slide-in-from-bottom-4 zoom-in-95 ease-out data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=closed]:slide-out-to-right-full"
51
58
  role="alert"
59
+ onmouseenter={() => toastStore.pause(toast.id)}
60
+ onmouseleave={() => toastStore.resume(toast.id)}
52
61
  >
53
62
  <!-- Icon -->
54
63
  <div class="shrink-0 {styles.iconColor} mt-0.5">
@@ -5,15 +5,24 @@ import { getToastStore } from "../../../../kernel/src/mod.js";
5
5
  import type { Toast, ToastType } from "../../../../kernel/src/mod.js";
6
6
  export type { Toast, ToastType };
7
7
  export declare class ToastStore {
8
+ #private;
8
9
  toasts: Toast[];
9
10
  /**
10
- * Add a new toast
11
+ * Add a new toast with FIFO eviction capped at exactly 5
11
12
  */
12
13
  send(message: string, type?: ToastType, duration?: number): void;
13
14
  /**
14
- * Remove a toast by id
15
+ * Remove a toast by id and clean up its active timer
15
16
  */
16
17
  dismiss(id: string): void;
18
+ /**
19
+ * Pause the auto-dismiss timer for a toast (e.g. on hover)
20
+ */
21
+ pause(id: string): void;
22
+ /**
23
+ * Resume the auto-dismiss timer for a toast (e.g. on mouse leave)
24
+ */
25
+ resume(id: string): void;
17
26
  success(msg: string): void;
18
27
  error(msg: string): void;
19
28
  warn(msg: string): void;
@@ -4,25 +4,76 @@
4
4
  import { getToastStore } from "../../../../kernel/src/mod.js";
5
5
  export class ToastStore {
6
6
  toasts = $state([]);
7
+ #meta = new Map();
7
8
  /**
8
- * Add a new toast
9
+ * Add a new toast with FIFO eviction capped at exactly 5
9
10
  */
10
11
  send(message, type = "info", duration = 3000) {
11
- const id = crypto.randomUUID();
12
+ // Evict oldest if we are at cap (FIFO)
13
+ while (this.toasts.length >= 5) {
14
+ const oldest = this.toasts[0];
15
+ this.dismiss(oldest.id);
16
+ }
17
+ // Generate id: use crypto.randomUUID if available, else fallback
18
+ let id;
19
+ if (
20
+ typeof crypto !== "undefined" && typeof crypto.randomUUID === "function"
21
+ ) {
22
+ id = crypto.randomUUID();
23
+ } else {
24
+ id = "toast-" + Math.random().toString(36).substring(2, 9) + "-" +
25
+ Date.now();
26
+ }
12
27
  const toast = { id, message, type, duration };
13
28
  this.toasts.push(toast);
14
29
  if (duration > 0) {
15
- setTimeout(() => {
30
+ const meta = {
31
+ remaining: duration,
32
+ lastStarted: Date.now(),
33
+ };
34
+ this.#meta.set(id, meta);
35
+ meta.timeoutId = setTimeout(() => {
16
36
  this.dismiss(id);
17
37
  }, duration);
18
38
  }
19
39
  }
20
40
  /**
21
- * Remove a toast by id
41
+ * Remove a toast by id and clean up its active timer
22
42
  */
23
43
  dismiss(id) {
44
+ const meta = this.#meta.get(id);
45
+ if (meta) {
46
+ if (meta.timeoutId) {
47
+ clearTimeout(meta.timeoutId);
48
+ }
49
+ this.#meta.delete(id);
50
+ }
24
51
  this.toasts = this.toasts.filter((t) => t.id !== id);
25
52
  }
53
+ /**
54
+ * Pause the auto-dismiss timer for a toast (e.g. on hover)
55
+ */
56
+ pause(id) {
57
+ const meta = this.#meta.get(id);
58
+ if (meta && meta.timeoutId) {
59
+ clearTimeout(meta.timeoutId);
60
+ const elapsed = Date.now() - meta.lastStarted;
61
+ meta.remaining = Math.max(0, meta.remaining - elapsed);
62
+ meta.timeoutId = undefined;
63
+ }
64
+ }
65
+ /**
66
+ * Resume the auto-dismiss timer for a toast (e.g. on mouse leave)
67
+ */
68
+ resume(id) {
69
+ const meta = this.#meta.get(id);
70
+ if (meta && !meta.timeoutId && meta.remaining > 0) {
71
+ meta.lastStarted = Date.now();
72
+ meta.timeoutId = setTimeout(() => {
73
+ this.dismiss(id);
74
+ }, meta.remaining);
75
+ }
76
+ }
26
77
  // Helper methods
27
78
  success(msg) {
28
79
  this.send(msg, "success");
@@ -0,0 +1,83 @@
1
+ import { describe, expect, it, vi } from "vite-plus/test";
2
+ import { ToastStore } from "./store.svelte.js";
3
+ describe("ToastStore", () => {
4
+ it("should initialize with empty toasts", () => {
5
+ const store = new ToastStore();
6
+ expect(store.toasts).toEqual([]);
7
+ });
8
+ it("should cap at exactly 5 toasts and evict the oldest first (FIFO)", () => {
9
+ const store = new ToastStore();
10
+ store.send("Msg 1");
11
+ store.send("Msg 2");
12
+ store.send("Msg 3");
13
+ store.send("Msg 4");
14
+ store.send("Msg 5");
15
+ expect(store.toasts.length).toBe(5);
16
+ expect(store.toasts[0].message).toBe("Msg 1");
17
+ store.send("Msg 6");
18
+ expect(store.toasts.length).toBe(5);
19
+ expect(store.toasts[0].message).toBe("Msg 2");
20
+ expect(store.toasts.map((t) => t.message)).toEqual([
21
+ "Msg 2",
22
+ "Msg 3",
23
+ "Msg 4",
24
+ "Msg 5",
25
+ "Msg 6",
26
+ ]);
27
+ });
28
+ it("should generate valid fallback IDs when crypto is not present", () => {
29
+ const store = new ToastStore();
30
+ const originalCrypto = globalThis.crypto;
31
+ // Temporarily hide crypto
32
+ // @ts-ignore: Mocking globalThis.crypto for test
33
+ delete globalThis.crypto;
34
+ try {
35
+ store.send("No crypto id test");
36
+ expect(store.toasts.length).toBe(1);
37
+ expect(store.toasts[0].id).toContain("toast-");
38
+ } finally {
39
+ // Restore crypto
40
+ globalThis.crypto = originalCrypto;
41
+ }
42
+ });
43
+ it("should dismiss a toast manually and clean up its timer", () => {
44
+ vi.useFakeTimers();
45
+ try {
46
+ const store = new ToastStore();
47
+ const clearTimeoutSpy = vi.spyOn(globalThis, "clearTimeout");
48
+ store.send("Timer test", "info", 5000);
49
+ const toastId = store.toasts[0].id;
50
+ store.dismiss(toastId);
51
+ expect(store.toasts).toEqual([]);
52
+ expect(clearTimeoutSpy).toHaveBeenCalled();
53
+ clearTimeoutSpy.mockRestore();
54
+ } finally {
55
+ vi.useRealTimers();
56
+ }
57
+ });
58
+ it("should pause auto-dismiss timer on pause() and resume on resume()", () => {
59
+ vi.useFakeTimers();
60
+ try {
61
+ const store = new ToastStore();
62
+ store.send("Pause test", "info", 1000);
63
+ const toastId = store.toasts[0].id;
64
+ // Advance time by 400ms
65
+ vi.advanceTimersByTime(400);
66
+ // Hover: pause
67
+ store.pause(toastId);
68
+ // Advance time by another 1000ms. Toast should still be present because it was paused!
69
+ vi.advanceTimersByTime(1000);
70
+ expect(store.toasts.length).toBe(1);
71
+ // Leave: resume. Remaining duration should be 600ms.
72
+ store.resume(toastId);
73
+ // Advance 500ms. Still present.
74
+ vi.advanceTimersByTime(500);
75
+ expect(store.toasts.length).toBe(1);
76
+ // Advance final 100ms. Toast should be dismissed.
77
+ vi.advanceTimersByTime(100);
78
+ expect(store.toasts.length).toBe(0);
79
+ } finally {
80
+ vi.useRealTimers();
81
+ }
82
+ });
83
+ });
@@ -27,7 +27,7 @@ declare function convertAmount(
27
27
  fromCode: string,
28
28
  toCode?: string,
29
29
  ): number;
30
- export declare const currencyStore: ConfigStore<Currency> & {
30
+ export declare const currencyStore: ConfigStore<Currency, "code"> & {
31
31
  addCurrency: typeof addCurrency;
32
32
  convertAmount: typeof convertAmount;
33
33
  readonly canConvert: boolean;
@@ -29,8 +29,7 @@ export declare class ExchangeRateStore {
29
29
  */
30
30
  getRate(fromCode: string, toCode: string): ScaledRate | number | undefined;
31
31
  /**
32
- * Internal conversion logic that handles triangulation through base currency.
33
- * Delegates all math to ConversionStrategies (directConversion, inverseConversion, triangularConversion).
32
+ * Internal conversion logic that reads directly from the derived cross-rate matrix.
34
33
  */
35
34
  convertAmount(amount: number, fromCode: string, toCode: string): number;
36
35
  }
@@ -1,20 +1,59 @@
1
1
  // sdk/state/src/exchange-rate.svelte.ts
2
2
  import { getContext } from "svelte";
3
- import {
4
- convertMoney,
5
- createMoney,
6
- CURRENCY_MAP,
7
- directConversion,
8
- inverseConversion,
9
- resolveRate,
10
- scaledRate,
11
- toMoneySnapshot,
12
- triangularConversion,
13
- } from "./mod.js";
3
+ import { CURRENCY_MAP, resolveRate, scaledRate } from "./mod.js";
14
4
  import { RUNE_LAB_CONTEXT } from "../../../../kernel/src/mod.js";
15
5
  export class ExchangeRateStore {
16
6
  #rates = $state({});
17
7
  #baseCurrency = $state("USD");
8
+ /**
9
+ * Combined reactive cross-rate matrix: from -> to -> floatRate
10
+ */
11
+ #matrix = $derived.by(() => {
12
+ const base = this.#baseCurrency;
13
+ const rates = this.#rates;
14
+ if (!rates || Object.keys(rates).length === 0) {
15
+ return {};
16
+ }
17
+ const currencies = new Set([base, ...Object.keys(rates)]);
18
+ const mat = {};
19
+ for (const from of currencies) {
20
+ mat[from] = {};
21
+ for (const to of currencies) {
22
+ if (from === to) {
23
+ mat[from][to] = 1.0;
24
+ continue;
25
+ }
26
+ // Direct: base -> to
27
+ if (from === base) {
28
+ const rateTo = rates[to];
29
+ if (rateTo !== undefined) {
30
+ mat[from][to] = resolveRate(rateTo);
31
+ }
32
+ } // Inverse: from -> base
33
+ else if (to === base) {
34
+ const rateFrom = rates[from];
35
+ if (rateFrom !== undefined) {
36
+ const r = resolveRate(rateFrom);
37
+ if (r !== 0) {
38
+ mat[from][to] = 1.0 / r;
39
+ }
40
+ }
41
+ } // Triangular: from -> base -> to
42
+ else {
43
+ const rateFrom = rates[from];
44
+ const rateTo = rates[to];
45
+ if (rateFrom !== undefined && rateTo !== undefined) {
46
+ const rFrom = resolveRate(rateFrom);
47
+ if (rFrom !== 0) {
48
+ const rTo = resolveRate(rateTo);
49
+ mat[from][to] = rTo / rFrom;
50
+ }
51
+ }
52
+ }
53
+ }
54
+ }
55
+ return mat;
56
+ });
18
57
  /**
19
58
  * The current exchange rates relative to the base currency.
20
59
  */
@@ -69,56 +108,26 @@ export class ExchangeRateStore {
69
108
  if (!this.hasRates) {
70
109
  return undefined;
71
110
  }
72
- try {
73
- // 1 unit of fromCode (major unit)
74
- const oneUnit = Math.pow(
75
- Array.isArray(fromCurrency.base)
76
- ? fromCurrency.base[0]
77
- : fromCurrency.base,
78
- fromCurrency.exponent,
79
- );
80
- const currentRates = this.#rates;
81
- const currentBase = this.#baseCurrency;
82
- const sourceMoney = createMoney(oneUnit, fromCode);
83
- let targetMoney;
84
- if (fromCode === currentBase) {
85
- // Direct: base → target
86
- targetMoney = convertMoney(sourceMoney, toCode, currentRates);
87
- } else if (toCode === currentBase) {
88
- // Inverse: target → base
89
- const rateToBase = currentRates[fromCode];
90
- if (!rateToBase) {
91
- return undefined;
92
- }
93
- const r = resolveRate(rateToBase);
94
- targetMoney = convertMoney(sourceMoney, toCode, {
95
- [toCode]: scaledRate(1 / r, 6),
96
- });
97
- } else {
98
- // Triangular: source → base → target
99
- const rateToBase = currentRates[fromCode];
100
- const rateToTarget = currentRates[toCode];
101
- if (!rateToBase || !rateToTarget) {
102
- return undefined;
103
- }
104
- const r1 = resolveRate(rateToBase);
105
- const r2 = resolveRate(rateToTarget);
106
- targetMoney = convertMoney(sourceMoney, toCode, {
107
- [toCode]: scaledRate(r2 / r1, 6),
108
- });
109
- }
110
- const snapshot = toMoneySnapshot(targetMoney);
111
- return {
112
- amount: snapshot.amount,
113
- scale: snapshot.scale,
114
- };
115
- } catch (_err) {
111
+ const rate = this.#matrix[fromCode]?.[toCode];
112
+ if (rate === undefined) {
116
113
  return undefined;
117
114
  }
115
+ let scale = 6;
116
+ if (fromCode === this.#baseCurrency) {
117
+ const rateEntry = this.#rates[toCode];
118
+ const rateScale = (rateEntry && typeof rateEntry === "object")
119
+ ? rateEntry.scale
120
+ : 4;
121
+ const exponent = toCurrency.exponent;
122
+ scale = exponent + rateScale;
123
+ } else {
124
+ const exponent = toCurrency.exponent;
125
+ scale = exponent + 6;
126
+ }
127
+ return scaledRate(rate, scale);
118
128
  }
119
129
  /**
120
- * Internal conversion logic that handles triangulation through base currency.
121
- * Delegates all math to ConversionStrategies (directConversion, inverseConversion, triangularConversion).
130
+ * Internal conversion logic that reads directly from the derived cross-rate matrix.
122
131
  */
123
132
  convertAmount(amount, fromCode, toCode) {
124
133
  if (fromCode === toCode) {
@@ -127,33 +136,11 @@ export class ExchangeRateStore {
127
136
  if (!this.hasRates) {
128
137
  return amount;
129
138
  }
130
- // Direct: Base → Target
131
- if (fromCode === this.#baseCurrency) {
132
- const rateToTarget = this.#rates[toCode];
133
- if (!rateToTarget) {
134
- return amount;
135
- }
136
- return directConversion(amount, resolveRate(rateToTarget));
137
- }
138
- // Inverse: Target → Base
139
- if (toCode === this.#baseCurrency) {
140
- const rateFromSource = this.#rates[fromCode];
141
- if (!rateFromSource) {
142
- return amount;
143
- }
144
- return inverseConversion(amount, resolveRate(rateFromSource));
145
- }
146
- // Triangulation: From → Base → To
147
- const rateFromSource = this.#rates[fromCode];
148
- const rateToTarget = this.#rates[toCode];
149
- if (!rateFromSource || !rateToTarget) {
139
+ const rate = this.#matrix[fromCode]?.[toCode];
140
+ if (rate === undefined) {
150
141
  return amount;
151
142
  }
152
- return triangularConversion(
153
- amount,
154
- resolveRate(rateFromSource),
155
- resolveRate(rateToTarget),
156
- );
143
+ return Math.round(amount * rate);
157
144
  }
158
145
  }
159
146
  /**
@@ -166,5 +153,11 @@ export function createExchangeRateStore() {
166
153
  * Consumer to retrieve the ExchangeRateStore from context.
167
154
  */
168
155
  export function getExchangeRateStore() {
169
- return getContext(RUNE_LAB_CONTEXT.exchangeRate);
156
+ const store = getContext(RUNE_LAB_CONTEXT.exchangeRate);
157
+ if (!store) {
158
+ throw new Error(
159
+ "[rune-lab] getExchangeRateStore() found no ExchangeRateStore. Did you register MoneyPlugin in <RuneProvider plugins={[…]}>?",
160
+ );
161
+ }
162
+ return store;
170
163
  }
@@ -26,8 +26,23 @@ import {
26
26
  */
27
27
  export function useMoney() {
28
28
  const currencyStore = getContext(RUNE_LAB_CONTEXT.currency);
29
+ if (!currencyStore) {
30
+ throw new Error(
31
+ "[rune-lab] useMoney() found no CurrencyStore. Did you register MoneyPlugin in <RuneProvider plugins={[…]}>?",
32
+ );
33
+ }
29
34
  const languageStore = getContext(RUNE_LAB_CONTEXT.language);
35
+ if (!languageStore) {
36
+ throw new Error(
37
+ "[rune-lab] useMoney() found no LanguageStore. Did you register LayoutPlugin in <RuneProvider plugins={[…]}>?",
38
+ );
39
+ }
30
40
  const exchangeRateStore = getContext(RUNE_LAB_CONTEXT.exchangeRate);
41
+ if (!exchangeRateStore) {
42
+ throw new Error(
43
+ "[rune-lab] useMoney() found no ExchangeRateStore. Did you register MoneyPlugin in <RuneProvider plugins={[…]}>?",
44
+ );
45
+ }
31
46
  /**
32
47
  * Convert amount to a Dinero object using current currency
33
48
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rune-lab",
3
- "version": "0.4.8-alpha.2",
3
+ "version": "0.4.8",
4
4
  "description": "Modern toolkit for Svelte 5 Runes applications.",
5
5
  "type": "module",
6
6
  "author": "Fernando Bryan Reza Campos",
@@ -33,7 +33,7 @@
33
33
  "devDependencies": {
34
34
  "vite-plus": "npm:vite-plus",
35
35
  "svelte": "npm:svelte",
36
- "@sveltejs/package": "npm:@sveltejs/package@^2.5.7",
36
+ "@sveltejs/package": "npm:@sveltejs/package",
37
37
  "@sveltejs/vite-plugin-svelte": "npm:@sveltejs/vite-plugin-svelte",
38
38
  "jsdom": "npm:jsdom",
39
39
  "@testing-library/jest-dom": "npm:@testing-library/jest-dom"
@@ -45,4 +45,4 @@
45
45
  "ui",
46
46
  "components"
47
47
  ]
48
- }
48
+ }