@selvajs/ui 7.0.0 → 7.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,7 +4,7 @@
4
4
  import type { LiveSolveState, SolveSettlement } from '@selvajs/solve/client';
5
5
  import type { ActionButton } from '../../types/actionButton';
6
6
  import type { PresetLabels } from '../../types/presetLabels';
7
- import { ChevronUp, Download, Ellipsis, Upload } from '@lucide/svelte';
7
+ import { ChevronLeft, ChevronUp, Download, Ellipsis, Upload } from '@lucide/svelte';
8
8
  import type { ThreeViewer } from '@selvajs/visualization/render';
9
9
  import Viewer, { type ViewerConfig } from '../viewer/Viewer.svelte';
10
10
  import CalculateButton from '../primitives/CalculateButton.svelte';
@@ -155,14 +155,27 @@
155
155
  // With several tabs the first tab's name would mislabel whichever one is showing.
156
156
  const drawerLabel = $derived(leftTabs.length === 1 ? leftTabs[0].label : t.parametersTab);
157
157
 
158
+ // A phone on its side is wide enough for the desktop columns but far too short for them, and a
159
+ // bottom drawer would leave the model a strip. It gets the phone layout with the drawer moved to
160
+ // the right edge instead.
161
+ let sideSheet = $state(false);
162
+
158
163
  $effect(() => {
159
- const mqMobile = window.matchMedia('(max-width: 639px)');
164
+ const mqNarrow = window.matchMedia('(max-width: 639px)');
165
+ const mqPhoneLandscape = window.matchMedia(
166
+ '(pointer: coarse) and (orientation: landscape) and (max-height: 500px)'
167
+ );
160
168
  function update() {
161
- isMobile = mqMobile.matches;
169
+ sideSheet = mqPhoneLandscape.matches;
170
+ isMobile = mqNarrow.matches || sideSheet;
162
171
  }
163
172
  update();
164
- mqMobile.addEventListener('change', update);
165
- return () => mqMobile.removeEventListener('change', update);
173
+ mqNarrow.addEventListener('change', update);
174
+ mqPhoneLandscape.addEventListener('change', update);
175
+ return () => {
176
+ mqNarrow.removeEventListener('change', update);
177
+ mqPhoneLandscape.removeEventListener('change', update);
178
+ };
166
179
  });
167
180
 
168
181
  // Without a viewer there's nothing to sit beside, so a narrow host (a docked Rhino panel) gets
@@ -189,30 +202,39 @@
189
202
  await onLoadValues?.(loadedValues);
190
203
  }
191
204
 
192
- let touchStartY = 0;
205
+ let touchStart = { x: 0, y: 0 };
193
206
 
207
+ // The side sheet beside the model has no half state: it is open or closed.
194
208
  function stepDrawer(by: number) {
209
+ if (sideSheet) {
210
+ drawer = by > 0 ? 'full' : 'closed';
211
+ return;
212
+ }
195
213
  const index = DRAWER_STATES.indexOf(drawer) + by;
196
214
  drawer = DRAWER_STATES[Math.max(0, Math.min(DRAWER_STATES.length - 1, index))];
197
215
  }
198
216
 
199
217
  function onDrawerTouchStart(e: TouchEvent) {
200
- touchStartY = e.touches[0].clientY;
218
+ touchStart = { x: e.touches[0].clientX, y: e.touches[0].clientY };
201
219
  }
202
220
 
