selva-shared 0.8.2 → 0.8.4

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,239 @@
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
+ // Snippets for custom layout
36
+ header?: Snippet;
37
+ children?: Snippet<[{ errors: string[]; warnings: string[] }]>;
38
+ }
39
+
40
+ let {
41
+ schema,
42
+ onSolve,
43
+ definitionKey = '',
44
+ title,
45
+ isEmbedded,
46
+ primaryColor,
47
+ showModeToggle = false,
48
+ stateManagerActions = [],
49
+ showSaveButton = true,
50
+ showLoadButton = true,
51
+ footerComponent,
52
+ footerComponentProps,
53
+ footerItemId = 'footer-item',
54
+ footerItemPriority = 0,
55
+ header,
56
+ children
57
+ }: Props = $props();
58
+
59
+ // ── Helpers ──────────────────────────────────────────────────────────────────
60
+ function createInitialValues(s: UISchema) {
61
+ const v: Record<string, unknown> = {};
62
+ for (const input of s.inputs) {
63
+ v[input.id] = input.default ?? getDefaultValue(input.paramType);
64
+ }
65
+ for (const output of s.outputs) {
66
+ v[output.id] = null;
67
+ }
68
+ return v;
69
+ }
70
+
71
+ // ── Core state ───────────────────────────────────────────────────────────────
72
+ // svelte-ignore state_referenced_locally
73
+ let values = $state<Record<string, unknown>>(createInitialValues(schema));
74
+ let error = $state('');
75
+ let computeErrors = $state<string[]>([]);
76
+ let computeWarnings = $state<string[]>([]);
77
+ let meshes = $state<any[]>([]);
78
+ let pendingValues = $state<Record<string, unknown>>({});
79
+ // svelte-ignore state_referenced_locally
80
+ let hasPendingChanges = $state(schema?.instanceSolve === false);
81
+ // svelte-ignore state_referenced_locally
82
+ let hasNeverSolved = $state(schema?.instanceSolve === false);
83
+ let isViewerFullscreen = $state(false);
84
+
85
+ // ── Solve logic ──────────────────────────────────────────────────────────────
86
+ async function performSolveInternal(solveValues: Record<string, unknown>, signal: AbortSignal) {
87
+ try {
88
+ error = '';
89
+ computeErrors = [];
90
+ computeWarnings = [];
91
+
92
+ const result = await onSolve(solveValues, signal);
93
+
94
+ if (signal.aborted) return;
95
+
96
+ computeErrors = result.errors ?? [];
97
+ computeWarnings = result.warnings ?? [];
98
+ meshes = result.meshes ?? [];
99
+
100
+ Object.assign(values, result.outputs);
101
+ pendingValues = {};
102
+ hasPendingChanges = false;
103
+ hasNeverSolved = false;
104
+ } catch (err) {
105
+ if (err instanceof Error && err.name === 'AbortError') return;
106
+ error = err instanceof Error ? err.message : String(err);
107
+ }
108
+ }
109
+
110
+ const computeThrottle = createComputeThrottle<Record<string, unknown>>(performSolveInternal, {
111
+ timeout: 60000
112
+ });
113
+
114
+ let solving = $derived(computeThrottle.isComputing);
115
+ const solvingIndicator = createSolvingIndicator(() => solving);
116
+
117
+ function performSolve() {
118
+ computeThrottle.trigger($state.snapshot(values));
119
+ }
120
+
121
+ // ── Definition switching ─────────────────────────────────────────────────────
122
+ let previousDefinitionKey = $state('');
123
+ let isInitialLoad = $state(true);
124
+
125
+ $effect(() => {
126
+ const _ = definitionKey;
127
+
128
+ untrack(() => {
129
+ if (isInitialLoad) {
130
+ isInitialLoad = false;
131
+ previousDefinitionKey = definitionKey;
132
+ if (schema?.instanceSolve !== false) {
133
+ performSolve();
134
+ }
135
+ } else if (previousDefinitionKey !== definitionKey) {
136
+ meshes = [];
137
+ values = createInitialValues(schema);
138
+ error = '';
139
+ computeErrors = [];
140
+ computeWarnings = [];
141
+ if (schema && Object.keys(values).length > 0) {
142
+ performSolve();
143
+ }
144
+ previousDefinitionKey = definitionKey;
145
+ }
146
+ });
147
+ });
148
+
149
+ // ── Handlers ─────────────────────────────────────────────────────────────────
150
+ async function handleValueChange(id: string, val: unknown) {
151
+ values[id] = val;
152
+
153
+ if (schema?.instanceSolve === false) {
154
+ pendingValues[id] = val;
155
+ hasPendingChanges = true;
156
+ return;
157
+ }
158
+
159
+ performSolve();
160
+ }
161
+
162
+ function handleCalculate() {
163
+ performSolve();
164
+ }
165
+
166
+ // ── Footer item ──────────────────────────────────────────────────────────────
167
+ // Use untrack to read these static props without creating reactive dependencies.
168
+ // footerComponentProps is intentionally NOT untracked — it's a getter called every render.
169
+ const _footerItemId = untrack(() => footerItemId);
170
+ const _footerComponent = untrack(() => footerComponent);
171
+ const _footerItemPriority = untrack(() => footerItemPriority);
172
+ useFooterItem(
173
+ _footerItemId,
174
+ _footerComponent,
175
+ () => (_footerComponent ? (footerComponentProps?.() ?? {}) : {}),
176
+ 'left',
177
+ _footerItemPriority
178
+ );
179
+
180
+ // ── Embed + custom style ─────────────────────────────────────────────────────
181
+ let resolvedIsEmbedded = $derived(isEmbedded ?? page.url.searchParams.get('embed') === 'true');
182
+ let resolvedPrimaryColor = $derived(primaryColor ?? page.url.searchParams.get('primary'));
183
+ let customStyle = $derived(
184
+ resolvedPrimaryColor ? `--primary: ${hexToOklch(resolvedPrimaryColor)}` : ''
185
+ );
186
+
187
+ let pageTitle = $derived(title ?? (schema?.description || schema.name));
188
+ </script>
189
+
190
+ <div style={customStyle} style:display="contents">
191
+ {#if children}
192
+ {@render children({ errors: computeErrors, warnings: computeWarnings })}
193
+ {:else}
194
+ <PageContainer errors={computeErrors} warnings={computeWarnings}>
195
+ {#if header}
196
+ {@render header()}
197
+ {:else if !resolvedIsEmbedded}
198
+ <PageHeader title={pageTitle} {showModeToggle} />
199
+ {/if}
200
+
201
+ <div class="bg-background flex flex-1 flex-col overflow-hidden">
202
+ {#if error}
203
+ <div class="flex min-h-100 items-center justify-center p-8">
204
+ <StateDisplay type="error" size="medium" message={error} />
205
+ </div>
206
+ {:else if !schema}
207
+ <div class="flex min-h-100 items-center justify-center">
208
+ <StateDisplay type="loading" size="large" message="Loading schema..." />
209
+ </div>
210
+ {:else}
211
+ {#key definitionKey}
212
+ <AppLayout
213
+ {schema}
214
+ {meshes}
215
+ isSolving={solving}
216
+ showSolvingIndicator={schema.instanceSolve !== false && solvingIndicator.show}
217
+ {hasPendingChanges}
218
+ {hasNeverSolved}
219
+ bind:isViewerFullscreen
220
+ bind:values
221
+ {stateManagerActions}
222
+ {showSaveButton}
223
+ {showLoadButton}
224
+ onValueChange={handleValueChange}
225
+ oncalculate={handleCalculate}
226
+ onLoadValues={() => {
227
+ if (schema?.instanceSolve !== false) {
228
+ performSolve();
229
+ } else {
230
+ hasPendingChanges = true;
231
+ }
232
+ }}
233
+ />
234
+ {/key}
235
+ {/if}
236
+ </div>
237
+ </PageContainer>
238
+ {/if}
239
+ </div>
@@ -0,0 +1,28 @@
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
+ header?: Snippet;
21
+ children?: Snippet<[{
22
+ errors: string[];
23
+ warnings: string[];
24
+ }]>;
25
+ }
26
+ declare const ComputeApp: import("svelte").Component<Props, {}, "">;
27
+ type ComputeApp = ReturnType<typeof ComputeApp>;
28
+ 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>;
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.2",
3
+ "version": "0.8.4",
4
4
  "description": "Shared UI components and utilities for Selva applications",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -44,19 +44,19 @@
44
44
  "dependencies": {
45
45
  "@floating-ui/dom": "^1.6.13",
46
46
  "@iconify/svelte": "^5.2.1",
47
+ "@lucide/svelte": "^0.577.0",
47
48
  "clsx": "^2.1.1",
48
49
  "mode-watcher": "^1.1.0",
50
+ "paneforge": "^1.0.2",
49
51
  "svelte-sonner": "^1.0.7",
50
52
  "tailwind-merge": "^3.4.0",
51
53
  "tw-animate-css": "^1.4.0"
52
54
  },
53
55
  "devDependencies": {
54
56
  "@internationalized/date": "^3.10.1",
55
- "@lucide/svelte": "^0.577.0",
56
57
  "@sveltejs/kit": "2.53.4",
57
58
  "@types/three": "^0.183.1",
58
59
  "bits-ui": "^2.15.4",
59
- "paneforge": "^1.0.2",
60
60
  "svelte": "5.53.7",
61
61
  "tailwind-variants": "^3.2.2",
62
62
  "@selva/config": "0.0.0"
@@ -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,239 @@
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
+ // Snippets for custom layout
36
+ header?: Snippet;
37
+ children?: Snippet<[{ errors: string[]; warnings: string[] }]>;
38
+ }
39
+
40
+ let {
41
+ schema,
42
+ onSolve,
43
+ definitionKey = '',
44
+ title,
45
+ isEmbedded,
46
+ primaryColor,
47
+ showModeToggle = false,
48
+ stateManagerActions = [],
49
+ showSaveButton = true,
50
+ showLoadButton = true,
51
+ footerComponent,
52
+ footerComponentProps,
53
+ footerItemId = 'footer-item',
54
+ footerItemPriority = 0,
55
+ header,
56
+ children
57
+ }: Props = $props();
58
+
59
+ // ── Helpers ──────────────────────────────────────────────────────────────────
60
+ function createInitialValues(s: UISchema) {
61
+ const v: Record<string, unknown> = {};
62
+ for (const input of s.inputs) {
63
+ v[input.id] = input.default ?? getDefaultValue(input.paramType);
64
+ }
65
+ for (const output of s.outputs) {
66
+ v[output.id] = null;
67
+ }
68
+ return v;
69
+ }
70
+
71
+ // ── Core state ───────────────────────────────────────────────────────────────
72
+ // svelte-ignore state_referenced_locally
73
+ let values = $state<Record<string, unknown>>(createInitialValues(schema));
74
+ let error = $state('');
75
+ let computeErrors = $state<string[]>([]);
76
+ let computeWarnings = $state<string[]>([]);
77
+ let meshes = $state<any[]>([]);
78
+ let pendingValues = $state<Record<string, unknown>>({});
79
+ // svelte-ignore state_referenced_locally
80
+ let hasPendingChanges = $state(schema?.instanceSolve === false);
81
+ // svelte-ignore state_referenced_locally
82
+ let hasNeverSolved = $state(schema?.instanceSolve === false);
83
+ let isViewerFullscreen = $state(false);
84
+
85
+ // ── Solve logic ──────────────────────────────────────────────────────────────
86
+ async function performSolveInternal(solveValues: Record<string, unknown>, signal: AbortSignal) {
87
+ try {
88
+ error = '';
89
+ computeErrors = [];
90
+ computeWarnings = [];
91
+
92
+ const result = await onSolve(solveValues, signal);
93
+
94
+ if (signal.aborted) return;
95
+
96
+ computeErrors = result.errors ?? [];
97
+ computeWarnings = result.warnings ?? [];
98
+ meshes = result.meshes ?? [];
99
+
100
+ Object.assign(values, result.outputs);
101
+ pendingValues = {};
102
+ hasPendingChanges = false;
103
+ hasNeverSolved = false;
104
+ } catch (err) {
105
+ if (err instanceof Error && err.name === 'AbortError') return;
106
+ error = err instanceof Error ? err.message : String(err);
107
+ }
108
+ }
109
+
110
+ const computeThrottle = createComputeThrottle<Record<string, unknown>>(performSolveInternal, {
111
+ timeout: 60000
112
+ });
113
+
114
+ let solving = $derived(computeThrottle.isComputing);
115
+ const solvingIndicator = createSolvingIndicator(() => solving);
116
+
117
+ function performSolve() {
118
+ computeThrottle.trigger($state.snapshot(values));
119
+ }
120
+
121
+ // ── Definition switching ─────────────────────────────────────────────────────
122
+ let previousDefinitionKey = $state('');
123
+ let isInitialLoad = $state(true);
124
+
125
+ $effect(() => {
126
+ const _ = definitionKey;
127
+
128
+ untrack(() => {
129
+ if (isInitialLoad) {
130
+ isInitialLoad = false;
131
+ previousDefinitionKey = definitionKey;
132
+ if (schema?.instanceSolve !== false) {
133
+ performSolve();
134
+ }
135
+ } else if (previousDefinitionKey !== definitionKey) {
136
+ meshes = [];
137
+ values = createInitialValues(schema);
138
+ error = '';
139
+ computeErrors = [];
140
+ computeWarnings = [];
141
+ if (schema && Object.keys(values).length > 0) {
142
+ performSolve();
143
+ }
144
+ previousDefinitionKey = definitionKey;
145
+ }
146
+ });
147
+ });
148
+
149
+ // ── Handlers ─────────────────────────────────────────────────────────────────
150
+ async function handleValueChange(id: string, val: unknown) {
151
+ values[id] = val;
152
+
153
+ if (schema?.instanceSolve === false) {
154
+ pendingValues[id] = val;
155
+ hasPendingChanges = true;
156
+ return;
157
+ }
158
+
159
+ performSolve();
160
+ }
161
+
162
+ function handleCalculate() {
163
+ performSolve();
164
+ }
165
+
166
+ // ── Footer item ──────────────────────────────────────────────────────────────
167
+ // Use untrack to read these static props without creating reactive dependencies.
168
+ // footerComponentProps is intentionally NOT untracked — it's a getter called every render.
169
+ const _footerItemId = untrack(() => footerItemId);
170
+ const _footerComponent = untrack(() => footerComponent);
171
+ const _footerItemPriority = untrack(() => footerItemPriority);
172
+ useFooterItem(
173
+ _footerItemId,
174
+ _footerComponent,
175
+ () => (_footerComponent ? (footerComponentProps?.() ?? {}) : {}),
176
+ 'left',
177
+ _footerItemPriority
178
+ );
179
+
180
+ // ── Embed + custom style ─────────────────────────────────────────────────────
181
+ let resolvedIsEmbedded = $derived(isEmbedded ?? page.url.searchParams.get('embed') === 'true');
182
+ let resolvedPrimaryColor = $derived(primaryColor ?? page.url.searchParams.get('primary'));
183
+ let customStyle = $derived(
184
+ resolvedPrimaryColor ? `--primary: ${hexToOklch(resolvedPrimaryColor)}` : ''
185
+ );
186
+
187
+ let pageTitle = $derived(title ?? (schema?.description || schema.name));
188
+ </script>
189
+
190
+ <div style={customStyle} style:display="contents">
191
+ {#if children}
192
+ {@render children({ errors: computeErrors, warnings: computeWarnings })}
193
+ {:else}
194
+ <PageContainer errors={computeErrors} warnings={computeWarnings}>
195
+ {#if header}
196
+ {@render header()}
197
+ {:else if !resolvedIsEmbedded}
198
+ <PageHeader title={pageTitle} {showModeToggle} />
199
+ {/if}
200
+
201
+ <div class="bg-background flex flex-1 flex-col overflow-hidden">
202
+ {#if error}
203
+ <div class="flex min-h-100 items-center justify-center p-8">
204
+ <StateDisplay type="error" size="medium" message={error} />
205
+ </div>
206
+ {:else if !schema}
207
+ <div class="flex min-h-100 items-center justify-center">
208
+ <StateDisplay type="loading" size="large" message="Loading schema..." />
209
+ </div>
210
+ {:else}
211
+ {#key definitionKey}
212
+ <AppLayout
213
+ {schema}
214
+ {meshes}
215
+ isSolving={solving}
216
+ showSolvingIndicator={schema.instanceSolve !== false && solvingIndicator.show}
217
+ {hasPendingChanges}
218
+ {hasNeverSolved}
219
+ bind:isViewerFullscreen
220
+ bind:values
221
+ {stateManagerActions}
222
+ {showSaveButton}
223
+ {showLoadButton}
224
+ onValueChange={handleValueChange}
225
+ oncalculate={handleCalculate}
226
+ onLoadValues={() => {
227
+ if (schema?.instanceSolve !== false) {
228
+ performSolve();
229
+ } else {
230
+ hasPendingChanges = true;
231
+ }
232
+ }}
233
+ />
234
+ {/key}
235
+ {/if}
236
+ </div>
237
+ </PageContainer>
238
+ {/if}
239
+ </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}
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
+ };