@selvajs/ui 6.6.0-beta.7 → 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 +10 -22
  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 +11 -21
  92. package/src/lib/utils/host-files.ts +61 -0
  93. package/src/lib/utils/webp-to-png.ts +48 -0
@@ -1,4 +1,5 @@
1
1
  <script lang="ts">
2
+ import type { Snippet } from 'svelte';
2
3
  import type { UISchema, SupportedTypes, ParameterPreset } from '@selvajs/schemas';
3
4
  import type { SolveSettlement } from '@selvajs/solve/client';
4
5
  import type { ActionButton } from '../../types/actionButton';
@@ -34,6 +35,10 @@
34
35
  meshes?: any[];
35
36
  isSolving: boolean;
36
37
  showSolvingIndicator?: boolean;
38
+ /** `SolvingIndicator`'s `placement`; `inline` puts it in the action row. */
39
+ solvingIndicatorPlacement?: 'floating' | 'inline';
40
+ /** Rendered above the action row, for a host that keeps the solve's messages in the flow. */
41
+ solveMessages?: Snippet;
37
42
  hasPendingChanges?: boolean;
38
43
  hasNeverSolved?: boolean;
39
44
  isViewerFullscreen?: boolean;
@@ -63,6 +68,8 @@
63
68
  meshes = [],
64
69
  isSolving,
65
70
  showSolvingIndicator = false,
71
+ solvingIndicatorPlacement = 'floating',
72
+ solveMessages,
66
73
  hasPendingChanges = false,
67
74
  hasNeverSolved = false,
68
75
  isViewerFullscreen = $bindable(false),
@@ -248,6 +255,9 @@
248
255
  <!-- One primary action and one menu. A definition that solves on every change has no Calculate,
249
256
  so the menu sits alone at the end of the row. -->
