selva-shared 0.8.3 → 0.8.5

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/README.md CHANGED
@@ -43,3 +43,5 @@ Types are generated from `packages/schemas/ui-schema.json`. After modifying the
43
43
  ```bash
44
44
  cd packages/schemas && pnpm run generate:all
45
45
  ```
46
+
47
+ cd packages/shared && pnpm publish --no-git-checks
@@ -1,5 +1,6 @@
1
1
  <script lang="ts">
2
2
  import type { UISchema, SupportedTypes } from '../types/generated';
3
+ import type { ActionButton } from '../types/actionButton';
3
4
  import { ChevronUp } from '@lucide/svelte';
4
5
  import Viewer from './Viewer.svelte';
5
6
  import CalculateButton from './ui/CalculateButton.svelte';
@@ -25,6 +26,9 @@
25
26
  values: Record<string, unknown>;
26
27
  onValueChange: (id: string, val: SupportedTypes) => void | Promise<void>;
27
28
  onLoadValues?: () => void | Promise<void>;
29
+ stateManagerActions?: ActionButton[];
30
+ showSaveButton?: boolean;
31
+ showLoadButton?: boolean;
28
32
  }
29
33
 
30
34
  let {
@@ -38,7 +42,10 @@
38
42
  oncalculate = () => {},
39
43
  values = $bindable({}),
40
44
  onValueChange,
41
- onLoadValues
45
+ onLoadValues,
46
+ stateManagerActions = [],
47
+ showSaveButton = true,
48
+ showLoadButton = true
42
49
  }: Props = $props();
43
50
 
44
51
  // ── Layout flags ─────────────────────────────────────────────────────────────
