From e10a65d0e013d679c90c4df51acb45074ec6aa3d Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Sat, 1 Aug 2026 15:26:27 +0300 Subject: [PATCH 1/2] [fix] UI batch: dropdown width, duplicate tint, modal key gate, window resize Roadmap #15 batch B (plan: cloud repo plans-core/roadmap-15, specs inline): - B1 ThemedSelect: the portaled popup copied the TRIGGER's width, and the trigger is sized to the SELECTED label - picking a short option ("Box") ellipsised every longer name on reopen. The trigger width is now a MINIMUM; the popup sizes to its content (capped at 28rem / the viewport) and is clamped back on screen when that overflows the right edge. Fixes all ~22 call sites at once. - B2 duplicate: a multi-select member wears an emissive highlight, and the clone's fresh material baked it in - the FIRST clone stayed selection-blue forever (its tinted value got recorded as its "original"). Clones now restore the source's recorded emissive (stripSelectionTint), and duplicateSelection no longer selects each clone mid-loop (which collapsed the set and restored sources one at a time); it selects the whole clone set once at the end. - B3 packs: double-click / Enter / "Place in scene" now show the same dismissible loading toast the viewport DROP path shows (shared holdLoadingToast helper) - a slow CDN fetch used to look like nothing happened. - B4 Connect: the chevron badges the toast count the CLOSED drawer is holding when toasts are routed drawer-only (mirrors the drawer tab badge; amber when approvals are pending). - B5 Sessions: naming a session (save + rename) is an inline textbox instead of window.prompt - Enter commits, Esc cancels, focus is taken automatically. - B6 modals: app modals are non-modal s, so the page behind them is NOT inert and window key handlers still fired (WASD flew the camera behind Settings). New derived `anyModalOpen` gates shortcuts.js, editorNavigation (keydown AND the per-frame fly, which also clears held keys) and inputRuntime; only Settings was guarded before. - B7 windows: dragWindow gains an opt-in `resizable` corner grabber that persists {w,h} in the same win: record and re-clamps on viewport resize; What's New opts in and goes full-screen below the Connect bar at <=640px (the .tp-modal-frame treatment). - B8 context menu: Edit mesh / Sculpt hide for a multi-selection - they are single-object modes and silently acted on the last-picked object only. Verification: new tests/e2e/ui-fixes-15b.test.cjs (22 checks covering B2/B4/ B5/B6/B7/B8), themed-select extended for B1; sessions + whats-new + the new suite green; build green; svelte-check baseline 435/62 held. Co-Authored-By: Claude Fable 5 --- src/components/editors/Explorer.svelte | 11 +- src/components/menu/Connect.svelte | 39 +++- src/components/menu/SessionsManager.svelte | 102 +++++++++-- src/components/menu/WhatsNew.svelte | 18 +- src/components/ui/ThemedSelect.svelte | 14 +- src/lib/dragWindow.js | 90 ++++++++- src/lib/editorNavigation.js | 8 +- src/lib/explorerDrop.js | 22 ++- src/lib/inputRuntime.js | 3 + src/lib/objectActions.js | 32 +++- src/lib/objectMenu.js | 23 ++- src/lib/shortcuts.js | 9 +- src/stores/appStore.js | 17 +- tests/e2e/themed-select.test.cjs | 33 ++++ tests/e2e/ui-fixes-15b.test.cjs | 203 +++++++++++++++++++++ 15 files changed, 576 insertions(+), 48 deletions(-) create mode 100644 tests/e2e/ui-fixes-15b.test.cjs diff --git a/src/components/editors/Explorer.svelte b/src/components/editors/Explorer.svelte index 4df05374..f72a6573 100644 --- a/src/components/editors/Explorer.svelte +++ b/src/components/editors/Explorer.svelte @@ -832,12 +832,21 @@ } // N6: place a default-pack item into the scene (double-click / Enter) at origin + // 15-B3: the CDN fetch takes seconds — hold the SAME loading toast the drop + // path shows (it used to look like nothing happened until the model popped in) async function placePackItem(item: any) { + const { holdLoadingToast } = await import('$lib/explorerDrop'); + const dismiss = holdLoadingToast(String(item.name || 'model')); try { const res = await fetch(item.glbUrl); - if (!res.ok) return showToast('Could not fetch the pack item'); + if (!res.ok) { + dismiss(); + return showToast('Could not fetch the pack item'); + } await importFile(new File([await res.blob()], item.name + '.glb'), item.name, 'glb'); + dismiss(); } catch { + dismiss(); showToast('Could not load the pack item (check the network / CORS)'); } } diff --git a/src/components/menu/Connect.svelte b/src/components/menu/Connect.svelte index c5ff8262..488ee421 100644 --- a/src/components/menu/Connect.svelte +++ b/src/components/menu/Connect.svelte @@ -1,6 +1,6 @@ @@ -892,7 +905,8 @@
{ - $backgroundColor = event.detail.hex; - $globalScene.background = new THREE.Color($backgroundColor); - sendBackgroundColor(); - }} + hex={$backgroundColor} + onInput={(/** @type {any} */ c) => setBackground(c.hex)} /> { - if (hexColor.test(e.currentTarget.value)) { - $backgroundColor = e.currentTarget.value; - $globalScene.background = new THREE.Color($backgroundColor); - sendBackgroundColor(); - } + if (hexColor.test(e.currentTarget.value)) setBackground(e.currentTarget.value); }} />
@@ -927,7 +933,8 @@
{ - fogColor = event.detail.hex; + hex={fogColor} + onInput={(/** @type {any} */ c) => { + fogColor = c.hex; applyFog(); }} /> @@ -963,10 +970,9 @@ size="xs" color="alternative" onclick={() => { - $globalScene.fog = null; fogNear = null; fogFar = null; - sendFogColor(); + editEnvSky({ fog: null }); }}>Remove Fog
@@ -1169,7 +1175,8 @@
{ - $selectedObject.color.set(event.detail.hex); - color = event.detail.hex; + hex={color} + onInput={(/** @type {any} */ c) => { + $selectedObject.color.set(c.hex); + color = c.hex; sendLightUpdate(); }} /> @@ -1203,7 +1210,8 @@ { - $selectedObject.groundColor.set(event.detail.hex); - groundColor = event.detail.hex; + hex={groundColor} + onInput={(/** @type {any} */ c) => { + $selectedObject.groundColor.set(c.hex); + groundColor = c.hex; sendLightUpdate(); }} /> @@ -1333,7 +1341,8 @@ {#if material.color && material.type !== 'MeshNormalMaterial'} { - trackColorGesture($selectedObject.uuid, event.detail.hex); - $selectedObject.material.color.set(event.detail.hex); - $peers.send({ type: 'color', uuid: $selectedObject.uuid, color: event.detail.hex }); + hex={color} + onInput={(/** @type {any} */ c) => { + // live drag: ONE debounced undo entry per gesture (setObjectColor + // would record on every frame), then apply + replicate + trackColorGesture($selectedObject.uuid, c.hex); + $selectedObject.material.color.set(c.hex); + $selectedObject.material.needsUpdate = true; + objectsGroup.update((v) => v); + $peers.send({ type: 'color', uuid: $selectedObject.uuid, color: c.hex }); }} /> { if (hexColor.test(e.currentTarget.value)) { color = e.currentTarget.value; - $selectedObject.material.color.set(color); - $peers.send({ type: 'color', uuid: $selectedObject.uuid, color }); + // a typed value is ONE discrete change — the shared write path + // applies, replicates and records a single undo entry + setObjectColor($selectedObject.uuid, color); } }} /> diff --git a/src/lib/environment.js b/src/lib/environment.js index 0aaf627c..0c0664a2 100644 --- a/src/lib/environment.js +++ b/src/lib/environment.js @@ -289,6 +289,24 @@ export function applyCustomPreset(payload) { }); } +/** + * 15-C: the scene inspector's Background / Fog controls wrote the scene (and + * the backgroundColor store) DIRECTLY, and the next applyEnvironment() restored + * the preset's values — so the edit looked like it did nothing. (Invisible + * until the color picker's dead `on:input` was fixed, since the handler never + * ran at all.) Editing the sky now detaches into a live custom payload, exactly + * like editRigComponent: it sticks, persists and replicates. + * @param {{background?: string, fog?: {color?: string, near?: number, far?: number} | null}} patch + */ +export function editEnvSky(patch) { + const payload = JSON.parse(JSON.stringify(presetPayload())); + payload.label = 'Custom'; + if (patch.background !== undefined) payload.background = patch.background; + if (patch.fog !== undefined) + payload.fog = patch.fog === null ? null : { ...(payload.fog ?? {}), ...patch.fog }; + commit({ preset: 'custom', customPreset: payload }); +} + /** Editing a rig component detaches into a live custom payload * @param {'hemi'|'sun'} part @param {any} patch */ export function editRigComponent(part, patch) { diff --git a/tests/e2e/color-picker-15c.test.cjs b/tests/e2e/color-picker-15c.test.cjs new file mode 100644 index 00000000..84d04ede --- /dev/null +++ b/tests/e2e/color-picker-15c.test.cjs @@ -0,0 +1,119 @@ +// Roadmap #15 batch C — the color picker went dead in the deps migration: +// svelte-awesome-color-picker 3.x -> 4.1.3 is a runes rewrite with NO component +// events, so every `on:input` handler silently never fired (bind:hex still +// tracked, which is why the swatch moved while nothing applied). The fix is the +// `onInput` PROP + `c.hex`. C2 enables the picker's hex/rgb/hsv text inputs. +// +// Test hook: the picker's OWN hex field (rendered by C2) calls the very same +// `onInput` prop the drag surface does — far more stable than canvas-drag math, +// and it fails loudly against the pre-fix `on:input` wiring. The app's separate +// hex box below the picker is a different control (it always worked). +const h = require('./helpers.cjs'); + +/** type a hex into the Nth picker's own text field (fires the lib's onInput) */ +const typeIntoPicker = (page, index, hex) => + page.evaluate( + ([i, value]) => { + const wrapper = document.querySelectorAll('.wrapper')[i]; + const input = wrapper?.querySelector('input'); + if (!input) return false; + input.value = value; + // delegated attribute-form handlers need a BUBBLING event + input.dispatchEvent(new Event('input', { bubbles: true })); + return true; + }, + [index, hex] + ); + +h.run(async () => { + const browser = await h.launch(); + const A = await h.setupPage(browser, 'A'); + + // a white box, selected with the Properties inspector open + await A.page.evaluate(async () => { + const w = window.__stores; + w.commandsHandler.sceneCommand('/create Box 1 1 1'); + const g = await new Promise((r) => w.objectsGroup.subscribe(r)()); + const box = g.children[g.children.length - 1]; + window.__box = box; + box.material.color.set('#ffffff'); + w.objectActions.selectObject(box.uuid, true); + }); + await A.page.waitForTimeout(700); + + // ---- C2: the hex/rgb/hsv text inputs render (were disabled everywhere) ---- + const ui = await A.page.evaluate(() => { + const wrapper = document.querySelector('.wrapper'); + if (!wrapper) return null; + return { + inputs: wrapper.querySelectorAll('input').length, + modeToggle: (wrapper.querySelector('button')?.textContent ?? '').trim() + }; + }); + h.check(!!ui, 'the material color picker renders'); + h.check(ui.inputs > 0, `the picker exposes a text input (${ui?.inputs})`); + h.check(/rgb|hsv|hex/i.test(ui.modeToggle), `a mode cycle button is offered ("${ui?.modeToggle}")`); + + // ---- C1: the picker's onInput applies + replicates (was a silent no-op) ---- + await A.page.evaluate(async () => { + const peer = await new Promise((r) => window.__stores.peers.subscribe(r)()); + window.__sentColors = []; + const orig = peer.send.bind(peer); + peer.send = (m) => { + if (m && m.type === 'color') window.__sentColors.push(m.color); + return orig(m); + }; + }); + h.check(await typeIntoPicker(A.page, 0, '#3366ff'), 'the picker hex field is reachable'); + await A.page.waitForTimeout(200); + const applied = await A.page.evaluate(() => ({ + hex: window.__box.material.color.getHexString(), + sends: window.__sentColors.slice() + })); + h.check(applied.hex === '3366ff', `the picker applies to the material (${applied.hex})`); + h.check( + applied.sends.includes('#3366ff'), + `the change replicates as {type:'color'} (${JSON.stringify(applied.sends)})` + ); + + // one DEBOUNCED undo entry per gesture, and undo restores the old color. + // (the first change's 600ms timer must FIRE before the second starts, or + // both collapse into one gesture — which is the intended live-drag behavior) + await A.page.waitForTimeout(900); + const depthBefore = await A.page.evaluate( + () => new Promise((r) => window.__stores.history.undoStack.subscribe((s) => r(s.length))()) + ); + await typeIntoPicker(A.page, 0, '#22cc55'); + await A.page.waitForTimeout(900); // past the 600ms gesture debounce + const afterGesture = await A.page.evaluate( + () => new Promise((r) => window.__stores.history.undoStack.subscribe((s) => r(s.length))()) + ); + h.check( + afterGesture === depthBefore + 1, + `a color gesture records exactly one undo entry (${depthBefore} -> ${afterGesture})` + ); + const undone = await A.page.evaluate(async () => { + window.__stores.history.undo(); + await new Promise((r) => setTimeout(r, 200)); + return window.__box.material.color.getHexString(); + }); + h.check(undone === '3366ff', `undo steps back one color change (${undone})`); + + // ---- C1: the SCENE pickers (Configure scene ▸ background / fog) ---- + await A.page.evaluate(() => window.__stores.showSidebar('scene')); + await A.page.waitForTimeout(600); + const wrappers = await A.page.evaluate(() => document.querySelectorAll('.wrapper').length); + h.check(wrappers >= 2, `the scene inspector renders background + fog pickers (${wrappers})`); + + h.check(await typeIntoPicker(A.page, 0, '#123456'), 'the background picker hex field is reachable'); + await A.page.waitForTimeout(250); + const bg = await A.page.evaluate(async () => { + const store = await new Promise((r) => window.__stores.backgroundColor.subscribe((v) => r(v))()); + const scene = await new Promise((r) => window.__stores.globalScene.subscribe((s) => r(s))()); + return { store, applied: '#' + (scene.background?.getHexString?.() ?? '') }; + }); + h.check(String(bg.store).toLowerCase() === '#123456', `the background store follows (${bg.store})`); + h.check(bg.applied.toLowerCase() === '#123456', `the three.js scene background applies (${bg.applied})`); + + await h.finish(browser); +});