@selvajs/ui 6.6.0-beta.4 → 6.6.0-beta.6

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 (114) hide show
  1. package/dist/components/ErrorScreen.svelte +6 -2
  2. package/dist/components/compute/AppLayout.svelte +163 -74
  3. package/dist/components/compute/AppLayout.svelte.d.ts +11 -0
  4. package/dist/components/compute/ComputeApp.svelte +40 -20
  5. package/dist/components/compute/ComputeApp.svelte.d.ts +19 -2
  6. package/dist/components/compute/ComputeMessagesDialog.svelte +30 -40
  7. package/dist/components/compute/ParameterPresetManager.svelte +7 -44
  8. package/dist/components/compute/ParameterPresetManager.svelte.d.ts +4 -5
  9. package/dist/components/compute/SolveMessages.svelte +30 -37
  10. package/dist/components/compute/SolveMessages.svelte.d.ts +0 -12
  11. package/dist/components/layout/PageFooter.svelte +21 -11
  12. package/dist/components/preview/ChartOutput.svelte +17 -8
  13. package/dist/components/preview/Group.svelte +33 -18
  14. package/dist/components/preview/HelpButton.svelte +4 -2
  15. package/dist/components/preview/ImageOutput.svelte +427 -68
  16. package/dist/components/preview/InputControl.svelte +11 -2
  17. package/dist/components/preview/OutputDisplay.svelte +17 -12
  18. package/dist/components/preview/TabContent.svelte +3 -0
  19. package/dist/components/preview/inputs/ColorInput.svelte +3 -1
  20. package/dist/components/preview/inputs/ColorInput.svelte.d.ts +1 -0
  21. package/dist/components/preview/inputs/DropdownInput.svelte +4 -1
  22. package/dist/components/preview/inputs/FileInput.svelte +50 -34
  23. package/dist/components/preview/inputs/NumberInput.svelte +92 -10
  24. package/dist/components/preview/inputs/NumberInput.svelte.d.ts +2 -0
  25. package/dist/components/preview/inputs/TextInput.svelte +8 -3
  26. package/dist/components/primitives/CalculateButton.svelte +49 -23
  27. package/dist/components/primitives/CalculateButton.svelte.d.ts +3 -0
  28. package/dist/components/primitives/SolvingIndicator.svelte +21 -17
  29. package/dist/components/primitives/badge/badge.svelte +2 -2
  30. package/dist/components/primitives/badge/badge.svelte.d.ts +4 -4
  31. package/dist/components/primitives/button/button.svelte +2 -2
  32. package/dist/components/primitives/button/button.svelte.d.ts +4 -4
  33. package/dist/components/primitives/checkbox/checkbox.svelte +1 -1
  34. package/dist/components/primitives/dialog/dialog-content.svelte +6 -2
  35. package/dist/components/primitives/input/input.svelte +2 -2
  36. package/dist/components/primitives/input-group/input-group.svelte +1 -1
  37. package/dist/components/primitives/mode-toggle/mode-toggle.svelte +4 -1
  38. package/dist/components/primitives/scroll-area/scroll-area.svelte +1 -1
  39. package/dist/components/primitives/search/search.svelte +1 -1
  40. package/dist/components/primitives/select/select-trigger.svelte +1 -1
  41. package/dist/components/primitives/slider/slider.svelte +7 -2
  42. package/dist/components/primitives/slider/slider.svelte.d.ts +5 -1
  43. package/dist/components/primitives/switch/switch.svelte +1 -1
  44. package/dist/components/primitives/tabs/tabs-trigger.svelte +1 -1
  45. package/dist/components/primitives/tabs/tabs-trigger.svelte.d.ts +1 -1
  46. package/dist/components/primitives/textarea/textarea.svelte +1 -1
  47. package/dist/components/primitives/toggle/toggle.svelte +1 -1
  48. package/dist/components/primitives/toggle/toggle.svelte.d.ts +1 -1
  49. package/dist/components/viewer/Viewer.svelte +20 -17
  50. package/dist/compute/run-panel-action.d.ts +8 -0
  51. package/dist/compute/run-panel-action.js +12 -0
  52. package/dist/compute/useSolveSession.svelte.js +3 -0
  53. package/dist/i18n/localeContext.svelte.d.ts +10 -3
  54. package/dist/i18n/localeContext.svelte.js +27 -5
  55. package/dist/i18n/messages.d.ts +115 -2
  56. package/dist/i18n/messages.js +199 -11
  57. package/dist/index.d.ts +1 -1
  58. package/dist/public.d.ts +1 -1
  59. package/dist/schema/visibility-rules.js +21 -0
  60. package/dist/styles/base.css +9 -6
  61. package/dist/types/actionButton.d.ts +17 -3
  62. package/dist/types/presetLabels.d.ts +3 -2
  63. package/dist/types/presetLabels.js +9 -8
  64. package/dist/utils/loadScript.d.ts +2 -0
  65. package/dist/utils/loadScript.js +2 -0
  66. package/package.json +8 -8
  67. package/src/lib/components/ErrorScreen.svelte +6 -2
  68. package/src/lib/components/compute/AppLayout.svelte +163 -74
  69. package/src/lib/components/compute/ComputeApp.svelte +40 -20
  70. package/src/lib/components/compute/ComputeMessagesDialog.svelte +30 -40
  71. package/src/lib/components/compute/ParameterPresetManager.svelte +7 -44
  72. package/src/lib/components/compute/SolveMessages.svelte +30 -37
  73. package/src/lib/components/layout/PageFooter.svelte +21 -11
  74. package/src/lib/components/preview/ChartOutput.svelte +17 -8
  75. package/src/lib/components/preview/Group.svelte +33 -18
  76. package/src/lib/components/preview/HelpButton.svelte +4 -2
  77. package/src/lib/components/preview/ImageOutput.svelte +427 -68
  78. package/src/lib/components/preview/InputControl.svelte +11 -2
  79. package/src/lib/components/preview/OutputDisplay.svelte +17 -12
  80. package/src/lib/components/preview/TabContent.svelte +3 -0
  81. package/src/lib/components/preview/inputs/ColorInput.svelte +3 -1
  82. package/src/lib/components/preview/inputs/DropdownInput.svelte +4 -1
  83. package/src/lib/components/preview/inputs/FileInput.svelte +50 -34
  84. package/src/lib/components/preview/inputs/NumberInput.svelte +92 -10
  85. package/src/lib/components/preview/inputs/TextInput.svelte +8 -3
  86. package/src/lib/components/primitives/CalculateButton.svelte +49 -23
  87. package/src/lib/components/primitives/SolvingIndicator.svelte +21 -17
  88. package/src/lib/components/primitives/badge/badge.svelte +2 -2
  89. package/src/lib/components/primitives/button/button.svelte +2 -2
  90. package/src/lib/components/primitives/checkbox/checkbox.svelte +1 -1
  91. package/src/lib/components/primitives/dialog/dialog-content.svelte +6 -2
  92. package/src/lib/components/primitives/input/input.svelte +2 -2
  93. package/src/lib/components/primitives/input-group/input-group.svelte +1 -1
  94. package/src/lib/components/primitives/mode-toggle/mode-toggle.svelte +4 -1
  95. package/src/lib/components/primitives/scroll-area/scroll-area.svelte +1 -1
  96. package/src/lib/components/primitives/search/search.svelte +1 -1
  97. package/src/lib/components/primitives/select/select-trigger.svelte +1 -1
  98. package/src/lib/components/primitives/slider/slider.svelte +7 -2
  99. package/src/lib/components/primitives/switch/switch.svelte +1 -1
  100. package/src/lib/components/primitives/tabs/tabs-trigger.svelte +1 -1
  101. package/src/lib/components/primitives/textarea/textarea.svelte +1 -1
  102. package/src/lib/components/primitives/toggle/toggle.svelte +1 -1
  103. package/src/lib/components/viewer/Viewer.svelte +20 -17
  104. package/src/lib/compute/run-panel-action.ts +16 -0
  105. package/src/lib/compute/useSolveSession.svelte.ts +3 -0
  106. package/src/lib/i18n/localeContext.svelte.ts +40 -5
  107. package/src/lib/i18n/messages.ts +351 -13
  108. package/src/lib/index.ts +1 -0
  109. package/src/lib/public.ts +1 -0
  110. package/src/lib/schema/visibility-rules.ts +23 -0
  111. package/src/lib/styles/base.css +9 -6
  112. package/src/lib/types/actionButton.ts +16 -3
  113. package/src/lib/types/presetLabels.ts +13 -11
  114. package/src/lib/utils/loadScript.ts +3 -0