@@ -121,7 +128,7 @@
121
128
  {#if showStateManager || (!isMobile && showCalculateButton && schema.instanceSolve === false)}
122
129
  <div class="panel-footer px-3">
123
130
  {#if showStateManager}
124
- <StateManager {schema} currentValues={values} onLoadValues={handleLoadValues} />
131
+ <StateManager {schema} currentValues={values} onLoadValues={handleLoadValues} actions={stateManagerActions} {showSaveButton} {showLoadButton} />
125
132
  {/if}
126
133
  {#if !isMobile && showCalculateButton && schema.instanceSolve === false}
127
134
  <CalculateButton {hasPendingChanges} {hasNeverSolved} {isSolving} {oncalculate} />
@@ -1,4 +1,5 @@
1
1
  import type { UISchema, SupportedTypes } from '../types/generated';
2
+ import type { ActionButton } from '../types/actionButton';
2
3
  interface Props {
3
4
  schema: UISchema;
4
5
  meshes?: any[];
@@ -11,6 +12,9 @@ interface Props {
11
12
  values: Record<string, unknown>;
12
13
  onValueChange: (id: string, val: SupportedTypes) => void | Promise<void>;
13
14
  onLoadValues?: () => void | Promise<void>;
15
+ stateManagerActions?: ActionButton[];
16
+ showSaveButton?: boolean;
17
+ showLoadButton?: boolean;
14
18
  }
15
19
  declare const AppLayout: import("svelte").Component<Props, {}, "values" | "isViewerFullscreen">;
16
20
  type AppLayout = ReturnType<typeof AppLayout>;
@@ -0,0 +1,256 @@
1
+ <script lang="ts">
2
+ import { untrack } from 'svelte';
3
+ import { page } from '$app/state';
4
+ import type { UISchema } from '../types/generated';
5
+ import type { ActionButton } from '../types/actionButton';
6
+ import type { SolveFn } from '../types/solveFn';
7
+ import { getDefaultValue } from '../utils/utils-shared';
8
+ import { createComputeThrottle } from '../utils/computeThrottle.svelte';
9
+ import { createSolvingIndicator } from '../utils/solving.svelte';
10
+ import { useFooterItem } from '../composables/useFooterItem.svelte';
11
+ import { hexToOklch } from '../utils/color';
12
+ import PageContainer from './layout/PageContainer.svelte';
13
+ import PageHeader from './layout/PageHeader.svelte';
14
+ import AppLayout from './AppLayout.svelte';
15
+ import StateDisplay from './ui/StateDisplay.svelte';
16
+
17
+ // ── Props ────────────────────────────────────────────────────────────────────
18
+ import type { Snippet } from 'svelte';
19
+
20
+ interface Props {
21
+ schema: UISchema;
22
+ onSolve: SolveFn;
23
+ definitionKey?: string;
24
+ title?: string;
25
+ isEmbedded?: boolean;
26
+ primaryColor?: string;
27
+ showModeToggle?: boolean;
28
+ stateManagerActions?: ActionButton[];
29
+ showSaveButton?: boolean;
30
+ showLoadButton?: boolean;
31
+ footerComponent?: any;
32
+ footerComponentProps?: () => Record<string, unknown>;
33
+ footerItemId?: string;
34
+ footerItemPriority?: number;
35
+ // Callback to expose the loadValues function to the parent
36
+ // Usage: bind:loadValues={myLoadFn} or onReady={({ loadValues }) => ...}
37
+ onReady?: (api: { loadValues: (values: Record<string, unknown>) => void }) => void;
38
+ // Snippets for custom layout
39
+ header?: Snippet;
40
+ children?: Snippet<[{ errors: string[]; warnings: string[] }]>;
41
+ }
42
+
43
+ let {
44
+ schema,
45
+ onSolve,
46
+ definitionKey = '',
47
+ title,
48
+ isEmbedded,
49
+ primaryColor,
50
+ showModeToggle = false,
51
+ stateManagerActions = [],
52
+ showSaveButton = true,
53
+ showLoadButton = true,
54
+ footerComponent,
55
+ footerComponentProps,
56
+ footerItemId = 'footer-item',
57
+ footerItemPriority = 0,
58
+ header,
59
+ children,
60
+ onReady
61
+ }: Props = $props();
62
+
63
+ // ── Helpers ──────────────────────────────────────────────────────────────────
64
+ function createInitialValues(s: UISchema) {
65
+ const v: Record<string, unknown> = {};
66
+ for (const input of s.inputs) {
67
+ v[input.id] = input.default ?? getDefaultValue(input.paramType);
68
+ }
69
+ for (const output of s.outputs) {
70
+ v[output.id] = null;
71
+ }
72
+ return v;
73
+ }
74
+
75
+ // ── Core state ───────────────────────────────────────────────────────────────
76
+ // svelte-ignore state_referenced_locally
77
+ let values = $state<Record<string, unknown>>(createInitialValues(schema));
78
+ let error = $state('');
79
+ let computeErrors = $state<string[]>([]);
80
+ let computeWarnings = $state<string[]>([]);
81
+ let meshes = $state<any[]>([]);
82
+ let pendingValues = $state<Record<string, unknown>>({});
83
+ // svelte-ignore state_referenced_locally
84
+ let hasPendingChanges = $state(schema?.instanceSolve === false);
85
+ // svelte-ignore state_referenced_locally
86
+ let hasNeverSolved = $state(schema?.instanceSolve === false);
87
+ let isViewerFullscreen = $state(false);
88
+
89
+ // ── Solve logic ──────────────────────────────────────────────────────────────
90
+ async function performSolveInternal(solveValues: Record<string, unknown>, signal: AbortSignal) {
91
+ try {
92
+ error = '';
93
+ computeErrors = [];
94
+ computeWarnings = [];
95
+
96
+ const result = await onSolve(solveValues, signal);
97
+
98
+ if (signal.aborted) return;
99
+
100
+ computeErrors = result.errors ?? [];
101
+ computeWarnings = result.warnings ?? [];
102
+ meshes = result.meshes ?? [];
103
+
104
+ Object.assign(values, result.outputs);
105
+ pendingValues = {};
106
+ hasPendingChanges = false;
107
+ hasNeverSolved = false;
108
+ } catch (err) {
109
+ if (err instanceof Error && err.name === 'AbortError') return;
110
+ error = err instanceof Error ? err.message : String(err);
111
+ }
112
+ }
113
+
114
+ const computeThrottle = createComputeThrottle<Record<string, unknown>>(performSolveInternal, {
115
+ timeout: 60000
116
+ });
117
+
118
+ let solving = $derived(computeThrottle.isComputing);
119
+ const solvingIndicator = createSolvingIndicator(() => solving);
120
+
121
+ function performSolve() {
122
+ computeThrottle.trigger($state.snapshot(values));
123
+ }
124
+
125
+ function loadValues(incoming: Record<string, unknown>) {
126
+ Object.assign(values, incoming);
127
+ if (schema?.instanceSolve !== false) {
128
+ performSolve();
129
+ } else {
130
+ hasPendingChanges = true;
131
+ }
132
+ }
133
+
134
+ $effect(() => {
135
+ onReady?.({ loadValues });
136
+ });
137
+
138
+ // ── Definition switching ─────────────────────────────────────────────────────
139
+ let previousDefinitionKey = $state('');
140
+ let isInitialLoad = $state(true);
141
+
142
+ $effect(() => {
143
+ const _ = definitionKey;
144
+
145
+ untrack(() => {
146
+ if (isInitialLoad) {
147
+ isInitialLoad = false;
148
+ previousDefinitionKey = definitionKey;
149
+ if (schema?.instanceSolve !== false) {
150
+ performSolve();
151
+ }
152
+ } else if (previousDefinitionKey !== definitionKey) {
153
+ meshes = [];
154
+ values = createInitialValues(schema);
155
+ error = '';
156
+ computeErrors = [];
157
+ computeWarnings = [];
158
+ if (schema && Object.keys(values).length > 0) {
159
+ performSolve();
160
+ }
161
+ previousDefinitionKey = definitionKey;
162
+ }
163
+ });
164
+ });
165
+
166
+ // ── Handlers ─────────────────────────────────────────────────────────────────
167
+ async function handleValueChange(id: string, val: unknown) {
168
+ values[id] = val;
169
+
170
+ if (schema?.instanceSolve === false) {
171
+ pendingValues[id] = val;
172
+ hasPendingChanges = true;
173
+ return;
174
+ }
175
+
176
+ performSolve();
177
+ }
178
+
179
+ function handleCalculate() {
180
+ performSolve();
181
+ }
182
+
183
+ // ── Footer item ──────────────────────────────────────────────────────────────
184
+ // Use untrack to read these static props without creating reactive dependencies.
185
+ // footerComponentProps is intentionally NOT untracked — it's a getter called every render.
186
+ const _footerItemId = untrack(() => footerItemId);
187
+ const _footerComponent = untrack(() => footerComponent);
188
+ const _footerItemPriority = untrack(() => footerItemPriority);
189
+ useFooterItem(
190
+ _footerItemId,
191
+ _footerComponent,
192
+ () => (_footerComponent ? (footerComponentProps?.() ?? {}) : {}),
193
+ 'left',
194
+ _footerItemPriority
195
+ );
196
+
197
+ // ── Embed + custom style ─────────────────────────────────────────────────────
198
+ let resolvedIsEmbedded = $derived(isEmbedded ?? page.url.searchParams.get('embed') === 'true');
199
+ let resolvedPrimaryColor = $derived(primaryColor ?? page.url.searchParams.get('primary'));
200
+ let customStyle = $derived(
201
+ resolvedPrimaryColor ? `--primary: ${hexToOklch(resolvedPrimaryColor)}` : ''
202
+ );
203
+
204
+ let pageTitle = $derived(title ?? (schema?.description || schema.name));
205
+ </script>
206
+
207
+ <div style={customStyle} style:display="contents">
208
+ {#if children}
209
+ {@render children({ errors: computeErrors, warnings: computeWarnings })}
210
+ {:else}
211
+ <PageContainer errors={computeErrors} warnings={computeWarnings}>
212
+ {#if header}
213
+ {@render header()}
214
+ {:else if !resolvedIsEmbedded}
215
+ <PageHeader title={pageTitle} {showModeToggle} />
216
+ {/if}
217
+
218
+ <div class="bg-background flex flex-1 flex-col overflow-hidden">
219
+ {#if error}
220
+ <div class="flex min-h-100 items-center justify-center p-8">
221
+ <StateDisplay type="error" size="medium" message={error} />
222
+ </div>
223
+ {:else if !schema}
224
+ <div class="flex min-h-100 items-center justify-center">
225
+ <StateDisplay type="loading" size="large" message="Loading schema..." />
226
+ </div>
227
+ {:else}
228
+ {#key definitionKey}
229
+ <AppLayout
230
+ {schema}
231
+ {meshes}
232
+ isSolving={solving}
233
+ showSolvingIndicator={schema.instanceSolve !== false && solvingIndicator.show}
234
+ {hasPendingChanges}
235
+ {hasNeverSolved}
236
+ bind:isViewerFullscreen
237
+ bind:values
238
+ {stateManagerActions}
239
+ {showSaveButton}
240
+ {showLoadButton}
241
+ onValueChange={handleValueChange}
242
+ oncalculate={handleCalculate}
243
+ onLoadValues={() => {
244
+ if (schema?.instanceSolve !== false) {
245
+ performSolve();
246
+ } else {
247
+ hasPendingChanges = true;
248
+ }
249
+ }}
250
+ />
251
+ {/key}
252
+ {/if}
253
+ </div>
254
+ </PageContainer>
255
+ {/if}
256
+ </div>
@@ -0,0 +1,31 @@
1
+ import type { UISchema } from '../types/generated';
2
+ import type { ActionButton } from '../types/actionButton';
3
+ import type { SolveFn } from '../types/solveFn';
4
+ import type { Snippet } from 'svelte';
5
+ interface Props {
6
+ schema: UISchema;
7
+ onSolve: SolveFn;
8
+ definitionKey?: string;
9
+ title?: string;
10
+ isEmbedded?: boolean;
11
+ primaryColor?: string;
12
+ showModeToggle?: boolean;
13
+ stateManagerActions?: ActionButton[];
14
+ showSaveButton?: boolean;
15
+ showLoadButton?: boolean;
16
+ footerComponent?: any;
17
+ footerComponentProps?: () => Record<string, unknown>;
18
+ footerItemId?: string;
19
+ footerItemPriority?: number;
20
+ onReady?: (api: {
21
+ loadValues: (values: Record<string, unknown>) => void;
22
+ }) => void;
23
+ header?: Snippet;
24
+ children?: Snippet<[{
25
+ errors: string[];
26
+ warnings: string[];
27
+ }]>;
28
+ }
29
+ declare const ComputeApp: import("svelte").Component<Props, {}, "">;
30
+ type ComputeApp = ReturnType<typeof ComputeApp>;
31
+ export default ComputeApp;
@@ -10,13 +10,25 @@
10
10
  } from '../utils/param-exporter';
11
11
  import { Button, Input, Label, Textarea, Dialog, Card } from '../components/ui';
12
12
 
13
+ import type { ActionButton } from '../types/actionButton';
14
+
13
15
  interface Props {
14
16
  schema: UISchema;
15
17
  currentValues: Record<string, unknown>;
16
18
  onLoadValues: (values: Record<string, unknown>) => void;
19
+ showSaveButton?: boolean;
20
+ showLoadButton?: boolean;
21
+ actions?: ActionButton[];
17
22
  }
18
23
 
19
- let { schema, currentValues, onLoadValues }: Props = $props();
24
+ let {
25
+ schema,
26
+ currentValues,
27
+ onLoadValues,
28
+ showSaveButton = true,
29
+ showLoadButton = true,
30
+ actions = []
31
+ }: Props = $props();
20
32
 
21
33
  // Save dialog state
22
34
  let showExportDialog = $state(false);
@@ -127,15 +139,33 @@
127
139
  </script>
128
140
 
129
141
  <div class="gap-2 mb-2 flex items-center justify-center">
130
- <Button variant="default" size="sm" onclick={openExportDialog}>
131
- <Download class="mr-2 h-4 w-4" />
132
- Save State
133
- </Button>
134
-
135
- <Button variant="outline" size="sm" onclick={openLoadDialog}>
136
- <Upload class="mr-2 h-4 w-4" />
137
- Load State
138
- </Button>
142
+ {#if showSaveButton}
143
+ <Button variant="default" size="sm" onclick={openExportDialog}>
144
+ <Download class="mr-2 h-4 w-4" />
145
+ Save State
146
+ </Button>
147
+ {/if}
148
+
149
+ {#if showLoadButton}
150
+ <Button variant="outline" size="sm" onclick={openLoadDialog}>
151
+ <Upload class="mr-2 h-4 w-4" />
152
+ Load State
153
+ </Button>
154
+ {/if}
155
+
156
+ {#each actions as action (action.id)}
157
+ <Button
158
+ variant={action.variant ?? 'outline'}
159
+ size={action.size ?? 'sm'}
160
+ onclick={action.onclick}
161
+ >
162
+ {#if action.icon}
163
+ {@const IconComponent = action.icon}
164
+ <IconComponent class="mr-2 h-4 w-4" />
165
+ {/if}
166
+ {action.label}
167
+ </Button>
168
+ {/each}
139
169
 
140
170
  <input
141
171
  bind:this={fileInputRef}
@@ -1,8 +1,12 @@
1
1
  import type { UISchema } from '../types/generated';
2
+ import type { ActionButton } from '../types/actionButton';
2
3
  interface Props {
3
4
  schema: UISchema;
4
5
  currentValues: Record<string, unknown>;
5
6
  onLoadValues: (values: Record<string, unknown>) => void;
7
+ showSaveButton?: boolean;
8
+ showLoadButton?: boolean;
9
+ actions?: ActionButton[];
6
10
  }
7
11
  declare const StateManager: import("svelte").Component<Props, {}, "">;
8
12
  type StateManager = ReturnType<typeof StateManager>;
@@ -1,5 +1,5 @@
1
- import { onMount, onDestroy } from 'svelte';
2
- import { useFooter } from '../contexts/footerContext.svelte';
1
+ import { getContext, onMount, onDestroy } from 'svelte';
2
+ import { FOOTER_CONTEXT_KEY } from '../contexts/footerContext.svelte';
3
3
  /**
4
4
  * Register a footer item component that reactively updates its props.
5
5
  * The `getProps` function is called on every render, so returning reactive state
@@ -16,13 +16,15 @@ import { useFooter } from '../contexts/footerContext.svelte';
16
16
  */
17
17
  export function useFooterItem(id, component, getProps, position = 'left', priority = 0, onClick) {
18
18
  onMount(() => {
19
- const footer = useFooter();
20
- footer.register(id, component, getProps, position, priority, onClick);
19
+ const store = getContext(FOOTER_CONTEXT_KEY);
20
+ if (!store || !component)
21
+ return;
22
+ store.register(id, component, getProps, position, priority, onClick);
21
23
  });
22
24
  onDestroy(() => {
23
25
  try {
24
- const footer = useFooter();
25
- footer.unregister(id);
26
+ const store = getContext(FOOTER_CONTEXT_KEY);
27
+ store?.unregister(id);
26
28
  }
27
29
  catch {
28
30
  // Context may not exist during SSR cleanup — safe to ignore
@@ -13,5 +13,6 @@ export interface FooterStore {
13
13
  register(id: string, component: any, getProps: () => Record<string, any>, position?: 'left' | 'right', priority?: number, onClick?: () => void): void;
14
14
  unregister(id: string): void;
15
15
  }
16
+ export declare const FOOTER_CONTEXT_KEY: unique symbol;
16
17
  export declare function initializeFooterContext(): FooterStore;
17
18
  export declare function useFooter(): FooterStore;
@@ -1,6 +1,6 @@
1
1
  import { getContext, setContext } from 'svelte';
2
2
  import { SvelteMap } from 'svelte/reactivity';
3
- const FOOTER_CONTEXT_KEY = Symbol('footer-context');
3
+ export const FOOTER_CONTEXT_KEY = Symbol('footer-context');
4
4
  export function initializeFooterContext() {
5
5
  const items = new SvelteMap();
6
6
  const store = {
package/dist/index.d.ts CHANGED
@@ -2,6 +2,7 @@ export { default as PageContainer } from './components/layout/PageContainer.svel
2
2
  export { default as PageHeader } from './components/layout/PageHeader.svelte';
3
3
  export { default as PageFooter } from './components/layout/PageFooter.svelte';
4
4
  export { default as AppLayout } from './components/AppLayout.svelte';
5
+ export { default as ComputeApp } from './components/ComputeApp.svelte';
5
6
  export { default as ErrorScreen } from './components/ErrorScreen.svelte';
6
7
  export { default as TabLayout } from './components/preview/TabLayout.svelte';
7
8
  export { default as InputControl } from './components/preview/InputControl.svelte';
@@ -14,6 +15,7 @@ export { default as SolvingIndicator } from './components/ui/SolvingIndicator.sv
14
15
  export { default as ComputeMessages } from './components/ComputeMessages.svelte';
15
16
  export * from './features/preview/handlers';
16
17
  export * from './features/preview/notifications';
18
+ export * from './utils/color';
17
19
  export * from './utils/debounce';
18
20
  export * from './utils/utils-shared';
19
21
  export * from './utils/file-download';
@@ -25,5 +27,7 @@ export * from './composables/useFooterItem.svelte';
25
27
  export { themeStore } from './stores/themeStore.svelte';
26
28
  export * from './utils';
27
29
  export type * from './types/generated';
30
+ export type { ActionButton } from './types/actionButton';
31
+ export type { SolveFn, SolveResult } from './types/solveFn';
28
32
  export { ACCEPTED_FILE_FORMATS } from './types/generated/schema';
29
33
  export * from './themes';
package/dist/index.js CHANGED
@@ -3,6 +3,7 @@ export { default as PageContainer } from './components/layout/PageContainer.svel
3
3
  export { default as PageHeader } from './components/layout/PageHeader.svelte';
4
4
  export { default as PageFooter } from './components/layout/PageFooter.svelte';
5
5
  export { default as AppLayout } from './components/AppLayout.svelte';
6
+ export { default as ComputeApp } from './components/ComputeApp.svelte';
6
7
  // Error components
7
8
  export { default as ErrorScreen } from './components/ErrorScreen.svelte';
8
9
  // Preview components
@@ -20,6 +21,7 @@ export { default as ComputeMessages } from './components/ComputeMessages.svelte'
20
21
  export * from './features/preview/handlers';
21
22
  export * from './features/preview/notifications';
22
23
  // Utilities
24
+ export * from './utils/color';
23
25
  export * from './utils/debounce';
24
26
  export * from './utils/utils-shared';
25
27
  export * from './utils/file-download';
@@ -0,0 +1,8 @@
1
+ export interface ActionButton {
2
+ id: string;
3
+ label: string;
4
+ icon?: any;
5
+ variant?: 'default' | 'outline' | 'destructive' | 'secondary' | 'ghost';
6
+ size?: 'default' | 'sm' | 'lg';
7
+ onclick: () => void | Promise<void>;
8
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,7 @@
1
+ export interface SolveResult {
2
+ outputs: Record<string, unknown>;
3
+ meshes?: any[];
4
+ errors?: string[];
5
+ warnings?: string[];
6
+ }
7
+ export type SolveFn = (values: Record<string, unknown>, signal: AbortSignal) => Promise<SolveResult>;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export declare const hexToOklch: (hex: string | null) => string;
@@ -0,0 +1,38 @@
1
+ // Helper to convert hex to OKLCH
2
+ export const hexToOklch = (hex) => {
3
+ if (!hex)
4
+ return '';
5
+ // Normalize hex (remove # if present)
6
+ const normalized = hex.replace(/^#/, '');
7
+ // If it's not a valid hex color, return as-is
8
+ if (!/^[0-9A-Fa-f]{6}$/.test(normalized)) {
9
+ return hex;
10
+ }
11
+ // Parse hex to RGB
12
+ const r = parseInt(normalized.slice(0, 2), 16) / 255;
13
+ const g = parseInt(normalized.slice(2, 4), 16) / 255;
14
+ const b = parseInt(normalized.slice(4, 6), 16) / 255;
15
+ // Convert RGB to linear RGB
16
+ const toLinear = (c) => (c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4));
17
+ const lr = toLinear(r);
18
+ const lg = toLinear(g);
19
+ const lb = toLinear(b);
20
+ // Convert linear RGB to XYZ
21
+ const x = lr * 0.4124564 + lg * 0.3575761 + lb * 0.1804375;
22
+ const y = lr * 0.2126729 + lg * 0.7151522 + lb * 0.072175;
23
+ const z = lr * 0.0193339 + lg * 0.119192 + lb * 0.9503041;
24
+ // Convert XYZ to OKLab
25
+ const l_ = Math.cbrt(0.8189330101 * x + 0.3618667424 * y - 0.1288597137 * z);
26
+ const m_ = Math.cbrt(0.0329845436 * x + 0.9293118715 * y + 0.0361456387 * z);
27
+ const s_ = Math.cbrt(0.0482003018 * x + 0.2643662691 * y + 0.633851707 * z);
28
+ const L = 0.2104542553 * l_ + 0.793617785 * m_ - 0.0040720468 * s_;
29
+ const a = 1.9779984951 * l_ - 2.428592205 * m_ + 0.4505937099 * s_;
30
+ const b_lab = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.808675766 * s_;
31
+ // Convert to LCH
32
+ const C = Math.sqrt(a * a + b_lab * b_lab);
33
+ let H = (Math.atan2(b_lab, a) * 180) / Math.PI;
34
+ if (H < 0)
35
+ H += 360;
36
+ // Format as OKLCH (round to reasonable precision)
37
+ return `oklch(${L.toFixed(3)} ${C.toFixed(3)} ${H.toFixed(1)})`;
38
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "selva-shared",
3
- "version": "0.8.3",
3
+ "version": "0.8.5",
4
4
  "description": "Shared UI components and utilities for Selva applications",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -1,5 +1,6 @@
1
1
  <script lang="ts">
2
2
  import type { UISchema, SupportedTypes } from '../types/generated';
3
+ import type { ActionButton } from '../types/actionButton';
3
4
  import { ChevronUp } from '@lucide/svelte';
4
5
  import Viewer from './Viewer.svelte';
5
6
  import CalculateButton from './ui/CalculateButton.svelte';
@@ -25,6 +26,9 @@
25
26
  values: Record<string, unknown>;
26
27
  onValueChange: (id: string, val: SupportedTypes) => void | Promise<void>;
27
28
  onLoadValues?: () => void | Promise<void>;
29
+ stateManagerActions?: ActionButton[];
30
+ showSaveButton?: boolean;
31
+ showLoadButton?: boolean;
28
32
  }
29
33
 
30
34
  let {
@@ -38,7 +42,10 @@
38
42
  oncalculate = () => {},
39
43
  values = $bindable({}),
40
44
  onValueChange,
41
- onLoadValues
45
+ onLoadValues,
46
+ stateManagerActions = [],
47
+ showSaveButton = true,
48
+ showLoadButton = true
42
49
  }: Props = $props();
43
50
 
44
51
  // ── Layout flags ─────────────────────────────────────────────────────────────
@@ -121,7 +128,7 @@
121
128
  {#if showStateManager || (!isMobile && showCalculateButton && schema.instanceSolve === false)}
122
129
  <div class="panel-footer px-3">
123
130
  {#if showStateManager}
124
- <StateManager {schema} currentValues={values} onLoadValues={handleLoadValues} />
131
+ <StateManager {schema} currentValues={values} onLoadValues={handleLoadValues} actions={stateManagerActions} {showSaveButton} {showLoadButton} />
125
132
  {/if}
126
133
  {#if !isMobile && showCalculateButton && schema.instanceSolve === false}
127
134
  <CalculateButton {hasPendingChanges} {hasNeverSolved} {isSolving} {oncalculate} />
@@ -0,0 +1,256 @@
1
+ <script lang="ts">
2
+ import { untrack } from 'svelte';
3
+ import { page } from '$app/state';
4
+ import type { UISchema } from '../types/generated';
5
+ import type { ActionButton } from '../types/actionButton';
6
+ import type { SolveFn } from '../types/solveFn';
7
+ import { getDefaultValue } from '../utils/utils-shared';
8
+ import { createComputeThrottle } from '../utils/computeThrottle.svelte';
9
+ import { createSolvingIndicator } from '../utils/solving.svelte';
10
+ import { useFooterItem } from '../composables/useFooterItem.svelte';
11
+ import { hexToOklch } from '../utils/color';
12
+ import PageContainer from './layout/PageContainer.svelte';
13
+ import PageHeader from './layout/PageHeader.svelte';
14
+ import AppLayout from './AppLayout.svelte';
15
+ import StateDisplay from './ui/StateDisplay.svelte';
16
+
17
+ // ── Props ────────────────────────────────────────────────────────────────────
18
+ import type { Snippet } from 'svelte';
19
+
20
+ interface Props {
21
+ schema: UISchema;
22
+ onSolve: SolveFn;
23
+ definitionKey?: string;
24
+ title?: string;
25
+ isEmbedded?: boolean;
26
+ primaryColor?: string;
27
+ showModeToggle?: boolean;
28
+ stateManagerActions?: ActionButton[];
29
+ showSaveButton?: boolean;
30
+ showLoadButton?: boolean;
31
+ footerComponent?: any;
32
+ footerComponentProps?: () => Record<string, unknown>;
33
+ footerItemId?: string;
34
+ footerItemPriority?: number;
35
+ // Callback to expose the loadValues function to the parent
36
+ // Usage: bind:loadValues={myLoadFn} or onReady={({ loadValues }) => ...}
37
+ onReady?: (api: { loadValues: (values: Record<string, unknown>) => void }) => void;
38
+ // Snippets for custom layout
39
+ header?: Snippet;
40
+ children?: Snippet<[{ errors: string[]; warnings: string[] }]>;
41
+ }
42
+
43
+ let {
44
+ schema,
45
+ onSolve,
46
+ definitionKey = '',
47
+ title,
48
+ isEmbedded,
49
+ primaryColor,
50
+ showModeToggle = false,
51
+ stateManagerActions = [],
52
+ showSaveButton = true,
53
+ showLoadButton = true,
54
+ footerComponent,
55
+ footerComponentProps,
56
+ footerItemId = 'footer-item',
57
+ footerItemPriority = 0,
58
+ header,
59
+ children,
60
+ onReady
61
+ }: Props = $props();
62
+
63
+ // ── Helpers ──────────────────────────────────────────────────────────────────
64
+ function createInitialValues(s: UISchema) {
65
+ const v: Record<string, unknown> = {};
66
+ for (const input of s.inputs) {
67
+ v[input.id] = input.default ?? getDefaultValue(input.paramType);
68
+ }
69
+ for (const output of s.outputs) {
70
+ v[output.id] = null;
71
+ }
72
+ return v;
73
+ }
74
+
75
+ // ── Core state ───────────────────────────────────────────────────────────────
76
+ // svelte-ignore state_referenced_locally
77
+ let values = $state<Record<string, unknown>>(createInitialValues(schema));
78
+ let error = $state('');
79
+ let computeErrors = $state<string[]>([]);
80
+ let computeWarnings = $state<string[]>([]);
81
+ let meshes = $state<any[]>([]);
82
+ let pendingValues = $state<Record<string, unknown>>({});
83
+ // svelte-ignore state_referenced_locally
84
+ let hasPendingChanges = $state(schema?.instanceSolve === false);
85
+ // svelte-ignore state_referenced_locally
86
+ let hasNeverSolved = $state(schema?.instanceSolve === false);
87
+ let isViewerFullscreen = $state(false);
88
+
89
+ // ── Solve logic ──────────────────────────────────────────────────────────────
90
+ async function performSolveInternal(solveValues: Record<string, unknown>, signal: AbortSignal) {
91
+ try {
92
+ error = '';
93
+ computeErrors = [];
94
+ computeWarnings = [];
95
+
96
+ const result = await onSolve(solveValues, signal);
97
+
98
+ if (signal.aborted) return;
99
+
100
+ computeErrors = result.errors ?? [];
101
+ computeWarnings = result.warnings ?? [];
102
+ meshes = result.meshes ?? [];
103
+
104
+ Object.assign(values, result.outputs);
105
+ pendingValues = {};
106
+ hasPendingChanges = false;
107
+ hasNeverSolved = false;
108
+ } catch (err) {
109
+ if (err instanceof Error && err.name === 'AbortError') return;
110
+ error = err instanceof Error ? err.message : String(err);
111
+ }
112
+ }
113
+
114
+ const computeThrottle = createComputeThrottle<Record<string, unknown>>(performSolveInternal, {
115
+ timeout: 60000
116
+ });
117
+
118
+ let solving = $derived(computeThrottle.isComputing);
119
+ const solvingIndicator = createSolvingIndicator(() => solving);
120
+
121
+ function performSolve() {
122
+ computeThrottle.trigger($state.snapshot(values));
123
+ }
124
+
125
+ function loadValues(incoming: Record<string, unknown>) {
126
+ Object.assign(values, incoming);
127
+ if (schema?.instanceSolve !== false) {
128
+ performSolve();
129
+ } else {
130
+ hasPendingChanges = true;
131
+ }
132
+ }
133
+
134
+ $effect(() => {
135
+ onReady?.({ loadValues });
136
+ });
137
+
138
+ // ── Definition switching ─────────────────────────────────────────────────────
139
+ let previousDefinitionKey = $state('');
140
+ let isInitialLoad = $state(true);
141
+
142
+ $effect(() => {
143
+ const _ = definitionKey;
144
+
145
+ untrack(() => {
146
+ if (isInitialLoad) {
147
+ isInitialLoad = false;
148
+ previousDefinitionKey = definitionKey;
149
+ if (schema?.instanceSolve !== false) {
150
+ performSolve();
151
+ }
152
+ } else if (previousDefinitionKey !== definitionKey) {
153
+ meshes = [];
154
+ values = createInitialValues(schema);
155
+ error = '';
156
+ computeErrors = [];
157
+ computeWarnings = [];
158
+ if (schema && Object.keys(values).length > 0) {
159
+ performSolve();
160
+ }
161
+ previousDefinitionKey = definitionKey;
162
+ }
163
+ });
164
+ });
165
+
166
+ // ── Handlers ─────────────────────────────────────────────────────────────────
167
+ async function handleValueChange(id: string, val: unknown) {
168
+ values[id] = val;
169
+
170
+ if (schema?.instanceSolve === false) {
171
+ pendingValues[id] = val;
172
+ hasPendingChanges = true;
173
+ return;
174
+ }
175
+
176
+ performSolve();
177
+ }
178
+
179
+ function handleCalculate() {
180
+ performSolve();
181
+ }
182
+
183
+ // ── Footer item ──────────────────────────────────────────────────────────────
184
+ // Use untrack to read these static props without creating reactive dependencies.
185
+ // footerComponentProps is intentionally NOT untracked — it's a getter called every render.
186
+ const _footerItemId = untrack(() => footerItemId);
187
+ const _footerComponent = untrack(() => footerComponent);
188
+ const _footerItemPriority = untrack(() => footerItemPriority);
189
+ useFooterItem(
190
+ _footerItemId,
191
+ _footerComponent,
192
+ () => (_footerComponent ? (footerComponentProps?.() ?? {}) : {}),
193
+ 'left',
194
+ _footerItemPriority
195
+ );
196
+
197
+ // ── Embed + custom style ─────────────────────────────────────────────────────
198
+ let resolvedIsEmbedded = $derived(isEmbedded ?? page.url.searchParams.get('embed') === 'true');
199
+ let resolvedPrimaryColor = $derived(primaryColor ?? page.url.searchParams.get('primary'));
200
+ let customStyle = $derived(
201
+ resolvedPrimaryColor ? `--primary: ${hexToOklch(resolvedPrimaryColor)}` : ''
202
+ );
203
+
204
+ let pageTitle = $derived(title ?? (schema?.description || schema.name));
205
+ </script>
206
+
207
+ <div style={customStyle} style:display="contents">
208
+ {#if children}
209
+ {@render children({ errors: computeErrors, warnings: computeWarnings })}
210
+ {:else}
211
+ <PageContainer errors={computeErrors} warnings={computeWarnings}>
212
+ {#if header}
213
+ {@render header()}
214
+ {:else if !resolvedIsEmbedded}
215
+ <PageHeader title={pageTitle} {showModeToggle} />
216
+ {/if}
217
+
218
+ <div class="bg-background flex flex-1 flex-col overflow-hidden">
219
+ {#if error}
220
+ <div class="flex min-h-100 items-center justify-center p-8">
221
+ <StateDisplay type="error" size="medium" message={error} />
222
+ </div>
223
+ {:else if !schema}
224
+ <div class="flex min-h-100 items-center justify-center">
225
+ <StateDisplay type="loading" size="large" message="Loading schema..." />
226
+ </div>
227
+ {:else}
228
+ {#key definitionKey}
229
+ <AppLayout
230
+ {schema}
231
+ {meshes}
232
+ isSolving={solving}
233
+ showSolvingIndicator={schema.instanceSolve !== false && solvingIndicator.show}
234
+ {hasPendingChanges}
235
+ {hasNeverSolved}
236
+ bind:isViewerFullscreen
237
+ bind:values
238
+ {stateManagerActions}
239
+ {showSaveButton}
240
+ {showLoadButton}
241
+ onValueChange={handleValueChange}
242
+ oncalculate={handleCalculate}
243
+ onLoadValues={() => {
244
+ if (schema?.instanceSolve !== false) {
245
+ performSolve();
246
+ } else {
247
+ hasPendingChanges = true;
248
+ }
249
+ }}
250
+ />
251
+ {/key}
252
+ {/if}
253
+ </div>
254
+ </PageContainer>
255
+ {/if}
256
+ </div>
@@ -10,13 +10,25 @@
10
10
  } from '../utils/param-exporter';
11
11
  import { Button, Input, Label, Textarea, Dialog, Card } from '../components/ui';
12
12
 
13
+ import type { ActionButton } from '../types/actionButton';
14
+
13
15
  interface Props {
14
16
  schema: UISchema;
15
17
  currentValues: Record<string, unknown>;
16
18
  onLoadValues: (values: Record<string, unknown>) => void;
19
+ showSaveButton?: boolean;
20
+ showLoadButton?: boolean;
21
+ actions?: ActionButton[];
17
22
  }
18
23
 
19
- let { schema, currentValues, onLoadValues }: Props = $props();
24
+ let {
25
+ schema,
26
+ currentValues,
27
+ onLoadValues,
28
+ showSaveButton = true,
29
+ showLoadButton = true,
30
+ actions = []
31
+ }: Props = $props();
20
32
 
21
33
  // Save dialog state
22
34
  let showExportDialog = $state(false);
@@ -127,15 +139,33 @@
127
139
  </script>
128
140
 
129
141
  <div class="gap-2 mb-2 flex items-center justify-center">
130
- <Button variant="default" size="sm" onclick={openExportDialog}>
131
- <Download class="mr-2 h-4 w-4" />
132
- Save State
133
- </Button>
134
-
135
- <Button variant="outline" size="sm" onclick={openLoadDialog}>
136
- <Upload class="mr-2 h-4 w-4" />
137
- Load State
138
- </Button>
142
+ {#if showSaveButton}
143
+ <Button variant="default" size="sm" onclick={openExportDialog}>
144
+ <Download class="mr-2 h-4 w-4" />
145
+ Save State
146
+ </Button>
147
+ {/if}
148
+
149
+ {#if showLoadButton}
150
+ <Button variant="outline" size="sm" onclick={openLoadDialog}>
151
+ <Upload class="mr-2 h-4 w-4" />
152
+ Load State
153
+ </Button>
154
+ {/if}
155
+
156
+ {#each actions as action (action.id)}
157
+ <Button
158
+ variant={action.variant ?? 'outline'}
159
+ size={action.size ?? 'sm'}
160
+ onclick={action.onclick}
161
+ >
162
+ {#if action.icon}
163
+ {@const IconComponent = action.icon}
164
+ <IconComponent class="mr-2 h-4 w-4" />
165
+ {/if}
166
+ {action.label}
167
+ </Button>
168
+ {/each}
139
169
 
140
170
  <input
141
171
  bind:this={fileInputRef}
@@ -1,5 +1,5 @@
1
- import { onMount, onDestroy } from 'svelte';
2
- import { useFooter } from '$lib/contexts/footerContext.svelte';
1
+ import { getContext, onMount, onDestroy } from 'svelte';
2
+ import { type FooterStore, FOOTER_CONTEXT_KEY } from '$lib/contexts/footerContext.svelte';
3
3
 
4
4
  /**
5
5
  * Register a footer item component that reactively updates its props.
@@ -24,14 +24,15 @@ export function useFooterItem(
24
24
  onClick?: () => void
25
25
  ) {
26
26
  onMount(() => {
27
- const footer = useFooter();
28
- footer.register(id, component, getProps, position, priority, onClick);
27
+ const store = getContext<FooterStore | undefined>(FOOTER_CONTEXT_KEY);
28
+ if (!store || !component) return;
29
+ store.register(id, component, getProps, position, priority, onClick);
29
30
  });
30
31
 
31
32
  onDestroy(() => {
32
33
  try {
33
- const footer = useFooter();
34
- footer.unregister(id);
34
+ const store = getContext<FooterStore | undefined>(FOOTER_CONTEXT_KEY);
35
+ store?.unregister(id);
35
36
  } catch {
36
37
  // Context may not exist during SSR cleanup — safe to ignore
37
38
  }
@@ -24,7 +24,7 @@ export interface FooterStore {
24
24
  unregister(id: string): void;
25
25
  }
26
26
 
27
- const FOOTER_CONTEXT_KEY = Symbol('footer-context');
27
+ export const FOOTER_CONTEXT_KEY = Symbol('footer-context');
28
28
 
29
29
  export function initializeFooterContext(): FooterStore {
30
30
  const items = new SvelteMap<string, FooterItem>();
package/src/lib/index.ts CHANGED
@@ -3,6 +3,7 @@ export { default as PageContainer } from './components/layout/PageContainer.svel
3
3
  export { default as PageHeader } from './components/layout/PageHeader.svelte';
4
4
  export { default as PageFooter } from './components/layout/PageFooter.svelte';
5
5
  export { default as AppLayout } from './components/AppLayout.svelte';
6
+ export { default as ComputeApp } from './components/ComputeApp.svelte';
6
7
 
7
8
  // Error components
8
9
  export { default as ErrorScreen } from './components/ErrorScreen.svelte';
@@ -25,6 +26,7 @@ export * from './features/preview/handlers';
25
26
  export * from './features/preview/notifications';
26
27
 
27
28
  // Utilities
29
+ export * from './utils/color';
28
30
  export * from './utils/debounce';
29
31
  export * from './utils/utils-shared';
30
32
  export * from './utils/file-download';
@@ -44,6 +46,8 @@ export * from './utils';
44
46
 
45
47
  // Re-export types from generated schema
46
48
  export type * from './types/generated';
49
+ export type { ActionButton } from './types/actionButton';
50
+ export type { SolveFn, SolveResult } from './types/solveFn';
47
51
 
48
52
  // Re-export constants from generated schema
49
53
  export { ACCEPTED_FILE_FORMATS } from './types/generated/schema';
@@ -0,0 +1,8 @@
1
+ export interface ActionButton {
2
+ id: string;
3
+ label: string;
4
+ icon?: any;
5
+ variant?: 'default' | 'outline' | 'destructive' | 'secondary' | 'ghost';
6
+ size?: 'default' | 'sm' | 'lg';
7
+ onclick: () => void | Promise<void>;
8
+ }
@@ -0,0 +1,11 @@
1
+ export interface SolveResult {
2
+ outputs: Record<string, unknown>;
3
+ meshes?: any[];
4
+ errors?: string[];
5
+ warnings?: string[];
6
+ }
7
+
8
+ export type SolveFn = (
9
+ values: Record<string, unknown>,
10
+ signal: AbortSignal
11
+ ) => Promise<SolveResult>;
@@ -0,0 +1,45 @@
1
+ // Helper to convert hex to OKLCH
2
+ export const hexToOklch = (hex: string | null): string => {
3
+ if (!hex) return '';
4
+
5
+ // Normalize hex (remove # if present)
6
+ const normalized = hex.replace(/^#/, '');
7
+
8
+ // If it's not a valid hex color, return as-is
9
+ if (!/^[0-9A-Fa-f]{6}$/.test(normalized)) {
10
+ return hex;
11
+ }
12
+
13
+ // Parse hex to RGB
14
+ const r = parseInt(normalized.slice(0, 2), 16) / 255;
15
+ const g = parseInt(normalized.slice(2, 4), 16) / 255;
16
+ const b = parseInt(normalized.slice(4, 6), 16) / 255;
17
+
18
+ // Convert RGB to linear RGB
19
+ const toLinear = (c: number) => (c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4));
20
+ const lr = toLinear(r);
21
+ const lg = toLinear(g);
22
+ const lb = toLinear(b);
23
+
24
+ // Convert linear RGB to XYZ
25
+ const x = lr * 0.4124564 + lg * 0.3575761 + lb * 0.1804375;
26
+ const y = lr * 0.2126729 + lg * 0.7151522 + lb * 0.072175;
27
+ const z = lr * 0.0193339 + lg * 0.119192 + lb * 0.9503041;
28
+
29
+ // Convert XYZ to OKLab
30
+ const l_ = Math.cbrt(0.8189330101 * x + 0.3618667424 * y - 0.1288597137 * z);
31
+ const m_ = Math.cbrt(0.0329845436 * x + 0.9293118715 * y + 0.0361456387 * z);
32
+ const s_ = Math.cbrt(0.0482003018 * x + 0.2643662691 * y + 0.633851707 * z);
33
+
34
+ const L = 0.2104542553 * l_ + 0.793617785 * m_ - 0.0040720468 * s_;
35
+ const a = 1.9779984951 * l_ - 2.428592205 * m_ + 0.4505937099 * s_;
36
+ const b_lab = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.808675766 * s_;
37
+
38
+ // Convert to LCH
39
+ const C = Math.sqrt(a * a + b_lab * b_lab);
40
+ let H = (Math.atan2(b_lab, a) * 180) / Math.PI;
41
+ if (H < 0) H += 360;
42
+
43
+ // Format as OKLCH (round to reasonable precision)
44
+ return `oklch(${L.toFixed(3)} ${C.toFixed(3)} ${H.toFixed(1)})`;
45
+ };