@selvajs/ui 6.6.0-beta.8 → 6.6.0-beta.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/dist/components/compute/AppLayout.svelte +37 -7
  2. package/dist/components/compute/AppLayout.svelte.d.ts +5 -0
  3. package/dist/components/compute/ParameterPresetManager.svelte +8 -20
  4. package/dist/components/compute/SolveMessages.svelte +26 -3
  5. package/dist/components/compute/SolveMessages.svelte.d.ts +7 -0
  6. package/dist/components/layout/PageFooter.svelte +3 -1
  7. package/dist/components/layout/SideNav.svelte +55 -14
  8. package/dist/components/preview/ChartOutput.svelte +18 -13
  9. package/dist/components/preview/Group.svelte +6 -1
  10. package/dist/components/preview/ImageOutput.svelte +3 -1
  11. package/dist/components/preview/InputControl.svelte +108 -44
  12. package/dist/components/preview/InputControl.svelte.d.ts +5 -1
  13. package/dist/components/preview/TabContent.svelte +61 -29
  14. package/dist/components/preview/TabContent.svelte.d.ts +5 -1
  15. package/dist/components/preview/TabLayout.svelte +25 -3
  16. package/dist/components/preview/TabLayout.svelte.d.ts +5 -0
  17. package/dist/components/preview/inputs/ChecklistInput.svelte +7 -2
  18. package/dist/components/preview/inputs/ChecklistInput.svelte.d.ts +2 -1
  19. package/dist/components/preview/inputs/DropdownInput.svelte +16 -4
  20. package/dist/components/preview/inputs/DropdownInput.svelte.d.ts +2 -1
  21. package/dist/components/preview/inputs/FileInput.svelte +56 -65
  22. package/dist/components/preview/inputs/GeometryInput.svelte +128 -0
  23. package/dist/components/preview/inputs/GeometryInput.svelte.d.ts +21 -0
  24. package/dist/components/preview/inputs/OptionGlyph.svelte +29 -0
  25. package/dist/components/preview/inputs/OptionGlyph.svelte.d.ts +8 -0
  26. package/dist/components/preview/inputs/SegmentedInput.svelte +44 -0
  27. package/dist/components/preview/inputs/SegmentedInput.svelte.d.ts +12 -0
  28. package/dist/components/preview/inputs/TileInput.svelte +80 -0
  29. package/dist/components/preview/inputs/TileInput.svelte.d.ts +12 -0
  30. package/dist/components/preview/inputs/index.d.ts +3 -0
  31. package/dist/components/preview/inputs/index.js +3 -0
  32. package/dist/components/primitives/SolvingIndicator.svelte +28 -3
  33. package/dist/components/primitives/SolvingIndicator.svelte.d.ts +7 -0
  34. package/dist/components/primitives/button/button.svelte +3 -0
  35. package/dist/components/primitives/button/button.svelte.d.ts +3 -0
  36. package/dist/components/primitives/toggle/toggle.svelte +3 -1
  37. package/dist/components/primitives/toggle/toggle.svelte.d.ts +3 -0
  38. package/dist/components/viewer/Viewer.svelte +11 -11
  39. package/dist/compute/solving.svelte.d.ts +7 -1
  40. package/dist/compute/solving.svelte.js +12 -1
  41. package/dist/contexts/hostContext.svelte.d.ts +39 -0
  42. package/dist/contexts/hostContext.svelte.js +9 -0
  43. package/dist/i18n/messages.d.ts +33 -0
  44. package/dist/i18n/messages.js +58 -0
  45. package/dist/index.d.ts +3 -0
  46. package/dist/index.js +3 -0
  47. package/dist/schema/host-support.d.ts +29 -0
  48. package/dist/schema/host-support.js +97 -0
  49. package/dist/schema/option-display.d.ts +12 -0
  50. package/dist/schema/option-display.js +29 -0
  51. package/dist/schema/param-exporter.d.ts +4 -1
  52. package/dist/schema/param-exporter.js +10 -6
  53. package/dist/styles/base.css +6 -3
  54. package/dist/utils/file-download.js +6 -10
  55. package/dist/utils/host-files.d.ts +12 -0
  56. package/dist/utils/host-files.js +46 -0
  57. package/dist/utils/webp-to-png.d.ts +10 -0
  58. package/dist/utils/webp-to-png.js +41 -0
  59. package/package.json +9 -9
  60. package/src/lib/components/compute/AppLayout.svelte +37 -7
  61. package/src/lib/components/compute/ParameterPresetManager.svelte +8 -20
  62. package/src/lib/components/compute/SolveMessages.svelte +26 -3
  63. package/src/lib/components/layout/PageFooter.svelte +3 -1
  64. package/src/lib/components/layout/SideNav.svelte +55 -14
  65. package/src/lib/components/preview/ChartOutput.svelte +18 -13
  66. package/src/lib/components/preview/Group.svelte +6 -1
  67. package/src/lib/components/preview/ImageOutput.svelte +3 -1
  68. package/src/lib/components/preview/InputControl.svelte +108 -44
  69. package/src/lib/components/preview/TabContent.svelte +61 -29
  70. package/src/lib/components/preview/TabLayout.svelte +25 -3
  71. package/src/lib/components/preview/inputs/ChecklistInput.svelte +7 -2
  72. package/src/lib/components/preview/inputs/DropdownInput.svelte +16 -4
  73. package/src/lib/components/preview/inputs/FileInput.svelte +56 -65
  74. package/src/lib/components/preview/inputs/GeometryInput.svelte +128 -0
  75. package/src/lib/components/preview/inputs/OptionGlyph.svelte +29 -0
  76. package/src/lib/components/preview/inputs/SegmentedInput.svelte +44 -0
  77. package/src/lib/components/preview/inputs/TileInput.svelte +80 -0
  78. package/src/lib/components/preview/inputs/index.ts +3 -0
  79. package/src/lib/components/primitives/SolvingIndicator.svelte +28 -3
  80. package/src/lib/components/primitives/button/button.svelte +3 -0
  81. package/src/lib/components/primitives/toggle/toggle.svelte +3 -1
  82. package/src/lib/components/viewer/Viewer.svelte +11 -11
  83. package/src/lib/compute/solving.svelte.ts +16 -1
  84. package/src/lib/contexts/hostContext.svelte.ts +51 -0
  85. package/src/lib/i18n/messages.ts +96 -0
  86. package/src/lib/index.ts +20 -0
  87. package/src/lib/schema/host-support.ts +129 -0
  88. package/src/lib/schema/option-display.ts +44 -0
  89. package/src/lib/schema/param-exporter.ts +10 -7
  90. package/src/lib/styles/base.css +6 -3
  91. package/src/lib/utils/file-download.ts +7 -11
  92. package/src/lib/utils/host-files.ts +61 -0
  93. package/src/lib/utils/webp-to-png.ts +48 -0
