@selvajs/ui 0.10.0 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,7 +9,7 @@
9
9
  value: string;
10
10
  }
11
11
 
12
- let { item, value }: Props = $props();
12
+ let { item: _item, value }: Props = $props();
13
13
 
14
14
  const MAP_TYPE_SET = new Set(['scattermap', 'scattermapbox', 'choropleth', 'scattergeo']);
15
15
 
@@ -103,12 +103,12 @@
103
103
  }
104
104
 
105
105
  const duplicatePaths = $derived.by(() => {
106
- const seen = new Map<string, number>();
106
+ const seen: Record<string, number> = {};
107
107
  for (const f of filesArray) {
108
108
  const key = fullPath(f);
109
- seen.set(key, (seen.get(key) ?? 0) + 1);
109
+ seen[key] = (seen[key] ?? 0) + 1;
110
110
  }
111
- return Array.from(seen.entries())
111
+ return Object.entries(seen)
112
112
  .filter(([, n]) => n > 1)
113
113
  .map(([path]) => path);
114
114
  });
@@ -108,8 +108,8 @@
108
108
  {disabled}
109
109
  onclick={() => (open = !open)}
110
110
  class={cn(
111
- 'bg-background flex h-10 w-full items-center gap-2 rounded-md border px-3 text-left text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-50',
112
- open ? 'border-ring ring-ring/30 ring-2' : 'border-input hover:bg-muted/40'
111
+ 'h-10 gap-2 px-3 text-sm flex w-full items-center rounded-md border bg-background text-left transition-colors disabled:cursor-not-allowed disabled:opacity-50',
112
+ open ? 'border-ring ring-2 ring-ring/30' : 'border-input hover:bg-muted/40'
113
113
  )}
114
114
  >