@@ -1,8 +1,20 @@
1
1
  <script lang="ts">
2
2
  import type { OutputImageLayoutItem } from '@selvajs/schemas';
3
3
  import type { FileData } from '@selvajs/compute/core';
4
- import { Download, Maximize, Minimize } from '@lucide/svelte';
4
+ import {
5
+ ChevronLeft,
6
+ ChevronRight,
7
+ Download,
8
+ LoaderCircle,
9
+ Maximize,
10
+ Minimize,
11
+ Scan,
12
+ ZoomIn,
13
+ ZoomOut
14
+ } from '@lucide/svelte';
5
15
  import { downloadFiles, isFileData, MIME_BY_EXT } from '../../utils/file-download';
16
+ import { getLocaleContext } from '../../i18n/localeContext.svelte';
17
+ import { formatMessage } from '../../i18n/messages';
6
18
 
7
19
  interface Props {
8
20
  item: OutputImageLayoutItem;
@@ -11,29 +23,42 @@
11
23
 
12
24
  let { item, value }: Props = $props();
13
25
 
26
+ const locale = getLocaleContext();
27
+ const t = $derived(locale.messages);
28
+
14
29
  const SUPPORTED_EXTS = new Set(['.png', '.jpg', '.jpeg', '.webp', '.gif', '.svg']);
30
+ const ZOOM_STEP = 1.5;
31
+ const MAX_ZOOM_OVER_NATURAL = 32;
32
+ // Above this many screen pixels per image pixel, smoothing hides exactly the detail the user
33
+ // zoomed in to see, so raster images switch to nearest-neighbour.
34
+ const PIXELATED_FROM_SCALE = 2;
35
+
36
+ const extOf = (f: FileData) => (f.fileType ?? '').toLowerCase();
15
37
 
16
- const file = $derived.by<FileData | null>(() => {
17
- if (Array.isArray(value)) return value.find(isFileData) ?? null;
18
- return isFileData(value) ? value : null;
38
+ const files = $derived.by<FileData[]>(() => {
39
+ if (Array.isArray(value)) return value.filter(isFileData);
40
+ return isFileData(value) ? [value] : [];
19
41
  });
42
+ const images = $derived(files.filter((f) => SUPPORTED_EXTS.has(extOf(f))));
43
+ const unsupported = $derived(images.length === 0 ? files[0] : undefined);
20
44
 
21
- const ext = $derived(file ? (file.fileType ?? '').toLowerCase() : '');
22
- const isSupported = $derived(SUPPORTED_EXTS.has(ext));
45
+ let index = $state(0);
46
+ const currentIndex = $derived(Math.max(0, Math.min(index, images.length - 1)));
47
+ const current = $derived(images[currentIndex] ?? null);
48
+ const isSvg = $derived(current ? extOf(current) === '.svg' : false);
23
49
 
24
50
  // Binary formats (PNG/JPG/WEBP/GIF) arrive base64; SVG arrives as plain UTF-8 XML
25
51
  // and must be percent-encoded into the data URL.
26
52
  // Rendering through <img src> rather than inlining the SVG is a security choice:
27
53
  // browsers disable scripting in image-context SVGs, so untrusted SVG cannot
28
54
  // execute against the host. Don't switch to {@html} or an inline <svg>.
29
- const dataUrl = $derived.by(() => {
30
- if (!file || !isSupported) return null;
31
- const mime = MIME_BY_EXT[ext] ?? 'application/octet-stream';
32
- if (file.isBase64Encoded) {
33
- return `data:${mime};base64,${file.data}`;
34
- }
55
+ function toDataUrl(file: FileData): string {
56
+ const mime = MIME_BY_EXT[extOf(file)] ?? 'application/octet-stream';
57
+ if (file.isBase64Encoded) return `data:${mime};base64,${file.data}`;
35
58
  return `data:${mime};utf8,${encodeURIComponent(file.data)}`;
36
- });
59
+ }
60
+
61
+ const dataUrl = $derived(current ? toDataUrl(current) : null);
37
62
 
38
63
  const cfg = $derived(item.config ?? {});
39
64
  const allowDownload = $derived(cfg.allowDownload ?? true);
@@ -41,103 +66,437 @@
41
66
  const backgroundColor = $derived(cfg.backgroundColor);
42
67
 
43
68
  let wrapperEl = $state<HTMLDivElement | null>(null);
69
+ let stageEl = $state<HTMLDivElement | null>(null);
44
70
  let isFullscreen = $state(false);
45
71
  let downloading = $state(false);
46
72
  let error = $state<string | null>(null);
73
+ let decodeFailed = $state(false);
74
+
75
+ // View state. `scale` is screen px per image px; (x, y) is the image's top-left in stage px.
76
+ // The image is sized with width/height rather than a CSS transform so the browser re-rasters
77
+ // SVGs and resamples bitmaps at the displayed size instead of stretching a cached layer.
78
+ let stageW = $state(0);
79
+ let stageH = $state(0);
80
+ let naturalW = $state(0);
81
+ let naturalH = $state(0);
82
+ let scale = $state(1);
83
+ let x = $state(0);
84
+ let y = $state(0);
85
+ let fitted = $state(true);
86
+
87
+ const ready = $derived(naturalW > 0 && naturalH > 0 && stageW > 0 && stageH > 0);
88
+ const fitScale = $derived(ready ? Math.min(stageW / naturalW, stageH / naturalH) : 1);
89
+ const minScale = $derived(Math.min(fitScale, 1));
90
+ const maxScale = $derived(Math.max(fitScale, 1) * MAX_ZOOM_OVER_NATURAL);
91
+ const zoomPercent = $derived(Math.round(scale * 100));
92
+ const pannable = $derived(naturalW * scale > stageW + 1 || naturalH * scale > stageH + 1);
93
+ const pixelated = $derived(!isSvg && scale >= PIXELATED_FROM_SCALE);
94
+
95
+ function clampAxis(pos: number, size: number, stage: number) {
96
+ if (size <= stage) return (stage - size) / 2;
97
+ return Math.min(0, Math.max(stage - size, pos));
98
+ }
99
+
100
+ function applyView(nextScale: number, nextX: number, nextY: number) {
101
+ scale = nextScale;
102
+ x = clampAxis(nextX, naturalW * nextScale, stageW);
103
+ y = clampAxis(nextY, naturalH * nextScale, stageH);
104
+ }
105
+
106
+ function fit() {
107
+ fitted = true;
108
+ applyView(fitScale, 0, 0);
109
+ }
110
+
111
+ function zoomTo(target: number, px = stageW / 2, py = stageH / 2) {
112
+ const next = Math.min(maxScale, Math.max(minScale, target));
113
+ const ratio = next / scale;
114
+ fitted = Math.abs(next - fitScale) < 1e-3;
115
+ applyView(next, px - (px - x) * ratio, py - (py - y) * ratio);
116
+ }
117
+
118
+ // Refit on stage resize (fullscreen, layout changes) unless the user has zoomed in.
119
+ $effect(() => {
120
+ if (!stageEl) return;
121
+ const ro = new ResizeObserver(([entry]) => {
122
+ stageW = entry.contentRect.width;
123
+ stageH = entry.contentRect.height;
124
+ if (fitted) fit();
125
+ else applyView(Math.max(scale, minScale), x, y);
126
+ });
127
+ ro.observe(stageEl);
128
+ return () => ro.disconnect();
129
+ });
130
+
131
+ function onImageLoad(e: Event) {
132
+ const img = e.currentTarget as HTMLImageElement;
133
+ decodeFailed = false;
134
+ // A viewBox-only SVG can report 0×0; fall back to the browser's default replaced-element size.
135
+ const w = img.naturalWidth || 300;
136
+ const h = img.naturalHeight || 150;
137
+ // Keep the zoom across solves when the new image has the same size: the user is usually
138
+ // watching one detail change as they move a slider.
139
+ const sameSize = w === naturalW && h === naturalH;
140
+ naturalW = w;
141
+ naturalH = h;
142
+ if (!sameSize || fitted) fit();
143
+ }
144
+
145
+ // ---- pointer: drag to pan, two fingers to pinch -------------------------------------------
146
+ // Not reactive state: only read inside handlers.
147
+ const pointers: Record<number, { x: number; y: number }> = {};
148
+ const pointerList = () => Object.values(pointers);
149
+ let dragging = $state(false);
150
+ let pinchDistance = 0;
151
+
152
+ function stagePoint(e: { clientX: number; clientY: number }) {
153
+ const r = stageEl!.getBoundingClientRect();
154
+ return { x: e.clientX - r.left, y: e.clientY - r.top };
155
+ }
156
+
157
+ function onPointerDown(e: PointerEvent) {
158
+ if (e.button !== 0 || (e.target as Element).closest('button')) return;
159
+ stageEl?.setPointerCapture(e.pointerId);
160
+ pointers[e.pointerId] = stagePoint(e);
161
+ const count = pointerList().length;
162
+ dragging = count === 1 && pannable;
163
+ if (count === 2) {
164
+ const [a, b] = pointerList();
165
+ pinchDistance = Math.hypot(a.x - b.x, a.y - b.y);
166
+ }
167
+ }
168
+
169
+ function onPointerMove(e: PointerEvent) {
170
+ const prev = pointers[e.pointerId];
171
+ if (!prev) return;
172
+ const p = stagePoint(e);
173
+ pointers[e.pointerId] = p;
174
+
175
+ if (pointerList().length === 2) {
176
+ const [a, b] = pointerList();
177
+ const distance = Math.hypot(a.x - b.x, a.y - b.y);
178
+ if (pinchDistance > 0)
179
+ zoomTo(scale * (distance / pinchDistance), (a.x + b.x) / 2, (a.y + b.y) / 2);
180
+ pinchDistance = distance;
181
+ } else if (dragging) {
182
+ fitted = false;
183
+ applyView(scale, x + p.x - prev.x, y + p.y - prev.y);
184
+ }
185
+ }
186
+
187
+ function onPointerUp(e: PointerEvent) {
188
+ delete pointers[e.pointerId];
189
+ const count = pointerList().length;
190
+ if (count < 2) pinchDistance = 0;
191
+ if (count === 0) dragging = false;
192
+ }
193
+
194
+ function onDoubleClick(e: MouseEvent) {
195
+ if (!fitted) return fit();
196
+ const p = stagePoint(e);
197
+ // Large images go to 1:1 (true pixels); small ones are already ≥1:1 when fitted, so magnify.
198
+ zoomTo(fitScale < 0.8 ? 1 : fitScale * 3, p.x, p.y);
199
+ }
200
+
201
+ // ---- wheel: zoom on Ctrl/⌘ (and trackpad pinch, which sends ctrlKey) or in fullscreen -------
202
+ let showWheelHint = $state(false);
203
+ let wheelHintTimeout: ReturnType<typeof setTimeout>;
204
+
205
+ // Registered by hand: preventDefault needs a non-passive listener.
206
+ $effect(() => {
207
+ if (!stageEl) return;
208
+ const el = stageEl;
209
+ function onWheel(e: WheelEvent) {
210
+ if (!ready) return;
211
+ if (!(e.ctrlKey || e.metaKey || isFullscreen)) {
212
+ // Let the page scroll, but say how to zoom.
213
+ showWheelHint = true;
214
+ clearTimeout(wheelHintTimeout);
215
+ wheelHintTimeout = setTimeout(() => (showWheelHint = false), 1200);
216
+ return;
217
+ }
218
+ e.preventDefault();
219
+ showWheelHint = false;
220
+ const delta = e.deltaMode === 1 ? e.deltaY * 16 : e.deltaY;
221
+ const p = stagePoint(e);
222
+ zoomTo(scale * Math.exp(-delta * 0.002), p.x, p.y);
223
+ }
224
+ el.addEventListener('wheel', onWheel, { passive: false });
225
+ return () => {
226
+ el.removeEventListener('wheel', onWheel);
227
+ clearTimeout(wheelHintTimeout);
228
+ };
229
+ });
230
+
231
+ function onKeyDown(e: KeyboardEvent) {
232
+ const actions: Record<string, () => void> = {
233
+ '+': () => zoomTo(scale * ZOOM_STEP),
234
+ '=': () => zoomTo(scale * ZOOM_STEP),
235
+ '-': () => zoomTo(scale / ZOOM_STEP),
236
+ '0': fit,
237
+ '1': () => zoomTo(1),
238
+ ArrowLeft: () => step(-1),
239
+ ArrowRight: () => step(1)
240
+ };
241
+ if (allowFullscreen) actions.f = toggleFullscreen;
242
+ const action = actions[e.key];
243
+ if (!action) return;
244
+ e.preventDefault();
245
+ action();
246
+ }
247
+
248
+ function step(delta: number) {
249
+ if (images.length < 2) return;
250
+ select((currentIndex + delta + images.length) % images.length);
251
+ }
252
+
253
+ function select(i: number) {
254
+ if (i === currentIndex) return;
255
+ index = i;
256
+ // A different image in the set: start fitted rather than carrying over the old zoom.
257
+ naturalW = 0;
258
+ naturalH = 0;
259
+ fitted = true;
260
+ }
47
261
 
48
262
  function toggleFullscreen() {
49
263
  if (!wrapperEl) return;
50
264
  if (!document.fullscreenElement) {
51
265
  wrapperEl.requestFullscreen().catch((err) => {
52
- error = `Fullscreen unavailable: ${err.message}`;
266
+ error = formatMessage(t.fullscreenUnavailable, { error: err.message });
53
267
  });
54
268
  } else {
55
269
  document.exitFullscreen().catch((err) => {
56
- error = `Exit fullscreen failed: ${err.message}`;
270
+ error = formatMessage(t.exitFullscreenFailed, { error: err.message });
57
271
  });
58
272
  }
59
273
  }
60
274
 
61
275
  $effect(() => {
62
276
  function onFullscreenChange() {
63
- isFullscreen = !!document.fullscreenElement;
277
+ isFullscreen = document.fullscreenElement === wrapperEl;
64
278
  }
65
279
  document.addEventListener('fullscreenchange', onFullscreenChange);
66
280
  return () => document.removeEventListener('fullscreenchange', onFullscreenChange);
67
281
  });
68
282
 
69
283
  async function handleDownload() {
70
- if (!file) return;
284
+ if (!current) return;
71
285
  downloading = true;
72
286
  error = null;
73
287
  try {
74
- await downloadFiles(file, file.fileName);
288
+ await downloadFiles(current, current.fileName);
75
289
  } catch (e) {
76
- error = e instanceof Error ? e.message : 'Download failed';
290
+ error = e instanceof Error ? e.message : t.downloadFailed;
77
291
  } finally {
78
292
  downloading = false;
79
293
  }
80
294
  }
295
+
296
+ const toolButton =
297
+ 'flex h-7 min-w-7 items-center justify-center rounded px-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-40';
81
298
  </script>
82
299
 
83
- <div
84
- bind:this={wrapperEl}
85
- class="group rounded relative overflow-hidden border border-border {isFullscreen
86
- ? 'bg-background'
87
- : ''}"
88
- >
89
- {#if error}
90
- <div class="px-4 py-3 text-sm text-destructive">{error}</div>
91
- {:else if !file}
92
- <div class="px-4 py-8 text-sm text-center text-muted-foreground">Waiting for image...</div>
93
- {:else if !isSupported}
94
- <div class="px-4 py-3 text-sm text-destructive">
95
- Unsupported image format: <span class="font-mono">{ext || '(unknown)'}</span>. Supported:
96
- .png, .jpg, .jpeg, .webp, .gif, .svg
97
- </div>
98
- {:else}
99
- <div
100
- class="w-full bg-(--image-bg) {isFullscreen ? 'h-screen' : 'h-95'}"
101
- style:--image-bg={backgroundColor}
102
- >
103
- <img
104
- src={dataUrl}
105
- alt={item.displayName ?? file.fileName}
106
- class="h-full w-full object-contain"
107
- />
108
- </div>
109
-
110
- {#if allowDownload || allowFullscreen}
300
+ <!-- The layout hides this output until a solve returns a file, so there is no empty state here. -->
301
+ {#if error || unsupported || current}
302
+ <div
303
+ bind:this={wrapperEl}
304
+ class="rounded flex flex-col overflow-hidden border border-border bg-background {isFullscreen
305
+ ? 'h-screen'
306
+ : ''}"
307
+ >
308
+ {#if error}
309
+ <div class="px-4 py-3 text-sm text-destructive">{error}</div>
310
+ {:else if unsupported}
311
+ <div class="px-4 py-3 text-sm text-destructive">
312
+ {formatMessage(t.imageUnsupported, {
313
+ ext: extOf(unsupported) || t.imageUnknownFormat,
314
+ formats: [...SUPPORTED_EXTS].join(', ')
315
+ })}
316
+ </div>
317
+ {:else if current}
318
+ <!-- The stage is a pan/zoom surface with its own keyboard shortcuts; no native element fits. -->
319
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex, a11y_no_noninteractive_element_interactions -->
111
320
  <div
112
- class="gap-1 right-2 top-2 absolute z-10 flex items-center opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100 {isFullscreen
113
- ? 'opacity-100'
114
- : ''}"
321
+ bind:this={stageEl}
322
+ role="application"
323
+ aria-roledescription={t.imageViewer}
324
+ aria-label={item.displayName ?? current.fileName}
325
+ tabindex="0"
326
+ class="relative w-full overflow-hidden outline-none select-none focus-visible:ring-2 focus-visible:ring-ring
327
+ {isFullscreen ? 'flex-1' : 'h-95'}
328
+ {dragging ? 'cursor-grabbing' : pannable ? 'cursor-grab' : 'cursor-zoom-in'}
329
+ {fitted ? 'touch-pan-y' : 'touch-none'}
330
+ {backgroundColor ? 'bg-(--image-bg)' : 'checkerboard'}"
331
+ style:--image-bg={backgroundColor}
332
+ onpointerdown={onPointerDown}
333
+ onpointermove={onPointerMove}
334
+ onpointerup={onPointerUp}
335
+ onpointercancel={onPointerUp}
336
+ ondblclick={onDoubleClick}
337
+ onkeydown={onKeyDown}
115
338
  >
116
- {#if allowDownload && file}
339
+ {#if decodeFailed}
340
+ <div class="inset-0 text-sm absolute flex items-center justify-center text-destructive">
341
+ {formatMessage(t.imageDecodeFailed, { file: `${current.fileName}${current.fileType}` })}
342
+ </div>
343
+ {:else}
344
+ {#if !ready}
345
+ <div
346
+ class="inset-0 gap-2 text-sm absolute flex items-center justify-center text-muted-foreground"
347
+ >
348
+ <LoaderCircle size={16} class="animate-spin" />
349
+ {t.imageLoading}
350
+ </div>
351
+ {/if}
352
+ <img
353
+ src={dataUrl}
354
+ alt=""
355
+ draggable="false"
356
+ onload={onImageLoad}
357
+ onerror={() => (decodeFailed = true)}
358
+ class="absolute top-(--img-y) left-(--img-x) h-(--img-h) w-(--img-w) max-w-none
359
+ {pixelated ? 'pixelated' : ''}
360
+ {ready ? '' : 'invisible'}"
361
+ style:--img-x="{x}px"
362
+ style:--img-y="{y}px"
363
+ style:--img-w="{naturalW * scale}px"
364
+ style:--img-h="{naturalH * scale}px"
365
+ />
366
+ {/if}
367
+
368
+ {#if images.length > 1}
117
369
  <button
118
- onclick={handleDownload}
119
- disabled={downloading}
120
- title="Download image"
121
- class="rounded p-1.5 backdrop-blur-sm flex items-center border border-border bg-background/80 text-foreground transition-colors hover:bg-background disabled:cursor-not-allowed disabled:opacity-50"
370
+ onclick={() => step(-1)}
371
+ title="{t.previousImage} (←)"
372
+ aria-label={t.previousImage}
373
+ class="left-2 p-1.5 backdrop-blur-sm absolute top-1/2 -translate-y-1/2 rounded-full border border-border bg-background/80 text-foreground hover:bg-background"
122
374
  >
123
- <Download size={14} />
375
+ <ChevronLeft size={16} />
376
+ </button>
377
+ <button
378
+ onclick={() => step(1)}
379
+ title="{t.nextImage} (→)"
380
+ aria-label={t.nextImage}
381
+ class="right-2 p-1.5 backdrop-blur-sm absolute top-1/2 -translate-y-1/2 rounded-full border border-border bg-background/80 text-foreground hover:bg-background"
382
+ >
383
+ <ChevronRight size={16} />
124
384
  </button>
125
385
  {/if}
126
386
 
127
- {#if allowFullscreen}
387
+ <div
388
+ class="px-2.5 py-1 text-xs bottom-2 rounded pointer-events-none absolute left-1/2 -translate-x-1/2 bg-foreground/80 text-background transition-opacity {showWheelHint
389
+ ? 'opacity-100'
390
+ : 'opacity-0'}"
391
+ >
392
+ {t.zoomHint}
393
+ </div>
394
+ </div>
395
+
396
+ {#if images.length > 1}
397
+ <div class="gap-1.5 p-1.5 flex overflow-x-auto border-t border-border">
398
+ {#each images as image, i (i)}
399
+ <button
400
+ onclick={() => select(i)}
401
+ title="{image.fileName}{image.fileType}"
402
+ class="h-12 w-16 rounded shrink-0 overflow-hidden border-2 bg-muted transition-colors {i ===
403
+ currentIndex
404
+ ? 'border-primary'
405
+ : 'border-transparent opacity-70 hover:opacity-100'}"
406
+ >
407
+ <img src={toDataUrl(image)} alt="" class="h-full w-full object-contain" />
408
+ </button>
409
+ {/each}
410
+ </div>
411
+ {/if}
412
+
413
+ <div class="gap-2 px-2 py-1 text-xs flex items-center border-t border-border">
414
+ <div class="min-w-0 gap-1.5 flex flex-1 items-center text-muted-foreground">
415
+ <span class="truncate text-foreground">{current.fileName}{current.fileType}</span>
416
+ {#if ready}
417
+ <span class="shrink-0 tabular-nums">{naturalW}×{naturalH}</span>
418
+ {/if}
419
+ </div>
420
+
421
+ <div class="gap-0.5 flex shrink-0 items-center">
128
422
  <button
129
- onclick={toggleFullscreen}
130
- title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
131
- class="rounded p-1.5 backdrop-blur-sm flex items-center border border-border bg-background/80 text-foreground transition-colors hover:bg-background"
423
+ class={toolButton}
424
+ onclick={() => zoomTo(scale / ZOOM_STEP)}
425
+ disabled={!ready || scale <= minScale + 1e-3}
426
+ title="{t.zoomOut} (−)"
427
+ aria-label={t.zoomOut}
132
428
  >
133
- {#if isFullscreen}
134
- <Minimize size={14} />
135
- {:else}
136
- <Maximize size={14} />
137
- {/if}
429
+ <ZoomOut size={14} />
138
430
  </button>
139
- {/if}
431
+ <span class="w-11 text-center text-muted-foreground tabular-nums">{zoomPercent}%</span>
432
+ <button
433
+ class={toolButton}
434
+ onclick={() => zoomTo(scale * ZOOM_STEP)}
435
+ disabled={!ready || scale >= maxScale - 1e-3}
436
+ title="{t.zoomIn} (+)"
437
+ aria-label={t.zoomIn}
438
+ >
439
+ <ZoomIn size={14} />
440
+ </button>
441
+ <button
442
+ class={toolButton}
443
+ onclick={fit}
444
+ disabled={!ready || fitted}
445
+ title="{t.zoomFit} (0)"
446
+ aria-label={t.zoomFit}
447
+ >
448
+ <Scan size={14} />
449
+ </button>
450
+ <button
451
+ class="{toolButton} font-medium tabular-nums"
452
+ onclick={() => zoomTo(1)}
453
+ disabled={!ready || Math.abs(scale - 1) < 1e-3}
454
+ title="{t.zoomActual} (1)"
455
+ aria-label={t.zoomActual}
456
+ >
457
+ 1:1
458
+ </button>
459
+
460
+ {#if allowDownload || allowFullscreen}
461
+ <div class="mx-1 h-4 w-px bg-border"></div>
462
+ {/if}
463
+ {#if allowDownload}
464
+ <button
465
+ class={toolButton}
466
+ onclick={handleDownload}
467
+ disabled={downloading}
468
+ title={t.downloadImage}
469
+ aria-label={t.downloadImage}
470
+ >
471
+ <Download size={14} />
472
+ </button>
473
+ {/if}
474
+ {#if allowFullscreen}
475
+ <button
476
+ class={toolButton}
477
+ onclick={toggleFullscreen}
478
+ title="{isFullscreen ? t.exitFullscreen : t.fullscreen} (F)"
479
+ aria-label={isFullscreen ? t.exitFullscreen : t.fullscreen}
480
+ >
481
+ {#if isFullscreen}
482
+ <Minimize size={14} />
483
+ {:else}
484
+ <Maximize size={14} />
485
+ {/if}
486
+ </button>
487
+ {/if}
488
+ </div>
140
489
  </div>
141
490
  {/if}
142
- {/if}
143
- </div>
491
+ </div>
492
+ {/if}
493
+
494
+ <style>
495
+ /* Shows transparency in PNG/WebP/SVG output instead of blending it into the page. */
496
+ .checkerboard {
497
+ background: repeating-conic-gradient(var(--muted) 0 25%, transparent 0 50%) 0 0 / 16px 16px;
498
+ }
499
+ .pixelated {
500
+ image-rendering: pixelated;
501
+ }
502
+ </style>
@@ -27,6 +27,7 @@
27
27
  TextInput
28
28
  } from './inputs';
29
29
  import { getClientSlot } from '../../contexts/clientSlotContext.svelte';
30
+ import { getLocaleContext } from '../../i18n/localeContext.svelte';
30
31
 
31
32
  interface Props {
32
33
  item: InputLayoutItem;
@@ -52,6 +53,8 @@
52
53
  dynamicOptions
53
54
  }: Props = $props();
54
55
 
56
+ const locale = getLocaleContext();
57
+
55
58
  const inputId = $derived(`input-${item.paramId}`);
56
59
  const label = $derived(displayName || item.displayName || item.paramId);
57
60
 
@@ -171,7 +174,11 @@
171
174
  </script>
172
175
 
173
176
  {#snippet labelText(forId?: string, tone: 'quiet' | 'strong' = 'quiet')}
174
- <Field.Label for={forId} class="min-w-0 block break-words">
177
+ <Field.Label
178
+ for={forId}
179
+ id={forId ? `${forId}-label` : undefined}
180
+ class="min-w-0 block break-words"
181
+ >
175
182
  <span
176
183
  class={tone === 'quiet' ? 'font-normal text-muted-foreground' : 'font-normal text-foreground'}
177
184
  >{label}</span
@@ -221,6 +228,7 @@
221
228
  {@const config = item.config}
222
229
  <NumberInput
223
230
  {inputId}
231
+ labelId="{inputId}-label"
224
232
  value={typeof value === 'number' ? value : undefined}
225
233
  {config}
226
234
  onChange={commit}
@@ -290,7 +298,7 @@
290
298
  />
291
299
  {/if}
292
300
  {:else}
293
- <p class="text-sm text-muted-foreground">No options available yet.</p>
301
+ <p class="text-sm text-muted-foreground">{locale.messages.noOptionsYet}</p>
294
302
  {/if}
295
303
  {:else if isFileWidget(item)}
296
304
  {@const config = item.config as FileInputWidgetConfig}
@@ -303,6 +311,7 @@
303
311
  />
304
312
  {:else if isColorWidget(item)}
305
313
  <ColorInput
314
+ {inputId}
306
315
  value={typeof value === 'string' ? value : '#000000'}
307
316
  onChange={(newValue) => commit(newValue)}
308
317
  />