From 8ce56af5bc37efaaf126aeae727659edffc3926a Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Tue, 6 Oct 2026 00:32:32 +0300 Subject: [PATCH 01/65] [docs] claude.md: the UI redesign design-system section + the every-icon-through-Icon.svelte note (38-tokens R0) - the CLAUDE-snippet from the redesign handoff, path adjusted to the cloud checkout (../cloud/docs/design/redesign/) - icons note: SPEC 7 routes every icon (static ones too) through ui/Icon.svelte, sizes 16/20; direct @lucide/svelte imports are legacy until R10 Co-Authored-By: Claude Opus 5.5 (1M context) --- CLAUDE.md | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) 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, From 2e2299b4feed0eeb53a14ce6ddfade56f5f7f4bb Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Tue, 6 Oct 2026 00:38:11 +0300 Subject: [PATCH 02/65] [feat] redesign tokens: SPEC 1 semantic variables, Tailwind @theme, .theme.json mapping, light values, check:tokens (38-tokens R2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - src/styles/tokens.css: bg-app / surface-1/2/inset / border(-strong/-input) / text(-2/-muted/ -faint) / accent(-soft/-muted/-text) / live / warn-text / danger / badge + control metrics, 4px spacing, rem type scale (mobile < 640px: 16/14/16, 44px targets, 51x31 toggles) - the five names the legacy themes already own (--text --text-2 --border --accent --surface-2) carry SPEC values only inside .tp-ui: dark leaves them undefined and ~400 call sites render their per-site fallbacks, so the default theme is unchanged - light: its own block; green / 8-bit / contrast / custom .theme.json DERIVE every token from the legacy keys they already carry; old dark exports get the dark values - app.css: @theme inline colour + type utilities (bg-surface-2, text-text-muted, text-body …), @theme static radii + font-ui / font-ui-mono (IBM Plex, self-hosted, OFL) - themes.js: REDESIGN_TOKENS in THEME_TOKENS; export leaves DERIVED tokens derived - scripts/check-tokens.cjs (npm run check:tokens, CI warning step): hex / rgb / Tailwind palette in src/components, --strict for R11, a CLEAN list that fails even now; unit test Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/ci.yml | 5 + package.json | 3 +- scripts/check-tokens.cjs | 147 ++++++++++++++ src/app.css | 55 ++++++ src/lib/themes.js | 63 +++++- src/styles/tokens.css | 217 +++++++++++++++++++++ static/fonts/LICENSE-ibm-plex.txt | 93 +++++++++ static/fonts/ibm-plex-mono-500-latin.woff2 | Bin 0 -> 14888 bytes static/fonts/ibm-plex-sans-latin.woff2 | Bin 0 -> 45712 bytes tests/unit/checkTokens.test.js | 55 ++++++ 10 files changed, 634 insertions(+), 4 deletions(-) create mode 100644 scripts/check-tokens.cjs create mode 100644 src/styles/tokens.css create mode 100644 static/fonts/LICENSE-ibm-plex.txt create mode 100644 static/fonts/ibm-plex-mono-500-latin.woff2 create mode 100644 static/fonts/ibm-plex-sans-latin.woff2 create mode 100644 tests/unit/checkTokens.test.js 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/package.json b/package.json index 861a0fbb..d58d2531 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/scripts/check-tokens.cjs b/scripts/check-tokens.cjs new file mode 100644 index 00000000..8caac792 --- /dev/null +++ b/scripts/check-tokens.cjs @@ -0,0 +1,147 @@ +#!/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/Chips.svelte', + 'src/components/ui/EmptyState.svelte', + 'src/components/ui/NavRow.svelte', + 'src/components/ui/PropRow.svelte', + 'src/components/ui/SettingRow.svelte', + 'src/components/ui/SettingsCard.svelte', + 'src/components/ui/Segmented.svelte', + 'src/components/ui/Sheet.svelte', + 'src/components/ui/Tabs.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/src/app.css b/src/app.css index 52768c78..03518523 100644 --- a/src/app.css +++ b/src/app.css @@ -2,6 +2,8 @@ @import './styles/ui.css' layer(utilities); @import './styles/ui.utilities.css'; @import './styles/theme.css' layer(utilities); +/* 38 R2: the redesign tokens (SPEC §1) — after theme.css, whose legacy blocks they derive from */ +@import './styles/tokens.css' layer(utilities); @plugin 'flowbite/plugin'; @@ -22,6 +24,59 @@ --color-primary-900: #a5371b; } +/* 38 R2: the redesign tokens as Tailwind utilities. `inline` so a utility emits + var(--surface-2) itself (resolved per ELEMENT): the colliding names only carry their + SPEC values inside `.tp-ui`, and a theme switch must repaint without a rebuild. + Names: bg-app, bg-surface-1/2/inset, border-border/-strong/-input, text-text/-2/-muted/ + -faint, bg-accent, bg-accent-soft, text-accent-text, bg-live, text-warn-text, bg-danger, + bg-badge text-badge-text; sizes text-badge … text-page-title; rounded-input … rounded-pill; + font-ui / font-ui-mono. (Never --font-mono / --radius-lg here: those would restyle the + existing UI's font-mono / rounded-lg.) */ +@theme inline { + --color-app: var(--bg-app); + --color-surface-1: var(--surface-1); + --color-surface-2: var(--surface-2); + --color-surface-inset: var(--surface-inset); + --color-surface-hover: var(--surface-hover); + --color-border: var(--border); + --color-border-strong: var(--border-strong); + --color-border-input: var(--border-input); + --color-text: var(--text); + --color-text-2: var(--text-2); + --color-text-muted: var(--text-muted); + --color-text-faint: var(--text-faint); + --color-accent: var(--accent); + --color-accent-soft: var(--accent-soft); + --color-accent-muted: var(--accent-muted); + --color-accent-soft-text: var(--accent-soft-text); + --color-accent-text: var(--accent-text); + --color-live: var(--live); + --color-warn-text: var(--warn-text); + --color-danger: var(--danger); + --color-badge: var(--badge-bg); + --color-badge-text: var(--badge-text); + --text-badge: var(--fs-badge); + --text-section: var(--fs-section); + --text-desc: var(--fs-desc); + --text-body: var(--fs-body); + --text-panel-title: var(--fs-panel-title); + --text-modal-title: var(--fs-modal-title); + --text-page-title: var(--fs-page-title); +} +/* radii + the two UI fonts are plain values, so they live HERE (one name for the CSS var and + the utility; `static` emits them on :root even before a utility uses them). The green and + 8-bit themes override --font-ui/--font-ui-mono in tokens.css. */ +@theme static { + --radius-input: 6px; + --radius-button: 8px; + --radius-card: 10px; + --radius-window: 12px; + --radius-modal: 14px; + --radius-pill: 999px; + --font-ui: 'IBM Plex Sans', ui-sans-serif, system-ui, sans-serif; + --font-ui-mono: 'IBM Plex Mono', ui-monospace, 'Cascadia Mono', Consolas, monospace; +} + /* The default border color has changed to `currentcolor` in Tailwind CSS v4, so we've added these compatibility styles to make sure everything still diff --git a/src/lib/themes.js b/src/lib/themes.js index 204a8e19..cc705c17 100644 --- a/src/lib/themes.js +++ b/src/lib/themes.js @@ -21,7 +21,55 @@ export const THEMES = [ { id: 'contrast', name: 'High contrast' } ]; -// the full token set a theme file carries (semantic + 146 dropdown + 148 scrollbar) +// 38 R2: the redesign's own keys (styles/tokens.css). DERIVED ones are computed from the +// theme's base colours in a custom theme, so an EXPORT leaves them out unless the theme set +// them itself — otherwise editing --accent in the file would leave a frozen copy of the old +// accent in --accent-soft, --badge-bg … . +export const REDESIGN_TOKENS = [ + '--bg-app', + '--surface-1', + '--surface-inset', + '--border-strong', + '--border-input', + '--text-muted', + '--text-faint', + '--accent-soft', + '--accent-muted', + '--accent-soft-text', + '--accent-text', + '--live', + '--warn-text', + '--danger', + '--on-danger', + '--badge-bg', + '--badge-text', + '--control-off', + '--knob', + '--surface-hover', + '--surface-active', + '--scrim' +]; +export const DERIVED_TOKENS = [ + '--bg-app', + '--surface-1', + '--surface-inset', + '--border-strong', + '--border-input', + '--text-muted', + '--text-faint', + '--accent-soft', + '--accent-muted', + '--accent-soft-text', + '--accent-text', + '--warn-text', + '--badge-bg', + '--badge-text', + '--control-off', + '--surface-hover', + '--surface-active' +]; + +// the full token set a theme file carries (semantic + 146 dropdown + 148 scrollbar + 38) export const THEME_TOKENS = [ '--surface-deep', '--surface-deep-rgb', @@ -66,7 +114,12 @@ export const THEME_TOKENS = [ '--ink-warn', '--ink-good', '--accent-fill', - '--on-accent' + '--on-accent', + // 38 R2: the redesign tokens (styles/tokens.css, SPEC §1). A custom theme may set any of + // them; one that does not DERIVES them from the legacy keys above (tokens.css "EXOTIC + + // CUSTOM"), so every .theme.json written before 38 keeps restyling the redesigned UI. + // (--text/--text-2/--border/--accent/--surface-2 are already listed: same names.) + ...REDESIGN_TOKENS ]; function loadCustomThemes() { @@ -142,9 +195,13 @@ export function activeThemeName() { /** Download the ACTIVE theme as an editable .theme.json file (149). */ export function exportActiveTheme() { + const tokens = activeThemeTokens(); + // 38 R2: derived tokens stay derived (see DERIVED_TOKENS) unless this theme set them + const own = get(customThemes).find((/** @type {any} */ t) => t.id === get(theme))?.tokens ?? {}; + for (const token of DERIVED_TOKENS) if (!(token in own)) delete tokens[token]; const payload = { name: activeThemeName() + ' (copy)', - tokens: activeThemeTokens() + tokens }; const json = JSON.stringify(payload, null, 2); const blob = new Blob([json], { type: 'application/json' }); diff --git a/src/styles/tokens.css b/src/styles/tokens.css new file mode 100644 index 00000000..b7cba9ae --- /dev/null +++ b/src/styles/tokens.css @@ -0,0 +1,217 @@ +/* 38 R2 — THE REDESIGN TOKENS (cloud docs/design/redesign/SPEC.md §1). + + Semantic variables every redesigned surface paints from. Tailwind sees them through the + `@theme inline` block in app.css (bg-surface-2, text-text-muted, rounded-card …); scoped + CSS uses them directly (var(--surface-2)). Primitives in components/ui are built ONLY + from these. + + THE ONE TRAP, and why `.tp-ui` exists. Five SPEC names were already phase-89 theme + tokens: --text, --text-2, --border, --accent, --surface-2. The DEFAULT (dark) theme + leaves those five UNDEFINED on purpose, and ~400 call sites in 68 components render + their own per-site fallback (`var(--text, #e5e7eb)`, `var(--accent, #ef562f)`, + `var(--surface-2, #374151)` …). Defining them on :root would repaint the whole default + theme (20 orange accents turn blue), which R2 forbids. So: + - every OTHER SPEC token is global (:root, every theme); + - the five colliding names carry their SPEC values inside `.tp-ui` — the class every + primitive's root wears (WindowChrome too, so a migrated window's whole body is in + it). Outside `.tp-ui` they keep their legacy meaning until R11 moves this block to + :root, when the last legacy fallback is gone. + Custom and exotic themes (green / 8-bit / contrast / custom-*) need no scope: they + DEFINE the five on :root already, and `.tp-ui` leaves them alone there. + + THEMES. Dark = the mockup values (the default). Light = its own block below. + Green / 8-bit / contrast / a custom .theme.json DERIVE every token from the legacy set + they already carry (--surface-deep, --surface, --field, --muted, --accent …), so a + custom theme keeps restyling everything with no new keys; a .theme.json MAY also set + any token here directly (themes.js THEME_TOKENS lists them). */ + +@font-face { + font-family: 'IBM Plex Sans'; + font-style: normal; + font-weight: 400 600; + font-display: swap; + src: url('/fonts/ibm-plex-sans-latin.woff2') format('woff2'); +} +@font-face { + font-family: 'IBM Plex Mono'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url('/fonts/ibm-plex-mono-500-latin.woff2') format('woff2'); +} + +/* ---- DARK (default): global tokens ---- */ +:root { + --bg-app: #0b0e14; /* behind panels */ + --surface-1: #151a24; /* modal / window body */ + --surface-inset: #11151d; /* inputs, segmented track, value boxes */ + --border-strong: #343c4f; /* outline buttons, chips */ + --border-input: #2a3142; /* input / segmented-track outline */ + --text-muted: #98a1b3; /* descriptions (>= 4.5:1 on surface-2) */ + --text-faint: #8b94a7; /* section headers, counts, hints */ + --accent-soft: #22304a; /* selected nav item / chip fill */ + --accent-muted: #2a3550; /* secondary button, segmented thumb */ + --accent-soft-text: #e8f0ff; /* text on accent-soft / accent-muted */ + --accent-text: #7fb0ff; /* links, ghost buttons */ + --live: #f05a2a; /* ONLY Play, record, live/streaming */ + --warn-text: #f0a07a; /* reset + soft-destructive text actions */ + --danger: #d43c3c; /* destructive confirm buttons (white on it 4.7:1) */ + --on-danger: #ffffff; + --badge-bg: #1f2b42; /* scope badges */ + --badge-text: #9fb8e6; + --control-off: #3a4256; /* toggle track when off */ + --knob: #ffffff; /* toggle knob */ + --surface-hover: rgb(255 255 255 / 0.06); /* ghost / icon button / row hover */ + --surface-active: rgb(255 255 255 / 0.1); + --scrim: rgb(5 7 12 / 0.55); /* behind a Sheet or a modal */ + --shadow-window: 0 16px 48px rgb(0 0 0 / 0.45), 0 2px 6px rgb(0 0 0 / 0.3); + --shadow-knob: 0 1px 2px rgb(0 0 0 / 0.35); + + /* radii (SPEC §1: 6 / 8 / 10 / 12-14 / 999) and --font-ui / --font-ui-mono are in app.css + `@theme static` — one name for the CSS var and the utility (rounded-card, font-ui) */ + + /* spacing: a 4px grid (Tailwind's own scale is the same grid: p-1 = 4px) */ + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-5: 20px; + --space-6: 24px; + --space-8: 32px; + + /* type sizes: rem so the 8-bit theme's small root em still shrinks them. */ + --fs-badge: 0.6875rem; /* 11 */ + --fs-section: 0.75rem; /* 12, uppercase, tracking 0.06em */ + --fs-desc: 0.8125rem; /* 13 description / property */ + --fs-body: 0.875rem; /* 14 body / label */ + --fs-input: 0.875rem; /* 14 */ + --fs-panel-title: 0.9375rem; /* 15 */ + --fs-modal-title: 1.125rem; /* 18 */ + --fs-page-title: 1.375rem; /* 22 */ + --tracking-section: 0.06em; + + /* control metrics */ + --control-h: 36px; /* buttons */ + --control-h-sm: 32px; /* compact buttons, chips' row, search fields */ + --icon-button: 32px; + --toggle-w: 40px; + --toggle-h: 24px; + --row-h: 36px; /* PropRow minimum */ + --prop-label-w: 96px; +} + +/* SPEC §6 mobile (< 640px): body/labels 16, descriptions 14, inputs 16 (no iOS zoom), + touch targets >= 44, toggles 51x31 */ +@media (max-width: 639.98px) { + :root { + --fs-body: 1rem; + --fs-desc: 0.875rem; + --fs-input: 1rem; + --control-h: 44px; + --control-h-sm: 44px; + --icon-button: 44px; + --toggle-w: 51px; + --toggle-h: 31px; + --row-h: 44px; + } +} + +/* ---- DARK: the five colliding names, inside the redesign scope only (see the header) ---- */ +:root:not([data-theme]) .tp-ui, +:root[data-theme='dark'] .tp-ui { + --text: #e6e9ef; + --text-2: #c6cdda; + --border: #262d3b; + --surface-2: #1b212d; + --accent: #3b7cf0; +} + +/* ---- LIGHT ---- */ +:root[data-theme='light'] { + --bg-app: #eef0f4; + --surface-1: #ffffff; + --surface-inset: #f0f2f5; + --border-strong: #c9cfd9; + --border-input: #d3d8e0; + --text-muted: #586174; + --text-faint: #646d80; + --accent-soft: #e3ecfd; + --accent-muted: #d6e3fb; + --accent-soft-text: #1a3f8f; + --accent-text: #1d4ed8; + --live: #e2531f; + --warn-text: #b4451a; + --danger: #c53030; + --badge-bg: #e4ecfb; + --badge-text: #2b4f8f; + --control-off: #c4cad6; + --surface-hover: rgb(15 23 42 / 0.05); + --surface-active: rgb(15 23 42 / 0.09); + --scrim: rgb(15 23 42 / 0.35); + --shadow-window: 0 16px 48px rgb(15 23 42 / 0.16), 0 2px 6px rgb(15 23 42 / 0.08); + --shadow-knob: 0 1px 3px rgb(15 23 42 / 0.3); +} +/* light's legacy block (theme.css) defines the five for the phase-89 remaps; the redesign + scope states its own */ +:root[data-theme='light'] .tp-ui { + --text: #141a24; + --text-2: #2f3747; + --border: #e1e5eb; + --surface-2: #f7f8fa; + --accent: #2563eb; +} + +/* ---- EXOTIC + CUSTOM themes: derive from the legacy tokens they carry ---- + `[data-theme]` is required so the pre-hydration root (no attribute yet) stays on the dark + literals above. Every var() has a dark fallback: a custom .theme.json may omit any key. */ +:root[data-theme]:not([data-theme='dark']):not([data-theme='light']) { + --bg-app: var(--surface-deep, #0b0e14); + --surface-1: var(--surface, #151a24); + --surface-inset: var(--field, #11151d); + --border-strong: color-mix(in srgb, var(--border, #262d3b) 70%, var(--text, #e6e9ef)); + --border-input: var(--border, #2a3142); + --text-muted: var(--muted, #98a1b3); + --text-faint: var(--muted, #8b94a7); + --accent-soft: color-mix(in srgb, var(--accent, #3b7cf0) 24%, var(--surface, #151a24)); + --accent-muted: color-mix(in srgb, var(--accent, #3b7cf0) 34%, var(--surface, #151a24)); + --accent-soft-text: var(--text, #e6e9ef); + --accent-text: color-mix(in srgb, var(--accent, #3b7cf0) 55%, var(--text, #e6e9ef)); + --warn-text: var(--ink-warn, #f0a07a); + --badge-bg: color-mix(in srgb, var(--accent, #3b7cf0) 20%, var(--surface, #151a24)); + --badge-text: color-mix(in srgb, var(--accent, #3b7cf0) 40%, var(--text, #e6e9ef)); + --control-off: var(--surface-3, #3a4256); + --surface-hover: var(--hover, rgb(255 255 255 / 0.06)); + --surface-active: color-mix(in srgb, var(--hover, #262d3b) 70%, var(--text, #e6e9ef)); +} +/* a custom .theme.json exported from Dark BEFORE 38 carries no --text/--border/... (dark + left them undefined, so the export skipped them). Give such a file the dark values; an + inline token from the file always wins over this rule. */ +:root[data-theme^='custom-'] { + --text: #e6e9ef; + --text-2: #c6cdda; + --border: #262d3b; + --surface-2: #1b212d; + --accent: #3b7cf0; +} + +/* personalities that are TYPE, not colour: the redesign scope keeps them */ +:root[data-theme='green'] { + --font-ui: ui-monospace, 'Cascadia Mono', Consolas, 'Courier New', monospace; + --font-ui-mono: ui-monospace, 'Cascadia Mono', Consolas, 'Courier New', monospace; +} +:root[data-theme='bit8'] { + --font-ui: 'Press Start 2P', ui-monospace, monospace; + --font-ui-mono: 'Press Start 2P', ui-monospace, monospace; +} + +/* ---- the scope itself ---- */ +.tp-ui { + font-family: var(--font-ui); + -webkit-font-smoothing: antialiased; +} +/* SPEC §8: a visible focus ring in the accent, on everything focusable in the scope */ +.tp-ui :focus-visible, +.tp-ui:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} diff --git a/static/fonts/LICENSE-ibm-plex.txt b/static/fonts/LICENSE-ibm-plex.txt new file mode 100644 index 00000000..c35c4c61 --- /dev/null +++ b/static/fonts/LICENSE-ibm-plex.txt @@ -0,0 +1,93 @@ +Copyright © 2017 IBM Corp. with Reserved Font Name "Plex" + +This Font Software is licensed under the SIL Open Font License, Version 1.1. + +This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/static/fonts/ibm-plex-mono-500-latin.woff2 b/static/fonts/ibm-plex-mono-500-latin.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..090f82f7ec7997047324e07650865305a099969a GIT binary patch literal 14888 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

-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 literal 0 HcmV?d00001 diff --git a/tests/unit/checkTokens.test.js b/tests/unit/checkTokens.test.js new file mode 100644 index 00000000..e539fa35 --- /dev/null +++ b/tests/unit/checkTokens.test.js @@ -0,0 +1,55 @@ +import { describe, it, expect } from 'vitest'; +import { createRequire } from 'node:module'; + +// 38 R2: the `check:tokens` scanner. What it must catch is every way a component paints a +// colour of its own; what it must NOT catch is everything that merely looks like one — a +// Svelte block (`{#each}`), an HTML entity, an id selector, a token utility, a note in a +// comment — or warning mode drowns in noise and nobody reads it. +const require = createRequire(import.meta.url); +const { scanText, CLEAN } = require('../../scripts/check-tokens.cjs'); + +/** @param {string} text */ +const kinds = (text) => scanText(text).map((/** @type {any} */ h) => h.kind + ':' + h.match); + +describe('check:tokens scanText', () => { + it('flags hex literals, including var() fallbacks and alpha forms', () => { + expect(kinds('color: #fff;')).toEqual(['hex:#fff']); + expect(kinds('background: var(--surface-2, #374151);')).toEqual(['hex:#374151']); + expect(kinds("const c = '#1b212dcc';")).toEqual(['hex:#1b212dcc']); + }); + + it('flags rgb/rgba/hsl literals', () => { + expect(kinds('border: 1px solid rgb(75 85 99 / 0.6);')).toEqual(['rgb/hsl:rgb(7']); + expect(kinds('color: rgba(255, 255, 255, 0.2)')).toEqual(['rgb/hsl:rgba(2']); + expect(kinds('fill: hsl(210 40% 50%)')).toEqual(['rgb/hsl:hsl(2']); + }); + + it('flags Tailwind palette utilities with variants and opacity', () => { + expect(kinds('

')).toEqual([ + 'palette:bg-gray-800', + 'palette:text-red-400', + 'palette:border-blue-500/50' + ]); + expect(kinds('')).toEqual(['palette:text-white', 'palette:bg-black']); + expect(kinds('')).toEqual(['palette:bg-primary-600']); + }); + + it('leaves token utilities, token vars and look-alikes alone', () => { + expect(kinds('
')).toEqual([]); + expect(kinds('color: var(--accent); background: var(--surface-inset);')).toEqual([]); + expect(kinds('{#each items as item}{#if a}{/if}{/each}')).toEqual([]); + expect(kinds('{ #play-button { clip-path: none }')).toEqual([]); + expect(kinds('')).toEqual([]); + }); + + it('ignores colours named in comments, but keeps line numbers', () => { + const src = ['/* was #fff */', '', '// rgb(1 2 3)', 'a { color: #000 }', "x = 'https://e.x/#abc'"].join('\n'); + const hits = scanText(src); + expect(hits.map((/** @type {any} */ h) => [h.line, h.match])).toEqual([[4, '#000']]); + }); + + it('CLEAN lists the primitives the redesign builds from tokens', () => { + expect(CLEAN).toContain('src/components/ui/Toggle.svelte'); + expect(CLEAN).toContain('src/components/ui/WindowChrome.svelte'); + }); +}); From 16bc7374cdd802eed6e9662350847036c2aa0a44 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Tue, 6 Oct 2026 00:57:42 +0300 Subject: [PATCH 03/65] [feat] redesign primitives in components/ui + the dev-only /kit page (38-tokens R3) - SPEC 2 primitives, every root in the .tp-ui token scope: WindowChrome (modal / panel / tool, mobile nav bar, footer; chrome only - open/pin/dock stay the caller's), Tabs (tablist, automatic activation, panel wiring), Segmented (radiogroup, one tab stop), Chips (single / multi), Toggle (aria-pressed, 40x24 / 51x31), Button (primary on --accent-fill = #2f6fe0, secondary, outline, ghost, warn-text, danger, icon + count/dot, live), Badge (scope / status dot / count / live), SettingRow (keeps .setting-row + .sr-name for Settings search; wide / stack), PropRow (wraps DragRow UNCHANGED: props + events pass through, restyled via the vars DragRow reads), NavRow, EmptyState, Sheet (peek / half / full, flick, drag to dismiss) - Section grows variant=card (Settings; off the Inspector filter + deep links) and variant=panel (collapsible chevron); the default legacy markup is byte-identical - from the design review page's kit: Menu, Toast, Checkbox (list pick only), SearchField, Slider; tokens --segment-on --axis-x/y/z --on-live --shadow-thumb; light values = the page's - $lib/ui/roving.js + $lib/ui/sheetSnap.js (pure) with unit tests; Icon map + 13 names - routes/kit: dev or VITE_UI_KIT=1 builds only (the import is dropped otherwise); every primitive in every state, ?theme=dark|light|custom|green|bit8|contrast, live token readout and a contrast table (all pairs >= 4.5:1 in dark, light, custom) - e2e ui-kit (51 checks): tokens per theme, Toggle, Segmented, Tabs, Chips, Sheet detents / flick / dismiss, PropRow -> DragRow scrub / Shift / type / arrows / Esc; check:tokens CLEAN list covers every new primitive; svelte-check unchanged (318 / 47) Co-Authored-By: Claude Opus 5.5 (1M context) --- scripts/check-tokens.cjs | 8 +- src/components/ui/Badge.svelte | 89 +++ src/components/ui/Button.svelte | 225 ++++++ src/components/ui/Checkbox.svelte | 99 +++ src/components/ui/Chips.svelte | 120 ++++ src/components/ui/EmptyState.svelte | 73 ++ src/components/ui/Icon.svelte | 26 + src/components/ui/Menu.svelte | 166 +++++ src/components/ui/NavRow.svelte | 147 ++++ src/components/ui/PropRow.svelte | 187 +++++ src/components/ui/SearchField.svelte | 142 ++++ src/components/ui/Section.svelte | 137 +++- src/components/ui/Segmented.svelte | 139 ++++ src/components/ui/SettingRow.svelte | 162 +++++ src/components/ui/Sheet.svelte | 303 +++++++++ src/components/ui/Slider.svelte | 65 ++ src/components/ui/Tabs.svelte | 161 +++++ src/components/ui/Toast.svelte | 135 ++++ src/components/ui/Toggle.svelte | 91 +++ src/components/ui/WindowChrome.svelte | 273 ++++++++ src/components/ui/kit/KitPage.svelte | 946 ++++++++++++++++++++++++++ src/components/ui/kit/kitData.js | 30 + src/lib/themes.js | 6 + src/lib/ui/roving.js | 35 + src/lib/ui/sheetSnap.js | 68 ++ src/routes/kit/+page.js | 3 + src/routes/kit/+page.svelte | 37 + src/styles/tokens.css | 67 +- tests/e2e/ui-kit.test.cjs | 258 +++++++ tests/unit/roving.test.js | 45 ++ tests/unit/sheetSnap.test.js | 53 ++ 31 files changed, 4263 insertions(+), 33 deletions(-) create mode 100644 src/components/ui/Badge.svelte create mode 100644 src/components/ui/Button.svelte create mode 100644 src/components/ui/Checkbox.svelte create mode 100644 src/components/ui/Chips.svelte create mode 100644 src/components/ui/EmptyState.svelte create mode 100644 src/components/ui/Menu.svelte create mode 100644 src/components/ui/NavRow.svelte create mode 100644 src/components/ui/PropRow.svelte create mode 100644 src/components/ui/SearchField.svelte create mode 100644 src/components/ui/Segmented.svelte create mode 100644 src/components/ui/SettingRow.svelte create mode 100644 src/components/ui/Sheet.svelte create mode 100644 src/components/ui/Slider.svelte create mode 100644 src/components/ui/Tabs.svelte create mode 100644 src/components/ui/Toast.svelte create mode 100644 src/components/ui/Toggle.svelte create mode 100644 src/components/ui/WindowChrome.svelte create mode 100644 src/components/ui/kit/KitPage.svelte create mode 100644 src/components/ui/kit/kitData.js create mode 100644 src/lib/ui/roving.js create mode 100644 src/lib/ui/sheetSnap.js create mode 100644 src/routes/kit/+page.js create mode 100644 src/routes/kit/+page.svelte create mode 100644 tests/e2e/ui-kit.test.cjs create mode 100644 tests/unit/roving.test.js create mode 100644 tests/unit/sheetSnap.test.js diff --git a/scripts/check-tokens.cjs b/scripts/check-tokens.cjs index 8caac792..02e06f11 100644 --- a/scripts/check-tokens.cjs +++ b/scripts/check-tokens.cjs @@ -27,15 +27,19 @@ const COMPONENTS = path.join(ROOT, 'src', 'components'); 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/SettingRow.svelte', - 'src/components/ui/SettingsCard.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' diff --git a/src/components/ui/Badge.svelte b/src/components/ui/Badge.svelte new file mode 100644 index 00000000..ae0704e3 --- /dev/null +++ b/src/components/ui/Badge.svelte @@ -0,0 +1,89 @@ + + + + {#if dot || STATUS.includes(tone)}{/if} + {#if children}{@render children()}{:else}{text}{/if} + + + diff --git a/src/components/ui/Button.svelte b/src/components/ui/Button.svelte new file mode 100644 index 00000000..42e2772e --- /dev/null +++ b/src/components/ui/Button.svelte @@ -0,0 +1,225 @@ + + +{#snippet inner()} + {#if icon}{/if} + {#if !iconOnly} + {#if children}{@render children()}{:else}{text}{/if} + {/if} + {#if iconRight}{/if} + {#if count !== undefined && count !== null && count !== '' && count !== 0}{:else if dot}{/if} +{/snippet} + +{#if href && !disabled} + {@render inner()} +{:else} + +{/if} + + diff --git a/src/components/ui/Checkbox.svelte b/src/components/ui/Checkbox.svelte new file mode 100644 index 00000000..a10c0a64 --- /dev/null +++ b/src/components/ui/Checkbox.svelte @@ -0,0 +1,99 @@ + + + + + + + + diff --git a/src/components/ui/Chips.svelte b/src/components/ui/Chips.svelte new file mode 100644 index 00000000..ae59eb4f --- /dev/null +++ b/src/components/ui/Chips.svelte @@ -0,0 +1,120 @@ + + +
+ {#each options as o (o.value)} + + {/each} +
+ + diff --git a/src/components/ui/EmptyState.svelte b/src/components/ui/EmptyState.svelte new file mode 100644 index 00000000..81cb0f8c --- /dev/null +++ b/src/components/ui/EmptyState.svelte @@ -0,0 +1,73 @@ + + +
+ {#if icon}{/if} +

{title}

+ {#if description}

{description}

{/if} + {#if action} +
{@render action()}
+ {:else if actionLabel && onaction} +
+ {/if} +
+ + diff --git a/src/components/ui/Icon.svelte b/src/components/ui/Icon.svelte index bb74f53a..6fce9f40 100644 --- a/src/components/ui/Icon.svelte +++ b/src/components/ui/Icon.svelte @@ -6,18 +6,23 @@ import { Archive, ArrowDownToLine, + Bell, Box, Boxes, Brush, Camera, + Check, // W8b: `MAP[name] ?? Box` means an unmapped name renders a SQUARE, silently — // which is what the toolbar's reorder controls had been drawing since they // shipped, two identical boxes where a direction was meant to be. Any name a // menu passes has to exist here. ChevronDown, + ChevronLeft, + ChevronRight, ChevronUp, Combine, Copy, + Ellipsis, Download, ExternalLink, Eye, @@ -37,14 +42,21 @@ HardDrive, History, Image, + Inbox, Info, + LayoutGrid, Lock, + List, Magnet, + Minus, Music, Package, PackageOpen, Pencil, PenTool, + Pin, + PinOff, + Play, Plus, Radar, Redo2, @@ -57,6 +69,7 @@ Sparkles, Spline, StickyNote, + Sun, Trash2, Undo2, Ungroup, @@ -73,14 +86,19 @@ const MAP = { archive: Archive, 'arrow-down-to-line': ArrowDownToLine, + bell: Bell, box: Box, boxes: Boxes, brush: Brush, camera: Camera, + check: Check, 'chevron-down': ChevronDown, + 'chevron-left': ChevronLeft, + 'chevron-right': ChevronRight, 'chevron-up': ChevronUp, combine: Combine, copy: Copy, + ellipsis: Ellipsis, download: Download, 'external-link': ExternalLink, eye: Eye, @@ -100,14 +118,21 @@ 'hard-drive': HardDrive, history: History, image: Image, + inbox: Inbox, info: Info, + 'layout-grid': LayoutGrid, lock: Lock, + list: List, magnet: Magnet, + minus: Minus, music: Music, package: Package, 'package-open': PackageOpen, pencil: Pencil, 'pen-tool': PenTool, + pin: Pin, + 'pin-off': PinOff, + play: Play, plus: Plus, radar: Radar, 'redo-2': Redo2, @@ -120,6 +145,7 @@ sparkles: Sparkles, spline: Spline, 'sticky-note': StickyNote, + sun: Sun, 'trash-2': Trash2, 'undo-2': Undo2, ungroup: Ungroup, diff --git a/src/components/ui/Menu.svelte b/src/components/ui/Menu.svelte new file mode 100644 index 00000000..adc1f185 --- /dev/null +++ b/src/components/ui/Menu.svelte @@ -0,0 +1,166 @@ + + + + + diff --git a/src/components/ui/NavRow.svelte b/src/components/ui/NavRow.svelte new file mode 100644 index 00000000..93ce135c --- /dev/null +++ b/src/components/ui/NavRow.svelte @@ -0,0 +1,147 @@ + + +{#snippet inner()} + {#if icon} + + {/if} + + + {label} + {#if dot}(new){/if} + {#if badge}{/if} + + {#if description}{description}{/if} + + {#if value}{value}{/if} + +{/snippet} + +{#if href && !disabled} + {@render inner()} +{:else} + +{/if} + + diff --git a/src/components/ui/PropRow.svelte b/src/components/ui/PropRow.svelte new file mode 100644 index 00000000..4acbff2f --- /dev/null +++ b/src/components/ui/PropRow.svelte @@ -0,0 +1,187 @@ + + +
+ {#if labelFor || (valueBox && id)} + + {:else} + {label} + {/if} + {#if slider} + + {:else if control} +
{@render control()}
+ {/if} + {#if valueBox} +
+ +
+ {/if} +
+ + diff --git a/src/components/ui/SearchField.svelte b/src/components/ui/SearchField.svelte new file mode 100644 index 00000000..f2c4bcb3 --- /dev/null +++ b/src/components/ui/SearchField.svelte @@ -0,0 +1,142 @@ + + + + + diff --git a/src/components/ui/Section.svelte b/src/components/ui/Section.svelte index ad447aea..44025792 100644 --- a/src/components/ui/Section.svelte +++ b/src/components/ui/Section.svelte @@ -4,7 +4,18 @@ // while $inspectorFilter is non-empty every section force-renders its // content (so hidden rows are searchable), matches the query against its // rendered TEXT, and hides itself when nothing matches. + // + // 38 R3 (SPEC §2 Section): two redesign variants beside the legacy look (the DEFAULT — + // every existing caller renders byte-identical until its lane opts in): + // variant="card" Settings: uppercase header + optional scope badge + ONE card whose + // rows are split by 1px dividers. Never collapses, and is NOT wired to + // the Inspector's filter or deep links (a Settings card named "Grid" + // must not answer — and clear — the Inspector's "Grid" request). + // variant="panel" Inspector / panels: collapsible uppercase header with a chevron and + // an optional badge; same filter + deep-link + persisted collapse as legacy. import { inspectorFilter, inspectorScrollTo } from '../../stores/appStore'; + import Icon from './Icon.svelte'; + import Badge from './Badge.svelte'; /** * P6: `aliases` are OLD deep-link names this section still answers to. A section's @@ -12,15 +23,18 @@ * menus, other components and suites — the 21-G1 rule is that the word may change and * the identifier may not, so a rename lists what it used to be called instead of * hunting every caller (and silently missing one). - * @type {{label?: string, aliases?: string[], collapsible?: boolean, open?: boolean, children?: any}} + * @type {{label?: string, aliases?: string[], collapsible?: boolean, open?: boolean, variant?: 'legacy'|'card'|'panel', badge?: string, children?: any}} */ let { label = '', aliases = [], collapsible = true, open = $bindable(true), + variant = 'legacy', + badge = '', children = null } = $props(); + const isCard = $derived(variant === 'card'); const LS = typeof localStorage !== 'undefined' ? localStorage : null; // persisted collapse, keyed by the section label (static per instance — a @@ -37,7 +51,7 @@ /** @type {any} */ let root = $state(null); let match = $state(true); $effect(() => { - const q = $inspectorFilter.trim().toLowerCase(); + const q = isCard ? '' : $inspectorFilter.trim().toLowerCase(); if (!q) { match = true; return; @@ -46,7 +60,7 @@ match = (label + ' ' + (root?.textContent ?? '')).toLowerCase().includes(q); }); - const filtering = $derived($inspectorFilter.trim().length > 0); + const filtering = $derived(!isCard && $inspectorFilter.trim().length > 0); const showContent = $derived(filtering ? true : !collapsible || (open && !collapsed)); // 16-Q2: a menu deep link ("More snapping settings…") names a section — expand it @@ -56,7 +70,7 @@ const request = $inspectorScrollTo; // a request is either "Grid" or "Camera:Saved views" (section:sub-anchor) const [wanted, anchor] = String(request ?? '').split(':'); - if (!request || (wanted !== label && !aliases.includes(wanted))) return; + if (isCard || !request || (wanted !== label && !aliases.includes(wanted))) return; collapsed = false; try { LS?.setItem('inspector:sec:' + label, 'open'); @@ -105,6 +119,37 @@ }); +{#if variant === 'card'} +
+
+

{label}

+ {#if badge}{/if} +
+
+ {@render children?.()} +
+
+{:else if variant === 'panel'} +
+ {#if collapsible && !filtering} + + {:else} +
+ {label} + {#if badge}{/if} +
+ {/if} + {#if showContent} +
+ {@render children?.()} +
+ {/if} +
+{:else}
{#if collapsible && !filtering}
+{/if} + + diff --git a/src/components/ui/Segmented.svelte b/src/components/ui/Segmented.svelte new file mode 100644 index 00000000..0eeef142 --- /dev/null +++ b/src/components/ui/Segmented.svelte @@ -0,0 +1,139 @@ + + +
+ {#each options as o, i (o.value)} + + {/each} +
+ + diff --git a/src/components/ui/SettingRow.svelte b/src/components/ui/SettingRow.svelte new file mode 100644 index 00000000..f43becc5 --- /dev/null +++ b/src/components/ui/SettingRow.svelte @@ -0,0 +1,162 @@ + + +
+
+
+ {#if labelFor} + + {:else} + {label} + {/if} + {#if badge}{/if} +
+ {#if desc} +

{@render desc()}

+ {:else if description} +

{description}

+ {/if} +
+ {#if children} +
{@render children()}
+ {/if} + {#if extra} +
{@render extra()}
+ {/if} +
+ + diff --git a/src/components/ui/Sheet.svelte b/src/components/ui/Sheet.svelte new file mode 100644 index 00000000..777fc178 --- /dev/null +++ b/src/components/ui/Sheet.svelte @@ -0,0 +1,303 @@ + + + (viewportH = window.innerHeight)} /> + +{#if open} + {#if modal} + + {/if} +
+
+ + {#if header} + {@render header()} + {:else if title} +
+

{title}

+ {#if dismissible} + + {/if} +
+ {/if} +
+
+ {@render children?.()} +
+
+{/if} + + diff --git a/src/components/ui/Slider.svelte b/src/components/ui/Slider.svelte new file mode 100644 index 00000000..e9049861 --- /dev/null +++ b/src/components/ui/Slider.svelte @@ -0,0 +1,65 @@ + + +
+ + {#if readout}{/if} +
+ + diff --git a/src/components/ui/Tabs.svelte b/src/components/ui/Tabs.svelte new file mode 100644 index 00000000..9b910005 --- /dev/null +++ b/src/components/ui/Tabs.svelte @@ -0,0 +1,161 @@ + + +
+
+
+ {#each tabs as t, i (t.id)} + + {/each} +
+ {#if actions}
{@render actions()}
{/if} +
+ {#if panel && value} +
+ {@render panel(value)} +
+ {/if} +
+ + diff --git a/src/components/ui/Toast.svelte b/src/components/ui/Toast.svelte new file mode 100644 index 00000000..0757636f --- /dev/null +++ b/src/components/ui/Toast.svelte @@ -0,0 +1,135 @@ + + +{#if more > 0} + +{:else} +
+ + {message} + {#if repeat > 1}×{repeat}{/if} + {#if actionLabel && onaction} + + {/if} + {#if ondismiss} + + {/if} +
+{/if} + + diff --git a/src/components/ui/Toggle.svelte b/src/components/ui/Toggle.svelte new file mode 100644 index 00000000..398b01e6 --- /dev/null +++ b/src/components/ui/Toggle.svelte @@ -0,0 +1,91 @@ + + + + + diff --git a/src/components/ui/WindowChrome.svelte b/src/components/ui/WindowChrome.svelte new file mode 100644 index 00000000..a33256ed --- /dev/null +++ b/src/components/ui/WindowChrome.svelte @@ -0,0 +1,273 @@ + + +
+
+ {#if onback} + + {/if} + {#if icon && size === 'panel' && !onback} + + {/if} +

{title}

+ {#if hasCount}{count}{/if} + {#if actions}
{@render actions()}
{/if} + {#if onpopout} + + {/if} + {#if onpin} + + {/if} + {#if onclose} + + {/if} +
+ {#if body} +
+ {@render children?.()} +
+ {#if footer} +
{@render footer()}
+ {/if} + {/if} +
+ + diff --git a/src/components/ui/kit/KitPage.svelte b/src/components/ui/kit/KitPage.svelte new file mode 100644 index 00000000..d9870cff --- /dev/null +++ b/src/components/ui/kit/KitPage.svelte @@ -0,0 +1,946 @@ + + +{#snippet st(/** @type {string} */ label, /** @type {import('svelte').Snippet} */ body)} +
+ {@render body()} + {label} +
+{/snippet} + +
+
+
+

UI kit

+ roadmap 38 · SPEC §2 primitives · src/components/ui +
+ applyKitTheme(/** @type {string} */ (v))} + data-testid="kit-theme" + /> +
+ +
+ +
+
TokensSPEC §1 · resolved in this theme
+
+ {#each SWATCHES as t (t)} +
+ + {t}{values[t] ?? ''} +
+ {/each} +
+ + + + {#each contrast as c (c.fg + c.bg)} + + + + + + + {/each} + +
TextOnUsed forRatio
{c.fg}{c.bg}{c.what}= 4.5 ? 'ok' : 'bad'} text={c.ratio.toFixed(2) + ':1'} />
+
+ + +
+
WindowChromesize = modal · panel · tool · mobile nav bar
+
+ + {#snippet actions()}{/snippet} +

Modal · 56px · 18px title · one action + close

+
+ +

Panel · 48px · icon, title, pin, close

+
+ +

Tool · 40px · count

+
+ +

Panel, pinned, with pop-out

+
+ +

Mobile nav bar: ‹ Back · Title · Close

+
+
+ +

With the Settings footer

+ {#snippet footer()} + + Changes save automatically + + {/snippet} +
+
+
+
+ + +
+
Tabsunderline · optional count · arrows move + select
+ + {#snippet actions()}{/snippet} + {#snippet panel(active)}

Showing {active}

{/snippet} +
+
+ {#snippet hoverTabs()} + + {/snippet} + {#snippet focusTabs()} + + {/snippet} + {@render st('Hover (Toasts)', hoverTabs)} + {@render st('Focus (Grid)', focusTabs)} +
+
+ + +
+
Segmented2 to 4 exclusive options
+
+ {#snippet segText()} + + {/snippet} + {#snippet segIcons()} + + {/snippet} + {@render st('Text', segText)} + {@render st('Icons', segIcons)} +
+ {#snippet segFull()} + + {/snippet} + {@render st('Full width (mobile / wide row)', segFull)} +
+ {#snippet segStates()} + + {/snippet} + {#snippet segDisabled()} + + {/snippet} + {@render st('Selected · hover · disabled option', segStates)} + {@render st('Disabled group', segDisabled)} +
+
+ + +
+
Chips5+ options, presets, filters
+ {#snippet chipSingle()} + + {/snippet} + {#snippet chipMulti()} + + {/snippet} + {#snippet chipStates()} + + {/snippet} + {#snippet chipSmall()} + + {/snippet} + {@render st('Single choice', chipSingle)} + {@render st('Filter, multi-select, counts', chipMulti)} + {@render st('Selected · hover · focus · disabled', chipStates)} + {@render st('Small', chipSmall)} +
+ + +
+
Toggle40 × 24 desktop · 51 × 31 touch
+
+ {#snippet t1()}{/snippet} + {#snippet t2()}{/snippet} + {#snippet t3()}{/snippet} + {#snippet t4()}{/snippet} + {#snippet t5()}{/snippet} + {#snippet t6()}{/snippet} + {#snippet t7()}{/snippet} + {@render st('Off', t1)} + {@render st('On', t2)} + {@render st('Hover', t3)} + {@render st('Focus', t4)} + {@render st('Disabled', t5)} + {@render st('Disabled on', t6)} + {@render st('Touch 51×31', t7)} +
+
+ {#snippet c1()}{/snippet} + {#snippet c2()}{/snippet} + {#snippet c3()}{/snippet} + {#snippet c4()}{/snippet} + {@render st('Checkbox: list pick only', c1)} + {@render st('Unpicked', c2)} + {@render st('Some', c3)} + {@render st('Disabled', c4)} +
+
+ + +
+
Buttonone primary per view · live only for Play
+
+ {#snippet b1()}{/snippet} + {#snippet b2()}{/snippet} + {#snippet b3()}{/snippet} + {#snippet b4()}{/snippet} + {#snippet b5()}{/snippet} + {@render st('Primary', b1)} + {@render st('Hover', b2)} + {@render st('Focus', b3)} + {@render st('Disabled', b4)} + {@render st('Small + icon', b5)} +
+
+ {#snippet b6()}{/snippet} + {#snippet b7()}{/snippet} + {#snippet b8()}{/snippet} + {#snippet b9()}{/snippet} + {#snippet b10()}{/snippet} + {#snippet b11()}{/snippet} + {#snippet b12()}{/snippet} + {@render st('Secondary', b6)} + {@render st('Outline', b7)} + {@render st('Outline hover', b8)} + {@render st('Ghost', b9)} + {@render st('Warn text', b10)} + {@render st('Danger', b11)} + {@render st('Menu trigger', b12)} +
+
+ {#snippet i1()}
+
+ + +
+
Badgescope, status, count
+
+ + + + + + + + + + +
+
+ + + + + +
+
SettingRowlabel + one sentence left, control right
+
+ + + + + + + + v + '%'} onchange={(v) => (sfx = v)} /> + + + {#snippet desc()}Export the current theme as .theme.json, edit the colours, then load it back.{/snippet} + + + + + {#snippet desc()}Normally shown only on your first visit. Open it now{/snippet} + + + + + + + + + + {#snippet extra()} +
+ + +
+ {/snippet} +
+
+
+ + +
+
PropRowwraps DragRow; same props and events
+
+ (exposure = v)} + onscrubstart={() => scrubs.start++} + onscrubend={() => scrubs.end++} + /> + + {#snippet control()} +
+ (pos.x = v)} /> + (pos.y = v)} /> + (pos.z = v)} /> +
+ {/snippet} +
+ + {#snippet control()} + {#each SKY_GROUND as c, i (i)} + + {/each} + {/snippet} + + (softness = v)} /> + + +
+

+ Exposure {exposure.toFixed(2)} · scrubs started {scrubs.start}, ended {scrubs.end} · position {pos.x.toFixed(2)}, + {pos.y.toFixed(2)}, {pos.z.toFixed(2)} +

+

Drag a value sideways (Shift = fine, Ctrl = snap), click to type, ↑↓ step, Esc restores. Long labels wrap; mixed shows a dash; disabled cannot scrub.

+
+ + +
+
NavRowlabel, value, chevron
+
+ + + + + + + +
+
+ + +
+
Sectionsettings card · collapsible panel
+
+ + v + '%'} /> + +
+
+
+ +

Collapsible header for the Inspector and panels (state persists per label).

+
+
+

Closed by default.

+
+
+
+ + +
+
EmptyStatewhat goes here + one action
+
+ +
+
+ +
+
+ + +
+
Sheetmobile · peek / half / full · drag to dismiss
+
+ + + + +
+

Sheet: {sheetOpen ? 'open at ' + sheetDetent : 'closed'}

+

Drag the handle; a flick moves one detent; below peek it closes. Handle: ↑↓ step, Enter cycles; Esc closes.

+
+ + +
+
Menucontext, Add and viewport menus share it
+ (lastMenu = it.label ?? '')} + items={[ + { label: 'Add', icon: 'plus', submenu: true }, + { label: 'Undo', icon: 'undo-2', shortcut: 'Ctrl+Z' }, + { label: 'Redo', icon: 'redo-2', shortcut: 'Ctrl+Y', disabled: true }, + { separator: true }, + { section: 'Tools & view' }, + { label: 'Snapping', icon: 'magnet', value: 'Off', submenu: true }, + { label: "What's new", icon: 'sparkles', dot: true }, + { separator: true }, + { label: 'Clear scene…', icon: 'trash-2', warn: true } + ]} + /> +

Chosen: {lastMenu || '—'}

+
+ + +
+
Toastmax 3, then "+N more"
+
+ + + + +
+
+
+

Last action: {lastAction || '—'}

+
+ + +
+ +
+ + + +
+
+
+ + diff --git a/src/components/ui/kit/kitData.js b/src/components/ui/kit/kitData.js new file mode 100644 index 00000000..a1a57553 --- /dev/null +++ b/src/components/ui/kit/kitData.js @@ -0,0 +1,30 @@ +// 38 R3 — sample DATA for /kit that is not UI colour: a custom theme written with only the +// pre-38 (legacy) .theme.json keys — so the kit proves a custom theme restyles every +// primitive through the derivation in styles/tokens.css — and scene colours a swatch row +// shows. Kept out of KitPage.svelte so that file stays token-clean (check:tokens CLEAN). + +/** a .theme.json as users wrote them before 38: legacy keys only, no redesign tokens */ +export const SAMPLE_CUSTOM_THEME = { + name: 'Kit sample (legacy keys only)', + tokens: { + '--surface-deep': '#120f1c', + '--surface-deep-rgb': '18 15 28', + '--surface': '#1a1528', + '--surface-rgb': '26 21 40', + '--surface-2': '#241d38', + '--surface-3': '#2f2648', + '--field': '#150f22', + '--hover': '#352a55', + '--text': '#f1ecff', + '--text-2': '#d8cff3', + '--muted': '#aa9dd0', + '--border': '#3d3263', + '--accent': '#b47af2', + '--accent-2': '#9a5ade', + '--accent-fill': '#7f3fd0', + '--on-accent': '#ffffff' + } +}; + +/** scene colours (user data, not chrome) for the PropRow swatch demo */ +export const SKY_GROUND = ['#ffffff', '#4a4f59']; diff --git a/src/lib/themes.js b/src/lib/themes.js index cc705c17..50a0a7fd 100644 --- a/src/lib/themes.js +++ b/src/lib/themes.js @@ -35,9 +35,11 @@ export const REDESIGN_TOKENS = [ '--text-faint', '--accent-soft', '--accent-muted', + '--segment-on', '--accent-soft-text', '--accent-text', '--live', + '--on-live', '--warn-text', '--danger', '--on-danger', @@ -45,6 +47,9 @@ export const REDESIGN_TOKENS = [ '--badge-text', '--control-off', '--knob', + '--axis-x', + '--axis-y', + '--axis-z', '--surface-hover', '--surface-active', '--scrim' @@ -59,6 +64,7 @@ export const DERIVED_TOKENS = [ '--text-faint', '--accent-soft', '--accent-muted', + '--segment-on', '--accent-soft-text', '--accent-text', '--warn-text', diff --git a/src/lib/ui/roving.js b/src/lib/ui/roving.js new file mode 100644 index 00000000..b4614452 --- /dev/null +++ b/src/lib/ui/roving.js @@ -0,0 +1,35 @@ +// 38 R3 — keyboard movement for the redesign's single-choice primitives (Segmented, Tabs). +// +// WAI-ARIA radiogroup / tablist: ONE stop in the tab order (the selected item), and the +// arrow keys move — and, for both of ours, SELECT, because a segmented control and a tab +// strip both act on selection ("automatic activation"). Disabled items are skipped and the +// ends wrap. Pure, imports nothing: tests/unit/roving.test.js. + +/** keys this module answers; anything else is the caller's */ +export const ROVING_KEYS = ['ArrowRight', 'ArrowDown', 'ArrowLeft', 'ArrowUp', 'Home', 'End']; + +/** + * The index a key moves to, or -1 when it does not move (not a roving key, nothing enabled). + * @param {number} current index of the item that has focus (or the selected one) + * @param {string} key KeyboardEvent.key + * @param {boolean[]} disabled one flag per item + * @param {'horizontal'|'vertical'|'both'} [orientation] which arrows move ('both' for radiogroups) + * @returns {number} + */ +export function rovingIndex(current, key, disabled, orientation = 'both') { + const n = disabled.length; + if (!n || disabled.every(Boolean)) return -1; + const fwd = orientation === 'horizontal' ? ['ArrowRight'] : orientation === 'vertical' ? ['ArrowDown'] : ['ArrowRight', 'ArrowDown']; + const back = orientation === 'horizontal' ? ['ArrowLeft'] : orientation === 'vertical' ? ['ArrowUp'] : ['ArrowLeft', 'ArrowUp']; + /** first enabled index walking from `start` by `step` (wrapping), inclusive of start */ + const walk = (/** @type {number} */ start, /** @type {number} */ step) => { + for (let i = 0, j = start; i < n; i++, j = (j + step + n) % n) if (!disabled[j]) return j; + return -1; + }; + if (key === 'Home') return walk(0, 1); + if (key === 'End') return walk(n - 1, -1); + const from = current >= 0 && current < n ? current : 0; + if (fwd.includes(key)) return walk((from + 1) % n, 1); + if (back.includes(key)) return walk((from - 1 + n) % n, -1); + return -1; +} diff --git a/src/lib/ui/sheetSnap.js b/src/lib/ui/sheetSnap.js new file mode 100644 index 00000000..addc4a93 --- /dev/null +++ b/src/lib/ui/sheetSnap.js @@ -0,0 +1,68 @@ +// 38 R3 — where a mobile bottom Sheet comes to rest (SPEC §2 Sheet, §6 mobile). +// +// A sheet has DETENTS (peek / half / full: heights in px for the current viewport) and is +// dragged by its grab handle. On release it goes to the detent the gesture MEANT, which is +// not always the nearest one: a flick carries it one detent in the flick's direction, and a +// drag (or flick) below the lowest detent dismisses it. Pure, imports nothing: +// tests/unit/sheetSnap.test.js. + +/** px/ms past which a release counts as a flick (≈ 500 px/s, the platform feel) */ +export const FLICK_VELOCITY = 0.5; +/** below this fraction of the LOWEST detent's height a slow release dismisses */ +export const DISMISS_FRACTION = 0.5; + +/** + * Detent heights for a viewport. + * @param {number} viewportH window.innerHeight + * @param {{peek?: number, topInset?: number}} [opts] peek height in px (default 148), the gap + * kept above a FULL sheet (default 48, so the app's top bar stays reachable) + * @returns {{peek: number, half: number, full: number}} + */ +export function detentHeights(viewportH, opts = {}) { + const full = Math.max(0, viewportH - (opts.topInset ?? 48)); + const half = Math.min(full, Math.round(viewportH * 0.5)); + const peek = Math.min(half, opts.peek ?? 148); + return { peek, half, full }; +} + +/** + * The detent a released drag settles on. + * @param {{height: number, velocity: number, heights: Record, detents: string[], dismissible?: boolean}} g + * height = the sheet's height at release; velocity = px/ms, POSITIVE = moving DOWN (shrinking); + * detents = the allowed names (any order); dismissible = may it close (default true) + * @returns {string} a detent name, or 'closed' + */ +export function snapDetent({ height, velocity, heights, detents, dismissible = true }) { + const order = detents.filter((d) => Number.isFinite(heights[d])).sort((a, b) => heights[a] - heights[b]); + if (!order.length) return dismissible ? 'closed' : ''; + const lowest = heights[order[0]]; + if (velocity > FLICK_VELOCITY) { + // flick DOWN: the first detent below where the finger let go, else close + const below = [...order].reverse().find((d) => heights[d] < height - 1); + return below ?? (dismissible ? 'closed' : order[0]); + } + if (velocity < -FLICK_VELOCITY) { + // flick UP: the first detent above, else the top one + return order.find((d) => heights[d] > height + 1) ?? order[order.length - 1]; + } + if (dismissible && height < lowest * DISMISS_FRACTION) return 'closed'; + let best = order[0]; + for (const d of order) if (Math.abs(heights[d] - height) < Math.abs(heights[best] - height)) best = d; + return best; +} + +/** + * The next detent up (+1) or down (-1) from `current` — the keyboard / handle-tap path. + * Down from the lowest closes when dismissible; up from the top stays. + * @param {string} current @param {1|-1} dir @param {string[]} detents @param {Record} heights + * @param {boolean} [dismissible] + * @returns {string} + */ +export function stepDetent(current, dir, detents, heights, dismissible = true) { + const order = detents.filter((d) => Number.isFinite(heights[d])).sort((a, b) => heights[a] - heights[b]); + const i = order.indexOf(current); + if (i < 0) return order[0] ?? current; + if (dir > 0) return order[Math.min(order.length - 1, i + 1)]; + if (i === 0) return dismissible ? 'closed' : current; + return order[i - 1]; +} diff --git a/src/routes/kit/+page.js b/src/routes/kit/+page.js new file mode 100644 index 00000000..1c58c40b --- /dev/null +++ b/src/routes/kit/+page.js @@ -0,0 +1,3 @@ +// 38 R3: the UI kit (every redesign primitive in every state). Prerendered like every route +// (adapter-static); the page itself only loads the kit in dev or a VITE_UI_KIT=1 build. +export const prerender = true; diff --git a/src/routes/kit/+page.svelte b/src/routes/kit/+page.svelte new file mode 100644 index 00000000..3fecdb36 --- /dev/null +++ b/src/routes/kit/+page.svelte @@ -0,0 +1,37 @@ + + + + UI kit · theprototype.app + + + +{#if load} + {#await load then mod} + + {/await} +{:else} +

The UI kit is only available in development builds. Open the app

+{/if} + + diff --git a/src/styles/tokens.css b/src/styles/tokens.css index b7cba9ae..22b4aff1 100644 --- a/src/styles/tokens.css +++ b/src/styles/tokens.css @@ -50,22 +50,28 @@ --text-muted: #98a1b3; /* descriptions (>= 4.5:1 on surface-2) */ --text-faint: #8b94a7; /* section headers, counts, hints */ --accent-soft: #22304a; /* selected nav item / chip fill */ - --accent-muted: #2a3550; /* secondary button, segmented thumb */ + --accent-muted: #2a3550; /* secondary button fill */ + --segment-on: #2b3a58; /* the selected segment's thumb */ --accent-soft-text: #e8f0ff; /* text on accent-soft / accent-muted */ --accent-text: #7fb0ff; /* links, ghost buttons */ --live: #f05a2a; /* ONLY Play, record, live/streaming */ + --on-live: #ffffff; --warn-text: #f0a07a; /* reset + soft-destructive text actions */ - --danger: #d43c3c; /* destructive confirm buttons (white on it 4.7:1) */ + --danger: #d23b3b; /* destructive confirm buttons (white on it 4.8:1) */ --on-danger: #ffffff; --badge-bg: #1f2b42; /* scope badges */ --badge-text: #9fb8e6; --control-off: #3a4256; /* toggle track when off */ + --axis-x: #f0625a; /* X / Y / Z axis labels on transform rows (both themes) */ + --axis-y: #74c655; + --axis-z: #5b97f5; --knob: #ffffff; /* toggle knob */ --surface-hover: rgb(255 255 255 / 0.06); /* ghost / icon button / row hover */ --surface-active: rgb(255 255 255 / 0.1); --scrim: rgb(5 7 12 / 0.55); /* behind a Sheet or a modal */ --shadow-window: 0 16px 48px rgb(0 0 0 / 0.45), 0 2px 6px rgb(0 0 0 / 0.3); --shadow-knob: 0 1px 2px rgb(0 0 0 / 0.35); + --shadow-thumb: 0 1px 2px rgb(0 0 0 / 0.18); /* the selected segment */ /* radii (SPEC §1: 6 / 8 / 10 / 12-14 / 999) and --font-ui / --font-ui-mono are in app.css `@theme static` — one name for the CSS var and the utility (rounded-card, font-ui) */ @@ -124,41 +130,47 @@ --border: #262d3b; --surface-2: #1b212d; --accent: #3b7cf0; + /* FILLED primary buttons: white on #3b7cf0 is 3.9:1, under SPEC §8's 4.5 — the review + page's answer is #2f6fe0 (4.7:1); #3b7cf0 stays for rings, toggles and sliders */ + --accent-fill: #2f6fe0; } /* ---- LIGHT ---- */ :root[data-theme='light'] { - --bg-app: #eef0f4; + /* the design review page's light values (same roles as dark) */ + --bg-app: #e8ecf2; --surface-1: #ffffff; - --surface-inset: #f0f2f5; - --border-strong: #c9cfd9; - --border-input: #d3d8e0; - --text-muted: #586174; - --text-faint: #646d80; - --accent-soft: #e3ecfd; - --accent-muted: #d6e3fb; - --accent-soft-text: #1a3f8f; - --accent-text: #1d4ed8; - --live: #e2531f; - --warn-text: #b4451a; - --danger: #c53030; - --badge-bg: #e4ecfb; - --badge-text: #2b4f8f; - --control-off: #c4cad6; - --surface-hover: rgb(15 23 42 / 0.05); - --surface-active: rgb(15 23 42 / 0.09); - --scrim: rgb(15 23 42 / 0.35); - --shadow-window: 0 16px 48px rgb(15 23 42 / 0.16), 0 2px 6px rgb(15 23 42 / 0.08); + --surface-inset: #edf0f5; + --border-strong: #c2c9d5; + --border-input: #cfd5df; + --text-muted: #566072; + --text-faint: #5f6a7c; + --accent-soft: #e0eafc; + --accent-muted: #dfe8f8; + --segment-on: #ffffff; + --accent-soft-text: #17305e; + --accent-text: #1d55c0; + --live: #e4501d; + --warn-text: #a5441a; + --danger: #c93232; + --badge-bg: #e3ebf8; + --badge-text: #284a84; + --control-off: #c6cdd8; + --surface-hover: rgb(20 30 50 / 0.06); + --surface-active: rgb(20 30 50 / 0.1); + --scrim: rgb(20 26 36 / 0.42); + --shadow-window: 0 12px 32px rgb(24 32 48 / 0.18), 0 2px 6px rgb(24 32 48 / 0.1); --shadow-knob: 0 1px 3px rgb(15 23 42 / 0.3); } /* light's legacy block (theme.css) defines the five for the phase-89 remaps; the redesign scope states its own */ :root[data-theme='light'] .tp-ui { - --text: #141a24; - --text-2: #2f3747; - --border: #e1e5eb; - --surface-2: #f7f8fa; - --accent: #2563eb; + --text: #141922; + --text-2: #2b3342; + --border: #dde2ea; + --surface-2: #f5f7fa; + --accent: #2563d9; + --accent-fill: #2563d9; /* white on it 5.3:1 */ } /* ---- EXOTIC + CUSTOM themes: derive from the legacy tokens they carry ---- @@ -174,6 +186,7 @@ --text-faint: var(--muted, #8b94a7); --accent-soft: color-mix(in srgb, var(--accent, #3b7cf0) 24%, var(--surface, #151a24)); --accent-muted: color-mix(in srgb, var(--accent, #3b7cf0) 34%, var(--surface, #151a24)); + --segment-on: color-mix(in srgb, var(--accent, #3b7cf0) 40%, var(--surface, #151a24)); --accent-soft-text: var(--text, #e6e9ef); --accent-text: color-mix(in srgb, var(--accent, #3b7cf0) 55%, var(--text, #e6e9ef)); --warn-text: var(--ink-warn, #f0a07a); diff --git a/tests/e2e/ui-kit.test.cjs b/tests/e2e/ui-kit.test.cjs new file mode 100644 index 00000000..75572677 --- /dev/null +++ b/tests/e2e/ui-kit.test.cjs @@ -0,0 +1,258 @@ +// 38 R2 + R3: the redesign tokens and the UI-kit primitives, driven on the dev-only /kit page +// (no app boot, no peers). What it pins: +// - TOKENS: the default dark theme leaves the five legacy-colliding names (--text --text-2 +// --border --accent --surface-2) UNDEFINED on :root — the ~400 per-site fallbacks that keep +// the default look unchanged — while inside `.tp-ui` they carry the SPEC values; light has +// its own; a custom .theme.json with legacy keys only DERIVES every redesign token; every +// text pair in the kit's contrast table is >= 4.5:1 in dark, light and that custom theme. +// - Toggle (aria-pressed, click + Space, disabled), Segmented (radiogroup, ONE tab stop, +// arrows move + select, wrap, skip disabled), Tabs (tablist, automatic activation, panel +// wiring, disabled skipped), Chips (single + multi). +// - Sheet: open at a detent, handle keys, Escape, a slow drag settles on the NEAREST detent, +// a fast flick moves ONE detent, a drag below peek dismisses. +// - PropRow passes DragRow through untouched: scrub (with its start/end bracket), Shift = +// fine, click-to-type, Esc reverts, and the slider drives the same onchange. +const h = require('./helpers.cjs'); + +const KIT = h.URL.replace(/\/?$/, '/') + 'kit'; + +h.run(async () => { + const browser = await h.launch(); + const ctx = await browser.newContext({ ignoreHTTPSErrors: true, viewport: { width: 1440, height: 900 } }); + const page = await ctx.newPage(); + /** @type {string[]} */ + page.__errors = []; + page.on('pageerror', (e) => page.__errors.push(e.message)); + + /** @param {string} theme */ + async function open(theme) { + await page.goto(KIT + '?theme=' + theme, { waitUntil: 'networkidle', timeout: 90000 }); + await page.waitForSelector('#kit-contrast tbody tr', { timeout: 60000 }); + await page.waitForTimeout(300); + } + /** resolved custom properties on :root and on the kit's .tp-ui root */ + const tokens = (/** @type {string[]} */ names) => + page.evaluate((names) => { + const root = getComputedStyle(document.documentElement); + const ui = getComputedStyle(/** @type {Element} */ (document.querySelector('.kit'))); + /** @type {Record} */ + const out = {}; + for (const n of names) out[n] = { root: root.getPropertyValue(n).trim(), ui: ui.getPropertyValue(n).trim() }; + return out; + }, names); + const lowContrast = () => + page.$$eval('#kit-contrast tbody tr', (rows) => + rows.filter((r) => Number(r.getAttribute('data-ratio')) < 4.5).map((r) => r.textContent?.replace(/\s+/g, ' ').trim()) + ); + + // ---------------- tokens ---------------- + await open('dark'); + let t = await tokens(['--text', '--border', '--accent', '--surface-2', '--text-2', '--bg-app', '--surface-1', '--accent-fill']); + h.check(['--text', '--border', '--accent', '--surface-2', '--text-2'].every((n) => t[n].root === ''), 'dark: the five colliding names stay UNDEFINED on :root (legacy fallbacks keep the default look)'); + h.check(t['--text'].ui === '#e6e9ef' && t['--accent'].ui === '#3b7cf0' && t['--surface-2'].ui === '#1b212d', `dark: .tp-ui carries the SPEC values (${t['--text'].ui} ${t['--accent'].ui} ${t['--surface-2'].ui})`); + h.check(t['--bg-app'].root === '#0b0e14' && t['--surface-1'].root === '#151a24', 'dark: the other SPEC tokens are global'); + h.check(t['--accent-fill'].ui === '#2f6fe0', `dark: filled buttons use #2f6fe0 inside the scope (${t['--accent-fill'].ui})`); + h.check(t['--accent-fill'].root === '#2563eb', 'dark: the legacy --accent-fill outside the scope is unchanged'); + let low = await lowContrast(); + h.check(low.length === 0, 'dark: every kit text pair >= 4.5:1' + (low.length ? ' — ' + low.join(' | ') : '')); + + await open('light'); + t = await tokens(['--text', '--surface-2', '--bg-app', '--surface-1']); + h.check(t['--text'].root === '#111827' && t['--surface-2'].root === '#e5e7eb', 'light: the phase-89 legacy values on :root are untouched'); + h.check(t['--text'].ui === '#141922' && t['--surface-2'].ui === '#f5f7fa' && t['--surface-1'].root === '#ffffff', 'light: the redesign values inside .tp-ui'); + low = await lowContrast(); + h.check(low.length === 0, 'light: every kit text pair >= 4.5:1' + (low.length ? ' — ' + low.join(' | ') : '')); + + await open('custom'); + t = await tokens(['--surface-1', '--bg-app', '--surface-inset', '--text-muted', '--accent', '--accent-soft']); + h.check(t['--surface-1'].ui === '#1a1528' && t['--bg-app'].ui === '#120f1c' && t['--surface-inset'].ui === '#150f22', 'custom (legacy keys only): surfaces derive from --surface / --surface-deep / --field'); + h.check(t['--accent'].ui === '#b47af2' && t['--text-muted'].ui === '#aa9dd0', 'custom: its own accent and muted reach the scope'); + const softBg = await page.evaluate(() => { + const el = document.createElement('i'); + el.style.background = 'var(--accent-soft)'; + document.querySelector('.kit')?.appendChild(el); + const c = getComputedStyle(el).backgroundColor; + el.remove(); + return c; + }); + h.check(!/^rgba?\(0, 0, 0(, 0)?\)$/.test(softBg) && softBg !== 'rgb(34, 48, 74)', `custom: --accent-soft is mixed from ITS accent, not the dark literal (${softBg})`); + low = await lowContrast(); + h.check(low.length === 0, 'custom: every kit text pair >= 4.5:1' + (low.length ? ' — ' + low.join(' | ') : '')); + const pressedChip = await page.$eval('#kit-chips [aria-pressed="true"]', (el) => getComputedStyle(el).borderColor); + h.check(pressedChip === 'rgb(180, 122, 242)', `custom: a selected chip paints in the custom accent (${pressedChip})`); + + await open('dark'); + const saved = await page.evaluate(() => localStorage.getItem('theme')); + h.check(saved !== 'custom-kit' && saved !== 'custom', 'the kit never writes the app theme it previews'); + + // ---------------- Toggle ---------------- + const tg = page.locator('[data-testid="kit-toggle-off"]'); + h.check((await tg.getAttribute('aria-pressed')) === 'false', 'Toggle starts off (aria-pressed=false)'); + await tg.click(); + h.check((await tg.getAttribute('aria-pressed')) === 'true', 'Toggle: click turns it on'); + await tg.focus(); + await page.keyboard.press('Space'); + h.check((await tg.getAttribute('aria-pressed')) === 'false', 'Toggle: Space turns it off'); + const dis = page.getByRole('button', { name: 'Disabled off' }); + await dis.click({ force: true }); + h.check((await dis.getAttribute('aria-pressed')) === 'false', 'Toggle: disabled ignores a click'); + const size = await tg.boundingBox(); + h.check(!!size && Math.round(size.width) === 40 && Math.round(size.height) === 24, `Toggle is 40x24 on desktop (${size?.width}x${size?.height})`); + + // ---------------- Segmented ---------------- + const seg = page.locator('[data-testid="kit-seg-text"]'); + h.check((await seg.getAttribute('role')) === 'radiogroup', 'Segmented is a radiogroup'); + const radios = seg.getByRole('radio'); + const checkedLabel = async () => (await seg.locator('[aria-checked="true"]').textContent())?.trim(); + await radios.nth(1).click(); + h.check((await checkedLabel()) === 'Always', 'Segmented: click selects'); + h.check((await seg.locator('[tabindex="0"]').count()) === 1, 'Segmented: exactly one tab stop'); + await page.keyboard.press('ArrowRight'); + h.check((await checkedLabel()) === 'Never', 'Segmented: ArrowRight moves AND selects'); + await page.keyboard.press('ArrowRight'); + h.check((await checkedLabel()) === 'Auto', 'Segmented: wraps at the end'); + const focused = await page.evaluate(() => document.activeElement?.textContent?.trim()); + h.check(focused === 'Auto', 'Segmented: focus follows the selection'); + const states = page.getByRole('radiogroup', { name: 'States' }); + await states.getByRole('radio', { name: 'Off' }).click(); + await page.keyboard.press('ArrowRight'); + h.check((await states.locator('[aria-checked="true"]').textContent())?.trim() === 'On', 'Segmented: a disabled option is skipped'); + + // ---------------- Tabs ---------------- + const tabs = page.getByRole('tablist', { name: 'Modules' }); + const panel = page.locator('[data-testid="kit-tab-panel"]'); + await tabs.getByRole('tab', { name: /^User/ }).click(); + h.check((await panel.textContent())?.includes('user') === true, 'Tabs: click switches the panel'); + await page.keyboard.press('ArrowRight'); + h.check((await panel.textContent())?.includes('browse') === true, 'Tabs: ArrowRight activates the next tab'); + await page.keyboard.press('ArrowRight'); + h.check((await panel.textContent())?.includes('core') === true, 'Tabs: the disabled tab is skipped and the strip wraps'); + await page.keyboard.press('End'); + h.check((await panel.textContent())?.includes('browse') === true, 'Tabs: End = last ENABLED tab'); + const wiring = await page.evaluate(() => { + const p = document.querySelector('[role="tabpanel"]#kit-mod-panel-browse'); + const tab = document.getElementById(p?.getAttribute('aria-labelledby') ?? ''); + return !!p && tab?.getAttribute('aria-selected') === 'true' && tab?.getAttribute('aria-controls') === p.id; + }); + h.check(wiring, 'Tabs: tab and tabpanel are wired (aria-controls / aria-labelledby)'); + const vertical = await page.keyboard.press('ArrowDown').then(() => panel.textContent()); + h.check(vertical?.includes('browse') === true, 'Tabs: a horizontal strip ignores ArrowDown'); + + // ---------------- Chips ---------------- + const single = page.locator('[data-testid="kit-chips-single"]'); + await single.getByRole('button', { name: 'Night' }).click(); + h.check((await single.locator('[aria-pressed="true"]').count()) === 1 && (await single.getByRole('button', { name: 'Night' }).getAttribute('aria-pressed')) === 'true', 'Chips: single choice keeps exactly one pressed'); + const multi = page.getByRole('group', { name: 'Object filters' }); + await multi.getByRole('button', { name: /Groups/ }).click(); + h.check((await multi.locator('[aria-pressed="true"]').count()) === 3, 'Chips: multiple adds to the set'); + + // ---------------- Sheet ---------------- + const sheetState = () => page.locator('[data-testid="kit-sheet-state"]').textContent(); + const sheetEl = page.locator('[data-testid="kit-sheet-el"]'); + const sheetHeight = async () => { + const box = await sheetEl.boundingBox(); + return box ? Math.round(900 - box.y) : 0; // visible height above the viewport bottom + }; + await page.click('[data-testid="kit-sheet-half"]'); + await page.waitForTimeout(400); + h.check((await sheetEl.getAttribute('data-detent')) === 'half', 'Sheet opens at half'); + h.check(Math.abs((await sheetHeight()) - 450) <= 2, `Sheet: half = 50% of the viewport (${await sheetHeight()}px)`); + const handle = page.locator('.sh-handle'); + await handle.focus(); + await page.keyboard.press('ArrowUp'); + await page.waitForTimeout(350); + h.check((await sheetEl.getAttribute('data-detent')) === 'full', 'Sheet: ArrowUp on the handle steps to full'); + await page.keyboard.press('ArrowDown'); + await page.keyboard.press('ArrowDown'); + await page.waitForTimeout(350); + h.check((await sheetEl.getAttribute('data-detent')) === 'peek', 'Sheet: ArrowDown steps back down to peek'); + await page.keyboard.press('Escape'); + await page.waitForTimeout(200); + h.check((await sheetState())?.includes('closed') === true, 'Sheet: Escape closes it'); + + /** drag the handle by dy, `steps` moves `pause` ms apart @param {number} dy @param {number} steps @param {number} pause */ + async function dragHandle(dy, steps, pause) { + const b = await handle.boundingBox(); + if (!b) return; + const x = b.x + b.width / 2; + const y = b.y + b.height / 2; + await page.mouse.move(x, y); + await page.mouse.down(); + for (let i = 1; i <= steps; i++) { + await page.mouse.move(x, y + (dy * i) / steps); + if (pause) await page.waitForTimeout(pause); + } + await page.mouse.up(); + await page.waitForTimeout(400); + } + // slow drag from half up to just short of full: the NEAREST detent (full) + await page.click('[data-testid="kit-sheet-half"]'); + await page.waitForTimeout(400); + await dragHandle(-300, 20, 60); + h.check((await sheetEl.getAttribute('data-detent')) === 'full', 'Sheet: a slow drag settles on the nearest detent (half → full)'); + // a fast flick DOWN a short way from full: one detent down, though full is nearer + await dragHandle(80, 3, 0); + h.check((await sheetEl.getAttribute('data-detent')) === 'half', 'Sheet: a fast flick moves one detent (full → half)'); + // a slow drag far below peek: dismissed + await dragHandle(420, 20, 60); + h.check((await sheetState())?.includes('closed') === true, 'Sheet: dragging below peek dismisses it'); + + // ---------------- PropRow → DragRow, unchanged ---------------- + const readout = () => page.locator('[data-testid="kit-prop-readout"]').textContent(); + const box = page.locator('#kit-exposure'); + await box.scrollIntoViewIfNeeded(); // raw mouse events need it on screen (.kit is the scroller) + const vb = await box.boundingBox(); + if (vb) { + const y = vb.y + vb.height / 2; + await page.mouse.move(vb.x + 20, y); + await page.mouse.down(); + for (let i = 1; i <= 10; i++) await page.mouse.move(vb.x + 20 + i * 5, y); + await page.mouse.up(); + } + let r = await readout(); + h.check(/Exposure 1\.50/.test(r ?? ''), `PropRow: a 50px scrub moves the value by step x px through DragRow (${r?.match(/Exposure [\d.]+/)?.[0]})`); + h.check(/scrubs started 1, ended 1/.test(r ?? ''), 'PropRow: onscrubstart / onscrubend pass through (one undo bracket)'); + if (vb) { + const y = vb.y + vb.height / 2; + await page.keyboard.down('Shift'); + await page.mouse.move(vb.x + 20, y); + await page.mouse.down(); + for (let i = 1; i <= 10; i++) await page.mouse.move(vb.x + 20 + i * 5, y); + await page.mouse.up(); + await page.keyboard.up('Shift'); + } + r = await readout(); + h.check(/Exposure 1\.55/.test(r ?? ''), `PropRow: Shift scrubs at a tenth (${r?.match(/Exposure [\d.]+/)?.[0]})`); + await box.click(); + await page.waitForTimeout(100); // DragRow selects all on the next frame, so typing replaces + await page.keyboard.type('2.5'); + r = await readout(); + h.check(/Exposure 2\.50/.test(r ?? ''), 'PropRow: click-to-type applies live'); + // Esc after ARROW steps restores the focus-time value (number-fields' case). NOT after + // typing: DragRow's blur() fires the native `change` with the typed text still in the box, + // which re-commits it — a pre-existing DragRow behaviour (QUESTIONS-38-tokens Q4), locked + // by SPEC §0, so PropRow passes it through as it is. + await page.keyboard.press('Escape'); // leaves 2.50 (see above) + await box.click(); + await page.waitForTimeout(100); + await page.keyboard.press('ArrowUp'); + await page.keyboard.press('ArrowUp'); + r = await readout(); + h.check(/Exposure 2\.52/.test(r ?? ''), `PropRow: ArrowUp steps one minor unit (${r?.match(/Exposure [\d.]+/)?.[0]})`); + await page.keyboard.press('Escape'); + await page.waitForTimeout(100); + r = await readout(); + h.check(/Exposure 2\.50/.test(r ?? ''), `PropRow: Esc restores the value it was focused with (${r?.match(/Exposure [\d.]+/)?.[0]})`); + await page.$eval('#kit-proprow input[type="range"]', (el) => { + const input = /** @type {HTMLInputElement} */ (el); + input.value = '0.75'; + input.dispatchEvent(new Event('input', { bubbles: true })); + }); + r = await readout(); + h.check(/Exposure 0\.75/.test(r ?? ''), 'PropRow: the slider drives the same onchange'); + const mono = await page.$eval('#kit-exposure', (el) => getComputedStyle(el).fontFamily); + h.check(/Plex Mono/.test(mono), `PropRow: the value box is mono (${mono})`); + + h.check(page.__errors.length === 0, 'no page errors on /kit' + (page.__errors.length ? ': ' + page.__errors[0] : '')); + await h.finish(browser); +}); diff --git a/tests/unit/roving.test.js b/tests/unit/roving.test.js new file mode 100644 index 00000000..59b1ff90 --- /dev/null +++ b/tests/unit/roving.test.js @@ -0,0 +1,45 @@ +import { describe, it, expect } from 'vitest'; +import { rovingIndex, ROVING_KEYS } from '../../src/lib/ui/roving.js'; + +// 38 R3: Segmented (radiogroup) and Tabs (tablist) share this. One tab stop, arrows move +// AND select, disabled items are skipped, the ends wrap — and a key it does not own returns +// -1 so the component lets it through (Tab must still leave the group). +describe('rovingIndex', () => { + const none = [false, false, false, false]; + + it('moves forward and back with wrap', () => { + expect(rovingIndex(0, 'ArrowRight', none)).toBe(1); + expect(rovingIndex(3, 'ArrowRight', none)).toBe(0); + expect(rovingIndex(0, 'ArrowLeft', none)).toBe(3); + expect(rovingIndex(2, 'ArrowDown', none)).toBe(3); + expect(rovingIndex(2, 'ArrowUp', none)).toBe(1); + }); + + it('Home / End go to the first / last ENABLED item', () => { + expect(rovingIndex(2, 'Home', [true, false, false, false])).toBe(1); + expect(rovingIndex(0, 'End', [false, false, false, true])).toBe(2); + }); + + it('skips disabled items in both directions', () => { + expect(rovingIndex(0, 'ArrowRight', [false, true, true, false])).toBe(3); + expect(rovingIndex(3, 'ArrowLeft', [false, true, true, false])).toBe(0); + }); + + it('respects orientation (a horizontal tablist ignores up/down)', () => { + expect(rovingIndex(1, 'ArrowDown', none, 'horizontal')).toBe(-1); + expect(rovingIndex(1, 'ArrowRight', none, 'vertical')).toBe(-1); + expect(rovingIndex(1, 'ArrowDown', none, 'vertical')).toBe(2); + }); + + it('returns -1 for foreign keys and an all-disabled group', () => { + expect(rovingIndex(0, 'Tab', none)).toBe(-1); + expect(rovingIndex(0, 'Enter', none)).toBe(-1); + expect(rovingIndex(0, 'ArrowRight', [true, true])).toBe(-1); + expect(rovingIndex(0, 'ArrowRight', [])).toBe(-1); + expect(ROVING_KEYS).not.toContain('Tab'); + }); + + it('an out-of-range current starts from the first item', () => { + expect(rovingIndex(-1, 'ArrowRight', none)).toBe(1); + }); +}); diff --git a/tests/unit/sheetSnap.test.js b/tests/unit/sheetSnap.test.js new file mode 100644 index 00000000..4684f217 --- /dev/null +++ b/tests/unit/sheetSnap.test.js @@ -0,0 +1,53 @@ +import { describe, it, expect } from 'vitest'; +import { detentHeights, snapDetent, stepDetent, FLICK_VELOCITY } from '../../src/lib/ui/sheetSnap.js'; + +// 38 R3: the mobile Sheet's resting place. A slow release goes to the NEAREST detent, a +// flick goes one detent in its direction (even when another is nearer), and below the +// lowest detent a sheet closes — unless it is not dismissible. +describe('sheetSnap', () => { + const heights = detentHeights(844); // the 390x844 phone the redesign is shot at + const all = ['peek', 'half', 'full']; + + it('detent heights for a 844px phone', () => { + expect(heights).toEqual({ peek: 148, half: 422, full: 796 }); + const tiny = detentHeights(200); + expect(tiny.peek).toBeLessThanOrEqual(tiny.half); + expect(tiny.half).toBeLessThanOrEqual(tiny.full); + }); + + it('a slow release settles on the nearest detent', () => { + expect(snapDetent({ height: 400, velocity: 0, heights, detents: all })).toBe('half'); + expect(snapDetent({ height: 700, velocity: 0.1, heights, detents: all })).toBe('full'); + expect(snapDetent({ height: 200, velocity: -0.1, heights, detents: all })).toBe('peek'); + }); + + it('a flick moves one detent in its direction, past a nearer one', () => { + const fast = FLICK_VELOCITY + 0.3; + // released just under full but flicked DOWN: half, not full + expect(snapDetent({ height: 780, velocity: fast, heights, detents: all })).toBe('half'); + // released just above peek, flicked UP: half + expect(snapDetent({ height: 160, velocity: -fast, heights, detents: all })).toBe('half'); + // flicked up from above the top: stays full + expect(snapDetent({ height: 796, velocity: -fast, heights, detents: all })).toBe('full'); + }); + + it('dismisses below the lowest detent; never when not dismissible', () => { + expect(snapDetent({ height: 40, velocity: 0, heights, detents: all })).toBe('closed'); + expect(snapDetent({ height: 120, velocity: 1.2, heights, detents: all })).toBe('closed'); + expect(snapDetent({ height: 40, velocity: 0, heights, detents: all, dismissible: false })).toBe('peek'); + expect(snapDetent({ height: 120, velocity: 1.2, heights, detents: all, dismissible: false })).toBe('peek'); + }); + + it('a sheet limited to some detents only uses those', () => { + expect(snapDetent({ height: 300, velocity: 0, heights, detents: ['half', 'full'] })).toBe('half'); + expect(snapDetent({ height: 120, velocity: 0, heights, detents: ['half', 'full'] })).toBe('closed'); + }); + + it('stepDetent: keyboard / handle tap', () => { + expect(stepDetent('peek', 1, all, heights)).toBe('half'); + expect(stepDetent('full', 1, all, heights)).toBe('full'); + expect(stepDetent('half', -1, all, heights)).toBe('peek'); + expect(stepDetent('peek', -1, all, heights)).toBe('closed'); + expect(stepDetent('peek', -1, all, heights, false)).toBe('peek'); + }); +}); From 53d7720c32ea37ebce673e0c958ef9ff5958592f Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Tue, 6 Oct 2026 02:06:15 +0300 Subject: [PATCH 04/65] [feat] material presets core: payload, starter set, library + peer broadcast (37 R5 phase 1) - materialPresetsCore.js (pure leaf): payload v1 with one normalize at every boundary (IndexedDB, a peer, an imported file), the seven starters (wood, metal, plastic, glass, stone, rubber, neon) tuned for a scene with no IBL and the Quest budget (glass is clear-coat + opacity, no transmission pass), sameLook for the active swatch, uniqueName, capLibrary, swatchBackground - materialPresetMaps.js: tileable procedural wood/stone height fields -> a luminance colour map + a tangent normal map, generated per device, never stored - materialPresets.js: the library in IndexedDB (matpreset:) with save / rename / delete / import / export, the per-person library broadcast on the envpresets shape (matpresets, mesh-wide, wireValidate row, handshake push, dropped at all three disconnect sites), and APPLY through UV4's objectParameters/materials payload: one undo entry + one message per object, D2 sharers handed the new instance - fix: serializeMaterials handed ObjectLoader uuid-KEYED texture/image caches where it walks arrays by length, so every map on the materials path (slot commits, their undo, and undoing a preset over a textured object) came back untextured - storage modal: a Material presets category with its own deleter - vitest materialPresets (30) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/lib/debugHooks.js | 2 + src/lib/materialPresetMaps.js | 162 ++++++++++++ src/lib/materialPresets.js | 357 ++++++++++++++++++++++++++ src/lib/materialPresetsCore.js | 397 +++++++++++++++++++++++++++++ src/lib/materialsHandler.js | 9 +- src/lib/peerHandler.svelte.js | 7 + src/lib/peerScenes.js | 1 + src/lib/storageUsage.js | 28 +- src/lib/wireValidate.js | 2 + tests/unit/materialPresets.test.js | 248 ++++++++++++++++++ 10 files changed, 1210 insertions(+), 3 deletions(-) create mode 100644 src/lib/materialPresetMaps.js create mode 100644 src/lib/materialPresets.js create mode 100644 src/lib/materialPresetsCore.js create mode 100644 tests/unit/materialPresets.test.js diff --git a/src/lib/debugHooks.js b/src/lib/debugHooks.js index 36a4bcc6..9908dcd6 100644 --- a/src/lib/debugHooks.js +++ b/src/lib/debugHooks.js @@ -67,6 +67,8 @@ export const DEBUG_HOOKS = [ ['flowRuntime', () => import('./flowRuntime')], ['history', () => import('./history')], ['materialsHandler', () => import('./materialsHandler')], + // 37 R5: material presets (materialPresets, peerMaterialPresets, applyMaterialPreset, materialPresetsDebug…) + ['materialPresets', () => import('./materialPresets')], ['objectActions', () => import('./objectActions')], ['commandsHandler', () => import('./commandsHandler.svelte')], ['moduleSDK', () => import('./moduleSDK')], diff --git a/src/lib/materialPresetMaps.js b/src/lib/materialPresetMaps.js new file mode 100644 index 00000000..d938a558 --- /dev/null +++ b/src/lib/materialPresetMaps.js @@ -0,0 +1,162 @@ +// 37 R5 — the starter set's PROCEDURAL maps (wood grain, stone), generated on each device. +// +// Why procedural and not shipped images: a starter preset is never stored or broadcast (every +// peer has the same seven), so its maps cost nothing on the wire only if they cost nothing in +// the bundle either. The height fields are pure functions of fixed seeds and TILE (the lattice +// wraps at the map edge), so a repeat across a big floor shows no seam. The image an apply +// sends over the wire is the encoded data-URL, exactly as a user-picked texture travels — a +// peer never has to regenerate anything to agree. +// +// The colour map is a LUMINANCE map (0.62..1.0): the preset's colour tints it, so recolouring +// wood in the Inspector gives a different wood instead of a brown multiply on a brown image. + +/** the map resolution — 256² tiles cleanly and encodes to ~20-40 KB of webp */ +export const MAP_SIZE = 256; + +/** tileable value noise: the lattice wraps at px × py cells + * @param {number} x @param {number} y @param {number} px @param {number} py @param {number} seed */ +function tileNoise(x, y, px, py, seed) { + const xi = Math.floor(x); + const yi = Math.floor(y); + const tx = x - xi; + const ty = y - yi; + const sx = tx * tx * (3 - 2 * tx); + const sy = ty * ty * (3 - 2 * ty); + const wx = (/** @type {number} */ i) => ((i % px) + px) % px; + const wy = (/** @type {number} */ i) => ((i % py) + py) % py; + const a = hash(wx(xi), wy(yi), seed); + const b = hash(wx(xi + 1), wy(yi), seed); + const c = hash(wx(xi), wy(yi + 1), seed); + const d = hash(wx(xi + 1), wy(yi + 1), seed); + const top = a + (b - a) * sx; + const bottom = c + (d - c) * sx; + return top + (bottom - top) * sy; +} + +/** integer hash → [0, 1) (the noise.js avalanche, inlined so this leaf imports nothing) */ +function hash(/** @type {number} */ x, /** @type {number} */ y, /** @type {number} */ seed) { + let h = Math.imul(x | 0, 0x27d4eb2d) ^ Math.imul(y | 0, 0x85ebca6b) ^ Math.imul(seed | 0, 0xc2b2ae35); + h = Math.imul(h ^ (h >>> 15), 0x2545f491); + h ^= h >>> 13; + h = Math.imul(h, 0x27d4eb2d); + h ^= h >>> 16; + return (h >>> 0) / 4294967296; +} + +/** tileable fBm over [0,1)², `cu` × `cv` base cells (integers, so every octave wraps) + * @param {number} u @param {number} v @param {number} cu @param {number} cv @param {number} octaves @param {number} seed */ +function tileFbm(u, v, cu, cv, octaves, seed) { + let sum = 0; + let norm = 0; + let amp = 1; + let pu = cu; + let pv = cv; + for (let i = 0; i < octaves; i++) { + sum += tileNoise(u * pu, v * pv, pu, pv, seed + i * 1013) * amp; + norm += amp; + amp *= 0.5; + pu *= 2; + pv *= 2; + } + return sum / norm; +} + +/** + * The height field of a kind, size×size in [0, 1], tileable in both directions. + * @param {'wood'|'stone'} kind @param {number} [size] + * @returns {Float32Array} + */ +export function heightField(kind, size = MAP_SIZE) { + const out = new Float32Array(size * size); + for (let y = 0; y < size; y++) { + for (let x = 0; x < size; x++) { + const u = x / size; + const v = y / size; + let h; + if (kind === 'wood') { + // planks of grain running along u: rings are a sine of v warped by low-frequency + // noise, plus fine streaks — all periodic in u and v so the tile wraps + const warp = tileFbm(u, v, 2, 2, 3, 11) * 6; + const rings = 0.5 + 0.5 * Math.sin((v * 9 + warp) * Math.PI * 2); + const streak = tileFbm(u, v, 6, 64, 2, 23); // long along u, fine across v + const board = Math.floor(v * 4) % 2 ? 0.04 : 0; // alternate planks a touch + h = rings * 0.55 + streak * 0.4 + board; + // a thin dark seam between planks + const seam = Math.abs(((v * 4) % 1) - 0.5) > 0.485 ? 0.35 : 0; + h = Math.max(0, h - seam); + } else { + // stone: broad blotches, a crisp mid band and grit + const broad = tileFbm(u, v, 3, 3, 4, 41); + const grit = tileFbm(u, v, 48, 48, 2, 59); + const cracks = 1 - Math.min(1, Math.abs(tileFbm(u, v, 6, 6, 3, 71) - 0.5) * 14); + h = broad * 0.65 + grit * 0.35 - cracks * 0.25; + } + out[y * size + x] = Math.min(1, Math.max(0, h)); + } + } + return out; +} + +/** + * RGBA bytes for the luminance colour map and the tangent-space normal map of a height field. + * The normal comes from central differences that WRAP, so it tiles like the height does. + * @param {Float32Array} height @param {number} size @param {number} strength + */ +export function mapBytes(height, size, strength = 3) { + const color = new Uint8ClampedArray(size * size * 4); + const normal = new Uint8ClampedArray(size * size * 4); + const at = (/** @type {number} */ x, /** @type {number} */ y) => + height[((y + size) % size) * size + ((x + size) % size)]; + for (let y = 0; y < size; y++) { + for (let x = 0; x < size; x++) { + const i = (y * size + x) * 4; + const lum = Math.round((0.62 + height[y * size + x] * 0.38) * 255); + color[i] = color[i + 1] = color[i + 2] = lum; + color[i + 3] = 255; + const dx = (at(x + 1, y) - at(x - 1, y)) * strength; + const dy = (at(x, y + 1) - at(x, y - 1)) * strength; + const len = Math.hypot(dx, dy, 1); + normal[i] = Math.round((-dx / len * 0.5 + 0.5) * 255); + // canvas y runs down while uv v runs up (flipY), so the green channel is flipped + normal[i + 1] = Math.round((dy / len * 0.5 + 0.5) * 255); + normal[i + 2] = Math.round((1 / len * 0.5 + 0.5) * 255); + normal[i + 3] = 255; + } + } + return { color, normal }; +} + +/** @type {Map} */ +const cache = new Map(); + +/** + * The encoded maps of a procedural kind (browser only; null elsewhere). Cached per session, + * so swatches, applies and the active-swatch compare all see the same strings. + * @param {'wood'|'stone'} kind + * @returns {{map: string, normalMap: string} | null} + */ +export function proceduralMaps(kind) { + const hit = cache.get(kind); + if (hit) return hit; + if (typeof document === 'undefined') return null; + try { + const size = MAP_SIZE; + const { color, normal } = mapBytes(heightField(kind, size), size, kind === 'wood' ? 2.5 : 4); + const encode = (/** @type {Uint8ClampedArray} */ bytes, /** @type {boolean} */ hq) => { + const canvas = document.createElement('canvas'); + canvas.width = canvas.height = size; + const ctx = canvas.getContext('2d'); + if (!ctx) return ''; + ctx.putImageData(new ImageData(/** @type {any} */ (bytes), size, size), 0, 0); + // a normal map is DATA, so it is encoded at a higher quality than the colour map + const webp = canvas.toDataURL('image/webp', hq ? 0.95 : 0.85); + return webp.startsWith('data:image/webp') ? webp : canvas.toDataURL('image/jpeg', 0.88); + }; + const maps = { map: encode(color, false), normalMap: encode(normal, true) }; + if (!maps.map || !maps.normalMap) return null; + cache.set(kind, maps); + return maps; + } catch { + return null; + } +} diff --git a/src/lib/materialPresets.js b/src/lib/materialPresets.js new file mode 100644 index 00000000..f1ec1ca3 --- /dev/null +++ b/src/lib/materialPresets.js @@ -0,0 +1,357 @@ +// 37 R5 — MATERIAL PRESETS, the runtime half: the library (IndexedDB), the per-peer library +// broadcast, and APPLY. +// +// THE SHAPE IS THE ENVIRONMENT PRESETS' (86), on purpose: a person's library lives in +// IndexedDB under `matpreset:`, is broadcast whole as `{type: 'matpresets', from, +// presets}` on connect and on every change, and a peer's library is shown beside yours until +// they leave. It is mesh-wide (a PERSON's library, not the scene's look — the `envpresets` +// row in peerScenes' list), never saved into a scene and never undone. +// +// APPLYING ONE IS NOT A NEW MESSAGE. A preset becomes an ordinary material replace through +// UV4's `objectParameters / materials` payload — the slot list as three's own material JSON, +// textures riding as data-URLs — so the receiver, undo (the `material` history kind's +// 'materials' param), a late joiner (the object sync carries the material) and an older peer +// all already understand it. ONE undo entry per object, ONE message per object (plus the D2 +// share fan, which `broadcast` in materialsHandler does for every material message). + +import * as THREE from 'three'; +import { writable, get } from 'svelte/store'; +import { objectsGroup, pokeScene } from '../stores/sceneStore'; +import { peers, showToast } from '../stores/appStore'; +import { idbGet, idbPut, idbDelete, idbKeys } from './idb'; +import { recordEntry } from './history'; +import { applyMaterials, materialsPayload } from './materialsHandler'; +import { fanTargets } from './materialSharing'; +import { + MAT_PRESET_KEY, + STARTER_PRESETS, + normalizePreset, + snapshotLook, + sameLook, + uniqueName, + cleanName, + capLibrary, + fieldsOf, + LOOK_DEFAULTS +} from './materialPresetsCore'; +import { proceduralMaps } from './materialPresetMaps'; + +/** this person's saved presets: [{name, payload}] */ +export const materialPresets = writable(/** @type {{name: string, payload: any}[]} */ ([])); +/** each connected peer's library, by peer id */ +export const peerMaterialPresets = writable(/** @type {Record} */ ({})); + +/** @param {string} uuid */ +function objectOf(uuid) { + return get(objectsGroup)?.getObjectByProperty('uuid', uuid); +} + +// ---- resolving a preset into a concrete look ------------------------------------------------ + +/** + * A preset with its procedural maps filled in (a starter's wood/stone), ready to compare or + * apply. Absent maps stay absent. Never mutates the input. + * @param {any} preset + */ +export function resolvePreset(preset) { + const p = normalizePreset(preset); + if (!p) return null; + if (p.procedural) { + const maps = proceduralMaps(p.procedural); + delete p.procedural; + if (maps) { + p.map = maps.map; + p.normalMap = maps.normalMap; + } + } + return p; +} + +/** the starter set, resolved (maps generated once per session) */ +export function starterPresets() { + return STARTER_PRESETS.map((p) => ({ id: p.id, label: p.label, preset: p })); +} + +/** three's texture JSON for a data-URL image (the ObjectLoader input shape) + * @param {string} uuid @param {string} image @param {boolean} color @param {number[]|undefined} repeat */ +function textureJson(uuid, image, color, repeat) { + return { + uuid, + name: '', + image, + mapping: THREE.UVMapping, + channel: 0, + repeat: repeat ?? [1, 1], + offset: [0, 0], + center: [0, 0], + rotation: 0, + // REPEAT, because a preset map is a TILE (and `repeat` would do nothing under clamp) + wrap: [THREE.RepeatWrapping, THREE.RepeatWrapping], + format: THREE.RGBAFormat, + type: THREE.UnsignedByteType, + colorSpace: color ? THREE.SRGBColorSpace : THREE.NoColorSpace, + minFilter: THREE.LinearMipmapLinearFilter, + magFilter: THREE.LinearFilter, + anisotropy: 4, + flipY: true, + generateMipmaps: true, + premultiplyAlpha: false, + unpackAlignment: 4 + }; +} + +/** + * The UV4 `materials` wire payload for a preset worn by `object`. The LOOK comes from the + * preset; what belongs to the OBJECT is kept from its current material: which side renders + * (a double-sided leaf stays double-sided), wireframe, and the D2 share id (on userData of the + * object, not the material, so it is untouched by construction). + * @param {any} preset a RESOLVED preset @param {any} [current] the material being replaced + */ +export function presetWirePayload(preset, current) { + /** @type {any} */ + const material = new (/** @type {any} */ (THREE))[preset.type](); + const fields = fieldsOf(preset.type); + for (const key of fields) { + const value = preset[key] ?? LOOK_DEFAULTS[key]; + if (key in material && typeof value === 'number') material[key] = value; + } + if (material.color) material.color.set(preset.color ?? LOOK_DEFAULTS.color); + if (material.emissive) material.emissive.set(preset.emissive ?? LOOK_DEFAULTS.emissive); + material.transparent = !!preset.transparent; + if ('flatShading' in material) material.flatShading = !!preset.flatShading; + if (current && typeof current.side === 'number') material.side = current.side; + if (current && 'wireframe' in material && current.wireframe) material.wireframe = true; + material.name = preset.label; + material.userData.materialPreset = preset.label; + if (preset.normalMap) material.userData.normalMapDataUrl = preset.normalMap; + const json = material.toJSON(); + delete json.metadata; + material.dispose(); + /** @type {any[]} */ + const textures = []; + /** @type {any[]} */ + const images = []; + if (preset.map && 'map' in material) { + const tex = THREE.MathUtils.generateUUID(); + const img = THREE.MathUtils.generateUUID(); + images.push({ uuid: img, url: preset.map }); + textures.push(textureJson(tex, img, true, preset.repeat)); + json.map = tex; + } + if (preset.normalMap && 'normalMap' in material) { + const tex = THREE.MathUtils.generateUUID(); + const img = THREE.MathUtils.generateUUID(); + images.push({ uuid: img, url: preset.normalMap }); + textures.push(textureJson(tex, img, false, preset.repeat)); + json.normalMap = tex; + const s = preset.normalScale ?? 1; + json.normalScale = [s, s]; + } + return { + materials: [json], + textures, + images, + mapDataUrls: [preset.map && 'map' in material ? preset.map : null] + }; +} + +/** + * Put a preset on ONE object: one undo entry, one `materials` message, and the same new + * instance handed to every object sharing its material (D2 — the receivers get the fanned + * message and re-unify by id). Refuses a material ARRAY (the Inspector's rule: presets edit + * single-material objects; a slot array is the UV editor's business). + * @param {string} uuid @param {any} preset any preset payload (starter or saved) + * @returns {boolean} + */ +export function applyMaterialPreset(uuid, preset) { + const object = objectOf(uuid); + const resolved = resolvePreset(preset); + if (!object?.material || Array.isArray(object.material) || !resolved) return false; + const before = materialsPayload(object); + const after = presetWirePayload(resolved, object.material); + applyMaterials(object, after, false); + const fresh = object.material; + for (const other of fanTargets(uuid)) { + const node = objectOf(other); + if (node && !Array.isArray(node.material)) node.material = fresh; + } + recordEntry({ kind: 'material', uuid, param: 'materials', before: { value: before }, after: { value: after } }); + /** @type {any} */ + const peer = get(peers); + if (peer) { + const message = { type: 'objectParameters', parameter: 'materials', uuid, payload: after }; + peer.send(message); + for (const other of fanTargets(uuid)) peer.send({ ...message, uuid: other }); + } + pokeScene(); + return true; +} + +// ---- reading what an object wears ------------------------------------------------------------ + +/** + * The look an object's material has now, as a preset payload (the "Save current" source and + * the active-swatch test). Maps are read from the data-URLs the app keeps beside them; a map + * the app cannot name (an imported GLB's texture) is left out rather than re-encoded. + * @param {any} material @param {string} [label] + */ +export function lookOfMaterial(material, label = 'Material') { + if (!material || Array.isArray(material)) return null; + return snapshotLook(material, label, { + mapUrl: material.userData?.mapDataUrl ?? null, + normalMapUrl: material.normalMap ? (material.userData?.normalMapDataUrl ?? null) : null + }); +} + +/** Does this material wear this preset? @param {any} material @param {any} preset */ +export function wearsPreset(material, preset) { + const look = lookOfMaterial(material); + const resolved = resolvePreset(preset); + return !!look && !!resolved && sameLook(look, resolved); +} + +// ---- the library (IndexedDB) ----------------------------------------------------------------- + +export async function loadMaterialPresets() { + try { + const keys = await idbKeys(); + const names = keys.filter((/** @type {any} */ key) => String(key).startsWith(MAT_PRESET_KEY)); + const list = []; + for (const key of names) { + const payload = normalizePreset(await idbGet(String(key))); + if (payload) list.push({ name: String(key).slice(MAT_PRESET_KEY.length), payload }); + } + list.sort((a, b) => (a.payload.savedAt ?? 0) - (b.payload.savedAt ?? 0) || a.name.localeCompare(b.name)); + materialPresets.set(list); + } catch { + materialPresets.set([]); + } +} + +/** names in use (yours and the starter set's) @returns {string[]} */ +export function takenNames() { + return [...get(materialPresets).map((p) => p.name), ...STARTER_PRESETS.map((p) => p.label)]; +} + +/** + * Save a payload under a name. A name already in your library is REPLACED only with + * `overwrite`; otherwise the next free name is used ("Wood" -> "Wood 2"), so saving never + * silently loses a preset. + * @param {string} name @param {any} payload @param {{overwrite?: boolean}} [opts] + * @returns {Promise} the name it was saved under + */ +export async function saveMaterialPreset(name, payload, opts = {}) { + const clean = cleanName(name); + if (!clean) return null; + const final = opts.overwrite ? clean : uniqueName(clean, takenNames()); + const stored = normalizePreset({ ...payload, label: final }); + if (!stored) return null; + stored.savedAt = Date.now(); + await idbPut(MAT_PRESET_KEY + final, stored); + await loadMaterialPresets(); + broadcastMaterialPresets(); + return final; +} + +/** Snapshot an object's material and save it. @param {string} uuid @param {string} name */ +export async function saveObjectMaterialAsPreset(uuid, name) { + const look = lookOfMaterial(objectOf(uuid)?.material, name); + if (!look) { + showToast('This object has no single material to save'); + return null; + } + return saveMaterialPreset(name, look); +} + +/** + * Rename one of YOUR presets. Refuses a name already taken (yours or a starter's). + * @param {string} from @param {string} to @returns {Promise} + */ +export async function renameMaterialPreset(from, to) { + const clean = cleanName(to); + const entry = get(materialPresets).find((p) => p.name === from); + if (!entry || !clean) return null; + if (clean === from) return from; + if (takenNames().some((n) => n.toLowerCase() === clean.toLowerCase() && n !== from)) return null; + const payload = { ...entry.payload, label: clean }; + await idbPut(MAT_PRESET_KEY + clean, payload); + await idbDelete(MAT_PRESET_KEY + from); + await loadMaterialPresets(); + broadcastMaterialPresets(); + return clean; +} + +/** @param {string} name */ +export async function deleteMaterialPreset(name) { + await idbDelete(MAT_PRESET_KEY + name); + await loadMaterialPresets(); + broadcastMaterialPresets(); +} + +/** JSON for a .matpreset.json download @param {any} payload */ +export function exportMaterialPreset(payload) { + const out = normalizePreset(payload); + if (out) delete out.savedAt; + return JSON.stringify(out, null, 2); +} + +/** Import a .matpreset.json: saved under its label (next free name) @param {string} json */ +export async function importMaterialPreset(json) { + const payload = normalizePreset(JSON.parse(json)); + if (!payload) throw new Error('not a material preset'); + return saveMaterialPreset(payload.label, payload); +} + +// ---- the library broadcast (the envpresets shape) -------------------------------------------- + +/** the message carrying this peer's whole library (capped like the env library) */ +export function materialPresetsState() { + /** @type {any} */ + const peer = get(peers); + const presets = capLibrary(get(materialPresets).map((p) => { + const { savedAt, ...rest } = p.payload; + return rest; + })); + return { type: 'matpresets', from: peer?.peer?.id, presets }; +} + +export function broadcastMaterialPresets() { + /** @type {any} */ + const peer = get(peers); + if (peer) peer.send(materialPresetsState()); +} + +/** receiver side: every entry is re-validated (a peer's bytes are never trusted) @param {any} data */ +export function applyRemoteMaterialPresets(data) { + if (!data?.from || typeof data.from !== 'string') return; + const list = Array.isArray(data.presets) ? data.presets.map(normalizePreset).filter(Boolean) : []; + peerMaterialPresets.update((map) => ({ ...map, [data.from]: list.slice(0, 200) })); +} + +/** drop a disconnected peer's library @param {string} peerId */ +export function dropPeerMaterialPresets(peerId) { + peerMaterialPresets.update((map) => { + if (!(peerId in map)) return map; + const next = { ...map }; + delete next[peerId]; + return next; + }); +} + +/** test/debug view */ +export function materialPresetsDebug() { + return { + mine: get(materialPresets).map((p) => p.name), + peers: Object.fromEntries(Object.entries(get(peerMaterialPresets)).map(([id, list]) => [id, list.map((p) => p.label)])), + starters: STARTER_PRESETS.map((p) => p.label) + }; +} + +// Boot: load the library once in a browser. peerHandler imports this module at boot, so the +// library is (normally) in the store before the first handshake; a load that lands later +// announces itself, so a peer that connected first still hears it. +if (typeof window !== 'undefined' && typeof indexedDB !== 'undefined') { + loadMaterialPresets().then(() => { + if (get(materialPresets).length) broadcastMaterialPresets(); + }); +} diff --git a/src/lib/materialPresetsCore.js b/src/lib/materialPresetsCore.js new file mode 100644 index 00000000..d15305ea --- /dev/null +++ b/src/lib/materialPresetsCore.js @@ -0,0 +1,397 @@ +// 37 R5 — MATERIAL PRESETS, the pure half (no THREE, no DOM, no stores — unit-tested). +// +// A preset is a named LOOK: the handful of numbers and colours that make a surface read as +// wood or glass, plus up to two image maps. It is deliberately NOT a THREE material's +// toJSON: that carries a uuid, every default the renderer knows about and per-object state +// (side, wireframe, the D2 share id) that belongs to the object, not the look. A preset is +// what you would write on a swatch card. +// +// THE PAYLOAD (version 1) — every field but `version`, `label` and `type` is optional: +// { version: 1, label, type: 'MeshStandardMaterial'|…, color: '#rrggbb' (sRGB), +// roughness, metalness, clearcoat, clearcoatRoughness, transmission, ior, shininess, +// emissive: '#rrggbb', emissiveIntensity, opacity, transparent, flatShading, +// map: dataURL|null, normalMap: dataURL|null, normalScale, repeat: [u, v], +// procedural: 'wood'|'stone' } ← built-ins only: the maps are generated on the device +// +// Applying one goes through the EXISTING `objectParameters / materials` message (UV4's slot +// payload), so the wire gains only the library broadcast (`matpresets`, the `envpresets` +// shape) — an applied preset is an ordinary material replace every peer already understands. + +export const PRESET_VERSION = 1; + +/** the IndexedDB key prefix (beside `envpreset:`) */ +export const MAT_PRESET_KEY = 'matpreset:'; + +/** a library broadcast is capped like the env library's (bytes of JSON, roughly) */ +export const MAT_PRESETS_BROADCAST_CAP = 2_000_000; + +/** longest name we keep — a swatch label, not a paragraph */ +export const NAME_MAX = 40; + +/** The material types a preset may carry: the lit, colourable ones. Normal/Depth/Shadow + * derive their look from geometry or light, so a "preset" of them would be one bit. */ +export const PRESET_TYPES = [ + 'MeshStandardMaterial', + 'MeshPhysicalMaterial', + 'MeshPhongMaterial', + 'MeshLambertMaterial', + 'MeshToonMaterial', + 'MeshBasicMaterial' +]; + +/** numeric look fields, copied where the material type has them */ +export const NUMERIC_FIELDS = [ + 'roughness', + 'metalness', + 'clearcoat', + 'clearcoatRoughness', + 'transmission', + 'ior', + 'shininess', + 'emissiveIntensity', + 'opacity' +]; + +/** + * The starter set. Tuned for this app's lighting, which has NO image-based light until + * 37-hdri lands — so `metal` stops at 0.8 metalness (a fully metallic surface with nothing + * to reflect reads black), and `glass` is a clear-coated see-through surface rather than + * transmission, which costs an extra full-scene render pass the Quest budget cannot pay. + * `wood` and `stone` carry PROCEDURAL maps (generated on each device, never stored). + * @type {any[]} + */ +export const STARTER_PRESETS = [ + { + version: 1, + id: 'wood', + label: 'Wood', + type: 'MeshStandardMaterial', + color: '#b98352', + roughness: 0.72, + metalness: 0, + procedural: 'wood', + normalScale: 0.6 + }, + { + version: 1, + id: 'metal', + label: 'Metal', + type: 'MeshStandardMaterial', + color: '#d4d8de', + roughness: 0.32, + metalness: 0.8 + }, + { + version: 1, + id: 'plastic', + label: 'Plastic', + type: 'MeshPhysicalMaterial', + color: '#e0483e', + roughness: 0.42, + metalness: 0, + clearcoat: 0.5, + clearcoatRoughness: 0.25 + }, + { + version: 1, + id: 'glass', + label: 'Glass', + type: 'MeshPhysicalMaterial', + color: '#d8f0ff', + roughness: 0.05, + metalness: 0, + clearcoat: 1, + clearcoatRoughness: 0.03, + ior: 1.5, + opacity: 0.3, + transparent: true + }, + { + version: 1, + id: 'stone', + label: 'Stone', + type: 'MeshStandardMaterial', + color: '#9b968e', + roughness: 0.92, + metalness: 0, + procedural: 'stone', + normalScale: 1 + }, + { + version: 1, + id: 'rubber', + label: 'Rubber', + type: 'MeshStandardMaterial', + color: '#2d2d31', + roughness: 0.96, + metalness: 0 + }, + { + version: 1, + id: 'neon', + label: 'Neon', + type: 'MeshStandardMaterial', + color: '#1c1020', + roughness: 0.5, + metalness: 0, + emissive: '#ff2bd6', + emissiveIntensity: 2.5 + } +]; + +/** @param {any} name */ +export function cleanName(name) { + return String(name ?? '') + .replace(/[\u0000-\u001f]/g, '') + .trim() + .slice(0, NAME_MAX); +} + +/** '#rrggbb' or null @param {any} value */ +function hexOrNull(value) { + if (typeof value !== 'string') return null; + const m = /^#?([0-9a-f]{6})$/i.exec(value.trim()); + return m ? '#' + m[1].toLowerCase() : null; +} + +/** a finite number clamped to [lo, hi], or undefined @param {any} v @param {number} lo @param {number} hi */ +function num(v, lo, hi) { + const n = Number(v); + if (v === null || v === undefined || v === '' || !Number.isFinite(n)) return undefined; + return Math.min(hi, Math.max(lo, n)); +} + +/** @type {Record} */ +const RANGES = { + roughness: [0, 1], + metalness: [0, 1], + clearcoat: [0, 1], + clearcoatRoughness: [0, 1], + transmission: [0, 1], + ior: [1, 2.333], + shininess: [0, 1000], + emissiveIntensity: [0, 20], + opacity: [0, 1] +}; + +/** a data:image URL or null — presets never carry a remote URL (a library would fetch it) @param {any} v */ +function imageUrlOrNull(v) { + return typeof v === 'string' && /^data:image\/(png|jpeg|webp);base64,/.test(v) ? v : null; +} + +/** + * Validate + normalise a payload from ANYWHERE (IndexedDB, a peer, an imported file). + * Unknown fields are dropped, numbers clamped, colours canonicalised. Null when it is + * not a material preset at all. + * @param {any} input @returns {any|null} + */ +export function normalizePreset(input) { + if (!input || typeof input !== 'object') return null; + const type = PRESET_TYPES.includes(input.type) ? input.type : null; + const label = cleanName(input.label); + if (!type || !label) return null; + /** @type {any} */ + const out = { version: PRESET_VERSION, label, type }; + if (typeof input.id === 'string' && STARTER_PRESETS.some((p) => p.id === input.id)) out.id = input.id; + const color = hexOrNull(input.color); + if (color) out.color = color; + const emissive = hexOrNull(input.emissive); + if (emissive) out.emissive = emissive; + for (const key of NUMERIC_FIELDS) { + const [lo, hi] = RANGES[key]; + const value = num(input[key], lo, hi); + if (value !== undefined) out[key] = value; + } + if (input.transparent === true) out.transparent = true; + if (input.flatShading === true) out.flatShading = true; + const map = imageUrlOrNull(input.map); + if (map) out.map = map; + const normalMap = imageUrlOrNull(input.normalMap); + if (normalMap) out.normalMap = normalMap; + const normalScale = num(input.normalScale, 0, 4); + if (normalScale !== undefined) out.normalScale = normalScale; + if (Array.isArray(input.repeat) && input.repeat.length === 2) { + const u = num(input.repeat[0], 0.01, 100); + const v = num(input.repeat[1], 0.01, 100); + if (u !== undefined && v !== undefined && (u !== 1 || v !== 1)) out.repeat = [u, v]; + } + if (input.procedural === 'wood' || input.procedural === 'stone') out.procedural = input.procedural; + if (Number.isFinite(input.savedAt)) out.savedAt = input.savedAt; + return out; +} + +/** round to 4 decimals so a slider's float noise does not mint a "different" look @param {number} n */ +const r4 = (n) => Math.round(n * 1e4) / 1e4; + +/** + * Snapshot a material-LIKE object (a THREE material, or a plain test double with the same + * fields) as a preset payload. `mapUrl`/`normalMapUrl` are the data-URLs the caller found + * for its maps — the material itself only holds GPU textures. + * @param {any} material @param {string} label + * @param {{mapUrl?: string|null, normalMapUrl?: string|null, procedural?: string|null}} [maps] + */ +export function snapshotLook(material, label, maps = {}) { + /** @type {any} */ + const raw = { version: PRESET_VERSION, label, type: material?.type }; + if (!PRESET_TYPES.includes(raw.type)) raw.type = 'MeshStandardMaterial'; + if (material?.color?.getHexString) raw.color = '#' + material.color.getHexString(); + if (material?.emissive?.getHexString) { + const emissive = '#' + material.emissive.getHexString(); + if (emissive !== '#000000') raw.emissive = emissive; + } + for (const key of NUMERIC_FIELDS) { + if (typeof material?.[key] === 'number') raw[key] = r4(material[key]); + } + // emissiveIntensity without an emissive colour is noise; opacity 1 is the default + if (!raw.emissive) delete raw.emissiveIntensity; + if (raw.opacity === 1) delete raw.opacity; + if (material?.transparent) raw.transparent = true; + if (material?.flatShading) raw.flatShading = true; + if (maps.procedural) raw.procedural = maps.procedural; + else { + if (maps.mapUrl) raw.map = maps.mapUrl; + if (maps.normalMapUrl) raw.normalMap = maps.normalMapUrl; + } + if (material?.normalMap && material?.normalScale && typeof material.normalScale.x === 'number') + raw.normalScale = r4(material.normalScale.x); + const rep = material?.map?.repeat; + if (rep && typeof rep.x === 'number' && (rep.x !== 1 || rep.y !== 1)) raw.repeat = [r4(rep.x), r4(rep.y)]; + return normalizePreset(raw); +} + +/** the fields that make two looks the same (label/id/version are names, not looks) */ +const LOOK_FIELDS = [ + 'type', + 'color', + 'emissive', + ...NUMERIC_FIELDS, + 'transparent', + 'flatShading', + 'procedural', + 'map', + 'normalMap', + 'repeat' +]; + +/** + * Do two payloads describe the same look? Used to light the swatch the selection wears. + * A field one side lacks counts as that type's default, so a snapshot (which reads every + * field its material has) matches a sparse starter preset. The defaults are the ones a + * fresh THREE material has — applyLook writes them for absent fields, so a just-applied + * preset always matches its own snapshot. + * @param {any} a @param {any} b + */ +export function sameLook(a, b) { + if (!a || !b) return false; + for (const key of LOOK_FIELDS) { + const x = withDefault(a, key); + const y = withDefault(b, key); + if (Array.isArray(x) || Array.isArray(y)) { + if (JSON.stringify(x ?? null) !== JSON.stringify(y ?? null)) return false; + } else if (typeof x === 'number' && typeof y === 'number') { + if (Math.abs(x - y) > 1e-3) return false; + } else if ((x ?? null) !== (y ?? null)) return false; + } + return true; +} + +/** @type {Record} the fresh-material defaults applyLook relies on */ +export const LOOK_DEFAULTS = { + color: '#ffffff', + emissive: '#000000', + roughness: 1, + metalness: 0, + clearcoat: 0, + clearcoatRoughness: 0, + transmission: 0, + ior: 1.5, + shininess: 30, + emissiveIntensity: 1, + opacity: 1, + transparent: false, + flatShading: false +}; + +/** which numeric fields each type actually has (so sameLook ignores the rest) */ +const TYPE_FIELDS = { + MeshStandardMaterial: ['roughness', 'metalness', 'emissiveIntensity', 'opacity'], + MeshPhysicalMaterial: [ + 'roughness', + 'metalness', + 'clearcoat', + 'clearcoatRoughness', + 'transmission', + 'ior', + 'emissiveIntensity', + 'opacity' + ], + MeshPhongMaterial: ['shininess', 'emissiveIntensity', 'opacity'], + MeshLambertMaterial: ['emissiveIntensity', 'opacity'], + MeshToonMaterial: ['emissiveIntensity', 'opacity'], + MeshBasicMaterial: ['opacity'] +}; + +/** the numeric fields a type has @param {string} type @returns {string[]} */ +export function fieldsOf(type) { + return /** @type {any} */ (TYPE_FIELDS)[type] ?? []; +} + +/** @param {any} p @param {string} key */ +function withDefault(p, key) { + if (NUMERIC_FIELDS.includes(key) && !fieldsOf(p.type).includes(key)) return undefined; + if (key === 'emissive' && p.type === 'MeshBasicMaterial') return undefined; + if (key === 'emissiveIntensity' && (p.emissive ?? '#000000') === '#000000') return undefined; + return p[key] ?? LOOK_DEFAULTS[key]; +} + +/** + * A name that is free in `taken` — "Wood" → "Wood 2" → "Wood 3". + * @param {string} name @param {string[]} taken + */ +export function uniqueName(name, taken) { + const base = cleanName(name) || 'Material'; + const set = new Set(taken.map((t) => t.toLowerCase())); + if (!set.has(base.toLowerCase())) return base; + for (let i = 2; i < 1000; i++) { + const suffix = ' ' + i; + const candidate = base.slice(0, NAME_MAX - suffix.length) + suffix; + if (!set.has(candidate.toLowerCase())) return candidate; + } + return base; +} + +/** + * Cap a library for the wire: drop presets from the END until the JSON fits. + * @param {any[]} list @param {number} [cap] + */ +export function capLibrary(list, cap = MAT_PRESETS_BROADCAST_CAP) { + let out = list; + while (out.length && JSON.stringify(out).length > cap) out = out.slice(0, out.length - 1); + return out; +} + +/** CSS for a swatch: a lit-sphere look from the numbers alone (no render, no WebGL). + * Roughness widens and dims the highlight, metalness tints it with the base colour, + * emissive adds a glow, opacity shows the checker through. + * @param {any} p @returns {string} a `background` value */ +export function swatchBackground(p) { + const base = p?.color ?? '#cccccc'; + const rough = typeof p?.roughness === 'number' ? p.roughness : p?.type === 'MeshPhongMaterial' ? 0.4 : 0.6; + const metal = typeof p?.metalness === 'number' ? p.metalness : 0; + const spot = Math.round(18 + rough * 40); // highlight radius % + const shine = Math.round((1 - rough) * 85 + 10); // highlight strength % + const tint = metal > 0.5 ? base : '#ffffff'; + const glow = p?.emissive && p.emissive !== '#000000' ? p.emissive : null; + const alpha = typeof p?.opacity === 'number' && p.opacity < 1 ? p.opacity : 1; + const layers = [ + `radial-gradient(circle at 32% 28%, color-mix(in srgb, ${tint} ${shine}%, transparent) 0%, transparent ${spot}%)`, + `radial-gradient(circle at 50% 50%, transparent 55%, color-mix(in srgb, #000 ${Math.round(25 + metal * 25)}%, transparent) 100%)` + ]; + if (glow) layers.push(`radial-gradient(circle at 50% 50%, ${glow} 0%, color-mix(in srgb, ${glow} 40%, transparent) 70%)`); + layers.push( + alpha < 1 + ? `linear-gradient(color-mix(in srgb, ${base} ${Math.round(alpha * 100)}%, transparent), color-mix(in srgb, ${base} ${Math.round(alpha * 100)}%, transparent))` + : `linear-gradient(${base}, ${base})` + ); + return layers.join(', '); +} diff --git a/src/lib/materialsHandler.js b/src/lib/materialsHandler.js index 3dc71c01..59c633e1 100644 --- a/src/lib/materialsHandler.js +++ b/src/lib/materialsHandler.js @@ -132,10 +132,15 @@ registerHistoryKind('material', (entry, state) => { * textures ride as data-URLs exactly as they do in the object message. * @param {any[]} materials @returns {any} */ function serializeMaterials(materials) { + // The meta caches are KEYED BY UUID (Texture/Source.toJSON write `meta.textures[uuid]`), + // while ObjectLoader's parseTextures/parseImages walk an ARRAY by `.length`. Handing it the + // keyed objects themselves (they used to be `[]`s written by key) parsed ZERO textures, so + // every map on this path - a slot commit, its undo, and (37 R5) undoing a material preset + // over a textured object - silently came back untextured. Flatten to arrays here. /** @type {any} */ - const meta = { textures: [], images: [] }; + const meta = { textures: {}, images: {} }; const list = materials.map((material) => material.toJSON(meta)); - return { materials: list, textures: meta.textures, images: meta.images }; + return { materials: list, textures: Object.values(meta.textures), images: Object.values(meta.images) }; } /** diff --git a/src/lib/peerHandler.svelte.js b/src/lib/peerHandler.svelte.js index 6e077821..cef3d1f8 100644 --- a/src/lib/peerHandler.svelte.js +++ b/src/lib/peerHandler.svelte.js @@ -60,6 +60,7 @@ import { applyJointCreate, applyJointDelete, applyJointsSnapshot, sendJoints } f import { applyAnimData, applyAnimPlay, applyAnimationsSnapshot, sendAnimations } from '$lib/animationPreview'; import { applyHandModel, handModelState, dropPeerHandModel } from '$lib/handModels'; import { applyRemoteEnvironment, environmentState, envPresetsState, applyRemoteEnvPresets, dropPeerEnvPresets } from '$lib/environment'; +import { materialPresetsState, applyRemoteMaterialPresets, dropPeerMaterialPresets } from '$lib/materialPresets'; // 37 R5 import { applyRemoteMusic, musicState } from '$lib/sceneMusic'; import { applyRemoteScenePhysics, scenePhysicsState } from '$lib/scenePhysics'; // CO1: where the physical room's origin sits in content coords. The scenephysics @@ -921,6 +922,8 @@ export class PeerConnection { receiveKitMessage(data, conn.peer); } else if(data.type == 'envpresets') { applyRemoteEnvPresets(data); + } else if(data.type == 'matpresets') { + applyRemoteMaterialPresets(data); // 37 R5: a person's material library } else if(data.type == 'geometry') { applyRemoteGeometry(data); } else if(data.type == 'lighttarget') { @@ -1043,6 +1046,7 @@ export class PeerConnection { dropPeerVars(data.peerId); // 21-G4 dropPeerColocation(data.peerId); // CO5 dropPeerEnvPresets(data.peerId); + dropPeerMaterialPresets(data.peerId); dropPeerHandModel(data.peerId); } } else if(data.type == 'getnodes') { @@ -1423,6 +1427,7 @@ export class PeerConnection { } conn.send(handModelState()) conn.send(envPresetsState()) + conn.send(materialPresetsState()) if (getobjects && !holdContent) this.requestFullState(conn) // singleton PUSH, like environmentState/scenePhysicsState above if (!holdContent) conn.send(gameStatePayload()) @@ -1714,6 +1719,7 @@ export class PeerConnection { dropPeerVars(peerId); // 21-G4 dropPeerColocation(peerId); // CO5 dropPeerEnvPresets(peerId); + dropPeerMaterialPresets(peerId); dropPeerHandModel(peerId); if (relay) this.broadcast({ type: 'disconnected', peerId }); checkLocks(); @@ -1750,6 +1756,7 @@ export class PeerConnection { dropPeerVars(peerId); // 21-G4 dropPeerColocation(peerId); // CO5 dropPeerEnvPresets(peerId); + dropPeerMaterialPresets(peerId); dropPeerHandModel(peerId); } } diff --git a/src/lib/peerScenes.js b/src/lib/peerScenes.js index 37c66413..8e32f321 100644 --- a/src/lib/peerScenes.js +++ b/src/lib/peerScenes.js @@ -599,6 +599,7 @@ export function roomsOfSession(map, mine, host) { // a private peer is by construction elsewhere from everybody. It carries no scene // CONTENT and no name: a request is a peer id, a grant is a promise to publish. // 'envpresets' - a PERSON's preset library, keyed by peer, not the scene's sky. +// 'matpresets' - the same for material presets (37 R5). // 'userdata'/'hosts'/'cloud'/'disconnected' - the session itself. // EVERY get* REQUEST. A request is ~40 bytes and asking is never the harm; the REPLY is // where a room is enforced, which is also where `canApply`'s ALWAYS_ALLOWED floor draws diff --git a/src/lib/storageUsage.js b/src/lib/storageUsage.js index f35ddf16..33595a0d 100644 --- a/src/lib/storageUsage.js +++ b/src/lib/storageUsage.js @@ -21,7 +21,8 @@ // Deleting the record behind the app's back leaves the other half claiming a file // that is gone — the dead-pointer shape the project already forbids in `.tp` exports. // `explorer.deleteItem`, `sessions.deleteSession`, `autosave.clearSavedSession`, -// `prefabs.removePrefab`, `environment.deleteEnvPreset`, `userModules +// `prefabs.removePrefab`, `environment.deleteEnvPreset`, +// `materialPresets.deleteMaterialPreset`, `userModules // .removeUserModule`, `sharedLibrary.purgeDeletedItem`, `assetShare // .forgetSharedThumb`, `vrSleeve.clearSlot` are the deleters, and each one keeps its // own index consistent. An UNKNOWN key — a prefix no module in this build claims — is @@ -53,6 +54,7 @@ import { projectManifest, keepableHashes } from './projectManifest'; import { deletedLog, purgeDeletedItem } from './sharedLibrary'; import { prefabs, removePrefab } from './prefabs'; import { envPresets, deleteEnvPreset } from './environment'; +import { deleteMaterialPreset } from './materialPresets'; // 37 R5 import { userModules, removeUserModule } from './userModules'; import { sharedThumbs, forgetSharedThumb } from './assetShare'; import { sleeveSlots, clearSlot } from './vrSleeve'; @@ -184,6 +186,11 @@ export const CATEGORIES = [ label: 'Environment presets', note: 'Lighting and sky presets you saved.' }, + { + key: 'matpresets', + label: 'Material presets', + note: 'Material swatches you saved in the Inspector (the starter set is built in and stores nothing).' + }, { key: 'sleeve', label: 'VR sleeve slots', @@ -204,6 +211,7 @@ const KEY_MANIFEST = 'project:manifest'; const KEY_PREFABS = 'prefabs-v1'; const KEY_MODULES = 'user-modules-v1'; const KEY_PRESET = 'envpreset:'; +const KEY_MATPRESET = 'matpreset:'; // 37 R5 const KEY_SLEEVE = 'vrsleeve-slots-v1'; const KEY_AUTOSAVE = 'latest'; @@ -515,6 +523,21 @@ export async function scanStorage() { continue; } + // ---- material presets (37 R5) --------------------------------------------- + if (key.startsWith(KEY_MATPRESET)) { + const name = key.slice(KEY_MATPRESET.length); + push({ + id: key, + category: 'matpresets', + label: name, + bytes: valueBytes((await safeGet(key)).value), + removable: true, + kind: 'matpreset', + ref: name + }); + continue; + } + // ---- the two STRUCTURE keys ---------------------------------------------- if (key === KEY_INDEX) { push({ @@ -691,6 +714,9 @@ export async function reclaimRow(row) { case 'preset': await deleteEnvPreset(String(row.ref)); return row.bytes; + case 'matpreset': + await deleteMaterialPreset(String(row.ref)); + return row.bytes; case 'module': await removeUserModule(String(row.ref)); return row.bytes; diff --git a/src/lib/wireValidate.js b/src/lib/wireValidate.js index a8cec804..202c3a99 100644 --- a/src/lib/wireValidate.js +++ b/src/lib/wireValidate.js @@ -197,6 +197,8 @@ export const VALIDATORS = { assetchunk: (d) => typeof d.hash === 'string' && Number.isInteger(d.seq), assetfile: (d) => typeof d.hash === 'string', manifest: (d) => !!d.manifest && typeof d.manifest === 'object', + // 37 R5: a person's material-preset library; every entry is re-normalised on arrival + matpresets: (d) => typeof d.from === 'string' && isArray(d.presets) && d.presets.length <= 200, environment: (d) => !!d && typeof d === 'object', atscene: (d) => typeof d.peerId === 'string', disconnected: (d) => typeof d.peerId === 'string', diff --git a/tests/unit/materialPresets.test.js b/tests/unit/materialPresets.test.js new file mode 100644 index 00000000..8ba2763c --- /dev/null +++ b/tests/unit/materialPresets.test.js @@ -0,0 +1,248 @@ +import { describe, it, expect } from 'vitest'; +import { + STARTER_PRESETS, + PRESET_TYPES, + normalizePreset, + snapshotLook, + sameLook, + uniqueName, + capLibrary, + cleanName, + swatchBackground, + NAME_MAX +} from '../../src/lib/materialPresetsCore.js'; +import { heightField, mapBytes } from '../../src/lib/materialPresetMaps.js'; +import { validateWireMessage } from '../../src/lib/wireValidate.js'; + +// 37 R5 — material presets, the pure half: the payload boundary every source (IndexedDB, a +// peer, an imported file) passes through, the look compare that lights the active swatch, and +// the procedural maps the starter set's wood and stone wear. + +/** a material-shaped double (what snapshotLook reads off a THREE material) @param {any} fields */ +function fakeMaterial(fields) { + const color = (/** @type {string} */ hex) => ({ getHexString: () => hex.replace('#', '') }); + return { + type: 'MeshStandardMaterial', + color: color('#ffffff'), + emissive: color('#000000'), + roughness: 1, + metalness: 0, + emissiveIntensity: 1, + opacity: 1, + transparent: false, + ...fields + }; +} + +describe('the starter set', () => { + it('is the seven the brief names, in order', () => { + expect(STARTER_PRESETS.map((p) => p.id)).toEqual(['wood', 'metal', 'plastic', 'glass', 'stone', 'rubber', 'neon']); + }); + + it('every starter survives its own normalisation unchanged in look', () => { + for (const p of STARTER_PRESETS) { + const n = normalizePreset(p); + expect(n, p.id).not.toBeNull(); + expect(sameLook(n, p), p.id).toBe(true); + expect(PRESET_TYPES).toContain(n.type); + } + }); + + it('glass is see-through WITHOUT transmission (the Quest budget: no extra scene pass)', () => { + const glass = STARTER_PRESETS.find((p) => p.id === 'glass'); + expect(glass.transparent).toBe(true); + expect(glass.opacity).toBeLessThan(1); + expect(glass.transmission ?? 0).toBe(0); + }); + + it('neon glows and wood/stone carry procedural maps', () => { + expect(STARTER_PRESETS.find((p) => p.id === 'neon').emissive).not.toBe('#000000'); + expect(STARTER_PRESETS.find((p) => p.id === 'wood').procedural).toBe('wood'); + expect(STARTER_PRESETS.find((p) => p.id === 'stone').procedural).toBe('stone'); + }); +}); + +describe('normalizePreset — the boundary', () => { + it('refuses what is not a preset', () => { + expect(normalizePreset(null)).toBeNull(); + expect(normalizePreset('wood')).toBeNull(); + expect(normalizePreset({ label: 'x', type: 'ShaderMaterial' })).toBeNull(); + expect(normalizePreset({ label: '', type: 'MeshStandardMaterial' })).toBeNull(); + expect(normalizePreset({ label: 'x', type: 'MeshNormalMaterial' })).toBeNull(); + }); + + it('clamps numbers, canonicalises colours, drops unknown fields', () => { + const n = normalizePreset({ + label: ' Hot ', + type: 'MeshStandardMaterial', + color: 'FF0000', + roughness: 7, + metalness: -2, + opacity: NaN, + emissiveIntensity: 1e9, + evil: ' + +
+
+ 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)} +
+ + {#if editing} + + + + + + {/if} +
+ {/each} +
+ {#if editing} +
+ + + {#if !$materialPresets.length} + 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)} +
+ + {#if editing} + + + + {/if} +
+ {/each} +
+ {/each} +
+ + diff --git a/src/lib/materialPresetsCore.js b/src/lib/materialPresetsCore.js index d15305ea..dac311e8 100644 --- a/src/lib/materialPresetsCore.js +++ b/src/lib/materialPresetsCore.js @@ -372,9 +372,15 @@ export function capLibrary(list, cap = MAT_PRESETS_BROADCAST_CAP) { /** CSS for a swatch: a lit-sphere look from the numbers alone (no render, no WebGL). * Roughness widens and dims the highlight, metalness tints it with the base colour, - * emissive adds a glow, opacity shows the checker through. + * emissive adds a glow, opacity lets the swatch's own backdrop show through. * @param {any} p @returns {string} a `background` value */ export function swatchBackground(p) { + return swatchLayers(p).join(', '); +} + +/** the same, as its layer list (top first; the LAST layer is the base colour) + * @param {any} p @returns {string[]} */ +export function swatchLayers(p) { const base = p?.color ?? '#cccccc'; const rough = typeof p?.roughness === 'number' ? p.roughness : p?.type === 'MeshPhongMaterial' ? 0.4 : 0.6; const metal = typeof p?.metalness === 'number' ? p.metalness : 0; @@ -393,5 +399,5 @@ export function swatchBackground(p) { ? `linear-gradient(color-mix(in srgb, ${base} ${Math.round(alpha * 100)}%, transparent), color-mix(in srgb, ${base} ${Math.round(alpha * 100)}%, transparent))` : `linear-gradient(${base}, ${base})` ); - return layers.join(', '); + return layers; } diff --git a/tests/e2e/material-presets.test.cjs b/tests/e2e/material-presets.test.cjs new file mode 100644 index 00000000..711c65cb --- /dev/null +++ b/tests/e2e/material-presets.test.cjs @@ -0,0 +1,373 @@ +// 37 R5 — MATERIAL PRESETS: named looks saved to IndexedDB, shared with peers like the +// environment presets, applied from a swatch row in the Inspector. +// +// What is measured, and why each one is the load-bearing reading: +// - an apply is ONE undo step per object and ONE `objectParameters / materials` message +// (no new wire type for applying: a receiver and an older peer already understand it); +// - UNDO over a TEXTURED object gives the texture back — the reason serializeMaterials had +// to be fixed (it handed ObjectLoader keyed caches where it walks arrays, so the map came +// back gone); the counterfactual is computed in-page on the old shape; +// - a multi-selection is ONE undo; +// - the library survives a reload (IndexedDB), rename refuses a taken name, delete asks; +// - a second peer sees the library and can apply from it, and the applied look reaches the +// first peer; leaving drops the library. +// Screenshots (dark + light) go to the lane's evidence folder. + +const fs = require('fs'); +const path = require('path'); +const h = require('./helpers.cjs'); + +const SHOTS = process.env.MP_SHOTS || '/home/deck/.code/lanes-30/after-37/37-materials'; +const shot = async (page, name) => { + try { + fs.mkdirSync(SHOTS, { recursive: true }); + const panel = page.locator('#material-presets'); + const box = await panel.boundingBox(); + if (box) { + await page.screenshot({ + path: path.join(SHOTS, name), + clip: { x: Math.max(0, box.x - 12), y: Math.max(0, box.y - 60), width: box.width + 24, height: Math.min(560, box.height + 330) } + }); + } + await page.screenshot({ path: path.join(SHOTS, name.replace('.png', '-full.png')) }); + } catch (e) { + console.log('screenshot failed', name, String(e).slice(0, 120)); + } +}; + +/** 1x1 red png — a real one */ +const TINY_PNG = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=='; + +/** a box's material facts @param {any} page @param {string} uuid */ +const matOf = (page, uuid) => + page.evaluate((u) => { + let group = null; + window.__stores.objectsGroup.subscribe((g) => (group = g))(); + const o = group?.getObjectByProperty('uuid', u); + const m = o?.material; + if (!m) return null; + return { + type: m.type, + color: m.color ? m.color.getHexString() : null, + roughness: m.roughness, + metalness: m.metalness, + opacity: m.opacity, + transparent: m.transparent, + emissive: m.emissive ? m.emissive.getHexString() : null, + hasMap: !!m.map, + mapUrl: (m.userData?.mapDataUrl ?? '').slice(0, 30), + hasNormal: !!m.normalMap, + preset: m.userData?.materialPreset ?? null, + name: m.name + }; + }, uuid); + +const pressed = (page) => + page.evaluate(() => + [...document.querySelectorAll('#material-presets .mp-swatch[aria-pressed="true"]')].map((b) => b.getAttribute('data-preset-name')) + ); + +/** poll until `pred` holds; returns the last value either way @param {() => Promise} fn @param {(v: any) => boolean} pred @param {number} ms */ +const waitVal = async (fn, pred, ms) => { + const start = Date.now(); + let last; + while (Date.now() - start < ms) { + last = await fn(); + if (pred(last)) return last; + await new Promise((r) => setTimeout(r, 400)); + } + return last; +}; + +const swatch = (page, kind, name) => page.locator(`#material-presets .mp-swatch[data-preset-kind="${kind}"][data-preset-name="${name}"]`); + +/** create N boxes, return their uuids @param {any} page @param {number} n */ +const makeBoxes = (page, n) => + page.evaluate(async (count) => { + const w = window.__stores; + const group0 = await new Promise((r) => w.objectsGroup.subscribe(r)()); + const before = new Set(group0.children.map((c) => c.uuid)); + for (let i = 0; i < count; i++) w.commandsHandler.sceneCommand('/create Box 1 1 1'); + await new Promise((r) => setTimeout(r, 600)); + const group = await new Promise((r) => w.objectsGroup.subscribe(r)()); + const fresh = group.children.filter((c) => !before.has(c.uuid)); + fresh.forEach((o, i) => { + o.position.set(i * 1.6 - 1, 0.5, 0); + o.material.color.set('#3366cc'); + o.material.roughness = 0.55; + }); + return fresh.map((o) => o.uuid); + }, n); + +/** select (and open the Inspector on) a set @param {any} page @param {string[]} uuids */ +const selectSet = async (page, uuids) => { + await page.evaluate(async (list) => { + const w = window.__stores; + w.objectActions.selectObject(list[0], true); + await new Promise((r) => setTimeout(r, 200)); + if (list.length > 1) w.objectActions.applySelectionSet(list); + }, uuids); + await page.waitForTimeout(700); +}; + +/** wire spy: record what the app sends @param {any} page */ +const spy = (page) => + page.evaluate(async () => { + const peer = await new Promise((r) => window.__stores.peers.subscribe(r)()); + window.__mpSent = []; + if (!peer.__mpWrapped) { + const orig = peer.send.bind(peer); + peer.send = (m) => { + if (m && typeof m === 'object' && window.__mpSent) window.__mpSent.push({ type: m.type, parameter: m.parameter, uuid: m.uuid }); + return orig(m); + }; + peer.__mpWrapped = true; + } + }); +const sent = (page) => page.evaluate(() => window.__mpSent ?? []); + +h.run(async () => { + const browser = await h.launch(); + const A = await h.setupPage(browser, 'A', { storage: { 'inspector:sec:Material': 'open' } }); + await A.page.evaluate(() => localStorage.setItem('inspector:sec:Material', 'open')); + + // ------------------------------------------------------------------ 1. the row + console.log('\n=== 1. the swatch row ==='); + const [b1, b2, b3] = await makeBoxes(A.page, 3); + h.check(!!b1 && !!b2 && !!b3, '1.0 premise: three boxes'); + await selectSet(A.page, [b1]); + await A.page.locator('#material-presets').waitFor({ timeout: 15000 }).catch(() => {}); + const starters = await A.page.evaluate(() => + [...document.querySelectorAll('#material-presets .mp-swatch[data-preset-kind="starter"]')].map((b) => b.getAttribute('data-preset-name')) + ); + h.check( + JSON.stringify(starters) === JSON.stringify(['Wood', 'Metal', 'Plastic', 'Glass', 'Stone', 'Rubber', 'Neon']), + '1.1 the starter set renders in the Material section: ' + starters.join(', ') + ); + h.check((await pressed(A.page)).length === 0, '1.2 a plain box wears none of them (no swatch lit)'); + + // ------------------------------------------------------------------ 2. apply + console.log('\n=== 2. apply = one undo step, one message ==='); + await spy(A.page); + await swatch(A.page, 'starter', 'Wood').click(); + await A.page.waitForTimeout(900); + let m = await matOf(A.page, b1); + h.check(m.type === 'MeshStandardMaterial' && m.color === 'b98352', `2.1 Wood applied: ${m.type} #${m.color}`); + h.check(m.hasMap && m.hasNormal, `2.2 Wood wears its procedural grain + normal map (map ${m.hasMap}, normal ${m.hasNormal})`); + h.check(Math.abs(m.roughness - 0.72) < 1e-3, '2.3 roughness from the preset: ' + m.roughness); + h.check(JSON.stringify(await pressed(A.page)) === '["Wood"]', '2.4 the Wood swatch lights up: ' + JSON.stringify(await pressed(A.page))); + const wire = (await sent(A.page)).filter((x) => x.type === 'objectParameters'); + h.check( + wire.length === 1 && wire[0].parameter === 'materials' && wire[0].uuid === b1, + '2.5 ONE objectParameters/materials message for the one object: ' + JSON.stringify(wire) + ); + h.check(!(await sent(A.page)).some((x) => x.type === 'matpresets'), '2.6 applying sends no library message'); + await shot(A.page, '01-swatches-dark-wood.png'); + + // undo / redo + await A.page.evaluate(() => window.__stores.history.undo()); + await A.page.waitForTimeout(500); + m = await matOf(A.page, b1); + h.check(m.color === '3366cc' && !m.hasMap && Math.abs(m.roughness - 0.55) < 1e-3, `2.7 ONE undo gives the old material back (#${m.color}, map ${m.hasMap}, r ${m.roughness})`); + await A.page.evaluate(() => window.__stores.history.redo()); + await A.page.waitForTimeout(500); + m = await matOf(A.page, b1); + h.check(m.color === 'b98352' && m.hasMap, `2.8 redo puts Wood back (#${m.color}, map ${m.hasMap})`); + + // ------------------------------------------------------------------ 3. undo over a TEXTURE + console.log('\n=== 3. undo over a textured object keeps the texture ==='); + await A.page.evaluate(async ({ u, url }) => { + let group = null; + window.__stores.objectsGroup.subscribe((g) => (group = g))(); + const o = group.getObjectByProperty('uuid', u); + window.__stores.materialsHandler.switchMaterialType(u, 'MeshStandardMaterial', false); + window.__stores.materialsHandler.applyMap(o, url); + await new Promise((r) => setTimeout(r, 400)); + }, { u: b2, url: TINY_PNG }); + m = await matOf(A.page, b2); + h.check(m.hasMap, '3.0 premise: box 2 wears a texture'); + // the counterfactual, measured in-page: the OLD meta shape parses zero textures + const counter = await A.page.evaluate((u) => { + let group = null; + window.__stores.objectsGroup.subscribe((g) => (group = g))(); + const o = group.getObjectByProperty('uuid', u); + const meta = { textures: [], images: [] }; // the pre-fix shape + const json = o.material.toJSON(meta); + const fixed = window.__stores.materialsHandler.materialsPayload(o); + return { oldTextures: meta.textures.length, fixedTextures: fixed.textures.length, fixedImages: fixed.images.length, mapRef: !!json.map }; + }, b2); + h.check( + counter.mapRef && counter.oldTextures === 0 && counter.fixedTextures === 1 && counter.fixedImages === 1, + '3.1 the undo snapshot now CARRIES the texture (old shape: ' + counter.oldTextures + ' textures, fixed: ' + counter.fixedTextures + ')' + ); + await selectSet(A.page, [b2]); + await swatch(A.page, 'starter', 'Metal').click(); + await A.page.waitForTimeout(700); + m = await matOf(A.page, b2); + h.check(!m.hasMap && Math.abs(m.metalness - 0.8) < 1e-3, `3.2 Metal replaced the texture (map ${m.hasMap}, metal ${m.metalness})`); + await A.page.evaluate(() => window.__stores.history.undo()); + await A.page.waitForTimeout(900); + m = await matOf(A.page, b2); + h.check(m.hasMap && m.mapUrl.startsWith('data:image/png'), `3.3 undo brings the texture BACK (map ${m.hasMap}, ${m.mapUrl})`); + + // ------------------------------------------------------------------ 4. multi-select + console.log('\n=== 4. a multi-selection is one undo ==='); + await selectSet(A.page, [b1, b2, b3]); + const note = await A.page.locator('#material-presets .mp-title').textContent(); + h.check(/applies to all 3/.test(note ?? ''), '4.0 the row says it acts on the set: ' + note); + await spy(A.page); + await swatch(A.page, 'starter', 'Glass').click(); + await A.page.waitForTimeout(800); + const glass = await Promise.all([b1, b2, b3].map((u) => matOf(A.page, u))); + h.check( + glass.every((g) => g.type === 'MeshPhysicalMaterial' && g.transparent && g.opacity < 0.5), + '4.1 all three are Glass: ' + glass.map((g) => g.type + '/' + g.opacity).join(', ') + ); + h.check((await sent(A.page)).filter((x) => x.type === 'objectParameters' && x.parameter === 'materials').length === 3, '4.2 one message per object (3)'); + await A.page.evaluate(() => window.__stores.history.undo()); + await A.page.waitForTimeout(800); + const back = await Promise.all([b1, b2, b3].map((u) => matOf(A.page, u))); + h.check( + back[0].color === 'b98352' && back[1].hasMap && back[2].color === '3366cc', + '4.3 ONE undo restores each object\'s own previous look: ' + back.map((g) => g.color + (g.hasMap ? '+map' : '')).join(', ') + ); + + // ------------------------------------------------------------------ 5. save current + console.log('\n=== 5. save current, persist, rename, delete ==='); + await selectSet(A.page, [b3]); + await A.page.evaluate((u) => { + window.__stores.materialsHandler.setObjectColor(u, '#cc2244'); + window.__stores.materialsHandler.setMaterialParam(u, 'roughness', 0.2); + window.__stores.materialsHandler.setMaterialParam(u, 'metalness', 0.4); + }, b3); + await A.page.waitForTimeout(400); + await A.page.locator('#material-preset-save').click(); + const nameField = A.page.locator('#material-preset-name'); + await nameField.waitFor({ timeout: 5000 }); + await nameField.fill('Candy red'); + await nameField.press('Enter'); + await A.page.waitForTimeout(800); + h.check((await swatch(A.page, 'mine', 'Candy red').count()) === 1, '5.1 the saved preset joins the row'); + h.check(JSON.stringify(await pressed(A.page)) === '["Candy red"]', '5.2 ...and lights up for the object it came from'); + const stored = await A.page.evaluate(async () => { + await window.__stores.materialPresets.loadMaterialPresets(); + const list = await new Promise((r) => window.__stores.materialPresets.materialPresets.subscribe(r)()); + return list.map((p) => ({ name: p.name, color: p.payload.color, roughness: p.payload.roughness })); + }); + h.check( + stored.length === 1 && stored[0].color === '#cc2244' && Math.abs(stored[0].roughness - 0.2) < 1e-3, + '5.3 stored in IndexedDB with the look: ' + JSON.stringify(stored) + ); + // a duplicate save does not overwrite: it counts up + await A.page.locator('#material-preset-save').click(); + await nameField.fill('Candy red'); + await nameField.press('Enter'); + await A.page.waitForTimeout(800); + h.check((await swatch(A.page, 'mine', 'Candy red 2').count()) === 1, '5.4 saving a taken name never overwrites (Candy red 2)'); + + await h.freshReload(A); + await A.page.evaluate(() => localStorage.setItem('inspector:sec:Material', 'open')); + const [b4] = await makeBoxes(A.page, 1); + await selectSet(A.page, [b4]); + await A.page.locator('#material-presets').waitFor({ timeout: 15000 }).catch(() => {}); + h.check((await swatch(A.page, 'mine', 'Candy red').count()) === 1, '5.5 the library survives a reload'); + await swatch(A.page, 'mine', 'Candy red').click(); + await A.page.waitForTimeout(600); + m = await matOf(A.page, b4); + h.check(m.color === 'cc2244' && Math.abs(m.metalness - 0.4) < 1e-3, `5.6 a saved preset applies (#${m.color}, metal ${m.metalness})`); + + // rename + await A.page.locator('#material-preset-edit').click(); + await A.page.locator('[data-preset-rename="Candy red 2"]').click(); + await nameField.fill('Wood'); + await nameField.press('Enter'); + await A.page.waitForTimeout(500); + const err = await A.page.locator('#material-presets .mp-error').textContent().catch(() => ''); + h.check(/taken/i.test(err ?? '') && (await swatch(A.page, 'mine', 'Candy red 2').count()) === 1, '5.7 renaming onto a starter name is refused: ' + err); + await nameField.fill('Ruby'); + await nameField.press('Enter'); + await A.page.waitForTimeout(700); + const renamed = await A.page.evaluate(async () => { + const list = await new Promise((r) => window.__stores.materialPresets.materialPresets.subscribe(r)()); + return list.map((p) => p.name).sort(); + }); + h.check(JSON.stringify(renamed) === '["Candy red","Ruby"]', '5.8 rename moved the record: ' + JSON.stringify(renamed)); + await shot(A.page, '02-swatches-dark-edit.png'); + + // delete — asks first + await A.page.locator('[data-preset-delete="Ruby"]').click(); + await A.page.locator('#confirm-dialog-ok').waitFor({ timeout: 5000 }); + await A.page.locator('#confirm-dialog-ok').click(); + await A.page.waitForTimeout(700); + h.check((await swatch(A.page, 'mine', 'Ruby').count()) === 0, '5.9 delete (after the confirm) removes it'); + await A.page.locator('#material-preset-edit').click(); + + // ------------------------------------------------------------------ 6. light theme + console.log('\n=== 6. light theme ==='); + await A.page.evaluate(() => window.__stores.themes.theme.set('light')); + await A.page.waitForTimeout(500); + await shot(A.page, '03-swatches-light.png'); + const ink = await A.page.evaluate(() => { + const t = document.querySelector('#material-presets .mp-label'); + return t ? getComputedStyle(t).color : ''; + }); + h.check(!!ink && ink !== 'rgb(209, 213, 219)', '6.1 labels take the light theme ink: ' + ink); + await A.page.evaluate(() => window.__stores.themes.theme.set('dark')); + + // ------------------------------------------------------------------ 7. two peers + console.log('\n=== 7. a peer sees your library, applies from it, and the look replicates ==='); + const B = await h.setupPage(browser, 'B', { storage: { 'inspector:sec:Material': 'open' } }); + await B.page.evaluate(() => localStorage.setItem('inspector:sec:Material', 'open')); + await h.connect(B, A); + const lib = await waitVal( + () => + B.page.evaluate(async () => { + const map = await new Promise((r) => window.__stores.materialPresets.peerMaterialPresets.subscribe(r)()); + return Object.values(map).flat().map((p) => p.label); + }), + (v) => v.includes('Candy red'), + 15000 + ); + h.check(lib.includes('Candy red'), '7.1 B received A\'s library in the handshake: ' + JSON.stringify(lib)); + await B.page.waitForTimeout(1500); + await selectSet(B.page, [b4]); + await B.page.locator('#material-presets').waitFor({ timeout: 15000 }).catch(() => {}); + h.check((await B.page.locator('#material-presets .mp-peer').count()) === 1, '7.2 B shows a "From A" row'); + await swatch(B.page, 'peer', 'Candy red').click().catch(() => {}); + await swatch(B.page, 'starter', 'Neon').click(); + await B.page.waitForTimeout(500); + await shot(B.page, '04-peer-library-dark.png'); + const onA = await waitVal(() => matOf(A.page, b4), (v) => v && v.emissive === 'ff2bd6', 10000); + h.check(!!onA && onA.emissive === 'ff2bd6', '7.3 B applied Neon and A sees the glow: ' + JSON.stringify(onA && { e: onA.emissive, c: onA.color })); + // A saves a new one -> B hears it without reconnecting + await A.page.evaluate(async () => { + await window.__stores.materialPresets.saveMaterialPreset('Mint', { version: 1, label: 'Mint', type: 'MeshStandardMaterial', color: '#66ddaa', roughness: 0.3 }); + }); + const lib2 = await waitVal( + () => + B.page.evaluate(async () => { + const map = await new Promise((r) => window.__stores.materialPresets.peerMaterialPresets.subscribe(r)()); + return Object.values(map).flat().map((p) => p.label); + }), + (v) => v.includes('Mint'), + 10000 + ); + h.check(lib2.includes('Mint'), '7.4 a new save reaches the peer live: ' + JSON.stringify(lib2)); + // leaving drops the library on the other side + await A.page.evaluate(() => { + let p; + window.__stores.peers.subscribe((x) => (p = x))(); + p.leaveSession(); + }); + const gone = await waitVal( + () => B.page.evaluate(async () => Object.keys(await new Promise((r) => window.__stores.materialPresets.peerMaterialPresets.subscribe(r)())).length), + (v) => v === 0, + 15000 + ); + h.check(gone === 0, '7.5 when A leaves, B drops A\'s library (' + gone + ' left)'); + + h.check(h.pageErrors(A).length === 0, '8.1 no page errors on A: ' + h.pageErrors(A).slice(0, 2).join(' | ')); + h.check(h.pageErrors(B).length === 0, '8.2 no page errors on B: ' + h.pageErrors(B).slice(0, 2).join(' | ')); + await browser.close(); +}); From ff7cc7bdcb16caeaba260a808259c33be53a5ebe Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Tue, 6 Oct 2026 02:12:30 +0300 Subject: [PATCH 10/65] [feat] R14 named workspace layouts (save/apply/rename/delete) - uiLayoutsCore (leaf, vitest): which stored keys are the layout, record normalize/upsert/rename/remove, the onLayoutRestore re-read registry - uiLayouts: capture (open panels + layout keys), apply in four steps (close all -> write keys -> owners re-read -> reopen), no page reload - re-read hooks: dragWindow, docking, bottomDock, windowTabs, the nine dock-family panels, the object list - UI: menu > Layouts popover + Settings > Interface row (one component) - svelte-check 318/47 = v1.25.0 base; e2e suite workspace-layouts OWED Co-Authored-By: Claude Opus 5.5 (1M context) --- src/components/Flow.svelte | 13 + src/components/Menu.svelte | 2 + src/components/editors/AnimationWindow.svelte | 13 + src/components/editors/CodeWorkspace.svelte | 12 + src/components/editors/Explorer.svelte | 13 + src/components/editors/FlowCode.svelte | 12 + src/components/editors/HudEditor.svelte | 13 + src/components/editors/Profiler.svelte | 12 + src/components/editors/ShaderEditor.svelte | 13 + src/components/editors/UvEditor.svelte | 13 + src/components/menu/Controls.svelte | 14 ++ src/components/menu/LayoutsMenu.svelte | 71 ++++++ src/components/menu/Settings.svelte | 2 + src/components/menu/Sidebar.svelte | 6 + src/components/menu/WorkspaceLayouts.svelte | 234 ++++++++++++++++++ .../settings/WorkspaceLayoutsSettings.svelte | 20 ++ src/lib/bottomDock.js | 9 + src/lib/debugHooks.js | 2 + src/lib/docking.js | 67 +++-- src/lib/dragWindow.js | 29 +++ src/lib/uiLayouts.js | 163 ++++++++++++ src/lib/uiLayoutsCore.js | 215 ++++++++++++++++ src/lib/windowTabs.js | 27 ++ src/lib/workspace.js | 9 +- tests/unit/uiLayoutsCore.test.js | 107 ++++++++ 25 files changed, 1072 insertions(+), 19 deletions(-) create mode 100644 src/components/menu/LayoutsMenu.svelte create mode 100644 src/components/menu/WorkspaceLayouts.svelte create mode 100644 src/components/menu/settings/WorkspaceLayoutsSettings.svelte create mode 100644 src/lib/uiLayouts.js create mode 100644 src/lib/uiLayoutsCore.js create mode 100644 tests/unit/uiLayoutsCore.test.js diff --git a/src/components/Flow.svelte b/src/components/Flow.svelte index 278de88e..30f16706 100644 --- a/src/components/Flow.svelte +++ b/src/components/Flow.svelte @@ -1,4 +1,5 @@ + + { + if ($layoutsMenuOpen && e.key === 'Escape') layoutsMenuOpen.set(false); + }} +/> + +{#if $layoutsMenuOpen} +
+
+ Workspace layouts + +
+ +
+{/if} + + diff --git a/src/components/menu/Settings.svelte b/src/components/menu/Settings.svelte index 7b41ad01..f17739bb 100644 --- a/src/components/menu/Settings.svelte +++ b/src/components/menu/Settings.svelte @@ -20,6 +20,7 @@ import LoadingSettings from './settings/LoadingSettings.svelte'; import CheckpointSettings from './settings/CheckpointSettings.svelte'; // 36 B14 import NodeEditorViewSettings from './settings/NodeEditorViewSettings.svelte'; // 36 F10 + import WorkspaceLayoutsSettings from './settings/WorkspaceLayoutsSettings.svelte'; // 37 R14 import SettingRow from './SettingRow.svelte'; import TextSelectionSettings from './settings/TextSelectionSettings.svelte'; // 36 U6 import AvatarSettings from './settings/AvatarSettings.svelte'; // 36-avatars @@ -950,6 +951,7 @@ Bring back any floating window (object list, chat, Explorer, editors) that drifted off-screen or behind the UI + Undo / Redo / Multi-select buttons beside the logo, for touch — no Ctrl+Z or Shift needed. Multi-select adds on tap and boxes on drag, for objects and for mesh vertices, edges and faces. On by default on phones diff --git a/src/components/menu/Sidebar.svelte b/src/components/menu/Sidebar.svelte index 6a1a97ae..29a2f5ca 100644 --- a/src/components/menu/Sidebar.svelte +++ b/src/components/menu/Sidebar.svelte @@ -34,6 +34,8 @@ import { startEditorTour } from '$lib/tours/builtin.js'; import { safeStorage } from '$lib/safeStorage'; import { statsOpen } from '$lib/sceneBudget'; + import { layoutsMenuOpen } from '$lib/uiLayouts'; // 37 R14 + import { PanelsTopLeft } from '@lucide/svelte'; // 37 R14 // 203: redesigned as a compact floating panel — flat list (order preserved, // no boxed group / section headers / vertical bar), a fast fade-in (was a @@ -263,6 +265,10 @@ {#if box.shortcut}{box.shortcut}{/if} {/each} + + diff --git a/src/components/menu/WorkspaceLayouts.svelte b/src/components/menu/WorkspaceLayouts.svelte new file mode 100644 index 00000000..e6a7381d --- /dev/null +++ b/src/components/menu/WorkspaceLayouts.svelte @@ -0,0 +1,234 @@ + + +
+ {#if $uiLayouts.length} +
    + {#each $uiLayouts as layout (layout.id)} +
  • + {#if renaming === layout.id} + { + e.stopPropagation(); + if (e.key === 'Enter') commitRename(); + else if (e.key === 'Escape') renaming = null; + }} + onblur={commitRename} + /> + {:else} + + + + {/if} +
  • + {/each} +
+ {:else} +

No saved layouts yet. Arrange your windows, then save the arrangement under a name.

+ {/if} +
{ + e.preventDefault(); + save(); + }} + > + e.stopPropagation()} + /> + +
+ {#if error}{/if} +
+ + diff --git a/src/components/menu/settings/WorkspaceLayoutsSettings.svelte b/src/components/menu/settings/WorkspaceLayoutsSettings.svelte new file mode 100644 index 00000000..834a9ebc --- /dev/null +++ b/src/components/menu/settings/WorkspaceLayoutsSettings.svelte @@ -0,0 +1,20 @@ + + + + +
+ + + + + Save the windows you have open — which panels, docked or floating, their sizes, the side and bottom docks — under a name, and switch between arrangements in one click (also in the menu ▸ Layouts). Kept on this device; a page reload still starts clean + +
diff --git a/src/lib/bottomDock.js b/src/lib/bottomDock.js index ede1a717..67e38ae5 100644 --- a/src/lib/bottomDock.js +++ b/src/lib/bottomDock.js @@ -1,5 +1,6 @@ import { writable, derived, get } from 'svelte/store'; import { viewPrefs } from './viewPrefs'; +import { onLayoutRestore } from './uiLayoutsCore'; // Bottom dock: the dock shows exactly ONE panel at a time, and every panel that is // docked+open is a notebook TAB in it — the Flow family (Node editor / Flow Code / @@ -150,6 +151,14 @@ dockTabOrder.subscribe((value) => { } catch {} }); +// 37 R14: a named workspace layout rewrote the dock's height and tab order — adopt them. +// (Which tab is in front rides the layout's workspace record; minimized never persists.) +onLayoutRestore(() => { + dockHeight.set(clampH(parseInt(ls?.getItem('flowDockHeight') ?? '320'))); + dockTabOrder.set(readTabOrder()); + dockMinimized.set(false); +}); + /** * A stored (possibly stale, possibly hand-edited) order -> a usable one: unknown keys * dropped, duplicates collapsed, nothing added. A key the list has never heard of — a diff --git a/src/lib/debugHooks.js b/src/lib/debugHooks.js index 36a4bcc6..2199a375 100644 --- a/src/lib/debugHooks.js +++ b/src/lib/debugHooks.js @@ -220,6 +220,8 @@ export const DEBUG_HOOKS = [ ['units', () => import('./units')], ['postBackends', () => import('./postBackends')], ['workspace', () => import('./workspace')], + ['uiLayouts', () => import('./uiLayouts')], + ['docking', () => import('./docking')], ['editResume', () => import('./editResume')], ['moduleRequirements', () => import('./moduleRequirements')], ['hudDocs', () => import('./hudDocs')], diff --git a/src/lib/docking.js b/src/lib/docking.js index d6de8c79..3bb774d8 100644 --- a/src/lib/docking.js +++ b/src/lib/docking.js @@ -3,6 +3,7 @@ import { get } from 'svelte/store'; import { inspectorClose, closeMenu } from '../stores/appStore'; import { bottomDockWouldTake } from './bottomDockDrop'; import { safeStorage } from './safeStorage'; +import { onLayoutRestore } from './uiLayoutsCore'; // Docking lite (phase 81L). Drag a window near the left/right screen edge to // dock it as a full-height panel (--z-drawer tier); drag its header away to @@ -267,26 +268,60 @@ export function undock(key, x, y) { docked[side] = docked[side].filter((k) => k !== key); persist(); const entry = registry.get(key); - if (entry) { - const { node, prevRect } = entry; - delete node.dataset.docked; - delete node.dataset.dockSlot; - entry.handle?.remove(); - entry.handle = null; - entry.divider?.remove(); - entry.divider = null; - node.style.height = prevRect?.height || ''; - node.style.width = prevRect?.width || ''; - node.style.maxWidth = ''; - node.style.maxHeight = ''; - node.style.zIndex = prevRect?.zIndex || '40'; - node.style.left = (x != null ? Math.max(0, x - 120) : parseFloat(prevRect?.left) || 200) + 'px'; - node.style.top = (y != null ? Math.max(0, y - 12) : parseFloat(prevRect?.top) || 120) + 'px'; - } + if (entry) releaseNode(entry, x, y); // 81.4: the member left behind takes the whole column again applySide(side); } +/** Hand a node that is no longer docked its floating geometry back. + * @param {any} entry @param {number=} x @param {number=} y */ +function releaseNode(entry, x, y) { + const { node, prevRect } = entry; + delete node.dataset.docked; + delete node.dataset.dockSlot; + entry.handle?.remove(); + entry.handle = null; + entry.divider?.remove(); + entry.divider = null; + node.style.height = prevRect?.height || ''; + node.style.width = prevRect?.width || ''; + node.style.maxWidth = ''; + node.style.maxHeight = ''; + node.style.zIndex = prevRect?.zIndex || '40'; + node.style.left = (x != null ? Math.max(0, x - 120) : parseFloat(prevRect?.left) || 200) + 'px'; + node.style.top = (y != null ? Math.max(0, y - 12) : parseFloat(prevRect?.top) || 120) + 'px'; +} + +// 37 R14: a named workspace layout rewrote `dockedWindows` (and the widths/splits, which +// are read live) — adopt the stored sides. A live window that leaves the column gets its +// floating geometry back (its own dragWindow re-read then places it); one that joins a +// column remembers where it floated, as a drag-dock does. Registered at module load, so +// it runs BEFORE the windows' own re-reads. +onLayoutRestore(() => { + /** @type {{left: string[], right: string[]}} */ + let next = { left: [], right: [] }; + try { + const saved = JSON.parse(safeStorage.getItem('dockedWindows') ?? 'null'); + if (saved) next = { left: sideList(saved.left), right: sideList(saved.right) }; + } catch {} + const was = docked; + docked = next; + for (const [key, entry] of registry) { + const before = was.left.includes(key) || was.right.includes(key); + const now = !!sideOf(key); + if (before && !now) releaseNode(entry); + else if (!before && now) + entry.prevRect = { + left: entry.node.style.left, + top: entry.node.style.top, + width: entry.node.style.width, + height: entry.node.style.height, + zIndex: entry.node.style.zIndex + }; + } + if (!isCoarse()) applyAll(); +}); + /** @type {any} */ let zoneEl = null; /** * @param {{side: 'left'|'right', split?: {node: any, slot: 'top'|'bottom'}} | null} target diff --git a/src/lib/dragWindow.js b/src/lib/dragWindow.js index 04dce503..9657d8c4 100644 --- a/src/lib/dragWindow.js +++ b/src/lib/dragWindow.js @@ -5,6 +5,7 @@ import { clampWinSize, clampResize, bottomReserve } from './windowSize'; import { safeStorage } from './safeStorage'; import { isHeaderDrag, startWindowDragGuard } from './windowGrip'; +import { onLayoutRestore } from './uiLayoutsCore'; // 169: live reset registry — every draggable window (this action + the object // list's own dragMe) registers a reset fn so Settings can rescue windows stuck @@ -297,6 +298,33 @@ export function dragWindow(node, { key, defaultRect = {}, resizable = false, axi } const unregisterReset = registerWindowReset(resetToDefault); + // 37 R14: a named workspace layout was applied — RE-READ the stored rect (the layout + // just wrote it, or removed it so the default applies). A tab member / side-docked + // window is placed by its owner, so only its stored rect is refreshed. + const unregisterReload = onLayoutRestore(() => { + /** @type {any} */ + let stored = null; + try { + stored = JSON.parse(safeStorage.getItem('win:' + key) ?? 'null'); + } catch {} + rect = stored ?? { ...defaultRect }; + if (resizable && !(stored && typeof stored.w === 'number')) { + node.style.removeProperty('width'); + node.style.removeProperty('height'); + } + if (node.dataset?.tabMember || node.dataset?.docked) return; + if (typeof rect.left === 'number' && typeof rect.top === 'number') { + clamp(true); + apply(); + } else if (!resolveDefaults() && typeof ResizeObserver !== 'undefined') { + // hidden right now (a class-hidden window): resolve on the first visible frame + const once = new ResizeObserver(() => { + if (resolveDefaults()) once.disconnect(); + }); + once.observe(node); + } + }); + // 21-I3: the keyed reveal (see revealWindow above). Deliberately does NOT save() — // same reasoning as the IntersectionObserver reveal: bringing a window back is a // DISPLAY decision, and persisting it would overwrite the user's parked spot. @@ -444,6 +472,7 @@ export function dragWindow(node, { key, defaultRect = {}, resizable = false, axi return { destroy() { unregisterReset(); + unregisterReload(); // only if it is still OURS — a remount can register the new node before the old // one tears down, and deleting blindly would strand the live window if (revealers.get(key) === reveal) revealers.delete(key); diff --git a/src/lib/uiLayouts.js b/src/lib/uiLayouts.js new file mode 100644 index 00000000..bd4e8c51 --- /dev/null +++ b/src/lib/uiLayouts.js @@ -0,0 +1,163 @@ +import { writable, get } from 'svelte/store'; +import { tick } from 'svelte'; +import { safeStorage } from './safeStorage'; +import { + pickLayoutStorage, + layoutWrites, + parseLayouts, + upsertLayout, + removeLayout, + renameLayout, + cleanName, + runLayoutReloaders, + MAX_LAYOUTS +} from './uiLayoutsCore'; +import { snapshotWorkspace, applyWorkspace, closeWorkspace } from './workspace'; +import { hudEditorClose, profilerClose, chatHidden, settingsOpen } from '../stores/appStore'; + +// 37 R14 — NAMED WORKSPACE LAYOUTS, the live half (the rules are in uiLayoutsCore). +// +// A layout = which panels are open + the stored geometry of every window (dock sides and +// widths, the bottom dock's height and tab order, each panel's docked-or-floating mode and +// floating size, tab groups, the object list's rect). LOCAL to this browser — a fact about +// this screen, like the dock order — so nothing here replicates, saves into a scene or +// undoes. The workspace rule stands: a reload is still a clean slate; a layout comes back +// only when you pick it. +// +// APPLYING IS FOUR STEPS, and the order is the design: +// 1. close every panel, so each floating window UNMOUNTS (it re-reads its rect on mount); +// 2. write the layout's keys into storage and drop the layout keys it does not name +// (their owners fall back to defaults — what "Reset window layout" relies on too); +// 3. tell the owners that stay mounted to re-read (`onLayoutRestore`): docking, the +// bottom dock, the tab groups, every dock-family panel's mode + size, the object list +// and any class-hidden dragWindow; +// 4. reopen what the layout had open, with its dock tab in front. +// No page reload — that would drop the peer session. + +const STORE_KEY = 'uiLayouts'; +const ACTIVE_KEY = 'uiLayouts:active'; + +/** the saved layouts, oldest first @type {import('svelte/store').Writable} */ +export const uiLayouts = writable(parseLayouts(safeStorage.getItem(STORE_KEY))); +/** the id of the layout applied (or saved) last — the list marks it @type {import('svelte/store').Writable} */ +export const activeLayoutId = writable(safeStorage.getItem(ACTIVE_KEY)); + +/** @param {any[]} list */ +function persist(list) { + uiLayouts.set(list); + safeStorage.setItem(STORE_KEY, JSON.stringify(list)); +} +/** @param {string|null} id */ +function setActive(id) { + activeLayoutId.set(id); + if (id) safeStorage.setItem(ACTIVE_KEY, id); + else safeStorage.removeItem(ACTIVE_KEY); +} + +function newId() { + try { + return 'lay-' + crypto.randomUUID().slice(0, 8); + } catch { + return 'lay-' + Math.random().toString(36).slice(2, 10); + } +} + +/** The live UI as a layout record (not stored). @param {string} name */ +export function captureLayout(name) { + const ws = snapshotWorkspace({ always: true }); + return { + v: 1, + id: newId(), + name: cleanName(name), + savedAt: Date.now(), + viewport: typeof window !== 'undefined' ? { w: window.innerWidth, h: window.innerHeight } : null, + open: { + ...ws.open, + hud: !get(hudEditorClose), + profiler: !get(profilerClose), + chat: get(chatHidden) === '' + }, + dockTab: ws.dockTab, + inspector: ws.inspector, + storage: pickLayoutStorage(safeStorage.keys(), (key) => safeStorage.getItem(key)) + }; +} + +/** + * Save the live UI under a name. The same name (any case) overwrites that layout in place. + * @param {string} name + * @returns {{ok: true, layout: any, updated: boolean} | {ok: false, reason: string}} + */ +export function saveLayout(name) { + const clean = cleanName(name); + if (!clean) return { ok: false, reason: 'Give the layout a name' }; + const list = get(uiLayouts); + const updated = list.some((l) => l.name.toLowerCase() === clean.toLowerCase()); + const next = upsertLayout(list, captureLayout(clean)); + if (!next) return { ok: false, reason: `You can keep up to ${MAX_LAYOUTS} layouts — delete one first` }; + persist(next); + const layout = next.find((l) => l.name.toLowerCase() === clean.toLowerCase()); + setActive(layout.id); + return { ok: true, layout, updated }; +} + +/** @param {string} id */ +export function deleteLayout(id) { + persist(removeLayout(get(uiLayouts), id)); + if (get(activeLayoutId) === id) setActive(null); +} + +/** @param {string} id @param {string} name @returns {boolean} */ +export function renameLayoutTo(id, name) { + const next = renameLayout(get(uiLayouts), id, name); + if (!next) return false; + persist(next); + return true; +} + +/** the panels a layout opens beyond the workspace record's own table */ +const EXTRA = [ + { name: 'hud', open: () => hudEditorClose.set(false), close: () => hudEditorClose.set(true) }, + { name: 'profiler', open: () => profilerClose.set(false), close: () => profilerClose.set(true) }, + { name: 'chat', open: () => chatHidden.set(''), close: () => chatHidden.set('hidden') } +]; + +let applying = false; + +/** + * Put a saved layout back. Resolves false for an unknown id (or one already applying). + * @param {string} id + */ +export async function applyLayout(id) { + const layout = get(uiLayouts).find((l) => l.id === id); + if (!layout || applying) return false; + applying = true; + try { + // Settings snapshots the panels when it opens and RESTORES them when it closes, which + // would undo the layout the moment the dialog went away — close it first. + if (get(settingsOpen)) { + settingsOpen.set(false); + await tick(); + } + closeWorkspace(); + for (const extra of EXTRA) extra.close(); + await tick(); + + const { set, remove } = layoutWrites(layout.storage, safeStorage.keys()); + for (const key of remove) safeStorage.removeItem(key); + for (const [key, value] of set) safeStorage.setItem(key, value); + runLayoutReloaders(); + await tick(); + + applyWorkspace({ open: layout.open, dockTab: layout.dockTab, inspector: layout.inspector }); + for (const extra of EXTRA) if (layout.open?.[extra.name] === true) extra.open(); + await tick(); + setActive(id); + return true; + } finally { + applying = false; + } +} + +/** the burger menu's Layouts popover */ +export const layoutsMenuOpen = writable(false); diff --git a/src/lib/uiLayoutsCore.js b/src/lib/uiLayoutsCore.js new file mode 100644 index 00000000..ff5354a7 --- /dev/null +++ b/src/lib/uiLayoutsCore.js @@ -0,0 +1,215 @@ +// 37 R14 — NAMED WORKSPACE LAYOUTS, the part with no runtime: which stored keys ARE the +// layout, what a saved record looks like, the list operations, and the reload registry +// the live windows answer to. A LEAF (imports nothing) so the unit layer covers it. +// +// WHY A LAYOUT IS A SET OF STORAGE KEYS. The window geometry of this app is scattered +// over ~40 keys, each owned by the module that draws the window (dragWindow's `win:*`, +// docking's sides/widths/splits, the bottom dock's tab/height/order, every panel's +// docked flag + floating size, the tab groups, the object list's rect). Every owner +// already reads its key and already writes it on every user change — so a snapshot of +// those keys IS the layout, captured in the owners' own formats, and nothing new has to +// learn how a window describes itself. Restoring is the reverse plus one thing the owners +// lacked: a way to RE-READ their key while they are mounted (`onLayoutRestore`). +// +// What it is NOT: the toolbar roster (`controlsLayout`, a separate customisation), +// settings, or anything scene-side. Layouts are LOCAL — a fact about this screen, like +// the dock order — and never replicate or ride a save. The `workspace` rule (a plain +// reload is a clean slate) is untouched: a layout is applied only when you pick one. + +/** the dock-family panels whose mode + floating size are component state, by the prefix + * their keys use (`Docked`, `WinW`, `WinH`) */ +export const PANEL_PREFIXES = ['flow', 'flowCode', 'animation', 'uv', 'shader', 'hud', 'explorer', 'profiler', 'code']; + +/** exact keys that belong to the layout */ +const EXACT = new Set([ + 'dockedWindows', + 'bottomDockActive', + 'flowDockHeight', + 'dockTabOrder', + 'windowTabGroups', + 'objectListRect', + 'profilerDockSized', + ...PANEL_PREFIXES.flatMap((p) => [p + 'Docked', p + 'WinW', p + 'WinH']) +]); +/** key families that belong to the layout */ +const PREFIXES = ['win:', 'dockWidth:', 'dockSplit:']; + +/** Is this stored key part of a workspace layout? @param {string} key */ +export function isLayoutKey(key) { + if (typeof key !== 'string') return false; + return EXACT.has(key) || PREFIXES.some((p) => key.startsWith(p)); +} + +/** the most layouts kept; the oldest unnamed overflow is refused rather than evicted */ +export const MAX_LAYOUTS = 24; +/** the longest name */ +export const MAX_NAME = 40; +/** a stored value longer than this is not geometry and is not copied */ +const MAX_VALUE = 4096; + +/** @param {any} name */ +export function cleanName(name) { + return String(name ?? '') + .replace(/[\u0000-\u001f]/g, ' ') + .replace(/\s+/g, ' ') + .trim() + .slice(0, MAX_NAME); +} + +/** + * The layout keys out of a key list + reader. + * @param {string[]} keys @param {(key: string) => string | null} read + * @returns {Record} + */ +export function pickLayoutStorage(keys, read) { + /** @type {Record} */ + const out = {}; + for (const key of [...keys].sort()) { + if (!isLayoutKey(key)) continue; + const value = read(key); + if (typeof value === 'string' && value.length <= MAX_VALUE) out[key] = value; + } + return out; +} + +/** + * The writes that make storage match a layout: every layout key the record has is SET, + * every layout key storage has and the record does not is REMOVED (its owner falls back + * to its default — the same thing "Reset window layout" relies on). + * @param {Record} storage the record's keys + * @param {string[]} currentKeys what storage holds now + * @returns {{set: [string, string][], remove: string[]}} + */ +export function layoutWrites(storage, currentKeys) { + /** @type {[string, string][]} */ + const set = []; + for (const [key, value] of Object.entries(storage ?? {})) + if (isLayoutKey(key) && typeof value === 'string' && value.length <= MAX_VALUE) set.push([key, value]); + const remove = currentKeys.filter((key) => isLayoutKey(key) && !Object.prototype.hasOwnProperty.call(storage ?? {}, key)); + return { set, remove }; +} + +/** + * A stored record -> a usable one, or null. Unknown fields are kept (a newer build's + * record restores what this build knows), wrong-typed ones are dropped. + * @param {any} raw + */ +export function normalizeLayout(raw) { + if (!raw || typeof raw !== 'object') return null; + const name = cleanName(raw.name); + if (!name || typeof raw.id !== 'string' || !raw.id) return null; + /** @type {Record} */ + const storage = {}; + if (raw.storage && typeof raw.storage === 'object') + for (const [key, value] of Object.entries(raw.storage)) + if (isLayoutKey(key) && typeof value === 'string' && value.length <= MAX_VALUE) storage[key] = value; + /** @type {Record} */ + const open = {}; + if (raw.open && typeof raw.open === 'object') + for (const [key, value] of Object.entries(raw.open)) if (typeof value === 'boolean') open[key] = value; + return { + ...raw, + v: 1, + id: raw.id, + name, + savedAt: Number.isFinite(raw.savedAt) ? raw.savedAt : 0, + open, + storage + }; +} + +/** @param {any} text a stored list @returns {any[]} */ +export function parseLayouts(text) { + try { + const list = JSON.parse(text ?? '[]'); + if (!Array.isArray(list)) return []; + /** @type {any[]} */ + const out = []; + const seen = new Set(); + for (const raw of list) { + const rec = normalizeLayout(raw); + if (!rec || seen.has(rec.id)) continue; + seen.add(rec.id); + out.push(rec); + } + return out.slice(0, MAX_LAYOUTS); + } catch { + return []; + } +} + +/** + * Save `record` into `list`: a layout with the SAME NAME (case-insensitive) is + * overwritten in place and keeps its id — "save as Modeling" twice is an update, not two + * rows called Modeling. Returns null when the list is full and the name is new. + * @param {any[]} list @param {any} record + */ +export function upsertLayout(list, record) { + const rec = normalizeLayout(record); + if (!rec) return null; + const at = list.findIndex((l) => l.name.toLowerCase() === rec.name.toLowerCase()); + if (at >= 0) { + const next = [...list]; + next[at] = { ...rec, id: list[at].id }; + return next; + } + if (list.length >= MAX_LAYOUTS) return null; + return [...list, rec]; +} + +/** @param {any[]} list @param {string} id */ +export function removeLayout(list, id) { + return list.filter((l) => l.id !== id); +} + +/** @param {any[]} list @param {string} id @param {string} name */ +export function renameLayout(list, id, name) { + const clean = cleanName(name); + if (!clean) return null; + if (list.some((l) => l.id !== id && l.name.toLowerCase() === clean.toLowerCase())) return null; + return list.map((l) => (l.id === id ? { ...l, name: clean } : l)); +} + +// ---------------------------------------------------------------- the reload registry + +/** @type {Set<() => void>} */ +const reloaders = new Set(); + +/** + * A live window that reads its geometry from storage ONCE (at mount) registers here to + * re-read it when a layout is applied. Returns the unregister function, so a component + * can hand it straight back from `onMount`. + * @param {() => void} fn + */ +export function onLayoutRestore(fn) { + reloaders.add(fn); + return () => { + reloaders.delete(fn); + }; +} + +/** run every registered re-read; one that throws does not stop the rest */ +export function runLayoutReloaders() { + let ran = 0; + for (const fn of [...reloaders]) { + try { + fn(); + ran++; + } catch {} + } + return ran; +} + +/** + * A dock-family panel's stored mode + floating size, the read every one of them did + * inline at mount (defaults: docked, the panel's own default size). + * @param {(key: string) => string | null} read @param {string} prefix + * @param {number} defW @param {number} defH + */ +export function storedPanelLayout(read, prefix, defW, defH) { + return { + docked: read(prefix + 'Docked') !== 'false', + w: parseInt(read(prefix + 'WinW') ?? String(defW)) || defW, + h: parseInt(read(prefix + 'WinH') ?? String(defH)) || defH + }; +} diff --git a/src/lib/windowTabs.js b/src/lib/windowTabs.js index 8d4740f8..adae80cf 100644 --- a/src/lib/windowTabs.js +++ b/src/lib/windowTabs.js @@ -1,6 +1,7 @@ import { isHeaderDrag } from './windowGrip'; import { writable, get } from 'svelte/store'; import { safeStorage } from './safeStorage'; +import { onLayoutRestore } from './uiLayoutsCore'; // Window tab groups (phase 83, floating windows only — docked splits stay in // pending/81). Grouped windows share ONE rect; the active member is visible, @@ -359,6 +360,32 @@ function tryRestore() { }); } +/** @param {string} text */ +function parseGroups(text) { + try { + return JSON.parse(text ?? '[]').map((/** @type {any} */ saved) => ({ + ...saved, + members: (saved.members ?? []).map(migrateKey), + active: migrateKey(saved.active) + })); + } catch { + return []; + } +} + +// 37 R14: a named workspace layout rewrote `windowTabGroups`. Dissolve what is grouped +// now (every member places itself again) and queue the stored groups the way a boot does: +// each forms once its members have registered — members that open after the layout +// applies register then and call `tryRestore` themselves. +onLayoutRestore(() => { + // read FIRST: dissolving persists, and would overwrite what the layout just wrote + const stored = parseGroups(safeStorage.getItem('windowTabGroups') ?? '[]'); + for (const group of get(tabGroups)) for (const key of [...group.members]) removeFromGroup(key); + tabGroups.set([]); + pendingRestore = stored; + tryRestore(); +}); + /** * svelte action for a floating window that can join tab groups. * @param {any} node diff --git a/src/lib/workspace.js b/src/lib/workspace.js index b44f1fdb..4320d382 100644 --- a/src/lib/workspace.js +++ b/src/lib/workspace.js @@ -58,7 +58,9 @@ const PANELS = [ ]; /** - * Read the live workspace, for a save payload. + * Read the live workspace, for a save payload. `always` (a named layout) returns the record + * even when nothing is open. + * @param {{always?: boolean}} [opts] * * Returns NULL when nothing is open, and that is deliberate on two counts: a scene with * no windows open adds no field to its file (so an ordinary save stays as small and as @@ -67,7 +69,7 @@ const PANELS = [ * somebody's windows because the author had none is the aggressive reading of it. * @returns {any|null} */ -export function snapshotWorkspace() { +export function snapshotWorkspace({ always = false } = {}) { /** @type {any} */ const open = {}; let any = false; @@ -76,7 +78,8 @@ export function snapshotWorkspace() { open[panel.name] = isOpen; if (isOpen) any = true; } - if (!any) return null; + // 37 R14: a named layout records "nothing open" too — applying it is asked for + if (!any && !always) return null; return { open, dockTab: get(bottomDockActive), diff --git a/tests/unit/uiLayoutsCore.test.js b/tests/unit/uiLayoutsCore.test.js new file mode 100644 index 00000000..9e3375a3 --- /dev/null +++ b/tests/unit/uiLayoutsCore.test.js @@ -0,0 +1,107 @@ +import { describe, it, expect } from 'vitest'; +import { + isLayoutKey, + pickLayoutStorage, + layoutWrites, + normalizeLayout, + parseLayouts, + upsertLayout, + removeLayout, + renameLayout, + cleanName, + onLayoutRestore, + runLayoutReloaders, + storedPanelLayout, + MAX_LAYOUTS +} from '../../src/lib/uiLayoutsCore.js'; + +// 37 R14 — the rules of a named workspace layout (the live half is e2e `workspace-layouts`). + +describe('which stored keys are the layout', () => { + it('takes window rects, docks and panel modes, nothing else', () => { + for (const key of ['win:chat', 'dockWidth:explorer', 'dockSplit:left', 'dockedWindows', 'bottomDockActive', 'flowDockHeight', 'dockTabOrder', 'windowTabGroups', 'objectListRect', 'flowDocked', 'explorerWinW', 'codeWinH']) + expect(isLayoutKey(key), key).toBe(true); + for (const key of ['uiLayouts', 'theme', 'controlsLayout', 'viewMode', 'win', 'flowPaletteOpen', 'explorerSingleClickOpen', 'shortcutOverrides']) + expect(isLayoutKey(key), key).toBe(false); + }); + + it('picks the layout keys out of storage, skipping oversized values', () => { + /** @type {Record} */ + const store = { 'win:chat': '{"left":1,"top":2}', theme: 'light', flowDocked: 'false', 'win:huge': 'x'.repeat(5000) }; + expect(pickLayoutStorage(Object.keys(store), (k) => store[k])).toEqual({ flowDocked: 'false', 'win:chat': '{"left":1,"top":2}' }); + }); + + it('restoring SETS what the layout names and REMOVES layout keys it does not', () => { + const { set, remove } = layoutWrites({ 'win:chat': 'A', flowDocked: 'true' }, ['win:chat', 'win:flowWin', 'theme', 'explorerDocked', 'uiLayouts']); + expect(set).toEqual([['win:chat', 'A'], ['flowDocked', 'true']]); + // counterfactual: a key outside the layout (theme, the list itself) is never touched + expect(remove).toEqual(['win:flowWin', 'explorerDocked']); + }); +}); + +describe('the saved list', () => { + const rec = (/** @type {string} */ id, /** @type {string} */ name) => ({ id, name, savedAt: 1, open: { flow: true }, storage: { flowDocked: 'true' } }); + + it('normalizes: a bad record is dropped, foreign storage keys are stripped, unknown fields kept', () => { + expect(normalizeLayout(null)).toBe(null); + expect(normalizeLayout({ id: 'a', name: ' ' })).toBe(null); + const n = normalizeLayout({ id: 'a', name: ' Model\nling ', storage: { theme: 'x', 'win:a': 'y' }, open: { flow: true, bad: 3 }, future: 7 }); + expect(n?.name).toBe('Model ling'); + expect(n?.storage).toEqual({ 'win:a': 'y' }); + expect(n?.open).toEqual({ flow: true }); + expect(n?.future).toBe(7); + }); + + it('parses defensively and drops duplicate ids', () => { + expect(parseLayouts('not json')).toEqual([]); + expect(parseLayouts('{"a":1}')).toEqual([]); + expect(parseLayouts(JSON.stringify([rec('a', 'One'), rec('a', 'Two'), rec('b', 'Three')])).map((l) => l.name)).toEqual(['One', 'Three']); + }); + + it('saving under an existing name (any case) UPDATES it in place and keeps its id', () => { + const list = [rec('a', 'Modelling'), rec('b', 'Nodes')]; + const next = upsertLayout(list, { ...rec('z', 'modelling'), open: { uv: true } }); + expect(next?.length).toBe(2); + expect(next?.[0].id).toBe('a'); + expect(next?.[0].open).toEqual({ uv: true }); + }); + + it('refuses a NEW name past the cap, but still updates an existing one', () => { + const full = Array.from({ length: MAX_LAYOUTS }, (_, i) => rec('id' + i, 'L' + i)); + expect(upsertLayout(full, rec('new', 'Another'))).toBe(null); + expect(upsertLayout(full, rec('new', 'L3'))?.length).toBe(MAX_LAYOUTS); + }); + + it('renames (refusing a clash or an empty name) and deletes', () => { + const list = [rec('a', 'One'), rec('b', 'Two')]; + expect(renameLayout(list, 'a', 'two')).toBe(null); + expect(renameLayout(list, 'a', ' ')).toBe(null); + expect(renameLayout(list, 'a', 'Uno')?.[0].name).toBe('Uno'); + expect(removeLayout(list, 'a').map((l) => l.id)).toEqual(['b']); + expect(cleanName('x'.repeat(60)).length).toBe(40); + }); +}); + +describe('the reload registry', () => { + it('runs every re-read, survives one that throws, and unregisters', () => { + const seen = /** @type {string[]} */ ([]); + const offA = onLayoutRestore(() => seen.push('a')); + const offB = onLayoutRestore(() => { + throw new Error('boom'); + }); + const offC = onLayoutRestore(() => seen.push('c')); + expect(runLayoutReloaders()).toBe(2); + expect(seen).toEqual(['a', 'c']); + offA(); + offB(); + offC(); + expect(runLayoutReloaders()).toBe(0); + }); + + it('reads a panel mode + size with the panel defaults', () => { + /** @type {Record} */ + const store = { flowDocked: 'false', flowWinW: '800' }; + expect(storedPanelLayout((k) => store[k] ?? null, 'flow', 760, 480)).toEqual({ docked: false, w: 800, h: 480 }); + expect(storedPanelLayout(() => null, 'uv', 640, 460)).toEqual({ docked: true, w: 640, h: 460 }); + }); +}); From 85fb2610752546e19ab11dde43400aaa387d8e9a Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Tue, 6 Oct 2026 02:12:49 +0300 Subject: [PATCH 11/65] [feat] prefab instances: linked placement, revisions, "Update N instances" (37 R4 phase 2) - prefabs.js: every element write is keyed (stampElementKeys); updatePrefab / restorePrefabBytes keep the old bytes as a REVISION (idb prefab-revs-v1:, newest 8) and bump `rev`; instantiatePrefab links the instance (root userData.prefab {id, rev}, per-node prefabKeys) for library prefabs only; a prefab carries its objects' flow graphs (`graphs`, keyed by element node, the .tpnode payload shape) and installs them on placement; toJSON after updateMatrixWorld. - prefabLinks.js (new): prefabInstances, prefabInstanceCounts, updateInstances (merge per instance against the revision it was placed from, replace IN PLACE through the existing `object` override message, graphs replaced via nodedelete/edgedelete + graphcreate/nodes, ONE `prefabsync` history entry), offerInstanceUpdate (toast: Update N instances / Update, reset overrides / Undo), applyInstanceToPrefab, resetInstance, syncInstance, unlinkInstance, selectInstances, instanceOverrides. - Explorer: Update from selection offers the instance update; card menu "Instances in scene (N)". Object menu: "Prefab: " submenu (Update from prefab / Apply changes / Reset overrides / Select all / Unlink). - saveAs snapshot path keys + captures graphs; Icon gains tag/unlink/upload; debugHooks rows prefabLinks + prefabSync. - svelte-check 318/47 (baseline), vitest 1564 green. e2e NOT yet run. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/components/editors/Explorer.svelte | 44 ++- src/components/ui/Icon.svelte | 6 + src/lib/debugHooks.js | 2 + src/lib/objectMenu.js | 74 +++++ src/lib/prefabLinks.js | 405 +++++++++++++++++++++++++ src/lib/prefabSync.js | 3 + src/lib/prefabs.js | 197 ++++++++++-- src/lib/saveAs.js | 7 +- 8 files changed, 713 insertions(+), 25 deletions(-) create mode 100644 src/lib/prefabLinks.js diff --git a/src/components/editors/Explorer.svelte b/src/components/editors/Explorer.svelte index 3794e098..03a2d555 100644 --- a/src/components/editors/Explorer.svelte +++ b/src/components/editors/Explorer.svelte @@ -314,6 +314,8 @@ savePrefabSelection, addPrefabRecord, exportPrefab, duplicatePrefab } from '$lib/prefabs'; + // 37 R4: an edited prefab offers to update its instances (replicated, one undo) + import { offerInstanceUpdate, updateInstances, selectInstances, prefabInstanceCounts } from '$lib/prefabLinks'; // 21-I3: Export ▸ scene (.tpscene) — a scene containing just this prefab. Built from // the EMPTY payload plus this one object, never a capture of the live scene. // R22 round 13 P3: `sessions` is read for the Mount picker (which saved entries are @@ -4207,12 +4209,16 @@ const before = prefabSnapshot(prefab.id); // captured BEFORE, held in this closure const next = await updatePrefab(prefab.id, uuids, { toast: false }); if (!next) return; // updatePrefab already said why (missing object / too large) - showToast( - `Updated "${next.name}" from ${uuids.length === 1 ? 'the selection' : uuids.length + ' selected objects'}`, + // 37 R4: the report doubles as the offer — "Update N instances" beside the Undo. The + // Undo still belongs to the toast (a library edit); the instance update is a scene + // edit and takes ONE Ctrl+Z of its own. + offerInstanceUpdate(prefab.id, { + exclude: uuids, + fallback: before ? { element: before.element, graphs: before.graphs } : null, // `undefined`, never `[]` — showToast treats any array as an action toast, and an // action toast with no buttons is a card the user cannot dismiss by acting on it - before ? [{ label: 'Undo', action: () => void undoPrefabUpdate(before, next.name) }] : undefined - ); + extra: before ? [{ label: 'Undo', action: () => void undoPrefabUpdate(before, next.name) }] : undefined + }); } async function undoPrefabUpdate(snapshot: any, name: string) { @@ -4235,6 +4241,35 @@ ]); } + /** 37 R4: the instances in this scene, from the card — only when there are some. */ + function prefabInstanceItems(prefab: any) { + const n = $prefabInstanceCounts[prefab.id] ?? 0; + if (!n) return []; + const s = n === 1 ? '' : 's'; + return [ + { + label: `Instances in scene (${n})`, + icon: 'boxes', + children: [ + { + label: `Update ${n} instance${s}`, + tooltip: 'Bring every placed copy up to this prefab — each keeps its own changes (one undo)', + action: () => void updateInstances(prefab.id) + }, + { + label: 'Update, reset overrides', + tooltip: 'Make every placed copy match this prefab exactly — only where each one stands is kept', + action: () => void updateInstances(prefab.id, { reset: true }) + }, + { + label: `Select ${n} instance${s}`, + action: () => selectInstances(prefab.id) + } + ] + } + ]; + } + function prefabMenu(e: MouseEvent, item: any) { const prefab = prefabById(item.prefabId); if (!prefab) return; @@ -4299,6 +4334,7 @@ tooltip: 'Re-save this prefab from the objects selected in the scene', action: () => updatePrefabFromSelection(prefab) }, + ...prefabInstanceItems(prefab), { label: 'Properties', icon: 'info', action: () => showProperties({ kind: 'item', item }) }, { label: 'Rename', icon: 'pencil', action: () => startRenamePrefab(item) }, { label: 'Delete', icon: 'trash-2', danger: true, action: () => void deletePrefabToBin(prefab) } diff --git a/src/components/ui/Icon.svelte b/src/components/ui/Icon.svelte index 524ee95a..f8e0a430 100644 --- a/src/components/ui/Icon.svelte +++ b/src/components/ui/Icon.svelte @@ -18,6 +18,9 @@ ChevronUp, Combine, Copy, + Tag, + Unlink, + Upload, Download, ExternalLink, Eye, @@ -125,6 +128,9 @@ spline: Spline, 'sticky-note': StickyNote, 'trash-2': Trash2, + tag: Tag, + unlink: Unlink, + upload: Upload, 'undo-2': Undo2, ungroup: Ungroup, users: Users, diff --git a/src/lib/debugHooks.js b/src/lib/debugHooks.js index 36a4bcc6..f1fd416c 100644 --- a/src/lib/debugHooks.js +++ b/src/lib/debugHooks.js @@ -75,6 +75,8 @@ export const DEBUG_HOOKS = [ ['pathCapture', () => import('./pathCapture')], ['lockControl', () => import('./lockControl')], ['prefabs', () => import('./prefabs')], + ['prefabLinks', () => import('./prefabLinks')], + ['prefabSync', () => import('./prefabSync')], ['physics', () => import('./physics')], // 36-sim: buoyancy/jiggle/fluid/splash state ['sim', () => import('./sim/debug.js')], diff --git a/src/lib/objectMenu.js b/src/lib/objectMenu.js index 9a396afd..0db5d8b6 100644 --- a/src/lib/objectMenu.js +++ b/src/lib/objectMenu.js @@ -27,6 +27,79 @@ import { pivotMode, reseatPivot, pivotParentAvailable } from './multiTransform'; import { addAnnotation } from './annotationsHandler'; import { pingObject, pingObjects } from './ping'; import { hasOriginal } from './decimate'; +import { linkOf } from './prefabSync'; +import { prefabById } from './prefabs'; + +/** + * 37 R4: the "Prefab" submenu of an object that is (or sits inside) a prefab instance. + * The runtime is reached by a dynamic import — it registers a history kind, and the menu + * builder is imported by components that must not pull it in eagerly. + * @param {any} object @param {any} group + */ +function prefabInstanceMenu(object, group) { + /** @type {any} */ + let root = null; + for (let o = object; o && o !== group; o = o.parent) { + if (linkOf(o)) { + root = o; + break; + } + } + const link = linkOf(root); + if (!root || !link) return []; + const entry = prefabById(link.id); + const links = () => import('./prefabLinks'); + const uuid = root.uuid; + /** @type {any[]} */ + const children = entry + ? [ + ...((entry.rev ?? 0) > link.rev + ? [ + { + label: 'Update from prefab', + icon: 'refresh-cw', + tooltip: 'Take the prefab’s newer version — this copy keeps its own changes', + action: () => void links().then((m) => m.syncInstance(uuid)) + } + ] + : []), + { + label: 'Apply changes to prefab', + icon: 'upload', + tooltip: 'Make this copy the prefab’s new version, then offer it to the other copies', + action: () => void links().then((m) => m.applyInstanceToPrefab(uuid)) + }, + { + label: 'Reset overrides', + icon: 'rotate-ccw', + tooltip: 'Make this copy match the prefab again — where it stands is kept (one undo)', + action: () => void links().then((m) => m.resetInstance(uuid)) + }, + { + label: 'Select all instances', + icon: 'boxes', + action: () => void links().then((m) => m.selectInstances(link.id)) + }, + { section: ' ' } + ] + : []; + children.push({ + label: 'Unlink from prefab', + icon: 'unlink', + tooltip: entry + ? 'Make this an ordinary object — prefab updates will not reach it' + : 'This copy came from a prefab that is not in your library; unlinking makes it an ordinary object', + action: () => void links().then((m) => m.unlinkInstance(uuid)) + }); + return [ + { + label: 'Prefab: ' + (entry?.name ?? 'not in your library'), + icon: 'boxes', + tooltip: root === object ? 'This object is a placed copy of a prefab' : 'This object is part of a placed copy of a prefab', + children + } + ]; +} /** * The FULL object context menu, shared so the direct object menu (right-click an @@ -548,6 +621,7 @@ export function buildObjectMenuItems(uuid, opts = {}) { action: () => addAnnotation(uuid, point) } ]), + ...(multi ? [] : prefabInstanceMenu(object, group)), { // R22 round 11 (user): "I would like to be able to save prefabs as they are now // with right click 'Save as...'". The single "Save as prefab" row became a diff --git a/src/lib/prefabLinks.js b/src/lib/prefabLinks.js new file mode 100644 index 00000000..097997e3 --- /dev/null +++ b/src/lib/prefabLinks.js @@ -0,0 +1,405 @@ +import * as THREE from 'three'; +import { get, derived } from 'svelte/store'; +import { objectsGroup, TControls, selectedObjects, pokeScene } from '../stores/sceneStore'; +import { peers, showToast } from '../stores/appStore'; +import { graphOf, updateGraph } from '../stores/flowStore'; +import { registerHistoryKind, recordEntry } from './history'; +import { applySelectionSet } from './objectActions'; +import { deleteObjectGraph } from './flowGraphs'; +import { serializeNode, serializeEdge } from './nodesHandler'; +import { parkEditOverlays, stripEditOverlays } from './editOverlays'; +import { keepSet, disposeTree } from './disposeTree'; +import { prefabs, prefabById, prefabRevision, updatePrefab } from './prefabs'; +import { linkOf, mergeInstance, overridesOf, unlinkPatch, instanceKey, same } from './prefabSync'; + +// 37 R4 — PREFAB UPDATE PROPAGATION, the runtime half (prefabSync.js is the merge). +// +// An update REPLACES each instance in place: same uuid, same parent, the merged element. +// That is a message every peer already understands — `{type:'object', element, override}`, +// the arrival heal's replace — so the wire learns nothing new and an older peer applies it. +// Peers do not need the prefab: the library is local, the instances are scene content. +// +// The whole update is ONE history entry (`prefabsync`): every instance's element before and +// after, plus the flow graphs the update installed or replaced, so one Ctrl+Z puts every +// instance back and replicates that too. Graph documents replace through the messages the +// flownodes kind already sends (nodedelete/edgedelete, then graphcreate + a `nodes` +// snapshot), because a `nodes` snapshot alone MERGES and could never remove a node. + +const loader = new THREE.ObjectLoader(); + +/** Every instance root of `id` in the scene (nested ones included). @param {string} id + * @returns {any[]} */ +export function prefabInstances(id) { + const group = get(objectsGroup); + /** @type {any[]} */ + const out = []; + group?.traverse((/** @type {any} */ node) => { + if (node !== group && linkOf(node)?.id === id) out.push(node); + }); + return out; +} + +/** The nearest instance root at or above `object` (any prefab), or null. @param {any} object */ +export function instanceRootOf(object) { + const group = get(objectsGroup); + for (let o = object; o && o !== group; o = o.parent) if (linkOf(o)) return o; + return null; +} + +/** instance counts per prefab id, for the Library cards (re-read on every scene poke) */ +export const prefabInstanceCounts = derived([objectsGroup, prefabs], ([$group]) => { + /** @type {Record} */ + const counts = {}; + /** @type {any} */ ($group)?.traverse((/** @type {any} */ node) => { + const link = node !== $group ? linkOf(node) : null; + if (link) counts[link.id] = (counts[link.id] ?? 0) + 1; + }); + return counts; +}); + +/** The serialized element of a live object, as the merge and the wire want it. @param {any} object */ +function elementOf(object) { + const unpark = parkEditOverlays(object); + try { + object.updateMatrixWorld(true); + return JSON.parse(JSON.stringify(object.toJSON())); + } finally { + unpark(); + } +} + +/** @param {any} graph @returns {{nodes: any[], edges: any[]}|null} */ +function serializedGraph(graph) { + if (!graph || (!graph.nodes?.length && !graph.edges?.length)) return null; + return { nodes: graph.nodes.map(serializeNode), edges: graph.edges.map(serializeEdge) }; +} + +/** A graph's CONTENT: types, data and wiring by position in the list — node ids are minted + * per copy, and where a node sits on the canvas is layout, not logic. @param {any} graph */ +function graphContent(graph) { + if (!graph || (!graph.nodes?.length && !graph.edges?.length)) return null; + /** @type {Record} */ + const at = {}; + graph.nodes.forEach((/** @type {any} */ n, /** @type {number} */ i) => (at[n.id] = i)); + return { + nodes: graph.nodes.map((/** @type {any} */ n) => ({ type: n.type, data: n.data ?? {} })), + edges: graph.edges.map((/** @type {any} */ e) => [at[e.source], e.sourceHandle ?? '', at[e.target], e.targetHandle ?? '']) + }; +} + +/** A copy with fresh node ids and canonical edge ids (copyGraphFrom's rule). @param {any} doc */ +function freshGraph(doc) { + /** @type {Record} */ + const idMap = {}; + const nodes = doc.nodes.map((/** @type {any} */ node) => { + const id = crypto.randomUUID(); + idMap[node.id] = id; + return { ...node, id, position: node.position ? { ...node.position } : { x: 0, y: 0 }, data: { ...node.data } }; + }); + const edges = doc.edges + .map((/** @type {any} */ edge) => { + const source = idMap[edge.source]; + const target = idMap[edge.target]; + if (!source || !target) return null; + return { + ...edge, + id: 'e-' + source + (edge.sourceHandle ? '.' + edge.sourceHandle : '') + '-' + target + (edge.targetHandle ? '.' + edge.targetHandle : ''), + source, + target + }; + }) + .filter(Boolean); + return { nodes, edges }; +} + +/** Replace one object's flow document, replicated. null = no document. @param {string} uuid @param {any} doc */ +function setGraphDoc(uuid, doc) { + /** @type {any} */ + const peer = get(peers); + const current = graphOf(uuid); + if (!doc) { + if (current) deleteObjectGraph(uuid, { record: false }); + return; + } + if (current && peer) { + const edgeIds = current.edges.map((/** @type {any} */ e) => e.id); + const nodeIds = current.nodes.map((/** @type {any} */ n) => n.id); + if (edgeIds.length) peer.send({ type: 'edgedelete', ids: edgeIds, graphId: uuid }); + if (nodeIds.length) peer.send({ type: 'nodedelete', ids: nodeIds, graphId: uuid }); + } + updateGraph(uuid, () => ({ + nodes: doc.nodes.map((/** @type {any} */ n) => ({ ...n, data: { ...n.data } })), + edges: doc.edges.map((/** @type {any} */ e) => ({ ...e })) + })); + if (peer) { + peer.send({ type: 'graphcreate', uuid }); + peer.send({ type: 'nodes', graphs: { [uuid]: doc } }); + } +} + +/** + * Put `element` in place of the object with its root uuid, locally and on every peer. + * The receive path is the override replace (commandsHandler.applyCreateObject); locally + * the same steps run synchronously so the history applier can report what happened. + * @param {any} element @param {string|null} parentUuid where it lives when it is not found + * @returns {boolean} + */ +function replaceInPlace(element, parentUuid) { + const group = get(objectsGroup); + if (!group || !element?.object) return false; + const uuid = element.object.uuid; + const existing = group.getObjectByProperty('uuid', uuid); + let object; + try { + object = loader.parse(element); + stripEditOverlays(object); + } catch (error) { + console.log('prefab instance parse failed', error); + return false; + } + const parent = existing?.parent ?? (parentUuid ? group.getObjectByProperty('uuid', parentUuid) : null) ?? group; + /** @type {any} */ + const controls = get(TControls); + if (existing && controls?.object && instanceContains(existing, controls.object)) controls.detach(); + if (existing) existing.parent?.remove(existing); + parent.add(object); + if (existing) { + let root = group; + while (root.parent) root = root.parent; + disposeTree(existing, { keep: keepSet(root, existing) }); + } + pokeScene(); + /** @type {any} */ + const peer = get(peers); + if (peer) peer.send({ type: 'object', element, override: true, ...(parent !== group ? { groupuuid: parent.uuid } : {}) }); + return true; +} + +/** @param {any} root @param {any} node */ +function instanceContains(root, node) { + for (let o = node; o; o = o.parent) if (o === root) return true; + return false; +} + +/** After replacing, the selection's gizmo is attached to a removed object: re-seat it. */ +function reseatSelection() { + const set = get(selectedObjects); + if (set.length) applySelectionSet([...set]); +} + +/** @param {any} entry @param {'before'|'after'} side */ +function applySync(entry, side) { + let any = false; + for (const item of entry.items) if (replaceInPlace(item[side], item.parentUuid)) any = true; + for (const g of entry.graphs ?? []) setGraphDoc(g.uuid, g[side]); + reseatSelection(); + if (!any) showToast('Cannot undo/redo: those instances no longer exist'); + return any; +} + +registerHistoryKind('prefabsync', (entry, state) => applySync(entry, state === entry.before ? 'before' : 'after')); + +/** + * The element an instance's overrides are measured against: the revision it was placed + * from when that is still known, else `fallback` (the element the caller replaced), else + * null — no overrides detected, which the merge documents. + * @param {string} id @param {number} rev @param {any} fallback + */ +async function baseFor(id, rev, fallback) { + const known = await prefabRevision(id, rev); + return known ?? fallback ?? null; +} + +/** + * Bring instances of prefab `id` up to its current element, keeping their overrides unless + * `reset`. ONE undo step, replicated per instance. Returns what happened. + * @param {string} id + * @param {{reset?: boolean, uuids?: string[]|null, fallback?: {element: any, graphs: any}|null, label?: string, quiet?: boolean}} [opts] + * `uuids` limits the update to those instance roots; `fallback` is the base for an + * instance whose own revision is no longer kept + */ +export async function updateInstances(id, opts = {}) { + const { reset = false, uuids = null, fallback = null, quiet = false } = opts; + const entry = prefabById(id); + if (!entry?.element) return { updated: 0, overrides: 0, total: 0 }; + const rev = entry.rev ?? 0; + const roots = prefabInstances(id).filter((o) => !uuids || uuids.includes(o.uuid)); + const group = get(objectsGroup); + /** @type {any[]} */ + const items = []; + /** @type {any[]} */ + const graphs = []; + let overrides = 0; + /** @type {Map} */ + const bases = new Map(); + for (const root of roots) { + // a nested instance inside another instance being replaced in this same pass would + // be replaced twice; the outer element already carries the inner one + const link = linkOf(root); + if (!link) continue; + if (!bases.has(link.rev)) bases.set(link.rev, await baseFor(id, link.rev, fallback)); + const base = bases.get(link.rev); + const before = elementOf(root); + const merged = mergeInstance(before, base?.element ?? null, entry.element, { prefabId: id, rev, reset }); + overrides += merged.overrides.length; + const parentUuid = root.parent && root.parent !== group ? root.parent.uuid : null; + if (!same(before.object, merged.element.object) || !same(before.materials, merged.element.materials) || !same(before.geometries, merged.element.geometries)) + items.push({ uuid: root.uuid, parentUuid, before, after: merged.element }); + // the flow graphs, node by node of the merged instance + const walk = (/** @type {any} */ node) => { + const key = instanceKey(node, id); + if (key !== undefined) { + const nextG = entry.graphs?.[key] ?? null; + const baseG = base?.graphs?.[key] ?? null; + if (nextG || baseG) { + const mine = serializedGraph(graphOf(node.uuid)); + const kept = !reset && !!base && !same(graphContent(mine), graphContent(baseG)); + if (kept) overrides++; + else if (!same(graphContent(mine), graphContent(nextG))) + graphs.push({ uuid: node.uuid, before: mine, after: nextG ? freshGraph(nextG) : null }); + } + } + for (const c of node.children ?? []) walk(c); + }; + walk(merged.element.object); + } + // nested instances of THIS prefab inside another of its instances are replaced with the + // outer one; drop the inner items so nothing is applied twice + const outer = new Set(items.map((i) => i.uuid)); + const flat = items.filter((item) => { + const node = group?.getObjectByProperty('uuid', item.uuid); + for (let o = node?.parent; o && o !== group; o = o.parent) if (outer.has(o.uuid)) return false; + return true; + }); + if (!flat.length && !graphs.length) { + if (!quiet) showToast(roots.length ? 'Every instance already matches "' + entry.name + '"' : 'No instances of "' + entry.name + '" in this scene'); + return { updated: 0, overrides, total: roots.length }; + } + const record = { + kind: 'prefabsync', + label: (reset ? 'Reset ' : 'Update ') + roots.length + ' instance' + (roots.length === 1 ? '' : 's') + ' of ' + entry.name, + items: flat, + graphs, + before: 'before', + after: 'after' + }; + applySync(record, 'after'); + recordEntry(record); + if (!quiet) + showToast( + (reset ? 'Reset ' : 'Updated ') + + flat.length + + ' instance' + + (flat.length === 1 ? '' : 's') + + ' of "' + + entry.name + + '"' + + (!reset && overrides ? ' — kept ' + overrides + ' override' + (overrides === 1 ? '' : 's') : '') + ); + return { updated: flat.length, overrides, total: roots.length }; +} + +/** + * After a prefab's bytes changed: offer to bring its instances along. `exclude` are the + * objects the edit was made FROM — they are updated silently (their revision has to move) + * and not counted. + * @param {string} id @param {{exclude?: string[], fallback?: any, extra?: any[]}} [opts] + */ +export function offerInstanceUpdate(id, opts = {}) { + const entry = prefabById(id); + if (!entry) return; + const exclude = new Set(opts.exclude ?? []); + const all = prefabInstances(id); + const others = all.filter((o) => !exclude.has(o.uuid)); + const sources = all.filter((o) => exclude.has(o.uuid)).map((o) => o.uuid); + // the instance the edit came from IS the new revision: re-link it now, no question asked + if (sources.length) void updateInstances(id, { uuids: sources, fallback: opts.fallback, quiet: true }); + if (!others.length) { + showToast(`Updated "${entry.name}"`, opts.extra); + return; + } + const n = others.length; + const uuids = others.map((o) => o.uuid); + showToast(`Updated "${entry.name}" — ${n} other instance${n === 1 ? '' : 's'} in this scene`, [ + { label: `Update ${n} instance${n === 1 ? '' : 's'}`, action: () => void updateInstances(id, { uuids, fallback: opts.fallback }) }, + { label: 'Update, reset overrides', action: () => void updateInstances(id, { uuids, fallback: opts.fallback, reset: true }) }, + ...(opts.extra ?? []) + ]); +} + +/** "Apply to prefab": the instance's state becomes the prefab's next revision, then the + * other instances are offered the update. @param {string} uuid */ +export async function applyInstanceToPrefab(uuid) { + const group = get(objectsGroup); + const root = group?.getObjectByProperty('uuid', uuid); + const link = linkOf(root); + if (!root || !link) return null; + const entry = prefabById(link.id); + if (!entry) { + showToast('That prefab is not in your library'); + return null; + } + const previous = { element: entry.element, graphs: entry.graphs ?? null }; + const next = await updatePrefab(link.id, [uuid], { toast: false }); + if (!next) return null; + offerInstanceUpdate(link.id, { exclude: [uuid], fallback: previous }); + return next; +} + +/** Reset one instance to its prefab (placement kept). @param {string} uuid */ +export function resetInstance(uuid) { + const link = linkOf(get(objectsGroup)?.getObjectByProperty('uuid', uuid)); + if (!link) return Promise.resolve(null); + return updateInstances(link.id, { uuids: [uuid], reset: true }); +} + +/** Bring one instance up to its prefab, overrides kept. @param {string} uuid */ +export function syncInstance(uuid) { + const link = linkOf(get(objectsGroup)?.getObjectByProperty('uuid', uuid)); + if (!link) return Promise.resolve(null); + return updateInstances(link.id, { uuids: [uuid] }); +} + +/** What an instance overrides (field labels), or null when its base is unknown. + * @param {string} uuid @returns {Promise} */ +export async function instanceOverrides(uuid) { + const root = get(objectsGroup)?.getObjectByProperty('uuid', uuid); + const link = linkOf(root); + if (!link) return null; + const base = await prefabRevision(link.id, link.rev); + if (!base) return null; + return overridesOf(elementOf(root), base.element, link.id); +} + +/** Cut an instance loose from its prefab: an ordinary object from now on. ONE undo step. + * @param {string} uuid */ +export function unlinkInstance(uuid) { + const group = get(objectsGroup); + const root = group?.getObjectByProperty('uuid', uuid); + const link = linkOf(root); + if (!root || !link) return false; + const before = elementOf(root); + const after = JSON.parse(JSON.stringify(before)); + const patch = unlinkPatch(after.object, link.id); + const walk = (/** @type {any} */ node) => { + if (patch[node.uuid]) { + node.userData = patch[node.uuid]; + if (!Object.keys(node.userData).length) delete node.userData; + } + for (const c of node.children ?? []) walk(c); + }; + walk(after.object); + const parentUuid = root.parent && root.parent !== group ? root.parent.uuid : null; + const record = { kind: 'prefabsync', label: 'Unlink from prefab', items: [{ uuid, parentUuid, before, after }], graphs: [], before: 'before', after: 'after' }; + applySync(record, 'after'); + recordEntry(record); + showToast(`Unlinked from "${prefabById(link.id)?.name ?? 'its prefab'}" — updates will not reach it`); + return true; +} + +/** Select every instance of a prefab. @param {string} id */ +export function selectInstances(id) { + const uuids = prefabInstances(id).map((o) => o.uuid); + if (uuids.length) applySelectionSet(uuids); + return uuids.length; +} diff --git a/src/lib/prefabSync.js b/src/lib/prefabSync.js index 0eade8f9..e55e7ae0 100644 --- a/src/lib/prefabSync.js +++ b/src/lib/prefabSync.js @@ -84,6 +84,9 @@ export function stampElementKeys(element, prefabId) { if (!key || seen.has(key)) key = ud[KEY_FIELD] && !seen.has(ud[KEY_FIELD]) ? ud[KEY_FIELD] : node.uuid; seen.add(/** @type {string} */ (key)); ud[KEY_FIELD] = key; + // a prefab never contains an instance of itself (an update made from a selection + // that held one would otherwise nest the prefab inside its own next revision) + if (ud[LINK_FIELD]?.id === prefabId) delete ud[LINK_FIELD]; if (ud[KEYS_FIELD]) { const keys = { ...ud[KEYS_FIELD] }; delete keys[prefabId]; diff --git a/src/lib/prefabs.js b/src/lib/prefabs.js index a88d0409..b3ffd7b0 100644 --- a/src/lib/prefabs.js +++ b/src/lib/prefabs.js @@ -6,13 +6,19 @@ import { recordObjectPresence, beginHistoryBatch, endHistoryBatch } from './hist import { patch as audioPatch, addCablesRemapped } from './audioPatch'; import { selectObject } from './objectActions'; import { parkEditOverlays, stripEditOverlays } from './editOverlays'; -import { idbGet, idbPut } from './idb'; +import { idbGet, idbPut, idbDelete } from './idb'; +import { stampElementKeys, linkNode, linkRoot, elementKey } from './prefabSync'; +import { graphOf } from '../stores/flowStore'; +import { serializeNode, serializeEdge } from './nodesHandler'; // Personal prefab library: save any object/group as a reusable asset // (ObjectLoader snapshot + rendered thumbnail) in IndexedDB. The library is // LOCAL by design — instantiated copies replicate like any other object. -/** @type {import('svelte/store').Writable} [{id, name, createdAt, thumbnail, element}] */ +/** @type {import('svelte/store').Writable} [{id, name, createdAt, thumbnail, element, + * rev?, graphs?, folder?, tags?}] — 37 R4: `rev` counts element changes (absent = 0), + * `graphs` the objects' flow graphs keyed by element node key, `folder`/`tags` the + * Library's own organisation (prefabLibrary.js) */ export const prefabs = writable([]); const KEY = 'prefabs-v1'; @@ -37,6 +43,86 @@ async function persist() { } } +// ---- 37 R4: revisions --------------------------------------------------------------- +// An instance remembers the REVISION it was placed from, because an override is "what this +// instance changed relative to what it got" — so an update needs the element the instance +// was made from, not just the one before the edit. The older elements live under their own +// idb key per prefab (one read per update, never in the in-memory list), newest REV_KEEP. + +const REV_KEEP = 8; +/** @param {string} id */ +const revKey = (id) => 'prefab-revs-v1:' + id; + +/** Keep `entry`'s CURRENT bytes as its revision before they are replaced. @param {any} entry */ +async function keepRevision(entry) { + if (!entry?.id || !entry.element) return; + try { + /** @type {Record} */ + const revs = (await idbGet(revKey(entry.id))) ?? {}; + revs[entry.rev ?? 0] = { element: entry.element, graphs: entry.graphs ?? null }; + const keep = Object.keys(revs) + .map(Number) + .sort((a, b) => b - a) + .slice(0, REV_KEEP); + /** @type {Record} */ + const out = {}; + for (const r of keep) out[r] = revs[r]; + await idbPut(revKey(entry.id), out); + } catch (error) { + console.log('prefab revision keep failed', error); + } +} + +/** + * The element (and graphs) a prefab had at `rev` — the current one, a kept older one, or + * null when it is no longer known. + * @param {string} id @param {number} rev + * @returns {Promise<{element: any, graphs: any}|null>} + */ +export async function prefabRevision(id, rev) { + const entry = prefabById(id); + if (!entry) return null; + if ((entry.rev ?? 0) === rev) return { element: entry.element, graphs: entry.graphs ?? null }; + try { + const revs = await idbGet(revKey(id)); + return revs?.[rev] ?? null; + } catch { + return null; + } +} + +/** + * The objects' flow graphs, keyed by the element node key they will travel under — the + * same `{nodes, edges}` payload a `.tpnode` carries, so a prefab's logic is a node group + * like any other (37 R4: "a prefab can carry its graph group"). + * @param {any} element a STAMPED element @param {Record} liveToElement + * @returns {Record|null} + */ +function captureGraphs(element, liveToElement) { + /** @type {Record} */ + const keyOf = {}; + const walk = (/** @type {any} */ n) => { + keyOf[n.uuid] = elementKey(n); + for (const c of n.children ?? []) walk(c); + }; + walk(element.object); + /** @type {Record} */ + const graphs = {}; + for (const [live, el] of Object.entries(liveToElement)) { + const graph = graphOf(live); + if (!graph || (!graph.nodes?.length && !graph.edges?.length) || !keyOf[el]) continue; + graphs[keyOf[el]] = { nodes: graph.nodes.map(serializeNode), edges: graph.edges.map(serializeEdge) }; + } + return Object.keys(graphs).length ? graphs : null; +} + +/** Key a freshly built element for prefab `id` and capture its graphs. + * @param {{element: any, liveToElement: Record}} snap @param {string} id */ +function finishElement(snap, id) { + stampElementKeys(snap.element, id); + return captureGraphs(snap.element, snap.liveToElement); +} + /** Small offscreen render of the snapshot @param {any} element */ function renderThumbnail(element) { try { @@ -92,7 +178,7 @@ function stampCables(element, cables) { * size refusal lives in exactly one place. * @param {string[]} uuids @param {string=} name * @param {{keepUuids?: boolean}} [opts] - * @returns {{element: any, name: string}|null} + * @returns {{element: any, name: string, liveToElement: Record}|null} */ function buildPrefabElement(uuids, name, opts = {}) { const group = get(objectsGroup); @@ -108,6 +194,9 @@ function buildPrefabElement(uuids, name, opts = {}) { /** @type {any} */ let element; try { + // toJSON reads the matrix the last render composed — compose it now, or a move + // made since the last frame never reaches the prefab + object.updateMatrixWorld(true); element = object.toJSON(); } finally { unpark(); @@ -121,7 +210,7 @@ function buildPrefabElement(uuids, name, opts = {}) { const identity = {}; object.traverse((/** @type {any} */ node) => (identity[node.uuid] = node.uuid)); stampCables(element, cablesWithin(identity)); - return { element, name: name || object.name || object.type }; + return { element, name: name || object.name || object.type, liveToElement: identity }; } const holder = new THREE.Group(); holder.name = name || 'Group'; @@ -159,25 +248,29 @@ function buildPrefabElement(uuids, name, opts = {}) { holder.add(clone); } if (!holder.children.length) return null; + holder.updateMatrixWorld(true); const element = holder.toJSON(); stampCables(element, cablesWithin(remap)); if (JSON.stringify(element).length > PREFAB_LIMIT) { showToast('Selection is too large for a prefab (>5 MB)'); return null; } - return { element, name: holder.name }; + return { element, name: holder.name, liveToElement: remap }; } /** Save an object (by uuid) into the prefab library @param {string} uuid @param {string=} name */ export async function savePrefab(uuid, name) { const snap = buildPrefabElement([uuid], name); if (!snap) return null; + const id = crypto.randomUUID(); + const graphs = finishElement(snap, id); const entry = { - id: crypto.randomUUID(), + id, name: snap.name, createdAt: Date.now(), thumbnail: renderThumbnail(snap.element), - element: snap.element + element: snap.element, + ...(graphs ? { graphs } : {}) }; prefabs.update((list) => [...list, entry]); await persist(); @@ -192,12 +285,15 @@ export async function savePrefabSelection(uuids, name) { if (!uuids || uuids.length <= 1) return savePrefab(uuids?.[0], name); const snap = buildPrefabElement(uuids, name); if (!snap) return null; + const id = crypto.randomUUID(); + const graphs = finishElement(snap, id); const entry = { - id: crypto.randomUUID(), + id, name: snap.name, createdAt: Date.now(), thumbnail: renderThumbnail(snap.element), - element: snap.element + element: snap.element, + ...(graphs ? { graphs } : {}) }; prefabs.update((list) => [...list, entry]); await persist(); @@ -219,12 +315,17 @@ export async function updatePrefab(id, uuids, opts = {}) { if (!entry) return null; const snap = buildPrefabElement(uuids, entry.name); if (!snap) return null; + const graphs = finishElement(snap, id); + await keepRevision(entry); // 37 R4: the instances placed from it still need it const next = { ...entry, element: snap.element, + graphs, thumbnail: renderThumbnail(snap.element), - updatedAt: Date.now() + updatedAt: Date.now(), + rev: (entry.rev ?? 0) + 1 }; + if (!graphs) delete next.graphs; prefabs.update((list) => list.map((p) => (p.id === id ? next : p))); await persist(); if (opts.toast !== false) showToast(`Updated "${entry.name}" from the selection`); @@ -245,7 +346,7 @@ export async function updatePrefab(id, uuids, opts = {}) { * `element` is plain JSON and `thumbnail` a dataURL string, so the snapshot is a value: * nothing it points at can be mutated out from under it. * @param {string} id - * @returns {{id: string, element: any, thumbnail: string|null, updatedAt: number|null}|null} + * @returns {{id: string, element: any, graphs: any, thumbnail: string|null, updatedAt: number|null}|null} */ export function prefabSnapshot(id) { const entry = prefabById(id); @@ -253,6 +354,7 @@ export function prefabSnapshot(id) { return { id, element: entry.element, + graphs: entry.graphs ?? null, thumbnail: entry.thumbnail ?? null, updatedAt: entry.updatedAt ?? null }; @@ -262,13 +364,26 @@ export function prefabSnapshot(id) { * Put a `prefabSnapshot` back — the Undo half. Keeps the entry's CURRENT name (a rename * between the update and the undo is a different edit, and reverting it too would be * undoing something nobody asked about). - * @param {{id: string, element: any, thumbnail: string|null, updatedAt: number|null}|null} snap + * + * 37 R4: putting old bytes back is a NEW revision, never a step back in the count — + * instances updated in between were placed from the revision being replaced, and their + * overrides are measured against it. + * @param {{id: string, element: any, graphs?: any, thumbnail: string|null, updatedAt: number|null}|null} snap */ export async function restorePrefabBytes(snap) { if (!snap?.id || !snap.element) return null; const entry = prefabById(snap.id); if (!entry) return null; // deleted in the meantime — nothing to restore into - const next = { ...entry, element: snap.element, thumbnail: snap.thumbnail, updatedAt: snap.updatedAt }; + await keepRevision(entry); + const next = { + ...entry, + element: snap.element, + graphs: snap.graphs ?? null, + thumbnail: snap.thumbnail, + updatedAt: snap.updatedAt, + rev: (entry.rev ?? 0) + 1 + }; + if (!next.graphs) delete next.graphs; if (next.updatedAt === null) delete next.updatedAt; // it had never been updated prefabs.update((list) => list.map((p) => (p.id === snap.id ? next : p))); await persist(); @@ -298,9 +413,11 @@ export async function duplicatePrefab(id) { id: crypto.randomUUID(), name, createdAt: Date.now(), - element: JSON.parse(JSON.stringify(source.element)) + element: JSON.parse(JSON.stringify(source.element)), + ...(source.graphs ? { graphs: JSON.parse(JSON.stringify(source.graphs)) } : {}) }; delete entry.updatedAt; + delete entry.rev; // a new prefab: its instances start at revision 0 prefabs.update((list) => [...list, entry]); await persist(); return entry; @@ -383,6 +500,13 @@ export function prefabElementFor(uuids, name, opts) { return buildPrefabElement(uuids, name, opts); } +/** 37 R4: key an element built through `prefabElementFor` for the record `id` it will be + * stored under, and capture its objects' graphs — the saveAs path's half of finishElement. + * @param {{element: any, liveToElement: Record}} snap @param {string} id */ +export function finishPrefabElement(snap, id) { + return finishElement(snap, id); +} + /** The offscreen render, exposed so a byte-backed prefab gets the same picture. * @param {any} element */ export function prefabThumbnail(element) { @@ -392,16 +516,20 @@ export function prefabThumbnail(element) { /** * Store a prefab record built elsewhere (see $lib/saveAs). One write path, so the size * refusal, the persist and the toast cannot drift between the formats. - * @param {{name: string, element: any, thumbnail?: string|null, format?: string, bytes?: any}} spec + * @param {{name: string, element: any, id?: string, graphs?: any, thumbnail?: string|null, format?: string, bytes?: any}} spec */ export async function addPrefabRecord(spec) { if (!spec?.element) return null; + const id = spec.id ?? crypto.randomUUID(); + // a record built elsewhere may not be keyed yet (stamping twice is a no-op) + stampElementKeys(spec.element, id); const entry = { - id: crypto.randomUUID(), + id, name: spec.name || 'Prefab', createdAt: Date.now(), thumbnail: spec.thumbnail ?? renderThumbnail(spec.element), element: spec.element, + ...(spec.graphs ? { graphs: spec.graphs } : {}), ...(spec.format && spec.format !== 'snapshot' ? { format: spec.format, bytes: spec.bytes } : {}) }; prefabs.update((list) => [...list, entry]); @@ -430,11 +558,23 @@ export function instantiatePrefab(prefab, position) { // it is handed one at the call site rather than changing either contract. /** @type {Map} the saved uuid -> the fresh one, for the carries below */ const uuidMap = new Map(); + // 37 R4: a library prefab's instance is LINKED to it — every node keyed so an update + // can find it again. A snapshot that is not a library record (a VR sleeve slot) is not. + const linked = !!prefab.id && !!prefabById(prefab.id); + /** @type {Record} element key -> fresh uuid (the graph carry) */ + const freshByKey = {}; object.traverse((node) => { const fresh = crypto.randomUUID(); uuidMap.set(node.uuid, fresh); + freshByKey[elementKey(node)] = fresh; + if (linked) linkNode(node, prefab.id); + else if (node.userData?.prefabKey !== undefined) { + node.userData = { ...node.userData }; + delete node.userData.prefabKey; + } node.uuid = fresh; }); + if (linked) linkRoot(object, prefab.id, prefab.rev ?? 0); const cables = Array.isArray(object.userData?.cables) ? object.userData.cables : []; // the snapshot stays in the LIBRARY, not on the instance - and ObjectLoader hands the // parsed object the element's userData by REFERENCE, so the copy comes first or the @@ -465,9 +605,24 @@ export function instantiatePrefab(prefab, position) { // object back); the documents follow, because reading the zip is async and holding // the placement up for it would be the wrong trade. if (prefab.format === 'tpscene' && prefab.bytes) void carryPrefabDocuments(prefab, uuidMap); + else if (prefab.graphs) void carryPrefabGraphs(prefab.graphs, freshByKey); return object; } +/** + * 37 R4: a snapshot prefab's own flow graphs, installed on the fresh objects. The object's + * create entry owns the lifecycle (copyGraphFrom's own rule), so nothing records here. + * @param {Record} graphs @param {Record} freshByKey + */ +async function carryPrefabGraphs(graphs, freshByKey) { + try { + const { copyGraphFrom } = await import('./flowGraphs'); + for (const [key, graph] of Object.entries(graphs)) if (freshByKey[key]) copyGraphFrom(graph, freshByKey[key]); + } catch (error) { + console.log('prefab graphs failed', error); + } +} + /** * The second half of a .tpscene prefab. Dynamic import on purpose: sessions.js pulls the * zip library and the whole save machinery, and a prefab library has no business paying @@ -499,6 +654,7 @@ async function carryPrefabDocuments(prefab, uuidMap) { export async function removePrefab(id) { prefabs.update((list) => list.filter((p) => p.id !== id)); await persist(); + idbDelete(revKey(id)).catch(() => {}); } /** @param {string} id @param {string} name */ @@ -510,7 +666,7 @@ export async function renamePrefab(id, name) { /** JSON string for sharing a prefab as a file @param {any} prefab */ export function exportPrefab(prefab) { - return JSON.stringify({ name: prefab.name, element: prefab.element }); + return JSON.stringify({ name: prefab.name, element: prefab.element, ...(prefab.graphs ? { graphs: prefab.graphs } : {}) }); } /** Import a previously exported prefab @param {string} json */ @@ -518,12 +674,15 @@ export async function importPrefab(json) { try { const parsed = JSON.parse(json); if (!parsed?.element) throw new Error('not a prefab file'); + const id = crypto.randomUUID(); + stampElementKeys(parsed.element, id); const entry = { - id: crypto.randomUUID(), + id, name: parsed.name || 'Imported prefab', createdAt: Date.now(), thumbnail: renderThumbnail(parsed.element), - element: parsed.element + element: parsed.element, + ...(parsed.graphs && typeof parsed.graphs === 'object' ? { graphs: parsed.graphs } : {}) }; prefabs.update((list) => [...list, entry]); await persist(); diff --git a/src/lib/saveAs.js b/src/lib/saveAs.js index c2562c48..a7f5de86 100644 --- a/src/lib/saveAs.js +++ b/src/lib/saveAs.js @@ -42,7 +42,7 @@ import { get } from 'svelte/store'; import { objectsGroup } from '../stores/sceneStore'; import { showToast } from '../stores/appStore'; -import { addPrefabRecord, prefabElementFor, prefabThumbnail } from './prefabs'; +import { addPrefabRecord, prefabElementFor, prefabThumbnail, finishPrefabElement } from './prefabs'; import { gltfBytesFor } from './fileHandler.svelte.js'; /** @@ -135,7 +135,10 @@ export async function saveSelectionAs(formatId, uuids, name) { if (format.id === 'snapshot') { const element = prefabElementFor(list, name); if (!element) return null; - const entry = await addPrefabRecord({ name: element.name, element: element.element }); + // 37 R4: keyed for its own id, and carrying the objects' graphs + const id = crypto.randomUUID(); + const graphs = finishPrefabElement(element, id); + const entry = await addPrefabRecord({ id, name: element.name, element: element.element, graphs }); if (entry) showToast('Prefab saved to your library'); return entry; } From 00f3b89b68b9166e9db6ac75abd9f144d236786f Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Tue, 6 Oct 2026 04:01:27 +0300 Subject: [PATCH 12/65] [fix] a module-declared touch stick stays live under the module's own keys claim - Race claims 'keys' (it reads input itself), and TouchPlayControls stood the whole stick down under any keys claim, so a phone could not steer (race-touch-vr run 1: move.x 0) - resolveTouchControls reports declared; the stick (not the look drag) stays live when a module declared the controls; vitest touchActions 27/27 Co-Authored-By: Claude Opus 5.5 (1M context) --- src/components/play/TouchPlayControls.svelte | 6 ++++-- src/lib/touchActions.js | 7 +++++-- tests/unit/touchActions.test.js | 3 +++ 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/components/play/TouchPlayControls.svelte b/src/components/play/TouchPlayControls.svelte index 8f7ec181..ddb37c83 100644 --- a/src/components/play/TouchPlayControls.svelte +++ b/src/components/play/TouchPlayControls.svelte @@ -101,7 +101,9 @@ // stick that stopped where the keyboard kept going would move the same scene two // different ways depending on the device. const inputLive = $derived(shown && !$playPointerFree && !$inputClaims.includes('keys')); - const stickLive = $derived(inputLive && spec.stick); + // 37: except the stick a MODULE declared — it claimed the keys because it reads the input + // itself (api.input().touch), and the stick is how a phone feeds it (Race's steering) + const stickLive = $derived(spec.stick && (inputLive || (shown && !$playPointerFree && !!spec.declared))); const lookLive = $derived(inputLive && spec.look); // buttons: in play while the menu substate is not up (a module's key claim does NOT // stop them — it still reads keys through api.onInput), or in the editor when asked @@ -220,7 +222,7 @@ /** @param {PointerEvent} event */ function onPointerDown(event) { if (/** @type {any} */ (event)[TOUCH_ACTION_EVENT]) return; - if (!inputLive || event.pointerType !== 'touch' || !onCanvas(event.target)) return; + if (!(inputLive || stickLive) || event.pointerType !== 'touch' || !onCanvas(event.target)) return; // the stick owns the left half (or its own base, wherever the player put it); with // no stick the whole view looks const nearBase = diff --git a/src/lib/touchActions.js b/src/lib/touchActions.js index 20435db5..abf76843 100644 --- a/src/lib/touchActions.js +++ b/src/lib/touchActions.js @@ -169,7 +169,10 @@ export function declareTouchActions(owner, actions, opts = {}) { /* --------------------------------------------------------------- resolution ---- */ /** - * @typedef {{stick: boolean, look: boolean, preset: string, actions: TouchAction[]}} TouchControlsSpec + * `declared` = a module asked for these controls (api.input.actions) rather than the scene + * implying them — the module then READS the stick itself (api.input().touch), so the stick + * stays live under the module's own 'keys' claim (37: Race steers with it while it owns WASD). + * @typedef {{stick: boolean, look: boolean, preset: string, actions: TouchAction[], declared: boolean}} TouchControlsSpec */ /** @@ -206,7 +209,7 @@ export function resolveTouchControls({ declared = [], walk = false, fly = false, add(normalizeAction({ id: 'key:' + code, label: keyLabel(code), icon: '', keys: [code] })); } } - return { stick, look, preset, actions }; + return { stick, look, preset, actions, declared: declared.length > 0 }; } /* ------------------------------------------------------------------- prefs ---- */ diff --git a/tests/unit/touchActions.test.js b/tests/unit/touchActions.test.js index 6c1d31b5..dc061f7e 100644 --- a/tests/unit/touchActions.test.js +++ b/tests/unit/touchActions.test.js @@ -106,6 +106,9 @@ describe('where buttons come from', () => { ].map((a) => /** @type {any} */ (normalizeAction(a, 'race'))); const spec = resolveTouchControls({ declared: [{ owner: 'race', actions: pedals, preset: 'drive', at: 1 }] }); expect(spec.preset).toBe('drive'); + // a module asked for it, so the overlay keeps its stick live under the module's 'keys' claim + expect(spec.declared).toBe(true); + expect(resolveTouchControls({}).declared).toBe(false); expect(spec.stick).toBe(true); expect(spec.look).toBe(false); // movement keys are fine as a DECLARED action (only scene-implied keys skip them) From 29928870909690ff96be7fe3db6de64081a51bb5 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Tue, 6 Oct 2026 04:01:41 +0300 Subject: [PATCH 13/65] [test] R14 e2e workspace-layouts (save/apply live, mounted windows re-read, UI, reload, Settings) Written, not yet run green - queued on the snapshot runner. Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/e2e/workspace-layouts.test.cjs | 183 +++++++++++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100644 tests/e2e/workspace-layouts.test.cjs diff --git a/tests/e2e/workspace-layouts.test.cjs b/tests/e2e/workspace-layouts.test.cjs new file mode 100644 index 00000000..cfb267d3 --- /dev/null +++ b/tests/e2e/workspace-layouts.test.cjs @@ -0,0 +1,183 @@ +// 37 R14: named workspace layouts — save the windows you have open under a name and switch +// between arrangements live (no page reload). The load-bearing checks are the ones on +// windows that STAY MOUNTED while closed (chat, the object list, the dock-family panels' +// docked flag): only the `onLayoutRestore` re-reads can move those. +const h = require('./helpers.cjs'); + +h.run(async () => { + const browser = await h.launch(); + const A = await h.setupPage(browser, 'A', { storage: { flowDocked: 'false', explorerDocked: 'true' } }); + const page = A.page; + + const st = (/** @type {string} */ k) => page.evaluate((key) => localStorage.getItem(key), k); + const rectOf = (/** @type {string} */ sel) => + page.evaluate((s) => { + const el = document.querySelector(s); + if (!el) return null; + const r = el.getBoundingClientRect(); + return { left: Math.round(r.left), top: Math.round(r.top), w: Math.round(r.width), h: Math.round(r.height), shown: r.width > 0 }; + }, sel); + /** drag a window by its header by (dx, dy) with the real mouse */ + async function dragHeader(/** @type {string} */ sel, dx, dy) { + const r = await page.evaluate((s) => { + const hd = document.querySelector(s + ' .move-handle'); + const b = hd.getBoundingClientRect(); + return { x: b.left + 40, y: b.top + b.height / 2 }; + }, sel); + await page.mouse.move(r.x, r.y); + await page.mouse.down(); + await page.mouse.move(r.x + dx / 2, r.y + dy / 2, { steps: 5 }); + await page.mouse.move(r.x + dx, r.y + dy, { steps: 5 }); + await page.mouse.up(); + await page.waitForTimeout(200); + } + + // ---- 1. arrangement Alpha: node editor FLOATING, Explorer docked, chat open at a dragged spot + await page.evaluate(() => { + const s = window.__stores; + s.flowGraphClose.set(false); + s.explorerClose.set(false); + s.chatHidden.set(''); + }); + await page.waitForTimeout(800); + h.check(!!(await rectOf('#flow-window'))?.shown, 'premise: the node editor opens floating'); + await dragHeader('#chat-window', -300, -120); + const chatAlpha = await rectOf('#chat-window'); + const storedAlpha = JSON.parse((await st('win:chat')) ?? 'null'); + h.check(!!storedAlpha && Math.abs(storedAlpha.left - chatAlpha.left) <= 2, `premise: the chat drag persisted its rect (${JSON.stringify(storedAlpha)})`); + await page.evaluate(() => window.__stores.bottomDock.dockHeight.set(300)); + const saveA = await page.evaluate(() => window.__stores.uiLayouts.saveLayout('Alpha')); + h.check(saveA.ok && !saveA.updated, 'Alpha saved as a new layout'); + + // ---- 2. arrangement Beta: node editor closed, profiler open, chat moved again, taller dock + await page.evaluate(() => { + const s = window.__stores; + s.flowGraphClose.set(true); + s.profilerClose.set(false); + }); + await dragHeader('#chat-window', 220, 80); + await page.evaluate(() => window.__stores.bottomDock.dockHeight.set(420)); + await page.waitForTimeout(300); + const chatBeta = await rectOf('#chat-window'); + h.check(Math.abs(chatBeta.left - chatAlpha.left) > 100, `premise: chat moved between the two (${chatAlpha.left} -> ${chatBeta.left})`); + const saveB = await page.evaluate(() => window.__stores.uiLayouts.saveLayout('Beta')); + h.check(saveB.ok, 'Beta saved'); + const ids = await page.evaluate(() => { + let list = []; + window.__stores.uiLayouts.uiLayouts.subscribe((v) => (list = v))(); + return list.map((l) => ({ id: l.id, name: l.name })); + }); + h.check(ids.length === 2 && ids[0].name === 'Alpha' && ids[1].name === 'Beta', 'two layouts listed in save order'); + + // ---- 3. apply Alpha + const okA = await page.evaluate((id) => window.__stores.uiLayouts.applyLayout(id), ids[0].id); + await page.waitForTimeout(600); + h.check(okA === true, 'applyLayout(Alpha) resolves true'); + const afterA = await page.evaluate(() => { + const s = window.__stores; + const read = (/** @type {any} */ store) => { + let v; + store.subscribe((x) => (v = x))(); + return v; + }; + return { + flowOpen: read(s.flowGraphClose) === false, + profilerOpen: read(s.profilerClose) === false, + explorerOpen: read(s.explorerClose) === false, + chatOpen: read(s.chatHidden) === '', + dockH: read(s.bottomDock.dockHeight) + }; + }); + h.check(afterA.flowOpen, 'Alpha: the node editor is open again'); + h.check(!afterA.profilerOpen, 'Alpha: the profiler (opened after the save) is closed'); + h.check(afterA.explorerOpen && afterA.chatOpen, 'Alpha: Explorer and chat are open'); + h.check(afterA.dockH === 300, `Alpha: the bottom dock height comes back (${afterA.dockH})`); + h.check(!!(await rectOf('#flow-window'))?.shown, 'Alpha: the node editor came back FLOATING'); + // the chat window never unmounts (class-hidden): only the dragWindow re-read can move it + const chatAfterA = await rectOf('#chat-window'); + h.check(Math.abs(chatAfterA.left - chatAlpha.left) <= 2 && Math.abs(chatAfterA.top - chatAlpha.top) <= 2, `Alpha: the live chat window moved back (${chatAfterA.left},${chatAfterA.top} vs ${chatAlpha.left},${chatAlpha.top})`); + + // ---- 4. apply Beta + await page.evaluate((id) => window.__stores.uiLayouts.applyLayout(id), ids[1].id); + await page.waitForTimeout(600); + const chatAfterB = await rectOf('#chat-window'); + h.check(Math.abs(chatAfterB.left - chatBeta.left) <= 2, `Beta: the chat window moved to Beta's spot (${chatAfterB.left} vs ${chatBeta.left})`); + const flowB = await page.evaluate(() => { + let v; + window.__stores.flowGraphClose.subscribe((x) => (v = x))(); + return v; + }); + h.check(flowB === true, 'Beta: the node editor is closed'); + + // ---- 5. a docked flag on a mounted panel follows the layout (the panel re-read) + await page.evaluate(() => localStorage.setItem('flowDocked', 'true')); + await page.evaluate((id) => window.__stores.uiLayouts.applyLayout(id), ids[0].id); + await page.waitForTimeout(600); + h.check(!!(await rectOf('#flow-window'))?.shown && (await st('flowDocked')) === 'false', 'a stale docked flag is overwritten: Alpha re-floats the node editor'); + + // ---- 6. the UI: menu ▸ Layouts popover saves, lists, applies, renames, deletes + await page.evaluate(() => window.__stores.closeMenu.set(false)); + await page.waitForTimeout(300); + await page.click('#open-layouts'); + await page.waitForSelector('#layouts-menu'); + h.check(true, 'menu ▸ Layouts opens the popover'); + await page.fill('#layouts-menu-name', 'Gamma'); + await page.click('#layouts-menu-save'); + await page.waitForTimeout(200); + const rows = await page.$$eval('#layouts-menu-list li', (els) => els.map((e) => e.textContent?.trim())); + h.check(rows.length === 3 && rows[2]?.includes('Gamma'), `the saved layout is listed (${rows.join(' | ')})`); + await page.fill('#layouts-menu-name', 'gamma'); + await page.click('#layouts-menu-save'); + await page.waitForTimeout(200); + h.check((await page.$$('#layouts-menu-list li')).length === 3, 'saving the same name (any case) updates instead of adding'); + await page.click('#layouts-menu-list li:nth-child(1) .wl-apply'); + await page.waitForTimeout(700); + h.check(await page.evaluate(() => localStorage.getItem('uiLayouts:active')) === ids[0].id, 'clicking a row applies it (Alpha marked active)'); + // rename Beta via the pencil + await page.click('#open-layouts').catch(() => {}); + if (!(await page.$('#layouts-menu'))) { + await page.evaluate(() => window.__stores.uiLayouts.layoutsMenuOpen.set(true)); + await page.waitForSelector('#layouts-menu'); + } + await page.click('#layouts-menu-list li:nth-child(2) .wl-icon[title="Rename"]'); + await page.fill('#layouts-menu-list .wl-rename', 'Beta two'); + await page.keyboard.press('Enter'); + await page.waitForTimeout(200); + const names = await page.$$eval('#layouts-menu-list .wl-name', (els) => els.map((e) => e.textContent)); + h.check(names[1] === 'Beta two', `rename sticks (${names.join(',')})`); + await page.click('#layouts-menu-list li:nth-child(3) .wl-danger'); + await page.waitForTimeout(200); + h.check((await page.$$('#layouts-menu-list li')).length === 2, 'delete removes the row'); + await page.keyboard.press('Escape'); + await page.waitForTimeout(200); + h.check(!(await page.$('#layouts-menu')), 'Escape closes the popover'); + + // ---- 7. a reload is still a clean slate, and the list survives + await h.freshReload(A); + const reload = await page.evaluate(() => { + let flow, list; + window.__stores.flowGraphClose.subscribe((x) => (flow = x))(); + window.__stores.uiLayouts.uiLayouts.subscribe((x) => (list = x))(); + return { flowClosed: flow, count: list.length }; + }); + h.check(reload.flowClosed === true, 'after a reload nothing is open (no layout is applied at boot)'); + h.check(reload.count === 2, 'the saved layouts survive the reload'); + + // ---- 8. Settings ▸ Interface carries the same list, searchable + await page.evaluate(() => window.__stores.settingsOpen.set(true)); + await page.waitForTimeout(500); + await page.getByText('Interface', { exact: true }).first().click(); + await page.waitForTimeout(300); + const inSettings = await page.$$eval('#settings-layouts-list li', (els) => els.length).catch(() => 0); + h.check(inSettings === 2, `Settings ▸ Interface lists the layouts (${inSettings})`); + await page.click('#settings-layouts-list li:nth-child(1) .wl-apply'); + await page.waitForTimeout(800); + const settingsClosed = await page.evaluate(() => { + let v; + window.__stores.settingsOpen.subscribe((x) => (v = x))(); + return !v; + }); + h.check(settingsClosed && !!(await rectOf('#flow-window'))?.shown, 'applying from Settings closes Settings and the layout STAYS (not undone by the panel restore)'); + + await h.finish(browser); +}); From 5d35e788b5b17f12c2b86121ef07c569c6824c51 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Tue, 6 Oct 2026 04:03:55 +0300 Subject: [PATCH 14/65] [feat] 37 R1 P2: drag a whole selection onto a group or parent in the object list - dragUuidsFor: a row that is part of a multi-selection drags every top-level member (application/x-object-uuids JSON beside the old single key, which older drops read) - moveObjectsToParent(uuids, target): a group takes them in, ANY other object becomes their parent (moveObjectToGroup gains {anyParent}; receivers' createGroup already attaches to any uuid), 'root' from the list body/header. Cycles and non-editable members are skipped; ONE undo step and ONE replicated batch for the set. - local-only members dropped onto a shared parent are shared into it, as before. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/components/menu/Controls.svelte | 37 ++++++++++++------ src/components/menu/Objects.svelte | 39 +++++++++++++------ src/lib/objectActions.js | 60 +++++++++++++++++++++++++++-- 3 files changed, 110 insertions(+), 26 deletions(-) diff --git a/src/components/menu/Controls.svelte b/src/components/menu/Controls.svelte index 5965d6fc..0968dff8 100644 --- a/src/components/menu/Controls.svelte +++ b/src/components/menu/Controls.svelte @@ -15,7 +15,7 @@ import { ENV_ROOT } from '$lib/environment'; import { flyTo } from '$lib/objectActions'; import { mutedFlowObjects } from '../../stores/flowStore'; - import { focusObject, duplicateObject, toggleObjectVisibility, moveObjectToGroup, setTransformMode, selectObject, toggleEditorMode } from '$lib/objectActions'; + import { focusObject, duplicateObject, toggleObjectVisibility, moveObjectsToParent, setTransformMode, selectObject, toggleEditorMode } from '$lib/objectActions'; import { registerWindowReset } from '$lib/dragWindow'; import { enterEditMode } from '$lib/meshEdit'; import { addAnnotation } from '$lib/annotationsHandler'; @@ -390,6 +390,25 @@ // Drop-to-share: dragging a LOCAL object anywhere over the shared object-list body // shares it to the scene root (a shared object just moves to root). Uses an action // so it adds no on:-directive/a11y warnings in this on:-style component. + /** 37 R1: the set a row drag carries (Objects.svelte), else its single uuid */ + function listDropUuids(e: DragEvent): string[] { + try { + const list = JSON.parse(e.dataTransfer?.getData('application/x-object-uuids') || 'null'); + if (Array.isArray(list) && list.length) return list.filter((id: any) => typeof id === 'string'); + } catch {} + const one = e.dataTransfer?.getData('application/x-object-uuid'); + return one ? [one] : []; + } + /** a list-body drop: local objects are shared to the root, the rest move there as ONE undo + batch */ + function dropToRoot(uuids: string[]) { + const moves: string[] = []; + for (const uuid of uuids) { + const obj: any = ($objectsGroup as any)?.getObjectByProperty('uuid', uuid); + if (obj?.userData?.__localOnly) shareObject(obj); + else moves.push(uuid); + } + moveObjectsToParent(moves, 'root'); + } function shareDropZone(node: HTMLElement) { const setActive = (on: boolean) => { node.style.boxShadow = on ? 'inset 0 0 0 2px rgb(59 130 246 / 0.7)' : ''; @@ -405,13 +424,11 @@ const leave = () => setActive(false); const drop = (e: DragEvent) => { setActive(false); - const uuid = e.dataTransfer?.getData('application/x-object-uuid'); - if (!uuid) return; + const uuids = listDropUuids(e); + if (!uuids.length) return; e.preventDefault(); e.stopPropagation(); - const obj: any = ($objectsGroup as any)?.getObjectByProperty('uuid', uuid); - if (obj?.userData?.__localOnly) shareObject(obj); - else moveObjectToGroup(uuid, 'root'); + dropToRoot(uuids); }; node.addEventListener('dragover', over); node.addEventListener('dragleave', leave); @@ -2282,12 +2299,10 @@ use:objHeaderWidth on:dragover={(e) => { if (e.dataTransfer?.types.includes('application/x-object-uuid')) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; } }} on:drop={(e) => { - const uuid = e.dataTransfer?.getData('application/x-object-uuid'); - if (!uuid) return; + const uuids = listDropUuids(e); + if (!uuids.length) return; e.preventDefault(); - const obj = ($objectsGroup as any)?.getObjectByProperty('uuid', uuid); - if (obj?.userData?.__localOnly) shareObject(obj); - else moveObjectToGroup(uuid, 'root'); + dropToRoot(uuids); }} > 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/lib/objectActions.js b/src/lib/objectActions.js index b7e7d940..01aaa405 100644 --- a/src/lib/objectActions.js +++ b/src/lib/objectActions.js @@ -831,7 +831,8 @@ registerHistoryKind('group', (entry, state) => { showToast('Cannot undo/redo: the object no longer exists'); return false; } - moveObjectToGroup(entry.uuid, state.parent); + // 37 R1: the recorded parent was reached legitimately, so a replay may be any object + moveObjectToGroup(entry.uuid, state.parent, { anyParent: true }); return true; }); @@ -899,7 +900,7 @@ export function renameObject(uuid, name) { * differs per client. * @param {string} uuid @param {string} target - group uuid | 'up' | 'root' */ -export function moveObjectToGroup(uuid, target) { +export function moveObjectToGroup(uuid, target, opts = /** @type {{anyParent?: boolean}} */ ({})) { const group = get(objectsGroup); const object = group?.getObjectByProperty('uuid', uuid); if (!object) return; @@ -920,7 +921,9 @@ export function moveObjectToGroup(uuid, target) { object.parent.parent.attach(object); } else { const destination = group.getObjectByProperty('uuid', target); - if (!destination || destination.type !== 'Group') return; + // 37 R1: `anyParent` lets the object list parent onto ANY object (Blender's drop-onto); + // receivers' createGroup already attaches to whatever the uuid names + if (!destination || (destination.type !== 'Group' && !opts.anyParent)) return; if (destination.uuid === object.uuid || object.parent === destination) return; // never drop a group into its own descendant let ancestor = destination; @@ -938,6 +941,57 @@ export function moveObjectToGroup(uuid, target) { pokeScene(); } +/** + * 37 R1: the objects a row drag in the object list carries. Dragging a row that is PART of + * a multi-selection drags the whole selection (its top-level members only — a child whose + * ancestor is also selected rides along inside it); any other row drags just itself. + * @param {string} uuid the dragged row @returns {string[]} + */ +export function dragUuidsFor(uuid) { + const set = get(selectedObjects); + if (set.length < 2 || !set.includes(uuid)) return [uuid]; + const group = get(objectsGroup); + const chosen = new Set(set); + return set.filter((id) => { + let parent = group?.getObjectByProperty('uuid', id)?.parent; + while (parent && parent !== group) { + if (chosen.has(parent.uuid)) return false; + parent = parent.parent; + } + return true; + }); +} + +/** + * 37 R1: drop a set of objects onto a parent in the object list — a group (move into it), + * any other object (parent to it) or 'root'. Members that would land inside themselves are + * skipped. ONE undo step and ONE replicated batch for the whole set. Returns how many moved. + * @param {string[]} uuids @param {string} target a uuid or 'root' + */ +export function moveObjectsToParent(uuids, target) { + const group = get(objectsGroup); + const destination = target === 'root' ? group : group?.getObjectByProperty('uuid', target); + if (!group || !destination) return 0; + const movable = uuids.filter((uuid) => { + const object = group.getObjectByProperty('uuid', uuid); + if (!object || object === destination || object.parent === destination) return false; + for (let up = destination; up; up = up.parent) if (up === object) return false; // into itself + return canEditObject(object); + }); + if (!movable.length) return 0; + const many = movable.length > 1; + if (many) beginHistoryBatch(); + try { + withWireBatch(() => { + for (const uuid of movable) moveObjectToGroup(uuid, target, { anyParent: true }); + }); + } finally { + if (many) endHistoryBatch(`Move ${movable.length} objects`); + } + pokeScene(); + return movable.length; +} + /** * Ungroup (216): move every child up to the group's parent (world transform kept * via moveObjectToGroup 'up', which replicates + records undo per child), then From 2da0b75c0843e9404b5bb87dcfc659da53fd73bb Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Tue, 6 Oct 2026 04:10:56 +0300 Subject: [PATCH 15/65] [feat] prefab library: folders, tags and a tag filter in the Prefabs tab (37 R4 phase 4) - prefabLibraryCore.js (pure, vitest 10): tag/folder normalisation, the view (folder cards + the prefabs filed here; a search or tag filter reaches every prefab below the folder, tags AND, search matches name or tag), free folder names, rebase for rename/move. - prefabLibrary.js: prefabFolder / prefabTagFilter / prefabFolderList stores, folders persisted in idb (an empty folder is a place), create / rename / delete (contents move up, nothing deleted) / move, set and add tags. LOCAL. - prefabs.js patchPrefabs: one write path for folder/tag edits. - Explorer: folder cards (prefab-folder), breadcrumb Prefabs / A / B, New folder on the grid background (inline card), card menu Move to folder + Edit tags, folder card menu Open / Rename (inline) / Delete folder, a prefab dropped on a folder card is filed there, PrefabTagBar under the crumbs. - PrefabDetails in the prefab's Properties: folder, tags editor, copies in the scene (Update all / Select), carried logic + Export .tpnode. - e2e suites written: prefab-propagation (two peers), prefab-library. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/components/editors/Explorer.svelte | 177 +++++++++++++++++-- src/components/editors/PrefabDetails.svelte | 126 ++++++++++++++ src/components/editors/PrefabTagBar.svelte | 54 ++++++ src/lib/debugHooks.js | 1 + src/lib/prefabLibrary.js | 149 ++++++++++++++++ src/lib/prefabLibraryCore.js | 140 +++++++++++++++ src/lib/prefabs.js | 10 ++ tests/e2e/prefab-library.test.cjs | 178 ++++++++++++++++++++ tests/e2e/prefab-propagation.test.cjs | 126 ++++++++++++++ tests/unit/prefabLibraryCore.test.js | 85 ++++++++++ 10 files changed, 1030 insertions(+), 16 deletions(-) create mode 100644 src/components/editors/PrefabDetails.svelte create mode 100644 src/components/editors/PrefabTagBar.svelte create mode 100644 src/lib/prefabLibrary.js create mode 100644 src/lib/prefabLibraryCore.js create mode 100644 tests/e2e/prefab-library.test.cjs create mode 100644 tests/e2e/prefab-propagation.test.cjs create mode 100644 tests/unit/prefabLibraryCore.test.js diff --git a/src/components/editors/Explorer.svelte b/src/components/editors/Explorer.svelte index 03a2d555..13f3f9c0 100644 --- a/src/components/editors/Explorer.svelte +++ b/src/components/editors/Explorer.svelte @@ -316,6 +316,20 @@ exportPrefab, duplicatePrefab } from '$lib/prefabs'; // 37 R4: an edited prefab offers to update its instances (replicated, one undo) import { offerInstanceUpdate, updateInstances, selectInstances, prefabInstanceCounts } from '$lib/prefabLinks'; + // 37 R4: the prefab tab's folders + tags (local organisation of a local library) + import { + prefabFolder, + prefabTagFilter, + prefabFolderList, + loadPrefabFolders, + createPrefabFolder, + renamePrefabFolder, + deletePrefabFolder, + movePrefabsTo + } from '$lib/prefabLibrary'; + import { prefabView, folderPaths, nameOf as folderLeaf } from '$lib/prefabLibraryCore'; + import PrefabTagBar from './PrefabTagBar.svelte'; + import PrefabDetails from './PrefabDetails.svelte'; // 21-I3: Export ▸ scene (.tpscene) — a scene containing just this prefab. Built from // the EMPTY payload plus this one object, never a capture of the live scene. // R22 round 13 P3: `sessions` is read for the Mount picker (which saved entries are @@ -393,6 +407,7 @@ docked = true; loadExplorer(); loadPrefabs(); + loadPrefabFolders(); loadMountedVolumes(); function setDocked(v: boolean) { @@ -1009,6 +1024,7 @@ text: 'file-text', object: 'box', prefab: 'boxes', + 'prefab-folder': 'folder', // 37 R4: a folder inside the Prefabs view scene: 'map' // 21-F4: a level (.tpscene) }; // semantic icon colors (ui.css classes over the --icon-* theme tokens) @@ -1033,6 +1049,7 @@ text: 'ico-doc', object: 'ico-object', prefab: 'ico-prefab', + 'prefab-folder': 'ico-folder', scene: 'ico-prefab' // 21-F4: levels share the prefab tint }; @@ -1254,14 +1271,28 @@ .map((i: any) => ({ ...i, volumeId: volume.id, volumeName: volume.name, volumeItem: true })) .filter(passesFilter); } - if ($activeFolder === 'prefabs') - return $prefabs.map((p) => ({ - id: 'prefab:' + p.id, - name: p.name, - kind: 'prefab', - thumbnail: p.thumbnail, - prefabId: p.id - })); + if ($activeFolder === 'prefabs') { + // 37 R4: the tab has FOLDERS and TAGS now — folder cards first, then the prefabs + // filed here; a search or a tag filter reaches everything below (prefabLibraryCore) + const view = prefabView($prefabs, $prefabFolderList, $prefabFolder, $prefabTagFilter, search); + return [ + ...view.folders.map((f) => ({ + id: 'prefabfolder:' + f.path, + name: f.name, + kind: 'prefab-folder', + prefabFolder: f.path, + count: f.count + })), + ...view.items.map((p: any) => ({ + id: 'prefab:' + p.id, + name: p.name, + kind: 'prefab', + thumbnail: p.thumbnail, + prefabId: p.id, + tags: p.tags ?? [] + })) + ]; + } // P4: the Packs root — one card per pack (single-click a pack card opens it) if ($activeFolder === 'packs') { return shownPacks.map((p: any) => ({ @@ -1548,7 +1579,17 @@ for (const f of chain) out.push({ label: f.name, id: volumeKey(volScope.volumeId, f.id) }); return out; } - if (a === 'prefabs') return [{ label: 'Prefabs', id: 'prefabs' as string | null }]; + if (a === 'prefabs') { + // 37 R4: "Prefabs / Furniture / Chairs" — each crumb a `prefabs/` id that + // openFolder answers by moving the view's own folder, not `activeFolder` + const out = [{ label: 'Prefabs', id: 'prefabs' as string | null }]; + let path = ''; + for (const seg of ($prefabFolder || '').split('/').filter(Boolean)) { + path = path ? path + '/' + seg : seg; + out.push({ label: seg, id: 'prefabs/' + path }); + } + return out; + } if (a === 'packs') return [{ label: 'Packs', id: 'packs' as string | null }]; // R22 round 7: the bin is its own place, so the breadcrumb has to say so — it read // "Library", which is exactly where these files are not. @@ -2475,6 +2516,8 @@ // R5: the scene rename — replicated through the manifest, its files follow else if (edit.mode === 'rename-scene') renameScene(edit.sceneName, edit.value); else if (edit.mode === 'rename-prefab') renamePrefab(edit.prefabId, edit.value); + else if (edit.mode === 'rename-prefab-folder') await renamePrefabFolder(edit.folderPath, edit.value); + else if (edit.mode === 'new-prefab-folder') await createPrefabFolder($prefabFolder, edit.value); else if (edit.mode === 'rename-pack') renamePack(edit.packName, edit.value); // 21-G9 (union): land the scene where the user is looking — Scenes when the // active folder is a pseudo view or a stale id @@ -2562,6 +2605,7 @@ editing.mode === 'new-scene' || editing.mode === 'duplicate-scene' || editing.mode === 'new-pack' || + editing.mode === 'new-prefab-folder' || (editing.mode === 'create' && editing.inGrid)) ? (editing.mode as string) : null @@ -3669,7 +3713,8 @@ // `deleted:`, which `explorerItems` has never held — so every batch op here was // addressing nothing, silently. The bin's own menus are where its rows are acted on. !item.deletedEntry && - item.kind !== 'pack-folder'; + item.kind !== 'pack-folder' && + item.kind !== 'prefab-folder'; /** what the selection breaks down into, once and for every batch entry point */ // ---- 24-C1: Duplicate / Copy / Cut / Paste -------------------------------------- @@ -4241,6 +4286,75 @@ ]); } + /** 37 R4: "Move to folder ▸" — every folder of the tab, the root first, and a new one. + * @param {string[]} ids @param {string} current */ + function prefabMoveItem(ids: string[], current: string) { + const paths = [...folderPaths($prefabs, $prefabFolderList)].sort(); + return { + label: 'Move to folder', + icon: 'folder-input', + children: [ + { label: 'Prefabs (top level)', checked: !current, action: () => void movePrefabsTo(ids, '') }, + ...paths.map((path) => ({ label: path.split('/').join(' / '), checked: current === path, action: () => void movePrefabsTo(ids, path) })), + { section: ' ' }, + { + label: 'New folder here', + icon: 'folder-plus', + tooltip: 'Make a folder where you are standing and move this into it', + action: async () => { + const path = await createPrefabFolder($prefabFolder); + await movePrefabsTo(ids, path); + startRenamePrefabFolder(path); + } + } + ] + }; + } + + /** 37 R4: a folder card's menu in the Prefabs view. */ + function prefabFolderMenu(e: MouseEvent, item: any) { + menu = { + x: e.clientX, + y: e.clientY, + items: [ + { label: 'Open', icon: 'folder', action: () => openFolder('prefabs/' + item.prefabFolder) }, + { label: 'Rename', icon: 'pencil', action: () => startRenamePrefabFolder(item.prefabFolder) }, + { + label: 'Delete folder', + icon: 'trash-2', + danger: true, + tooltip: 'Its prefabs and folders move up one level — nothing is deleted with it', + action: () => void deletePrefabFolder(item.prefabFolder) + } + ] + }; + } + + /** @param {string} path */ + function startRenamePrefabFolder(path: string) { + settlePendingEdit(); + editing = { mode: 'rename-prefab-folder', cardId: 'prefabfolder:' + path, folderPath: path, value: folderLeaf(path) }; + } + function startNewPrefabFolder() { + settlePendingEdit(); + editing = { mode: 'new-prefab-folder', value: 'New folder', inGrid: true }; + } + + /** a prefab card dropped on a folder card files it there @param {DragEvent} e @param {any} item */ + function dropOnPrefabFolder(e: DragEvent, item: any) { + const payload = payloadOf(e); + dropFolder = null; + if (!payload) return; + const dragged = payload.items?.length ? payload.items : [payload]; + const ids = dragged.map((p: any) => p?.prefabId).filter(Boolean); + if (!ids.length) return; + e.preventDefault(); + e.stopPropagation(); + void movePrefabsTo(ids, item.prefabFolder).then(() => + showToast('Moved ' + (ids.length === 1 ? 'the prefab' : ids.length + ' prefabs') + ' to ' + item.name) + ); + } + /** 37 R4: the instances in this scene, from the card — only when there are some. */ function prefabInstanceItems(prefab: any) { const n = $prefabInstanceCounts[prefab.id] ?? 0; @@ -4335,6 +4449,8 @@ action: () => updatePrefabFromSelection(prefab) }, ...prefabInstanceItems(prefab), + prefabMoveItem([prefab.id], prefab.folder ?? ''), + { label: 'Edit tags…', icon: 'tag', tooltip: 'Tags filter the Prefabs view', action: () => showProperties({ kind: 'item', item }) }, { label: 'Properties', icon: 'info', action: () => showProperties({ kind: 'item', item }) }, { label: 'Rename', icon: 'pencil', action: () => startRenamePrefab(item) }, { label: 'Delete', icon: 'trash-2', danger: true, action: () => void deletePrefabToBin(prefab) } @@ -4360,6 +4476,7 @@ if (pack) packRowMenu(e, pack); return; } + if (item.kind === 'prefab-folder') return prefabFolderMenu(e, item); // P3: a file in a MOUNTED project. Its own menu, for the reason every branch here // has one: Rename / Delete / Share / Download all address a library record it has // none of, and Properties is the only one of them that would have worked. @@ -5134,6 +5251,12 @@ icon: 'boxes', tooltip: 'Save the objects selected in the scene as a new prefab', action: () => void createPrefabFromSelection() + }, + { + label: 'New folder', + icon: 'folder-plus', + tooltip: 'A folder for prefabs, here', + action: () => startNewPrefabFolder() } ] }; @@ -5805,6 +5928,10 @@ openFolder('pack:' + item.packName); // P4: single-click a pack card opens it return; } + if (item.kind === 'prefab-folder') { + openFolder('prefabs/' + item.prefabFolder); // 37 R4: the pack card's rule + return; + } if (item.packEntry) { // pack items aren't library items (no inspectedFile highlight); just select // for the Properties panel @@ -5848,6 +5975,12 @@ } function openFolder(id: string | null) { search = ''; + // 37 R4: a folder INSIDE the Prefabs view is the view's own state, not a location + if (id === 'prefabs' || (typeof id === 'string' && id.startsWith('prefabs/'))) { + prefabFolder.set(id === 'prefabs' ? '' : id.slice('prefabs/'.length)); + activeFolder.set('prefabs'); + return; + } // R22 round 36: `deletedlog` is an ALIAS now, not a place. Round 13 made the log a // navigable folder id so it could be deep-linked and returned to; round 36 makes the // same reading a view FLAG over the bin (see `deletedRootCount`). Answering the old id @@ -6131,6 +6264,10 @@ openFolder('pack:' + item.packName); return; } + if (item.kind === 'prefab-folder') { + openFolder('prefabs/' + item.prefabFolder); + return; + } if (item.kind === 'prefab') { // 21-H2: full model parity — double-click opens the POP-OUT preview. The // inline one in Properties stands down while it is open (previewSuspended). @@ -6442,7 +6579,7 @@ > {/if} - {#if (editing?.mode === 'rename' && editing.inGrid && (editing.cardId ?? editing.folderId) === id) || (editing?.mode === 'rename-item' && editing.itemId === id) || (editing?.mode === 'rename-scene' && editing.itemId === id) || (editing?.mode === 'rename-prefab' && editing.prefabId === item?.prefabId)} + {#if (editing?.mode === 'rename' && editing.inGrid && (editing.cardId ?? editing.folderId) === id) || (editing?.mode === 'rename-item' && editing.itemId === id) || (editing?.mode === 'rename-scene' && editing.itemId === id) || (editing?.mode === 'rename-prefab' && editing.prefabId === item?.prefabId) || (editing?.mode === 'rename-prefab-folder' && editing.cardId === id)} {@render cardEdit()} {:else} {/if} + {#if $activeFolder === 'prefabs'}{/if} {/snippet} {#snippet primary()} @@ -7342,8 +7480,8 @@
- - {#if pendingCard === 'create'} + + {#if pendingCard === 'create' || pendingCard === 'new-prefab-folder'}
+ + 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..44571e52 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); + }; + + /** 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 +395,116 @@ 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'; + + + -
-{/each} - -
-
+{/snippet}