@selvajs/ui 7.0.0 → 7.0.1-beta.0
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.
- package/dist/components/compute/AppLayout.svelte +158 -41
- package/dist/components/layout/PageFooter.svelte +4 -1
- package/dist/components/viewer/ViewerDock.svelte +57 -9
- package/dist/styles/base.css +7 -0
- package/package.json +6 -6
- package/src/lib/components/compute/AppLayout.svelte +158 -41
- package/src/lib/components/layout/PageFooter.svelte +4 -1
- package/src/lib/components/viewer/ViewerDock.svelte +57 -9
- package/src/lib/styles/base.css +7 -0
|
@@ -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
|
|
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
|
-
|
|
169
|
+
sideSheet = mqPhoneLandscape.matches;
|
|
170
|
+
isMobile = mqNarrow.matches || sideSheet;
|
|
162
171
|
}
|
|
163
172
|
update();
|
|
164
|
-
|
|
165
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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 === '
|
|
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
|
|
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
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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
|
-
|
|
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
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
175
|
-
|
|
176
|
-
|
|
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={
|
|
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}
|
package/dist/styles/base.css
CHANGED
|
@@ -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-beta.0",
|
|
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/compute": "^5.0.0",
|
|
65
66
|
"@selvajs/solve": "^2.0.0",
|
|
66
|
-
"@selvajs/
|
|
67
|
-
"@selvajs/
|
|
68
|
-
"@selvajs/compute": "^5.0.0"
|
|
67
|
+
"@selvajs/visualization": "^1.6.1-beta.0",
|
|
68
|
+
"@selvajs/schemas": "^5.3.0"
|
|
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/
|
|
100
|
+
"@selvajs/config": "0.0.4",
|
|
101
|
+
"@selvajs/visualization": "1.6.1-beta.0"
|
|
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
|
|
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
|
-
|
|
169
|
+
sideSheet = mqPhoneLandscape.matches;
|
|
170
|
+
isMobile = mqNarrow.matches || sideSheet;
|
|
162
171
|
}
|
|
163
172
|
update();
|
|
164
|
-
|
|
165
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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 === '
|
|
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
|
|
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
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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
|
-
|
|
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
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
175
|
-
|
|
176
|
-
|
|
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={
|
|
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}
|
package/src/lib/styles/base.css
CHANGED
|
@@ -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 {
|