250
257
  {#snippet footer()}
258
+ {#if solveMessages}
259
+ <div class="px-2">{@render solveMessages()}</div>
260
+ {/if}
251
261
  {#if schema.instanceSolve === false}
252
262
  <CalculateButton
253
263
  {hasPendingChanges}
@@ -256,9 +266,14 @@
256
266
  {oncalculate}
257
267
  trailing={hasMenu ? actionsMenu : undefined}
258
268
  />
259
- {:else if hasMenu}
260
- <div class="mt-3 pb-3 pt-2 px-2 flex justify-end border-t border-border/50">
261
- {@render actionsMenu()}
269
+ {:else if hasMenu || solvingIndicatorPlacement === 'inline'}
270
+ <div class="mt-3 pb-3 pt-2 px-2 gap-2 flex items-center border-t border-border/50">
271
+ {#if solvingIndicatorPlacement === 'inline'}
272
+ <SolvingIndicator show={showSolvingIndicator} placement="inline" />
273
+ {/if}
274
+ {#if hasMenu}
275
+ <div class="ml-auto">{@render actionsMenu()}</div>
276
+ {/if}
262
277
  </div>
263
278
  {/if}
264
279
  {/snippet}
@@ -272,7 +287,7 @@
272
287
  )}
273
288
  <div class="panel-content-wrapper">
274
289
  {#if schema.layout.type === 'tabbed'}
275
- <TabLayout {schema} {values} {onValueChange} {panelFilter} {requestedTabId} />
290
+ <TabLayout {schema} {values} {onValueChange} {panelFilter} {requestedTabId} bare={isMobile} />
276
291
  {/if}
277
292
  {#if hostsFooter && !isMobile}
278
293
  <div class="panel-footer px-1">
@@ -344,7 +359,7 @@
344
359
  <!-- Inert while closed: the fields are clipped out of view, and tabbing into them
345
360
  would scroll the drawer's insides around behind the handle. -->
346
361
  <div class="drawer-content" inert={drawer === 'closed'}>
347
- <div class="px-3 pb-6">
362
+ <div class="px-2 pb-4">
348
363
  {#if hasLeftPanel}
349
364
  {@render panelContent(hasRightPanel ? 'left' : undefined, requestedLeftTabId, false)}
350
365
  {/if}
@@ -509,7 +524,9 @@
509
524
  </div>
510
525
  {/if}
511
526
 
512
- <SolvingIndicator show={showSolvingIndicator} />
527
+ {#if solvingIndicatorPlacement === 'floating'}
528
+ <SolvingIndicator show={showSolvingIndicator} />
529
+ {/if}
513
530
  </main>
514
531
 
515
532
  <!-- The save, load and validation dialogs; the footer's menu opens them. -->
@@ -555,7 +572,13 @@
555
572
 
556
573
  .mobile-root {
557
574
  --drawer-closed-h: 60px;
558
- --drawer-half-h: 50%;
575
+ --drawer-half-h: 60%;
576
+ }
577
+
578
+ /* On a phone the panels stack in one scroller. Held at the scroller's height, a long left
579
+ panel would spill over the right panel below it. */
580
+ .mobile-root .panel-content-wrapper {
581
+ height: auto;
559
582
  }
560
583
 
561
584
  .viewer-region {
@@ -633,6 +656,13 @@
633
656
  padding: 0rem 1rem;
634
657
  }
635
658
 
659
+ /* The sheet's top edge already separates it from the model; the desktop footer's extra
660
+ spacing only takes rows from the fields. */
661
+ .drawer-footer :global(> div) {
662
+ margin-top: 0;
663
+ padding-block: 0.5rem;
664
+ }
665
+
636
666
  /* Touch gets 44px targets on the panel's controls; a fine pointer keeps the denser sizes. */
637
667
  @media (pointer: coarse) {
638
668
  .panel-content-wrapper
@@ -1,3 +1,4 @@
1
+ import type { Snippet } from 'svelte';
1
2
  import type { UISchema, SupportedTypes, ParameterPreset } from '@selvajs/schemas';
2
3
  import type { SolveSettlement } from '@selvajs/solve/client';
3
4
  import type { ActionButton } from '../../types/actionButton';
@@ -14,6 +15,10 @@ interface Props {
14
15
  meshes?: any[];
15
16
  isSolving: boolean;
16
17
  showSolvingIndicator?: boolean;
18
+ /** `SolvingIndicator`'s `placement`; `inline` puts it in the action row. */
19
+ solvingIndicatorPlacement?: 'floating' | 'inline';
20
+ /** Rendered above the action row, for a host that keeps the solve's messages in the flow. */
21
+ solveMessages?: Snippet;
17
22
  hasPendingChanges?: boolean;
18
23
  hasNeverSolved?: boolean;
19
24
  isViewerFullscreen?: boolean;
@@ -10,6 +10,7 @@
10
10
  } from '../../schema/param-exporter';
11
11
  import { Button, Input, Label, Textarea, Dialog, Callout, IssueList, toast } from '../primitives';
12
12
  import { DEFAULT_PRESET_LABELS, type PresetLabels } from '../../types/presetLabels';
13
+ import { pickFile } from '../../utils/host-files';
13
14
 
14
15
  interface Props {
15
16
  schema: UISchema;
@@ -37,7 +38,6 @@
37
38
  let showLoadDialog = $state(false);
38
39
  let loadResult = $state<PresetLoadResult | null>(null);
39
40
  let loadingPresetName = $state('');
40
- let fileInputRef = $state<HTMLInputElement | null>(null);
41
41
 
42
42
  let listedStates = $state<ParameterPreset[]>([]);
43
43
  let isLoadingList = $state(false);
@@ -69,7 +69,7 @@
69
69
  });
70
70
 
71
71
  if (onSaveState) await onSaveState(state);
72
- else exportStateAsJson(state);
72
+ else await exportStateAsJson(state);
73
73
  showExportDialog = false;
74
74
  }
75
75
 
@@ -86,19 +86,16 @@
86
86
  }
87
87
  }
88
88
 
89
- async function handleImport(event: Event) {
90
- const input = event.target as HTMLInputElement;
91
- if (!input.files || input.files.length === 0) return;
89
+ async function importFromFile() {
90
+ // .sps is the pre-rename extension; files with it exist on users' drives, so keep accepting it.
91
+ const file = await pickFile({ accept: '.slvp,.sps' });
92
+ if (!file) return;
92
93
 
93
94
  try {
94
- const imported = await importStateFromJson(input.files[0]);
95
- tryLoad(imported);
95
+ tryLoad(await importStateFromJson(file));
96
96
  } catch (error) {
97
97
  toast.error(t.loadImportError + (error as Error).message);
98
98
  }
99
-
100
- // Reset so re-picking the same file fires `change` again.
101
- input.value = '';
102
99
  }
103
100
 
104
101
  function confirmLoad() {
@@ -132,8 +129,8 @@
132
129
  }
133
130
 
134
131
  function handleLoadClick() {
135
- fileInputRef?.click();
136
132
  showLoadDialog = false;
133
+ void importFromFile();
137
134
  }
138
135
 
139
136
  function selectListedState(preset: ParameterPreset) {
@@ -142,15 +139,6 @@
142
139
  }
143
140
  </script>
144
141
 
145
- <!-- .sps is the pre-rename extension; files with it exist on users' drives, so keep accepting it. -->
146
- <input
147
- bind:this={fileInputRef}
148
- type="file"
149
- accept=".slvp,.sps"
150
- onchange={handleImport}
151
- class="hidden"
152
- />
153
-
154
142
  <!-- Save Dialog -->
155
143
  <Dialog.Root bind:open={showExportDialog}>
156
144
  <Dialog.Content>
@@ -31,6 +31,13 @@
31
31
  onabort: () => void;
32
32
  onconfirm: () => void;
33
33
  ondiscard: () => void;
34
+ /**
35
+ * See `createSolvingIndicator`. The live panel would only paint once the solve is over, so
36
+ * an Esc hint stands in for it; the result's messages still show.
37
+ */
38
+ freezesDuringSolve?: boolean;
39
+ /** `floating` overlays the bottom centre; `inline` sits in the flow below the host's content. */
40
+ placement?: 'floating' | 'inline';
34
41
  }
35
42
 
36
43
  let {
@@ -39,7 +46,9 @@
39
46
  diagnostics = [],
40
47
  onabort,
41
48
  onconfirm,
42
- ondiscard
49
+ ondiscard,
50
+ freezesDuringSolve = false,
51
+ placement = 'floating'
43
52
  }: Props = $props();
44
53
 
45
54
  const locale = getLocaleContext();
@@ -145,6 +154,7 @@
145
154
  $effect(() => {
146
155
  if (phase === 'live') {
147
156
  finishing = false;
157
+ if (freezesDuringSolve) return;
148
158
  const timer = setTimeout(() => {
149
159
  revealed = true;
150
160
  revealedAt = performance.now();
@@ -247,9 +257,22 @@
247
257
 
248
258
  <div class="sr-only" role="status" aria-live="polite">{announcement}</div>
249
259
 
250
- {#if visible}
260
+ {#if freezesDuringSolve && view === 'live'}
261
+ <!-- Painted before the solve takes the thread; Abort could not be clicked until it ends. Inline,
262
+ the solving chip carries the hint instead: a line here would resize the footer mid-solve. -->
263
+ {#if placement === 'floating'}
264
+ <p
265
+ class="bottom-4 text-xs fixed left-1/2 z-50 -translate-x-1/2 text-muted-foreground"
266
+ data-testid="solve-esc-hint"
267
+ >
268
+ {t.solveMessagesEscHint}
269
+ </p>
270
+ {/if}
271
+ {:else if visible}
251
272
  <div
252
- class="bottom-4 p-3 shadow-lg w-lg fixed left-1/2 z-50 max-w-screen-inset -translate-x-1/2 rounded-lg border bg-background {view ===
273
+ class="p-3 border {placement === 'inline'
274
+ ? 'mt-3 rounded-md bg-muted/40'
275
+ : 'bottom-4 shadow-lg w-lg fixed left-1/2 z-50 max-w-screen-inset -translate-x-1/2 rounded-lg bg-background'} {view ===
253
276
  'blocked'
254
277
  ? 'border-destructive/50'
255
278
  : ''}"
@@ -10,6 +10,13 @@ interface Props {
10
10
  onabort: () => void;
11
11
  onconfirm: () => void;
12
12
  ondiscard: () => void;
13
+ /**
14
+ * See `createSolvingIndicator`. The live panel would only paint once the solve is over, so
15
+ * an Esc hint stands in for it; the result's messages still show.
16
+ */
17
+ freezesDuringSolve?: boolean;
18
+ /** `floating` overlays the bottom centre; `inline` sits in the flow below the host's content. */
19
+ placement?: 'floating' | 'inline';
13
20
  }
14
21
  declare const SolveMessages: import("svelte").Component<Props, {}, "">;
15
22
  type SolveMessages = ReturnType<typeof SolveMessages>;
@@ -136,6 +136,8 @@
136
136
  <FooterItemRenderer {item} />
137
137
  {/each}
138
138
 
139
- <p class="truncate">{copyrightLine}</p>
139
+ {#if copyrightLine}
140
+ <p class="truncate">{copyrightLine}</p>
141
+ {/if}
140
142
  </div>
141
143
  </footer>
@@ -13,6 +13,9 @@
13
13
  <script lang="ts">
14
14
  import type { Snippet } from 'svelte';
15
15
  import { page } from '$app/state';
16
+ import ChevronsUpDown from '@lucide/svelte/icons/chevrons-up-down';
17
+ import Check from '@lucide/svelte/icons/check';
18
+ import * as DropdownMenu from '../primitives/dropdown-menu/index.js';
16
19
 
17
20
  interface SideNavProps {
18
21
  items: SideNavItem[];
@@ -41,10 +44,12 @@
41
44
  if (item.match === 'prefix') return path.startsWith(item.href);
42
45
  return path === item.href;
43
46
  }
47
+
48
+ const activeItem = $derived(items.find(isActive));
44
49
  </script>
45
50
 
46
- <!-- On a phone a 240px column would leave the page ~150px, so the nav becomes a scrolling strip
47
- above the content and the eyebrow, header and footer drop out. -->
51
+ <!-- On a phone a 240px column would leave the page ~150px, so the nav collapses to one switcher
52
+ naming the current section; the header and footer drop out. -->
48
53
  <aside
49
54
  class={`sm:h-full sm:w-60 sm:overflow-y-auto sm:border-b-0 flex w-full shrink-0 flex-col border-b border-border bg-background ${
50
55
  border ? 'sm:border-r' : ''
@@ -64,31 +69,67 @@
64
69
  </div>
65
70
  {/if}
66
71
 
67
- <nav
68
- class="gap-1 py-2 sm:gap-0 sm:pt-0 sm:pb-4 sm:pr-2 sm:flex-1 sm:space-y-px sm:block sm:px-0 flex overflow-x-auto px-(--page-px)"
69
- >
72
+ <div class="py-2 sm:hidden px-(--page-px)">
73
+ <DropdownMenu.Root>
74
+ <DropdownMenu.Trigger>
75
+ {#snippet child({ props })}
76
+ <button
77
+ {...props}
78
+ class="min-h-11 gap-3 px-3 text-sm font-medium flex w-full items-center rounded-md border border-border bg-card text-left"
79
+ >
80
+ {#if activeItem?.icon}
81
+ {@const Icon = activeItem.icon}
82
+ <Icon class="size-4 shrink-0 opacity-70" />
83
+ {/if}
84
+ <span class="min-w-0 flex-1 truncate">
85
+ {#if eyebrow}<span class="text-muted-foreground">{eyebrow} /</span>{/if}
86
+ {activeItem?.label ?? ''}
87
+ </span>
88
+ <ChevronsUpDown class="size-4 shrink-0 opacity-60" />
89
+ </button>
90
+ {/snippet}
91
+ </DropdownMenu.Trigger>
92
+ <DropdownMenu.Content sideOffset={4}>
93
+ {#each items as item (item.href)}
94
+ {@const active = isActive(item)}
95
+ {@const Icon = item.icon}
96
+ <DropdownMenu.Item class="min-h-11">
97
+ {#snippet child({ props })}
98
+ <a href={item.href} aria-current={active ? 'page' : undefined} {...props}>
99
+ {#if Icon}<Icon class="size-4 opacity-70" />{/if}
100
+ <span class="flex-1">{item.label}</span>
101
+ {#if item.badge !== undefined}
102
+ <span class="font-mono text-2xs tabular-nums opacity-60">{item.badge}</span>
103
+ {/if}
104
+ {#if active}<Check class="size-4" aria-hidden="true" />{/if}
105
+ </a>
106
+ {/snippet}
107
+ </DropdownMenu.Item>
108
+ {/each}
109
+ </DropdownMenu.Content>
110
+ </DropdownMenu.Root>
111
+ </div>
112
+
113
+ <nav class="pb-4 pr-2 sm:block hidden flex-1 space-y-px">
70
114
  {#each items as item (item.href)}
71
115
  {@const active = isActive(item)}
72
116
  {@const Icon = item.icon}
73
117
  <a
74
118
  href={item.href}
75
- class={`group gap-2 py-2 pr-2.5 sm:py-1.5 sm:w-full relative flex shrink-0 items-center text-left transition-colors ${
119
+ aria-current={active ? 'page' : undefined}
120
+ class={`group gap-2 py-1.5 pr-2.5 relative flex w-full items-center text-left transition-colors ${
76
121
  active
77
- ? 'pl-2.5 sm:pl-4 sm:rounded-l-none sm:rounded-r-md rounded-md bg-accent text-accent-foreground'
78
- : 'sm:mx-2 pl-2.5 rounded-md text-muted-foreground hover:bg-muted/60 hover:text-foreground'
122
+ ? 'pl-4 rounded-r-md bg-accent text-accent-foreground'
123
+ : 'mx-2 pl-2.5 rounded-md text-muted-foreground hover:bg-muted/60 hover:text-foreground'
79
124
  }`}
80
125
  >
81
126
  {#if active}
82
- <span
83
- class="left-0 top-0 bottom-0 w-0.75 sm:block absolute hidden bg-primary"
84
- aria-hidden="true"
85
- ></span>
127
+ <span class="left-0 top-0 bottom-0 w-0.75 absolute bg-primary" aria-hidden="true"></span>
86
128
  {/if}
87
129
  {#if Icon}
88
130
  <Icon class="h-3.5 w-3.5 shrink-0 opacity-70" />
89
131
  {/if}
90
- <span class="text-sm font-medium sm:flex-1 sm:truncate whitespace-nowrap">{item.label}</span
91
- >
132
+ <span class="text-sm font-medium flex-1 truncate">{item.label}</span>
92
133
  {#if item.badge !== undefined}
93
134
  <span class="font-mono shrink-0 text-2xs tabular-nums opacity-60">{item.badge}</span>
94
135
  {/if}
@@ -54,6 +54,9 @@
54
54
  return window.Plotly;
55
55
  }
56
56
 
57
+ // Some embedded WebViews (WKWebView in the Rhino panel) ship without the Fullscreen API at all.
58
+ const fullscreenSupported = typeof document !== 'undefined' && !!document.fullscreenEnabled;
59
+
57
60
  function toggleFullscreen() {
58
61
  if (!wrapperEl) return;
59
62
  if (!document.fullscreenElement) {
@@ -211,19 +214,21 @@
211
214
  </div>
212
215
  {/if}
213
216
 
214
- <button
215
- onclick={toggleFullscreen}
216
- disabled={loading}
217
- title={isFullscreen ? t.exitFullscreen : t.fullscreen}
218
- aria-label={isFullscreen ? t.exitFullscreen : t.fullscreen}
219
- 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"
220
- >
221
- {#if isFullscreen}
222
- <Minimize size={14} />
223
- {:else}
224
- <Maximize size={14} />
225
- {/if}
226
- </button>
217
+ {#if fullscreenSupported}
218
+ <button
219
+ onclick={toggleFullscreen}
220
+ disabled={loading}
221
+ title={isFullscreen ? t.exitFullscreen : t.fullscreen}
222
+ aria-label={isFullscreen ? t.exitFullscreen : t.fullscreen}
223
+ 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"
224
+ >
225
+ {#if isFullscreen}
226
+ <Minimize size={14} />
227
+ {:else}
228
+ <Maximize size={14} />
229
+ {/if}
230
+ </button>
231
+ {/if}
227
232
  </div>
228
233
  {/if}
229
234
  </div>
@@ -69,7 +69,7 @@
69
69
  </div>
70
70
  {/snippet}
71
71
 
72
- <section class="shadow-xs rounded-xl bg-card">
72
+ <section class="shadow-xs rounded-xl border bg-card">
73
73
  {#if flat}
74
74
  <div class="p-4">
75
75
  {@render grid()}
@@ -160,6 +160,11 @@
160
160
  grid-column: span var(--span) / span var(--span);
161
161
  }
162
162
 
163
+ /* A control with no label above it lines up with the controls beside it, not their labels. */
164
+ .grid-cell:has(:global([data-control-line])) {
165
+ align-self: end;
166
+ }
167
+
163
168
  /* An empty full-width row: the two row gaps around it already read as a break. */
164
169
  .grid-break {
165
170
  grid-column: 1 / -1;
@@ -62,7 +62,9 @@
62
62
 
63
63
  const cfg = $derived(item.config ?? {});
64
64
  const allowDownload = $derived(cfg.allowDownload ?? true);
65
- const allowFullscreen = $derived(cfg.allowFullscreen ?? true);
65
+ // Some embedded WebViews (WKWebView in the Rhino panel) ship without the Fullscreen API at all.
66
+ const fullscreenSupported = typeof document !== 'undefined' && !!document.fullscreenEnabled;
67
+ const allowFullscreen = $derived((cfg.allowFullscreen ?? true) && fullscreenSupported);
66
68
  const backgroundColor = $derived(cfg.backgroundColor);
67
69
 
68
70
  let wrapperEl = $state<HTMLDivElement | null>(null);