221
+ // Toward the open side opens: up for the bottom drawer, left for the side sheet.
203
222
  function onDrawerTouchEnd(e: TouchEvent) {
204
- const deltaY = e.changedTouches[0].clientY - touchStartY;
223
+ const delta = sideSheet
224
+ ? e.changedTouches[0].clientX - touchStart.x
225
+ : e.changedTouches[0].clientY - touchStart.y;
205
226
  const SWIPE_THRESHOLD = 40;
206
- if (Math.abs(deltaY) > SWIPE_THRESHOLD) {
227
+ if (Math.abs(delta) > SWIPE_THRESHOLD) {
207
228
  // Suppresses the click a short swipe can still end in, which would step the drawer twice.
208
229
  e.preventDefault();
209
- stepDrawer(deltaY < 0 ? 1 : -1);
230
+ stepDrawer(delta < 0 ? 1 : -1);
210
231
  }
211
232
  }
212
233
 
213
- // A tap walks closed → half → full → closed.
234
+ // A tap walks closed → half → full → closed; the side sheet just toggles.
214
235
  function onDrawerHandleClick() {
215
- drawer = drawer === 'full' ? 'closed' : DRAWER_STATES[DRAWER_STATES.indexOf(drawer) + 1];
236
+ if (sideSheet) drawer = drawer === 'closed' ? 'full' : 'closed';
237
+ else drawer = drawer === 'full' ? 'closed' : DRAWER_STATES[DRAWER_STATES.indexOf(drawer) + 1];
216
238
  }
217
239
  </script>
218
240
 
@@ -340,6 +362,7 @@
340
362
  class:fullscreen-layout={isViewerFullscreen}
341
363
  class:relative={isMobile}
342
364
  class:mobile-root={stacked}
365
+ class:side-sheet={isMobile && sideSheet}
343
366
  bind:clientWidth={rootWidth}
344
367
  >
345
368
  {#if stacked}
@@ -351,7 +374,7 @@
351
374
  <Viewer
352
375
  {meshes}
353
376
  bind:isFullscreen={isViewerFullscreen}
354
- drawerOpen={drawer === 'full'}
377
+ drawerOpen={!sideSheet && drawer === 'full'}
355
378
  {logoUrl}
356
379
  {onViewerReady}
357
380
  viewerConfig={{
@@ -364,7 +387,9 @@
364
387
  </div>
365
388
 
366
389
  <div
367
- class="drawer-container z-40 rounded-t-xl border-t bg-background shadow-sheet drawer-{drawer}"
390
+ class="drawer-container z-40 bg-background shadow-sheet drawer-{drawer} {sideSheet
391
+ ? 'rounded-l-xl border-l'
392
+ : 'rounded-t-xl border-t'}"
368
393
  >
369
394
  <!-- Named by its visible label; aria-expanded carries the open/closed state. -->
370
395
  <button
@@ -375,38 +400,53 @@
375
400
  aria-expanded={drawer !== 'closed'}
376
401
  >
377
402
  <div class="drawer-pill"></div>
378
- <span class="text-sm font-medium ml-2 text-foreground">{drawerLabel}</span>
379
- <ChevronUp
380
- size={16}
381
- class="ml-auto text-muted-foreground transition-transform duration-300 {drawer ===
382
- 'full'
383
- ? 'rotate-180'
384
- : ''}"
385
- />
403
+ <span class="drawer-label text-sm font-medium text-foreground">{drawerLabel}</span>
404
+ {#if sideSheet}
405
+ <ChevronLeft
406
+ size={16}
407
+ class="text-muted-foreground transition-transform duration-300 {drawer !== 'closed'
408
+ ? 'rotate-180'
409
+ : ''}"
410
+ />
411
+ {:else}
412
+ <ChevronUp
413
+ size={16}
414
+ class="ml-auto text-muted-foreground transition-transform duration-300 {drawer ===
415
+ 'full'
416
+ ? 'rotate-180'
417
+ : ''}"
418
+ />
419
+ {/if}
386
420
  </button>
387
421
 
388
- <!-- Inert while closed: the fields are clipped out of view, and tabbing into them
422
+ <div class="drawer-body">
423
+ <!-- Inert while closed: the fields are clipped out of view, and tabbing into them
389
424
  would scroll the drawer's insides around behind the handle. -->
390
- <div class="drawer-content" inert={drawer === 'closed'}>
391
- <div class="px-2 pb-4">
392
- {#if hasLeftPanel}
393
- {@render panelContent(hasRightPanel ? 'left' : undefined, requestedLeftTabId, false)}
394
- {/if}
395
- {#if hasRightPanel}
396
- <!-- Decorative: the section below names itself with a heading. -->
397
- <div class="my-4 gap-2 flex items-center" aria-hidden="true">
398
- <div class="h-px flex-1 bg-border"></div>
399
- <span class="text-xs font-medium px-1 text-muted-foreground">
400
- {rightTabs[0]?.label ?? t.moreTab}
401
- </span>
402
- <div class="h-px flex-1 bg-border"></div>
403
- </div>
404
- {@render panelContent('right', requestedRightTabId, false)}
405
- {/if}
425
+ <div class="drawer-content" inert={drawer === 'closed'}>
426
+ <div class="px-2 pb-4">
427
+ {#if hasLeftPanel}
428
+ {@render panelContent(
429
+ hasRightPanel ? 'left' : undefined,
430
+ requestedLeftTabId,
431
+ false
432
+ )}
433
+ {/if}
434
+ {#if hasRightPanel}
435
+ <!-- Decorative: the section below names itself with a heading. -->
436
+ <div class="my-4 gap-2 flex items-center" aria-hidden="true">
437
+ <div class="h-px flex-1 bg-border"></div>
438
+ <span class="text-xs font-medium px-1 text-muted-foreground">
439
+ {rightTabs[0]?.label ?? t.moreTab}
440
+ </span>
441
+ <div class="h-px flex-1 bg-border"></div>
442
+ </div>
443
+ {@render panelContent('right', requestedRightTabId, false)}
444
+ {/if}
445
+ </div>
406
446
  </div>
407
- </div>
408
447
 
409
- {@render drawerFooter(drawer === 'closed')}
448
+ {@render drawerFooter(drawer === 'closed')}
449
+ </div>
410
450
  </div>
411
451
  {:else}
412
452
  <div class="flex flex-1 flex-col overflow-hidden">
@@ -620,6 +660,12 @@
620
660
  bottom: var(--drawer-half-h);
621
661
  }
622
662
 
663
+ /* The viewer's own minimum height is for a page that scrolls; here the region sets it, and
664
+ anything taller would run under the drawer and take the viewer's dock with it. */
665
+ .viewer-region > :global(*) {
666
+ min-height: 0;
667
+ }
668
+
623
669
  .drawer-container {
624
670
  position: absolute;
625
671
  bottom: 0;
@@ -673,6 +719,77 @@
673
719
  opacity: 0.4;
674
720
  }
675
721
 
722
+ .drawer-body {
723
+ flex: 1;
724
+ min-height: 0;
725
+ display: flex;
726
+ flex-direction: column;
727
+ }
728
+
729
+ .drawer-label {
730
+ margin-left: 0.5rem;
731
+ }
732
+
733
+ /* Phone on its side: the drawer becomes a sheet on the right, its handle a strip down its edge. */
734
+ .side-sheet {
735
+ --sheet-closed-w: 3.25rem;
736
+ /* vw, not %: the drawer body resolves this against the sheet, not the layout. */
737
+ --sheet-open-w: min(22rem, 55vw);
738
+ }
739
+
740
+ .side-sheet .viewer-region {
741
+ inset: 0 var(--sheet-closed-w) 0 0;
742
+ }
743
+
744
+ .side-sheet .viewer-region-raised {
745
+ right: var(--sheet-open-w);
746
+ }
747
+
748
+ .side-sheet .drawer-container {
749
+ top: 0;
750
+ left: auto;
751
+ height: auto;
752
+ flex-direction: row;
753
+ transition: width 0.35s cubic-bezier(0.32, 0.72, 0, 1);
754
+ }
755
+
756
+ .side-sheet .drawer-closed {
757
+ width: var(--sheet-closed-w);
758
+ }
759
+
760
+ .side-sheet .drawer-half,
761
+ .side-sheet .drawer-full {
762
+ width: var(--sheet-open-w);
763
+ }
764
+
765
+ .side-sheet .drawer-handle-bar {
766
+ width: var(--sheet-closed-w);
767
+ height: auto;
768
+ padding: 0 0 0 0.75rem;
769
+ flex-direction: column;
770
+ justify-content: center;
771
+ gap: 0.75rem;
772
+ }
773
+
774
+ .side-sheet .drawer-pill {
775
+ top: 50%;
776
+ left: 8px;
777
+ transform: translateY(-50%);
778
+ width: 4px;
779
+ height: 2.5rem;
780
+ }
781
+
782
+ .side-sheet .drawer-label {
783
+ margin-left: 0;
784
+ writing-mode: vertical-rl;
785
+ transform: rotate(180deg);
786
+ }
787
+
788
+ /* Holds its open width while the sheet slides, so the fields don't reflow on every frame. */
789
+ .side-sheet .drawer-body {
790
+ min-width: calc(var(--sheet-open-w) - var(--sheet-closed-w));
791
+ }
792
+
676
793
  .drawer-content {
677
794
  flex: 1;
678
795
  overflow-y: auto;
@@ -137,7 +137,10 @@
137
137
  {/each}
138
138
 
139
139
  {#if copyrightLine}
140
- <p class="truncate">{copyrightLine}</p>
140
+ <!-- On a phone, upright or on its side, the row is for status and messages. -->
141
+ <p class="max-sm:hidden truncate [@media(pointer:coarse)_and_(max-height:500px)]:hidden">
142
+ {copyrightLine}
143
+ </p>
141
144
  {/if}
142
145
  </div>
143
146
  </footer>
@@ -105,8 +105,8 @@
105
105
  // Opening leads: the pill grows first and the buttons pop in behind it. Closing mirrors it: the
106
106
  // buttons fade first, then the pill shrinks. Without the delay on close the row is clipped mid-fade.
107
107
  const EASE = 'cubic-bezier(0.32, 0.72, 0, 1)';
108
+ const bottom = $derived(touch.current ? 'max(1rem,env(safe-area-inset-bottom))' : '0.75rem');
108
109
  const containerStyle = $derived.by(() => {
109
- const bottom = touch.current ? 'max(1rem,env(safe-area-inset-bottom))' : '0.75rem';
110
110
  const geometry = wantOpen
111
111
  ? `width:${rowWidth + PAD * 2 + 2}px;height:${EXPANDED_H}px;border-radius:12px;padding:${PAD}px`
112
112
  : `width:${COLLAPSED.w}px;height:${COLLAPSED.h}px;border-radius:${touch.current ? 22 : 99}px;padding:0px`;
@@ -129,7 +129,8 @@
129
129
  `opacity:${touch.current && !wantOpen ? 1 : 0};${reducedMotion.current ? '' : `transition:opacity ${wantOpen ? 100 : 100}ms ease ${wantOpen || dismissed ? 0 : 60}ms`}`
130
130
  );
131
131
 
132
- function scheduleCollapse(delay = touch.current ? 3500 : 150) {
132
+ // Touch has no hover to reopen it, so there the dock stays open until a press lands outside it.
133
+ function scheduleCollapse(delay = 150) {
133
134
  clearTimeout(idleTimer);
134
135
  idleTimer = setTimeout(() => (engaged = false), delay);
135
136
  }
@@ -168,12 +169,45 @@
168
169
  function openFromCollapsed() {
169
170
  if (wantOpen) return;
170
171
  engage();
171
- if (touch.current) scheduleCollapse();
172
172
  }
173
173
 
174
- // Any use of the open dock on touch pushes the idle collapse back.
175
- function keepAlive() {
176
- if (touch.current && engaged) scheduleCollapse();
174
+ // ==========================================================================
175
+ // Long-press labels
176
+ // ==========================================================================
177
+
178
+ // The icons carry no text and touch has no tooltip, so holding a button names it instead of
179
+ // pressing it.
180
+ const LONG_PRESS_MS = 450;
181
+ const HINT_LINGER_MS = 1200;
182
+ let hint = $state<string | null>(null);
183
+ let pressTimer: ReturnType<typeof setTimeout> | undefined;
184
+ let hintTimer: ReturnType<typeof setTimeout> | undefined;
185
+ let swallowClick = false;
186
+
187
+ function startPress(event: PointerEvent) {
188
+ if (event.pointerType !== 'touch' || !wantOpen || !(event.target instanceof Element)) return;
189
+ const button = event.target.closest('button[aria-label]');
190
+ if (!button) return;
191
+ clearTimeout(pressTimer);
192
+ pressTimer = setTimeout(() => {
193
+ // Keyboard shortcuts in the label mean nothing on a phone.
194
+ hint = (button.getAttribute('aria-label') ?? '').replace(/\s*\([A-Z]\)$/, '');
195
+ swallowClick = true;
196
+ }, LONG_PRESS_MS);
197
+ }
198
+
199
+ function endPress() {
200
+ clearTimeout(pressTimer);
201
+ if (!hint) return;
202
+ clearTimeout(hintTimer);
203
+ hintTimer = setTimeout(() => (hint = null), HINT_LINGER_MS);
204
+ }
205
+
206
+ function swallowLongPressClick(event: MouseEvent) {
207
+ if (!swallowClick) return;
208
+ swallowClick = false;
209
+ event.preventDefault();
210
+ event.stopPropagation();
177
211
  }
178
212
 
179
213
  function trackMenu(open: boolean) {
@@ -210,7 +244,7 @@
210
244
  role="toolbar"
211
245
  aria-label={t.toolsMenu}
212
246
  tabindex="-1"
213
- class="backdrop-blur-md absolute left-1/2 z-50 -translate-x-1/2 overflow-hidden transition-colors {hidden
247
+ class="backdrop-blur-md absolute left-1/2 z-50 -translate-x-1/2 overflow-hidden transition-colors select-none {hidden
214
248
  ? 'pointer-events-none opacity-0'
215
249
  : ''} {wantOpen
216
250
  ? 'shadow-md border border-border-strong bg-card/75'
@@ -219,14 +253,18 @@
219
253
  : nightOn
220
254
  ? 'border-white/25 bg-white/35 hover:bg-white/70 cursor-pointer border'
221
255
  : 'shadow-sm cursor-pointer border border-border-strong bg-card/90 hover:bg-card'}"
222
- style={containerStyle}
256
+ style="{containerStyle};-webkit-touch-callout:none"
223
257
  onclick={openFromCollapsed}
224
258
  onkeydown={(e) => (e.key === 'Escape' ? (engaged = false) : undefined)}
225
259
  onfocusin={() => (focusWithin = true)}
226
260
  onfocusout={(e) => {
227
261
  if (!dockEl?.contains(e.relatedTarget as Node | null)) focusWithin = false;
228
262
  }}
229
- onpointerdown={keepAlive}
263
+ onpointerdown={startPress}
264
+ onpointerup={endPress}
265
+ onpointercancel={endPress}
266
+ onclickcapture={swallowLongPressClick}
267
+ oncontextmenu={(e) => touch.current && e.preventDefault()}
230
268
  >
231
269
  {#if touch.current}
232
270
  <span
@@ -402,3 +440,13 @@
402
440
  </div>
403
441
  </div>
404
442
  </div>
443
+
444
+ {#if hint}
445
+ <div
446
+ role="status"
447
+ class="text-xs font-medium px-2.5 py-1 shadow-md pointer-events-none absolute left-1/2 z-50 -translate-x-1/2 rounded-md border border-border-strong bg-card text-foreground"
448
+ style="bottom:calc({bottom} + {EXPANDED_H + 8}px)"
449
+ >
450
+ {hint}
451
+ </div>
452
+ {/if}
@@ -190,6 +190,13 @@
190
190
  border-radius: inherit;
191
191
  }
192
192
 
193
+ /* A phone, upright or on its side, gives the model the height a full desktop bar would take. */
194
+ @media (max-width: 39.999rem), (pointer: coarse) and (max-height: 500px) {
195
+ :root {
196
+ --header-h: 3rem;
197
+ }
198
+ }
199
+
193
200
  /* Phones keep a narrower gutter: at 390px every 8px of side padding is 2% of the width. */
194
201
  @media (min-width: 40rem) {
195
202
  :root {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@selvajs/ui",
3
- "version": "7.0.0",
3
+ "version": "7.0.1",
4
4
  "description": "Shared UI components and utilities for Selva applications",
5
5
  "license": "MIT",
6
6
  "author": "VektorNode",
@@ -62,10 +62,10 @@
62
62
  "svelte": "^5",
63
63
  "tailwind-variants": "^3.3.1",
64
64
  "three": "^0.187.0",
65
- "@selvajs/solve": "^2.0.0",
65
+ "@selvajs/compute": "^5.0.0",
66
66
  "@selvajs/schemas": "^5.3.0",
67
- "@selvajs/visualization": "^1.6.0",
68
- "@selvajs/compute": "^5.0.0"
67
+ "@selvajs/solve": "^2.0.0",
68
+ "@selvajs/visualization": "^1.6.1"
69
69
  },
70
70
  "peerDependenciesMeta": {
71
71
  "three": {
@@ -96,9 +96,9 @@
96
96
  "vitest": "^5.0.5",
97
97
  "@selvajs/compute": "5.0.0",
98
98
  "@selvajs/schemas": "5.3.0",
99
- "@selvajs/config": "0.0.4",
100
99
  "@selvajs/solve": "2.0.0",
101
- "@selvajs/visualization": "1.6.0"
100
+ "@selvajs/visualization": "1.6.1",
101
+ "@selvajs/config": "0.0.4"
102
102
  },
103
103
  "imports": {
104
104
  "#lib": "./src/lib/index.js",
@@ -4,7 +4,7 @@
4
4
  import type { LiveSolveState, SolveSettlement } from '@selvajs/solve/client';
5
5
  import type { ActionButton } from '../../types/actionButton';
6
6
  import type { PresetLabels } from '../../types/presetLabels';
7
- import { ChevronUp, Download, Ellipsis, Upload } from '@lucide/svelte';
7
+ import { ChevronLeft, ChevronUp, Download, Ellipsis, Upload } from '@lucide/svelte';
8
8
  import type { ThreeViewer } from '@selvajs/visualization/render';
9
9
  import Viewer, { type ViewerConfig } from '../viewer/Viewer.svelte';
10
10
  import CalculateButton from '../primitives/CalculateButton.svelte';
@@ -155,14 +155,27 @@
155
155
  // With several tabs the first tab's name would mislabel whichever one is showing.
156
156
  const drawerLabel = $derived(leftTabs.length === 1 ? leftTabs[0].label : t.parametersTab);
157
157
 
158
+ // A phone on its side is wide enough for the desktop columns but far too short for them, and a
159
+ // bottom drawer would leave the model a strip. It gets the phone layout with the drawer moved to
160
+ // the right edge instead.
161
+ let sideSheet = $state(false);
162
+
158
163
  $effect(() => {
159
- const mqMobile = window.matchMedia('(max-width: 639px)');
164
+ const mqNarrow = window.matchMedia('(max-width: 639px)');
165
+ const mqPhoneLandscape = window.matchMedia(
166
+ '(pointer: coarse) and (orientation: landscape) and (max-height: 500px)'
167
+ );
160
168
  function update() {
161
- isMobile = mqMobile.matches;
169
+ sideSheet = mqPhoneLandscape.matches;
170
+ isMobile = mqNarrow.matches || sideSheet;
162
171
  }
163
172
  update();
164
- mqMobile.addEventListener('change', update);
165
- return () => mqMobile.removeEventListener('change', update);
173
+ mqNarrow.addEventListener('change', update);
174
+ mqPhoneLandscape.addEventListener('change', update);
175
+ return () => {
176
+ mqNarrow.removeEventListener('change', update);
177
+ mqPhoneLandscape.removeEventListener('change', update);
178
+ };
166
179
  });
167
180
 
168
181
  // Without a viewer there's nothing to sit beside, so a narrow host (a docked Rhino panel) gets
@@ -189,30 +202,39 @@
189
202
  await onLoadValues?.(loadedValues);
190
203
  }
191
204
 
192
- let touchStartY = 0;
205
+ let touchStart = { x: 0, y: 0 };
193
206
 
207
+ // The side sheet beside the model has no half state: it is open or closed.
194
208
  function stepDrawer(by: number) {
209
+ if (sideSheet) {
210
+ drawer = by > 0 ? 'full' : 'closed';
211
+ return;
212
+ }
195
213
  const index = DRAWER_STATES.indexOf(drawer) + by;
196
214
  drawer = DRAWER_STATES[Math.max(0, Math.min(DRAWER_STATES.length - 1, index))];
197
215
  }
198
216
 
199
217
  function onDrawerTouchStart(e: TouchEvent) {
200
- touchStartY = e.touches[0].clientY;
218
+ touchStart = { x: e.touches[0].clientX, y: e.touches[0].clientY };
201
219
  }
202
220
 
221
+ // Toward the open side opens: up for the bottom drawer, left for the side sheet.
203
222
  function onDrawerTouchEnd(e: TouchEvent) {
204
- const deltaY = e.changedTouches[0].clientY - touchStartY;
223
+ const delta = sideSheet
224
+ ? e.changedTouches[0].clientX - touchStart.x
225
+ : e.changedTouches[0].clientY - touchStart.y;
205
226
  const SWIPE_THRESHOLD = 40;
206
- if (Math.abs(deltaY) > SWIPE_THRESHOLD) {
227
+ if (Math.abs(delta) > SWIPE_THRESHOLD) {
207
228
  // Suppresses the click a short swipe can still end in, which would step the drawer twice.
208
229
  e.preventDefault();
209
- stepDrawer(deltaY < 0 ? 1 : -1);
230
+ stepDrawer(delta < 0 ? 1 : -1);
210
231
  }
211
232
  }
212
233
 
213
- // A tap walks closed → half → full → closed.
234
+ // A tap walks closed → half → full → closed; the side sheet just toggles.
214
235
  function onDrawerHandleClick() {
215
- drawer = drawer === 'full' ? 'closed' : DRAWER_STATES[DRAWER_STATES.indexOf(drawer) + 1];
236
+ if (sideSheet) drawer = drawer === 'closed' ? 'full' : 'closed';
237
+ else drawer = drawer === 'full' ? 'closed' : DRAWER_STATES[DRAWER_STATES.indexOf(drawer) + 1];
216
238
  }
217
239
  </script>
218
240
 
@@ -340,6 +362,7 @@
340
362
  class:fullscreen-layout={isViewerFullscreen}
341
363
  class:relative={isMobile}
342
364
  class:mobile-root={stacked}
365
+ class:side-sheet={isMobile && sideSheet}
343
366
  bind:clientWidth={rootWidth}
344
367
  >
345
368
  {#if stacked}
@@ -351,7 +374,7 @@
351
374
  <Viewer
352
375
  {meshes}
353
376
  bind:isFullscreen={isViewerFullscreen}
354
- drawerOpen={drawer === 'full'}
377
+ drawerOpen={!sideSheet && drawer === 'full'}
355
378
  {logoUrl}
356
379
  {onViewerReady}
357
380
  viewerConfig={{
@@ -364,7 +387,9 @@
364
387
  </div>
365
388
 
366
389
  <div
367
- class="drawer-container z-40 rounded-t-xl border-t bg-background shadow-sheet drawer-{drawer}"
390
+ class="drawer-container z-40 bg-background shadow-sheet drawer-{drawer} {sideSheet
391
+ ? 'rounded-l-xl border-l'
392
+ : 'rounded-t-xl border-t'}"
368
393
  >
369
394
  <!-- Named by its visible label; aria-expanded carries the open/closed state. -->
370
395
  <button
@@ -375,38 +400,53 @@
375
400
  aria-expanded={drawer !== 'closed'}
376
401
  >
377
402
  <div class="drawer-pill"></div>
378
- <span class="text-sm font-medium ml-2 text-foreground">{drawerLabel}</span>
379
- <ChevronUp
380
- size={16}
381
- class="ml-auto text-muted-foreground transition-transform duration-300 {drawer ===
382
- 'full'
383
- ? 'rotate-180'
384
- : ''}"
385
- />
403
+ <span class="drawer-label text-sm font-medium text-foreground">{drawerLabel}</span>
404
+ {#if sideSheet}
405
+ <ChevronLeft
406
+ size={16}
407
+ class="text-muted-foreground transition-transform duration-300 {drawer !== 'closed'
408
+ ? 'rotate-180'
409
+ : ''}"
410
+ />
411
+ {:else}
412
+ <ChevronUp
413
+ size={16}
414
+ class="ml-auto text-muted-foreground transition-transform duration-300 {drawer ===
415
+ 'full'
416
+ ? 'rotate-180'
417
+ : ''}"
418
+ />
419
+ {/if}
386
420
  </button>
387
421
 
388
- <!-- Inert while closed: the fields are clipped out of view, and tabbing into them
422
+ <div class="drawer-body">
423
+ <!-- Inert while closed: the fields are clipped out of view, and tabbing into them
389
424
  would scroll the drawer's insides around behind the handle. -->
390
- <div class="drawer-content" inert={drawer === 'closed'}>
391
- <div class="px-2 pb-4">
392
- {#if hasLeftPanel}
393
- {@render panelContent(hasRightPanel ? 'left' : undefined, requestedLeftTabId, false)}
394
- {/if}
395
- {#if hasRightPanel}
396
- <!-- Decorative: the section below names itself with a heading. -->
397
- <div class="my-4 gap-2 flex items-center" aria-hidden="true">
398
- <div class="h-px flex-1 bg-border"></div>
399
- <span class="text-xs font-medium px-1 text-muted-foreground">
400
- {rightTabs[0]?.label ?? t.moreTab}
401
- </span>
402
- <div class="h-px flex-1 bg-border"></div>
403
- </div>
404
- {@render panelContent('right', requestedRightTabId, false)}
405
- {/if}
425
+ <div class="drawer-content" inert={drawer === 'closed'}>
426
+ <div class="px-2 pb-4">
427
+ {#if hasLeftPanel}
428
+ {@render panelContent(
429
+ hasRightPanel ? 'left' : undefined,
430
+ requestedLeftTabId,
431
+ false
432
+ )}
433
+ {/if}
434
+ {#if hasRightPanel}
435
+ <!-- Decorative: the section below names itself with a heading. -->
436
+ <div class="my-4 gap-2 flex items-center" aria-hidden="true">
437
+ <div class="h-px flex-1 bg-border"></div>
438
+ <span class="text-xs font-medium px-1 text-muted-foreground">
439
+ {rightTabs[0]?.label ?? t.moreTab}
440
+ </span>
441
+ <div class="h-px flex-1 bg-border"></div>
442
+ </div>
443
+ {@render panelContent('right', requestedRightTabId, false)}
444
+ {/if}
445
+ </div>
406
446
  </div>
407
- </div>
408
447
 
409
- {@render drawerFooter(drawer === 'closed')}
448
+ {@render drawerFooter(drawer === 'closed')}
449
+ </div>
410
450
  </div>
411
451
  {:else}
412
452
  <div class="flex flex-1 flex-col overflow-hidden">
@@ -620,6 +660,12 @@
620
660
  bottom: var(--drawer-half-h);
621
661
  }
622
662
 
663
+ /* The viewer's own minimum height is for a page that scrolls; here the region sets it, and
664
+ anything taller would run under the drawer and take the viewer's dock with it. */
665
+ .viewer-region > :global(*) {
666
+ min-height: 0;
667
+ }
668
+
623
669
  .drawer-container {
624
670
  position: absolute;
625
671
  bottom: 0;
@@ -673,6 +719,77 @@
673
719
  opacity: 0.4;
674
720
  }
675
721
 
722
+ .drawer-body {
723
+ flex: 1;
724
+ min-height: 0;
725
+ display: flex;
726
+ flex-direction: column;
727
+ }
728
+
729
+ .drawer-label {
730
+ margin-left: 0.5rem;
731
+ }
732
+
733
+ /* Phone on its side: the drawer becomes a sheet on the right, its handle a strip down its edge. */
734
+ .side-sheet {
735
+ --sheet-closed-w: 3.25rem;
736
+ /* vw, not %: the drawer body resolves this against the sheet, not the layout. */
737
+ --sheet-open-w: min(22rem, 55vw);
738
+ }
739
+
740
+ .side-sheet .viewer-region {
741
+ inset: 0 var(--sheet-closed-w) 0 0;
742
+ }
743
+
744
+ .side-sheet .viewer-region-raised {
745
+ right: var(--sheet-open-w);
746
+ }
747
+
748
+ .side-sheet .drawer-container {
749
+ top: 0;
750
+ left: auto;
751
+ height: auto;
752
+ flex-direction: row;
753
+ transition: width 0.35s cubic-bezier(0.32, 0.72, 0, 1);
754
+ }
755
+
756
+ .side-sheet .drawer-closed {
757
+ width: var(--sheet-closed-w);
758
+ }
759
+
760
+ .side-sheet .drawer-half,
761
+ .side-sheet .drawer-full {
762
+ width: var(--sheet-open-w);
763
+ }
764
+
765
+ .side-sheet .drawer-handle-bar {
766
+ width: var(--sheet-closed-w);
767
+ height: auto;
768
+ padding: 0 0 0 0.75rem;
769
+ flex-direction: column;
770
+ justify-content: center;
771
+ gap: 0.75rem;
772
+ }
773
+
774
+ .side-sheet .drawer-pill {
775
+ top: 50%;
776
+ left: 8px;
777
+ transform: translateY(-50%);
778
+ width: 4px;
779
+ height: 2.5rem;
780
+ }
781
+
782
+ .side-sheet .drawer-label {
783
+ margin-left: 0;
784
+ writing-mode: vertical-rl;
785
+ transform: rotate(180deg);
786
+ }
787
+
788
+ /* Holds its open width while the sheet slides, so the fields don't reflow on every frame. */
789
+ .side-sheet .drawer-body {
790
+ min-width: calc(var(--sheet-open-w) - var(--sheet-closed-w));
791
+ }
792
+
676
793
  .drawer-content {
677
794
  flex: 1;
678
795
  overflow-y: auto;
@@ -137,7 +137,10 @@
137
137
  {/each}
138
138
 
139
139
  {#if copyrightLine}
140
- <p class="truncate">{copyrightLine}</p>
140
+ <!-- On a phone, upright or on its side, the row is for status and messages. -->
141
+ <p class="max-sm:hidden truncate [@media(pointer:coarse)_and_(max-height:500px)]:hidden">
142
+ {copyrightLine}
143
+ </p>
141
144
  {/if}
142
145
  </div>
143
146
  </footer>
@@ -105,8 +105,8 @@
105
105
  // Opening leads: the pill grows first and the buttons pop in behind it. Closing mirrors it: the
106
106
  // buttons fade first, then the pill shrinks. Without the delay on close the row is clipped mid-fade.
107
107
  const EASE = 'cubic-bezier(0.32, 0.72, 0, 1)';
108
+ const bottom = $derived(touch.current ? 'max(1rem,env(safe-area-inset-bottom))' : '0.75rem');
108
109
  const containerStyle = $derived.by(() => {
109
- const bottom = touch.current ? 'max(1rem,env(safe-area-inset-bottom))' : '0.75rem';
110
110
  const geometry = wantOpen
111
111
  ? `width:${rowWidth + PAD * 2 + 2}px;height:${EXPANDED_H}px;border-radius:12px;padding:${PAD}px`
112
112
  : `width:${COLLAPSED.w}px;height:${COLLAPSED.h}px;border-radius:${touch.current ? 22 : 99}px;padding:0px`;
@@ -129,7 +129,8 @@
129
129
  `opacity:${touch.current && !wantOpen ? 1 : 0};${reducedMotion.current ? '' : `transition:opacity ${wantOpen ? 100 : 100}ms ease ${wantOpen || dismissed ? 0 : 60}ms`}`
130
130
  );
131
131
 
132
- function scheduleCollapse(delay = touch.current ? 3500 : 150) {
132
+ // Touch has no hover to reopen it, so there the dock stays open until a press lands outside it.
133
+ function scheduleCollapse(delay = 150) {
133
134
  clearTimeout(idleTimer);
134
135
  idleTimer = setTimeout(() => (engaged = false), delay);
135
136
  }
@@ -168,12 +169,45 @@
168
169
  function openFromCollapsed() {
169
170
  if (wantOpen) return;
170
171
  engage();
171
- if (touch.current) scheduleCollapse();
172
172
  }
173
173
 
174
- // Any use of the open dock on touch pushes the idle collapse back.
175
- function keepAlive() {
176
- if (touch.current && engaged) scheduleCollapse();
174
+ // ==========================================================================
175
+ // Long-press labels
176
+ // ==========================================================================
177
+
178
+ // The icons carry no text and touch has no tooltip, so holding a button names it instead of
179
+ // pressing it.
180
+ const LONG_PRESS_MS = 450;
181
+ const HINT_LINGER_MS = 1200;
182
+ let hint = $state<string | null>(null);
183
+ let pressTimer: ReturnType<typeof setTimeout> | undefined;
184
+ let hintTimer: ReturnType<typeof setTimeout> | undefined;
185
+ let swallowClick = false;
186
+
187
+ function startPress(event: PointerEvent) {
188
+ if (event.pointerType !== 'touch' || !wantOpen || !(event.target instanceof Element)) return;
189
+ const button = event.target.closest('button[aria-label]');
190
+ if (!button) return;
191
+ clearTimeout(pressTimer);
192
+ pressTimer = setTimeout(() => {
193
+ // Keyboard shortcuts in the label mean nothing on a phone.
194
+ hint = (button.getAttribute('aria-label') ?? '').replace(/\s*\([A-Z]\)$/, '');
195
+ swallowClick = true;
196
+ }, LONG_PRESS_MS);
197
+ }
198
+
199
+ function endPress() {
200
+ clearTimeout(pressTimer);
201
+ if (!hint) return;
202
+ clearTimeout(hintTimer);
203
+ hintTimer = setTimeout(() => (hint = null), HINT_LINGER_MS);
204
+ }
205
+
206
+ function swallowLongPressClick(event: MouseEvent) {
207
+ if (!swallowClick) return;
208
+ swallowClick = false;
209
+ event.preventDefault();
210
+ event.stopPropagation();
177
211
  }
178
212
 
179
213
  function trackMenu(open: boolean) {
@@ -210,7 +244,7 @@
210
244
  role="toolbar"
211
245
  aria-label={t.toolsMenu}
212
246
  tabindex="-1"
213
- class="backdrop-blur-md absolute left-1/2 z-50 -translate-x-1/2 overflow-hidden transition-colors {hidden
247
+ class="backdrop-blur-md absolute left-1/2 z-50 -translate-x-1/2 overflow-hidden transition-colors select-none {hidden
214
248
  ? 'pointer-events-none opacity-0'
215
249
  : ''} {wantOpen
216
250
  ? 'shadow-md border border-border-strong bg-card/75'
@@ -219,14 +253,18 @@
219
253
  : nightOn
220
254
  ? 'border-white/25 bg-white/35 hover:bg-white/70 cursor-pointer border'
221
255
  : 'shadow-sm cursor-pointer border border-border-strong bg-card/90 hover:bg-card'}"
222
- style={containerStyle}
256
+ style="{containerStyle};-webkit-touch-callout:none"
223
257
  onclick={openFromCollapsed}
224
258
  onkeydown={(e) => (e.key === 'Escape' ? (engaged = false) : undefined)}
225
259
  onfocusin={() => (focusWithin = true)}
226
260
  onfocusout={(e) => {
227
261
  if (!dockEl?.contains(e.relatedTarget as Node | null)) focusWithin = false;
228
262
  }}
229
- onpointerdown={keepAlive}
263
+ onpointerdown={startPress}
264
+ onpointerup={endPress}
265
+ onpointercancel={endPress}
266
+ onclickcapture={swallowLongPressClick}
267
+ oncontextmenu={(e) => touch.current && e.preventDefault()}
230
268
  >
231
269
  {#if touch.current}
232
270
  <span
@@ -402,3 +440,13 @@
402
440
  </div>
403
441
  </div>
404
442
  </div>
443
+
444
+ {#if hint}
445
+ <div
446
+ role="status"
447
+ class="text-xs font-medium px-2.5 py-1 shadow-md pointer-events-none absolute left-1/2 z-50 -translate-x-1/2 rounded-md border border-border-strong bg-card text-foreground"
448
+ style="bottom:calc({bottom} + {EXPANDED_H + 8}px)"
449
+ >
450
+ {hint}
451
+ </div>
452
+ {/if}
@@ -190,6 +190,13 @@
190
190
  border-radius: inherit;
191
191
  }
192
192
 
193
+ /* A phone, upright or on its side, gives the model the height a full desktop bar would take. */
194
+ @media (max-width: 39.999rem), (pointer: coarse) and (max-height: 500px) {
195
+ :root {
196
+ --header-h: 3rem;
197
+ }
198
+ }
199
+
193
200
  /* Phones keep a narrower gutter: at 390px every 8px of side padding is 2% of the width. */
194
201
  @media (min-width: 40rem) {
195
202
  :root {