- {#each VR_ACTIONS as a (a.id)}
- {@const b = $vrBindings[a.id]}
-
-
{a.label}
- {#if a.kind === 'locked'}
-
Both
-
{controlName('both', b.control)}
+{#snippet schemaRow(/** @type {any} */ row)}
+
= 3 && row.id !== 'faceCap' && row.id !== 'vertexCap'}
+ data-tour={row.tour}
+ >
+ {#if row.kind === 'toggle'}
+ row.set?.(on)} />
+ {:else if row.kind === 'choice' && (row.id === 'faceCap' || row.id === 'vertexCap')}
+
+ {
+ const v = parseInt(e.currentTarget.value);
+ if (Number.isFinite(v) && v >= 10) row.set?.(v);
+ }}
+ />
+ {:else if row.kind === 'choice'}
+ row.set?.(coerce(row, v))} />
+ {:else if row.kind === 'range'}
+ String(v)} onchange={(v) => row.set?.(v)} />
+ {:else}
+ row.run?.()}>Reset
+ {/if}
+
+{/snippet}
+
+
+ {#if page === 'remap'}
+
+ {#each VR_ACTIONS as a (a.id)}
+ {@const b = $vrBindings[a.id]}
+
+ {#if a.kind === 'locked'}
+ Both · {controlName('both', b.control)}
+ {:else}
+
+ {#key b.hand + '|' + b.control + '|' + (pending ? pending.id : '')}
+ rebind(a.id, { hand: v })} />
+ {#if a.kind === 'button'}
+ rebind(a.id, { control: v })} />
{:else}
-
- {#key b.hand + '|' + b.control + '|' + (pending ? pending.id : '')}
- rebind(a.id, { hand: v })} />
- {#if a.kind === 'button'}
- rebind(a.id, { control: v })} />
- {:else}
- {controlName(b.hand, b.control)}
- {/if}
- {/key}
+ {controlName(b.hand, b.control)}
{/if}
- {a.doc}
-
+ {/key}
+ {/if}
+ {#snippet extra()}
{#if pending?.id === a.id}
-
- ⚠ {controlName(pending.want.hand, pending.want.control)} is already {labelOf(pending.other)}.
-
Swap them
-
(pending = null)}>Cancel
+
+ {controlName(pending.want.hand, pending.want.control)} is already {labelOf(pending.other)}.
+ Swap them
+ (pending = null)}>Cancel
{/if}
- {/each}
-
+ {/snippet}
+
+
+ resetRoomNudge()}>Reset
+
+
+ {:else}
+
+ {#each VR_SETTING_PAGES as p (p.id)}
+ {#if desktopRows(p.id).length}
+
+ {#each desktopRows(p.id) as row (row.id)}{@render schemaRow(row)}{/each}
+ {#if p.id === 'controls'}
+ nav.openSub('vr:remap', 'Remap buttons', 'vr')} />
+ {/if}
+
{/if}
- {/if}
- {/each}
+ {/each}
+
+
+ setMyHandModel(e.currentTarget.value)}>
+ Default
+ {#each handModels as item (item.id)}
+ {item.name}
+ {/each}
+
+
+
+
+
+ {#snippet desc()}{$roomAlignment ? 'Colocated · ' + ($roomAlignment.roomKey ?? 'room') : 'Not colocated'} — share one physical room with a co-present peer. Stand on the agreed spot facing the agreed way, then press Colocate here.{/snippet}
+ colocateHereFromView()}>Colocate here
+ stopColocation()}>Stop
+ {#if forgetKey}
+ forgetRoom(forgetKey)}>Forget {forgetKey}
+ {/if}
+
+ {#if $roomAlignment}
+ nav.openSub('vr:finetune', 'Fine-tune', 'vr')} />
+ {/if}
+
+
+
+
+
+
+ {#snippet desc()}Run it inside the headset, restart the browser, run it again: the second run’s restore delta is the answer.{arSupport === false ? ' This device reports no immersive-ar support.' : ''}{/snippet}
+ {
+ // USER ACTIVATION, and this ORDER is the whole reason for the comment: probeSupport() is
+ // STARTED and deliberately NOT awaited, so runArProbe() — whose first statement is the
+ // requestSession call — runs in the SAME task as this click
+ probeSupport();
+ runArProbe();
+ }}>{$probeRunning ? 'Probing…' : 'Probe AR capabilities'}
+ clearProbeState()}>Clear stored anchor
+ {#snippet extra()}
+ {#if $probeFindings.length}
+
+ {#each $probeFindings as finding, i (i)}
+
+ {finding.ok ? '✓' : '✗'}
+ {finding.step}
+ {finding.detail}
+
+ {/each}
+
+ {/if}
+ {/snippet}
+
+
+ {/if}
diff --git a/src/components/menu/WhatsNew.svelte b/src/components/menu/WhatsNew.svelte
index 8d547359..918af5cc 100644
--- a/src/components/menu/WhatsNew.svelte
+++ b/src/components/menu/WhatsNew.svelte
@@ -3,6 +3,7 @@
// windows — ui-panel + dragWindow + focusStack). Opened by the logo-menu row, the
// update toast, or Settings ▸ About. Opening it marks the version seen.
import { whatsNewOpen, closeWhatsNew, CHANGELOG } from '$lib/whatsNew';
+ import { changelogReleases } from '$lib/changelog.js';
import { dragWindow } from '$lib/dragWindow';
import { focusStack } from '$lib/windowFocus';
@@ -40,82 +41,10 @@
}
}
- // Deliberately tiny markdown subset instead of a dependency: the input is our own
- // bundled CHANGELOG.md, and everything is HTML-escaped BEFORE the inline rules run,
- // so the {@html} below cannot inject markup even if the file grows odd characters.
- function esc(s: string) {
- return s.replace(/&/g, '&').replace(//g, '>');
- }
- function inline(s: string) {
- return esc(s)
- .replace(/`([^`]+)`/g, '
$1')
- .replace(/\*\*([^*]+)\*\*/g, '
$1 ')
- .replace(/\[([^\]]+)\]\((https?:[^)\s]+)\)/g, '
$1 ');
- }
- /** Markdown -> a flat block list the template renders. */
- function blocks(source: string) {
- // HTML comments carry maintainer notes for the GitHub view — never render them
- const md = source.replace(//g, '');
- /** @type {{kind: string, html: string, items?: string[]}[]} */
- const out: { kind: string; html: string; items?: string[] }[] = [];
- let para: string[] = [];
- let list: string[] = [];
- const flushPara = () => {
- if (para.length) out.push({ kind: 'p', html: inline(para.join(' ')) });
- para = [];
- };
- const flushList = () => {
- if (list.length) out.push({ kind: 'ul', html: '', items: list.map(inline) });
- list = [];
- };
- const flush = () => {
- flushPara();
- flushList();
- };
- for (const raw of md.split(/\r?\n/)) {
- const line = raw.trim();
- if (!line) {
- flush();
- continue;
- }
- const heading = /^(#{1,4})\s+(.*)$/.exec(line);
- if (heading) {
- flush();
- out.push({ kind: 'h' + heading[1].length, html: inline(heading[2]) });
- continue;
- }
- const bullet = /^[-*]\s+(.*)$/.exec(line);
- if (bullet) {
- flushPara();
- list.push(bullet[1]);
- continue;
- }
- // a wrapped bullet continues the previous item
- if (list.length) list[list.length - 1] += ' ' + line;
- else para.push(line);
- }
- flush();
- return out;
- }
-
- // The leading "# Changelog" title is for the GitHub view — in here the window
- // header already says it, so h1s are dropped rather than duplicating it.
- const parsed = blocks(CHANGELOG).filter((b) => b.kind !== 'h1');
-
- /**
- * Group the flat blocks into one FOLDABLE section per release (h2), so the
- * window opens on the newest release instead of a wall of every version ever
- * shipped. Anything before the first h2 (an intro paragraph) stays loose above
- * them. `
` does the folding natively — keyboard and screen readers get
- * it for free, and there is no open/closed state to keep in sync.
- */
- const intro: typeof parsed = [];
- const releases: { title: string; body: typeof parsed }[] = [];
- for (const block of parsed) {
- if (block.kind === 'h2') releases.push({ title: block.html, body: [] });
- else if (releases.length) releases[releases.length - 1].body.push(block);
- else intro.push(block);
- }
+ // the changelog as releases (one foldable section each) — $lib/changelog.js, shared with
+ // Settings ▸ About ▸ What's new (37-settings)
+ const { intro, releases } = changelogReleases(CHANGELOG);
+ type Block = (typeof intro)[number];
{#if $whatsNewOpen}
@@ -136,7 +65,7 @@
✕
- {#snippet body(list: typeof parsed)}
+ {#snippet body(list: Block[])}
{#each list as block}
{#if block.kind === 'h3'}
{@html block.html}
diff --git a/src/components/menu/WorkspaceLayouts.svelte b/src/components/menu/WorkspaceLayouts.svelte
new file mode 100644
index 00000000..44c18277
--- /dev/null
+++ b/src/components/menu/WorkspaceLayouts.svelte
@@ -0,0 +1,241 @@
+
+
+
+ {#if $uiLayouts.length}
+
+ {:else}
+
No saved layouts yet. Arrange your windows, then save the arrangement under a name.
+ {/if}
+
+ {#if error}
{error}
{/if}
+
+
+
diff --git a/src/components/menu/settings/AiSettings.svelte b/src/components/menu/settings/AiSettings.svelte
new file mode 100644
index 00000000..3c88931a
--- /dev/null
+++ b/src/components/menu/settings/AiSettings.svelte
@@ -0,0 +1,518 @@
+
+
+
+
+
+ {#if view === 'stt'}
+
+ {:else if view === 'provider'}
+
+
+ Save
+ {aiTesting ? 'Testing…' : 'Test connection'}
+ {#if aiEditId && $aiActiveProvider !== aiEditId}
+ aiEditId && setAiActiveProvider(aiEditId)}>Use this provider
+ {/if}
+ nav.closeSub()}>Cancel
+ {#if aiEditId}
+
+ aiEditId && removeAi(aiEditId)}>Remove
+ {/if}
+
+ {#if aiTestResult}
+
+ {#if aiTestResult.ok}
+ ✓ {aiTestResult.detail}
+ {#if !aiTestResult.model}
+ · no model selected
+ {:else if aiTestResult.modelOk}
+ · model {aiTestResult.model} — Configuration OK
+ {:else if aiTestResult.modelOk === false}
+ · model “{aiTestResult.model}” did not respond — pick one from the list
+ {/if}
+ {:else}
+ ✗ {aiTestResult.detail}
+ {/if}
+
+ {/if}
+ {:else if view === 'mesh'}
+
+
+ Save
+ {#if meshEditId && $meshActiveProvider !== meshEditId}
+ meshEditId && setMeshActiveProvider(meshEditId)}>Use this provider
+ {/if}
+ nav.closeSub()}>Cancel
+ {#if meshEditId}
+
+ meshEditId && removeMesh(meshEditId)}>Remove
+ {/if}
+
+ {:else}
+
+
+ setAiEnabled(on)} />
+
+ {#each $aiProviders as p (p.id)}
+ aiStartEdit(p)} />
+ {/each}
+
+
+
+
+
+ setMeshGenEnabled(on)} />
+
+ {#each $meshProviders as p (p.id)}
+ meshStartEdit(p)} />
+ {/each}
+
+
+
+ {/if}
+
+
+
diff --git a/src/components/menu/settings/AvatarSettings.svelte b/src/components/menu/settings/AvatarSettings.svelte
deleted file mode 100644
index 3a7a371b..00000000
--- a/src/components/menu/settings/AvatarSettings.svelte
+++ /dev/null
@@ -1,39 +0,0 @@
-
-
-
-
-
-
Avatars
-
-
- {
- settingsOpen.set(false);
- characterModalOpen.set(true);
- }}>Customize character…
-
- Pick your character, head, hat, outfit colour and ping, with a live preview on yourself.
-
-
-
-
-
- On this device only: draws other people as the floating head instead of their animated character
- (lighter on a headset with many people). They still see your character.
-
-
diff --git a/src/components/menu/settings/ChangelogBody.svelte b/src/components/menu/settings/ChangelogBody.svelte
new file mode 100644
index 00000000..e4344aa5
--- /dev/null
+++ b/src/components/menu/settings/ChangelogBody.svelte
@@ -0,0 +1,121 @@
+
+
+{#snippet body(/** @type {import('$lib/changelog.js').Block[]} */ list)}
+ {#each list as block, i (i)}
+ {#if block.kind === 'h3'}
+
{@html block.html}
+ {:else if block.kind === 'h4'}
+
{@html block.html}
+ {:else if block.kind === 'ul'}
+
+ {#each block.items ?? [] as item, j (j)}
+ {@html item}
+ {/each}
+
+ {:else}
+
{@html block.html}
+ {/if}
+ {/each}
+{/snippet}
+
+
+ {@render body(intro)}
+ {#each releases as release, index (release.title)}
+
+ {@html release.title}
+ {@render body(release.body)}
+
+ {/each}
+
+
+
diff --git a/src/components/menu/settings/CheckpointSettings.svelte b/src/components/menu/settings/CheckpointSettings.svelte
index 183c002a..e88f6a04 100644
--- a/src/components/menu/settings/CheckpointSettings.svelte
+++ b/src/components/menu/settings/CheckpointSettings.svelte
@@ -1,4 +1,4 @@
-
@@ -7,53 +7,41 @@
// 36 B14 — Settings ▸ Scene ▸ Checkpoints: automatic rows in the timeline, how often, and how
// much of this device they may use. Its own file so the settings index stays a one-line union.
// Every value is LOCAL (this device), persisted through safeStorage by checkpoints.js.
- import ThemedSelect from '../../ui/ThemedSelect.svelte';
- import { Toggle } from 'flowbite-svelte';
- import SettingRow from '../SettingRow.svelte';
+ // 37-settings (R21): rows only (the Scene page puts them in its "Saving & checkpoints" card), on
+ // the redesign kit; the two 4-option choices are segmented controls (Decision D).
+ import SettingRow from '../../ui/SettingRow.svelte';
+ import Toggle from '../../ui/Toggle.svelte';
+ import Segmented from '../../ui/Segmented.svelte';
import { checkpointsOpen, settingsOpen } from '../../../stores/appStore.js';
- import {
- autoCheckpoints,
- autoCheckpointMinutes,
- checkpointCapMb,
- INTERVAL_CHOICES_MIN,
- CAP_CHOICES_MB
- } from '$lib/checkpoints';
+ import { autoCheckpoints, autoCheckpointMinutes, checkpointCapMb, INTERVAL_CHOICES_MIN, CAP_CHOICES_MB } from '$lib/checkpoints';
+
+ const EVERY = INTERVAL_CHOICES_MIN.map((m) => ({ value: String(m), label: m + ' min' }));
+ const CAP = CAP_CHOICES_MB.map((m) => ({ value: String(m), label: m >= 1000 ? m / 1000 + ' GB' : m + ' MB' }));
-
Checkpoints
-
-
- While you work, autosave also adds a row to the checkpoint timeline every few minutes — only when
- the scene changed. Named checkpoints (Ctrl +Shift +S ) are kept either way.
+
+
-
-
- ({ value: m, name: m + ' min' }))}
- bind:value={$autoCheckpointMinutes}
- />
-
- The shortest gap between two automatic checkpoints
+
+ autoCheckpointMinutes.set(Number(v))} />
-
-
- ({ value: m, name: m >= 1000 ? m / 1000 + ' GB' : m + ' MB' }))}
- bind:value={$checkpointCapMb}
- />
-
- How much of this device checkpoints may use. When a new one would not fit, the oldest unpinned
- checkpoints are removed — automatic ones before named ones. Pinned checkpoints are never removed.
- {
- settingsOpen.set(false);
- checkpointsOpen.set(true);
- }}>Open the timeline
+
+ {#snippet desc()}How much of this device checkpoints may use; the oldest unpinned ones go first. {
+ settingsOpen.set(false);
+ checkpointsOpen.set(true);
+ }}>Open the timeline {/snippet}
+ checkpointCapMb.set(Number(v))} />
+
+
diff --git a/src/components/menu/settings/ConnectionSettings.svelte b/src/components/menu/settings/ConnectionSettings.svelte
new file mode 100644
index 00000000..f57c23c8
--- /dev/null
+++ b/src/components/menu/settings/ConnectionSettings.svelte
@@ -0,0 +1,145 @@
+
+
+
+
+
+ {#if customPage}
+
+
+ {:else}
+
+
+
+ setPeerMode(v)} />
+
+ {#if $peerServerConfig.mode === 'custom'}
+ nav.openSub('conn:custom', 'Custom server', 'connection')} />
+ {/if}
+
+ location.reload()}>Reload
+ {applyingServer ? 'Switching…' : 'Apply'}
+
+
+ {/if}
+
+
+
diff --git a/src/components/menu/settings/ControlsSettings.svelte b/src/components/menu/settings/ControlsSettings.svelte
new file mode 100644
index 00000000..fec8ff5e
--- /dev/null
+++ b/src/components/menu/settings/ControlsSettings.svelte
@@ -0,0 +1,144 @@
+
+
+
+
+
+
+
+
+
+ trackpadMode.set(v)} />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ (showWheel = !showWheel)}>{showWheel ? 'Hide' : 'Show'}
+ {#snippet extra()}
+ {#if showWheel}
+
+
+ {wheelPlatform || 'unknown platform'} · pointer: {wheelCoarse ? 'coarse' : 'fine'} · wheel mode: {$trackpadMode === 'off' ? 'zoom' : $trackpadMode === 'on' ? 'pan' : 'auto'}
+
+ {#if $lastWheelEvents.length}
+
+ {:else}
+
Scroll over the viewport — the last 8 wheel events land here.
+ {/if}
+
If a mouse wheel pans instead of zooming (or a trackpad zooms), use Viewport menu ▸ View ▸ Mouse wheel, or Trackpad gestures above.
+
+ {/if}
+ {/snippet}
+
+
+
+
+
diff --git a/src/components/menu/settings/ExplorerSettings.svelte b/src/components/menu/settings/ExplorerSettings.svelte
new file mode 100644
index 00000000..1d4fa844
--- /dev/null
+++ b/src/components/menu/settings/ExplorerSettings.svelte
@@ -0,0 +1,132 @@
+
+
+
+
+
+
+
+ shareNewFiles.set(/** @type {any} */ (v))} />
+
+
+
+
+
+
+
+
+ unshareAuthority.set(v === 'owner' ? 'owner' : 'anyone')} />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ settingsOpen.set(false);
+ openStorageModal();
+ }}>Show breakdown
+
+
+
+
+
diff --git a/src/components/menu/settings/InputSettings.svelte b/src/components/menu/settings/InputSettings.svelte
new file mode 100644
index 00000000..57da96ba
--- /dev/null
+++ b/src/components/menu/settings/InputSettings.svelte
@@ -0,0 +1,93 @@
+
+
+
+
+
+
+
+ setGamepadPrefs({ enabled: on })} />
+
+
+ setGamepadPrefs({ swapSticks: on })} />
+
+
+ setGamepadPrefs({ invertY: on })} />
+
+
+ v.toFixed(2)}
+ onchange={(v) => setGamepadPrefs({ deadzone: v })}
+ />
+
+
+ v.toFixed(1) + '×'}
+ onchange={(v) => setGamepadPrefs({ lookSensitivity: v })}
+ />
+
+
+
+
+
+
+ flowMouseBindings.set(/** @type {any} */ (v))} />
+
+
+ nodeEditorOpens.set(/** @type {any} */ (v))} />
+
+
+
+
+
diff --git a/src/components/menu/settings/InterfaceSettings.svelte b/src/components/menu/settings/InterfaceSettings.svelte
new file mode 100644
index 00000000..883f0e7a
--- /dev/null
+++ b/src/components/menu/settings/InterfaceSettings.svelte
@@ -0,0 +1,340 @@
+
+
+
+
+
+
+
+
+
+ {
+ close();
+ characterModalOpen.set(true);
+ }}>Customize
+
+
+
+
+
+
+ ({ value: String(s), label: s ? s + ' s' : 'Off' }))}
+ value={String($knockedAfterSeconds)}
+ onchange={(v) => knockedAfterSeconds.set(Number(v))}
+ />
+
+
+
+
+
+ gameSoundVolume.set(v)} />
+
+
+ gameMusicVolume.set(v)} />
+
+
+
+
+
+ {#snippet desc()}Normally shown only on your first visit. {
+ close();
+ openWelcome();
+ }}>Open it now {/snippet}
+
+
+
+
+
+
+
+
+
+
+
+
+ tours.setAutoStart(on)} />
+
+
+ {#snippet desc()}Controllers, moving, grabbing, the radial menu and leaving VR. {tourStatus(VR_TOUR, $tourRecords)} {/snippet}
+ {
+ close();
+ startVRWelcome();
+ }}>Start
+
+
+ {#snippet desc()}Six steps around the editor ({editorTouch ? 'the touch-screen version' : 'mouse and keyboard'}). {tourStatus(editorTourId(), $tourRecords)} {/snippet}
+ {
+ close();
+ startEditorTour();
+ }}>Start
+
+
+
+
+
+ Reset
+
+
+
+
+ {#if $drawerSlot}
+
+
+
+ {/if}
+
+
+
+
+
+
+
+ {
+ resetWindowLayout();
+ showToast('Window positions reset');
+ }}>Reset
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {#if $perfReportsAvailable}
+
+
+
+ {/if}
+
+ setViewPrefs({ dockPushesViewport: on })} />
+
+
+
+
+
diff --git a/src/components/menu/settings/LoadingSettings.svelte b/src/components/menu/settings/LoadingSettings.svelte
index 7d69ee7a..93e8742d 100644
--- a/src/components/menu/settings/LoadingSettings.svelte
+++ b/src/components/menu/settings/LoadingSettings.svelte
@@ -1,4 +1,4 @@
-
+
+
diff --git a/src/components/menu/settings/NodeEditorViewSettings.svelte b/src/components/menu/settings/NodeEditorViewSettings.svelte
deleted file mode 100644
index 16374fe5..00000000
--- a/src/components/menu/settings/NodeEditorViewSettings.svelte
+++ /dev/null
@@ -1,22 +0,0 @@
-
-
-
-
-
-
-
-
-
- Where it was left (the default): a graph opens on the view its creator — or you — last panned or zoomed to, and every other graph (a template, an example, anything nobody moved) opens framed with all its nodes in view. Framed: every graph always opens with all its nodes in view, the same as pressing A
-
-
diff --git a/src/components/menu/settings/SceneSettings.svelte b/src/components/menu/settings/SceneSettings.svelte
new file mode 100644
index 00000000..05d5f63b
--- /dev/null
+++ b/src/components/menu/settings/SceneSettings.svelte
@@ -0,0 +1,250 @@
+
+
+
+
+
+
+
+
+
+ shadowQuality.set(/** @type {any} */ (v))} />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ modulesOnOpen.set(/** @type {any} */ (v))} />
+
+
+
+
+
+ doubleClickAction.set(/** @type {any} */ (v))} />
+
+
+
+
+
+ ({ value: u, name: u }))} bind:value={$lengthUnit} />
+
+
+ angleUnit.set(/** @type {any} */ (v))} />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/menu/settings/SettingsNav.svelte b/src/components/menu/settings/SettingsNav.svelte
index 0a35b3bf..2fd502b5 100644
--- a/src/components/menu/settings/SettingsNav.svelte
+++ b/src/components/menu/settings/SettingsNav.svelte
@@ -1,33 +1,89 @@
-
- {#each $entries as e, i (e)}
- nav.activate(e)}
- onkeydown={(ev) => onKey(ev, i)}
- >
- {@render e.header?.()}
-
- {/each}
+{#snippet navRow(e: any, about: boolean)}
+ nav.activate(e)}
+ onkeydown={(ev) => onKey(ev, e)}
+ >
+ {@render e.header?.()}
+ {#if about && $whatsNewUnseen}(new) {/if}
+ {#if layout === 'home'} {/if}
+
+{/snippet}
+
+
+
+ {#each grouped.groups as g (g.group.id)}
+
+
{g.group.label}
+
+ {#each g.items as e (e)}{@render navRow(e, false)}{/each}
+
+
+ {/each}
+
+ {#if grouped.about}
+
+
{@render navRow(grouped.about, true)}
+
+ {/if}
diff --git a/src/components/menu/settings/SettingsSection.svelte b/src/components/menu/settings/SettingsSection.svelte
index e81a1674..e327b83f 100644
--- a/src/components/menu/settings/SettingsSection.svelte
+++ b/src/components/menu/settings/SettingsSection.svelte
@@ -1,15 +1,23 @@
{#if open}
- {label}
- {@render children?.()}
+
+
+ {#if $searching}
+
+ nav?.jumpTo?.(key)}>{label} ›
+
+ {:else if subpage}
+
+ nav.closeSub()}>
+
+
+ nav.closeSub()}>{label}
+ ›
+ {subpage.label}
+
+ {:else}
+ {label}
+ {#if meta.description}{meta.description}
{/if}
+ {/if}
+
+
{@render children?.()}
+ {#if !$searching && !subpage && canResetCategory(key)}
+
+ askResetCategory(key, label)}>Reset {label} to defaults
+ Changes save automatically
+
+ {/if}
+
{/if}
diff --git a/src/components/menu/settings/ShortcutsSettings.svelte b/src/components/menu/settings/ShortcutsSettings.svelte
new file mode 100644
index 00000000..da2663cf
--- /dev/null
+++ b/src/components/menu/settings/ShortcutsSettings.svelte
@@ -0,0 +1,266 @@
+
+
+
+ {#if $nonLatinLayoutSeen && !layoutLabels}
+
+
Letter shortcuts use the physical key position on this layout — the key where the letter sits on a QWERTY keyboard.
+ {/if}
+ {#key `${version}:${$settingsResetTick}`}
+
+ {#each groups as group (group)}
+
+ {#each shortcuts.filter((s) => s.group === group) as shortcut (shortcut.id)}
+
+ {#if layoutHint(shortcut.keys, layoutLabels)}
+ · {layoutHint(shortcut.keys, layoutLabels)} on your layout
+ {/if}
+ {#if isRebindable(shortcut)}
+ {#if shortcut.keys !== shortcut.defaultKeys}
+ resetOne(shortcut.id)}>
+ {/if}
+ startCapture(shortcut.id)}>{capturingId === shortcut.id ? 'Press keys… Esc cancels' : shortcut.keys}
+ {:else}
+
+ {shortcut.keys}
+
+
+ {/if}
+ {#snippet extra()}
+ {#if conflict && conflict.id === shortcut.id}
+
+ {conflict.keys} is bound to {conflict.other.label}.
+ Swap
+ (conflict = null)}>Cancel
+
+ {/if}
+ {/snippet}
+
+ {/each}
+
+ {/each}
+
+ {/key}
+
+
+
diff --git a/src/components/menu/settings/TextSelectionSettings.svelte b/src/components/menu/settings/TextSelectionSettings.svelte
deleted file mode 100644
index 68ee01fc..00000000
--- a/src/components/menu/settings/TextSelectionSettings.svelte
+++ /dev/null
@@ -1,22 +0,0 @@
-
-
-
-
-
-
-
-
-
- Off: only text fields, code, chat, logs and help text can be selected, so a drag across a
- menu or a panel never paints a selection. The viewport and the node editor never start one.
-
-
diff --git a/src/components/menu/settings/ToursSettings.svelte b/src/components/menu/settings/ToursSettings.svelte
deleted file mode 100644
index 88c3495d..00000000
--- a/src/components/menu/settings/ToursSettings.svelte
+++ /dev/null
@@ -1,88 +0,0 @@
-
-
-
-
-
-
-
Tours
-
-
- Start the short first-run tours by themselves: the editor tour after the welcome card, and
- "Welcome to ThePrototype VR" the first time you enter VR. "Don't show again" on a tour turns
- this off
-
-
- Start VR welcome
- Your controllers, moving, pointing, grabbing, the radial menu, playing and leaving VR. In a
- headset it starts now (also: radial menu ▸ Settings ▸ Welcome tour); on a screen it plays the
- next time you enter VR, with a preview offered. {statusText(VR_TOUR, $tourRecords)}
-
-
- Start editor tour
- Six steps around the editor — adding things, looking around, the tools, Play, building
- together and the logo menu ({editorTourId() === 'editor-touch' ? 'the touch-screen version on this device' : 'mouse and keyboard'}).
- {statusText(editorTourId(), $tourRecords)}
-
-
-
- In a headset's browser (Quest), let the browser show its own "Enter VR" button for this page, once
- per visit. Declining it is remembered on this device; switching this off and on asks again
-
-
- Reset all
- Forget which tours you have seen or skipped and turn automatic tours back on
-
-
-
-
diff --git a/src/components/menu/settings/WorkspaceLayoutsSettings.svelte b/src/components/menu/settings/WorkspaceLayoutsSettings.svelte
new file mode 100644
index 00000000..1b6743f9
--- /dev/null
+++ b/src/components/menu/settings/WorkspaceLayoutsSettings.svelte
@@ -0,0 +1,18 @@
+
+
+
+
+
diff --git a/src/components/play/AvatarRig.svelte b/src/components/play/AvatarRig.svelte
index e2bae85d..82b5ce46 100644
--- a/src/components/play/AvatarRig.svelte
+++ b/src/components/play/AvatarRig.svelte
@@ -10,6 +10,7 @@
import { RiggedAvatar, lookOf } from '$lib/avatars/riggedAvatar'
import { avatarIkPeers, avatarInstances, peersAsClassic } from '$lib/avatars/avatarState'
import { feetBelowHead } from '$lib/avatars/catalog'
+ import { DizzyFx } from '$lib/avatars/dizzy'
// Builds a peer's character from their replicated avatar config (userdata
// slot 5: { character, head, outfit, body, hat, face, shape, showLabel }).
@@ -71,6 +72,60 @@
faceTexture = null
}
+ // 37 R22: the classic floating head's knocked-off idle — the same stars + star eyes as the rigged
+ // body, swaying an INNER group (the root is posed by every camera message, so it cannot carry it)
+ const classicDizzy = new DizzyFx()
+ let swayGroup: any = null
+ const CLASSIC_FACE: Record = {
+ sphere: { z: 0.6, y: 0.1 },
+ box: { z: 0.52, y: 0.1 },
+ capsule: { z: 0.47, y: 0.12 },
+ cone: { z: 0.33, y: 0 }
+ }
+ const classicEyes = [new THREE.Vector3(), new THREE.Vector3()]
+ const photoEyes = [new THREE.Vector3(-0.18, 0.12, 0.02), new THREE.Vector3(0.18, 0.12, 0.02)]
+ const classicHead = new THREE.Matrix4()
+ const classicCardFrame = new THREE.Matrix4()
+ const classicTurn = new THREE.Quaternion()
+ const classicRoll = new THREE.Quaternion()
+ const classicPos = new THREE.Vector3()
+ const viewerLocal = new THREE.Vector3()
+ const zAxis = new THREE.Vector3(0, 0, 1)
+ const yAxis = new THREE.Vector3(0, 1, 0)
+ onDestroy(() => classicDizzy.dispose())
+ function tickClassicDizzy(dt: number) {
+ if (!root) return
+ if (classicDizzy.mesh.parent !== root) root.add(classicDizzy.mesh)
+ const on = !look && !preview && !!root.userData?.knocked
+ if (classicDizzy.step(on, dt) <= 0) {
+ if (swayGroup) {
+ swayGroup.position.set(0, 0, 0)
+ swayGroup.rotation.set(0, 0, 0)
+ }
+ return
+ }
+ const sw = classicDizzy.sway()
+ if (swayGroup) {
+ swayGroup.position.set(sw.x, sw.y, 0)
+ swayGroup.rotation.set(0, 0, sw.roll)
+ }
+ // the head's frame: the camera looks down -Z, so turn it to face out of +Z like a rigged head
+ classicTurn.setFromAxisAngle(yAxis, Math.PI).multiply(classicRoll.setFromAxisAngle(zAxis, -sw.roll))
+ classicHead.compose(classicPos.set(sw.x, sw.y, 0), classicTurn, new THREE.Vector3(1, 1, 1))
+ const f = CLASSIC_FACE[config.shape] ?? CLASSIC_FACE.sphere
+ classicEyes[0].set(-0.2, f.y, f.z)
+ classicEyes[1].set(0.2, f.y, f.z)
+ root.updateMatrixWorld()
+ const vl = root.worldToLocal(viewerLocal.copy(viewer))
+ if (photoCard && card && swayGroup) {
+ // the card lives in the sway group: its frame in the root's is sway * card
+ swayGroup.updateMatrix()
+ card.updateMatrix()
+ classicCardFrame.multiplyMatrices(swayGroup.matrix, card.matrix)
+ classicDizzy.pose(classicHead, { ring: 0.62, lift: 0.72, size: 0.09 }, photoEyes, vl, classicCardFrame)
+ } else classicDizzy.pose(classicHead, { ring: 0.62, lift: 0.72, size: 0.09 }, classicEyes, vl)
+ }
+
// the photo card + the label billboard toward the viewer each frame; the rigged body updates
let root: any = null
let card: any = null
@@ -87,6 +142,7 @@
if (card) card.lookAt(viewer)
if (labelGroup) labelGroup.lookAt(viewer)
if (avatar && root) avatar.update(Math.min(delta, 0.1), performance.now() / 1000, root, preview ? null : $peerHands[user[0]], viewer)
+ tickClassicDizzy(Math.min(delta, 0.1))
})
@@ -107,6 +163,8 @@
{/if}
+
+
{#if photoCard && faceTexture}
@@ -174,6 +232,7 @@
{/if}
{/if}
+
{/if}
diff --git a/src/components/play/PointerLockControls.svelte b/src/components/play/PointerLockControls.svelte
index ee0c3d94..6743b0c6 100644
--- a/src/components/play/PointerLockControls.svelte
+++ b/src/components/play/PointerLockControls.svelte
@@ -40,8 +40,13 @@
walkStep,
collideRigStep
} from '$lib/charController'
+ import { setLocalFeet } from '$lib/avatars/avatarState'
const { renderer, camera, invalidate } = useThrelte()
+ // 37 R23: a walking player's feet travel on the camera stream (avatarState.setLocalFeet)
+ const feetProbe = new Vector3()
+ /** Player.svelte seats this camera in a group at y = 0.9 */
+ const PLAYER_BASE_Y = 0.9
const domElement = renderer.domElement
const cameraParent = useParent()
@@ -276,6 +281,8 @@
right: moveState.right || (mX > 0.01 ? 1 : 0)
}
tickWalker(walker, ctrl, delta, walkStep(walker, padWalkInput, speed, delta))
+ // 37 R23: the walker's floor, for the body peers draw (eyeHeight is world metres here)
+ setLocalFeet(walker.getWorldPosition(feetProbe).y - Math.max(0.1, Number(ctrl.eyeHeight ?? 1.7) || 1.7))
const lX = prefs.swapSticks ? pad.lx : pad.rx
const lY = prefs.swapSticks ? pad.ly : pad.ry
if (lX || lY) {
@@ -365,6 +372,10 @@
if (play.grounded && $isLocked && $cameraParent) {
const grounded: any = $cameraParent
grounded.position.y = play.eyeHeight
+ // 37 R23: the pin stands the eye `eyeHeight` above the camera group's 0.9 m base, i.e. on
+ // the floor at that base less 0.9 — tell peers so our body stands on it
+ grounded.updateWorldMatrix(true, false)
+ setLocalFeet(grounded.getWorldPosition(feetProbe).y - play.eyeHeight - PLAYER_BASE_Y)
}
// dungeon collision (58.1): slide the XZ step along the raster walls
diff --git a/src/components/play/TouchPlayControls.svelte b/src/components/play/TouchPlayControls.svelte
index 8f7ec181..ddb37c83 100644
--- a/src/components/play/TouchPlayControls.svelte
+++ b/src/components/play/TouchPlayControls.svelte
@@ -101,7 +101,9 @@
// stick that stopped where the keyboard kept going would move the same scene two
// different ways depending on the device.
const inputLive = $derived(shown && !$playPointerFree && !$inputClaims.includes('keys'));
- const stickLive = $derived(inputLive && spec.stick);
+ // 37: except the stick a MODULE declared — it claimed the keys because it reads the input
+ // itself (api.input().touch), and the stick is how a phone feeds it (Race's steering)
+ const stickLive = $derived(spec.stick && (inputLive || (shown && !$playPointerFree && !!spec.declared)));
const lookLive = $derived(inputLive && spec.look);
// buttons: in play while the menu substate is not up (a module's key claim does NOT
// stop them — it still reads keys through api.onInput), or in the editor when asked
@@ -220,7 +222,7 @@
/** @param {PointerEvent} event */
function onPointerDown(event) {
if (/** @type {any} */ (event)[TOUCH_ACTION_EVENT]) return;
- if (!inputLive || event.pointerType !== 'touch' || !onCanvas(event.target)) return;
+ if (!(inputLive || stickLive) || event.pointerType !== 'touch' || !onCanvas(event.target)) return;
// the stick owns the left half (or its own base, wherever the player put it); with
// no stick the whole view looks
const nearBase =
diff --git a/src/components/ui/Badge.svelte b/src/components/ui/Badge.svelte
new file mode 100644
index 00000000..ae0704e3
--- /dev/null
+++ b/src/components/ui/Badge.svelte
@@ -0,0 +1,89 @@
+
+
+
+ {#if dot || STATUS.includes(tone)} {/if}
+ {#if children}{@render children()}{:else}{text}{/if}
+
+
+
diff --git a/src/components/ui/Button.svelte b/src/components/ui/Button.svelte
new file mode 100644
index 00000000..42e2772e
--- /dev/null
+++ b/src/components/ui/Button.svelte
@@ -0,0 +1,225 @@
+
+
+{#snippet inner()}
+ {#if icon} {/if}
+ {#if !iconOnly}
+ {#if children}{@render children()}{:else}{text}{/if}
+ {/if}
+ {#if iconRight} {/if}
+ {#if count !== undefined && count !== null && count !== '' && count !== 0}{count} {:else if dot} {/if}
+{/snippet}
+
+{#if href && !disabled}
+ {@render inner()}
+{:else}
+ {@render inner()}
+{/if}
+
+
diff --git a/src/components/ui/Checkbox.svelte b/src/components/ui/Checkbox.svelte
new file mode 100644
index 00000000..a10c0a64
--- /dev/null
+++ b/src/components/ui/Checkbox.svelte
@@ -0,0 +1,99 @@
+
+
+
+
+
+ {#if indeterminate} {:else if checked} {/if}
+
+
+
+
diff --git a/src/components/ui/Chips.svelte b/src/components/ui/Chips.svelte
new file mode 100644
index 00000000..ae59eb4f
--- /dev/null
+++ b/src/components/ui/Chips.svelte
@@ -0,0 +1,120 @@
+
+
+
+ {#each options as o (o.value)}
+ press(o)}
+ >
+ {#if o.icon} {/if}
+ {o.label}
+ {#if o.count !== undefined && o.count !== null && o.count !== ''}{o.count} {/if}
+
+ {/each}
+
+
+
diff --git a/src/components/ui/DragRow.svelte b/src/components/ui/DragRow.svelte
index a2e13ede..423cd4b9 100644
--- a/src/components/ui/DragRow.svelte
+++ b/src/components/ui/DragRow.svelte
@@ -130,7 +130,21 @@
const next = uKind ? parseValue(typed, uKind, uName) : parseFloat(typed);
// LIVE: every keystroke that parses applies immediately (16-Q3 — it used to
// wait for Enter, so arrow keys inside the box looked like they did nothing)
- if (Number.isFinite(next)) commit(next);
+ if (Number.isFinite(next)) {
+ commit(next);
+ liveText = typed;
+ }
+ }
+
+ /** 37 R1: the text the last LIVE commit applied. Enter blurs, and the blur fires the
+ * native `change` with that same text — re-committing it applied a selection's typed
+ * rotation TWICE (90° → 180°: the multi rows drive a per-gesture pivot handle). A change
+ * that repeats it is skipped; any other change (autofill, a spinner) still commits. */
+ let liveText = '';
+ /** @param {any} event */
+ function onChange(event) {
+ if (event.currentTarget.value === liveText) return;
+ onInput(event);
}
/**
@@ -192,6 +206,7 @@
/** @param {any} event */
function onFocus(event) {
focused = true;
+ liveText = '';
entryValue = Number(value) || 0;
typed = fmt(value);
// select all so typing REPLACES, the usual expectation for a value field
@@ -281,7 +296,7 @@
{disabled}
use:keys
oninput={onInput}
- onchange={onInput}
+ onchange={onChange}
onfocus={onFocus}
onblur={onBlur}
/>
diff --git a/src/components/ui/EmptyState.svelte b/src/components/ui/EmptyState.svelte
new file mode 100644
index 00000000..81cb0f8c
--- /dev/null
+++ b/src/components/ui/EmptyState.svelte
@@ -0,0 +1,73 @@
+
+
+
+ {#if icon}
{/if}
+
{title}
+ {#if description}
{description}
{/if}
+ {#if action}
+
{@render action()}
+ {:else if actionLabel && onaction}
+
{actionLabel}
+ {/if}
+
+
+
diff --git a/src/components/ui/Icon.svelte b/src/components/ui/Icon.svelte
index 524ee95a..7bde8725 100644
--- a/src/components/ui/Icon.svelte
+++ b/src/components/ui/Icon.svelte
@@ -6,18 +6,27 @@
import {
Archive,
ArrowDownToLine,
+ Bell,
Box,
Boxes,
+ Crosshair, // 37 R1: the Pivot cell (Median point)
Brush,
Camera,
+ Check,
// W8b: `MAP[name] ?? Box` means an unmapped name renders a SQUARE, silently —
// which is what the toolbar's reorder controls had been drawing since they
// shipped, two identical boxes where a direction was meant to be. Any name a
// menu passes has to exist here.
ChevronDown,
+ ChevronLeft,
+ ChevronRight,
ChevronUp,
Combine,
Copy,
+ Tag,
+ Unlink,
+ Upload,
+ Ellipsis,
Download,
ExternalLink,
Eye,
@@ -39,14 +48,21 @@
HardDrive,
History,
Image,
+ Inbox,
Info,
+ LayoutGrid,
Lock,
+ List,
Magnet,
+ Minus,
Music,
Package,
PackageOpen,
Pencil,
PenTool,
+ Pin,
+ PinOff,
+ Play,
Plus,
Radar,
Redo2,
@@ -59,6 +75,7 @@
Sparkles,
Spline,
StickyNote,
+ Sun,
Trash2,
Undo2,
Ungroup,
@@ -66,7 +83,8 @@
Video,
Workflow,
Wrench,
- X
+ X,
+ LocateFixed // 37 R1: the Pivot cell (Active object)
} from '@lucide/svelte';
// 15-Q (context-menu redesign): the menu item defs carry icon NAMES in data,
@@ -75,14 +93,21 @@
const MAP = {
archive: Archive,
'arrow-down-to-line': ArrowDownToLine,
+ bell: Bell,
box: Box,
boxes: Boxes,
+ crosshair: Crosshair,
+ 'locate-fixed': LocateFixed,
brush: Brush,
camera: Camera,
+ check: Check,
'chevron-down': ChevronDown,
+ 'chevron-left': ChevronLeft,
+ 'chevron-right': ChevronRight,
'chevron-up': ChevronUp,
combine: Combine,
copy: Copy,
+ ellipsis: Ellipsis,
download: Download,
'external-link': ExternalLink,
eye: Eye,
@@ -104,14 +129,21 @@
'hard-drive': HardDrive,
history: History,
image: Image,
+ inbox: Inbox,
info: Info,
+ 'layout-grid': LayoutGrid,
lock: Lock,
+ list: List,
magnet: Magnet,
+ minus: Minus,
music: Music,
package: Package,
'package-open': PackageOpen,
pencil: Pencil,
'pen-tool': PenTool,
+ pin: Pin,
+ 'pin-off': PinOff,
+ play: Play,
plus: Plus,
radar: Radar,
'redo-2': Redo2,
@@ -124,7 +156,11 @@
sparkles: Sparkles,
spline: Spline,
'sticky-note': StickyNote,
+ sun: Sun,
'trash-2': Trash2,
+ tag: Tag,
+ unlink: Unlink,
+ upload: Upload,
'undo-2': Undo2,
ungroup: Ungroup,
users: Users,
diff --git a/src/components/ui/Menu.svelte b/src/components/ui/Menu.svelte
new file mode 100644
index 00000000..adc1f185
--- /dev/null
+++ b/src/components/ui/Menu.svelte
@@ -0,0 +1,166 @@
+
+
+
+
+
diff --git a/src/components/ui/NavRow.svelte b/src/components/ui/NavRow.svelte
new file mode 100644
index 00000000..bffa4157
--- /dev/null
+++ b/src/components/ui/NavRow.svelte
@@ -0,0 +1,142 @@
+
+
+{#snippet inner()}
+ {#if icon}
+
+ {/if}
+
+
+ {label}
+ {#if dot}(new) {/if}
+ {#if badge} {/if}
+
+ {#if description}{description} {/if}
+
+ {#if value}{value} {/if}
+
+{/snippet}
+
+{#if href && !disabled}
+ {@render inner()}
+{:else}
+ {@render inner()}
+{/if}
+
+
diff --git a/src/components/ui/PropRow.svelte b/src/components/ui/PropRow.svelte
new file mode 100644
index 00000000..4acbff2f
--- /dev/null
+++ b/src/components/ui/PropRow.svelte
@@ -0,0 +1,187 @@
+
+
+
+ {#if labelFor || (valueBox && id)}
+
{label}
+ {:else}
+
{label}
+ {/if}
+ {#if slider}
+
+ {:else if control}
+
{@render control()}
+ {/if}
+ {#if valueBox}
+
+
+
+ {/if}
+
+
+
diff --git a/src/components/ui/SearchField.svelte b/src/components/ui/SearchField.svelte
new file mode 100644
index 00000000..ee7396a4
--- /dev/null
+++ b/src/components/ui/SearchField.svelte
@@ -0,0 +1,143 @@
+
+
+
+
+ oninput(value)}
+ {...rest}
+ />
+ {#if value}
+
+ {:else if hint}
+ {hint}
+ {/if}
+
+
+
diff --git a/src/components/ui/Section.svelte b/src/components/ui/Section.svelte
index ad447aea..44025792 100644
--- a/src/components/ui/Section.svelte
+++ b/src/components/ui/Section.svelte
@@ -4,7 +4,18 @@
// while $inspectorFilter is non-empty every section force-renders its
// content (so hidden rows are searchable), matches the query against its
// rendered TEXT, and hides itself when nothing matches.
+ //
+ // 38 R3 (SPEC §2 Section): two redesign variants beside the legacy look (the DEFAULT —
+ // every existing caller renders byte-identical until its lane opts in):
+ // variant="card" Settings: uppercase header + optional scope badge + ONE card whose
+ // rows are split by 1px dividers. Never collapses, and is NOT wired to
+ // the Inspector's filter or deep links (a Settings card named "Grid"
+ // must not answer — and clear — the Inspector's "Grid" request).
+ // variant="panel" Inspector / panels: collapsible uppercase header with a chevron and
+ // an optional badge; same filter + deep-link + persisted collapse as legacy.
import { inspectorFilter, inspectorScrollTo } from '../../stores/appStore';
+ import Icon from './Icon.svelte';
+ import Badge from './Badge.svelte';
/**
* P6: `aliases` are OLD deep-link names this section still answers to. A section's
@@ -12,15 +23,18 @@
* menus, other components and suites — the 21-G1 rule is that the word may change and
* the identifier may not, so a rename lists what it used to be called instead of
* hunting every caller (and silently missing one).
- * @type {{label?: string, aliases?: string[], collapsible?: boolean, open?: boolean, children?: any}}
+ * @type {{label?: string, aliases?: string[], collapsible?: boolean, open?: boolean, variant?: 'legacy'|'card'|'panel', badge?: string, children?: any}}
*/
let {
label = '',
aliases = [],
collapsible = true,
open = $bindable(true),
+ variant = 'legacy',
+ badge = '',
children = null
} = $props();
+ const isCard = $derived(variant === 'card');
const LS = typeof localStorage !== 'undefined' ? localStorage : null;
// persisted collapse, keyed by the section label (static per instance — a
@@ -37,7 +51,7 @@
/** @type {any} */ let root = $state(null);
let match = $state(true);
$effect(() => {
- const q = $inspectorFilter.trim().toLowerCase();
+ const q = isCard ? '' : $inspectorFilter.trim().toLowerCase();
if (!q) {
match = true;
return;
@@ -46,7 +60,7 @@
match = (label + ' ' + (root?.textContent ?? '')).toLowerCase().includes(q);
});
- const filtering = $derived($inspectorFilter.trim().length > 0);
+ const filtering = $derived(!isCard && $inspectorFilter.trim().length > 0);
const showContent = $derived(filtering ? true : !collapsible || (open && !collapsed));
// 16-Q2: a menu deep link ("More snapping settings…") names a section — expand it
@@ -56,7 +70,7 @@
const request = $inspectorScrollTo;
// a request is either "Grid" or "Camera:Saved views" (section:sub-anchor)
const [wanted, anchor] = String(request ?? '').split(':');
- if (!request || (wanted !== label && !aliases.includes(wanted))) return;
+ if (isCard || !request || (wanted !== label && !aliases.includes(wanted))) return;
collapsed = false;
try {
LS?.setItem('inspector:sec:' + label, 'open');
@@ -105,6 +119,37 @@
});
+{#if variant === 'card'}
+
+
+
{label}
+ {#if badge} {/if}
+
+
+ {@render children?.()}
+
+
+{:else if variant === 'panel'}
+
+ {#if collapsible && !filtering}
+
+
+ {label}
+ {#if badge} {/if}
+
+ {:else}
+
+ {label}
+ {#if badge} {/if}
+
+ {/if}
+ {#if showContent}
+
+ {@render children?.()}
+
+ {/if}
+
+{:else}
{#if collapsible && !filtering}
{/if}
+{/if}
+
+
diff --git a/src/components/ui/Segmented.svelte b/src/components/ui/Segmented.svelte
new file mode 100644
index 00000000..2baaedcf
--- /dev/null
+++ b/src/components/ui/Segmented.svelte
@@ -0,0 +1,142 @@
+
+
+
+ {#each options as o, i (o.value)}
+ pick(i)}
+ onkeydown={(e) => onKey(e, i)}
+ >
+ {#if o.icon} {/if}
+ {o.label}
+
+ {/each}
+
+
+
diff --git a/src/components/ui/SettingRow.svelte b/src/components/ui/SettingRow.svelte
new file mode 100644
index 00000000..345d663b
--- /dev/null
+++ b/src/components/ui/SettingRow.svelte
@@ -0,0 +1,171 @@
+
+
+
+
+
+ {#if labelFor}
+ {label}
+ {:else}
+ {label}
+ {/if}
+ {#if badge} {/if}
+
+ {#if desc}
+
{@render desc()}
+ {:else if description}
+
{description}
+ {/if}
+
+ {#if children}
+
{@render children()}
+ {/if}
+ {#if extra}
+
+ {/if}
+
+
+
diff --git a/src/components/ui/Sheet.svelte b/src/components/ui/Sheet.svelte
new file mode 100644
index 00000000..777fc178
--- /dev/null
+++ b/src/components/ui/Sheet.svelte
@@ -0,0 +1,303 @@
+
+
+ (viewportH = window.innerHeight)} />
+
+{#if open}
+ {#if modal}
+ dismissible && close()}>
+ {/if}
+
+
+
+
+
+ {#if header}
+ {@render header()}
+ {:else if title}
+
+
{title}
+ {#if dismissible}
+
+
+
+ {/if}
+
+ {/if}
+
+
+ {@render children?.()}
+
+
+{/if}
+
+
diff --git a/src/components/ui/Slider.svelte b/src/components/ui/Slider.svelte
new file mode 100644
index 00000000..e9049861
--- /dev/null
+++ b/src/components/ui/Slider.svelte
@@ -0,0 +1,65 @@
+
+
+
+
+ {#if readout}{format(value)} {/if}
+
+
+
diff --git a/src/components/ui/Tabs.svelte b/src/components/ui/Tabs.svelte
new file mode 100644
index 00000000..9b910005
--- /dev/null
+++ b/src/components/ui/Tabs.svelte
@@ -0,0 +1,161 @@
+
+
+
+
+
+ {#each tabs as t, i (t.id)}
+ select(i)}
+ onkeydown={(e) => onKey(e, i)}
+ >
+ {t.label}{#if t.count !== undefined && t.count !== null && t.count !== ''}{t.count} {/if}
+
+ {/each}
+
+ {#if actions}
{@render actions()}
{/if}
+
+ {#if panel && value}
+
+ {@render panel(value)}
+
+ {/if}
+
+
+
diff --git a/src/components/ui/ThemedSelect.svelte b/src/components/ui/ThemedSelect.svelte
index 64923c8d..db67b940 100644
--- a/src/components/ui/ThemedSelect.svelte
+++ b/src/components/ui/ThemedSelect.svelte
@@ -16,6 +16,7 @@
id = undefined,
disabled = false,
title = undefined,
+ mixed = false,
class: klass = ''
}: {
items?: Array<{ value: any; name: string }>
@@ -25,6 +26,9 @@
id?: string
disabled?: boolean
title?: string
+ /** 37 R1: a multi-selection disagrees — show a dash, select nothing, and let ANY
+ * pick (even the primary's own value) write, since it sets every member */
+ mixed?: boolean
class?: string
} = $props()
@@ -35,8 +39,8 @@
let pos = $state({ left: 0, top: 0, width: 0 })
const eq = (a: any, b: any) => String(a) === String(b)
- let selected = $derived(items.find((it) => eq(it.value, value)))
- let label = $derived(selected ? selected.name : placeholder)
+ let selected = $derived(mixed ? undefined : items.find((it) => eq(it.value, value)))
+ let label = $derived(mixed ? '—' : selected ? selected.name : placeholder)
async function reposition() {
await tick()
@@ -58,7 +62,7 @@
function openList() {
if (disabled) return
open = true
- activeIndex = items.findIndex((it) => eq(it.value, value))
+ activeIndex = mixed ? -1 : items.findIndex((it) => eq(it.value, value))
reposition()
}
function toggle() {
@@ -67,7 +71,7 @@
}
function pick(item: any) {
open = false
- if (!eq(item.value, value)) {
+ if (mixed || !eq(item.value, value)) {
value = item.value
onchange(item.value)
}
@@ -107,8 +111,27 @@
if (btn?.contains(t) || listEl?.contains(t)) return
open = false
}
+ // 37 R18: a TRUE modal dialog (showModal) puts itself in the top layer and makes the rest
+ // of the page inert, so a list portaled to painted UNDER it and took no clicks.
+ // Inside such a dialog the list portals into the dialog instead; `origin` corrects for a
+ // containing block that is not the viewport (a transformed dialog), measured once.
+ let origin = $state({ x: 0, y: 0 })
+ function modalHost(): HTMLElement | null {
+ const dlg = btn?.closest('dialog[open]') as HTMLElement | null
+ try {
+ return dlg && dlg.matches(':modal') ? dlg : null
+ } catch {
+ return null
+ }
+ }
function portal(node: HTMLElement) {
- document.body.appendChild(node)
+ const host = modalHost()
+ ;(host ?? document.body).appendChild(node)
+ origin = { x: 0, y: 0 }
+ if (host) {
+ const r = node.getBoundingClientRect()
+ origin = { x: r.left - pos.left, y: r.top - pos.top }
+ }
return {
destroy() {
node.remove()
@@ -143,7 +166,7 @@
onclick={toggle}
onkeydown={onKey}
>
- {label}
+ {label}
▾
@@ -155,16 +178,16 @@
class="ts-list"
role="listbox"
tabindex="-1"
- style="left:{pos.left}px; top:{pos.top}px; min-width:{pos.width}px;"
+ style="left:{pos.left - origin.x}px; top:{pos.top - origin.y}px; min-width:{pos.width}px;"
>
{#each items as item, i (item.value)}