diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 33d953a3..7e8e98eb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,6 +53,11 @@ jobs: # the subscriber for the session. - name: no bare localStorage run: node scripts/check-storage.cjs + # 38 R2: colours come from the redesign tokens. WARNING mode while the redesign + # migrates area by area — it only fails when a token-CLEAN file (its CLEAN list) + # grows a raw colour. R11 switches this to --strict. + - name: redesign tokens (warning mode) + run: node scripts/check-tokens.cjs # 34 B3: scene-lint over every scene core ships (the seed) and every scene of the scenes ref # the perf budget is about (perf/budgets.json), with the packs checkout for the kit rules. diff --git a/CHANGELOG.md b/CHANGELOG.md index f9affa55..779fd5b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,69 @@ per release, newest first. HTML comments like this one are stripped before rendering, so maintainer notes stay out of the user-facing window. --> +## 1.26.0 — Edit many at once: prefabs that update, material presets and a new Settings 🧰 + +### 🎯 Selections + +- 🧮 **The Inspector edits a whole selection.** Rows that differ show a dash (checkboxes go indeterminate, lists show + —); one edit is ONE undo step and reaches everyone as one change. Visibility, shadow, render-order and culling flags + and every Light row are now undoable, and Light rows edit all selected lights at once. +- 📍 **Pivot point: Median, Active or Individual** — in the Inspector, the object menu, a new **Pivot** button on the + toolbar (after Scale) and VR Settings ▸ Editing. In VR, grip any member of a selection to move the whole set; twist + or push the stick to rotate and scale it about the pivot. +- 🗂️ **Drag a selection onto a group or any object** in the object list to put it inside; drop it on the list to + bring it back to the top. + +### 🧱 Prefabs and materials + +- 🔁 **Prefabs update their copies.** Editing a prefab (Update from selection, or an instance's Prefab ▸ Apply + changes) offers **Update N instances** — each copy keeps its own changes unless you pick Reset overrides; one undo, + and everyone in the room sees it. Right-click a placed copy for **Prefab ▸ Update / Reset / Unlink / Select all**. + Prefabs carry their objects' node graphs. +- 📁 **Folders and tags in the Library's Prefabs tab**: New folder, Move to folder, drag onto a folder card, a + breadcrumb, tags in Properties and a tag filter + tag search. +- 🎨 **Material presets**: save, rename and share named looks. A starter set (wood, metal, plastic, glass, stone, + rubber, neon) is one click away at the top of Inspector ▸ Material; your presets stay on your device and everyone + in the session can use them. +- 🩹 Texture maps no longer vanish when a multi-slot material change is undone or reaches a peer. + +### 🪟 Workspace, chat and notes + +- 🧭 **Settings, redesigned**: a grouped menu (General · Workspace · Devices & services, About & what's new pinned), + cards with one control per row, toggles, segmented controls and sliders with readouts, "This device" badges, + sub-pages with a breadcrumb (push navigation on a phone), search results that show their path and jump to the row, + a per-category **Reset … to defaults** that asks first, and **Interface ▸ Density** (Comfortable / Compact). + Clear saved session and Reset all settings moved to About ▸ Danger zone. No stored setting changed. +- 🗔 **Workspace layouts**: save the windows you have open (panels, docked or floating, sizes, docks, tab groups) + under a name and switch in one click — menu ▸ Layouts, or Settings ▸ Interface ▸ Windows & chrome. +- 💬 **Chat v2**: @mentions in each person's colour, emoji shortcodes (`:tada:`), an unread badge, and the chat so far + for people who join late or reopen a saved session. +- 🧵 **Reply to a note** in a thread; adding, editing and resolving notes is undoable. +- ↩️ **Undo after destructive actions**: Clear scene, Delete selection, Remove module and Reset all settings show a + toast with **Undo** for about 8 seconds — for everyone in the session for scene changes, on this device for modules + and settings. +- 🐞 **Report a problem** (menu, and in VR): a screenshot, boxes around what is wrong and a note, sent to the team + with your consent when you are signed in — or kept on your device. +- 🔧 Small fixes: **Z** cycles your view mode (Shaded → Shaded + AO → Wireframe), the edit-mode wireframe re-tints + the moment a colour changes, VR note pins stay on their objects while you grab the world, and dropdowns open + correctly inside a full-screen dialog. + +### 🧍 Characters + +- 🦶 **Characters stand on the floor** in Play and keep their feet planted. +- 💫 **Knocked off, not spinning**: an idle character now sees stars — orbiting stars, a woozy sway, star eyes — + instead of spinning its head (Settings ▸ Interface ▸ Avatars ▸ Knocked-off idle: Off / 10 / 20 / 60 s). +- 🕊️ **Flying in Play is opt-in per game**: off unless a game wants it, and a scene can remove it (Configure Scene ▸ + Physics ▸ Play mode ▸ Flying). Football, Marble maze, Target toss and Stars Room now walk on desktop. + +### 🏎️ Race and modules + +- 📱 **Race on phones and in VR**: on a phone the stick steers and Gas/Brake sit under your right thumb; in a headset + you sit in the car and the left stick drives (a VR player could not drive before). +- 🏔️ The Race mountains get grass, rock and snow by slope and height. +- 🧩 Module SDK: `api.registerPointerHandler`, `api.onClickMiss`, `api.camera()`, `api.onPlayMode` / `api.inGame()`, + `api.vrSeat` / `api.vrUnseat`, `api.input().touch` and the `drive` touch preset; Untangle aims with `api.camera()`. + ## 1.25.0 — Your feedback, fixed: panels that keep their keys, water that behaves 🌊 ### ⌨️ Panels and windows diff --git a/CLAUDE.md b/CLAUDE.md index d8954dbf..82284909 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2632,6 +2632,21 @@ loadable play content. Everything a user does must be visible to connected peers pushes/pulls a one-grip world, as Edit does an object); `addSetting({onLevels: true})` draws a choice as tabs on the VR Levels page, which now lists every level. +## UI design system (redesign epic) + +- All UI follows the redesign spec kept in the `cloud` repo: `docs/design/redesign/SPEC.md` + (mockups in `docs/design/redesign/mockups/`). Locally that is + `../cloud/docs/design/redesign/` from this checkout (`theprototype-app/cloud`). +- Build UI only from `src/components/ui` primitives (WindowChrome, Tabs, Segmented, Chips, + Toggle, Button, Badge, SettingRow, PropRow, NavRow, EmptyState, Sheet). Add a primitive + there before inventing a one-off. +- Colours, radii, spacing and type come from the semantic tokens in `app.css` only — no raw + hex, no ad-hoc Tailwind palette colours in components (`npm run check:tokens`). +- Every icon goes through `ui/Icon.svelte` (sizes 16 or 20 only). +- A redesign change must not alter behaviour: DragRow scrubbing, modifiers, click-to-type, + shortcuts, undo grouping, peer sync, storage keys and defaults. The behaviour-lock e2e + suites must stay green. + ## Replication golden rules 1. Every mutation = apply locally + `$peers.send({type, ...})`; receivers apply WITHOUT @@ -4943,11 +4958,13 @@ loadable play content. Everything a user does must be visible to connected peers `error during build: undefined` (svelte-check never runs; vite dev 500s too). Same trap in any non-TS component: JSDoc for types, never TS syntax (#13-B3). - **Icons = `@lucide/svelte` SVG components** (Font Awesome fully REMOVED post-1.0.1 - — never add `fa-` classes). Static markup imports named components - (`import { Play } from '@lucide/svelte'`; sizes 16 inline/menu, 18-20 toolbar/ - header, 24 the play FAB; `aria-hidden="true"` when decorative); DATA-DRIVEN icon - names (Explorer KIND_ICONS, menu-item defs) render via `components/ui/Icon.svelte` - (kebab lucide names). Icons inherit `currentColor` — never hardcode grays; + — never add `fa-` classes). **Redesign (SPEC §7): EVERY icon goes through + `components/ui/Icon.svelte`** — data-driven names (Explorer KIND_ICONS, menu-item defs) + AND static markup (``, kebab lucide names; add the name to its MAP, + an unmapped name silently renders a box). Sizes 16 (inline/menu/rows) or 20 (toolbar/ + header) only, stroke ~1.75 at 16; `aria-hidden="true"` when decorative. Older code still + imports named components from `@lucide/svelte` directly — R10 migrates it; new code + never does. Icons inherit `currentColor` — never hardcode grays; semantic colors come from the `--icon-*` theme tokens. TWO TRAPS: a `class` passed to a lucide component lands on the CHILD-scope ``, so scoped CSS targeting it needs `:global(...)` (bit cx-chevron/tp-toast-icon/role-caret — silent style loss, diff --git a/OPEN-CORE.md b/OPEN-CORE.md index 0a40d392..3c21f980 100644 --- a/OPEN-CORE.md +++ b/OPEN-CORE.md @@ -71,6 +71,7 @@ export function register(api) { api.camera.pose() / setPose(p) / bookmarks() / recall(id) api.startPlay() / api.stopPlay() api.setCommunityProvider(p) // swap the Templates ▸ Community source (null restores GitHub) + api.setProblemReporter(r) // 37 R20: where "Report a problem" sends ({submit, account}) — typeof-probed api.toast(message) } @@ -257,6 +258,21 @@ Promise<{liked, likeCount} | null>` puts a heart on every card (`[data-card-hear A play link's frame may carry `&src=play|embed&b=v` (read once with `embed=1`; `playMode.embedSource` / `embedBuild`) — the badge inside counts them. +#### 37 R20: "Report a problem" — `setProblemReporter(reporter | null)` — additive, no bump + +Core draws the whole report — the screenshot taken at the press, boxes drawn round what is +wrong, the note, an explicit consent box — and calls the reporter only when the person +presses **Send** with the box ticked. Without a reporter (no plugin, or an older plugin) the +report is kept on this device as a Profiler recording and the card offers no Send. + +| member | contract | +|---|---| +| `reporter.submit(report)` | `report = {note, marks: [{x, y, w, h}] (fractions of the picture, ≤ 12), meta: {version, build, scene, device, url (no query), viewport, xr, peers, at}, perf: T1 light window \| null, shot: Blob (JPEG) \| null}` → `Promise<{ok, id?, reason?, error?}>`. `reason: 'signin'` = the card keeps itself open and says where to sign in | +| `reporter.account()` | `{signedIn, name?}` — read when the card opens and every 1.5 s while it is open; Send is off until `signedIn` | + +In a headset (VR menu ▸ Profile ▸ Report a problem) there are no boxes: the VR keyboard's +title says that Enter sends the report, which is the consent. + #### Deep links: `?s=` Published scenes deep-link through the **query string** (`/?s=[&play=1][&remix=1]`) diff --git a/check-baseline.json b/check-baseline.json index 7574f424..44892f38 100644 --- a/check-baseline.json +++ b/check-baseline.json @@ -1,6 +1,6 @@ { "comment": "27-I: the svelte-check floor, read ONLY by scripts/check-ratchet.cjs. It used to be hardcoded in release.yml's shell block, where it went stale (362 while the tree measured 359). Ratchet it DOWN whenever a change legitimately removes errors - that is the project convention, and --update does it in one command.", - "errors": 318, + "errors": 306, "warnings": 47, - "measured": "2026-10-05" + "measured": "2026-10-07" } diff --git a/docs/settings-inventory.md b/docs/settings-inventory.md new file mode 100644 index 00000000..bfe59362 --- /dev/null +++ b/docs/settings-inventory.md @@ -0,0 +1,435 @@ +# Settings inventory — R21 Settings redesign (step 1, for review) + +Lane `37-settings`, core `feat/37-settings`, base 1.25.0 (f2be8687). Spec: +`cloud/plans/core/lanes/37/settings-redesign/SPEC.md` + roadmap 38 handoff SPEC §1–§3. + +**What this is.** Every category, section, setting and submenu in the Settings window as of 1.25.0: what it is called +today, what control it uses, its scope, and where its value is stored. Each row also shows the planned new label, control +and section. **Nothing about what a setting stores or does changes.** Only the layout, grouping, control type and copy do. +After the redesign, a test walks this table and checks that every storage key still reads and writes the same values. + +**How to read the tables** + +- **Scope**: `D` = this device only (local preference, never sent or saved into a scene). `S` = shared (other people see + the effect, or it travels with your identity). `—` = an action (a button that does something, stores nothing). +- **Store → key**: the Svelte store the row binds, then the exact `localStorage` key (through `safeStorage`) it persists + under. `(json)` = the value is a JSON object, and the row writes one field of it. `(idb)` = IndexedDB. +- **Now** / **New**: control types. `toggle` (switch), `check` (checkbox), `select` (dropdown), `seg` (segmented), + `range` (slider), `num` (number field), `text`, `color`, `btn` (button), `nav` (NavRow → a sub-page), `info` (text only). +- **New label** is left blank when the label stays. New descriptions are one sentence each. The full copy is in the + migration commits. Each new description says what the setting does, not how it is built. +- A row whose label changes keeps its **old label as a search keyword**, so searching the old name still finds it. + +--- + +## 1. Navigation (all categories) + +| Today (sidebar order) | New group | New position | Deep-link key (`settingsSection`, unchanged) | +|---|---|---|---| +| Interface | General | 1 | `interface` | +| Controls | General | 2 | `controls` | +| Input | General | 3 | `input` | +| Touch controls | General | 4 | `touch` | +| Shortcuts | General | 5 (moves up from 12th) | `shortcuts` | +| Scene | Workspace | 6 | `scene` | +| Explorer | Workspace | 7 | `explorer` | +| Node types | Workspace | 8 (moves above Export) | `nodetypes` | +| Export | Workspace | 9 | `export` | +| VR | Devices & services | 10 | `vr` | +| AI | Devices & services | 11 | `ai` | +| Connection | Devices & services | 12 | `connection` | +| About | **About & what's new** (pinned to the bottom, unread dot = `whatsNewUnseen`) | 13 | `about` | + +- **Desktop (≥ 640 px)**: the left menu is grouped under small uppercase headers. Search moves into the modal header. The + content column is about 660 px max. A submenu opens as a sub-page in the content area, with a breadcrumb + ("VR › Remap buttons") and a back button. +- **Mobile (< 640 px)**: the horizontal category chips are removed. Screen 1 shows the "Settings" title, a close button, + a full-width search field (16 px text) and the categories as grouped NavRows (52 px tall). Tapping one pushes the + category page ("‹ Settings · Title · ✕"). A submenu pushes again ("‹ VR"). +- **Search**: results become a list of matching rows. Each result shows its path ("Interface › Sound") and jumps to the + row. Every row label, every registered `keywords` list and every old label stays searchable. + +## 2. Footer and destructive actions + +| Today | Where it goes | Behaviour | +|---|---|---| +| Footer **Reset settings** (`safeStorage.clear()`: wipes **all** of this browser's local storage for the app, including window positions, Explorer column widths and AI keys; **no confirmation** today) | Each category gets a quiet footer link, **"Reset ‹Category› to defaults"** (desktop: bottom left in the warning text colour; mobile: end of the page). It asks for confirmation and clears only the keys listed in that category's table below. **About › Danger zone › "Reset all settings"** keeps today's wipe-everything behaviour, now behind a confirmation. | Category reset is new, as the spec requires. Reset all = unchanged, plus a confirmation. | +| Footer **Clear saved session** (`clearSavedSession()`: deletes the autosave snapshot, idb `latest`) | **About › Danger zone**, with a confirmation | Unchanged, plus a confirmation | +| Footer **What's new** (closes Settings, opens the What's new dialog) | **About & what's new › What's new**. It opens as a sub-page inside Settings, showing the same changelog. The logo menu entry keeps opening the stand-alone dialog. | Same content and the same "seen" bookkeeping | +| (none) | Desktop footer right: "Changes save automatically" + a primary **Done** (closes). Mobile: the note sits at the end of each page. | — | + +The sticky footer with three big buttons is removed. + +## 3. The categories + +### 3.1 Interface — "How the app looks, sounds and greets you." + +| # | Section now → new | Current label | Now | Scope | Store → key | New label | New | Notes | +|---|---|---|---|---|---|---|---|---| +| 1 | Appearance | Allow text selection everywhere | toggle | D | `allowTextSelection` → `allowTextSelection` | | toggle | | +| 2 | Avatars | Your character | btn "Customize character…" | — | opens Character panel (closes Settings) | | btn "Customize" | | +| 3 | Avatars | Show everyone as classic heads | toggle | D | `peersAsClassic` → `avatars:peersClassic` | Show others as classic heads | toggle | badge *This device* (replaces "On this device only") | +| 4 | Appearance | Theme | select (Dark, Light, Green console, 8-bit, High contrast + custom) | D | `theme` → `theme` | | select | badge *This device*. 5+ options, so it stays a dropdown (see Decision A). | +| 4b | Appearance | **NEW** (NOTES-38 #19) | — | D | `uiDensity` → `ui:density` (absent = Comfortable) | Density | segmented Comfortable / Compact (on its own line on a phone) | badge *This device*. Intentional NEW row, not in 1.25.0: Compact sets `data-density="compact"` on `` (desktop only; phones keep 44 px targets). | +| 5 | Appearance | Custom theme | 2 btns + chips of loaded themes (✕ removes) | D | `customThemes` → `customThemes` | | btn pair "Export" / "Load file…" (wide), loaded themes as removable chips under the row | | +| 6 | Sound *(section badge This device)* | Game sounds | range 0–1 (% in the description) | D | `gameSoundVolume` → `game:soundVolume` | | range + mono readout "80%" | | +| 7 | Sound | Music | range 0–1 | D | `gameMusicVolume` → `game:musicVolume` | | range + mono readout | | +| 8 | Notifications | Welcome on start | toggle + "open it now" link | D | `showWelcomeOnStart` → `showWelcomeOnStart` | Welcome card on start | toggle; link "Open it now" in the description | | +| 9 | Notifications | Announce new versions | toggle | D | `showWhatsNewNotice` → `showWhatsNewNotice` | | toggle | | +| 10 | Notifications | Toasts in drawer only | toggle | D | `toastsInDrawerOnly` → `toastsInDrawerOnly` | Show pop-ups only in the drawer | toggle | | +| 11 | Notifications → **Tours** | Show tours automatically | toggle | D | `tours.autoStartEnabled` → `toursAutoStart` | | toggle | | +| 12 | Tours | VR welcome | btn "Start VR welcome" + status text | — | `startVRWelcome()`; status from tour records | VR welcome tour | btn "Start"; status ("Not seen yet") as the row value | | +| 13 | Tours | Editor tour | btn "Start editor tour" + status | — | `startEditorTour()` | | btn "Start"; status as value | | +| 14 | Tours | Offer Enter VR | toggle | D | `xrOfferEnabled` → `xrOfferSession` | Offer Enter VR in a headset browser | toggle | | +| 15 | Tours | Reset tours | btn "Reset all" | — (removes every `tour.` + `toursAutoStart`) | `resetAllTours()` | | btn "Reset" | an action, so the category reset does not run it (it only resets `toursAutoStart`) | +| 16 | Windows & chrome | Show Rooms button *(only with the cloud plugin)* | toggle | D | `showRoomsButton` → `showRoomsButton` | | toggle | | +| 17 | Windows & chrome | Floating toolbar | toggle | D | `floatingToolbar` → `floatingToolbar` | Lift the toolbar above docked panels | toggle | | +| 18 | Windows & chrome | Toolbar always on top | toggle | D | `toolbarAlwaysOnTop` → `toolbarOnTop` | | toggle | | +| 19 | Windows & chrome | Window positions | btn "Reset" | — | `resetWindowLayout()` | | btn "Reset" | | +| 20 | Windows & chrome | Touch tools | toggle | D | `touchTools` → `touchTools` | | toggle | | +| 21 | Windows & chrome | Allow undocking (touch) | toggle | D | `mobileUndockAllowed` → `mobileUndockAllowed` | Allow undocking on touch screens | toggle | | +| 22 | Lists & menus | Advanced mode | check | D | `advancedMode` → `advancedMode` | Show system objects in the object list | **toggle** | old label kept as a keyword | +| 23 | Lists & menus | Environment in list | check | D | `showEnvInList` → `showEnvInList` | Show the environment in the object list | **toggle** | | +| 24 | Lists & menus | Object search in menu | check | D | `objectSearchEnabled` → `objectSearchEnabled` | Object search in the right-click menu | **toggle** | | +| 25 | Viewport | Show FPS + draw calls | toggle | D | `perfStatsShown` → `perfStats:show` | Show FPS and draw calls | toggle | the same store as VR › Display › "FPS and draw calls" (headset only) | +| 26 | Viewport | Send performance reports *(only when the build has a reports URL)* | toggle | D | `perfReportsOn` → `perfReports:send` | | toggle | | +| 27 | Viewport | Dock resizes the viewport | toggle | D | `viewPrefs.dockPushesViewport` → `viewPrefs` (json) | | toggle | | + +Section order in the new page: **Appearance** (theme, custom theme, text selection) · **Avatars** · **Sound** · +**Notifications** · **Tours** · **Windows & chrome** · **Lists & menus** · **Viewport**. In the mockup, Appearance holds +these three rows in this order. + +### 3.2 Controls — "Keyboard, mouse and trackpad." + +| # | Section | Current label | Now | Scope | Store → key | New label | New | Notes | +|---|---|---|---|---|---|---|---|---| +| 1 | Keyboard & mouse | Shift+A quick add | toggle | D | `enableShiftAdd` → `enableShiftAdd` | Shift+A opens the Add menu | toggle | | +| 2 | Keyboard & mouse | Show helpers in Play (debug) | toggle | D | `helpersInPlay` → `helpersInPlay` | Show helpers in Play | toggle | badge *Debug* | +| 3 | Keyboard & mouse | Double-click to open notes | toggle | D | `noteDoubleClickToOpen` → `noteDoubleClickToOpen` | | toggle | | +| 4 | Trackpad | Trackpad gestures | select Auto/On/Off | D | `trackpadMode` → `trackpadMode` | | **seg** Auto · On · Off | | +| 5 | Trackpad | Two-finger pan | toggle | D | `panEnabled` → `trackpadPanEnabled` | | toggle | | +| 6 | Trackpad | Reverse trackpad pan | toggle | D | `reversePan` → `trackpadReversePan` | | toggle | | +| 7 | Trackpad | Pinch zoom | toggle | D | `pinchZoomEnabled` → `trackpadPinchZoom` | | toggle | | +| 8 | Trackpad | Allow browser pinch zoom | toggle | D | `allowBrowserZoom` → `allowBrowserZoom` | | toggle | | +| 9 | Trackpad → **Diagnostics** (collapsible, closed) | Wheel diagnostics | info + table of the last 8 wheel events | — | `lastWheelEvents` (live, not stored) | | collapsible block | the spec's "Controls › wheel diagnostics into a collapsible Diagnostics block" | + +### 3.3 Input — "Gamepad and the node editor's mouse." + +| # | Section | Current label | Now | Scope | Store → key | New label | New | Notes | +|---|---|---|---|---|---|---|---|---| +| 1 | Gamepad | Gamepad | toggle | D | `gamepadPrefs.enabled` → `gamepadPrefs` (json) | Use a connected gamepad | toggle | | +| 2 | Gamepad | Swap sticks | toggle | D | `gamepadPrefs.swapSticks` (json) | | toggle | | +| 3 | Gamepad | Invert look Y | toggle | D | `gamepadPrefs.invertY` (json) | | toggle | | +| 4 | Gamepad | Stick deadzone | num (DEADZONE_RANGE, step 0.01) | D | `gamepadPrefs.deadzone` (json) | | **range** + mono readout | same min/max/step | +| 5 | Gamepad | Look sensitivity | num (SENSITIVITY_RANGE, step 0.1) | D | `gamepadPrefs.lookSensitivity` (json) | | **range** + readout "1.0×" | same min/max/step | +| 6 | Bindings | Per-game controls | info | — | — | — | muted footnote under Gamepad | no longer a row | +| 7 | Node editor | Mouse bindings | select (Classic / Select-first) | D | `flowMouseBindings` → `flow:mouseBindings` | Node editor mouse | **seg** Classic · Select-first (wide) | | +| 8 | Node editor | Node editor opens | select (Where it was left / Framed) | D | `nodeEditorOpens` → `flow:opens` | | **seg** Where left · Framed (wide) | | + +### 3.4 Touch controls — "The on-screen stick and action buttons for phones and tablets." + +| # | Section | Current label | Now | Scope | Store → key | New label | New | Notes | +|---|---|---|---|---|---|---|---|---| +| 1 | On-screen controls *(badge This device)* | Show touch controls | seg (tp-seg) Auto/Always/Never | D | `touchPrefs.visibility` → `touchControlsPrefs` (json) | | seg (Segmented primitive) | | +| 2 | On-screen controls | Show in edit | toggle | D | `touchPrefs.showInEdit` (json) | | toggle | | +| 3 | On-screen controls | Haptic tick | toggle | D | `touchPrefs.haptics` (json) | | toggle | | +| 4 | On-screen controls | Look speed | range (readout in the description) | D | `touchLookSpeed` → `touchLookSpeed` | | range + readout "1.00×" | | +| 5 | On-screen controls | Layout | btns "Edit layout" / "Reset" | — + D | `openTouchLayoutEditor()` / `resetTouchLayout('game'\|'global')` → `touchControlsLayouts` | | btn pair "Reset" · "Edit layout" | | +| 6 | Button looks | one row per action: Jump, Fire, Use, Grab, Crouch, Sprint, Reload, Up, Down (+ any the open scene's game adds) — each with previews, Released/Pressed image (Upload · Explorer… · ✕), Tint, Size, "Default look" | ~10 controls in one row | D | `touchTextures[actionId]` → `touchControlsTextures` (json: released, pressed, tint, scale) | | **3-column tile grid** (icon, name, "Default"/"Custom"); a tile is a **nav** to a sub-page | **SUBMENU** below | + +**Submenu — Touch controls › ‹Action›** (for example "Touch controls › Jump"), one per action: + +| Row | Control | Writes | +|---|---|---| +| Preview | released + pressed button previews | — | +| Released image | btn pair "Upload…" · "From Explorer…" (+ "Clear" when set) | `touchTextures[id].released` | +| Pressed image | same | `touchTextures[id].pressed` | +| Tint | color | `touchTextures[id].tint` | +| Size | range + readout | `touchTextures[id].scale` | +| Use the default look | btn (only when customised) | `clearTouchTexture(id)` | + +### 3.5 Shortcuts — "Click a shortcut's keys, then press the new combination." + +| # | Section | Current | Now | Scope | Store → key | New | Notes | +|---|---|---|---|---|---|---|---| +| 1 | page note | "Click a shortcut's keys to rebind it – Esc cancels" | info | — | — | the page description | | +| 2 | page note | non-Latin layout note *(conditional)* | info (amber) | — | `nonLatinLayoutSeen` | muted note under the description | | +| 3 | header | Reset all | btn | D | `resetAllShortcuts()` → removes `shortcutOverrides` | becomes the footer's "Reset Shortcuts to defaults" (confirm) | same function | +| 4 | one **section per group**: Mesh edit, Node editor, Transform, UV editor, Animation, Movement, Camera, Objects, Panels, Scene, History, Voice, Help (13 today; module groups are added at runtime) — the group scope ("keys work in the Node editor") | 3-column grid of rows | | | section header + a muted scope subtitle | | +| 5 | each shortcut (44 static + runtime) | key button (rebindable) or locked `kbd` + lock icon; ↺ reset icon when changed; "Swap / Cancel" conflict line | D | `shortcutOverrides` (json; removed when empty) | **one column of rows**: label left, key button right (locked: muted key + lock), ↺ icon button, the conflict line under the row | capture still swallows the key press (voice PTT, Esc) | + +### 3.6 Scene — "How the 3D view draws, saves and behaves on this device." (35 rows → 9 sections) + +| # | Section now → **new** | Current label | Now | Scope | Store → key | New label | New | Notes | +|---|---|---|---|---|---|---|---|---| +| 1 | — → **Viewport** | Show grid | check | D | `showGrid` → `showGrid` (stored `'false'` only when hidden; removed when shown) | | **toggle** | keep the inverted write exactly | +| 2 | — → Viewport | Light helper length | num 0.2–50 | D | `lightHelperLength` → `lightHelperLength` | | num (+ "m") | | +| 3 | — → Viewport | Simulation controls | check | D | `showSimControls` → `showSimControls` | Show the simulation controls | **toggle** | | +| 4 | — → **Performance** | Shadow quality | select Off/Low/Medium/High | D | `shadowQuality` → `shadowQuality` | | **seg** Off · Low · Medium · High | | +| 5 | — → Performance | Reduce quality when the scene is heavy | check | D | `autoQuality` → `autoQuality` | | **toggle** | | +| 6 | — → Performance | Simplify distant models | check | D | `lodEnabled` → `lodEnabled` | | **toggle** | | +| 7 | — → Performance | Draw repeated kit pieces together | check | D | `kitInstancingEnabled` → `kitInstancing` | | **toggle** | | +| 8 | — → Performance | Water quality | select Auto/High/Medium/Low (headset) | D | `waterQuality` → `water:quality` | | **seg** Auto · High · Medium · Low | | +| 9 | — → **Collaboration** | Sync animations | check | D | `syncedAnimations` → `syncedAnimations` | | **toggle** | | +| 10 | — → Collaboration | Spatial voice | check | D | `spatialVoice` → `spatialVoice` | | **toggle** | | +| 11 | — → Collaboration | Ping color + sound | color + select (5 sounds) + "▶ Preview" | S (peers see/hear your ping) | `pingColor` → `pingColor`, `pingSound` → `pingSound` | **two rows**: "Ping colour" (color) · "Ping sound" (select + ▶ preview icon) | | one 3-control row → two rows; nothing lost | +| 12 | — → **Saving & checkpoints** | Autosave | check | D | `autosaveEnabled` → `autosave` | | **toggle** | | +| 13 | — → Saving & checkpoints | Auto-restore on load | check | D | `autoRestoreEnabled` → `autoRestore` | | **toggle** | | +| 14 | Checkpoints → Saving & checkpoints | Keep automatic checkpoints | toggle | D | `autoCheckpoints` → `checkpoints:auto` | | toggle | | +| 15 | Checkpoints → Saving & checkpoints | Automatic checkpoint every | select 5/10/30/60 min | D | `autoCheckpointMinutes` → `checkpoints:every` | | **seg** 5 · 10 · 30 · 60 min | | +| 16 | Checkpoints → Saving & checkpoints | Checkpoint storage | select 100 MB/250 MB/500 MB/1 GB + "Open the timeline" | D | `checkpointCapMb` → `checkpoints:capMb` | | **seg** 100 MB · 250 MB · 500 MB · 1 GB; link "Open the timeline" in the description | | +| 17 | — → Saving & checkpoints | When opening another scene | select Ask/Keep modules/Unload modules | D | `modulesOnOpen` → `scenes:modulesOnOpen` | | **seg** Ask · Keep · Unload | | +| 18 | Selection → **Editing** | Double-click action | select (Open properties / Edit mesh / Focus and isolate / Select same type) | D | `doubleClickAction` → `doubleClickAction` | | **seg** Properties · Edit mesh · Isolate · Same type (wide) | see Decision D | +| 19 | Units | Length | select m/cm/mm/in/ft | D | `lengthUnit` → `lengthUnit` | | select (5 options) | | +| 20 | Units | Angle | select degrees/radians | D | `angleUnit` → `angleUnit` | | **seg** Degrees · Radians | | +| 21 | Duplicate → **Duplicates** | Carry animation clips | toggle | D | `duplicateCarriesAnimation` → `duplicateCarriesAnimation` | | toggle | | +| 22 | Duplicates | Carry object flow | toggle | D | `duplicateCarriesFlow` → `duplicateCarriesFlow` | | toggle | | +| 23 | Duplicates | Carry shader graph | toggle | D | `duplicateCarriesShader` → `duplicateCarriesShader` | | toggle | | +| 24 | Duplicates | Share materials | toggle | D (the effect is S) | `shareDuplicatedMaterials` → `shareDuplicatedMaterials` | | toggle | badge *Shared* (the edit reaches everyone) | +| 25 | Wireframe & outline → **Colours** | Wireframe color | color | D | `viewPrefs.wireColor` (json) | Wireframe colour | color | | +| 26 | Colours | Selection outline color | color | D | `viewPrefs.outlineColor` (json) | Selection outline colour | color | | +| 27 | Colours | Edit Mesh wireframe | check "Auto" + color | D | `viewPrefs.editWireColor` (json: `'auto'` or hex) | Edit mesh wireframe colour | **seg** Auto · Custom (+ swatch when Custom) | writes the same `'auto'`/`#2f81f7` values | +| 28 | Colours | Reset line colors | btn | — | `resetViewPrefs()` | Reset line colours | btn "Reset" | | +| 29 | Loading → **Loading placeholders** | Loading placeholders | select Modern/Colored boxes | D | `placeholderStyle` → `placeholderStyle` (JSON string) + `placeholderStyleChosen` | Placeholder style | **seg** Modern · Boxes | | +| 30 | Loading placeholders | Placeholder grid texture | toggle | D | `placeholderGrid.on` (json) | | toggle | disabled unless Modern | +| 31 | Loading placeholders | Grid size (m) | num 0.05–10 | D | `placeholderGrid.size` (json) | Grid size | num (+ "m") | | +| 32 | Loading placeholders | Grid color | color | D | `placeholderGrid.color` (json) | Grid colour | color | | +| 33 | Loading placeholders | Grid opacity | range 0–1 | D | `placeholderGrid.opacity` (json) | | range + readout % | | +| 34 | Loading placeholders | Placeholder animation speed | range 0–4 | D | `placeholderGrid.speed` (json) | Animation speed | range + readout "1.00×" | | +| 35 | Loading placeholders | Stuck after (seconds) | num 1–120 | D | `placeholderStuckSeconds` → `placeholderStuckSeconds` | Stuck after | num (+ "s") | | + +**Not in Settings, out of scope here.** Configure Scene's *Hold camera until loaded*, *Start simulation on load*, +*Selection passes through* and *Fluid budget* are **scene data** (the `scenePhysics` singleton, replicated and saved). +They live in the Inspector (`menu/scene/*.svelte`), not in this window. Roadmap 38's R5 (Inspector) covers them. + +### 3.7 Explorer — "Files, sharing and the recycle bin." + +| # | Section now → new | Current label | Now | Scope | Store → key | New label | New | Notes | +|---|---|---|---|---|---|---|---|---| +| 1 | — → **Sharing** | When you add files during a session | select Ask each time/Share automatically/Keep them local | D | `shareNewFiles` → `shared:shareNewFiles` | | **seg** Ask · Share · Keep local (wide) | | +| 2 | Sharing | Download shared files automatically | check | D | `autoDownload` → `shared:autoDownload` | | **toggle** | | +| 3 | Sharing | Offer to merge unsaved work on connect | check | D | `mergeOnConnect` → `connect:mergeOnConnect` | | **toggle** | | +| 4 | Sharing | Who can unshare a file | native select Anyone/Only whoever shared it | D | `unshareAuthority` → `shared:unshareAuthority` | | **seg** Anyone · Who shared it | | +| 5 | — → **Files** | Keep versions per scene | num 0–200 | D | `keepVersionsSetting` → `project:keepVersions` | | num | | +| 6 | Files | When importing files already in your library | select Ask/Skip them/Import as copies | D | `duplicateImportMode` → `importDuplicateMode` | | **seg** Ask · Skip · Copy | | +| 7 | Files | Save name | text | D | `saveNameTemplate` → `saveNameTemplate` | | text (wide) | | +| 8 | Deleted files | Keep a recycle bin | check | D | `recycleBinEnabled` → `shared:recycleBin` | | **toggle** | | +| 9 | Deleted files | Delete without asking | check | D | `deleteWithoutConfirm` → `shared:deleteNoConfirm` | | **toggle** | | +| 10 | Deleted files | Keep deleted files after a reload | check | D | `keepRecycleBin` → `shared:keepRecycleBin` | | **toggle** | | +| 11 | Deleted files | Deleted files log | check | D | `deletedLogEnabled` → `shared:deletedLog` | | **toggle** | | +| 12 | Disk | Storage used | btn "Show breakdown" | — | `openStorageModal()` | | btn "Show breakdown" | closes Settings first (no modal on a modal) | + +### 3.8 Node types — "Hide the node types you never use from the palette, add menus and search on this device." + +| # | Current | Now | Scope | Store → key | New | Notes | +|---|---|---|---|---|---|---| +| 1 | intro text | info | — | — | page description | | +| 2 | Filter node types… + "Turn all on (N off)" | text field + btn | — / D | `disabledNodeTypes.set([])` | search field (results show matching types inline as rows, with their group as the path); "Turn all on" becomes the footer's "Reset Node types to defaults" | same function | +| 3 | one block per node group (core catalog + module groups): group checkbox + one checkbox per type, "· N in use" | 3-column grid of ~200 checkboxes | D | `disabledNodeTypes` → `disabledNodeTypes` (json array) | one **nav** per group (value "12 of 14 on") | **SUBMENU** below | + +**Submenu — Node types › ‹Group›**: first row "All ‹Group› nodes" (toggle = `setNodeTypesEnabled(types, on)`), +then one toggle row per type (description "3 in use" when used). Each writes `disabledNodeTypes`. + +### 3.9 Export — "What the next exported game starts with." *(Shared component: the Publish / Export dialog's Settings tab renders the same rows.)* + +| # | Current label | Now | Scope | Store → key | New label | New | Notes | +|---|---|---|---|---|---|---|---| +| 1 | Show Made with ThePrototype badge | check, checked + disabled | — | none (always on) | Show the Made with ThePrototype badge | toggle on + disabled, badge *Always on* | | +| 2 | Start fullscreen | check | D | `exportPrefs.startFullscreen` → `export:prefs` (json) | | **toggle** | | +| 3 | Show FPS | check | D | `exportPrefs.showFps` (json) | | **toggle** | | +| 4 | Quality | select Auto/High/Medium/Low | D | `exportPrefs.quality` (json) | | **seg** Auto · High · Medium · Low | | +| 5 | Include VR button | check | D | `exportPrefs.vrButton` (json) | | **toggle** | | +| 6 | Use CDN for packs | check | D | `exportPrefs.useCdnForPacks` (json) | | **toggle** | | +| 7 | Compress textures | check, disabled | — | none | | toggle, disabled, badge *Not yet* | | + +### 3.10 VR — "Comfort, body, buttons and display in the headset (the same table as the headset's own Settings)." + +Rows 2–25 come from the one schema in `src/lib/vr/settingsSchema.js` (desktop rows only). The headset's radial and +panel read the same table. The "· now X" suffix is removed from every row (the control already shows the value). +Checkboxes become toggles. + +| # | Section | Current label | Now | Scope | Store → key | New label | New | Notes | +|---|---|---|---|---|---|---|---|---| +| 1 | — → **General** | VR override | check | D | `vrOverride` → `vrOverride` (`'true'` when on, removed when off) | Play on the screen, even in a headset | **toggle** | keep the write exactly | +| 2 | Comfort | Turning | select Snap/Smooth/Off | D | `vrSmoothTurn` → `vrSmoothTurn`, `vrSnapAngle` → `vrSnapAngle` (0 = off) | | **seg** Snap · Smooth · Off | | +| 3 | Comfort | Snap angle | select 15°/30°/45°/90° | D | `vrSnapAngleLast` → `vrSnapAngleLast` (+ `vrSnapAngle`) | | **seg** | | +| 4 | Comfort | Smooth speed | select 45/90/135/180 °/s | D | `vrSmoothTurnSpeed` → `vrSmoothTurnSpeed` | | **seg** | | +| 5 | Comfort | Mirror turn | check | D | `vrMirrorSnapTurn` → `vrMirrorSnapTurn` | | **toggle** | new description: "A left flick turns you right, and the other way round." (the spec asks for one) | +| 6 | Comfort | Comfort vignette | check | D | `vrComfortVignette` → `vrComfortVignette` | | **toggle** | | +| 7 | Comfort | Teleport | check | D | `vrTeleportEnabled` → `vrTeleportEnabled` | | **toggle** | | +| 8 | Comfort | Flying | check | D | `vrFlying` → `vrFlying` | | **toggle** | | +| 9 | Body | Stance | select Standing/Seated | D | `vrStance` → `vrStance` | | **seg** | | +| 10 | Body | Height | select of 0.05 steps (−50…+50 cm) | D | `vrHeightOffset` → `vrHeightOffset` | | **range** + readout "+10 cm" | same steps | +| 11 | Controls | Menu hand | select Right/Left | D | `vrMenuHand` → `vrMenuHand` | | **seg** | | +| 12 | Controls | Hold to open menu | check | D | `vrMenuHold` → `vrMenuHold` | | **toggle** | | +| 13 | Controls | Left-handed | check | D | derived: `mirrorBindings()` on `vrBindings` → `vrBindings` | | **toggle** | | +| 14 | Controls | Grab style | select Rigid/Move only/Rotate only | D | `vrGrabStyle` → `vrGrabStyle` | | **seg** | | +| 15 | Controls | (inline 4-column remap table: Action · Hand · Button · What it does; conflict Swap/Cancel; "Reset buttons") | table | D | `vrBindings` → `vrBindings` (json) | **nav "Remap buttons"** | | **SUBMENU** below | +| 16 | Display | Refresh rate | select Max/90 Hz/120 Hz | D | `vrTargetHz` → `vrTargetHz` | | **seg** | | +| 17 | Display | Statistics card | check | D | `vrStatsOpen` → `vrStats` | | **toggle** | | +| 18 | Display | Peer hands | select Model/Hands/Spheres | D | `peerHandStyle` → `peerHandStyle` | | **seg** | | +| 19 | Display | Passthrough | red toggle (+ "not supported on this device") | D | `vrPassthrough` → `vrPassthrough` | | toggle (accent colour; the "not supported" note stays) | | +| 20 | Display | Selection wireframe | check | D | `vrWireframeSelection` → `vrWireframe` | | **toggle** | | +| 21 | Display | Reset panel positions | btn | — | `resetWindowPoses()` | | btn "Reset" | | +| 22 | Display | Game HUD | select Follow head/Fixed in world/Wrist only | D | `vrHudPlacement` → `vr:hudPlacement` | | **seg** Head · World · Wrist | | +| 23 | Display | Game HUD size | select Small/Medium/Large | D | `vrHudSize` → `vr:hudSize` | | **seg** | | +| 24 | Display | Button hints | check | D | `vrHudHints` → `vr:hudHints` | | **toggle** | | +| 25 | Editing | Hold to move vertex | check | D | `vrVertexHold` → `vrVertexHold` | | **toggle** | | +| 26 | Editing | Sleeve palette | check | D | `vrSleeveEnabled` → `vrSleeveEnabled` | | **toggle** | badge *Experimental* | +| 27 | Editing | Face edit limit | num (free) | D | `vrFaceCap` → `vrFaceCap` | | num | | +| 28 | Editing | Vertex edit limit | num (free) | D | `vrVertexCap` → `vrVertexCap` | | num | | +| 29 | — → **Avatar** | My hand model | native select (Default + library objects) | S (your peers see it) | `myHandModel` → `myHandModel` | | select | badge *Shared* | +| 30 | — → **Colocation** | Colocation (status + Colocate here / Stop / Forget ‹room›) | 3 btns + status | — / D | `colocateHereFromView()`, `stopColocation()`, `forgetRoom()` (`colocation-anchors-v1`; Forget also clears that room in `colocation-nudge-v1`) | | status as the value; btn group (wide) | | +| 31 | Colocation | Fine-tune *(only while colocated)* | 4 DragRows X/Y/Z/Yaw + Reset | D | `roomNudge` → `colocation-nudge-v1` (per room) | | **nav "Fine-tune"** | **SUBMENU** below | +| 32 | Colocation | Ghost hands | toggle | D | `colocatedGhostHands` → `colocatedGhostHands` | | toggle | | +| 33 | — → **Advanced** | Colocation probe (dev) | btns "Probe AR capabilities" / "Clear stored anchor" | — / D | `runArProbe()`, `clearProbeState()` (`arprobe-findings-v1`, `arprobe-anchor-v1`) | Colocation probe | btn pair | the dev-only row moves to Advanced, as the spec requires | +| 34 | Advanced | Probe report *(after a run)* | mono list | — | `probeFindings` | | mono block under the probe row | | + +Headset-only rows (no desktop row today, unchanged): Reset height, Remap buttons (the headset page), Reset buttons, +FPS and draw calls, Microphone. + +**Submenu — VR › Remap buttons**: one row per VR action: Move, Turn, Teleport (sticks), Radial menu, Edit / Interact, +Game menu, Talk / jump, Ping (buttons), Drag the world (grip), Grab, Select / use (locked). Each row has a label and the +action's one-line description on the left. On the right: **seg** Left · Right for the hand, and a **select** for the +button (buttons only). Locked rows show "Both · ‹control›" muted. A conflict shows an inline line under the row, +"⚠ ‹button› is already ‹action›. Swap them · Cancel". The last row is "Reset buttons" (`resetBindings()`). Left-handed +also sits here. Writes `vrBindings` (unchanged). + +**Submenu — VR › Fine-tune**: X, Y, Z (DragRow, metres, ±`NUDGE_MAX_M`), Yaw (DragRow, ±15°), Reset. Writes +`setRoomNudge` / `resetRoomNudge` (unchanged), plus the description. + +### 3.11 AI — "The scene assistant, voice typing and mesh generation." + +| # | Section | Current label | Now | Scope | Store → key | New label | New | Notes | +|---|---|---|---|---|---|---|---|---| +| 1 | **Assistant** | Enable assistant | toggle | D | `aiEnabled` → `aiEnabled` | | toggle | | +| 2 | Assistant | Providers (list: radio = active, label, model, Edit, ✕; "+ Add provider") | list | D | `aiProviders` → `aiProviders` (json), `aiActiveProvider` → `aiActiveProvider` | — | one **nav** per provider (label · model, badge *Active* on the active one) + **nav "Add provider"** | **SUBMENU** below | +| 3 | Assistant | New / Edit provider (inline form) | form | D | writes `aiProviders` | — | the provider sub-page | | +| 4 | **Voice typing** | Voice typing provider | native select OpenAI/Groq/Self-hosted + status | D | `sttConfig.preset` → `aiStt` (json) | | **seg** OpenAI · Groq · Self-hosted, status badge *Ready* / *Needs a key* | | +| 5 | Voice typing | Voice typing server (Base URL, API key, Model, Language, Test connection) | form | D | `sttConfig.*` (json) | | **nav "Server"** (value = host) | **SUBMENU** below | +| 6 | **Mesh generation** | Mesh generation | toggle | D | `meshGenEnabled` → `meshGenEnabled` | | toggle | | +| 7 | Mesh generation | Mesh providers (list + "+ Add mesh provider") | list | D | `meshProviders` → `meshProviders` (json), `meshActiveProvider` → `meshActiveProvider` | — | **nav** per provider + **nav "Add mesh provider"** | **SUBMENU** below | +| 8 | Mesh generation | New / Edit mesh provider (inline form) | form | D | writes `meshProviders` | — | the mesh provider sub-page | | +| 9 | (end) | Storage ("API keys are stored unencrypted …") | info row | — | — | — | muted footnote at the end of the page | no longer a row | + +**Submenu — AI › ‹Provider›** (and "AI › Add provider"): Preset (select), Label (text), Base URL (text), API key +(password), Model (text + suggestions), Stream responses (toggle), Physics tools (toggle + "Local & small models guide" +link), Temperature (text). The actions row has Save (primary), Test connection, Cancel, plus **Use this provider** (= +the old radio) and **Remove** (= the old ✕) on an existing one. The test result line goes under the actions. Writes +`addAiProvider` / `updateAiProvider` / `removeAiProvider` / `setAiActiveProvider` (unchanged). + +**Submenu — AI › Voice typing server**: Base URL, API key, Model, Language hint (text rows) + Test connection. Writes +`setSttConfig` (unchanged). + +**Submenu — AI › ‹Mesh provider›**: Kind (select ComfyUI/Meshy), Label, URL, Key/token. For ComfyUI: Workflow JSON +(textarea) and Output node. Otherwise: Mode (seg Preview · Refine) and Asset proxy. Then Save / Cancel, plus Use this +provider / Remove. Writes `addMeshProvider` / `updateMeshProvider` / … (unchanged). + +### 3.12 Connection — "How you find other people." + +| # | Section | Current label | Now | Scope | Store → key | New label | New | Notes | +|---|---|---|---|---|---|---|---|---| +| 1 | **Session** | Session size | num 2–HARD_PEER_CAP | D | `softPeerCap` → `connect:softPeerCap` | | num | | +| 2 | **Signaling server** | Signaling server | select Default/Public PeerJS cloud/Custom server/Local dev | D | `peerServerConfig.mode` → `peerServerConfig` (json) | | **seg** Default · Public · Custom · Local dev (wide) | | +| 3 | Signaling server | Server host *(Custom only)* | text | D | `peerServerConfig.custom.host` (json) | | → submenu | **SUBMENU "Custom server"**, a nav shown when mode = Custom (value = the host) | +| 4 | Signaling server | Port + path *(Custom)* | 2 texts in one row | D | `.custom.port`, `.custom.path` | → "Port" and "Path" rows | | split into one control per row | +| 5 | Signaling server | Secure (wss) *(Custom)* | check | D | `.custom.secure` | | → toggle | | +| 6 | Signaling server | TURN URLs *(Custom)* | text | D | `.custom.turnUrls` | | → text | | +| 7 | Signaling server | TURN credentials *(Custom)* | 2 texts | D | `.custom.turnUsername`, `.custom.turnCredential` | → "TURN username", "TURN credential" | | split | +| 8 | Signaling server | STUN URLs *(Custom)* | text | D | `.custom.stunUrls` | | → text | | +| 9 | Signaling server | Apply changes | btn "Apply" + "Reload" link | — | `applyPeerServer()` / `location.reload()` | | btn pair "Reload" · "Apply" | | + +### 3.13 About & what's new + +| # | Section | Current label | Now | Scope | New | Notes | +|---|---|---|---|---|---|---| +| 1 | **About** | Version | info | — | value in mono | | +| 2 | About | Cloud plugin *(when loaded)* | info | — | value in mono | | +| 3 | About | Diagnostics | btn "Copy diagnostics" | — | btn "Copy" | | +| 4 | **What's new** | (footer btn today) | btn | — | **nav "What's new"** with the unread dot → sub-page with the changelog | | +| 5 | **Links** | Dev Builds / Source Code / Modules / Docs | links | — | one nav per link (external icon), "Dev builds", "Source code", "Modules", "Docs" | sentence case | +| 6 | **Danger zone** | Clear saved session (footer today) | btn | — | btn (warning text), confirm | from the footer | +| 7 | Danger zone | Reset settings (footer today) | btn | — | "Reset all settings", btn (warning), confirm | from the footer, same `safeStorage.clear()` | + +About has no "Reset … to defaults" footer link (it holds no settings). + +## 4. Sub-pages (all of them) + +| Path | Opened from | Holds | +|---|---|---| +| Touch controls › ‹Action› (9 built-in + a game's own) | the Button looks tile | images, tint, size, default look | +| Node types › ‹Group› (one per palette group) | a nav per group | the group toggle + one toggle per type | +| VR › Remap buttons | nav in VR › Controls | the remap table as rows | +| VR › Fine-tune | nav in VR › Colocation (while colocated) | X / Y / Z / Yaw + Reset | +| AI › ‹Provider› / Add provider | nav per provider | the provider form | +| AI › Voice typing server | nav in Voice typing | the STT server form | +| AI › ‹Mesh provider› / Add mesh provider | nav per mesh provider | the mesh provider form | +| Connection › Custom server | nav, when the mode is Custom | host, port, path, wss, TURN, STUN | +| About › What's new | nav | the changelog | + +Every sub-page has a breadcrumb + back on desktop and "‹ Parent" on mobile. None of them is a modal on top of Settings. +Three things leave Settings on purpose, as they do today: *Customize character*, *Storage breakdown* and *Edit touch +layout*. Each closes Settings and opens its own window. + +## 5. Decisions taken in this plan (say if you want otherwise; G and H are in §6) + +- **A. Theme stays a dropdown.** The mockup draws System · Light · Dark as a segmented control. The app has five built-in + themes plus custom ones and no "System" value. Under the spec's own rule (5+ options → dropdown) it stays a dropdown, + with the same values. Adding "System" (follow the OS light/dark) would be a new behaviour, so it is not in this lane. +- **B. "Reset settings" keeps working, behind a confirmation.** Today it wipes **all** of the app's local storage with + no question asked. The new per-category reset clears only that category's keys. The old wipe-everything button lives on + as About › Danger zone › "Reset all settings". +- **C. Renamed labels.** Every row with a "New label" keeps its old label as a search keyword, so a search for the old + name still lands on it. + old label as a search keyword. +- **D. 2–4 options always become a segmented control**, with short labels where the old ones were long ("Properties · + Edit mesh · Isolate · Same type"). A dropdown is kept only for 5+ options or open lists: Theme, Length unit, Ping sound, + My hand model, the VR remap button picker, provider presets. +- **E. Scope badges** go where today's copy says "this device only" / "local to you" (the spec: the badge replaces the + prose). A section is badged when all its rows share the scope. *Shared* goes on the rows whose effect reaches other + people (Share materials, Ping colour/sound, My hand model). Nearly every setting is per-device, so badging every row + would be noise. +- **F. Number fields with a fixed range become sliders with a mono readout** (gamepad deadzone and sensitivity, VR + height). Open-ended numbers stay number fields (light helper length, keep versions, session size, edit limits, grid + size, stuck seconds). + +## 6. Storage keys by category (what each category's reset clears) + +The rule: a category reset puts back the default of **every setting row on that page**. It never runs an action row +(Reset tours, Window positions, Clear stored anchor…), and it never touches data (saved providers, colocation anchors, +tour progress, checkpoints). Two JSON keys hold fields from two places, so those are reset **by field**, not by key: +`viewPrefs` (Interface: `dockPushesViewport`; Scene: `wireColor`, `outlineColor`, `editWireColor`) and `export:prefs` +(this page: `startFullscreen`, `showFps`, `quality`, `vrButton`, `useCdnForPacks`; the Publish dialog's own `preset`, +`thumbnail`, `viewportW/H` and `embedUrl` stay). A reset goes through each store's own setter, so stores, keys and any +side effects (theme classes, `vrBindings` ↔ `vrMenuHand`) stay consistent. Nothing is written with a raw `removeItem` +behind a store's back. + +| Category | Keys (localStorage, via safeStorage) | +|---|---| +| Interface | `allowTextSelection` `avatars:peersClassic` `theme` `customThemes`* `game:soundVolume` `game:musicVolume` `showWelcomeOnStart` `showWhatsNewNotice` `toastsInDrawerOnly` `toursAutoStart` `xrOfferSession` `showRoomsButton` `floatingToolbar` `toolbarOnTop` `touchTools` `mobileUndockAllowed` `advancedMode` `showEnvInList` `objectSearchEnabled` `perfStats:show` `perfReports:send` `viewPrefs.dockPushesViewport` `ui:density` (NEW) | +| Controls | `enableShiftAdd` `helpersInPlay` `noteDoubleClickToOpen` `trackpadMode` `trackpadPanEnabled` `trackpadReversePan` `trackpadPinchZoom` `allowBrowserZoom` | +| Input | `gamepadPrefs` `flow:mouseBindings` `flow:opens` | +| Touch controls | `touchControlsPrefs` `touchLookSpeed` `touchControlsTextures` `touchControlsLayouts` | +| Shortcuts | `shortcutOverrides` | +| Scene | `showGrid` `lightHelperLength` `showSimControls` `shadowQuality` `autoQuality` `lodEnabled` `kitInstancing` `water:quality` `syncedAnimations` `spatialVoice` `pingColor` `pingSound` `autosave` `autoRestore` `checkpoints:auto` `checkpoints:every` `checkpoints:capMb` `scenes:modulesOnOpen` `doubleClickAction` `lengthUnit` `angleUnit` `duplicateCarriesAnimation` `duplicateCarriesFlow` `duplicateCarriesShader` `shareDuplicatedMaterials` `viewPrefs.wireColor` `viewPrefs.outlineColor` `viewPrefs.editWireColor` `placeholderStyle` (+`placeholderStyleChosen`) `placeholderGrid` `placeholderStuckSeconds` | +| Explorer | `shared:shareNewFiles` `shared:autoDownload` `connect:mergeOnConnect` `shared:unshareAuthority` `project:keepVersions` `importDuplicateMode` `saveNameTemplate` `shared:recycleBin` `shared:deleteNoConfirm` `shared:keepRecycleBin` `shared:deletedLog` | +| Node types | `disabledNodeTypes` | +| Export | `export:prefs.{startFullscreen, showFps, quality, vrButton, useCdnForPacks}` | +| VR | `vrOverride` `vrSmoothTurn` `vrSnapAngle` `vrSnapAngleLast` `vrSmoothTurnSpeed` `vrMirrorSnapTurn` `vrComfortVignette` `vrTeleportEnabled` `vrFlying` `vrStance` `vrHeightOffset` `vrMenuHand` `vrMenuHold` `vrBindings` `vrGrabStyle` `vrTargetHz` `vrStats` `peerHandStyle` `vrPassthrough` `vrWireframe` `vr:hudPlacement` `vr:hudSize` `vr:hudHints` `vrVertexHold` `vrSleeveEnabled` `vrFaceCap` `vrVertexCap` `myHandModel` `colocatedGhostHands` | +| AI | `aiEnabled` `meshGenEnabled` `aiStt` (preset, base URL, model, language; see Decision G for keys and providers) | +| Connection | `connect:softPeerCap` `peerServerConfig` | +| About | — (no settings; the Danger zone holds the two global actions) | + +\* `customThemes` is the list of themes you loaded. **Decision H**: the Interface reset sets the theme back to Dark +but **keeps** your loaded themes (they are files you added, not preferences). Removing one stays its ✕. + +- **G. The AI reset keeps your providers and API keys.** It switches the assistant and mesh generation off and puts the + voice-typing preset back. It does not delete `aiProviders`, `aiActiveProvider`, `meshProviders` or + `meshActiveProvider`, because re-typing keys after a misclick would be expensive. Remove a provider on its own + sub-page. "Reset all settings" still wipes everything, as today. + +Exact defaults, encodings and write timing for every key (for example, `showGrid` is present only as `'false'`, +`avatars:peersClassic` stores `'1'`/`'0'`, and `scenes:modulesOnOpen` is removed for "Ask") are in +**[`settings-storage-keys.md`](settings-storage-keys.md)**. The storage-key test (`tests/unit/settingsInventory`) +is generated from that table: for every row it writes a non-default value through the new UI's code path and asserts +the same key and the same string as 1.25.0. diff --git a/docs/settings-storage-keys.md b/docs/settings-storage-keys.md new file mode 100644 index 00000000..b8f0e2a5 --- /dev/null +++ b/docs/settings-storage-keys.md @@ -0,0 +1,400 @@ +# Settings storage keys (1.25.0) — companion to settings-inventory.md + +Read from source on feat/37-settings @ f2be8687 (1.25.0). The storage-key test uses this table as its oracle. + +Worktree: `(worktree)`. Paths below are relative to `src/`. + +**How storage works.** Every key goes through `$lib/safeStorage` (`lib/safeStorage.js`), which is a thin +wrapper over `window.localStorage` with **no key prefix**. A key whose write failed lives in memory for the session +only. Unless a row says otherwise, "key" = a plain `localStorage` key and the value is a string. +IndexedDB is used only by "Clear saved session" (idb key `latest`). + +**Scope legend.** DEVICE = this browser only (localStorage), never replicated or saved into a scene. +SHARED = saved into the scene document and/or replicated to peers (message type given). +DEVICE+wire = a local pref whose value also travels to peers inside some message (noted). + +**Write-timing legend** (matters for "key exists?" assertions): +- *load-write*: the store's subscriber runs at module evaluation, so the key is (re)written on boot with the current/default value. +- *change-only*: the first subscriber call is skipped, so the key is absent until the value is changed once. +- *setter-only*: no subscriber; the key is written only by the named setter function (writing the store directly, e.g. `bind:`, does NOT persist). +- *start-write*: the persisting subscriber is installed inside a `start*()` boot function (load-write once that runs). + +Encodings: "bool str" = `'true'`/`'false'`; "num str" = `String(number)`; "JSON" = `JSON.stringify(...)`. + +--- + +## Settings.svelte (`components/menu/Settings.svelte`) — by AccordionItem section + +Section headers (`{#snippet header()}`), in DOM order: **Interface** (851), **Controls** (1002), **Input** (1082), +*Touch controls* (own file, line 1157), **Scene** (1158), **Explorer** (1407), **VR** (1645), **AI** (1848), +**Export** (2037), **Node types** (2041), **Connection** (2045), **Shortcuts** (2179), **About** (2249), + the modal footer. + +### Interface + +Rows 1-3 come from embedded files (see their own tables below): `TextSelectionSettings`, `AvatarSettings`; `ToursSettings` sits after "Toasts in drawer only". + +| Row label | store | storage key | default | scope | defined at | encoding / notes | +|---|---|---|---|---|---|---| +| *(Allow text selection everywhere — see TextSelectionSettings)* | | | | | | | +| *(Avatars: Your character / Show everyone as classic heads — see AvatarSettings)* | | | | | | | +| Theme | `theme` | `theme` | `'dark'` | DEVICE | lib/themes.js:87 | raw theme id (`dark`,`light`,`green`,… or `custom-`); load-write (applyTheme writes on every set incl. boot, themes.js:116) | +| Custom theme — Export template | *action* `exportActiveTheme()` | — (downloads a `.theme.json`) | — | — | lib/themes.js:144 | no storage write | +| Custom theme — Browse… | *action* `importThemeFile(file)` → `customThemes` + `theme` | `customThemes` (+ `theme` set to the new id) | `[]` | DEVICE | lib/themes.js:84 (store), :180 (fn) | JSON array `[{id:'custom-', name, tokens:{--token: value}}]`; load-write | +| Custom theme — ✕ (per theme) | *action* `removeCustomTheme(id)` | `customThemes`; `theme` → `'dark'` if it was active | — | DEVICE | lib/themes.js:190 | | +| Game sounds | `gameSoundVolume` | `game:soundVolume` | `0.8` | DEVICE | lib/gameSfx.js:76 | num str 0..1 (step 0.05); load-write | +| Music | `gameMusicVolume` | `game:musicVolume` | `0.6` | DEVICE | lib/gameMusic.js:46 | num str 0..1; load-write | +| Welcome on start | `showWelcomeOnStart` | `showWelcomeOnStart` | `false` | DEVICE | lib/whatsNew.js:45 | bool str; load-write (boolPref, whatsNew.js:25) | +| Welcome on start — "open it now" link | *action* `openWelcome()` (+ `settingsOpen.set(false)`) | — | — | — | lib/whatsNew.js:76 | | +| Announce new versions | `showWhatsNewNotice` | `showWhatsNewNotice` | `true` | DEVICE | lib/whatsNew.js:47 | bool str; load-write | +| Toasts in drawer only | `toastsInDrawerOnly` | `toastsInDrawerOnly` | `false` | DEVICE | stores/appStore.js:630 | bool str; load-write | +| *(Tours rows — see ToursSettings)* | | | | | | | +| Show Rooms button *(only when `$drawerSlot` — cloud plugin)* | `showRoomsButton` | `showRoomsButton` | `true` | DEVICE | stores/appStore.js:660 | bool str, read `!== 'false'`; load-write | +| Floating toolbar | `floatingToolbar` | `floatingToolbar` | `true` | DEVICE | stores/appStore.js:584 | bool str, read `!== 'false'`; load-write | +| Toolbar always on top | `toolbarAlwaysOnTop` | **`toolbarOnTop`** (NOT `toolbarAlwaysOnTop`) | `false` | DEVICE | stores/appStore.js:613 | bool str, read `=== 'true'`; load-write | +| Window positions — Reset | *action* `resetWindowLayout()` + toast | removes every `win:*` key + `objectListRect`, `explorerWinW`, `explorerWinH`, `explorerHeight`, `explorerTreeW`, `uvWinW`, `uvWinH`, `controlsLayout`; then runs registered resetters | — | DEVICE | lib/dragWindow.js:45 | | +| Touch tools | `touchTools` | `touchTools` | absent ⇒ `true` if `(pointer: coarse)` or `innerWidth <= 820`, else `false` | DEVICE | stores/appStore.js:448 | bool str; load-write (so the computed default is frozen on first boot) | +| Allow undocking (touch) | `mobileUndockAllowed` | `mobileUndockAllowed` | `false` | DEVICE | stores/appStore.js:563 | bool str; load-write; also toggles `.allow-undock` | +| Advanced mode | `advancedMode` | `advancedMode` | `false` | DEVICE | stores/appStore.js:366 | bool str; load-write | +| Environment in list | `showEnvInList` | `showEnvInList` | `false` | DEVICE | stores/appStore.js:374 | bool str; load-write | +| Object search in menu | `objectSearchEnabled` | `objectSearchEnabled` | `false` | DEVICE | stores/appStore.js:357 | bool str; load-write | +| Show FPS + draw calls (`#show-perf-stats`) | `perfStatsShown` | `perfStats:show` | `false` | DEVICE | lib/fpsMeter.js:26 | bool str; load-write | +| Send performance reports (`#send-perf-reports`) *(only when `$perfReportsAvailable`, i.e. `VITE_PERF_REPORTS_URL` set)* | `perfReportsOn` | `perfReports:send` | `false` | DEVICE | lib/perf/beacon.js:63 (key const :38) | bool str; load-write | +| Dock resizes the viewport (`#dock-pushes-viewport`) | `viewPrefs.dockPushesViewport` via `setViewPrefs` | `viewPrefs` | `true` | DEVICE | lib/viewPrefs.js:49 (defaults :21) | JSON object `{wireColor, outlineColor, editWireColor, dockPushesViewport}`; load-write. NB "Reset line colors" (Scene) resets this field too | + +### Controls + +| Row label | store | storage key | default | scope | defined at | encoding / notes | +|---|---|---|---|---|---|---| +| Shift+A quick add | `enableShiftAdd` | `enableShiftAdd` | `false` | DEVICE | stores/appStore.js:427 | bool str; load-write | +| Show helpers in Play (debug) (`#helpers-in-play`) | `helpersInPlay` | `helpersInPlay` | `false` | DEVICE | lib/helperLayer.js:47 | bool str; load-write | +| Double-click to open notes | `noteDoubleClickToOpen` | `noteDoubleClickToOpen` | `false` | DEVICE | stores/appStore.js:497 | bool str; load-write | +| Trackpad gestures (`#trackpad-mode`) | `trackpadMode` | `trackpadMode` | `'auto'` | DEVICE | lib/trackpadNav.js:24 | `'auto'`\|`'on'`\|`'off'`; load-write | +| Two-finger pan | `panEnabled` | `trackpadPanEnabled` | `true` | DEVICE | lib/trackpadNav.js:54 | bool str, read `!== 'false'`; load-write | +| Reverse trackpad pan | `reversePan` | `trackpadReversePan` | `false` | DEVICE | lib/trackpadNav.js:44 | bool str; load-write | +| Pinch zoom | `pinchZoomEnabled` | `trackpadPinchZoom` | `true` | DEVICE | lib/trackpadNav.js:64 | bool str, read `!== 'false'`; load-write | +| Allow browser pinch zoom | `allowBrowserZoom` | `allowBrowserZoom` | `false` | DEVICE | lib/trackpadNav.js:34 | bool str; load-write | +| Wheel diagnostics (`#wheel-diagnostics`, read-only) | `lastWheelEvents` (+ reads `trackpadMode`) | not persisted | `[]` | DEVICE (runtime) | lib/trackpadNav.js:113 | | + +### Input + +| Row label | store | storage key | default | scope | defined at | encoding / notes | +|---|---|---|---|---|---|---| +| Gamepad (`#gamepad-enabled`) | `gamepadPrefs.enabled` via `setGamepadPrefs` | `gamepadPrefs` | `true` | DEVICE | lib/gamepadPrefs.js:117 (key :58, defaults :60) | JSON `{enabled, invertY, deadzone, lookSensitivity, swapSticks}` (normalized); load-write | +| Swap sticks (`#gamepad-swap`) | `gamepadPrefs.swapSticks` | `gamepadPrefs` | `false` | DEVICE | lib/gamepadPrefs.js:117 | same JSON | +| Invert look Y (`#gamepad-invert-y`) | `gamepadPrefs.invertY` | `gamepadPrefs` | `false` | DEVICE | lib/gamepadPrefs.js:117 | same JSON | +| Stick deadzone (`#gamepad-deadzone`) | `gamepadPrefs.deadzone` | `gamepadPrefs` | `0.15` | DEVICE | lib/gamepadPrefs.js:117 | number, clamped 0.05–0.4 | +| Look sensitivity (`#gamepad-sensitivity`) | `gamepadPrefs.lookSensitivity` | `gamepadPrefs` | `1` | DEVICE | lib/gamepadPrefs.js:117 | number, clamped 0.5–3 | +| Per-game controls | — (info only) | — | — | — | — | | +| Mouse bindings (`#flow-mouse-bindings`) | `flowMouseBindings` | `flow:mouseBindings` | `'classic'` | DEVICE | lib/flowPrefs.js:30 (key :22) | `'classic'`\|`'select'`; load-write | +| *(Node editor opens — see NodeEditorViewSettings)* | | | | | | | + +### Scene + +| Row label | store | storage key | default | scope | defined at | encoding / notes | +|---|---|---|---|---|---|---| +| Show grid | `showGrid` (+ inline `onclick` toggling the key) | `showGrid` | shown (key absent) | DEVICE | stores/sceneStore.js:11 (store, init `null`); boot read components/Scene.svelte:190 | key ONLY present as `'false'` when hidden; the Checkbox `onclick` removes the key if present else sets `'false'` (presence-toggle, independent of the store value) | +| Light helper length (`#light-helper-length`) | `lightHelperLength` | `lightHelperLength` | `2` | DEVICE | lib/lightHelpers.js:21 | num str, min 0.2 (`Number(v) \|\| 2`); load-write | +| Shadow quality (`#shadow-quality`) | `shadowQuality` | `shadowQuality` | `'high'` | DEVICE | lib/lightParams.js:39 (writer :142) | `'off'`\|`'low'`\|`'medium'`\|`'high'`; start-write (`startLightParams`) | +| Reduce quality when the scene is heavy (`#auto-quality`) | `autoQuality` | `autoQuality` | `true` | DEVICE | lib/qualityGovernor.js:75 (writer :269) | bool str, read `!== 'false'`; change-only | +| Simplify distant models (`#lod-enabled`) | `lodEnabled` | `lodEnabled` | `true` | DEVICE | lib/lod.js:55 | bool str, read `!== 'false'`; change-only | +| Draw repeated kit pieces together (`#kit-instancing`) | `kitInstancingEnabled` | **`kitInstancing`** | `true` | DEVICE | lib/kitInstancing.js:45 | bool str, read `!== 'false'`; change-only | +| *(Water quality — see WaterSettings)* | | | | | | | +| Simulation controls | `showSimControls` | `showSimControls` | `false` | DEVICE | stores/appStore.js:384 | bool str; load-write | +| Sync animations | `syncedAnimations` | `syncedAnimations` | `true` | DEVICE | stores/flowStore.js:263 (writer lib/flowRuntime.js:4318) | bool str, read `!== 'false'`; start-write (flowRuntime start) | +| Spatial voice | `spatialVoice` | `spatialVoice` | `true` | DEVICE | lib/voiceChat.js:23 (writer :519) | bool str, read `!== 'false'`; load-write | +| Ping color + sound — color (`#ping-color`) | `pingColor` | `pingColor` | `''` (= automatic peer color; the input shows `#4f83cc` when empty) | DEVICE+wire (carried as `color` in every outgoing `ping` message, lib/ping.js:80) | lib/ping.js:19 | hex string or `''`; load-write; written on `change` | +| Ping color + sound — sound | `pingSound` | `pingSound` | `'ding'` | DEVICE+wire (`sound` field of `ping` message) | lib/ping.js:22 | sound id from `PING_SOUNDS` (lib/pingAudio.js:10); load-write | +| Ping color + sound — ▶ Preview (`#ping-preview`) | *action* `playPing($pingSound)` | — | — | — | lib/pingAudio.js | | +| Autosave | `autosaveEnabled` | **`autosave`** | `true` | DEVICE | lib/autosave.js:133 (writer :869) | bool str, read `!== 'false'`; start-write (`startAutosave`) | +| Auto-restore on load (`#auto-restore`) | `autoRestoreEnabled` | **`autoRestore`** | `false` | DEVICE | lib/autosave.js:141 (writer :870) | bool str; start-write | +| *(Checkpoints rows — see CheckpointSettings)* | | | | | | | +| When opening another scene (`#modules-on-open`) | `modulesOnOpen` | `scenes:modulesOnOpen` | `'ask'` | DEVICE | lib/sceneSwitch.js:51 (key const :43) | `'keep'`\|`'unload'` stored; **`'ask'` REMOVES the key** | +| Double-click action (`#double-click-action`) | `doubleClickAction` | `doubleClickAction` | `'properties'` | DEVICE | lib/selectionPrefs.js:35 | `'properties'`\|`'meshedit'`\|`'isolate'`\|`'sametype'`; load-write | +| Length (`#length-unit`) | `lengthUnit` | `lengthUnit` | `'m'` | DEVICE | lib/units.js:79 | `m`\|`cm`\|`mm`\|`in`\|`ft`; load-write | +| Angle (`#angle-unit`) | `angleUnit` | `angleUnit` | `'deg'` | DEVICE | lib/units.js:80 | `deg`\|`rad`; load-write | +| Carry animation clips | `duplicateCarriesAnimation` | `duplicateCarriesAnimation` | `true` | DEVICE | stores/appStore.js:475 | bool str, read `!== 'false'`; load-write | +| Carry object flow | `duplicateCarriesFlow` | `duplicateCarriesFlow` | `true` | DEVICE | stores/appStore.js:483 | same | +| Carry shader graph | `duplicateCarriesShader` | `duplicateCarriesShader` | `true` | DEVICE | stores/appStore.js:489 | same | +| Share materials | `shareDuplicatedMaterials` | `shareDuplicatedMaterials` | `false` | DEVICE (the shared material id it produces is scene data) | lib/materialSharing.js:57 | bool str; load-write | +| Wireframe color (`#wire-color`) | `viewPrefs.wireColor` via `setViewPrefs` | `viewPrefs` | `'#9aa4b0'` | DEVICE | lib/viewPrefs.js:49 | JSON object (see Interface) | +| Selection outline color (`#outline-color`) | `viewPrefs.outlineColor` | `viewPrefs` | `'#353535'` | DEVICE | lib/viewPrefs.js:49 | | +| Edit Mesh wireframe (`#edit-wire-auto`, `#edit-wire-color`) | `viewPrefs.editWireColor` | `viewPrefs` | `'auto'` | DEVICE | lib/viewPrefs.js:49 | `'auto'` or hex; unticking Auto writes `'#2f81f7'` | +| Reset line colors (`#reset-view-colors`) | *action* `resetViewPrefs()` + toast | `viewPrefs` ← full `DEFAULT_VIEW_PREFS` | — | DEVICE | lib/viewPrefs.js:60 | **also resets `dockPushesViewport` to `true`** (Interface row) | +| *(Loading rows — see LoadingSettings)* | | | | | | | + +### Explorer + +| Row label | store | storage key | default | scope | defined at | encoding / notes | +|---|---|---|---|---|---|---| +| When you add files during a session (`#share-new-files`) | `shareNewFiles` | `shared:shareNewFiles` | `'ask'` | DEVICE | lib/sharedLibrary.js:505 | `'ask'`\|`'always'`\|`'never'`; load-write. Migration: if key absent and legacy `shared:autoShareAll === 'true'` ⇒ `'always'` | +| Download shared files automatically (`#auto-download`) | `autoDownload` | `shared:autoDownload` | `true` | DEVICE | lib/sharedLibrary.js:527 | bool str; load-write | +| Offer to merge unsaved work on connect (`#merge-on-connect`) | `mergeOnConnect` | `connect:mergeOnConnect` | `false` | DEVICE | lib/connectionState.js:241 (writer :255) | bool str; load-write | +| Who can unshare a file (`#unshare-authority`) | `unshareAuthority` | `shared:unshareAuthority` | `'anyone'` | DEVICE | lib/sharedLibrary.js:461 | `'anyone'`\|`'owner'`; load-write | +| Keep versions per scene (`#keep-versions`) | `keepVersionsSetting` | `project:keepVersions` | `10` (`KEEP_VERSIONS`) | DEVICE | lib/projectManifest.js:61 | num str, integer ≥ 0 (0 = auto-versioning off); load-write | +| When importing files already in your library (`#import-duplicate-mode`) | `duplicateImportMode` | `importDuplicateMode` | `'ask'` | DEVICE | lib/importDuplicates.js:47 | `'ask'`\|`'skip'`\|`'copy'`; load-write | +| Save name (`#save-name-template`) | `saveNameTemplate` | `saveNameTemplate` | `'[name]'` | DEVICE | lib/saveName.js:149 (DEFAULT_TEMPLATE :26) | raw string; load-write | +| Keep a recycle bin (`#recycle-bin`) | `recycleBinEnabled` | `shared:recycleBin` | `true` | DEVICE | lib/sharedLibrary.js:574 | bool str; load-write | +| Delete without asking (`#delete-no-confirm`) | `deleteWithoutConfirm` | **`shared:deleteNoConfirm`** | `false` | DEVICE | lib/sharedLibrary.js:556 | bool str; load-write | +| Keep deleted files after a reload (`#keep-recycle-bin`) | `keepRecycleBin` | `shared:keepRecycleBin` | `false` | DEVICE | lib/sharedLibrary.js:577 | bool str; load-write | +| Deleted files log (`#deleted-log`) | `deletedLogEnabled` | `shared:deletedLog` | `true` | DEVICE | lib/sharedLibrary.js:618 | bool str; load-write | +| Storage used — Show breakdown (`#settings-storage`) | *action* `openStorageModal()` | — | — | — | lib/storageUsage.js:743 | | + +### VR + +Order: "VR override", then the whole `VRSettingsSection` (see its table), then the rows below. + +| Row label | store | storage key | default | scope | defined at | encoding / notes | +|---|---|---|---|---|---|---| +| VR override | `vrOverride` (+ inline `onclick` toggling the key) | `vrOverride` | off (key absent) | DEVICE | stores/sceneStore.js:55 (init `false`); boot read components/Scene.svelte:191 | presence-toggle like Show grid: on stores `'true'`, off REMOVES the key. Boot sets the store to the RAW string (`'true'` or `null`), not a boolean. Also written by components/menu/Controls.svelte:932/943/958 | +| *(VR settings table rows — see VRSettingsSection)* | | | | | | | +| My hand model (`#my-hand-model`) | `myHandModel` via `setMyHandModel(hash)` | `myHandModel` | `''` (Default) | DEVICE+wire: `setMyHandModel` sends `{type:'handmodel', peerId, hash}` + pushes the asset bytes; also in the handshake (`handModelState`) | lib/handModels.js:19 (setter :34, writer :98) | Explorer item content hash or `''`; start-write (`startHandModels`) | +| Colocation probe (dev) — Probe AR capabilities (`#ar-probe-run`) | *action* `probeSupport()` + `runArProbe()` | writes `arprobe-findings-v1` (JSON list) and `arprobe-anchor-v1` (JSON) | — | DEVICE | lib/arProbe.js:186, :522 (keys :24-25) | | +| Colocation probe (dev) — Clear stored anchor (`#ar-probe-clear`) | *action* `clearProbeState()` | removes `arprobe-anchor-v1` and `arprobe-findings-v1` | — | DEVICE | lib/arProbe.js:598 | | +| Probe report *(only when findings exist; read-only)* | `probeFindings` | `arprobe-findings-v1` | `[]` | DEVICE | lib/arProbe.js:63 | JSON array of `{ok, step, detail}` | +| Colocation — Colocate here (`#colocate-here`) | *action* `colocateHereFromView()` → `setRoomAlignment` | `roomAlignment` not persisted (a persistent anchor `colocation-anchors-v1` is minted only in-headset) | — | DEVICE | lib/colocationCalibrate.js:298; store lib/colocation.js:209 | | +| Colocation — Stop (`#colocate-stop`) | *action* `stopColocation()` | none (keeps the anchor record) | — | DEVICE | lib/colocationCalibrate.js:317 | | +| Colocation — Forget (`#colocate-forget`) | *action* `forgetRoom(key)`; candidate from `anchorRecords`+`roomAlignment` via `forgetCandidate` | removes `[key]` from `colocation-anchors-v1` AND from `colocation-nudge-v1` | — | DEVICE | lib/colocationAnchors.js:487 (store :103, key :66) | JSON map `{roomKey: {handle, alignment:{px,py,pz,yaw}, at}}` | +| Fine-tune X/Y/Z/Yaw (`#nudge-dx`…`#nudge-dyaw`) *(only while colocated)* | `roomNudge` via `setRoomNudge(patch)` | `colocation-nudge-v1` | `null` (zero) | DEVICE | store lib/colocation.js:504; setter lib/colocationNudge.js:121 (key :44) | JSON map `{roomKey: {dx, dy, dz, dyaw, at}}` (metres / radians; the Yaw field shows degrees); an all-zero nudge deletes the room's entry | +| Fine-tune — Reset (`#nudge-reset`) | *action* `resetRoomNudge()` | deletes current room's entry in `colocation-nudge-v1` | — | DEVICE | lib/colocationNudge.js:153 | | +| Ghost hands (`#colocated-ghost-hands`) | `colocatedGhostHands` | `colocatedGhostHands` | `true` | DEVICE | lib/colocationPresence.js:69 (writer :249) | bool str, read `!== 'false'`; load-write | + +### AI + +| Row label | store | storage key | default | scope | defined at | encoding / notes | +|---|---|---|---|---|---|---| +| Enable assistant | `aiEnabled` (bind) + `setAiEnabled` on change | `aiEnabled` | `false` | DEVICE | lib/ai/providers.js:129 (setter :219, key :92) | bool str; setter-only | +| Providers — list / active radio | `aiProviders`, `aiActiveProvider` via `setAiActiveProvider(id)` | `aiProviders`, `aiActiveProvider` | `[]`, `null` | DEVICE | lib/ai/providers.js:114 / :118 (keys :90-91, setter :210) | `aiProviders`: JSON array; `aiActiveProvider`: provider id string, **key removed when null**; setter-only. The first provider added becomes active automatically | +| Providers — Edit / ✕ / + Add provider | *actions* `aiStartEdit` (form), `removeAiProvider(id)`, `aiStartAdd` | `aiProviders` (+ `aiActiveProvider` if the active one was removed) | — | DEVICE | lib/ai/providers.js:200 | | +| New / Edit provider form — Save | *action* `addAiProvider(config)` / `updateAiProvider(id, config)` | `aiProviders` | — | DEVICE | lib/ai/providers.js:154 / :185 | each record `{id, preset, label, baseUrl, apiKey, model, stream?, physicsTools?, temperature?, models?, managedBy?}` — `stream` stored only when `false`, `physicsTools` only when `true`, `temperature` only when a number, `models` ≤ 500. Plaintext API key | +| New / Edit provider form — Test connection | *action* `testConnection(...)` / `listModels(...)` | none | — | — | lib/ai/client | | +| *(Voice typing rows — see AiSttSettings)* | | | | | | | +| Mesh generation | `meshGenEnabled` (bind) + `setMeshGenEnabled` | `meshGenEnabled` | `false` | DEVICE | lib/ai/meshProviders.js:90 (setter :175, key :55) | bool str; setter-only | +| Mesh providers — list / active radio | `meshProviders`, `meshActiveProvider` via `setMeshActiveProvider` | `meshProviders`, `meshActiveProvider` | `[]`, `null` | DEVICE | lib/ai/meshProviders.js:76 / :79 (keys :53-54) | as AI; active key removed when null; first added becomes active | +| New / Edit mesh provider form — Save | *action* `addMeshProvider` / `updateMeshProvider`; ✕ `removeMeshProvider` | `meshProviders` | — | DEVICE | lib/ai/meshProviders.js:114 / :142 / :158 | record `{id, kind, label, baseUrl, apiKey, …preset.defaults, workflowJson?, outputNodeId? (comfyui), mode?, assetProxy? (meshy), managedBy?}` | +| Storage | — (info only) | — | — | — | — | | + +### Export / Node types + +Each section is a single component — see ExportSettingsSection and NodeTypesSection below. + +### Connection + +| Row label | store | storage key | default | scope | defined at | encoding / notes | +|---|---|---|---|---|---|---| +| Session size (`#soft-peer-cap`) | `softPeerCap` | `connect:softPeerCap` | `8` (`SOFT_PEER_CAP_DEFAULT`) | DEVICE | lib/connectionState.js:119 (consts :85-86) | num str, integer 2–16 (`HARD_PEER_CAP` 16); load-write | +| Signaling server (`#peer-server-mode`) | `peerServerConfig.mode` via `setPeerMode` | `peerServerConfig` | `'default'` | DEVICE | lib/peerServer.js:170 (key :135, defaults :137) | JSON `{mode, custom:{host, port, path, secure, key, stunUrls, turnUrls, turnUsername, turnCredential}}`; mode `'default'`\|`'public'`\|`'custom'`\|`'local'`; load-write | +| Server host *(custom mode only)* | `peerServerConfig.custom.host` via `setPeerCustom` | `peerServerConfig` | `''` | DEVICE | lib/peerServer.js:170 | | +| Port + path *(custom)* | `.custom.port`, `.custom.path` | `peerServerConfig` | `443`, `'/peerjs'` | DEVICE | lib/peerServer.js:170 | default port is a NUMBER; after an edit it is the input's STRING | +| Secure (wss) *(custom)* | `.custom.secure` | `peerServerConfig` | `true` | DEVICE | lib/peerServer.js:170 | boolean | +| TURN URLs *(custom)* | `.custom.turnUrls` | `peerServerConfig` | `''` | DEVICE | lib/peerServer.js:170 | comma-separated string | +| TURN credentials *(custom)* | `.custom.turnUsername`, `.custom.turnCredential` | `peerServerConfig` | `''`, `''` | DEVICE | lib/peerServer.js:170 | | +| STUN URLs *(custom)* | `.custom.stunUrls` | `peerServerConfig` | `''` | DEVICE | lib/peerServer.js:170 | (`custom.key` exists in the record but has no row) | +| Apply changes — Apply (`#peer-server-apply`) | *action* `applyPeerServer()` → `$peers.switchServer(null)` (falls back to `location.reload()`) | none | — | — | components/menu/Settings.svelte:162 | | +| Apply changes — Reload (`#peer-server-reload`) | *action* `location.reload()` | none | — | — | | | + +### Shortcuts + +| Row label | store | storage key | default | scope | defined at | encoding / notes | +|---|---|---|---|---|---|---| +| Each rebindable shortcut (click keys → capture) | `shortcuts` registry (array, not a store) via `rebindShortcut(id, combo)` | `shortcutOverrides` | no overrides (key absent) | DEVICE | registry lib/shortcuts.js:182; key :762; save :791 | JSON `{shortcutId: comboString}`; **key removed when the map is empty**; setting a combo equal to `defaultKeys` deletes that entry | +| Conflict — Swap | *action* `setOverride(other.id, mine.keys)` + `setOverride(id, keys)` | `shortcutOverrides` | — | DEVICE | lib/shortcuts.js:994 | | +| Per-row reset (↺) | *action* `resetShortcut(id)` | `shortcutOverrides` | — | DEVICE | lib/shortcuts.js:1006 | | +| Reset all (`#shortcut-reset-all`) | *action* `resetAllShortcuts()` | removes `shortcutOverrides` | — | DEVICE | lib/shortcuts.js:1012 | | +| Layout note (`#shortcut-layout-note`) | `nonLatinLayoutSeen` (read-only) | not persisted | `false` | runtime | lib/shortcuts.js:1095 | | +| Locked rows | — (display only) | — | — | — | | | + +### About + +| Row label | store | storage key | default | scope | defined at | encoding / notes | +|---|---|---|---|---|---|---| +| Version | `versionString()` (read-only) | — | — | — | lib/version.js | | +| Diagnostics — Copy diagnostics (`#about-copy-diagnostics`) | *action* `copyDiagnostics()` + toast | none (clipboard) | — | — | lib/diagnostics | | +| Cloud plugin *(only with a plugin)* | `cloudPluginInfo` (read-only) | not persisted | `null` | runtime | lib/cloudHooks.js:196 | | +| Dev Builds / Source Code / Modules / Docs | — (links) | — | — | — | | | + +### Modal footer + +| Row label | store | storage key | default | scope | defined at | encoding / notes | +|---|---|---|---|---|---|---| +| Reset settings | *action* `safeStorage.clear()` | **clears ALL of localStorage** (every key in this document, plus non-settings keys) and the in-memory fallback | — | DEVICE | lib/safeStorage.js:125 | | +| Clear saved session | *action* `clearSavedSession()` | IndexedDB key `latest` deleted (+ toast) | — | DEVICE | lib/autosave.js:824 | | +| What's new (`#about-whats-new`) | *action* `openWhatsNew()` (+ `settingsOpen.set(false)`) | writes `lastSeenVersion` = APP_VERSION | — | DEVICE | lib/whatsNew.js:65 (markSeen :56) | | + +### Settings window chrome (not rows, but persisted by the modal) + +| What | store | storage key | default | scope | defined at | notes | +|---|---|---|---|---|---|---| +| Last section shown in the sidebar | settingsNav (`createSettingsNav`) | `settings:section` | — | DEVICE | lib/settingsNav.js:23 (write :74, read :101) | `sectionKeyOf(label)` = lowercase alnum (`interface`, `nodetypes`, `touchcontrols`, …) | +| WindowShell (key `"settings"`) sidebar state | WindowShell `$state` | `ws:settings:primaryOpen`, `ws:settings:secondaryOpen`, `ws:settings:secondaryMode`, `ws:settings:secondaryPinned`, `ws:settings:side`, `ws:settings:primaryWidth`, `ws:settings:secondaryWidth` | primaryWidth 168 | DEVICE | components/shared/WindowShell.svelte:52-68 | uses raw `localStorage`, not safeStorage | +| Search box | `settingsQuery` (component `let`) | not persisted | `''` | — | Settings.svelte:588 | | + +--- + +## settings/TextSelectionSettings.svelte (Interface) + +| Row label | store | storage key | default | scope | defined at | encoding / notes | +|---|---|---|---|---|---|---| +| Allow text selection everywhere (`#allow-text-select`) | `allowTextSelection` | `allowTextSelection` | `false` | DEVICE | lib/textSelection.js:21 (key :18) | bool str; change-only; also toggles `.allow-text-select` | + +## settings/AvatarSettings.svelte (Interface ▸ Avatars) + +| Row label | store | storage key | default | scope | defined at | encoding / notes | +|---|---|---|---|---|---|---| +| Your character — Customize character… (`#settings-customize-character`) | *action* `settingsOpen.set(false); characterModalOpen.set(true)` | none here | — | — | stores/appStore.js | opens CharacterModal | +| Show everyone as classic heads (`#avatars-peers-classic`) | `peersAsClassic` | `avatars:peersClassic` | `false` | DEVICE | lib/avatars/avatarState.js:36 | **`'1'`/`'0'`** (not true/false); load-write | + +## settings/ToursSettings.svelte (Interface ▸ Tours) + +| Row label | store | storage key | default | scope | defined at | encoding / notes | +|---|---|---|---|---|---|---| +| Show tours automatically (`#setting-tours-auto`) | no store; `tours.autoStartEnabled()` / `tours.setAutoStart(on)` (re-read via `tourRecords` tick) | `toursAutoStart` | on (key absent) | DEVICE | lib/tours/engine.js:53, :263-268; lib/tours/index.js:34 | off stores `'false'`; on REMOVES the key | +| VR welcome — Start VR welcome (`#setting-tour-vr`) | *action* `startVRWelcome()` (+ close Settings); status text reads `tour.vr-welcome` | progress key `tour.vr-welcome` | — | DEVICE | lib/tours/builtin.js:329 (ids :24-26); key fn engine.js:55 | progress value `'done'` or JSON `{at: n}` | +| Editor tour — Start editor tour (`#setting-tour-editor`) | *action* `startEditorTour()`; status reads `tour.editor` or `tour.editor-touch` (coarse pointer) | `tour.editor` / `tour.editor-touch` | — | DEVICE | lib/tours/builtin.js:338 | | +| Offer Enter VR (`#setting-xr-offer`) | `xrOfferEnabled` | `xrOfferSession` | `true` | DEVICE | lib/xrOffer.js:31 (keys :24-25) | bool str, read `!== 'false'`; load-write. Switching on after off also REMOVES `xrOfferDeclined` | +| Reset tours — Reset all (`#setting-tours-reset`) | *action* `resetAllTours()` → `tours.close(); tours.reset()` | removes `tour.` for every registered tour AND `toursAutoStart` | — | DEVICE | lib/tours/builtin.js:342; engine.js:257 | | + +## settings/CheckpointSettings.svelte (Scene ▸ Checkpoints) + +| Row label | store | storage key | default | scope | defined at | encoding / notes | +|---|---|---|---|---|---|---| +| Keep automatic checkpoints (`#checkpoints-auto`) | `autoCheckpoints` | `checkpoints:auto` | `true` | DEVICE | lib/checkpoints.js:89 | bool str, read `!== 'false'`; load-write | +| Automatic checkpoint every (`#checkpoints-every`) | `autoCheckpointMinutes` | `checkpoints:every` | `10` | DEVICE | lib/checkpoints.js:91 (choices :59-60) | num str ∈ {5,10,30,60}; load-write | +| Checkpoint storage (`#checkpoints-cap`) | `checkpointCapMb` | `checkpoints:capMb` | `250` | DEVICE | lib/checkpoints.js:93 (choices :57-58) | num str ∈ {100,250,500,1000} (MB); load-write | +| Checkpoint storage — Open the timeline (`#checkpoints-open-timeline`) | *action* `settingsOpen.set(false); checkpointsOpen.set(true)` | none | — | — | stores/appStore.js | | + +## settings/LoadingSettings.svelte (Scene ▸ Loading) + +| Row label | store | storage key | default | scope | defined at | encoding / notes | +|---|---|---|---|---|---|---| +| Loading placeholders (`#placeholder-style`) | `placeholderStyle` | `placeholderStyle` **+** `placeholderStyleChosen` | `'modern'` | DEVICE | lib/loadStates.js:108 (writer :111) | `placeholderStyle` is **JSON-encoded** (`'"modern"'` / `'"boxes"'`); every pick also writes `placeholderStyleChosen = '1'`; change-only | +| Placeholder grid texture (`#placeholder-grid-on`) | `placeholderGrid.on` via `setGrid` → `normalizeGrid` | `placeholderGrid` | `true` | DEVICE | lib/loadStates.js:120 (defaults :83, persisted() :28) | JSON `{on, size, color, opacity, speed}`; change-only | +| Grid size (m) (`#placeholder-grid-size`) | `placeholderGrid.size` | `placeholderGrid` | `0.5` | DEVICE | lib/loadStates.js:120 | clamp 0.05–10 | +| Grid color (`#placeholder-grid-color`) | `placeholderGrid.color` | `placeholderGrid` | `'#bfe6ff'` | DEVICE | lib/loadStates.js:120 | `#rrggbb` | +| Grid opacity (`#placeholder-grid-opacity`) | `placeholderGrid.opacity` | `placeholderGrid` | `0.45` | DEVICE | lib/loadStates.js:120 | 0–1 | +| Placeholder animation speed (`#placeholder-anim-speed`) | `placeholderGrid.speed` | `placeholderGrid` | `1` | DEVICE | lib/loadStates.js:120 | 0–4 | +| Stuck after (seconds) (`#placeholder-stuck-seconds`) | `placeholderStuckSeconds` | `placeholderStuckSeconds` | `10` | DEVICE | lib/loadStates.js:122 | JSON number (e.g. `'10'`), integer 1–120; change-only | + +## settings/NodeEditorViewSettings.svelte (Input ▸ Node editor) + +| Row label | store | storage key | default | scope | defined at | encoding / notes | +|---|---|---|---|---|---|---| +| Node editor opens (`#flow-opens`) | `nodeEditorOpens` | `flow:opens` | `'left'` | DEVICE | lib/flowView.js:35 (key :27) | `'left'`\|`'framed'`; load-write | + +## settings/SettingsNav.svelte, SettingsSections.svelte, SettingsSection.svelte + +Chrome only (sidebar, accordion wrapper). No setting rows. Persistence: `settings:section` via lib/settingsNav.js (see "Settings window chrome"). + +## TouchControlsSettings.svelte (its own section, header "Touch controls") + +| Row label | store | storage key | default | scope | defined at | encoding / notes | +|---|---|---|---|---|---|---| +| Show touch controls (`#touch-visibility-auto/always/never`) | `touchPrefs.visibility` via `setTouchPrefs` | `touchControlsPrefs` | `'auto'` | DEVICE | lib/touchActions.js:251 (key :217, defaults :219) | JSON `{visibility, showInEdit, haptics, scope}`; setter-only. `scope` (`'game'`) has no row here | +| Show in edit (`#touch-show-in-edit`) | `touchPrefs.showInEdit` | `touchControlsPrefs` | `false` | DEVICE | lib/touchActions.js:251 | | +| Haptic tick (`#touch-haptics`) | `touchPrefs.haptics` | `touchControlsPrefs` | `true` | DEVICE | lib/touchActions.js:251 | | +| Look speed (`#touch-look-speed`) | `touchLookSpeed` via `setTouchLookSpeed` | `touchLookSpeed` | `1` | DEVICE | lib/touchControls.js:75 (key :54, setter :78) | num str, clamp 0.25–3; setter-only | +| Layout — Edit layout (`#touch-edit-layout`) | *action* `openTouchLayoutEditor()` (+ close Settings); the editor saves via `saveTouchLayout` | `touchControlsLayouts` | — | DEVICE | lib/touchActions.js:619; store :344 (key :287) | JSON `{global: layout\|null, games: {[gameId]: {v:1, items:{[id]: {x, y, size, opacity, hidden?}}}}}` | +| Layout — Reset (`#touch-reset-layout`) | *action* `resetTouchLayout('game', $gameId)` + `resetTouchLayout('global', $gameId)` + toast | `touchControlsLayouts` (game entry deleted, global → null) | `{global:null, games:{}}` | DEVICE | lib/touchActions.js:375; `gameId` lib/gameSettings.js:183 | | +| Button looks — one row per action (built-ins + the open scene's actions): Upload / Explorer… / ✕ (Released, Pressed), Tint, Size, Default look | `touchTextures` via `setTouchTexture(id, patch)` / `clearTouchTexture(id)` | `touchControlsTextures` | `{}` (built-in look) | DEVICE | lib/touchActions.js:491 (key :455, setter :494, clear :509) | JSON `{[actionId]: {released?: dataURL, pressed?: dataURL, tint?: '#rrggbb', scale?: 0.5–1.6}}`; images are data: URLs ≤ 160 KB; scale 1 is omitted; an empty record is deleted | + +## VRSettingsSection.svelte (inside VR) + +Rows come from `VR_SETTINGS` (lib/vr/settingsSchema.js:70) filtered to `desktop !== false`, grouped by page +(Comfort, Body, Controls, Display, Editing). Hidden on desktop: `heightReset`, `remap`, `bindingsReset`, `fps`, `mic`. +Each row writes through the row's `set()`; `put(store, key, v)` (settingsSchema.js:61) = `store.set(v)` + `setItem(key, String(v))`. + +| Row label | store | storage key | default | scope | defined at | encoding / notes | +|---|---|---|---|---|---|---| +| Turning (`#vr-set-turning`) | `vrSmoothTurn` + `vrSnapAngle` (reads `vrSnapAngleLast`) | `vrSmoothTurn`; `vrSnapAngle` | shown as `'snap'` (smooth false, snap 45) | DEVICE | schema :72; lib/vr/prefs.js:50; stores/sceneStore.js:97 | derived `smooth`/`snap`/`off`. Writes `vrSmoothTurn` bool str; `off` writes `vrSnapAngle='0'`; `snap`/`smooth` restore `vrSnapAngle` from `vrSnapAngleLast` (or 45) if it was 0 | +| Snap angle (`#vr-set-snapAngle`) | `vrSnapAngleLast` (+ `vrSnapAngle`) | `vrSnapAngleLast`; `vrSnapAngle` | `45` | DEVICE | schema :92; lib/vr/prefs.js:60; stores/sceneStore.js:97 | num str ∈ {15,30,45,90}; `vrSnapAngleLast` change-only (persisted()); `vrSnapAngle` written unless snap is 0 while smooth is on. `vrSnapAngle` boot-read is `parseInt(... \|\| '45')` | +| Smooth speed (`#vr-set-smoothSpeed`) | `vrSmoothTurnSpeed` | `vrSmoothTurnSpeed` | `90` | DEVICE | schema :108; lib/vr/prefs.js:52 | num str ∈ {45,90,135,180}; change-only | +| Mirror turn (`#vr-set-mirror`) | `vrMirrorSnapTurn` | `vrMirrorSnapTurn` | `false` | DEVICE | schema :119; stores/sceneStore.js:101 | bool str (put) | +| Comfort vignette (`#vr-set-vignette`) | `vrComfortVignette` | `vrComfortVignette` | `false` | DEVICE | schema :120; lib/vr/prefs.js:54 | bool str; change-only | +| Teleport (`#vr-set-teleport`) | `vrTeleportEnabled` | `vrTeleportEnabled` | `true` | DEVICE | schema :121; stores/sceneStore.js:105 | bool str, read `!== 'false'` (put) | +| Flying (`#vr-set-flying`) | `vrFlying` | `vrFlying` | `false` | DEVICE | schema :122; stores/sceneStore.js:120 | bool str (put) | +| Stance (`#vr-set-stance`) | `vrStance` | `vrStance` | `'standing'` | DEVICE | schema :124; lib/vr/prefs.js:56 | `'standing'`\|`'seated'`; change-only | +| Height (`#vr-set-height`) | `vrHeightOffset` | `vrHeightOffset` | `0` | DEVICE | schema :140; lib/vr/prefs.js:58 | num str, metres −0.5…+0.5 rounded to 0.01; change-only | +| Menu hand (`#vr-set-menuHand`) | `vrMenuHand` (+ `vrBindings` follows) | `vrMenuHand` (+ `vrBindings`) | `'right'` | DEVICE | schema :156; stores/sceneStore.js:92; bindings.js:298 | `'left'`\|`'right'`; the bindings subscriber re-saves `vrBindings` | +| Hold to open menu (`#vr-set-menuHold`) | `vrMenuHold` | `vrMenuHold` | `false` | DEVICE | schema :177; stores/sceneStore.js:130 | bool str (put) | +| Left-handed (`#vr-set-leftHanded`) | derived `isLeftHanded()`; set = `mirrorBindings()` | `vrBindings` (+ `vrMenuHand` via syncMenuHandOut) | `false` (default layout) | DEVICE | schema :178; lib/vr/bindings.js:208, :221 | | +| Grab style (`#vr-set-grabStyle`) | `vrGrabStyle` | `vrGrabStyle` | `'rigid'` | DEVICE | schema :179; stores/sceneStore.js:235 | `'rigid'`\|`'move'`\|`'rotate'` (put) | +| Controls remap table — Hand / Button selects (`#vr-bind--hand`, `#vr-bind--control`), Swap them / Cancel | `vrBindings` via `setBinding(id, patch, {swap?})` | `vrBindings` | `defaultBindings()` from `VR_ACTIONS` (lib/vr/bindings.js:45: move L stick, turn R stick, teleport R stick, menu R secondary, mode L secondary, pause L primary, ptt R primary, ping R stickClick, worldPan R grip; grab/select locked) | DEVICE | lib/vr/bindings.js:132 (save :137, setBinding :167) | JSON `{[actionId]: {hand, control}}` | +| Controls remap table — Reset buttons (`#vr-bind-reset`) | *action* `resetBindings()` + toast | `vrBindings` (+ `vrMenuHand`) | — | DEVICE | lib/vr/bindings.js:201 | | +| Refresh rate (`#vr-set-refresh`) | `vrTargetHz` (+ `applyVRFrameRate()`) | `vrTargetHz` | `'auto'` | DEVICE | schema :198; stores/sceneStore.js:165 | STRING `'auto'`\|`'90'`\|`'120'`; load-write | +| Statistics card (`#vr-set-stats`) | `vrStatsOpen` | **`vrStats`** | `false` | DEVICE | schema :218; stores/sceneStore.js:212 | bool str (put) | +| Peer hands (`#vr-set-peerHands`) | `peerHandStyle` | `peerHandStyle` | `'hands'` | DEVICE | schema :219; stores/sceneStore.js:173 | `'model'`\|`'hands'`\|`'spheres'`; load-write | +| Passthrough (`#passthrough-toggle`, red switch) | `vrPassthrough` | `vrPassthrough` | `false` | DEVICE | schema :235; stores/sceneStore.js:125 | bool str (put) + toast | +| Selection wireframe (`#vr-set-wireframe`) | `vrWireframeSelection` | **`vrWireframe`** | `true` | DEVICE | schema :249; stores/sceneStore.js:208 | bool str, read `!== 'false'` (put) | +| Reset panel positions (`#vr-set-resetPanels`, action button) | *action* `resetWindowPoses()` + toast | removes `vrWindowPoses` | — | DEVICE | schema :250; lib/vrWindowPoses.js:53 | | +| Game HUD (`#vr-set-gameHud`) | `vrHudPlacement` via `setVrHudPlacement` | `vr:hudPlacement` | `'head'` | DEVICE | schema :252; lib/vrHudPrefs.js:37 (key :9) | `'head'`\|`'world'`\|`'wrist'`; setter-only. Migration: absent and legacy `vr:gameStrip === 'false'` ⇒ `'wrist'` | +| Game HUD size (`#vr-set-gameHudSize`) | `vrHudSize` via `setVrHudSize` | `vr:hudSize` | `'medium'` | DEVICE | schema :268; lib/vrHudPrefs.js:39 | `'small'`\|`'medium'`\|`'large'`; setter-only | +| Button hints (`#vr-set-gameHudHints`) | `vrHudHints` via `setVrHudHints` | `vr:hudHints` | `true` | DEVICE | schema :283; lib/vrHudPrefs.js:41 | bool str, read `!== 'false'`; setter-only | +| Hold to move vertex (`#vr-set-vertexHold`) | `vrVertexHold` | `vrVertexHold` | `true` | DEVICE | schema :302; stores/sceneStore.js:116 | bool str, read `!== 'false'` (put) | +| Sleeve palette (`#vr-set-sleeve`) | `vrSleeveEnabled` | `vrSleeveEnabled` | `false` | DEVICE | schema :303; stores/sceneStore.js:111 | bool str (put) | +| Face edit limit (`#vr-set-faceCap`, free number ≥ 10 on desktop) | `vrFaceCap` | `vrFaceCap` | `2500` (`VR_FACE_CAP`) | DEVICE | schema :305; lib/faceEdit.js:110 (const :107) | num str (int); load-write | +| Vertex edit limit (`#vr-set-vertexCap`, free number ≥ 10) | `vrVertexCap` | `vrVertexCap` | `800` (`VR_VERTEX_CAP`) | DEVICE | schema :316; lib/meshEdit.js:1779 (const :1776) | num str (int); load-write | + +## AiSttSettings.svelte (inside AI) + +| Row label | store | storage key | default | scope | defined at | encoding / notes | +|---|---|---|---|---|---|---| +| Voice typing provider (`#ai-stt-preset`) | `sttConfig.preset` via `applySttPreset(preset)` (also resets baseUrl + model to the preset's) | `aiStt` | `'openai'` | DEVICE | lib/ai/stt.js:64 (key :32, defaults :35, setter :67) | JSON `{preset, baseUrl, apiKey, model, language}` (normalized); setter-only | +| Voice typing server — Base URL (`#ai-stt-base`) | `sttConfig.baseUrl` via `setSttConfig` | `aiStt` | `'https://api.openai.com/v1'` | DEVICE | lib/ai/stt.js:64 | trailing `/` stripped | +| — API key (`#ai-stt-key`) | `sttConfig.apiKey` | `aiStt` | `''` | DEVICE | lib/ai/stt.js:64 | plaintext | +| — Model (`#ai-stt-model`) | `sttConfig.model` | `aiStt` | `'whisper-1'` | DEVICE | lib/ai/stt.js:64 | empty ⇒ preset default | +| — Language hint (`#ai-stt-language`) | `sttConfig.language` | `aiStt` | `''` | DEVICE | lib/ai/stt.js:64 | | +| — Test connection (`#ai-stt-test`) | *action* `transcribe(silentWav())` | none | — | — | lib/ai/stt.js:102 | | + +## ExportSettingsSection.svelte (Export) + +All rows share one key, `export:prefs` (lib/export/exportStores.js:39), JSON of the full coerced prefs +`{preset, startFullscreen, showFps, quality, vrButton, useCdnForPacks, thumbnail, viewportW, viewportH, embedUrl}`; +store `exportPrefs` (exportStores.js:93), setter-only via `setExportPrefs` (:96). Defaults at :46. + +| Row label | store | storage key | default | scope | defined at | encoding / notes | +|---|---|---|---|---|---|---| +| Show Made with ThePrototype badge (`#export-badge`) | — (always checked, disabled) | none | on | — | — | | +| Start fullscreen (`#export-start-fullscreen`) | `exportPrefs.startFullscreen` | `export:prefs` | `false` | DEVICE | lib/export/exportStores.js:93 | boolean | +| Show FPS (`#export-show-fps`) | `exportPrefs.showFps` | `export:prefs` | `false` | DEVICE | lib/export/exportStores.js:93 | boolean | +| Quality (`#export-quality`) | `exportPrefs.quality` | `export:prefs` | `'auto'` | DEVICE | lib/export/exportStores.js:93 | `auto`\|`high`\|`medium`\|`low` | +| Include VR button (`#export-vr-button`) | `exportPrefs.vrButton` | `export:prefs` | `true` | DEVICE | lib/export/exportStores.js:93 | boolean | +| Use CDN for packs (`#export-cdn-packs`) | `exportPrefs.useCdnForPacks` | `export:prefs` | `false` | DEVICE | lib/export/exportStores.js:93 | boolean | +| Compress textures (`#export-compress-textures`) | — (disabled, not wired) | none | off | — | — | | + +## NodeTypesSection.svelte (Node types) + +| Row label | store | storage key | default | scope | defined at | encoding / notes | +|---|---|---|---|---|---|---| +| Filter node types… (`#node-types-filter`) | component `let filter` | not persisted | `''` | — | NodeTypesSection.svelte:566 | | +| Turn all on (`#node-types-enable-all`) | `disabledNodeTypes.set([])` | removes `disabledNodeTypes` | — | DEVICE | lib/nodeTypePrefs.js:21 | | +| Group checkbox (`.node-type-group-toggle`) / per-type checkbox (`.node-type-toggle`) | `disabledNodeTypes` via `setNodeTypesEnabled(types, on)` | `disabledNodeTypes` | `[]` (all on) | DEVICE | lib/nodeTypePrefs.js:21 (key :9, setter :29) | JSON array of type ids, sorted; **key removed when empty**; load-write | + +## water/WaterSettings.svelte (Scene) + +| Row label | store | storage key | default | scope | defined at | encoding / notes | +|---|---|---|---|---|---|---| +| Water quality (`#water-quality`) | `waterQuality` | `water:quality` | `'auto'` | DEVICE | lib/water/waterPrefs.js:19 (key :7) | `auto`\|`high`\|`medium`\|`low`; load-write | + +## scene/*.svelte — NOT in the Settings modal + +These four are imported by `components/menu/Inspector.svelte:13-16` (Configure Scene panel), not by Settings.svelte. +All are **SHARED scene data**: fields of the `scenePhysicsState_` singleton (lib/scenePhysics.js:247), written via +`setScenePhysics` (:276), replicated as message **`scenephysics`** (full state, latest-wins on `changedAt`; +handshake reply :318), saved in `.tpscene`/sessions as payload key **`physics`** (lib/sessions.js:317) and in the +autosave snapshot as **`physics`** (lib/autosave.js:363). `scenePhysicsSnapshot()` (:324) returns null when everything +is default, and each field below is OMITTED from the state when at its default. + +| Row label | store | storage key | default | scope | defined at | encoding / notes | +|---|---|---|---|---|---|---| +| Hold camera until loaded (`#hold-camera-until-loaded`) — CameraHoldSetting | `scenePhysicsState_.holdCamera` | scene `physics.holdCamera` / msg `scenephysics` | `false` (omitted) | SHARED | lib/scenePhysics.js:214 | present only as `true` | +| Start simulation on load (`#sim-on-load`) — SimOnLoadSetting | `.simOnLoad` via `setSimOnLoad` | scene `physics.simOnLoad` / msg `scenephysics` | `false` (omitted) | SHARED (+ undo kind `simonload`) | lib/scenePhysics.js:219; lib/sim/simOnLoadHistory.js:16 | present only as `true` | +| Fluid budget (`#scene-fluid-budget`) — FluidBudgetSetting | `.fluidBudget` | scene `physics.fluidBudget` / msg `scenephysics` | `8000` (`DEFAULT_FLUID_BUDGET`, omitted when not set) | SHARED | lib/scenePhysics.js:222; lib/sim/fluidEmitterCore.js:253 | integer 200–20000 | +| Selection passes through — Water / Transparent surfaces / Triggers (`#pick-through-water/transparent/triggers`) — SelectionPassSetting | `.pick` | scene `physics.pick` / msg `scenephysics` | `{water:true, transparent:false, triggers:false}` (omitted when equal) | SHARED | lib/scenePhysics.js:74, :217; lib/selectThrough.js:64 | object `{water, transparent, triggers}` | + +--- + +## Uncertainties / caveats + +- `?` ThemedSelect with numeric items (`checkpoints:every`, `checkpoints:capMb`, VR choices): the stored string is the same either way, but whether the in-memory store holds a number or a string after a pick depends on ThemedSelect (not checked). +- `?` The exact JSON shape of an in-progress tour's progress (`tour.` = `{at: n}`) was read from engine.js:96-100's reader only; the writer (`saveProgress`) was not opened. +- `?` "Colocate here" / "Stop" persistence: `setRoomAlignment` (lib/colocation.js:225) was not read in full; it is documented as local and not replicated, and the anchor record is minted only in a headset session. +- The four `scene/*.svelte` components are not part of the Settings modal; they are included because they were listed. diff --git a/package.json b/package.json index ba50deff..bb56f02d 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,8 @@ "deps:check": "node scripts/deps-check.cjs", "sync-llms": "node scripts/sync-llms.cjs", "test:unit": "vitest run", - "check:storage": "node scripts/check-storage.cjs" + "check:storage": "node scripts/check-storage.cjs", + "check:tokens": "node scripts/check-tokens.cjs" }, "devDependencies": { "@sveltejs/adapter-static": "^3.0.10", diff --git a/perf/budgets.json b/perf/budgets.json index 42a696cf..6fb5e279 100644 --- a/perf/budgets.json +++ b/perf/budgets.json @@ -3,7 +3,7 @@ "comment": "34 B2 — the Quest budget per Games-tab game and General-tab level, held by `node scripts/perf-games.cjs --check` (CI: .github/workflows/perf-budget.yml). Counts only, in the headset-analogue profile. See perf/README.md.", "scenes": { "repo": "theprototype-app/scenes", - "ref": "preview-1-25-r36" + "ref": "preview-1-26-r37" }, "modules": { "repo": "theprototype-app/modules", diff --git a/scripts/check-tokens.cjs b/scripts/check-tokens.cjs new file mode 100644 index 00000000..02e06f11 --- /dev/null +++ b/scripts/check-tokens.cjs @@ -0,0 +1,151 @@ +#!/usr/bin/env node +// 38 R2 — `npm run check:tokens`: colours in components come from the redesign tokens only +// (cloud docs/design/redesign/SPEC.md §1; the tokens live in src/styles/tokens.css and app.css). +// +// What counts as a raw colour in src/components/**: +// - a hex literal #fff #1b212d #1b212dcc (also inside a var() fallback) +// - an rgb()/rgba()/hsl()/hsla() literal +// - a Tailwind PALETTE utility bg-gray-800 text-red-400 hover:border-blue-500/50 +// bg-white text-black … (token utilities — bg-surface-2, text-text-muted — are fine) +// Comments are stripped first, so a note that NAMES a colour is not a violation. +// +// MODES. The redesign migrates the app area by area (R4-R10), so this starts as a WARNING: +// node scripts/check-tokens.cjs report per file, exit 0 +// node scripts/check-tokens.cjs --strict exit 1 on any violation (R11 flips CI to this) +// node scripts/check-tokens.cjs --json machine-readable { total, files: {rel: n} } +// node scripts/check-tokens.cjs only these files (a lane checking its own diff) +// …except CLEAN: files already built from tokens fail even in warning mode. A lane that +// migrates a component adds it here — that is the ratchet, and why it is a list. + +const fs = require('fs'); +const path = require('path'); + +const ROOT = path.resolve(__dirname, '..'); +const COMPONENTS = path.join(ROOT, 'src', 'components'); + +/** files that must stay token-clean (relative to the repo root, forward slashes) */ +const CLEAN = [ + 'src/components/ui/Badge.svelte', + 'src/components/ui/Button.svelte', + 'src/components/ui/Checkbox.svelte', + 'src/components/ui/Chips.svelte', + 'src/components/ui/EmptyState.svelte', + 'src/components/ui/Menu.svelte', + 'src/components/ui/NavRow.svelte', + 'src/components/ui/PropRow.svelte', + 'src/components/ui/SearchField.svelte', + 'src/components/ui/Segmented.svelte', + 'src/components/ui/SettingRow.svelte', + 'src/components/ui/Sheet.svelte', + 'src/components/ui/Slider.svelte', + 'src/components/ui/Tabs.svelte', + 'src/components/ui/Toast.svelte', + 'src/components/ui/Toggle.svelte', + 'src/components/ui/WindowChrome.svelte', + 'src/components/ui/kit/KitPage.svelte' +]; + +const PALETTE = + 'slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose|primary'; +const UTIL = + 'bg|text|border|border-[trblxy]|ring|ring-offset|outline|divide|from|via|to|fill|stroke|placeholder|caret|accent|decoration|shadow'; +const RULES = [ + { kind: 'hex', re: /(?, CSS/JS block + * comments, and // line comments (only after start-of-line or whitespace, so `https://` + * and a `//` inside a string literal of a URL survive). + * @param {string} text @returns {string} + */ +function stripComments(text) { + const blank = (/** @type {string} */ m) => m.replace(/[^\n]/g, ' '); + return text + .replace(//g, blank) + .replace(/\/\*[\s\S]*?\*\//g, blank) + .replace(/(^|[\s;{}])\/\/[^\n]*/g, (m, lead) => lead + blank(m.slice(lead.length))); +} + +/** + * Every raw colour in one file's text. + * @param {string} text + * @returns {{line: number, kind: string, match: string}[]} + */ +function scanText(text) { + /** @type {{line: number, kind: string, match: string}[]} */ + const out = []; + const lines = stripComments(text).split('\n'); + lines.forEach((line, i) => { + for (const { kind, re } of RULES) { + re.lastIndex = 0; + let m; + while ((m = re.exec(line))) out.push({ line: i + 1, kind, match: m[0] }); + } + }); + return out; +} + +/** @param {string} dir @param {string[]} out @returns {string[]} */ +function walk(dir, out = []) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) walk(full, out); + else if (/\.(svelte|css|js|ts)$/.test(entry.name)) out.push(full); + } + return out; +} + +/** @param {string} file */ +const relOf = (file) => path.relative(ROOT, path.resolve(file)).split(path.sep).join('/'); + +function main() { + const args = process.argv.slice(2); + const strict = args.includes('--strict'); + const json = args.includes('--json'); + const only = args.filter((a) => !a.startsWith('--')); + const files = only.length ? only.map((f) => path.resolve(ROOT, f)) : walk(COMPONENTS); + + /** @type {Record} */ + const perFile = {}; + /** @type {string[]} */ + const cleanBroken = []; + let total = 0; + for (const file of files) { + if (!fs.existsSync(file)) continue; + const rel = relOf(file); + const hits = scanText(fs.readFileSync(file, 'utf8')); + if (!hits.length) continue; + perFile[rel] = hits.length; + total += hits.length; + if (CLEAN.includes(rel)) + for (const h of hits) cleanBroken.push(` ${rel}:${h.line} ${h.kind} ${h.match}`); + } + + if (json) { + console.log(JSON.stringify({ total, files: perFile, cleanBroken: cleanBroken.length }, null, 2)); + } else { + const ranked = Object.entries(perFile).sort((a, b) => b[1] - a[1]); + console.log( + `check:tokens — ${total} raw colour(s) in ${ranked.length} file(s)` + + (strict ? '' : ' (WARNING mode: R11 makes this a hard fail)') + ); + for (const [rel, n] of ranked.slice(0, only.length ? ranked.length : 25)) console.log(` ${String(n).padStart(4)} ${rel}`); + if (!only.length && ranked.length > 25) console.log(` … and ${ranked.length - 25} more (--json for all)`); + if (cleanBroken.length) { + console.error(`\ncheck:tokens — ${cleanBroken.length} raw colour(s) in a token-CLEAN file:`); + for (const l of cleanBroken) console.error(l); + console.error('\nUse the tokens (src/styles/tokens.css; Tailwind: bg-surface-2, text-text-muted …).'); + } + } + process.exit(cleanBroken.length || (strict && total) ? 1 : 0); +} + +if (require.main === module) main(); + +module.exports = { scanText, stripComments, CLEAN }; diff --git a/scripts/templates/race.cjs b/scripts/templates/race.cjs index 8d708285..b7cb176d 100644 --- a/scripts/templates/race.cjs +++ b/scripts/templates/race.cjs @@ -36,6 +36,30 @@ const ROAD = [ const ROAD_W = 4.2; // tube radius = half the road width const ASPHALT = 0x2e3238; +// 37-slipped (plan 21-C C9): the Race look's missing half — the terrain blended by SLOPE and HEIGHT, +// "the single graph that makes a noise terrain look like terrain rather than a green tablecloth". +// Grass where the ground is flat, rock where it is steep, snow on the high gentle ground. The +// mountains are FLAT-SHADED (three declares no normal varying then), so the face normal comes +// from the screen-space derivatives of vViewPosition — three's own flat-shading recipe — and is +// turned back into WORLD space through the view matrix (a view-space normal would change the +// blend as the camera turns). Materials, never post: VR draws no post stack. +const RACE_TERRAIN_SHADER = { + nodes: [ + { + id: 'blend', + type: 'glsl', + position: { x: 60, y: 80 }, + data: { + type: 'vec3', + expression: + 'mix(mix(vec3(0.16, 0.25, 0.07), vec3(0.22, 0.19, 0.16), 1.0 - smoothstep(0.55, 0.8, (vec4(normalize(cross(dFdx(vViewPosition), dFdy(vViewPosition))), 0.0) * viewMatrix).y)), vec3(0.82, 0.84, 0.88), smoothstep(24.0, 34.0, (cameraPosition + (vec4(-vViewPosition, 0.0) * viewMatrix).xyz).y) * smoothstep(0.45, 0.7, (vec4(normalize(cross(dFdx(vViewPosition), dFdy(vViewPosition))), 0.0) * viewMatrix).y))' + } + }, + { id: 's', type: 'surface', position: { x: 520, y: 80 }, data: {} } + ], + edges: [{ id: 'e-blend-s', source: 'blend', sourceHandle: 'out', target: 's', targetHandle: 'albedo' }] +}; + /** a car: one dynamic box (the collider is the box of its parts), the parts riding it * @param {number} n 1..4 @param {number} color @param {number[]} pos @param {number} yaw */ function car(n, color, pos, yaw) { @@ -180,6 +204,7 @@ const RACE_DEF = { }, // 21-C9's Race look, trimmed to what reads on a desktop (post is off in VR): an ACES film // curve, a touch of contrast, low bloom for the tail-lights, a vignette, SMAA + shaders: { Mountains: RACE_TERRAIN_SHADER }, post: { enabled: true, effects: [ diff --git a/src/App.svelte b/src/App.svelte index 625aa53c..0ee8f0eb 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -42,6 +42,7 @@ // budget meter. `renderer.info` had exactly one reader before this (the VR plate). import StatsOverlay from './components/menu/StatsOverlay.svelte' import MomentReport from './components/menu/MomentReport.svelte' + import ProblemReport from './components/menu/ProblemReport.svelte' // 37 R20 import ProfilerLive from './components/menu/ProfilerLive.svelte' import { startSceneMetrics, budgetSummary } from './lib/sceneBudget' import { startPerfRecorder } from './lib/perf/recorder' @@ -424,6 +425,7 @@ import { startMusicToolbox } from './lib/musicToolbox' + diff --git a/src/components/Scene.svelte b/src/components/Scene.svelte index d89e7526..19672f46 100644 --- a/src/components/Scene.svelte +++ b/src/components/Scene.svelte @@ -41,6 +41,7 @@ import { startClap, tickClap, stopClap } from '$lib/clap'; // 31: two hands together make something import { tickMoveSmoothing } from '$lib/moveSmoothing'; import { moduleInteractiveGroups, fireClickMiss, runClickHandlers } from '$lib/moduleSDK'; + import { modulePointerDown, modulePointerMove, modulePointerUp, modulePointerWanted } from '$lib/modulePointer'; import { updateSpatialAudio } from '$lib/voiceChat'; import { tickAnimatedMixers } from '$lib/animatedImports'; import { tickSim } from '$lib/sim/runtime.js'; // 36-sim: jiggle, splashes, fluid tanks @@ -62,6 +63,8 @@ // the annotation is TS syntax — a JSDoc @type cast is ignored here (the documented trap). let knifeFrom: number[] | null = null; import { peerScenes } from '$lib/peerScenes'; + // 37 R23/R22: presence extras on the camera stream (a walker's feet, the knocked-off idle) + import { localFeet, localKnockedOut, tickIdle } from '$lib/avatars/avatarState'; import { initVRControls, updateVRControls, raycastMenu, radialStickSelection, raycastPanel, raycastPalette, raycastProps, raycastPrefabs, raycastKeyboard, raycastChat, raycastEdit, raycastSnap, raycastSettings, raycastApprove, placePrefabGhost, vrFaceTrigger, vrVertexTrigger, vrVertexGrabStart, vrVertexGrabEnd, beginStretchSliderDrag, endStretchSliderDrag, executeVRMenuAction, resetWorldRig, onInputSourcesChange, worldToContentPose, boxSelectStart, boxSelectEnd, boxSelectActive, applyVRFrameRate, shouldSendHands, onHandPinchStart, onHandPinchEnd, pinchMenuToggledAt, firePingIfArmed, vrModuleTriggerStart, vrModuleTriggerEnd, vrModuleSelectSwallowed, handSnapshot, vrGrabbedUuids, hapticKnock, hapticPulse, onVRSessionStart } from '$lib/vrControls'; // 30b (vr-play): the game in your hands — hover/press haptics (P1), the sweep (P4) import { startVrGameInput, stopVrGameInput } from '$lib/vrGameInput'; @@ -229,6 +232,11 @@ const camContentPos = new THREE.Vector3(); const camContentQuat = new THREE.Quaternion(); const camContentEuler = new THREE.Euler(); + // 37 R23/R22: the camera message's optional presence extras (`feet` from a walker; the + // knocked-off idle flag) and the last ones SENT — a change re-publishes even standing still + const camFeetPoint = new THREE.Vector3(); + const camFeetQuat = new THREE.Quaternion(); + let lastCameraExtras = ''; function readControllerPose(index) { const controller = renderer.xr.getController(index); @@ -376,9 +384,21 @@ // world-grab repositions you for peers; no-op when the rig is unbent, so // desktop + normal VR stay unchanged. Detect movement in the SAME frame, // else a grab (which leaves camera.position untouched) never sends. - camContentPos.copy(camera.current.position); - camContentQuat.copy(camera.current.quaternion); + // 37 R23: the WORLD pose. Play mode's camera lives in a group at y = 0.9 (Player.svelte), + // so its local position put every walking player 0.9 m into the floor on every peer. + camera.current.updateWorldMatrix(true, false); + camera.current.matrixWorld.decompose(camContentPos, camContentQuat, camFeetPoint); + const feetWorld = localFeet(); + const camExtras: Record = {}; + if (feetWorld !== null) { + camFeetPoint.set(camContentPos.x, feetWorld, camContentPos.z); + worldToContentPose($worldRig, camFeetPoint, camFeetQuat.identity()); + camExtras.feet = Math.round(camFeetPoint.y * 1000) / 1000; + } + tickIdle(camContentPos.toArray(), camContentQuat.toArray(), performance.now()); + if (localKnockedOut()) camExtras.knocked = 1; worldToContentPose($worldRig, camContentPos, camContentQuat); + const camExtrasKey = JSON.stringify(camExtras); // 27-E (audit H7): the camera stream is RATE-GATED now. It used to send on every // frame the camera moved past a threshold — in VR that threshold is 0.0001 m, so // at 90 Hz it is a message per frame, and at N=10 each peer both sends and @@ -389,11 +409,13 @@ const camGapMs = ($isVRMode ? 33 : 50) * (presenceSlow ? 2 : 1); const nowMs = performance.now(); if ((camContentPos.distanceTo(lastCameraPosition) > ($isVRMode ? 0.0001 : 0.01) || - camContentQuat.angleTo(lastCameraQuaternion) > THREE.MathUtils.degToRad(1)) && + camContentQuat.angleTo(lastCameraQuaternion) > THREE.MathUtils.degToRad(1) || + camExtrasKey !== lastCameraExtras) && nowMs - lastCameraSendAt >= camGapMs) { lastCameraSendAt = nowMs; camContentEuler.setFromQuaternion(camContentQuat); - $peers.send({ type: 'camera', peerId: $peers.peer.id, position: camContentPos.toArray(), rotation: [camContentEuler.x, camContentEuler.y, camContentEuler.z] }); + $peers.send({ type: 'camera', peerId: $peers.peer.id, position: camContentPos.toArray(), rotation: [camContentEuler.x, camContentEuler.y, camContentEuler.z], ...camExtras }); + lastCameraExtras = camExtrasKey; lastCameraPosition.copy(camContentPos); lastCameraQuaternion.copy(camContentQuat); } @@ -767,7 +789,28 @@ const interactPress = () => editorInteractActive() && !$specatorMode && !$editingObject && !$faceEditObject && !$splineEditObject && !$drawMode && !$sculptObject; + // 37 (DEVX #29): a module may OWN a press — Interact, or Edit when no editor tool or + // session holds it (a handler that asked for 'edit'); Play's presses are playInteract's + let modulePressOwned = false; + const modulePressMode = () => { + if ($isLocked === true || $isVRMode || $specatorMode) return null; + if (interactPress()) return 'interact'; + if (editorInteractActive() || $editingObject || $faceEditObject || $splineEditObject || $drawMode || $sculptObject) return null; + return 'edit'; + }; + const onPointerDown = (event) => { + if (event.button === 0) { + const pm = modulePressMode(); + if (pm && modulePointerWanted(pm)) { + setRayFromEvent(event); + if (modulePointerDown(pm, selectionRaycaster, event)) { + modulePressOwned = true; + setOrbitEnabled(false); + return; + } + } + } if (event.button === 2) { rightDown = [event.clientX, event.clientY, Date.now()]; return; @@ -899,6 +942,12 @@ window.addEventListener('keyup', onAltKey); window.addEventListener('blur', onAltBlur); const onPointerMove = (event) => { + // 37: a module-owned press hears the drag, and nothing else does + if (modulePressOwned) { + setRayFromEvent(event); + modulePointerMove(selectionRaycaster, event); + return; + } // 36 S6: the Alt preview follows the cursor (~30 Hz) and goes the moment Alt is let go if (event.altKey !== altHeld) setAltHeld(event.altKey); if (altHeld && !event.buttons && performance.now() - lastAltPreviewAt > 33) { @@ -971,6 +1020,14 @@ }; const onPointerUp = (event) => { + if (modulePressOwned && event.button === 0) { + modulePressOwned = false; + setRayFromEvent(event); + modulePointerUp(selectionRaycaster, event); + setOrbitEnabled(true); + downPosition = null; + return; + } if (interactCarrying && event.button === 0) { interactCarrying = false; cursorGrabEnd(); // a throw (false if the carry was already cancelled) diff --git a/src/components/editors/AnimationWindow.svelte b/src/components/editors/AnimationWindow.svelte index 929e0164..d59067ec 100644 --- a/src/components/editors/AnimationWindow.svelte +++ b/src/components/editors/AnimationWindow.svelte @@ -1,4 +1,5 @@ {/if} + {#if $activeFolder === 'prefabs'}{/if} {/snippet} {#snippet primary()} @@ -7306,8 +7493,8 @@
- - {#if pendingCard === 'create'} + + {#if pendingCard === 'create' || pendingCard === 'new-prefab-folder'}
+ {#if existing} + + {#if replies.length} +
    + {#each replies as r (r.id)} +
  • +
    + {r.author || 'Someone'} · {when(r.ts)} + {#if r.authorKey && r.authorKey === myAuthorKey()} + + {/if} +
    +

    {r.text}

    +
  • + {/each} +
+ {/if} +
+ { + if (e.key === 'Enter') { + e.preventDefault(); + sendReply(); + } + }} + /> + +
+ {/if}
+ {/each} +
+ {/if} {#if hints.length}
{#each hints as hint} @@ -147,9 +189,15 @@ type="text" id="message" class="ui-input min-w-0 flex-1" - placeholder="Message — / for commands" + placeholder="Message — / for commands, @ for people, : for emoji" bind:value={message} onkeydown={(e) => { + // Tab takes the first suggestion (an @name or an emoji) + if (e.key === 'Tab' && suggestions.length) { + e.preventDefault(); + accept(suggestions[0]); + return; + } if (e.key === 'Enter') send(); }} /> diff --git a/src/components/menu/Controls.svelte b/src/components/menu/Controls.svelte index 5965d6fc..0d945bd6 100644 --- a/src/components/menu/Controls.svelte +++ b/src/components/menu/Controls.svelte @@ -1,7 +1,9 @@ + + { + if ($layoutsMenuOpen && e.key === 'Escape') layoutsMenuOpen.set(false); + }} +/> + +{#if $layoutsMenuOpen} +
+
+ Workspace layouts + +
+ +
+{/if} + + diff --git a/src/components/menu/MaterialPresetsRow.svelte b/src/components/menu/MaterialPresetsRow.svelte new file mode 100644 index 00000000..734fcac0 --- /dev/null +++ b/src/components/menu/MaterialPresetsRow.svelte @@ -0,0 +1,439 @@ + + +
+
+ Presets{count ? ` — applies to all ${count}` : ''} + +
+ + {#if naming} +
+ +
+ {#if nameError}{/if} + {/if} + +
+ {#each resolvedStarters as s (s.id)} + + {/each} + {#each $materialPresets as p (p.name)} +
+ +
+ {/each} +
+ {#if editing} +
+ {#if picked?.kind === 'mine'} + {@const name = picked.name} + {name} + + {:else} + {$materialPresets.length || peerLists.length ? 'Pick one of your presets to rename, export or delete it.' : 'Save a look with + to start your own library. The starter set is built in.'} + {/if} +
+
+ + +
+ {/if} + + {#each peerLists as [peerId, list] (peerId)} +

From {nameOf(peerId)}

+
+ {#each list as p, i (p.label + ':' + i)} +
+ +
+ {/each} +
+ {/each} +
+ + diff --git a/src/components/menu/NodeTypesSection.svelte b/src/components/menu/NodeTypesSection.svelte index a2873281..da873de2 100644 --- a/src/components/menu/NodeTypesSection.svelte +++ b/src/components/menu/NodeTypesSection.svelte @@ -1,79 +1,103 @@ - -
-

- Switch off the node types you never use — they leave the palette, the add menus and the node search - on this device. Nodes already in a graph keep working. -

-
- - -
-
- {#each shown as group (group.group)} - {@const types = group.items.map((i) => i.type)} - {@const allOn = types.every((t) => !$disabledNodeTypes.includes(t))} -
-
+ + {/if} + {/if}
+ + diff --git a/src/components/menu/Objects.svelte b/src/components/menu/Objects.svelte index a80328f7..e1f10b0f 100644 --- a/src/components/menu/Objects.svelte +++ b/src/components/menu/Objects.svelte @@ -38,7 +38,7 @@ import { withExpanded } from '$lib/objectListNav'; import { objectsGroup, TControls, selectedObject, selectedObjects, lockedObjects } from '../../stores/sceneStore'; import { sceneCommand } from '$lib/commandsHandler.svelte'; - import { selectObject, renameObject, moveObjectToGroup, toggleObjectVisibility } from '$lib/objectActions'; + import { selectObject, renameObject, toggleObjectVisibility, dragUuidsFor, moveObjectsToParent } from '$lib/objectActions'; import { nameOf, peerColor } from '$lib/lockControl'; import { showSidebar, @@ -144,13 +144,16 @@ function onRowDragStart(event) { event.dataTransfer.setData('application/x-object-uuid', element.uuid); + // 37 R1: a row that is part of a multi-selection drags the whole selection + event.dataTransfer.setData('application/x-object-uuids', JSON.stringify(dragUuidsFor(element.uuid))); event.dataTransfer.effectAllowed = 'move'; // rows live inside the draggable object-list window; don't drag the window too event.stopPropagation(); } + // 37 R1: any row is a drop target — a group takes the objects in, any other object + // becomes their parent (Blender's drop-onto) function onRowDragOver(event) { - if (element.type !== 'Group') return; if (!event.dataTransfer.types.includes('application/x-object-uuid')) return; event.preventDefault(); event.stopPropagation(); @@ -163,6 +166,17 @@ }, 600); } + /** the dragged set (37 R1), falling back to the single-row key older drags carry + * @param {DragEvent} event @returns {string[]} */ + function droppedUuids(event) { + try { + const list = JSON.parse(event.dataTransfer?.getData('application/x-object-uuids') || 'null'); + if (Array.isArray(list) && list.length) return list.filter((id) => typeof id === 'string'); + } catch {} + const one = event.dataTransfer?.getData('application/x-object-uuid'); + return one ? [one] : []; + } + function clearHoverExpand() { dropHover = false; clearTimeout(hoverExpandTimer); @@ -171,18 +185,19 @@ function onRowDrop(event) { clearHoverExpand(); - if (element.type !== 'Group') return; - const uuid = event.dataTransfer.getData('application/x-object-uuid'); - if (!uuid || uuid === element.uuid) return; + const uuids = droppedUuids(event); + if (!uuids.length || uuids.includes(element.uuid)) return; event.preventDefault(); event.stopPropagation(); - const dragged = $objectsGroup.getObjectByProperty('uuid', uuid); - // a LOCAL object dropped into a SHARED group is SHARED into that group (a bare - // reparent would never reach peers); local->local group stays local - if (dragged?.userData?.__localOnly && !element.userData?.__localOnly) - shareObject(dragged, element.uuid); - else - moveObjectToGroup(uuid, element.uuid); + // a LOCAL object dropped onto a SHARED parent is SHARED into it (a bare reparent + // would never reach peers); local->local stays local + const moves = []; + for (const uuid of uuids) { + const dragged = $objectsGroup.getObjectByProperty('uuid', uuid); + if (dragged?.userData?.__localOnly && !element.userData?.__localOnly) shareObject(dragged, element.uuid); + else moves.push(uuid); + } + moveObjectsToParent(moves, element.uuid); setExpanded(true); } diff --git a/src/components/menu/ProblemReport.svelte b/src/components/menu/ProblemReport.svelte new file mode 100644 index 00000000..95b08d5a --- /dev/null +++ b/src/components/menu/ProblemReport.svelte @@ -0,0 +1,284 @@ + + +{#if $problemDraft} + + +{/if} + + diff --git a/src/components/menu/SettingRow.svelte b/src/components/menu/SettingRow.svelte index 7d52f9e2..9bff650b 100644 --- a/src/components/menu/SettingRow.svelte +++ b/src/components/menu/SettingRow.svelte @@ -1,165 +1,113 @@ -
+
+
+
{name}
+
+
{#if !noControl}
{/if} -
{name}
- -
diff --git a/src/components/menu/Settings.svelte b/src/components/menu/Settings.svelte index 7b41ad01..b085d2a4 100644 --- a/src/components/menu/Settings.svelte +++ b/src/components/menu/Settings.svelte @@ -1,302 +1,65 @@ + let focusedInput: HTMLInputElement | null = null; + let stopSearchFocus: () => void = () => {}; + $: if (searchInput !== focusedInput) { + stopSearchFocus(); + focusedInput = searchInput; + stopSearchFocus = searchInput ? searchFocus(searchInput) : () => {}; + } - { + // 37-settings: the active section's key + title (the footer's reset, the phone's nav bar) + $: activeKey = sectionKeyOf($navActive?.label ?? ''); + $: activeLabel = $navActive?.label ?? ''; + $: searching = !!(settingsQuery || '').trim(); + + /** + * Jump from a search result to where it lives: end the search, show its page (and sub-page), + * bring the row into view and flash it. The spec: "search results show the matching row with + * its path and jump to it". + * @param {string} key the page @param {string | null} [rowLabel] @param {{id: string, label: string} | null} [sub] + */ + async function jumpTo(key: string, rowLabel: string | null = null, sub: { id: string; label: string } | null = null) { + settingsQuery = ''; + await tick(); + await new Promise((r) => setTimeout(r, 0)); // after the search's own restore (a microtask) + settingsNav.activateKey(key); + if (sub) baseOpenSub(sub.id, sub.label); + await tick(); + await tick(); + if (!rowLabel) return; + const page = document.querySelector(`#settings-sections .ss-page[data-section="${key}"]`); + const row = [...(page?.querySelectorAll('.setting-row, .tp-ui.nr') ?? [])].find( + (r) => (r.querySelector('.sr-name, .nr-label')?.textContent || '').trim() === rowLabel + ); + if (!row) return; + row.scrollIntoView({ block: 'center' }); + row.classList.add('sr-flash'); + setTimeout(() => row.classList.remove('sr-flash'), 1600); + const control = row.matches('button') ? row : row.querySelector('.sr-control button, .sr-control input, .sr-control select, .sr-control [tabindex="0"]'); + control?.focus({ preventScroll: true }); + } + (settingsNav as any).jumpTo = jumpTo; + // a page opens its sub-pages through the nav; while searching that is a jump first + const baseOpenSub = settingsNav.openSub; + settingsNav.openSub = (id: string, label: string, key?: string) => { + if (get(settingsNav.searching) && key) void jumpTo(key, null, { id, label }); + else baseOpenSub(id, label); + keepFocusInside(); + }; + const baseCloseSub = settingsNav.closeSub; + settingsNav.closeSub = () => { + baseCloseSub(); + keepFocusInside(); + }; + /** the button that opened (or closed) a sub-page unmounts with it: hand the focus to the content + * column so the keyboard — Esc included — stays inside Settings */ + function keepFocusInside() { + void tick().then(() => { + const a = document.activeElement; + if (!a || a === document.body || !a.closest('dialog.settings-dialog')) document.getElementById('settings-main')?.focus({ preventScroll: true }); + }); + } + /** Esc with the focus lost to (a control that was pressed has unmounted) still belongs to Settings */ + function onWindowKey(e: KeyboardEvent) { + if (!$settingsOpen || e.key !== 'Escape' || e.defaultPrevented) return; + const t = e.target as HTMLElement | null; + if (t && t !== document.body && t !== document.documentElement) return; + if (get(confirmDialog)) return; + onDialogKey(e); + } + + /** searching: a click on a row's NAME jumps to it (the controls keep working in place) */ + function onMainClick(e: MouseEvent) { + if (!searching) return; + const name = (e.target as HTMLElement)?.closest?.('.sr-name'); + const page = name?.closest('.ss-page'); + if (!name || !page) return; + void jumpTo(page.dataset.section || '', (name.textContent || '').trim()); + } + + /** About › Danger zone: 1.25.0's footer button, now behind a confirmation */ + async function askClearSavedSession() { + const ok = await showConfirm({ + title: 'Clear the saved session?', + message: 'The autosaved copy of your work on this device is deleted and no restore is offered next time. Your library, saved sessions and the scene on screen stay.', + confirmLabel: 'Clear', + cancelLabel: 'Cancel' + }); + if (ok) await clearSavedSession(); + } + + function closeSettings() { + settingsOpen.set(false); + } + + /** Esc: a sub-page first, then the search (36 I4: it keeps you where you are), then the window */ + function onDialogKey(e: KeyboardEvent) { if (e.key !== 'Escape') return; - // 36 I4: Esc first CLEARS a search (and keeps you where you are); the next one closes + if (get(navSub)) { + e.stopPropagation(); + settingsNav.closeSub(); + return; + } if ((settingsQuery || '').trim()) { e.stopPropagation(); settingsQuery = ''; @@ -806,1237 +417,118 @@ return; } settingsOpen.set(false); - }} + } + + /** the phone's nav bar: "‹ Settings" on a page, "‹ " on a sub-page */ + $: mobileBack = narrowSettings && !searching ? ($navSub ? () => settingsNav.closeSub() : !$navHome ? () => settingsNav.showHome() : null) : null; + $: mobileTitle = !narrowSettings || searching || $navHome ? 'Settings' : $navSub ? $navSub.label : activeLabel; + $: mobileBackLabel = $navSub ? activeLabel : 'Settings'; + + + + + -