@@ -7,6 +7,8 @@
7
7
  import { APP_DEFAULTS } from '../../../constants';
8
8
  import { getLocaleContext } from '../../../i18n/localeContext.svelte';
9
9
  import { formatMessage } from '../../../i18n/messages';
10
+ import { pickFile } from '../../../utils/host-files';
11
+ import { acceptsFileEnding, isWebp, shouldConvertWebp, webpToPng } from '../../../utils/webp-to-png';
10
12
 
11
13
  interface Props {
12
14
  value?: string;
@@ -40,7 +42,6 @@
40
42
  activeMode = effectiveModes.includes(preferred) ? preferred : effectiveModes[0];
41
43
  });
42
44
 
43
- let fileInput: HTMLInputElement | null = $state(null);
44
45
  let uploadedFileName = $state('');
45
46
  let urlInput = $state('');
46
47
  let isDragging = $state(false);
@@ -94,9 +95,27 @@
94
95
  return ext.toLowerCase();
95
96
  }
96
97
 
97
- function isValidFileExtension(fileEnding: string): boolean {
98
- if (acceptedFormats.length === 0) return true;
99
- return acceptedFormats.includes(fileEnding.toLowerCase());
98
+ // Runs before the size cap: the PNG is larger than the WebP it came from. A WebP already over
99
+ // the cap is passed through unconverted, so the cap rejects it without a full decode.
100
+ async function prepare(
101
+ blob: Blob,
102
+ fileEnding: string
103
+ ): Promise<{ blob: Blob; fileEnding: string }> {
104
+ if (blob.size > APP_DEFAULTS.FILE_UPLOAD.MAX_SIZE_BYTES) return { blob, fileEnding };
105
+ if (!shouldConvertWebp(fileEnding, acceptedFormats)) return { blob, fileEnding };
106
+ return { blob: await webpToPng(blob), fileEnding: '.png' };
107
+ }
108
+
109
+ function readBase64(blob: Blob): Promise<string> {
110
+ return new Promise<string>((resolve, reject) => {
111
+ const reader = new FileReader();
112
+ reader.onload = (e) => {
113
+ const result = e.target?.result as string;
114
+ resolve(result.includes(',') ? result.split(',')[1] : result);
115
+ };
116
+ reader.onerror = () => reject(new Error(t.fileReadFailed));
117
+ reader.readAsDataURL(blob);
118
+ });
100
119
  }
101
120
 
102
121
  function isCorsError(error: unknown): boolean {
@@ -138,14 +157,16 @@
138
157
  return;
139
158
  }
140
159
 
141
- const blob = await response.blob();
142
- const fileEnding = getFileExtension(urlInput);
160
+ const fetched = await response.blob();
161
+ const requestedEnding = (await isWebp(fetched)) ? '.webp' : getFileExtension(urlInput);
143
162
 
144
- if (!isValidFileExtension(fileEnding)) {
145
- urlError = { message: formatNotAccepted(fileEnding), isCors: false };
163
+ if (!acceptsFileEnding(requestedEnding, acceptedFormats)) {
164
+ urlError = { message: formatNotAccepted(requestedEnding), isCors: false };
146
165
  return;
147
166
  }
148
167
 
168
+ const { blob, fileEnding } = await prepare(fetched, requestedEnding);
169
+
149
170
  const maxSize = APP_DEFAULTS.FILE_UPLOAD.MAX_SIZE_BYTES;
150
171
  if (blob.size > maxSize) {
151
172
  urlError = {
@@ -155,15 +176,7 @@
155
176
  return;
156
177
  }
157
178
 
158
- const base64Data = await new Promise<string>((resolve, reject) => {
159
- const reader = new FileReader();
160
- reader.onload = (e) => {
161
- const result = e.target?.result as string;
162
- resolve(result.includes(',') ? result.split(',')[1] : result);
163
- };
164
- reader.onerror = () => reject(new Error(t.fileReadFailed));
165
- reader.readAsDataURL(blob);
166
- });
179
+ const base64Data = await readBase64(blob);
167
180
  const data = JSON.stringify({ file: base64Data, type: 'base64', fileEnding });
168
181
  value = data;
169
182
  urlSuccess = formatMessage(t.fileLoaded, {
@@ -206,48 +219,39 @@
206
219
  }
207
220
  }
208
221
 
209
- function handleFileUpload(event: Event) {
210
- const target = event.target as HTMLInputElement;
211
- const file = target.files?.[0];
212
- if (!file) return;
213
-
214
- const fileEnding = getFileExtension(file.name);
215
-
216
- if (!isValidFileExtension(fileEnding)) {
217
- toast.error(formatNotAccepted(fileEnding));
218
- return;
219
- }
222
+ async function handleFile(file: File) {
223
+ const requestedEnding = getFileExtension(file.name);
220
224
 
221
- // The file is base64-embedded into the compute request body, so without a
222
- // client-side cap an oversize file comes back as an opaque 413. This cap is
223
- // currently LOOSER than the server's COMPUTE_REQUEST_MAX_BYTES (see the TEMP
224
- // note in constants.ts) — some files pass here and still 413. The URL import
225
- // path applies the same cap.
226
- if (file.size > APP_DEFAULTS.FILE_UPLOAD.MAX_SIZE_BYTES) {
227
- toast.error(formatTooLarge(file.size));
225
+ if (!acceptsFileEnding(requestedEnding, acceptedFormats)) {
226
+ toast.error(formatNotAccepted(requestedEnding));
228
227
  return;
229
228
  }
230
229
 
231
- uploadedFileName = file.name;
232
-
233
- const reader = new FileReader();
234
- reader.onload = (e) => {
235
- const base64 = e.target?.result as string;
236
- const base64Data = base64.includes(',') ? base64.split(',')[1] : base64;
230
+ try {
231
+ const { blob, fileEnding } = await prepare(file, requestedEnding);
232
+
233
+ // The file is base64-embedded into the compute request body, so without a
234
+ // client-side cap an oversize file comes back as an opaque 413. This cap is
235
+ // currently LOOSER than the server's COMPUTE_REQUEST_MAX_BYTES (see the TEMP
236
+ // note in constants.ts) — some files pass here and still 413. The URL import
237
+ // path applies the same cap.
238
+ if (blob.size > APP_DEFAULTS.FILE_UPLOAD.MAX_SIZE_BYTES) {
239
+ toast.error(formatTooLarge(blob.size));
240
+ return;
241
+ }
237
242
 
238
- const data = JSON.stringify({
239
- file: base64Data,
240
- type: 'base64',
241
- fileEnding
242
- });
243
+ uploadedFileName = file.name;
244
+ const data = JSON.stringify({ file: await readBase64(blob), type: 'base64', fileEnding });
243
245
  value = data;
244
246
  onChange(data);
245
- };
246
- reader.readAsDataURL(file);
247
+ } catch (error) {
248
+ toast.error(error instanceof Error ? error.message : t.fileReadFailed);
249
+ }
247
250
  }
248
251
 
249
- function openFilePicker() {
250
- fileInput?.click();
252
+ async function openFilePicker() {
253
+ const file = await pickFile({ accept: acceptAttribute });
254
+ if (file) handleFile(file);
251
255
  }
252
256
 
253
257
  function handleDragOver(e: DragEvent) {
@@ -263,27 +267,14 @@
263
267
  e.preventDefault();
264
268
  isDragging = false;
265
269
 
266
- const files = e.dataTransfer?.files;
267
- if (files?.[0]) {
268
- const fakeEvent = new Event('change');
269
- const fakeTarget = { files: files } as HTMLInputElement;
270
- Object.defineProperty(fakeEvent, 'target', { value: fakeTarget });
271
- handleFileUpload(fakeEvent);
272
- }
270
+ const file = e.dataTransfer?.files?.[0];
271
+ if (file) handleFile(file);
273
272
  }
274
273
 
275
274
  const acceptAttribute = $derived(acceptedFormats.length > 0 ? acceptedFormats.join(',') : '*');
276
275
  </script>
277
276
 
278
277
  <div class="gap-2.5 flex w-full flex-col">
279
- <input
280
- bind:this={fileInput}
281
- type="file"
282
- accept={acceptAttribute}
283
- onchange={handleFileUpload}
284
- class="hidden"
285
- />
286
-
287
278
  {#if showToggle}
288
279
  <div class="p-0.5 text-xs flex rounded-md bg-muted">
289
280
  {#each effectiveModes as mode (mode)}
@@ -0,0 +1,128 @@
1
+ <script lang="ts">
2
+ import { Button } from '../../primitives/button';
3
+ import type { GeometryPickResult, PickKind } from '../../../contexts/hostContext.svelte';
4
+ import { getLocaleContext } from '../../../i18n/localeContext.svelte';
5
+ import type { ViewerMessages } from '../../../i18n/messages';
6
+
7
+ interface Props {
8
+ inputId: string;
9
+ /** The kinds of object the input takes; one kind names the button after it. */
10
+ kinds?: readonly PickKind[];
11
+ /** 1 picks a single object. */
12
+ atMost?: number;
13
+ /** What the host reports the param holds, such as "2 curves"; absent until something is picked. */
14
+ summary?: string;
15
+ /** Absent where this page may not pick, such as a browser tab beside the Rhino panel. */
16
+ onPick?: () => Promise<GeometryPickResult>;
17
+ onClear?: () => void;
18
+ /** Subscribes to what the host holds after any page picks or clears; returns an unsubscribe. */
19
+ watchSummary?: (listener: (summary: string | null) => void) => () => void;
20
+ /** Shown when the pick fails; the host's own message. */
21
+ onError?: (message: string) => void;
22
+ disabled?: boolean;
23
+ }
24
+
25
+ let {
26
+ inputId,
27
+ kinds,
28
+ atMost,
29
+ summary,
30
+ onPick,
31
+ onClear,
32
+ watchSummary,
33
+ onError,
34
+ disabled = false
35
+ }: Props = $props();
36
+
37
+ const locale = getLocaleContext();
38
+ const t = $derived(locale.messages);
39
+
40
+ type PickLabels = readonly [one: keyof ViewerMessages, other: keyof ViewerMessages];
41
+
42
+ const ANY_LABELS: PickLabels = ['geometryPickAnyOne', 'geometryPickAnyOther'];
43
+ const KIND_LABELS: Record<PickKind, PickLabels> = {
44
+ point: ['geometryPickPointOne', 'geometryPickPointOther'],
45
+ pointCloud: ['geometryPickPointCloudOne', 'geometryPickPointCloudOther'],
46
+ curve: ['geometryPickCurveOne', 'geometryPickCurveOther'],
47
+ line: ['geometryPickLineOne', 'geometryPickLineOther'],
48
+ plane: ['geometryPickPlaneOne', 'geometryPickPlaneOther'],
49
+ surface: ['geometryPickSurfaceOne', 'geometryPickSurfaceOther'],
50
+ polysurface: ['geometryPickPolysurfaceOne', 'geometryPickPolysurfaceOther'],
51
+ extrusion: ['geometryPickExtrusionOne', 'geometryPickExtrusionOther'],
52
+ mesh: ['geometryPickMeshOne', 'geometryPickMeshOther'],
53
+ subd: ['geometryPickSubdOne', 'geometryPickSubdOther'],
54
+ block: ['geometryPickBlockOne', 'geometryPickBlockOther']
55
+ };
56
+
57
+ const pickLabel = $derived.by(() => {
58
+ const [one, other] = kinds?.length === 1 ? KIND_LABELS[kinds[0]] : ANY_LABELS;
59
+ return t[atMost === 1 ? one : other];
60
+ });
61
+
62
+ // Rhino's command line prompts during the pick; the page keeps the button from being pressed
63
+ // twice and says where to look.
64
+ let picking = $state(false);
65
+ // The last pick or clear wins over what the host reported when the page loaded.
66
+ let local = $state<string | null | undefined>(undefined);
67
+ const shown = $derived(local !== undefined ? local : (summary ?? null));
68
+
69
+ $effect(() =>
70
+ watchSummary?.((held) => {
71
+ local = held;
72
+ })
73
+ );
74
+
75
+ async function pick(onPick: () => Promise<GeometryPickResult>) {
76
+ picking = true;
77
+ try {
78
+ const result = await onPick();
79
+ if (result.outcome === 'picked') local = result.summary;
80
+ else if (result.outcome === 'failed') onError?.(result.error);
81
+ } finally {
82
+ picking = false;
83
+ }
84
+ }
85
+
86
+ // The field label points at inputId: the button where there is one, else an <output>, which
87
+ // unlike a <span> can be labelled.
88
+ const readout = $derived(onPick ? 'span' : 'output');
89
+
90
+ function clear(onClear: () => void) {
91
+ local = null;
92
+ onClear();
93
+ }
94
+ </script>
95
+
96
+ <div class="gap-2 min-w-0 flex flex-wrap items-center">
97
+ {#if onPick}
98
+ <Button
99
+ id={inputId}
100
+ variant="outline"
101
+ size="sm"
102
+ onclick={() => pick(onPick)}
103
+ disabled={disabled || picking}
104
+ >
105
+ {shown ? t.geometryPickAgain : pickLabel}
106
+ </Button>
107
+ {/if}
108
+ {#if picking}
109
+ <span class="text-sm min-w-0 text-muted-foreground" role="status">{t.geometryPicking}</span>
110
+ {:else if shown}
111
+ <svelte:element
112
+ this={readout}
113
+ id={onPick ? undefined : inputId}
114
+ class="text-sm min-w-0 truncate text-foreground"
115
+ >
116
+ {shown}
117
+ </svelte:element>
118
+ {#if onClear}
119
+ <Button variant="ghost" size="sm" onclick={() => clear(onClear)} {disabled}>
120
+ {t.geometryClear}
121
+ </Button>
122
+ {/if}
123
+ {:else if !onPick}
124
+ <output id={inputId} class="text-sm min-w-0 text-muted-foreground"
125
+ >{t.geometryPickInPanel}</output
126
+ >
127
+ {/if}
128
+ </div>
@@ -0,0 +1,21 @@
1
+ import type { GeometryPickResult, PickKind } from '../../../contexts/hostContext.svelte';
2
+ interface Props {
3
+ inputId: string;
4
+ /** The kinds of object the input takes; one kind names the button after it. */
5
+ kinds?: readonly PickKind[];
6
+ /** 1 picks a single object. */
7
+ atMost?: number;
8
+ /** What the host reports the param holds, such as "2 curves"; absent until something is picked. */
9
+ summary?: string;
10
+ /** Absent where this page may not pick, such as a browser tab beside the Rhino panel. */
11
+ onPick?: () => Promise<GeometryPickResult>;
12
+ onClear?: () => void;
13
+ /** Subscribes to what the host holds after any page picks or clears; returns an unsubscribe. */
14
+ watchSummary?: (listener: (summary: string | null) => void) => () => void;
15
+ /** Shown when the pick fails; the host's own message. */
16
+ onError?: (message: string) => void;
17
+ disabled?: boolean;
18
+ }
19
+ declare const GeometryInput: import("svelte").Component<Props, {}, "">;
20
+ type GeometryInput = ReturnType<typeof GeometryInput>;
21
+ export default GeometryInput;
@@ -0,0 +1,29 @@
1
+ <script lang="ts">
2
+ import type { GlyphSource } from '../../../schema/option-display';
3
+ import { cn } from '../../../utils.js';
4
+
5
+ interface Props {
6
+ glyph: GlyphSource;
7
+ class?: string;
8
+ }
9
+
10
+ let { glyph, class: className }: Props = $props();
11
+ </script>
12
+
13
+ {#if glyph.kind === 'icon'}
14
+ <!-- Lucide's stroke defaults; the path is bound as an attribute, never parsed as markup. -->
15
+ <svg
16
+ viewBox="0 0 24 24"
17
+ fill="none"
18
+ stroke="currentColor"
19
+ stroke-width="2"
20
+ stroke-linecap="round"
21
+ stroke-linejoin="round"
22
+ aria-hidden="true"
23
+ class={cn('size-4 shrink-0', className)}
24
+ >
25
+ <path d={glyph.d} />
26
+ </svg>
27
+ {:else}
28
+ <img src={glyph.src} alt="" class={cn('size-4 shrink-0 rounded-sm object-cover', className)} />
29
+ {/if}
@@ -0,0 +1,8 @@
1
+ import type { GlyphSource } from '../../../schema/option-display';
2
+ interface Props {
3
+ glyph: GlyphSource;
4
+ class?: string;
5
+ }
6
+ declare const OptionGlyph: import("svelte").Component<Props, {}, "">;
7
+ type OptionGlyph = ReturnType<typeof OptionGlyph>;
8
+ export default OptionGlyph;
@@ -0,0 +1,44 @@
1
+ <script lang="ts">
2
+ import type { DropdownWidgetConfig, SupportedTypes, UISchema } from '@selvajs/schemas';
3
+ import * as ToggleGroup from '../../primitives/toggle-group';
4
+ import { optionGlyph } from '../../../schema/option-display';
5
+ import OptionGlyph from './OptionGlyph.svelte';
6
+
7
+ interface Props {
8
+ labelId: string;
9
+ value?: string;
10
+ config: DropdownWidgetConfig;
11
+ assets?: UISchema['assets'];
12
+ onChange: (value: SupportedTypes) => void;
13
+ disabled?: boolean;
14
+ }
15
+
16
+ let { labelId, value, config, assets, onChange, disabled = false }: Props = $props();
17
+
18
+ const options = $derived(Object.entries(config.options ?? {}));
19
+ const hasIcons = $derived(
20
+ options.some(([name]) => optionGlyph(config.optionMedia?.[name], assets))
21
+ );
22
+ </script>
23
+
24
+ <ToggleGroup.Root
25
+ type="single"
26
+ variant="outline"
27
+ size={hasIcons ? 'stacked' : 'default'}
28
+ class="w-full"
29
+ aria-labelledby={labelId}
30
+ value={value ?? ''}
31
+ onValueChange={(next) => {
32
+ // A single-select group emits '' when the active item is clicked again; keep the selection.
33
+ if (next) onChange(next);
34
+ }}
35
+ {disabled}
36
+ >
37
+ {#each options as [name, expr] (name)}
38
+ {@const glyph = optionGlyph(config.optionMedia?.[name], assets)}
39
+ <ToggleGroup.Item value={expr ?? name} class="min-w-0 flex-1" title={name}>
40
+ {#if glyph}<OptionGlyph {glyph} />{/if}
41
+ <span class="max-w-full truncate">{name}</span>
42
+ </ToggleGroup.Item>
43
+ {/each}
44
+ </ToggleGroup.Root>
@@ -0,0 +1,12 @@
1
+ import type { DropdownWidgetConfig, SupportedTypes, UISchema } from '@selvajs/schemas';
2
+ interface Props {
3
+ labelId: string;
4
+ value?: string;
5
+ config: DropdownWidgetConfig;
6
+ assets?: UISchema['assets'];
7
+ onChange: (value: SupportedTypes) => void;
8
+ disabled?: boolean;
9
+ }
10
+ declare const SegmentedInput: import("svelte").Component<Props, {}, "">;
11
+ type SegmentedInput = ReturnType<typeof SegmentedInput>;
12
+ export default SegmentedInput;
@@ -0,0 +1,80 @@
1
+ <script lang="ts">
2
+ import type { DropdownWidgetConfig, SupportedTypes, UISchema } from '@selvajs/schemas';
3
+ import { optionGlyph } from '../../../schema/option-display';
4
+ import OptionGlyph from './OptionGlyph.svelte';
5
+
6
+ interface Props {
7
+ labelId: string;
8
+ value?: string;
9
+ config: DropdownWidgetConfig;
10
+ assets?: UISchema['assets'];
11
+ onChange: (value: SupportedTypes) => void;
12
+ disabled?: boolean;
13
+ }
14
+
15
+ let { labelId, value, config, assets, onChange, disabled = false }: Props = $props();
16
+
17
+ const options = $derived(
18
+ Object.entries(config.options ?? {}).map(([name, expr]) => ({ name, value: expr ?? name }))
19
+ );
20
+ // With nothing selected the first tile takes focus, so the group is still reachable by Tab.
21
+ const focusIndex = $derived(
22
+ Math.max(
23
+ 0,
24
+ options.findIndex((o) => o.value === value)
25
+ )
26
+ );
27
+
28
+ let tiles: HTMLButtonElement[] = $state([]);
29
+
30
+ function onKeydown(event: KeyboardEvent, index: number) {
31
+ const step = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }[event.key];
32
+ if (!step) return;
33
+ event.preventDefault();
34
+ const next = (index + step + options.length) % options.length;
35
+ onChange(options[next].value);
36
+ tiles[next]?.focus();
37
+ }
38
+ </script>
39
+
40
+ <div role="radiogroup" aria-labelledby={labelId} class="gap-2 tile-grid grid">
41
+ {#each options as option, index (option.name)}
42
+ {@const glyph = optionGlyph(config.optionMedia?.[option.name], assets)}
43
+ {@const selected = option.value === value}
44
+ <button
45
+ bind:this={tiles[index]}
46
+ type="button"
47
+ role="radio"
48
+ aria-checked={selected}
49
+ tabindex={index === focusIndex ? 0 : -1}
50
+ {disabled}
51
+ onclick={() => onChange(option.value)}
52
+ onkeydown={(e) => onKeydown(e, index)}
53
+ class="gap-1.5 p-1.5 min-w-0 flex flex-col rounded-lg border text-left transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 {selected
54
+ ? 'border-primary bg-primary/5 ring-1 ring-primary'
55
+ : 'border-input hover:bg-accent/40'}"
56
+ >
57
+ <span
58
+ class="flex aspect-square w-full items-center justify-center overflow-hidden rounded-md bg-muted text-muted-foreground"
59
+ >
60
+ {#if glyph?.kind === 'image'}
61
+ <OptionGlyph {glyph} class="size-full" />
62
+ {:else if glyph}
63
+ <OptionGlyph {glyph} class="size-7" />
64
+ {:else}
65
+ <span class="text-lg font-medium" aria-hidden="true">{option.name.charAt(0)}</span>
66
+ {/if}
67
+ </span>
68
+ <span class="px-0.5 text-xs leading-tight truncate" class:font-medium={selected}>
69
+ {option.name}
70
+ </span>
71
+ </button>
72
+ {/each}
73
+ </div>
74
+
75
+ <style>
76
+ /* Two or three tiles a row as the panel narrows; a fixed count shrinks them to unreadable. */
77
+ .tile-grid {
78
+ grid-template-columns: repeat(auto-fill, minmax(5.5rem, 1fr));
79
+ }
80
+ </style>
@@ -0,0 +1,12 @@
1
+ import type { DropdownWidgetConfig, SupportedTypes, UISchema } from '@selvajs/schemas';
2
+ interface Props {
3
+ labelId: string;
4
+ value?: string;
5
+ config: DropdownWidgetConfig;
6
+ assets?: UISchema['assets'];
7
+ onChange: (value: SupportedTypes) => void;
8
+ disabled?: boolean;
9
+ }
10
+ declare const TileInput: import("svelte").Component<Props, {}, "">;
11
+ type TileInput = ReturnType<typeof TileInput>;
12
+ export default TileInput;
@@ -3,5 +3,8 @@ export { default as ChecklistInput } from './ChecklistInput.svelte';
3
3
  export { default as ColorInput } from './ColorInput.svelte';
4
4
  export { default as DropdownInput } from './DropdownInput.svelte';
5
5
  export { default as FileInput } from './FileInput.svelte';
6
+ export { default as GeometryInput } from './GeometryInput.svelte';
6
7
  export { default as NumberInput } from './NumberInput.svelte';
8
+ export { default as SegmentedInput } from './SegmentedInput.svelte';
7
9
  export { default as TextInput } from './TextInput.svelte';
10
+ export { default as TileInput } from './TileInput.svelte';
@@ -3,5 +3,8 @@ export { default as ChecklistInput } from './ChecklistInput.svelte';
3
3
  export { default as ColorInput } from './ColorInput.svelte';
4
4
  export { default as DropdownInput } from './DropdownInput.svelte';
5
5
  export { default as FileInput } from './FileInput.svelte';
6
+ export { default as GeometryInput } from './GeometryInput.svelte';
6
7
  export { default as NumberInput } from './NumberInput.svelte';
8
+ export { default as SegmentedInput } from './SegmentedInput.svelte';
7
9
  export { default as TextInput } from './TextInput.svelte';
10
+ export { default as TileInput } from './TileInput.svelte';
@@ -3,9 +3,16 @@
3
3
 
4
4
  interface Props {
5
5
  show: boolean;
6
+ /**
7
+ * `floating` overlays the bottom-left corner. `inline` is a compact chip the size of an
8
+ * icon button, for a narrow host whose corner holds its content and whose action row
9
+ * already reserves the chip's place. That host freezes while it solves, so the chip says
10
+ * Esc aborts: a frozen page can't offer a button.
11
+ */
12
+ placement?: 'floating' | 'inline';
6
13
  }
7
14
 
8
- let { show }: Props = $props();
15
+ let { show, placement = 'floating' }: Props = $props();
9
16
 
10
17
  const locale = getLocaleContext();
11
18
  const t = $derived(locale.messages);
@@ -13,8 +20,26 @@
13
20
 
14
21
  <!-- The live region stays mounted: a screen reader only announces text that changes inside a
15
22
  region it already knows about, so one created together with its text says nothing. -->
16
- <div role="status" aria-live="polite" aria-atomic="true">
17
- {#if show}
23
+ <div
24
+ role="status"
25
+ aria-live="polite"
26
+ aria-atomic="true"
27
+ class={placement === 'inline' ? 'h-10 gap-3 min-w-0 flex items-center' : undefined}
28
+ >
29
+ {#if show && placement === 'inline'}
30
+ <div
31
+ class="gap-2 px-3 h-10 text-sm font-semibold flex shrink-0 items-center rounded-md bg-primary text-primary-foreground"
32
+ >
33
+ <div
34
+ class="h-3 w-3 animate-spin rounded-full border-2 border-primary-foreground border-t-transparent"
35
+ aria-hidden="true"
36
+ ></div>
37
+ {t.solvingEllipsis}
38
+ </div>
39
+ <span class="text-xs min-w-0 truncate text-muted-foreground" data-testid="solve-esc-hint">
40
+ {t.solveMessagesEscHint}
41
+ </span>
42
+ {:else if show}
18
43
  <div
19
44
  class="bottom-6 left-6 gap-3 px-5 py-3 shadow-xl animate-in fade-in slide-in-from-left fixed z-50 flex items-center rounded-lg border border-primary/20 bg-primary text-primary-foreground duration-200"
20
45
  >
@@ -1,5 +1,12 @@
1
1
  interface Props {
2
2
  show: boolean;
3
+ /**
4
+ * `floating` overlays the bottom-left corner. `inline` is a compact chip the size of an
5
+ * icon button, for a narrow host whose corner holds its content and whose action row
6
+ * already reserves the chip's place. That host freezes while it solves, so the chip says
7
+ * Esc aborts: a frozen page can't offer a button.
8
+ */
9
+ placement?: 'floating' | 'inline';
3
10
  }
4
11
  declare const SolvingIndicator: import("svelte").Component<Props, {}, "">;
5
12
  type SolvingIndicator = ReturnType<typeof SolvingIndicator>;
@@ -12,6 +12,9 @@
12
12
  'bg-destructive shadow-xs hover:bg-destructive/90 dark:bg-destructive/60 text-destructive-foreground',
13
13
  outline:
14
14
  'bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border',
15
+ // Outline with an opaque dark fill: the translucent one washes out over the light 3D canvas.
16
+ overlay:
17
+ 'bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input border',
15
18
  secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
16
19
  ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
17
20
  dashed:
@@ -6,6 +6,7 @@ export declare const buttonVariants: import("tailwind-variants").TVReturnType<{
6
6
  default: "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90";
7
7
  destructive: "bg-destructive shadow-xs hover:bg-destructive/90 dark:bg-destructive/60 text-destructive-foreground";
8
8
  outline: "bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border";
9
+ overlay: "bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input border";
9
10
  secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80";
10
11
  ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50";
11
12
  dashed: "border border-dashed border-input bg-background text-muted-foreground hover:bg-muted hover:text-foreground";
@@ -25,6 +26,7 @@ export declare const buttonVariants: import("tailwind-variants").TVReturnType<{
25
26
  default: "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90";
26
27
  destructive: "bg-destructive shadow-xs hover:bg-destructive/90 dark:bg-destructive/60 text-destructive-foreground";
27
28
  outline: "bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border";
29
+ overlay: "bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input border";
28
30
  secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80";
29
31
  ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50";
30
32
  dashed: "border border-dashed border-input bg-background text-muted-foreground hover:bg-muted hover:text-foreground";
@@ -44,6 +46,7 @@ export declare const buttonVariants: import("tailwind-variants").TVReturnType<{
44
46
  default: "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90";
45
47
  destructive: "bg-destructive shadow-xs hover:bg-destructive/90 dark:bg-destructive/60 text-destructive-foreground";
46
48
  outline: "bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border";
49
+ overlay: "bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input border";
47
50
  secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80";
48
51
  ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50";
49
52
  dashed: "border border-dashed border-input bg-background text-muted-foreground hover:bg-muted hover:text-foreground";
@@ -12,7 +12,9 @@
12
12
  default:
13
13
  'h-8 min-w-8 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
14
14
  sm: "h-7 min-w-7 rounded-md px-2.5 text-xs has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
15
- lg: 'h-9 min-w-9 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2'
15
+ lg: 'h-9 min-w-9 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
16
+ // Icon over a short label, so a row of options still fits a phone-width column.
17
+ stacked: 'min-h-12 min-w-0 flex-col gap-1 px-1 py-1.5 text-xs'
16
18
  }
17
19
  },
18
20
  defaultVariants: {