@selvajs/ui 6.6.0-beta.11 → 6.6.0-beta.12

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.
@@ -420,7 +420,7 @@
420
420
  {/if}
421
421
  {:else if !hasViewer && !isTwoPanelMode && hasLeftPanel !== hasRightPanel}
422
422
  <!-- Single centered panel (left-only or right-only), no viewer -->
423
- <div class="min-h-0 flex h-full flex-1 justify-center">
423
+ <div class="min-h-0 p-1 flex h-full flex-1 justify-center">
424
424
  <div class="min-h-0 flex h-full w-4/5 flex-col">
425
425
  {@render panelContent(
426
426
  hasRightPanel ? 'right' : undefined,
@@ -447,7 +447,7 @@
447
447
  <Resizable.PaneGroup
448
448
  direction="horizontal"
449
449
  autoSaveId="selva-layout-{layoutKey}"
450
- class="min-h-0 flex-1"
450
+ class="min-h-0 m-1 flex-1"
451
451
  >
452
452
  {#if hasLeftPanel}
453
453
  <Resizable.Pane
@@ -460,7 +460,6 @@
460
460
  collapsedSize={0}
461
461
  onCollapse={() => (leftCollapsed = true)}
462
462
  onExpand={() => (leftCollapsed = false)}
463
- class="ml-1"
464
463
  >
465
464
  <div
466
465
  class="min-h-0 flex h-full flex-col {isViewerFullscreen || leftCollapsed
@@ -481,7 +480,7 @@
481
480
  {/if}
482
481
 
483
482
  {#if hasViewer}
484
- <Resizable.Pane order={2} minSize={20} class="min-h-0 mx-1 flex flex-col">
483
+ <Resizable.Pane order={2} minSize={20} class="min-h-0 flex flex-col">
485
484
  <Viewer
486
485
  {meshes}
487
486
  bind:isFullscreen={isViewerFullscreen}
@@ -498,14 +497,16 @@
498
497
  {/if}
499
498
 
500
499
  {#if hasRightPanel}
501
- <Resizable.Handle
502
- withHandle
503
- line={false}
504
- data-collapsed={rightCollapsed}
505
- onclick={() => {
506
- if (rightCollapsed) rightPaneRef?.expand();
507
- }}
508
- />
500
+ {#if hasViewer || !hasLeftPanel}
501
+ <Resizable.Handle
502
+ withHandle
503
+ line={false}
504
+ data-collapsed={rightCollapsed}
505
+ onclick={() => {
506
+ if (rightCollapsed) rightPaneRef?.expand();
507
+ }}
508
+ />
509
+ {/if}
509
510
  <Resizable.Pane
510
511
  bind:this={rightPaneRef}
511
512
  order={3}
@@ -516,7 +517,6 @@
516
517
  collapsedSize={0}
517
518
  onCollapse={() => (rightCollapsed = true)}
518
519
  onExpand={() => (rightCollapsed = false)}
519
- class="mr-1"
520
520
  >
521
521
  <div
522
522
  class="min-h-0 flex h-full flex-col {isViewerFullscreen || rightCollapsed
@@ -20,12 +20,14 @@
20
20
  [
21
21
  'gap-1 py-2 lg:py-3 lg:flex-col lg:w-auto px-2 lg:px-0',
22
22
  'flex w-full shrink-0 flex-row items-center',
23
- 'backdrop-blur-sm bg-background/90 cursor-pointer'
23
+ 'backdrop-blur-sm bg-background/90 cursor-pointer',
24
+ side === 'left' ? 'lg:border-r' : 'lg:border-l',
25
+ 'border-border'
24
26
  ].join(' ')
25
27
  );
26
28
 
27
29
  const tabButtonClass =
28
- 'w-8 h-8 rounded cursor-pointer flex shrink-0 items-center justify-center text-muted-foreground transition-colors hover:bg-accent hover:text-foreground';
30
+ 'w-8 h-8 rounded-md cursor-pointer flex shrink-0 items-center justify-center text-muted-foreground transition-colors hover:bg-accent hover:text-foreground';
29
31
  </script>
30
32
 
31
33
  <div
@@ -1,6 +1,7 @@
1
1
  <script lang="ts">
2
2
  import type { OutputChartLayoutItem } from '@selvajs/schemas';
3
- import { onMount } from 'svelte';
3
+ import { onMount, tick } from 'svelte';
4
+ import { mode } from 'mode-watcher';
4
5
  import { Loader, Maximize, Minimize } from '@lucide/svelte';
5
6
  import { loadScript } from '../../utils/loadScript';
6
7
  import { getLocaleContext } from '../../i18n/localeContext.svelte';
@@ -57,6 +58,19 @@
57
58
  // Some embedded WebViews (WKWebView in the Rhino panel) ship without the Fullscreen API at all.
58
59
  const fullscreenSupported = typeof document !== 'undefined' && !!document.fullscreenEnabled;
59
60
 
61
+ // `Plots.resize()`/`relayout({autosize: true})` ask Plotly to re-measure the container itself,
62
+ // which it silently fails to do across a Fullscreen transition — _fullLayout keeps the
63
+ // pre-toggle width/height even though clientWidth/clientHeight are already correct. Passing
64
+ // the measured size explicitly is the only call that actually takes.
65
+ function resizeChart() {
66
+ // @ts-expect-error — Plotly is loaded dynamically onto window
67
+ if (!containerEl || !window.Plotly) return;
68
+ const { width, height } = containerEl.getBoundingClientRect();
69
+ if (width === 0 || height === 0) return;
70
+ // @ts-expect-error — Plotly is loaded dynamically onto window
71
+ window.Plotly.relayout(containerEl, { width, height });
72
+ }
73
+
60
74
  function toggleFullscreen() {
61
75
  if (!wrapperEl) return;
62
76
  if (!document.fullscreenElement) {
@@ -73,26 +87,41 @@
73
87
  $effect(() => {
74
88
  function onFullscreenChange() {
75
89
  isFullscreen = !!document.fullscreenElement;
76
- Promise.resolve().then(() => {
77
- // @ts-expect-error — Plotly is loaded dynamically onto window
78
- if (containerEl && window.Plotly) {
79
- // @ts-expect-error — Plotly is loaded dynamically onto window
80
- window.Plotly.Plots.resize(containerEl);
81
- }
82
- });
90
+ // `tick()` waits for the h-dvh/h-95 class swap this state drives to actually reach the
91
+ // DOM before resizeChart measures it.
92
+ tick().then(resizeChart);
83
93
  }
84
94
  document.addEventListener('fullscreenchange', onFullscreenChange);
85
95
  return () => document.removeEventListener('fullscreenchange', onFullscreenChange);
86
96
  });
87
97
 
98
+ // Chromium's getComputedStyle returns color values in whatever function the CSS used to
99
+ // declare them — oklch() here, since base.css's theme tokens are oklch(). Plotly's own color
100
+ // parser doesn't recognize oklch() and silently falls back to its #444 default instead of
101
+ // erroring, so every chart read dark axis text even in dark mode. `fillStyle` alone just
102
+ // echoes the oklch() string back unconverted; actually rasterizing a pixel and reading its
103
+ // bytes is what forces a real rgb() value Plotly can parse.
104
+ function toRgb(cssColor: string): string {
105
+ const canvas = document.createElement('canvas');
106
+ canvas.width = 1;
107
+ canvas.height = 1;
108
+ const ctx = canvas.getContext('2d');
109
+ if (!ctx) return cssColor;
110
+ ctx.fillStyle = cssColor;
111
+ ctx.fillRect(0, 0, 1, 1);
112
+ const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data;
113
+ return a === 255 ? `rgb(${r}, ${g}, ${b})` : `rgba(${r}, ${g}, ${b}, ${(a / 255).toFixed(3)})`;
114
+ }
115
+
88
116
  function getThemeColors(): { textColor: string; gridColor: string } {
89
117
  if (!containerEl) return { textColor: '#333', gridColor: '#eee' };
90
118
  const style = getComputedStyle(containerEl);
91
- const textColor = style.getPropertyValue('color').trim() || '#333';
92
- const gridColor =
119
+ const textColor = toRgb(style.getPropertyValue('color').trim() || '#333');
120
+ const gridColor = toRgb(
93
121
  style.getPropertyValue('--border')?.trim() ||
94
- style.getPropertyValue('--color-border')?.trim() ||
95
- 'rgba(128,128,128,0.2)';
122
+ style.getPropertyValue('--color-border')?.trim() ||
123
+ 'rgba(128,128,128,0.2)'
124
+ );
96
125
  return { textColor, gridColor };
97
126
  }
98
127
 
@@ -125,11 +154,15 @@
125
154
  font: { color: textColor },
126
155
  paper_bgcolor: layoutRest.paper_bgcolor ?? 'transparent',
127
156
  plot_bgcolor: layoutRest.plot_bgcolor ?? 'transparent',
128
- ...(!isMapFigure && layoutRest.xaxis
129
- ? { xaxis: { ...(layoutRest.xaxis as object), gridcolor: gridColor, color: textColor } }
130
- : {}),
131
- ...(!isMapFigure && layoutRest.yaxis
132
- ? { yaxis: { ...(layoutRest.yaxis as object), gridcolor: gridColor, color: textColor } }
157
+ // Plotly's axis tick/label color doesn't inherit the top-level `font.color`, so this
158
+ // has to be set even when the figure's own JSON never declared an xaxis/yaxis —
159
+ // which the mock's figures don't. Gating on `layoutRest.xaxis` existing left every
160
+ // axis on Plotly's built-in #444 default, light or dark mode.
161
+ ...(!isMapFigure
162
+ ? {
163
+ xaxis: { ...(layoutRest.xaxis as object), gridcolor: gridColor, color: textColor },
164
+ yaxis: { ...(layoutRest.yaxis as object), gridcolor: gridColor, color: textColor }
165
+ }
133
166
  : {})
134
167
  };
135
168
 
@@ -154,11 +187,7 @@
154
187
  const ro = new ResizeObserver(() => {
155
188
  if (resizeTimer) clearTimeout(resizeTimer);
156
189
  resizeTimer = setTimeout(() => {
157
- // @ts-expect-error — Plotly is loaded dynamically onto window
158
- if (containerEl && window.Plotly) {
159
- // @ts-expect-error — Plotly is loaded dynamically onto window
160
- window.Plotly.Plots.resize(containerEl);
161
- }
190
+ resizeChart();
162
191
  resizeTimer = null;
163
192
  }, 150);
164
193
  });
@@ -175,6 +204,10 @@
175
204
  let renderTimeout: ReturnType<typeof setTimeout> | null = null;
176
205
 
177
206
  $effect(() => {
207
+ // Re-reads mode.current so a theme toggle re-renders with the new axis/font colors —
208
+ // getThemeColors() only reads the DOM once, at render time, and nothing else repaints an
209
+ // already-drawn chart when the page's color scheme changes under it.
210
+ void mode.current;
178
211
  if (!value || !mounted || !containerEl) return;
179
212
  if (renderTimeout) clearTimeout(renderTimeout);
180
213
  renderTimeout = setTimeout(() => {
@@ -189,7 +222,7 @@
189
222
 
190
223
  <div
191
224
  bind:this={wrapperEl}
192
- class="group rounded relative overflow-hidden border border-border {isFullscreen
225
+ class="group relative overflow-hidden rounded-lg border border-border {isFullscreen
193
226
  ? 'bg-background'
194
227
  : ''}"
195
228
  >
@@ -65,7 +65,8 @@
65
65
  // Some embedded WebViews (WKWebView in the Rhino panel) ship without the Fullscreen API at all.
66
66
  const fullscreenSupported = typeof document !== 'undefined' && !!document.fullscreenEnabled;
67
67
  const allowFullscreen = $derived((cfg.allowFullscreen ?? true) && fullscreenSupported);
68
- const backgroundColor = $derived(cfg.backgroundColor);
68
+ // SVGs are line drawings (often black on transparent), so a checkerboard hurts legibility.
69
+ const backgroundColor = $derived(cfg.backgroundColor ?? (isSvg ? '#ffffff' : undefined));
69
70
 
70
71
  let wrapperEl = $state<HTMLDivElement | null>(null);
71
72
  let stageEl = $state<HTMLDivElement | null>(null);
@@ -303,7 +304,7 @@
303
304
  {#if error || unsupported || current}
304
305
  <div
305
306
  bind:this={wrapperEl}
306
- class="rounded flex flex-col overflow-hidden border border-border bg-background {isFullscreen
307
+ class="flex flex-col overflow-hidden rounded-lg border border-border bg-background {isFullscreen
307
308
  ? 'h-dvh'
308
309
  : ''}"
309
310
  >
@@ -319,6 +320,7 @@
319
320
  {:else if current}
320
321
  <!-- The stage is a pan/zoom surface with its own keyboard shortcuts; no native element fits. -->
321
322
  <!-- svelte-ignore a11y_no_noninteractive_tabindex, a11y_no_noninteractive_element_interactions -->
323
+ <!-- eslint-disable shadcn/no-inline-styles -- backgroundColor is the author's per-output color, not a theme token -->
322
324
  <div
323
325
  bind:this={stageEl}
324
326
  role="application"
@@ -338,6 +340,7 @@
338
340
  ondblclick={onDoubleClick}
339
341
  onkeydown={onKeyDown}
340
342
  >
343
+ <!-- eslint-enable shadcn/no-inline-styles -->
341
344
  {#if decodeFailed}
342
345
  <div class="inset-0 text-sm absolute flex items-center justify-center text-destructive">
343
346
  {formatMessage(t.imageDecodeFailed, { file: `${current.fileName}${current.fileType}` })}
@@ -38,7 +38,7 @@
38
38
  );
39
39
  const hasValue = $derived(value != null);
40
40
 
41
- const boxClass = 'min-h-12.5 rounded px-3 py-3 font-mono text-sm border border-border';
41
+ const boxClass = 'min-h-12.5 rounded-md px-3 py-3 text-sm border border-border/60';
42
42
 
43
43
  async function copyToClipboard() {
44
44
  try {
@@ -157,7 +157,11 @@
157
157
 
158
158
  {#snippet fieldHeader()}
159
159
  <div class="gap-2 flex items-center">
160
- <Label>{label}</Label>
160
+ <Label>
161
+ <span class={item.widgetType === 'number' ? 'text-xs font-normal text-muted-foreground' : ''}
162
+ >{label}</span
163
+ >
164
+ </Label>
161
165
  {#if item.description}
162
166
  <HelpButton {label} description={item.description} />
163
167
  {/if}
@@ -213,12 +217,12 @@
213
217
 
214
218
  {#snippet fileDisplay()}
215
219
  {#if fileCount === 0}
216
- <div class="rounded px-3 py-3 text-sm border border-border bg-muted text-muted-foreground">
220
+ <div class="px-3 py-3 text-sm rounded-md border border-border bg-muted text-muted-foreground">
217
221
  {t.waitingForFiles}
218
222
  </div>
219
223
  {:else}
220
224
  <div class="gap-2 flex flex-col">
221
- <div class="rounded px-3 py-2 border border-border bg-muted">
225
+ <div class="px-3 py-2 rounded-md border border-border bg-muted">
222
226
  <div class="gap-2 flex items-center justify-between">
223
227
  <span class="min-w-0 text-sm font-medium truncate text-foreground">
224
228
  {fileCount === 1
@@ -248,7 +252,7 @@
248
252
  </div>
249
253
  {#if hasDuplicates}
250
254
  <div
251
- class="rounded px-3 py-2 text-sm border border-destructive bg-destructive/10 text-destructive"
255
+ class="px-3 py-2 text-sm rounded-md border border-destructive bg-destructive/10 text-destructive"
252
256
  >
253
257
  <div class="font-medium">{t.duplicateFilesTitle}</div>
254
258
  <div class="mt-1 text-xs">
@@ -264,7 +268,7 @@
264
268
  {/if}
265
269
  {#if downloadError}
266
270
  <div
267
- class="rounded px-3 py-2 text-sm border border-destructive bg-destructive/10 text-destructive"
271
+ class="px-3 py-2 text-sm rounded-md border border-destructive bg-destructive/10 text-destructive"
268
272
  >
269
273
  {downloadError}
270
274
  </div>
@@ -282,7 +286,10 @@
282
286
 
283
287
  <!-- Dynamic value list outputs are routing sinks (their options feed an input), not displayed. -->
284
288
  {#if item.widgetType !== 'dynamicValueList'}
285
- <div role="group" class="group/field gap-2 flex flex-col">
289
+ <div
290
+ role="group"
291
+ class="group/field flex flex-col {item.widgetType === 'number' ? 'gap-0.5' : 'gap-2'}"
292
+ >
286
293
  {@render fieldHeader()}
287
294
 
288
295
  {#if item.widgetType === 'chart'}
@@ -295,18 +302,18 @@
295
302
  {:else if item.widgetType === 'file'}
296
303
  {@render fileDisplay()}
297
304
  {:else if item.widgetType === 'number'}
298
- <div class="{boxClass} flex items-center bg-muted/50 wrap-break-word">
305
+ <div class="min-h-8 text-2xl font-semibold wrap-break-word text-foreground tabular-nums">
299
306
  {#if hasValue}
300
- <span class="font-bold text-primary">{formattedValue}</span>
307
+ {formattedValue}
301
308
  {:else}
302
- {@render placeholder()}
309
+ <span class="text-base font-normal">{@render placeholder()}</span>
303
310
  {/if}
304
311
  </div>
305
312
  {:else if item.widgetType === 'text'}
306
313
  <div class="group relative">
307
314
  {#if isObjectValue}
308
315
  <pre
309
- class="{boxClass} max-h-96 overflow-auto bg-muted/10 wrap-anywhere text-foreground">{formattedValue}</pre>
316
+ class="{boxClass} max-h-96 font-mono overflow-auto bg-muted/10 wrap-anywhere text-foreground">{formattedValue}</pre>
310
317
  {:else}
311
318
  <div
312
319
  class="{boxClass} bg-muted/10 wrap-anywhere wrap-break-word whitespace-pre-wrap text-foreground"
@@ -104,7 +104,7 @@
104
104
  {#if bare}
105
105
  {@render tabs()}
106
106
  {:else}
107
- <Card.Root variant="flush" class="min-h-0 mx-1 flex-1">
107
+ <Card.Root variant="flush" class="min-h-0 flex-1">
108
108
  {@render tabs()}
109
109
  </Card.Root>
110
110
  {/if}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@selvajs/ui",
3
- "version": "6.6.0-beta.11",
3
+ "version": "6.6.0-beta.12",
4
4
  "description": "Shared UI components and utilities for Selva applications",
5
5
  "license": "MIT",
6
6
  "author": "VektorNode",
@@ -31,12 +31,16 @@
31
31
  ".": {
32
32
  "selva-source": {
33
33
  "types": "./dist/index.d.ts",
34
- "svelte": "./dist/index.js"
34
+ "svelte": "./src/lib/index.ts"
35
35
  },
36
36
  "types": "./dist/public.d.ts",
37
37
  "svelte": "./dist/public.js"
38
38
  },
39
39
  "./external": {
40
+ "selva-source": {
41
+ "types": "./dist/external/storage.d.ts",
42
+ "svelte": "./src/lib/external/storage.ts"
43
+ },
40
44
  "types": "./dist/external/storage.d.ts",
41
45
  "svelte": "./dist/external/storage.js"
42
46
  },
@@ -60,9 +64,9 @@
60
64
  "tailwind-variants": "^3.3.1",
61
65
  "three": "^0.186.1",
62
66
  "@selvajs/compute": "^5.0.0-beta.8",
63
- "@selvajs/schemas": "^5.3.0-beta.1",
64
67
  "@selvajs/solve": "^2.0.0-beta.10",
65
- "@selvajs/visualization": "^1.6.0-beta.5"
68
+ "@selvajs/visualization": "^1.6.0-beta.6",
69
+ "@selvajs/schemas": "^5.3.0-beta.1"
66
70
  },
67
71
  "peerDependenciesMeta": {
68
72
  "three": {
@@ -92,8 +96,8 @@
92
96
  "vitest": "^5.0.5",
93
97
  "@selvajs/compute": "5.0.0-beta.8",
94
98
  "@selvajs/config": "0.0.4",
95
- "@selvajs/visualization": "1.6.0-beta.5",
96
99
  "@selvajs/schemas": "5.3.0-beta.1",
100
+ "@selvajs/visualization": "1.6.0-beta.6",
97
101
  "@selvajs/solve": "2.0.0-beta.10"
98
102
  },
99
103
  "scripts": {
@@ -420,7 +420,7 @@
420
420
  {/if}
421
421
  {:else if !hasViewer && !isTwoPanelMode && hasLeftPanel !== hasRightPanel}
422
422
  <!-- Single centered panel (left-only or right-only), no viewer -->
423
- <div class="min-h-0 flex h-full flex-1 justify-center">
423
+ <div class="min-h-0 p-1 flex h-full flex-1 justify-center">
424
424
  <div class="min-h-0 flex h-full w-4/5 flex-col">
425
425
  {@render panelContent(
426
426
  hasRightPanel ? 'right' : undefined,
@@ -447,7 +447,7 @@
447
447
  <Resizable.PaneGroup
448
448
  direction="horizontal"
449
449
  autoSaveId="selva-layout-{layoutKey}"
450
- class="min-h-0 flex-1"
450
+ class="min-h-0 m-1 flex-1"
451
451
  >
452
452
  {#if hasLeftPanel}
453
453
  <Resizable.Pane
@@ -460,7 +460,6 @@
460
460
  collapsedSize={0}
461
461
  onCollapse={() => (leftCollapsed = true)}
462
462
  onExpand={() => (leftCollapsed = false)}
463
- class="ml-1"
464
463
  >
465
464
  <div
466
465
  class="min-h-0 flex h-full flex-col {isViewerFullscreen || leftCollapsed
@@ -481,7 +480,7 @@
481
480
  {/if}
482
481
 
483
482
  {#if hasViewer}
484
- <Resizable.Pane order={2} minSize={20} class="min-h-0 mx-1 flex flex-col">
483
+ <Resizable.Pane order={2} minSize={20} class="min-h-0 flex flex-col">
485
484
  <Viewer
486
485
  {meshes}
487
486
  bind:isFullscreen={isViewerFullscreen}
@@ -498,14 +497,16 @@
498
497
  {/if}
499
498
 
500
499
  {#if hasRightPanel}
501
- <Resizable.Handle
502
- withHandle
503
- line={false}
504
- data-collapsed={rightCollapsed}
505
- onclick={() => {
506
- if (rightCollapsed) rightPaneRef?.expand();
507
- }}
508
- />
500
+ {#if hasViewer || !hasLeftPanel}
501
+ <Resizable.Handle
502
+ withHandle
503
+ line={false}
504
+ data-collapsed={rightCollapsed}
505
+ onclick={() => {
506
+ if (rightCollapsed) rightPaneRef?.expand();
507
+ }}
508
+ />
509
+ {/if}
509
510
  <Resizable.Pane
510
511
  bind:this={rightPaneRef}
511
512
  order={3}
@@ -516,7 +517,6 @@
516
517
  collapsedSize={0}
517
518
  onCollapse={() => (rightCollapsed = true)}
518
519
  onExpand={() => (rightCollapsed = false)}
519
- class="mr-1"
520
520
  >
521
521
  <div
522
522
  class="min-h-0 flex h-full flex-col {isViewerFullscreen || rightCollapsed
@@ -20,12 +20,14 @@
20
20
  [
21
21
  'gap-1 py-2 lg:py-3 lg:flex-col lg:w-auto px-2 lg:px-0',
22
22
  'flex w-full shrink-0 flex-row items-center',
23
- 'backdrop-blur-sm bg-background/90 cursor-pointer'
23
+ 'backdrop-blur-sm bg-background/90 cursor-pointer',
24
+ side === 'left' ? 'lg:border-r' : 'lg:border-l',
25
+ 'border-border'
24
26
  ].join(' ')
25
27
  );
26
28
 
27
29
  const tabButtonClass =
28
- 'w-8 h-8 rounded cursor-pointer flex shrink-0 items-center justify-center text-muted-foreground transition-colors hover:bg-accent hover:text-foreground';
30
+ 'w-8 h-8 rounded-md cursor-pointer flex shrink-0 items-center justify-center text-muted-foreground transition-colors hover:bg-accent hover:text-foreground';
29
31
  </script>
30
32
 
31
33
  <div
@@ -1,6 +1,7 @@
1
1
  <script lang="ts">
2
2
  import type { OutputChartLayoutItem } from '@selvajs/schemas';
3
- import { onMount } from 'svelte';
3
+ import { onMount, tick } from 'svelte';
4
+ import { mode } from 'mode-watcher';
4
5
  import { Loader, Maximize, Minimize } from '@lucide/svelte';
5
6
  import { loadScript } from '$lib/utils/loadScript';
6
7
  import { getLocaleContext } from '$lib/i18n/localeContext.svelte';
@@ -57,6 +58,19 @@
57
58
  // Some embedded WebViews (WKWebView in the Rhino panel) ship without the Fullscreen API at all.
58
59
  const fullscreenSupported = typeof document !== 'undefined' && !!document.fullscreenEnabled;
59
60
 
61
+ // `Plots.resize()`/`relayout({autosize: true})` ask Plotly to re-measure the container itself,
62
+ // which it silently fails to do across a Fullscreen transition — _fullLayout keeps the
63
+ // pre-toggle width/height even though clientWidth/clientHeight are already correct. Passing
64
+ // the measured size explicitly is the only call that actually takes.
65
+ function resizeChart() {
66
+ // @ts-expect-error — Plotly is loaded dynamically onto window
67
+ if (!containerEl || !window.Plotly) return;
68
+ const { width, height } = containerEl.getBoundingClientRect();
69
+ if (width === 0 || height === 0) return;
70
+ // @ts-expect-error — Plotly is loaded dynamically onto window
71
+ window.Plotly.relayout(containerEl, { width, height });
72
+ }
73
+
60
74
  function toggleFullscreen() {
61
75
  if (!wrapperEl) return;
62
76
  if (!document.fullscreenElement) {
@@ -73,26 +87,41 @@
73
87
  $effect(() => {
74
88
  function onFullscreenChange() {
75
89
  isFullscreen = !!document.fullscreenElement;
76
- Promise.resolve().then(() => {
77
- // @ts-expect-error — Plotly is loaded dynamically onto window
78
- if (containerEl && window.Plotly) {
79
- // @ts-expect-error — Plotly is loaded dynamically onto window
80
- window.Plotly.Plots.resize(containerEl);
81
- }
82
- });
90
+ // `tick()` waits for the h-dvh/h-95 class swap this state drives to actually reach the
91
+ // DOM before resizeChart measures it.
92
+ tick().then(resizeChart);
83
93
  }
84
94
  document.addEventListener('fullscreenchange', onFullscreenChange);
85
95
  return () => document.removeEventListener('fullscreenchange', onFullscreenChange);
86
96
  });
87
97
 
98
+ // Chromium's getComputedStyle returns color values in whatever function the CSS used to
99
+ // declare them — oklch() here, since base.css's theme tokens are oklch(). Plotly's own color
100
+ // parser doesn't recognize oklch() and silently falls back to its #444 default instead of
101
+ // erroring, so every chart read dark axis text even in dark mode. `fillStyle` alone just
102
+ // echoes the oklch() string back unconverted; actually rasterizing a pixel and reading its
103
+ // bytes is what forces a real rgb() value Plotly can parse.
104
+ function toRgb(cssColor: string): string {
105
+ const canvas = document.createElement('canvas');
106
+ canvas.width = 1;
107
+ canvas.height = 1;
108
+ const ctx = canvas.getContext('2d');
109
+ if (!ctx) return cssColor;
110
+ ctx.fillStyle = cssColor;
111
+ ctx.fillRect(0, 0, 1, 1);
112
+ const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data;
113
+ return a === 255 ? `rgb(${r}, ${g}, ${b})` : `rgba(${r}, ${g}, ${b}, ${(a / 255).toFixed(3)})`;
114
+ }
115
+
88
116
  function getThemeColors(): { textColor: string; gridColor: string } {
89
117
  if (!containerEl) return { textColor: '#333', gridColor: '#eee' };
90
118
  const style = getComputedStyle(containerEl);
91
- const textColor = style.getPropertyValue('color').trim() || '#333';
92
- const gridColor =
119
+ const textColor = toRgb(style.getPropertyValue('color').trim() || '#333');
120
+ const gridColor = toRgb(
93
121
  style.getPropertyValue('--border')?.trim() ||
94
- style.getPropertyValue('--color-border')?.trim() ||
95
- 'rgba(128,128,128,0.2)';
122
+ style.getPropertyValue('--color-border')?.trim() ||
123
+ 'rgba(128,128,128,0.2)'
124
+ );
96
125
  return { textColor, gridColor };
97
126
  }
98
127
 
@@ -125,11 +154,15 @@
125
154
  font: { color: textColor },
126
155
  paper_bgcolor: layoutRest.paper_bgcolor ?? 'transparent',
127
156
  plot_bgcolor: layoutRest.plot_bgcolor ?? 'transparent',
128
- ...(!isMapFigure && layoutRest.xaxis
129
- ? { xaxis: { ...(layoutRest.xaxis as object), gridcolor: gridColor, color: textColor } }
130
- : {}),
131
- ...(!isMapFigure && layoutRest.yaxis
132
- ? { yaxis: { ...(layoutRest.yaxis as object), gridcolor: gridColor, color: textColor } }
157
+ // Plotly's axis tick/label color doesn't inherit the top-level `font.color`, so this
158
+ // has to be set even when the figure's own JSON never declared an xaxis/yaxis —
159
+ // which the mock's figures don't. Gating on `layoutRest.xaxis` existing left every
160
+ // axis on Plotly's built-in #444 default, light or dark mode.
161
+ ...(!isMapFigure
162
+ ? {
163
+ xaxis: { ...(layoutRest.xaxis as object), gridcolor: gridColor, color: textColor },
164
+ yaxis: { ...(layoutRest.yaxis as object), gridcolor: gridColor, color: textColor }
165
+ }
133
166
  : {})
134
167
  };
135
168
 
@@ -154,11 +187,7 @@
154
187
  const ro = new ResizeObserver(() => {
155
188
  if (resizeTimer) clearTimeout(resizeTimer);
156
189
  resizeTimer = setTimeout(() => {
157
- // @ts-expect-error — Plotly is loaded dynamically onto window
158
- if (containerEl && window.Plotly) {
159
- // @ts-expect-error — Plotly is loaded dynamically onto window
160
- window.Plotly.Plots.resize(containerEl);
161
- }
190
+ resizeChart();
162
191
  resizeTimer = null;
163
192
  }, 150);
164
193
  });
@@ -175,6 +204,10 @@
175
204
  let renderTimeout: ReturnType<typeof setTimeout> | null = null;
176
205
 
177
206
  $effect(() => {
207
+ // Re-reads mode.current so a theme toggle re-renders with the new axis/font colors —
208
+ // getThemeColors() only reads the DOM once, at render time, and nothing else repaints an
209
+ // already-drawn chart when the page's color scheme changes under it.
210
+ void mode.current;
178
211
  if (!value || !mounted || !containerEl) return;
179
212
  if (renderTimeout) clearTimeout(renderTimeout);
180
213
  renderTimeout = setTimeout(() => {
@@ -189,7 +222,7 @@
189
222
 
190
223
  <div
191
224
  bind:this={wrapperEl}
192
- class="group rounded relative overflow-hidden border border-border {isFullscreen
225
+ class="group relative overflow-hidden rounded-lg border border-border {isFullscreen
193
226
  ? 'bg-background'
194
227
  : ''}"
195
228
  >
@@ -65,7 +65,8 @@
65
65
  // Some embedded WebViews (WKWebView in the Rhino panel) ship without the Fullscreen API at all.
66
66
  const fullscreenSupported = typeof document !== 'undefined' && !!document.fullscreenEnabled;
67
67
  const allowFullscreen = $derived((cfg.allowFullscreen ?? true) && fullscreenSupported);
68
- const backgroundColor = $derived(cfg.backgroundColor);
68
+ // SVGs are line drawings (often black on transparent), so a checkerboard hurts legibility.
69
+ const backgroundColor = $derived(cfg.backgroundColor ?? (isSvg ? '#ffffff' : undefined));
69
70
 
70
71
  let wrapperEl = $state<HTMLDivElement | null>(null);
71
72
  let stageEl = $state<HTMLDivElement | null>(null);
@@ -303,7 +304,7 @@
303
304
  {#if error || unsupported || current}
304
305
  <div
305
306
  bind:this={wrapperEl}
306
- class="rounded flex flex-col overflow-hidden border border-border bg-background {isFullscreen
307
+ class="flex flex-col overflow-hidden rounded-lg border border-border bg-background {isFullscreen
307
308
  ? 'h-dvh'
308
309
  : ''}"
309
310
  >
@@ -319,6 +320,7 @@
319
320
  {:else if current}
320
321
  <!-- The stage is a pan/zoom surface with its own keyboard shortcuts; no native element fits. -->
321
322
  <!-- svelte-ignore a11y_no_noninteractive_tabindex, a11y_no_noninteractive_element_interactions -->
323
+ <!-- eslint-disable shadcn/no-inline-styles -- backgroundColor is the author's per-output color, not a theme token -->
322
324
  <div
323
325
  bind:this={stageEl}
324
326
  role="application"
@@ -338,6 +340,7 @@
338
340
  ondblclick={onDoubleClick}
339
341
  onkeydown={onKeyDown}
340
342
  >
343
+ <!-- eslint-enable shadcn/no-inline-styles -->
341
344
  {#if decodeFailed}
342
345
  <div class="inset-0 text-sm absolute flex items-center justify-center text-destructive">
343
346
  {formatMessage(t.imageDecodeFailed, { file: `${current.fileName}${current.fileType}` })}
@@ -38,7 +38,7 @@
38
38
  );
39
39
  const hasValue = $derived(value != null);
40
40
 
41
- const boxClass = 'min-h-12.5 rounded px-3 py-3 font-mono text-sm border border-border';
41
+ const boxClass = 'min-h-12.5 rounded-md px-3 py-3 text-sm border border-border/60';
42
42
 
43
43
  async function copyToClipboard() {
44
44
  try {
@@ -157,7 +157,11 @@
157
157
 
158
158
  {#snippet fieldHeader()}
159
159
  <div class="gap-2 flex items-center">
160
- <Label>{label}</Label>
160
+ <Label>
161
+ <span class={item.widgetType === 'number' ? 'text-xs font-normal text-muted-foreground' : ''}
162
+ >{label}</span
163
+ >
164
+ </Label>
161
165
  {#if item.description}
162
166
  <HelpButton {label} description={item.description} />
163
167
  {/if}
@@ -213,12 +217,12 @@
213
217
 
214
218
  {#snippet fileDisplay()}
215
219
  {#if fileCount === 0}
216
- <div class="rounded px-3 py-3 text-sm border border-border bg-muted text-muted-foreground">
220
+ <div class="px-3 py-3 text-sm rounded-md border border-border bg-muted text-muted-foreground">
217
221
  {t.waitingForFiles}
218
222
  </div>
219
223
  {:else}
220
224
  <div class="gap-2 flex flex-col">
221
- <div class="rounded px-3 py-2 border border-border bg-muted">
225
+ <div class="px-3 py-2 rounded-md border border-border bg-muted">
222
226
  <div class="gap-2 flex items-center justify-between">
223
227
  <span class="min-w-0 text-sm font-medium truncate text-foreground">
224
228
  {fileCount === 1
@@ -248,7 +252,7 @@
248
252
  </div>
249
253
  {#if hasDuplicates}
250
254
  <div
251
- class="rounded px-3 py-2 text-sm border border-destructive bg-destructive/10 text-destructive"
255
+ class="px-3 py-2 text-sm rounded-md border border-destructive bg-destructive/10 text-destructive"
252
256
  >
253
257
  <div class="font-medium">{t.duplicateFilesTitle}</div>
254
258
  <div class="mt-1 text-xs">
@@ -264,7 +268,7 @@
264
268
  {/if}
265
269
  {#if downloadError}
266
270
  <div
267
- class="rounded px-3 py-2 text-sm border border-destructive bg-destructive/10 text-destructive"
271
+ class="px-3 py-2 text-sm rounded-md border border-destructive bg-destructive/10 text-destructive"
268
272
  >
269
273
  {downloadError}
270
274
  </div>
@@ -282,7 +286,10 @@
282
286
 
283
287
  <!-- Dynamic value list outputs are routing sinks (their options feed an input), not displayed. -->
284
288
  {#if item.widgetType !== 'dynamicValueList'}
285
- <div role="group" class="group/field gap-2 flex flex-col">
289
+ <div
290
+ role="group"
291
+ class="group/field flex flex-col {item.widgetType === 'number' ? 'gap-0.5' : 'gap-2'}"
292
+ >
286
293
  {@render fieldHeader()}
287
294
 
288
295
  {#if item.widgetType === 'chart'}
@@ -295,18 +302,18 @@
295
302
  {:else if item.widgetType === 'file'}
296
303
  {@render fileDisplay()}
297
304
  {:else if item.widgetType === 'number'}
298
- <div class="{boxClass} flex items-center bg-muted/50 wrap-break-word">
305
+ <div class="min-h-8 text-2xl font-semibold wrap-break-word text-foreground tabular-nums">
299
306
  {#if hasValue}
300
- <span class="font-bold text-primary">{formattedValue}</span>
307
+ {formattedValue}
301
308
  {:else}
302
- {@render placeholder()}
309
+ <span class="text-base font-normal">{@render placeholder()}</span>
303
310
  {/if}
304
311
  </div>
305
312
  {:else if item.widgetType === 'text'}
306
313
  <div class="group relative">
307
314
  {#if isObjectValue}
308
315
  <pre
309
- class="{boxClass} max-h-96 overflow-auto bg-muted/10 wrap-anywhere text-foreground">{formattedValue}</pre>
316
+ class="{boxClass} max-h-96 font-mono overflow-auto bg-muted/10 wrap-anywhere text-foreground">{formattedValue}</pre>
310
317
  {:else}
311
318
  <div
312
319
  class="{boxClass} bg-muted/10 wrap-anywhere wrap-break-word whitespace-pre-wrap text-foreground"
@@ -104,7 +104,7 @@
104
104
  {#if bare}
105
105
  {@render tabs()}
106
106
  {:else}
107
- <Card.Root variant="flush" class="min-h-0 mx-1 flex-1">
107
+ <Card.Root variant="flush" class="min-h-0 flex-1">
108
108
  {@render tabs()}
109
109
  </Card.Root>
110
110
  {/if}