115
115
  {#if SelectedIcon}
@@ -118,14 +118,14 @@
118
118
  style={selected?.iconStyle}
119
119
  />
120
120
  {:else if TriggerIcon}
121
- <TriggerIcon class="text-muted-foreground h-3.5 w-3.5 shrink-0" />
121
+ <TriggerIcon class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
122
122
  {/if}
123
123
  <span class={cn('min-w-0 flex-1 truncate', selected ? '' : 'text-muted-foreground')}>
124
124
  {selected ? selected.label : placeholder}
125
125
  </span>
126
126
  <ChevronDown
127
127
  class={cn(
128
- 'text-muted-foreground h-3.5 w-3.5 shrink-0 transition-transform',
128
+ 'h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform',
129
129
  open ? 'rotate-180' : ''
130
130
  )}
131
131
  />
@@ -133,26 +133,26 @@
133
133
 
134
134
  {#if open}
135
135
  <div
136
- class="border-border bg-popover absolute top-full right-0 left-0 z-50 mt-1 overflow-hidden rounded-md border shadow-md"
136
+ class="right-0 left-0 mt-1 shadow-md absolute top-full z-50 overflow-hidden rounded-md border border-border bg-popover"
137
137
  >
138
138
  {#if showSearch}
139
- <div class="border-border relative border-b">
139
+ <div class="relative border-b border-border">
140
140
  <Search
141
- class="text-muted-foreground pointer-events-none absolute top-1/2 left-3 h-3.5 w-3.5 -translate-y-1/2"
141
+ class="left-3 h-3.5 w-3.5 pointer-events-none absolute top-1/2 -translate-y-1/2 text-muted-foreground"
142
142
  />
143
143
  <input
144
144
  bind:this={inputRef}
145
145
  bind:value={query}
146
146
  onkeydown={handleKey}
147
147
  placeholder={searchPlaceholder}
148
- class="placeholder:text-muted-foreground h-8 w-full bg-transparent pr-3 pl-9 text-sm outline-none"
148
+ class="h-8 pr-3 pl-9 text-sm w-full bg-transparent outline-none placeholder:text-muted-foreground"
149
149
  />
150
150
  </div>
151
151
  {/if}
152
152
 
153
153
  <div class="max-h-60 overflow-y-auto">
154
154
  {#if matches.length === 0}
155
- <p class="text-muted-foreground px-3 py-3 text-center text-xs">
155
+ <p class="px-3 py-3 text-xs text-center text-muted-foreground">
156
156
  No matches for "{query}"
157
157
  </p>
158
158
  {:else}
@@ -163,7 +163,7 @@
163
163
  onclick={() => pick(item)}
164
164
  onmouseenter={() => (highlighted = i)}
165
165
  class={cn(
166
- 'flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm transition-colors',
166
+ 'gap-2 px-3 py-1.5 text-sm flex w-full items-center text-left transition-colors',
167
167
  highlighted === i ? 'bg-accent text-accent-foreground' : 'hover:bg-muted/60'
168
168
  )}
169
169
  >
@@ -175,7 +175,7 @@
175
175
  {/if}
176
176
  <span class="flex-1 truncate">{item.label}</span>
177
177
  {#if item.id === value}
178
- <Check class="text-primary h-3.5 w-3.5 shrink-0" />
178
+ <Check class="h-3.5 w-3.5 shrink-0 text-primary" />
179
179
  {/if}
180
180
  </button>
181
181
  {/each}
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Fallback per-solve abort timeout (ms) when the caller doesn't pass one.
3
- * Used only by callers without a deployment-specific limit (e.g. builder-app
4
- * over WebSocket); compute-app supplies `MAX_SOLVE_DURATION_MS` from its
3
+ * Used only by callers without a deployment-specific limit (e.g. plugin-ui
4
+ * over WebSocket); the selva app supplies `MAX_SOLVE_DURATION_MS` from its
5
5
  * server config via `ComputeApp`'s `solveTimeoutMs` prop.
6
6
  */
7
7
  const DEFAULT_TIMEOUT_MS = 60_000;
@@ -3,8 +3,8 @@
3
3
  // When an input has source.kind === 'external', a producer route writes the produced
4
4
  // value here, and the solver route reads it back. Scoped per (scopeKey, inputId) so
5
5
  // values for one solver/input don't bleed into another. The scope key is whatever
6
- // uniquely identifies the solver context — sessionId in builder-app/preview,
7
- // definition guid in compute-app/library, etc.
6
+ // uniquely identifies the solver context — sessionId in plugin-ui/preview,
7
+ // definition guid in selva/library, etc.
8
8
  //
9
9
  // inputId is the Grasshopper parameter instance GUID (LayoutItem.paramId / SchemaInput.id).
10
10
  const STORAGE_PREFIX = 'external';
package/dist/index.d.ts CHANGED
@@ -11,5 +11,6 @@ export * from './external/storage';
11
11
  export * from './contexts/footerContext.svelte';
12
12
  export * from './composables/useFooterItem.svelte';
13
13
  export * from './utils';
14
+ export { randomId } from './utils/randomId';
14
15
  export type { ActionButton } from './types/actionButton';
15
16
  export type { SolveFn } from './types/solveFn';
package/dist/index.js CHANGED
@@ -20,3 +20,4 @@ export * from './contexts/footerContext.svelte';
20
20
  export * from './composables/useFooterItem.svelte';
21
21
  // Utils (cn function)
22
22
  export * from './utils';
23
+ export { randomId } from './utils/randomId';
@@ -1,4 +1,5 @@
1
1
  import { APP_DEFAULTS } from '../constants';
2
+ import { randomId } from '../utils/randomId';
2
3
  function getGroups(schema) {
3
4
  if (!schema.layout)
4
5
  return [];
@@ -29,7 +30,7 @@ export function createSavedState(schema, currentValues, metadata) {
29
30
  }
30
31
  }
31
32
  return {
32
- id: crypto.randomUUID(),
33
+ id: randomId(),
33
34
  name: metadata.name,
34
35
  description: metadata.description,
35
36
  timestamp: new Date().toISOString(),
@@ -0,0 +1 @@
1
+ export declare function randomId(): string;
@@ -0,0 +1,51 @@
1
+ // Generate a UUIDv4-shaped string in any browser context.
2
+ //
3
+ // `crypto.randomUUID()` requires a secure context (HTTPS or localhost).
4
+ // Plain-HTTP deployments — common for first-time self-hosters trying things
5
+ // out — get `crypto.randomUUID is not a function`, which is a confusing
6
+ // browser-level error nobody can diagnose without devtools open.
7
+ //
8
+ // We try in order:
9
+ // 1. crypto.randomUUID() — fastest path, available in secure contexts
10
+ // 2. crypto.getRandomValues() — Web Crypto's lower-level entropy, works
11
+ // in any context that ships Web Crypto
12
+ // 3. Math.random() fallback — last resort; non-cryptographic, but the
13
+ // IDs we generate here are only used as
14
+ // collision-resistant keys for client-side
15
+ // form rows, not as security tokens.
16
+ //
17
+ // Callers that need cryptographic randomness (tokens, secrets) must NOT use
18
+ // this — they should fail loudly in insecure contexts instead of silently
19
+ // downgrading.
20
+ export function randomId() {
21
+ const c = typeof crypto !== 'undefined' ? crypto : undefined;
22
+ if (c?.randomUUID)
23
+ return c.randomUUID();
24
+ if (c?.getRandomValues)
25
+ return uuidFromBytes(c.getRandomValues(new Uint8Array(16)));
26
+ return uuidFromBytes(mathRandomBytes());
27
+ }
28
+ function mathRandomBytes() {
29
+ const out = new Uint8Array(16);
30
+ for (let i = 0; i < 16; i++)
31
+ out[i] = Math.floor(Math.random() * 256);
32
+ return out;
33
+ }
34
+ // Format 16 bytes as a UUIDv4 string with the version + variant bits set per
35
+ // RFC 4122 §4.4. Same shape as crypto.randomUUID() returns.
36
+ function uuidFromBytes(bytes) {
37
+ bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4
38
+ bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 10xx
39
+ const hex = [];
40
+ for (let i = 0; i < 16; i++)
41
+ hex.push(bytes[i].toString(16).padStart(2, '0'));
42
+ return (hex.slice(0, 4).join('') +
43
+ '-' +
44
+ hex.slice(4, 6).join('') +
45
+ '-' +
46
+ hex.slice(6, 8).join('') +
47
+ '-' +
48
+ hex.slice(8, 10).join('') +
49
+ '-' +
50
+ hex.slice(10, 16).join(''));
51
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@selvajs/ui",
3
- "version": "0.10.0",
3
+ "version": "2.0.1",
4
4
  "description": "Shared UI components and utilities for Selva applications",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -58,7 +58,8 @@
58
58
  "bits-ui": "^2.15.4",
59
59
  "svelte": "^5",
60
60
  "tailwind-variants": "^3.2.2",
61
- "three": "^0.184.0"
61
+ "three": "^0.184.0",
62
+ "@selvajs/schemas": "2.0.1"
62
63
  },
63
64
  "peerDependenciesMeta": {
64
65
  "three": {
@@ -74,8 +75,7 @@
74
75
  "paneforge": "^1.0.2",
75
76
  "svelte-sonner": "^1.1.1",
76
77
  "tailwind-merge": "^3.5.0",
77
- "tw-animate-css": "^1.4.0",
78
- "@selvajs/schemas": "1.2.0"
78
+ "tw-animate-css": "^1.4.0"
79
79
  },
80
80
  "devDependencies": {
81
81
  "@internationalized/date": "^3.12.1",
@@ -84,11 +84,12 @@
84
84
  "bits-ui": "^2.18.0",
85
85
  "svelte": "5.55.5",
86
86
  "tailwind-variants": "^3.2.2",
87
- "@selvajs/config": "0.0.0"
87
+ "@selvajs/config": "0.0.0",
88
+ "@selvajs/schemas": "2.0.1"
88
89
  },
89
90
  "scripts": {
90
91
  "dev": "vite dev",
91
- "build": "rm -rf dist && vite build && pnpm prepack",
92
+ "build": "rm -rf dist && pnpm prepack",
92
93
  "build:fast": "svelte-kit sync && svelte-package",
93
94
  "build:watch": "svelte-kit sync && svelte-package --watch",
94
95
  "preview": "vite preview",
@@ -9,7 +9,7 @@
9
9
  value: string;
10
10
  }
11
11
 
12
- let { item, value }: Props = $props();
12
+ let { item: _item, value }: Props = $props();
13
13
 
14
14
  const MAP_TYPE_SET = new Set(['scattermap', 'scattermapbox', 'choropleth', 'scattergeo']);
15
15
 
@@ -103,12 +103,12 @@
103
103
  }
104
104
 
105
105
  const duplicatePaths = $derived.by(() => {
106
- const seen = new Map<string, number>();
106
+ const seen: Record<string, number> = {};
107
107
  for (const f of filesArray) {
108
108
  const key = fullPath(f);
109
- seen.set(key, (seen.get(key) ?? 0) + 1);
109
+ seen[key] = (seen[key] ?? 0) + 1;
110
110
  }
111
- return Array.from(seen.entries())
111
+ return Object.entries(seen)
112
112
  .filter(([, n]) => n > 1)
113
113
  .map(([path]) => path);
114
114
  });
@@ -108,8 +108,8 @@
108
108
  {disabled}
109
109
  onclick={() => (open = !open)}
110
110
  class={cn(
111
- 'bg-background flex h-10 w-full items-center gap-2 rounded-md border px-3 text-left text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-50',
112
- open ? 'border-ring ring-ring/30 ring-2' : 'border-input hover:bg-muted/40'
111
+ 'h-10 gap-2 px-3 text-sm flex w-full items-center rounded-md border bg-background text-left transition-colors disabled:cursor-not-allowed disabled:opacity-50',
112
+ open ? 'border-ring ring-2 ring-ring/30' : 'border-input hover:bg-muted/40'
113
113
  )}
114
114
  >
115
115
  {#if SelectedIcon}
@@ -118,14 +118,14 @@
118
118
  style={selected?.iconStyle}
119
119
  />
120
120
  {:else if TriggerIcon}
121
- <TriggerIcon class="text-muted-foreground h-3.5 w-3.5 shrink-0" />
121
+ <TriggerIcon class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
122
122
  {/if}
123
123
  <span class={cn('min-w-0 flex-1 truncate', selected ? '' : 'text-muted-foreground')}>
124
124
  {selected ? selected.label : placeholder}
125
125
  </span>
126
126
  <ChevronDown
127
127
  class={cn(
128
- 'text-muted-foreground h-3.5 w-3.5 shrink-0 transition-transform',
128
+ 'h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform',
129
129
  open ? 'rotate-180' : ''
130
130
  )}
131
131
  />
@@ -133,26 +133,26 @@
133
133
 
134
134
  {#if open}
135
135
  <div
136
- class="border-border bg-popover absolute top-full right-0 left-0 z-50 mt-1 overflow-hidden rounded-md border shadow-md"
136
+ class="right-0 left-0 mt-1 shadow-md absolute top-full z-50 overflow-hidden rounded-md border border-border bg-popover"
137
137
  >
138
138
  {#if showSearch}
139
- <div class="border-border relative border-b">
139
+ <div class="relative border-b border-border">
140
140
  <Search
141
- class="text-muted-foreground pointer-events-none absolute top-1/2 left-3 h-3.5 w-3.5 -translate-y-1/2"
141
+ class="left-3 h-3.5 w-3.5 pointer-events-none absolute top-1/2 -translate-y-1/2 text-muted-foreground"
142
142
  />
143
143
  <input
144
144
  bind:this={inputRef}
145
145
  bind:value={query}
146
146
  onkeydown={handleKey}
147
147
  placeholder={searchPlaceholder}
148
- class="placeholder:text-muted-foreground h-8 w-full bg-transparent pr-3 pl-9 text-sm outline-none"
148
+ class="h-8 pr-3 pl-9 text-sm w-full bg-transparent outline-none placeholder:text-muted-foreground"
149
149
  />
150
150
  </div>
151
151
  {/if}
152
152
 
153
153
  <div class="max-h-60 overflow-y-auto">
154
154
  {#if matches.length === 0}
155
- <p class="text-muted-foreground px-3 py-3 text-center text-xs">
155
+ <p class="px-3 py-3 text-xs text-center text-muted-foreground">
156
156
  No matches for "{query}"
157
157
  </p>
158
158
  {:else}
@@ -163,7 +163,7 @@
163
163
  onclick={() => pick(item)}
164
164
  onmouseenter={() => (highlighted = i)}
165
165
  class={cn(
166
- 'flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm transition-colors',
166
+ 'gap-2 px-3 py-1.5 text-sm flex w-full items-center text-left transition-colors',
167
167
  highlighted === i ? 'bg-accent text-accent-foreground' : 'hover:bg-muted/60'
168
168
  )}
169
169
  >
@@ -175,7 +175,7 @@
175
175
  {/if}
176
176
  <span class="flex-1 truncate">{item.label}</span>
177
177
  {#if item.id === value}
178
- <Check class="text-primary h-3.5 w-3.5 shrink-0" />
178
+ <Check class="h-3.5 w-3.5 shrink-0 text-primary" />
179
179
  {/if}
180
180
  </button>
181
181
  {/each}
@@ -12,8 +12,8 @@ interface ComputeThrottleOptions {
12
12
 
13
13
  /**
14
14
  * Fallback per-solve abort timeout (ms) when the caller doesn't pass one.
15
- * Used only by callers without a deployment-specific limit (e.g. builder-app
16
- * over WebSocket); compute-app supplies `MAX_SOLVE_DURATION_MS` from its
15
+ * Used only by callers without a deployment-specific limit (e.g. plugin-ui
16
+ * over WebSocket); the selva app supplies `MAX_SOLVE_DURATION_MS` from its
17
17
  * server config via `ComputeApp`'s `solveTimeoutMs` prop.
18
18
  */
19
19
  const DEFAULT_TIMEOUT_MS = 60_000;
@@ -3,8 +3,8 @@
3
3
  // When an input has source.kind === 'external', a producer route writes the produced
4
4
  // value here, and the solver route reads it back. Scoped per (scopeKey, inputId) so
5
5
  // values for one solver/input don't bleed into another. The scope key is whatever
6
- // uniquely identifies the solver context — sessionId in builder-app/preview,
7
- // definition guid in compute-app/library, etc.
6
+ // uniquely identifies the solver context — sessionId in plugin-ui/preview,
7
+ // definition guid in selva/library, etc.
8
8
  //
9
9
  // inputId is the Grasshopper parameter instance GUID (LayoutItem.paramId / SchemaInput.id).
10
10
 
package/src/lib/index.ts CHANGED
@@ -28,6 +28,7 @@ export * from './composables/useFooterItem.svelte';
28
28
 
29
29
  // Utils (cn function)
30
30
  export * from './utils';
31
+ export { randomId } from './utils/randomId';
31
32
 
32
33
  // UI-specific runtime types (not from schema)
33
34
  export type { ActionButton } from './types/actionButton';
@@ -1,4 +1,5 @@
1
1
  import { APP_DEFAULTS } from '../constants';
2
+ import { randomId } from '../utils/randomId';
2
3
  import type {
3
4
  UISchema,
4
5
  ParameterPreset,
@@ -44,7 +45,7 @@ export function createSavedState(
44
45
  }
45
46
 
46
47
  return {
47
- id: crypto.randomUUID(),
48
+ id: randomId(),
48
49
  name: metadata.name,
49
50
  description: metadata.description,
50
51
  timestamp: new Date().toISOString(),
@@ -0,0 +1,52 @@
1
+ // Generate a UUIDv4-shaped string in any browser context.
2
+ //
3
+ // `crypto.randomUUID()` requires a secure context (HTTPS or localhost).
4
+ // Plain-HTTP deployments — common for first-time self-hosters trying things
5
+ // out — get `crypto.randomUUID is not a function`, which is a confusing
6
+ // browser-level error nobody can diagnose without devtools open.
7
+ //
8
+ // We try in order:
9
+ // 1. crypto.randomUUID() — fastest path, available in secure contexts
10
+ // 2. crypto.getRandomValues() — Web Crypto's lower-level entropy, works
11
+ // in any context that ships Web Crypto
12
+ // 3. Math.random() fallback — last resort; non-cryptographic, but the
13
+ // IDs we generate here are only used as
14
+ // collision-resistant keys for client-side
15
+ // form rows, not as security tokens.
16
+ //
17
+ // Callers that need cryptographic randomness (tokens, secrets) must NOT use
18
+ // this — they should fail loudly in insecure contexts instead of silently
19
+ // downgrading.
20
+ export function randomId(): string {
21
+ const c = typeof crypto !== 'undefined' ? crypto : undefined;
22
+ if (c?.randomUUID) return c.randomUUID();
23
+ if (c?.getRandomValues) return uuidFromBytes(c.getRandomValues(new Uint8Array(16)));
24
+ return uuidFromBytes(mathRandomBytes());
25
+ }
26
+
27
+ function mathRandomBytes(): Uint8Array {
28
+ const out = new Uint8Array(16);
29
+ for (let i = 0; i < 16; i++) out[i] = Math.floor(Math.random() * 256);
30
+ return out;
31
+ }
32
+
33
+ // Format 16 bytes as a UUIDv4 string with the version + variant bits set per
34
+ // RFC 4122 §4.4. Same shape as crypto.randomUUID() returns.
35
+ function uuidFromBytes(bytes: Uint8Array): string {
36
+ bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4
37
+ bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 10xx
38
+
39
+ const hex: string[] = [];
40
+ for (let i = 0; i < 16; i++) hex.push(bytes[i].toString(16).padStart(2, '0'));
41
+ return (
42
+ hex.slice(0, 4).join('') +
43
+ '-' +
44
+ hex.slice(4, 6).join('') +
45
+ '-' +
46
+ hex.slice(6, 8).join('') +
47
+ '-' +
48
+ hex.slice(8, 10).join('') +
49
+ '-' +
50
+ hex.slice(10, 16).join('')
51
+ );
52
+ }