From 78bd5ef81c3aac8a3f91fbba0ee3d45f702a0f87 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Wed, 11 Nov 2026 00:48:13 +0200 Subject: [PATCH 01/13] uir-polish A: sidebar handles go behind a dock that leaves no room, spot scales with window height MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A1: when the band above the bottom dock cannot hold a handle it waits BEHIND the dock (z under --z-dock) instead of being pushed onto it; the saved spot is untouched and it returns when the dock closes. A2: the saved spot is also recorded as a fraction of the free height (sidebar:handleFrac:, a layout key), so a taller/shorter window moves it in proportion; px-only spots convert on the first resize. sidebar-handles-dock §7/§8. Co-Authored-By: Claude Opus 5.5 --- src/components/menu/SidebarHandles.svelte | 52 ++++++++++++++++++----- src/lib/sidebarHandles.js | 36 ++++++++++++++-- tests/e2e/battery.json | 2 +- tests/e2e/sidebar-handles-dock.test.cjs | 49 +++++++++++++++++++++ 4 files changed, 125 insertions(+), 14 deletions(-) diff --git a/src/components/menu/SidebarHandles.svelte b/src/components/menu/SidebarHandles.svelte index 9ba29b12..9cd6a46d 100644 --- a/src/components/menu/SidebarHandles.svelte +++ b/src/components/menu/SidebarHandles.svelte @@ -9,7 +9,9 @@ // unfolded N6 drops that chrome under the Connect bar, so a fixed top would land on it) AND below the profile // menu's bottom edge, open or not (1.64, owner: the profile click used to move them), plus the offset the // person dragged it to along the edge (saved per side) - // - never on the bottom dock: drawn clamped above the dock's top, the saved offset untouched (1.64) + // - never on the bottom dock: drawn clamped above the dock's top, the saved offset untouched (1.64); when the + // band above the dock is too short to hold it, it goes BEHIND the dock, never on top (uir-polish A1) + // - the saved spot is a fraction of the free height too, so a taller / shorter window moves it in proportion (A2) // - a press toggles the column; a press that TRAVELS up / down moves the handle; right-click / long-press = its // menu (Hide handles · Move up / down · Reset position · Dim at rest) // - on a touch screen it stays while the column is open, on the column's inner edge (it is how the column @@ -20,6 +22,8 @@ import { sidebarHandleShown, sidebarHandleY, + sidebarHandleFrac, + handleOffset, sidebarHandlesDim, sidebarHandlesMode, setSidebarHandleY, @@ -101,14 +105,25 @@ } // drawn only once mounted: the page is prerendered, and where a handle goes is a question for this screen let mounted = $state(false); + /** the window's height, as state: a resize re-places the handles (A2) */ + let vh = $state(typeof window !== 'undefined' ? window.innerHeight : 800); onMount(() => { mounted = true; remeasure(); const t = setInterval(remeasure, 1000); - window.addEventListener('resize', remeasure); + const onResize = () => { + // a spot saved in px only (an older release, a layout): record it as a fraction of the height it was + // placed in, before that height changes, so it moves in proportion from now on + if (window.innerHeight !== vh) + for (const side of SIDES) + if ($sidebarHandleFrac[side] == null && $sidebarHandleY[side] > 0) setSidebarHandleY(side, $sidebarHandleY[side], true, rangeOf(side)); + vh = window.innerHeight; + remeasure(); + }; + window.addEventListener('resize', onResize); return () => { clearInterval(t); - window.removeEventListener('resize', remeasure); + window.removeEventListener('resize', onResize); }; }); $effect(() => { @@ -125,14 +140,27 @@ * stays the person's intent, so the handle slides up with the dock when the spot is under it and goes back when * the dock closes. A band too short to hold it puts it at the band's top. */ + /** the lowest top the handle may have with no dock (the bottom margin keeps it off the bottom chrome) */ + const lowest = (side: Side) => Math.max(floorOf(side) + GAP, vh - TAB_H - 96); + /** the free height the saved spot is a fraction of: the floor down to the lowest top (A2) */ + const rangeOf = (side: Side) => Math.max(1, lowest(side) - (floorOf(side) + GAP)); + /** A1: the dock covers the whole band the handle could move into: it is drawn BEHIND the dock */ + function under(side: Side) { + return $bottomInset > 0 && vh - $bottomInset - GAP - boxH() < floorOf(side) + GAP; + } function clampTop(side: Side, want: number) { const base = floorOf(side) + GAP; - let max = Math.max(base, window.innerHeight - TAB_H - 96); - if ($bottomInset > 0) max = Math.max(base, Math.min(max, window.innerHeight - $bottomInset - GAP - boxH())); + let max = lowest(side); + // no room above the dock: keep the saved spot (behind the dock, see under()), never pushed onto it + if ($bottomInset > 0 && !under(side)) max = Math.max(base, Math.min(max, vh - $bottomInset - GAP - boxH())); return Math.max(base, Math.min(max, want)); } function topOf(side: Side) { - return clampTop(side, floorOf(side) + GAP + $sidebarHandleY[side]); + return clampTop(side, floorOf(side) + GAP + handleOffset($sidebarHandleY[side], $sidebarHandleFrac[side], rangeOf(side))); + } + /** save a drawn top as the person's spot (px and the fraction of the free height) */ + function saveTop(side: Side, top: number) { + setSidebarHandleY(side, top - (floorOf(side) + GAP), true, rangeOf(side)); } /** a drag moves the handle from where it is DRAWN (the dock may be holding it above its saved spot) */ function dragTop(side: Side, dy: number) { @@ -160,8 +188,7 @@ const p = press; press = null; if (moving) { - const top = dragTop(p.side, moving.dy); - setSidebarHandleY(p.side, top - (floorOf(p.side) + GAP)); + saveTop(p.side, dragTop(p.side, moving.dy)); moving = null; suppress = true; } @@ -187,8 +214,7 @@ e.preventDefault(); const step = e.shiftKey ? 48 : 8; // from where it is DRAWN (the dock may be holding it up), and saved within the band, like a drag - const base = floorOf(side) + GAP; - setSidebarHandleY(side, clampTop(side, topOf(side) + (e.key === 'ArrowUp' ? -step : step)) - base); + saveTop(side, clampTop(side, topOf(side) + (e.key === 'ArrowUp' ? -step : step))); } else if (e.key === 'Enter' || e.key === 'Escape' || e.key === ' ') { e.preventDefault(); e.stopPropagation(); @@ -264,6 +290,8 @@ class:sbh-touch={$coarsePointer} class:sbh-moving={moving?.side === side || moveMode === side} class:sbh-open={open} + class:sbh-under={under(side)} + data-under={under(side) ? '' : undefined} data-side={side} aria-pressed={open} aria-label="{open ? 'Hide' : 'Show'} the {side} sidebar" @@ -315,6 +343,10 @@ .sbh-moving { transition: none; } + /* A1: the dock covers every place the handle could go: it waits BEHIND the dock (never painted on it) */ + .sbh-under { + z-index: calc(var(--z-dock) - 1); + } @media (prefers-reduced-motion: reduce) { .sbh { transition: none; diff --git a/src/lib/sidebarHandles.js b/src/lib/sidebarHandles.js index 3c8730f2..797934fb 100644 --- a/src/lib/sidebarHandles.js +++ b/src/lib/sidebarHandles.js @@ -25,6 +25,8 @@ const MODE_KEY = 'sidebarHandles'; const DIM_KEY = 'sidebarHandles:dim'; /** @param {Side} side */ const yKey = (side) => 'sidebar:handleY:' + side; +/** @param {Side} side */ +const fracKey = (side) => 'sidebar:handleFrac:' + side; const ls = typeof localStorage !== 'undefined'; /** @param {any} v @returns {HandlesMode} */ @@ -43,22 +45,50 @@ function readY(side) { const v = ls ? parseInt(safeStorage.getItem(yKey(side)) ?? '') : NaN; return Number.isFinite(v) ? Math.max(0, v) : 0; } +/** @param {Side} side @returns {number|null} */ +function readFrac(side) { + const v = ls ? parseFloat(safeStorage.getItem(fracKey(side)) ?? '') : NaN; + return Number.isFinite(v) ? Math.min(1, Math.max(0, v)) : null; +} /** how far below its resting place (under the top chrome) each handle was dragged, px (layout-saved) */ export const sidebarHandleY = writable(/** @type {Record} */ ({ left: readY('left'), right: readY('right') })); -/** @param {Side} side @param {number} y @param {boolean=} persist */ -export function setSidebarHandleY(side, y, persist = true) { +/** + * uir-polish A2 (owner 2026-11-11): the same spot as a FRACTION of the free height it can travel (below the top + * chrome, above the bottom margin; the dock is NOT part of it, the dock only clamps where it is drawn). A window made + * taller or shorter moves the handle proportionally. null = no fraction recorded (a spot set in px, e.g. by an older + * release or a test): the px offset is drawn as is. + */ +export const sidebarHandleFrac = writable(/** @type {Record} */ ({ left: readFrac('left'), right: readFrac('right') })); +/** + * @param {Side} side @param {number} y the offset below the resting place, px + * @param {boolean=} persist + * @param {number=} range the free height the handle travels (px): given = also record the spot as a fraction of it + */ +export function setSidebarHandleY(side, y, persist = true, range) { const next = Math.max(0, Math.round(Number.isFinite(y) ? y : 0)); sidebarHandleY.update((v) => ({ ...v, [side]: next })); + const frac = range && range > 0 && next ? Math.min(1, next / range) : null; + sidebarHandleFrac.update((v) => ({ ...v, [side]: frac })); if (persist && ls) { if (next) safeStorage.setItem(yKey(side), String(next)); else safeStorage.removeItem(yKey(side)); + if (frac != null) safeStorage.setItem(fracKey(side), frac.toFixed(5)); + else safeStorage.removeItem(fracKey(side)); } } /** "Reset position": back under the top chrome @param {Side} side */ export function resetSidebarHandleY(side) { setSidebarHandleY(side, 0); } -onLayoutRestore(() => sidebarHandleY.set({ left: readY('left'), right: readY('right') })); +/** the offset to draw for a free height of `range` px: the fraction when one is recorded, else the px offset + * @param {number} px @param {number|null} frac @param {number} range */ +export function handleOffset(px, frac, range) { + return frac != null && range > 0 ? Math.round(frac * range) : px; +} +onLayoutRestore(() => { + sidebarHandleY.set({ left: readY('left'), right: readY('right') }); + sidebarHandleFrac.set({ left: readFrac('left'), right: readFrac('right') }); +}); /** * 1.64 (owner): THE HANDLES REST BELOW THE PROFILE MENU, open or not. Clicking the profile used to move them: the open diff --git a/tests/e2e/battery.json b/tests/e2e/battery.json index 2d20d6fc..9e72c856 100644 --- a/tests/e2e/battery.json +++ b/tests/e2e/battery.json @@ -519,7 +519,7 @@ "code-flow-text": 240, "sidebar-handles-devices": 420, "profile-handles-still": 240, - "sidebar-handles-dock": 240, + "sidebar-handles-dock": 300, "burger-windows": 240, "explorer-file-history": 240, "profiler-live-row": 180, diff --git a/tests/e2e/sidebar-handles-dock.test.cjs b/tests/e2e/sidebar-handles-dock.test.cjs index a734c264..283ec76c 100644 --- a/tests/e2e/sidebar-handles-dock.test.cjs +++ b/tests/e2e/sidebar-handles-dock.test.cjs @@ -8,6 +8,9 @@ // 4. a drag while the dock is open saves the new spot, inside the band // 5. the dock closes: each handle is back on its saved spot // 6. a reload keeps the saved spots +// 7. uir-polish A1: a dock tall enough to leave no room above it: the handle goes BEHIND the dock (never on top), +// and comes back to its saved spot when the dock closes +// 8. uir-polish A2: the window gets shorter / taller: the handle keeps its place in proportion to the free height // // Run: APP_URL=https://theprototype.app:5809/ npm run e2e -- sidebar-handles-dock const h = require('./helpers.cjs'); @@ -112,5 +115,51 @@ h.run(async () => { await settle(page, 2000); st = await state(page); h.check(near(st.left, keep.left) && near(st.right, keep.right), `6 a reload keeps both saved spots (${st.left?.top}/${keep.left?.top}, ${st.right?.top}/${keep.right?.top})`); + + // ---- 7. A1: no room above the dock -> behind it ---- + const onTop = (side) => + page.evaluate((id) => { + const el = document.getElementById(id); + const b = el?.getBoundingClientRect(); + if (!b || !b.height) return null; + const hit = document.elementFromPoint(b.left + b.width / 2, b.top + b.height / 2); + return { mine: !!hit && el.contains(hit), under: el.hasAttribute('data-under') }; + }, 'sidebar-handle-' + side); + await nodeEditor(page); + await settle(page, 1000); + await page.evaluate(() => window.__stores.bottomDock.dockHeight.set(Math.round(innerHeight - 120))); + await settle(page, 1000); + st = await state(page); + h.check(st.dockTop !== null && st.dockTop < 200, `premise: the dock covers nearly the whole viewport (top ${st.dockTop})`); + for (const side of SIDES) { + const t = await onTop(side); + h.check(t && t.under && !t.mine, `7 no room above the dock: the ${side} handle waits BEHIND it, not painted on it (${JSON.stringify(t)})`); + } + await page.evaluate(() => window.__stores.bottomDock.dockHeight.set(320)); + await nodeEditor(page); + await settle(page, 1000); + st = await state(page); + h.check(st.inset === 0 && near(st.left, keep.left) && near(st.right, keep.right), `7 ...the dock closed: both back on their saved spots (${st.left?.top}/${keep.left?.top})`); + for (const side of SIDES) { + const t = await onTop(side); + h.check(t && !t.under && t.mine, `7 ...and on top again (${side}: ${JSON.stringify(t)})`); + } + + // ---- 8. A2: proportional to the window height ---- + const tops = await state(page); + await page.setViewportSize({ width: 1920, height: 760 }); + await settle(page, 1500); + const short = await state(page); + for (const side of SIDES) { + // the resting place (the floor) does not move with the height; the free height below it does + const rest = tops[side].top - tops.saved[side]; + const want = Math.round(rest + (tops.saved[side] / (1080 - 44 - 96 - rest)) * (760 - 44 - 96 - rest)); + h.check(short[side] && short[side].top < tops[side].top - 60 && Math.abs(short[side].top - want) <= 3, `8 a shorter window moves the ${side} handle up in proportion (${tops[side]?.top} -> ${short[side]?.top}, want ${want})`); + } + await page.setViewportSize({ width: 1920, height: 1080 }); + await settle(page, 1500); + const back = await state(page); + for (const side of SIDES) + h.check(back[side] && Math.abs(back[side].top - tops[side].top) <= 2, `8 ...back to the full height: the same spot as before (${tops[side]?.top} -> ${back[side]?.top})`); await h.finish(browser); }); From 4c67a88a62759b72158efcffd743bbd3689713de Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Wed, 11 Nov 2026 01:00:03 +0200 Subject: [PATCH 02/13] =?UTF-8?q?uir-polish=20B:=20docked=20windows'=20sid?= =?UTF-8?q?e=20panes=20=E2=80=94=20flush=20handles,=20scroll=20room,=20key?= =?UTF-8?q?s,=20switch=20side=20in=20menus?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit B1: a collapsed pane handle sits flush against the window edge — WindowShell measures the frame padding and draws closed handles across it (clips with a margin); PaneHandle shifts itself across the gap when no ancestor clips (Node editor, Code, Shader, Animation). B4: the right handle owns the edge — WindowShell's main body and the Code editor reserve room so a scrollbar never sits under the handle's tab. B5: Ctrl+B / Ctrl+Alt+B toggle the docked window's left/right pane when it has the focus or the mouse is over it ($lib/ui/paneKeys registry, every PaneHandle registers), else the app's sidebars as before. B6: 'Switch sidebar side' moves from the secondary pane header into the pane handles' context menu and the pane header's context menu. New suite docked-panes-polish; explorer-header-panels §7 uses the menu. Co-Authored-By: Claude Opus 5.5 --- src/components/editors/CodeWorkspace.svelte | 3 +- src/components/shared/WindowShell.svelte | 80 +++++++++- src/components/ui/PaneHandle.svelte | 37 ++++- src/lib/shortcuts.js | 9 +- src/lib/ui/paneKeys.js | 62 ++++++++ tests/e2e/battery.json | 1 + tests/e2e/docked-panes-polish.test.cjs | 155 ++++++++++++++++++++ tests/e2e/explorer-header-panels.test.cjs | 5 +- 8 files changed, 339 insertions(+), 13 deletions(-) create mode 100644 src/lib/ui/paneKeys.js create mode 100644 tests/e2e/docked-panes-polish.test.cjs diff --git a/src/components/editors/CodeWorkspace.svelte b/src/components/editors/CodeWorkspace.svelte index d8b5e3eb..71ed1502 100644 --- a/src/components/editors/CodeWorkspace.svelte +++ b/src/components/editors/CodeWorkspace.svelte @@ -749,7 +749,8 @@
startSide(e, 'left')} onpointermove={moveSide} onpointerup={endSide}>
{/if} -
+ +
{@render strip()} {@render body()}
diff --git a/src/components/shared/WindowShell.svelte b/src/components/shared/WindowShell.svelte index ce448c8f..5ab03c09 100644 --- a/src/components/shared/WindowShell.svelte +++ b/src/components/shared/WindowShell.svelte @@ -5,6 +5,7 @@ import { readPanelOpen, writePanelOpen, handheld } from '$lib/ui/handheldPanels.js' import { uiScale } from '$lib/uiScale' import PaneHandle from '../ui/PaneHandle.svelte' + import ContextMenu from '../ContextMenu.svelte' import Icon from '../ui/Icon.svelte' import { paneLocks } from '$lib/ui/paneLocks.js' import { edgeFade } from '$lib/ui/edgeFade.js' @@ -249,9 +250,65 @@ // the handles sit BELOW the topbar (owner: "placed lower, covering no controls") let topbarH = $state(0) const handleTop = $derived(Math.round(topbarH + 8)) + + // uir-polish B1 (owner 2026-11-11): a COLLAPSED pane's handle sits flush against the window's edge. A docked window + // (and a floating one) wraps this shell in its frame's padding, which left the tab floating a few px in from the + // edge with a dark strip beside it: the gap to the frame is MEASURED and the closed handle is drawn across it + // (the root clips with a margin as wide as that gap instead of at its own box). + const FRAME = '.tp-dock-panel, .tp-window, [data-pd-host], [data-docked]' + let rootEl = $state(null) + let gap = $state({ left: 0, right: 0 }) + function measureGap() { + const el = rootEl + const frame = el?.parentElement?.closest(FRAME) as HTMLElement | null + if (!el || !frame) return + const r = el.getBoundingClientRect() + const f = frame.getBoundingClientRect() + const cap = (v: number) => Math.max(0, Math.min(12, Math.round(v))) + const next = { left: cap(r.left - f.left - frame.clientLeft), right: cap(f.right - r.right - (frame.offsetWidth - frame.clientWidth - frame.clientLeft)) } + if (next.left !== gap.left || next.right !== gap.right) gap = next + } + $effect(() => { + void rootW + void narrow + const raf = requestAnimationFrame(measureGap) + return () => cancelAnimationFrame(raf) + }) + const clipMargin = $derived(Math.max(gap.left, gap.right)) + // B4: the handle on the RIGHT edge owns that edge — the main area's scroller ends where the handle's tab starts, so + // a vertical scrollbar is never under it (a closed pane's tab sits on the frame edge, across the measured gap) + const TAB_ROOM = 16 + const rightPaneOpen = $derived(side === 'right' ? primaryOpen && !hidePrimary : secondaryOpen) + const rightHandle = $derived(side === 'right' ? !hidePrimary : secondaryModes.length > 0) + // (an OPEN pane's handle hangs from the pane's inner edge into the main area: the same room keeps its scrollbar clear) + const mainRoomRight = $derived(narrow || !rightHandle ? 0 : Math.max(0, TAB_ROOM - (rightPaneOpen ? 0 : gap.right))) + /** where a handle is drawn on its edge: the panel's inner edge when the pane shows, else across the frame gap */ + const handleAt = (edge: 'left' | 'right', open: boolean, width: number) => (open ? width * k : -gap[edge]) + + // uir-polish B6 (owner 2026-11-11): "Switch sidebar side" lives in the sidebars' CONTEXT MENU (each pane handle's, + // and the secondary pane's header) instead of a button in that header + const switchItem = $derived({ + label: 'Switch sidebar side', + icon: 'arrow-left-right', + tooltip: `Move ${primaryLabel} to the ${side === 'left' ? 'right' : 'left'} and the other pane to the ${side}`, + action: switchSide + }) + let headMenu = $state<{ x: number; y: number } | null>(null) + function onHeadMenu(e: MouseEvent) { + e.preventDefault() + e.stopPropagation() + headMenu = { x: e.clientX, y: e.clientY } + } -
+
{#if narrow}
@@ -296,7 +353,7 @@ {#if topbar}
{@render topbar()}
{/if} -
{@render main?.()}
+
{@render main?.()}
@@ -306,7 +363,8 @@ style={narrow ? 'order: 1' : `order: ${secondaryPanelOrder}; width: ${secondaryEff * k}px`} > {#if !narrow} -
+ +
{#each secondaryModes as m (m.key)} {/each}
-
{/if} @@ -352,9 +409,10 @@ open={primaryOpen} label={primaryLabel} windowKey={key} + extraItems={secondaryModes.length ? [switchItem] : []} onclick={togglePrimary} data-ws-primary-toggle - style="top: {handleTop}px; {side === 'left' ? 'left' : 'right'}: {primaryOpen ? primaryWidth * k : 0}px" + style="top: {handleTop}px; {side === 'left' ? 'left' : 'right'}: {handleAt(side, primaryOpen, primaryWidth)}px" /> {/if} {#if secondaryModes.length && !narrow} @@ -364,12 +422,22 @@ label={secondaryModes.length > 1 ? secondaryModes.map((m) => m.label).join(' / ') : (activeMode?.label ?? 'the panel')} role={iconOf(activeMode)} windowKey={key} + extraItems={hidePrimary ? [] : [switchItem]} onclick={toggleSecondary} data-ws-secondary-toggle - style="top: {handleTop}px; {secondarySide === 'left' ? 'left' : 'right'}: {secondaryOpen ? secondaryEff * k : 0}px" + style="top: {handleTop}px; {secondarySide === 'left' ? 'left' : 'right'}: {handleAt(secondarySide, secondaryOpen, secondaryEff)}px" /> {/if}
+{#if headMenu} + (headMenu = null)} + /> +{/if} diff --git a/src/components/menu/ProblemReport.svelte b/src/components/menu/ProblemReport.svelte index 96a8d0ed..7b6dceff 100644 --- a/src/components/menu/ProblemReport.svelte +++ b/src/components/menu/ProblemReport.svelte @@ -1,19 +1,24 @@ {#if filter || badge} -
+
{#if filter} @@ -44,7 +37,6 @@
{/if} {#if badge}{/if} - {#if popped && panel}{/if}
{/if} @@ -59,10 +51,6 @@ background: var(--surface-2); border-bottom: 1px solid var(--border); } - /* 42 I1: unstuck — the header is the first thing in the scroller and scrolls away with it */ - .ins-head-flow { - position: relative; - } .ins-filter { display: flex; align-items: center; diff --git a/src/components/menu/inspector/InspectorLock.svelte b/src/components/menu/inspector/InspectorLock.svelte deleted file mode 100644 index fb5e81b6..00000000 --- a/src/components/menu/inspector/InspectorLock.svelte +++ /dev/null @@ -1,44 +0,0 @@ - - - - - diff --git a/src/components/menu/inspector/InspectorPin.svelte b/src/components/menu/inspector/InspectorPin.svelte deleted file mode 100644 index 391db1d7..00000000 --- a/src/components/menu/inspector/InspectorPin.svelte +++ /dev/null @@ -1,48 +0,0 @@ - - - - - diff --git a/src/components/menu/settings/InterfaceSettings.svelte b/src/components/menu/settings/InterfaceSettings.svelte index 2db5bd3b..fed4cdad 100644 --- a/src/components/menu/settings/InterfaceSettings.svelte +++ b/src/components/menu/settings/InterfaceSettings.svelte @@ -74,7 +74,6 @@ import SidebarHandlesSettings from './SidebarHandlesSettings.svelte'; // showhide-v8 (R3) import PresenceSettings from './PresenceSettings.svelte'; // uir-people import PopOutWindowSettings from './PopOutWindowSettings.svelte'; // 70 W3/W5 - import PanelHeaderSettings from './PanelHeaderSettings.svelte'; // 42 I1 import ChromeButtonsSettings from './ChromeButtonsSettings.svelte'; // panel-gestures item 5 import PhoneBarSettings from './PhoneBarSettings.svelte'; // 42 I5 import { gameSoundVolume } from '$lib/gameSfx'; @@ -326,7 +325,6 @@ - diff --git a/src/components/menu/settings/PanelHeaderSettings.svelte b/src/components/menu/settings/PanelHeaderSettings.svelte deleted file mode 100644 index 0783254e..00000000 --- a/src/components/menu/settings/PanelHeaderSettings.svelte +++ /dev/null @@ -1,23 +0,0 @@ - - - - setAllStickyHeads(mixed ? true : v)} /> - diff --git a/src/components/phone/PhoneShell.svelte b/src/components/phone/PhoneShell.svelte index 0f1ab733..d90b699f 100644 --- a/src/components/phone/PhoneShell.svelte +++ b/src/components/phone/PhoneShell.svelte @@ -23,7 +23,6 @@ import Icon from '../ui/Icon.svelte'; import { connectionStatus, connectionLabel } from '$lib/connectionStatus'; import SheetGrip from '../ui/SheetGrip.svelte'; - import InspectorLock from '../menu/inspector/InspectorLock.svelte'; // 78-int PL14 import { stripScroll } from '$lib/ui/stripScroll.js'; import { minimalScroll } from '$lib/ui/minimalScroll.js'; import { @@ -910,14 +909,7 @@
- - {#snippet end()} - - {#if $phoneDockShown === 'properties' || $phoneDockShown === 'scene'} - - {/if} - {/snippet} - +
{/if} diff --git a/src/lib/chromeButtons.js b/src/lib/chromeButtons.js index 6d59382d..e1c8423a 100644 --- a/src/lib/chromeButtons.js +++ b/src/lib/chromeButtons.js @@ -42,8 +42,6 @@ const REGISTRY = [ // buttons + a switch in the top-right group) — the id names the JOB, not the widget { id: 'sidebar.toggle', kind: 'sidebar', label: 'Show / hide sidebar', icon: 'panel-right-close' }, { id: 'sidebar.full', kind: 'sidebar', label: 'Full height', icon: 'unfold-vertical' }, - { id: 'sidebar.lock', kind: 'sidebar', label: 'Header lock', icon: 'lock' }, - { id: 'sidebar.pin', kind: 'sidebar', label: 'Follow the selection', icon: 'pin' }, // 78-int PL14 { id: 'dock.split', kind: 'dock', label: 'Split', icon: 'columns-2' }, { id: 'dock.undock', kind: 'dock', label: 'Undock', icon: 'app-window' }, { id: 'dock.minimize', kind: 'dock', label: 'Minimize the dock', icon: 'panel-bottom' }, diff --git a/src/lib/debugHooks.js b/src/lib/debugHooks.js index 05f10cb7..c32010f5 100644 --- a/src/lib/debugHooks.js +++ b/src/lib/debugHooks.js @@ -534,7 +534,6 @@ export const DEBUG_HOOKS = [ ['projectActions', () => import('./projectActions.js')], ['saveOptions', () => import('./saveOptions.js')], // 42 I1: sticky panel headers - ['panelHeaderPrefs', () => import('./panelHeaderPrefs.js')], ['rowFit', () => import('./ui/rowFit.js')], ['exportBoot', () => import('./export/exportBoot.js')], ['exportRuntime', () => import('./export/exportRuntime.js')], diff --git a/src/lib/dockMenu.js b/src/lib/dockMenu.js index ea4dd42e..abfe391c 100644 --- a/src/lib/dockMenu.js +++ b/src/lib/dockMenu.js @@ -10,8 +10,7 @@ import { profilerClose, codeWorkspaceClose, inspectorClose, - inspectorKind, - inspectorPinned + inspectorKind } from '../stores/appStore'; import { imageEditorClose } from './image/imageEditorState'; import { get, writable } from 'svelte/store'; @@ -19,7 +18,7 @@ import { activateDock, bottomInset, armDockMode, armDockModes, dockOccupants, do import { phoneShell } from './ui/phoneShell.js'; import { queueMerge, groupOfKey, activateTab, nodeOf } from './windowTabs'; import { maximizeDockTab } from './focusMode'; -import { sidebarTabs, sidebarVisible, activateSidebarTab, inspectorSide, inspectorTabs, moveSidebarTab, keepSideOpen } from './sidebars'; +import { sidebarTabs, sidebarVisible, activateSidebarTab, inspectorSide, inspectorTabs, moveSidebarTab, keepSideOpen, sceneSide } from './sidebars'; import { raiseWindow, raiseWindowNode } from './windowFocus'; import { revealWindow } from './dragWindow'; import { isAway, releasePanel, focusPanelWindow, popOutItems, popOutDockPanels, canPopOut } from './windows/panelPlaces.js'; @@ -333,8 +332,8 @@ export function registerFloatOpener(key, fn) { * 44 M7 — the Configure Scene row a sidebar "+" offers: the Inspector lives only in the sidebars. * showhide-v8 (owner 2026-11-09): Configure Scene is its OWN tab beside Properties (R2), and "+" MOVES an * already-open tab to where "+" was pressed: open on this side it is brought to the front; open on the OTHER side - * it comes here (Properties with it: the two tabs share one Inspector); closed it opens here as a new tab — an open - * Properties tab stays a tab beside it, never replaced. + * it comes here (uir-polish E5: ALONE — Properties stays in its column); closed it opens here as a new tab — an open + * Properties tab stays where it is, never replaced. * @param {'left'|'right'} side */ function sceneRow(side) { @@ -350,6 +349,7 @@ function sceneRow(side) { label: title, icon, hint: whereHint(at), + hoverHint: 'Move here', // uir-polish F3 checked: true, multiPick: true, tooltip: `${title} is open in the ${at} sidebar — click to move it here`, @@ -364,11 +364,9 @@ function sceneRow(side) { multiPick: true, tooltip: 'Sky, light, physics, grid and the rest of the scene’s settings', action: () => { - // the Inspector's two tabs share one column: a closed Inspector opens on THIS side, an open one brings - // its Properties tab along - const props = get(inspectorTabs).inspector; - if (props && get(inspectorSide) !== side) moveSidebarTab('inspector', side); - else if (!props) inspectorSide.set(side); + // uir-polish E5: Configure Scene opens on THIS side, alone — Properties stays where it is + sceneSide.set(side); + if (!get(inspectorTabs).inspector) inspectorSide.set(side); inspectorKind.set('scene'); inspectorClose.set(false); queueMicrotask(() => activateSidebarTab('scene')); @@ -448,6 +446,8 @@ export function addWindowItems({ target = 'dock', exclude = [] } = {}) { label: title, icon, hint: whereHint(where), + // uir-polish F3 (owner 2026-11-11): on hover / when highlighted the row says what a press does + hoverHint: !here && canMove ? 'Move here' : undefined, checked: true, multiPick: true, tooltip: here || !canMove ? `${title} is open in ${place} — click to show it there` : `${title} is open in ${place} — click to move it here`, @@ -577,19 +577,7 @@ export function dockTabItems(key) { const title = DOCK_TITLES[key] ?? 'this view'; /** @type {{label: string, icon?: string, tooltip: string, action?: () => void, danger?: boolean, disabled?: boolean, children?: any[], checked?: boolean, testid?: string}[]} */ const items = []; - // 78-int PL14: the phone's Properties / Configure Scene tab has no inner header any more, so its pin - // ("Follow the selection", the wider screen's strip button) is the FIRST item of the tab's menu - if (get(phoneShell) && (key === 'properties' || key === 'scene')) { - const pinned = get(inspectorPinned); - items.push({ - label: 'Follow the selection', - icon: pinned ? 'pin' : 'pin-off', - checked: pinned, - testid: 'inspector-pin', - tooltip: pinned ? 'On: this panel stays open and shows what you select. Tap to stop' : 'Keep this panel open and show what you select', - action: () => inspectorPinned.update((v) => !v) - }); - } + // uir-polish E1: the phone tab menu's "Follow the selection" (78-int PL14) went with the pin // 44 M9 — the split rows (decision: Split right / Unsplit / Swap sides); wider screens only if (!get(phoneShell)) { const pair = splitOf(key); diff --git a/src/lib/objectActions.js b/src/lib/objectActions.js index 4035d386..ce080f5a 100644 --- a/src/lib/objectActions.js +++ b/src/lib/objectActions.js @@ -395,9 +395,20 @@ export function deselectObject() { if (controls && !get(isVRMode)) controls.detach(); // selectedObject keeps the last object on purpose — the open inspector binds // to $selectedObject.position/material and would crash on an empty value + // uir-polish E3 (owner 2026-11-11): on a wider screen the Properties tab KEEPS showing that last object + // (a deselect is not a reason to lose the panel you were reading). The phone's sheet still closes — it + // covers the viewport. The read-only " is editing" state for a kept object a peer takes is 80a K1. + if (keepsLastObject()) return; closeSelectionInspector(); } +/** E3: does a deselect leave Properties on the last object (wide posture, the object still in the scene) */ +function keepsLastObject() { + if (!get(sidebarsWide) || get(phoneShell)) return false; + const last = /** @type {any} */ (get(selectedObject)); + return !!last?.uuid && get(objectsGroup)?.getObjectByProperty('uuid', last.uuid) === last; +} + /** * Set the gizmo transform mode from the toolbar OR the 1/2/3 shortcuts through * ONE path (151), so the toolbar tint (transformMode store) always matches. diff --git a/src/lib/panelHeaderPrefs.js b/src/lib/panelHeaderPrefs.js deleted file mode 100644 index 3c8a98bd..00000000 --- a/src/lib/panelHeaderPrefs.js +++ /dev/null @@ -1,66 +0,0 @@ -// 42 I1 — STICKY PANEL HEADERS, a LEAF (svelte/store + safeStorage). -// -// The user's words: "add additional pin button or implement somehow to stick/unstick/lock/unlock -// sticky header/name/'filter properties' … for 'configure scene'/'object properties' drawers, as it -// takes much space, but I want it to be configurable, enabled by default". The panel header (the -// title row + "Filter properties") either STICKS to the top of the panel while its properties -// scroll under it (the 38 R5 behaviour, still the default) or SCROLLS AWAY with them, giving the -// whole panel to the properties. One flag per PANEL — Configure Scene and Properties are separate -// panels and a person may want them different — the same on the phone's sheet / dock tab and the -// desktop sidebars, because the header component is one (InspectorHead). -// -// LOCAL like every chrome preference: saved on this device, never sent, never in a scene file. -// The header's own lock button flips its panel; Settings ▸ Interface ▸ "Sticky panel headers" -// sets both at once (and reads mixed when they differ). - -import { writable, get } from 'svelte/store'; -import { safeStorage } from './safeStorage'; - -const KEY = 'panels:stickyHeads'; - -/** the panels whose header can stick, in the order Settings lists them */ -export const STICKY_PANELS = /** @type {const} */ (['scene', 'properties']); -/** @typedef {'scene'|'properties'} StickyPanel */ - -/** @returns {Record} */ -function read() { - /** @type {Record} */ - const out = { scene: true, properties: true }; - try { - const raw = JSON.parse(safeStorage.getItem(KEY) || 'null'); - if (raw && typeof raw === 'object') for (const p of STICKY_PANELS) if (typeof raw[p] === 'boolean') out[p] = raw[p]; - } catch { - /* a broken value is the default */ - } - return out; -} - -/** per panel: does its header stick (true, the default) or scroll away with the content */ -export const stickyPanelHeads = writable(read()); - -let seen = false; -stickyPanelHeads.subscribe((v) => { - // the first call is the value just read — nothing to write back - if (!seen) { - seen = true; - return; - } - // write only what differs from the default, so an untouched device stores nothing - if (STICKY_PANELS.every((p) => v[p] !== false)) safeStorage.removeItem(KEY); - else safeStorage.setItem(KEY, JSON.stringify(v)); -}); - -/** @param {StickyPanel} panel */ -export function isStickyHead(panel) { - return get(stickyPanelHeads)[panel] !== false; -} - -/** the header's lock button @param {StickyPanel} panel */ -export function toggleStickyHead(panel) { - stickyPanelHeads.update((v) => ({ ...v, [panel]: v[panel] === false })); -} - -/** Settings ▸ "Sticky panel headers": both panels at once @param {boolean} on */ -export function setAllStickyHeads(on) { - stickyPanelHeads.set({ scene: !!on, properties: !!on }); -} diff --git a/src/lib/sidebars.js b/src/lib/sidebars.js index 2fad9497..78d940d5 100644 --- a/src/lib/sidebars.js +++ b/src/lib/sidebars.js @@ -89,6 +89,14 @@ function readWidth(side) { /** which side the Inspector docks to (LOCAL, persisted) */ export const inspectorSide = writable(/** @type {Side} */ (ls ? asSide(safeStorage.getItem('sidebar:inspectorSide')) : 'right')); inspectorSide.subscribe((v) => ls && safeStorage.setItem('sidebar:inspectorSide', v)); +/** + * uir-polish E5 (owner 2026-11-11, "moving Configure scene to the other sidebar drags the Properties tab with it"): + * the Configure Scene TAB has a side of its own (LOCAL, persisted). Absent = wherever Properties is (the pre-E5 + * single-side model, which is also every state where the two sides agree). See `inspectorDual` for what changes when + * they differ. + */ +export const sceneSide = writable(/** @type {Side} */ (ls && safeStorage.getItem('sidebar:sceneSide') ? asSide(safeStorage.getItem('sidebar:sceneSide')) : get(inspectorSide))); +sceneSide.subscribe((v) => ls && safeStorage.setItem('sidebar:sceneSide', v)); /** the Full-height pin, per side (LOCAL, persisted) */ export const sidebarFull = writable({ @@ -357,9 +365,14 @@ export function setDockMover(fn) { /** move a panel to a side (the tab drag's drop) @param {string} key @param {Side} side */ export function moveSidebarTab(key, side) { if (get(members)[key]?.side !== side) keepSideOpen(key); - // showhide-v8: the Inspector's two tabs share one element, so they move together - if (isInspectorKey(key)) inspectorSide.set(side); - else if (!dockMover?.(key, side)) return false; + // uir-polish E5: each Inspector tab moves ALONE (showhide-v8 moved both — one element cannot be in two columns; + // `inspectorDual` now draws Configure Scene with a second instance while the two sides differ) + if (key === 'scene') sceneSide.set(side); + else if (key === 'inspector') { + // Properties alone carries the element's side; with Configure Scene CLOSED its side follows (it opens beside) + if (!get(inspectorTabs).scene) sceneSide.set(side); + inspectorSide.set(side); + } else if (!dockMover?.(key, side)) return false; activateSidebarTab(key); return true; } @@ -464,6 +477,14 @@ function onInspector(closed, kind) { const key = tabOfKind(kind); if (key === 'inspector') propsKind = kind === 'file' ? 'file' : 'selection'; const tabs = get(inspectorTabs); + // uir-polish E5: Properties open in another column than Configure Scene's — asking for the scene opens / brings + // forward ITS tab (the second instance draws it) and the element stays on Properties + if (key === 'scene' && tabs.inspector && get(sceneSide) !== get(inspectorSide)) { + if (!tabs.scene) inspectorTabs.set({ ...tabs, scene: true }); + else activateSidebarTab('scene'); + insQuietly(() => inspectorKind.set(/** @type {any} */ (propsKind))); + return; + } if (!tabs[key]) inspectorTabs.set({ ...tabs, [key]: true }); else activateSidebarTab(key); // an open of a covered tab brings it forward (the old one-member arrival) return; @@ -505,14 +526,25 @@ export function startSidebars() { else if (!closed && !tabs[key]) inspectorTabs.set({ ...tabs, [key]: true }); }); // 70 W2: an Inspector in a pop-out window is no column member (it keeps its side for coming back) - derived([inspectorTabs, inspectorSide, panelAway], (x) => x).subscribe(([tabs, side, away]) => { + derived([inspectorTabs, inspectorSide, sceneSide, panelAway], (x) => x).subscribe(([tabs, side, sSide, away]) => { reportMember('inspector', side, tabs.inspector && !away.inspector); - reportMember('scene', side, tabs.scene && !away.inspector); + reportMember('scene', sSide, tabs.scene && !away.inspector); // uir-polish E5: its own side + }); + // uir-polish E5: entering the dual state (a tab moved apart) while the element showed the scene: it goes back to + // Properties — Configure Scene now has its own instance in its own column + inspectorDual.subscribe((dual) => { + if (dual && get(inspectorKind) === 'scene') insQuietly(() => inspectorKind.set(/** @type {any} */ (propsKind))); }); // a tab brought to the front shows its kind in the one element sidebarShown.subscribe((shown) => { - const key = shown[get(inspectorSide)]; - if (key && isInspectorKey(key) && get(inspectorTabs)[/** @type {'inspector'|'scene'} */ (key)]) showInspectorKind(/** @type {any} */ (key)); + // uir-polish E5: in the dual state the element is Properties' alone — Configure Scene's tab coming forward in + // its own column is the second instance's business + if (get(inspectorDual)) return; + const tabs = get(inspectorTabs); + // one tab open: it shows on its own side (Configure Scene alone may sit in the other column) + const side = !tabs.inspector && tabs.scene ? get(sceneSide) : get(inspectorSide); + const key = shown[side]; + if (key && isInspectorKey(key) && tabs[/** @type {'inspector'|'scene'} */ (key)]) showInspectorKind(/** @type {any} */ (key)); }); // an explicit open of an Inspector that is already open (Configure Scene, double-click, // a deep link) brings its tab to the front of the column @@ -548,6 +580,7 @@ onLayoutRestore(() => { right: safeStorage.getItem('sidebar:full:right') === 'true' }); inspectorSide.set(asSide(safeStorage.getItem('sidebar:inspectorSide'))); + sceneSide.set(safeStorage.getItem('sidebar:sceneSide') ? asSide(safeStorage.getItem('sidebar:sceneSide')) : get(inspectorSide)); sidebarMode.set({ left: readMode('left'), right: readMode('right') }); for (const side of SIDES) order[side] = readOrder(side); mru.update((v) => { @@ -573,6 +606,20 @@ export const sidebarsWide = readable(true, (set) => { return () => mq.removeEventListener('change', on); }); +/** + * uir-polish E5 — BOTH Inspector tabs open, in DIFFERENT columns (wide posture, the Inspector not in a pop-out window). + * Then the one Inspector element keeps Properties (on `inspectorSide`) and Configure Scene is drawn by a second + * instance (``, Menu.svelte) on `sceneSide`; `inspectorKind` never reads 'scene' in this state + * (a request for it activates the Configure Scene tab instead — `onInspector`). Every other state is the single + * element of showhide-v8: one tab open shows on ITS side, both on one side share the element. + */ +export const inspectorDual = derived([inspectorTabs, inspectorSide, sceneSide, sidebarsWide, panelAway], ([tabs, iSide, sSide, wide, away]) => !!(wide && tabs.inspector && tabs.scene && iSide !== sSide && !away.inspector)); +/** the column the one Inspector element draws in: Configure Scene's own side while it shows alone, else Properties' */ +export const inspectorElementSide = derived([inspectorKind, inspectorTabs, inspectorSide, sceneSide, inspectorDual], ([kind, tabs, iSide, sSide, dual]) => + dual || tabs.inspector ? iSide : tabs.scene || kind === 'scene' ? sSide : iSide +); + + const resizeTick = writable(0); // dock-fixes B (owner 2026-11-08, shot 5): the open main menu OVERLAYS the left column (it sits on --z-popover, diff --git a/src/lib/ui/minimalScroll.js b/src/lib/ui/minimalScroll.js index 90c5d8f4..af8269e0 100644 --- a/src/lib/ui/minimalScroll.js +++ b/src/lib/ui/minimalScroll.js @@ -33,27 +33,41 @@ function ensureStyle() { /** Pure geometry, exported for the unit test: where the thumb sits for a scroll state. * @param {{scrollTop: number, scrollHeight: number, clientHeight: number}} s * @param {number} [min] smallest thumb, px + * @param {number} [topInset] px of the viewport a sticky header covers (uir-polish E6): the track starts below it * @returns {{visible: boolean, top: number, height: number}} `top` is in CONTENT coordinates * (the thumb is a child of the scroller, so it scrolls with the content and is offset back) */ -export function thumbGeometry(s, min = 24) { +export function thumbGeometry(s, min = 24, topInset = 0) { const { scrollTop, scrollHeight, clientHeight } = s; const range = scrollHeight - clientHeight; if (!(range > 1) || clientHeight <= 0) return { visible: false, top: 0, height: 0 }; const inset = 2; - const track = clientHeight - inset * 2; + const top0 = Math.max(0, Math.min(topInset, clientHeight - inset * 2 - min)); + const track = clientHeight - inset * 2 - top0; const height = Math.max(min, Math.min(track, (clientHeight / scrollHeight) * track)); const t = Math.min(1, Math.max(0, scrollTop / range)); - return { visible: true, top: scrollTop + inset + t * (track - height), height }; + return { visible: true, top: scrollTop + top0 + inset + t * (track - height), height }; } /** * @param {HTMLElement} node the scrolling element - * @param {{hideAfter?: number} | undefined} [opts] + * @param {{hideAfter?: number, stickyTop?: string} | undefined} [opts] `stickyTop` = a selector for a sticky + * header INSIDE the scroller (uir-polish E6): the thumb runs below it instead of sliding under it */ export function minimalScroll(node, opts) { if (typeof window === 'undefined') return {}; ensureStyle(); let hideAfter = opts?.hideAfter ?? 900; + let stickyTop = opts?.stickyTop ?? ''; + /** how much of the scroller's viewport the sticky header covers (0 without one) */ + function topInset() { + if (!stickyTop) return 0; + const head = /** @type {HTMLElement | null} */ (node.querySelector(stickyTop)); + if (!head || !head.offsetParent) return 0; + const r = head.getBoundingClientRect(); + const n = node.getBoundingClientRect(); + return Math.max(0, Math.round(r.bottom - n.top - node.clientTop)); + } + let inset0 = 0; node.classList.add('tp-scroll'); if (getComputedStyle(node).position === 'static') node.style.position = 'relative'; @@ -74,7 +88,8 @@ export function minimalScroll(node, opts) { // the thumb up by its inset — the view drained 2 px a frame for seconds, items out of // sight above. Measure with the thumb out of layout: the offset clamps at once. thumb.style.display = 'none'; - geo = thumbGeometry(node); + inset0 = topInset(); + geo = thumbGeometry(node, 24, inset0); thumb.style.display = geo.visible ? '' : 'none'; if (!geo.visible) return; thumb.style.height = geo.height + 'px'; @@ -116,7 +131,7 @@ export function minimalScroll(node, opts) { /** @param {PointerEvent} e */ function onMove(e) { if (!thumb.hasAttribute('data-drag')) return; - const track = node.clientHeight - 4 - geo.height; + const track = node.clientHeight - 4 - inset0 - geo.height; const range = node.scrollHeight - node.clientHeight; if (track > 0) node.scrollTop = dragTop + ((e.clientY - dragY) / track) * range; } @@ -148,9 +163,11 @@ export function minimalScroll(node, opts) { place(); return { - /** @param {{hideAfter?: number} | undefined} next */ + /** @param {{hideAfter?: number, stickyTop?: string} | undefined} next */ update(next) { hideAfter = next?.hideAfter ?? 900; + stickyTop = next?.stickyTop ?? ''; + place(); }, destroy() { if (timer) clearTimeout(timer); diff --git a/src/lib/windows/panelHost.js b/src/lib/windows/panelHost.js index e36649ae..d61209f5 100644 --- a/src/lib/windows/panelHost.js +++ b/src/lib/windows/panelHost.js @@ -468,6 +468,7 @@ export const windowApi = { if (!m.dockTab(key)) cameBack([key]); } else if (key === 'inspector') { sb.inspectorSide.set(where); + sb.sceneSide.set(where); // uir-polish E5: a popped Inspector held both tabs — both come back here cameBack([key]); } else if (m.moveToSidebar(key, where)) b.armDockMode(key, false); // a column member floats else cameBack([key]); diff --git a/src/lib/workspace.js b/src/lib/workspace.js index 8dace771..3bc0f7f7 100644 --- a/src/lib/workspace.js +++ b/src/lib/workspace.js @@ -2,7 +2,6 @@ import { get } from 'svelte/store'; import { inspectorClose, inspectorKind, - inspectorPinned, flowGraphClose, flowCodeClose, animationClose, @@ -116,7 +115,7 @@ export function snapshotWorkspace({ always = false } = {}) { return { open, dockTab: get(bottomDockActive), - inspector: { kind: get(inspectorKind), pinned: get(inspectorPinned) } + inspector: { kind: get(inspectorKind) } // uir-polish E1: no pin any more (an old record's `pinned` is ignored) }; } @@ -140,7 +139,6 @@ export function applyWorkspace(record) { if (typeof record.dockTab === 'string') bottomDockActive.set(record.dockTab); if (record.inspector && typeof record.inspector === 'object') { if (typeof record.inspector.kind === 'string') inspectorKind.set(record.inspector.kind); - if (typeof record.inspector.pinned === 'boolean') inspectorPinned.set(record.inspector.pinned); } return true; } diff --git a/src/stores/appStore.js b/src/stores/appStore.js index 4a7b8cc9..8db16bb1 100644 --- a/src/stores/appStore.js +++ b/src/stores/appStore.js @@ -31,15 +31,13 @@ export const inspectorKind = writable('selection'); * its properties, nothing selected → the scene's (deselecting no longer closes * it). LOCAL preference. */ -export const inspectorPinned = writable( - typeof localStorage !== 'undefined' && safeStorage.getItem('inspectorPinned') === 'true' -); -if (typeof localStorage !== 'undefined') - inspectorPinned.subscribe((v) => { - try { - safeStorage.setItem('inspectorPinned', String(v)); - } catch {} - }); +export const inspectorPinned = writable(false); +// uir-polish E1 (owner 2026-11-11): the pin button is GONE from every surface, so the flag is no longer a +// preference — it stays a runtime switch (default off) for the suites that drive the old "follow every +// selection" posture through __stores, and the value an older build saved is dropped once. +try { + if (typeof localStorage !== 'undefined') safeStorage.removeItem('inspectorPinned'); +} catch {} export const flowGraphClose = writable(true); // Flow Code: an editable JSON view of the flow graph (roadmap 9). Added via the // Flow tab "+"; a standalone floating window that can tab-group with Flow. @@ -257,8 +255,11 @@ export function showSidebar(store, { focus = true } = {}) { } libraryClose.set(true); if (open && get(inspectorKind) === kind) { - revealInspector?.(kind); - if (focus) focusInspector?.(); + // uir-polish E4: a selection (focus: false) leaves a covered Properties tab covered on the phone too + if (focus) { + revealInspector?.(kind); + focusInspector?.(); + } return; // already showing this target } // 42 I4: an OPEN panel swaps its content in place. Closing it and reopening 50 ms later @@ -266,8 +267,10 @@ export function showSidebar(store, { focus = true } = {}) { // sidebar "shook". The slide belongs to a panel opening from CLOSED, never to a swap. if (open) { inspectorKind.set(kind); - revealInspector?.(kind); - if (focus) focusInspector?.(); + if (focus) { + revealInspector?.(kind); + focusInspector?.(); + } return; } inspectorClose.set(true); diff --git a/src/styles/ui.css b/src/styles/ui.css index 64a116c2..82c1ffb3 100644 --- a/src/styles/ui.css +++ b/src/styles/ui.css @@ -146,7 +146,7 @@ input.tp-check:disabled { its default top-16 / lower z, so it stays BELOW the profile icon. (Narrow = sheet.) */ @media (min-width: 641px) { /* 42 I3: the column's top under a docked bar is the sidebar bus's (--sb--top) */ - :root.connect-docked #inspector { + :root.connect-docked :is(#inspector, #inspector-scene) { z-index: calc(var(--z-chrome-top) + 1) !important; } } diff --git a/tests/e2e/battery.json b/tests/e2e/battery.json index 568ae62d..c16a85a4 100644 --- a/tests/e2e/battery.json +++ b/tests/e2e/battery.json @@ -230,7 +230,6 @@ "sheet-handoff": "red on the GitHub runner (SwiftShader, no retry) with unchanged code: deps-auto run 5, 2026-10-24", "sidebar-drop-targets": "red on the GitHub runner (SwiftShader, no retry) with unchanged code: deps-auto run 5, 2026-10-24", "sidebar-full-height": "flaky on the GitHub runner (SwiftShader): red or green-on-retry in deps-auto run 5 or 6 with unchanged code, 2026-10-24", - "sidebar-lock": "flaky on the GitHub runner (SwiftShader): red or green-on-retry in deps-auto run 5 or 6 with unchanged code, 2026-10-24", "sidebar-resize-follow": "red on the GitHub runner (SwiftShader, no retry) with unchanged code: deps-auto run 5, 2026-10-24", "sidebar-swap-motion": "flaky on the GitHub runner (SwiftShader): red or green-on-retry in deps-auto run 5 or 6 with unchanged code, 2026-10-24", "sidebar-tabs": "red on the GitHub runner (SwiftShader, no retry) with unchanged code: deps-auto run 5, 2026-10-24", @@ -521,6 +520,7 @@ "profile-handles-still": 240, "sidebar-handles-dock": 300, "docked-panes-polish": 180, + "uir-polish-fg": 240, "burger-windows": 240, "explorer-file-history": 240, "profiler-live-row": 180, @@ -806,7 +806,6 @@ "sidebar-any-window": 370, "sidebar-drop-targets": 34, "sidebar-full-height": 136, - "sidebar-lock": 134, "sidebar-plus-empty": 264, "dock-fixes": 240, "dock-fixes-b": 240, diff --git a/tests/e2e/lock-panels.test.cjs b/tests/e2e/lock-panels.test.cjs index 84d9c33e..79080a14 100644 --- a/tests/e2e/lock-panels.test.cjs +++ b/tests/e2e/lock-panels.test.cjs @@ -64,39 +64,18 @@ h.run(async () => { } await S.closeAll(page); - // ---- 2. the Properties pin ---------------------------------------------------------------------- + // ---- 2. Properties after a deselect (uir-polish E1/E3: no pin; the wide panel keeps the last object) ---- await page.evaluate((u) => window.__stores.objectActions.selectObject(u, true), ctx.box); - await page.locator('#inspector-pin').waitFor({ state: 'visible', timeout: 8000 }); - const pinnedAt = async () => (await page.locator('#inspector-pin').getAttribute('aria-pressed')) === 'true'; - if (await pinnedAt()) await page.locator('#inspector-pin').click(); - h.check(!(await pinnedAt()), 'Properties starts unpinned'); - await page.evaluate(() => window.__stores.objectActions.deselectObject()); await page.waitForTimeout(400); - h.check((await L.storeValue(page, 'inspectorClose')) === true, 'unpinned, deselecting closes the Properties panel'); - await page.evaluate((u) => window.__stores.objectActions.selectObject(u, true), ctx.box); - await page.locator('#inspector-pin').click(); - await page.waitForTimeout(200); - h.check(await pinnedAt(), 'the pin toggles on (aria-pressed)'); - h.check((await L.storeValue(page, 'inspectorPinned')) === true, 'and is the inspectorPinned setting'); + h.check((await page.locator('#inspector-pin').count()) === 0, 'Properties has no pin (E1)'); await page.evaluate(() => window.__stores.objectActions.deselectObject()); await page.waitForTimeout(400); - h.check((await L.storeValue(page, 'inspectorClose')) === false && (await L.storeValue(page, 'inspectorKind')) === 'scene', 'pinned, deselecting keeps it open on the scene'); - await page.evaluate((u) => window.__stores.objectActions.selectObject(u), ctx.sphere); - await page.waitForTimeout(400); - // showhide-v8 (R2): with Configure Scene in front, a pinned panel no longer SWAPS to the selection: Configure Scene - // stays in front and a Properties tab joins it (it shows the selection when brought forward) - const tabsNow = await page.evaluate(() => { - let v; - window.__stores.sidebars?.sidebarTabs.subscribe((x) => (v = x))(); - return v ? [...v.left, ...v.right] : null; + const kept = await page.evaluate(() => { + let o; + window.__stores.selectedObject.subscribe((v) => (o = v))(); + return o?.uuid ?? null; }); - h.check( - (await L.storeValue(page, 'inspectorClose')) === false && (await L.storeValue(page, 'inspectorKind')) === 'scene' && !!tabsNow?.includes('inspector'), - `pinned, a plain selection keeps Configure Scene in front and adds the Properties tab (no swap) (${JSON.stringify(tabsNow)})` - ); - await page.locator('#inspector-pin').click(); - await page.waitForTimeout(200); - h.check(!(await pinnedAt()), 'and unpins'); + h.check((await L.storeValue(page, 'inspectorClose')) === false && (await L.storeValue(page, 'inspectorKind')) === 'selection' && kept === ctx.box, `a deselect keeps Properties on the last object (E3) (${kept})`); await S.closeAll(page); // ---- 3. the toolbar cells ---------------------------------------------------------------------- diff --git a/tests/e2e/scene-panel-polish.test.cjs b/tests/e2e/scene-panel-polish.test.cjs index d39f8b14..4df326b0 100644 --- a/tests/e2e/scene-panel-polish.test.cjs +++ b/tests/e2e/scene-panel-polish.test.cjs @@ -92,14 +92,8 @@ h.run(async () => { }); h.check(head.placeholder === 'Filter scene settings', `PL14.1 the filter says what it filters ("${head.placeholder}")`); h.check(!head.title, 'PL14.2 no inner title row in the panel'); - h.check(head.pinShown && !head.pinInPanel, `PL14.3 the pin is a strip button outside the panel (shown ${head.pinShown}, in panel ${head.pinInPanel})`); - const pinned0 = (await page.locator('#inspector-pin').getAttribute('aria-pressed')) === 'true'; - await page.locator('#inspector-pin').click(); - await page.waitForTimeout(250); - const pinned1 = (await page.locator('#inspector-pin').getAttribute('aria-pressed')) === 'true'; - h.check(pinned1 !== pinned0, `PL14.4 the strip pin toggles Follow the selection (${pinned0} -> ${pinned1})`); - if (pinned1 !== pinned0) await page.locator('#inspector-pin').click(); - await shots(page, 'strip-pin'); + // uir-polish E1 (owner 2026-11-11): the strip's pin and lock are gone + h.check(!head.pinShown && (await page.locator('#inspector-sticky-head').count()) === 0, `PL14.3 no pin and no lock (E1) (pin ${head.pinShown})`); await page.evaluate(() => window.__stores.commandsHandler.sceneCommand('/create box')); await page.waitForTimeout(800); @@ -113,7 +107,7 @@ h.run(async () => { const firstSection = Math.min(...[...(ins?.querySelectorAll('.sec-panel') ?? [])].map((s) => s.getBoundingClientRect().top)); return { placeholder: document.querySelector('#inspector-search')?.getAttribute('placeholder') ?? '', name: name?.value ?? '', first: nameTop < firstSection }; }); - h.check(/^Filter properties of /.test(props.placeholder) && props.placeholder.includes(props.name), `PL14.5 a selection's filter names it ("${props.placeholder}")`); + h.check(props.placeholder === props.name, `PL14.5 a selection's filter placeholder is its name alone (E2) ("${props.placeholder}")`); h.check(props.first, 'PL14.6 the object name is the first Properties row'); await shots(page, 'properties-head'); @@ -313,8 +307,8 @@ h.run(async () => { h.check(!fit.guide, `PL29.1 ${dev.id}: no sky picture cards or guide`); h.check(fit.scrollTop === 0 && fit.rows.every((r) => r.ok) && fit.exposure, `PL29.2 ${dev.id}: the presets, Exposure and the sky row show without scrolling (${JSON.stringify(fit.rows)})`); await shots(P.page, `sky-${dev.id}`); - // the lock sits in the sheet's grip row; the pin is the first item of the tab's long-press menu - h.check((await P.page.locator('#ps-dock-grip #inspector-sticky-head').count()) === 1, `PL14.7 ${dev.id}: the header lock is in the grip row`); + // uir-polish E1: no header lock in the grip row and no "Follow the selection" in the long-press menu + h.check((await P.page.locator('#inspector-sticky-head').count()) === 0, `PL14.7 ${dev.id}: no header lock`); const tab = P.page.locator('[data-dock-tab="scene"]').first(); if (await tab.count()) { await tab.dispatchEvent('contextmenu'); @@ -326,7 +320,7 @@ h.run(async () => { const items = menu ? [...menu.querySelectorAll('[role="menuitem"]')] : []; return { testid: items[0]?.getAttribute('data-testid') ?? '', text: items[0]?.textContent?.trim() ?? '', found: !!pin }; }); - h.check(first.testid === 'inspector-pin', `PL14.8 ${dev.id}: "Follow the selection" is the first long-press item (${JSON.stringify(first)})`); + h.check(!first.found, `PL14.8 ${dev.id}: no "Follow the selection" in the long-press menu (${JSON.stringify(first)})`); await shots(P.page, `tab-menu-${dev.id}`); await P.page.keyboard.press('Escape'); } else h.check(false, `PL14.8 ${dev.id}: premise — the scene dock tab is on screen`); diff --git a/tests/e2e/sidebar-lock.test.cjs b/tests/e2e/sidebar-lock.test.cjs deleted file mode 100644 index b6e629d9..00000000 --- a/tests/e2e/sidebar-lock.test.cjs +++ /dev/null @@ -1,121 +0,0 @@ -// 43 J6 — ON A WIDER SCREEN THE PANEL LOCK SITS IN THE SIDEBAR HEADER, BESIDE "FULL HEIGHT". The user -// (preview 1.33): "Pin button on desktop mode now more relevant to show for sidebar it locks, I would -// place it near "Full height" button". 42 I1's lock (the panel header sticks / scrolls away) moves out -// of the Configure Scene / Properties header into the side's tab strip, next to the Full-height pin, -// whenever the Inspector is the tab in front; it still acts on the panel in front (one flag per panel) -// and the header still obeys it. The phone has no sidebars: there the lock stays in the panel header. -// Desktop 1440x900 + a 1024 window + a folded N6 (390x844, touch). -// -// Counterfactual (base = feat/42-int, 1.33.0): the lock is in the panel header on every screen -// (sections 1-2 red), the strip holds the pin alone. -const h = require('./helpers.cjs'); - -/** where the lock is and what it is next to */ -function lockPlace(page) { - return page.evaluate(() => { - const b = [...document.querySelectorAll('#inspector-sticky-head')].filter((e) => e.getClientRects().length > 0); - const one = b[0]; - const r = one?.getBoundingClientRect(); - const strip = one?.closest('.sb-strip'); - const pin = strip?.querySelector('[id^="sidebar-full-"]'); - const pr = pin?.getBoundingClientRect(); - return { - count: b.length, - inStrip: !!strip, - stripSide: strip?.getAttribute('data-side') ?? null, - inHeader: !!one?.closest('#drawer-label'), - gapToPin: r && pr ? Math.round(pr.left - r.right) : null, - sameRow: r && pr ? Math.abs(r.top + r.height / 2 - (pr.top + pr.height / 2)) < 3 : null, - pressed: one?.getAttribute('aria-pressed') ?? null, - headerHasLock: !!document.querySelector('#drawer-label #inspector-sticky-head'), - // 78-int PL14: the phone's lock rides the sheet-grip row (the Inspector has no inner title row) - inGrip: !!one?.closest('.sg-end') - }; - }); -} - -async function openProperties(page) { - await page.evaluate(() => window.__stores.commandsHandler.sceneCommand('/create box')); - await page.waitForTimeout(900); - await page.evaluate(() => { - const w = window.__stores; - let g; - w.objectsGroup.subscribe((v) => (g = v))(); - const o = g.children[g.children.length - 1]; - w.objectActions.selectObject(o.uuid, true); - w.inspectorPinned.set(true); - w.showSidebar('properties'); - }); - await page.waitForTimeout(1000); -} - -h.run(async () => { - const browser = await h.launch({ args: h.GPU_ARGS }); - for (const vw of [1440, 1024]) { - const P = await h.setupPage(browser, 'desk' + vw, { context: { viewport: { width: vw, height: vw === 1440 ? 900 : 768 } } }); - const page = P.page; - const tag = `${vw}px`; - await openProperties(page); - // ---------- 1. the lock is in the right sidebar's strip, beside Full height ---------- - let at = await lockPlace(page); - h.check(at.count === 1, `${tag}: one lock on screen (${at.count})`); - h.check(at.inStrip && at.stripSide === 'right' && !at.inHeader, `${tag}: the lock sits in the sidebar header strip, not the panel header (${JSON.stringify(at)})`); - h.check(at.sameRow && at.gapToPin !== null && at.gapToPin >= -1 && at.gapToPin <= 8, `${tag}: right beside the Full-height pin, on one row (${JSON.stringify(at)})`); - // ---------- 2. it still locks the header it always did ---------- - const sticky0 = await page.evaluate(() => document.querySelector('#drawer-label')?.getAttribute('data-sticky')); - await page.locator('#inspector-sticky-head').click(); - await page.waitForTimeout(400); - const sticky1 = await page.evaluate(() => document.querySelector('#drawer-label')?.getAttribute('data-sticky')); - at = await lockPlace(page); - h.check(sticky0 === 'true' && sticky1 === 'false' && at.pressed === 'false', `${tag}: a press lets the Properties header scroll away (${sticky0} -> ${sticky1}, pressed ${at.pressed})`); - // Configure Scene keeps its own flag - await page.evaluate(() => window.__stores.showSidebar('scene')); - await page.waitForTimeout(800); - at = await lockPlace(page); - const sceneSticky = await page.evaluate(() => document.querySelector('#drawer-label')?.getAttribute('data-sticky')); - h.check(at.inStrip && at.pressed === 'true' && sceneSticky === 'true', `${tag}: on Configure Scene the lock reads that panel's flag (locked) (${JSON.stringify(at)}, ${sceneSticky})`); - // ---------- 3. a side showing another tab has no lock ---------- - await page.locator('p[title="Object list (O)"]').click(); - await page.waitForTimeout(400); - const l = await page.evaluate(() => { - const r = document.querySelector('#object-list').getBoundingClientRect(); - return [r.x + 24, r.y + 14]; - }); - await page.mouse.move(l[0], l[1]); - await page.mouse.down(); - await page.mouse.move(vw - 12, 400, { steps: 14 }); - await page.mouse.up(); - await page.waitForTimeout(500); - at = await lockPlace(page); - const front = await page.evaluate(() => document.querySelector('#sidebar-strip-right [aria-selected="true"]')?.getAttribute('data-sidebar-tab')); - h.check(front === 'objects' && at.count === 0, `${tag}: with the Objects tab in front the strip shows no lock (${front}, ${at.count})`); - if (vw === 1440 && process.env.OUT) { - await page.locator('#sidebar-strip-right [data-sidebar-tab="inspector"]').click(); - await page.waitForTimeout(400); - for (const t of ['dark', 'light']) { - await page.evaluate((th) => window.__stores.themes.theme.set(th), t); - await page.waitForTimeout(350); - await page.screenshot({ path: `${process.env.OUT}/j6-lock-beside-full-height-${t}.png`, clip: { x: vw - 420, y: 40, width: 420, height: 160 } }); - } - await page.evaluate(() => window.__stores.themes.theme.set('dark')); - } - await P.ctx.close(); - } - - // ---------- 4. the phone keeps the lock on the panel: in its sheet-grip row (78-int PL14) ---------- - const F = await h.setupPage(browser, 'folded', { - context: { viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true, deviceScaleFactor: 2.9 }, - storage: { toursSeen: '{"editor-touch":true,"editor":true}' } - }); - await openProperties(F.page); - const ph = await lockPlace(F.page); - h.check(ph.count === 1 && ph.inGrip && !ph.inHeader && !ph.inStrip, `phone: the lock sits in the Properties sheet-grip row (${JSON.stringify(ph)})`); - // and still locks the header (the filter row) - const s0 = await F.page.evaluate(() => document.querySelector('#drawer-label')?.getAttribute('data-sticky')); - await F.page.locator('.sg-end #inspector-sticky-head').first().tap(); - await F.page.waitForTimeout(400); - const s1 = await F.page.evaluate(() => document.querySelector('#drawer-label')?.getAttribute('data-sticky')); - h.check(s0 === 'true' && s1 === 'false', `phone: a tap on the grip-row lock lets the header scroll away (${s0} -> ${s1})`); - - await h.finish(browser); -}); diff --git a/tests/e2e/sticky-panel-header.test.cjs b/tests/e2e/sticky-panel-header.test.cjs index 13849e35..4ccdbb9f 100644 --- a/tests/e2e/sticky-panel-header.test.cjs +++ b/tests/e2e/sticky-panel-header.test.cjs @@ -1,13 +1,14 @@ -// 42 I1 — "both mobile and desktop: add additional pin button or implement somehow to stick/unstick/ -// lock/unlock sticky header/name/'filter properties' on mobile for 'configure scene'/'object -// properties' drawers, as it takes much space, but I want it to be configurable, enabled by default". -// The panel header (title + "Filter properties") wears a LOCK: locked (the default) = it stays on top -// while the properties scroll; unlocked = it scrolls away with them. Remembered PER PANEL (Configure -// Scene vs Properties), saved on this device; Settings ▸ Interface ▸ "Sticky panel headers" sets both -// (mixed when they differ). One header component, so the desktop sidebar, the folded phone's sheet -// and the unfolded phone all get it — checked on each, the phone with real TAPS. -// Counterfactual (feat/41-int): there is no #inspector-sticky-head and the header always sticks. -// OUT= writes the screenshots (locked + unlocked, dark + light, per device). +// uir-polish phase E (owner feedback on 1.65.0, 2026-11-11) — the Configure Scene / Properties panels. +// E1 no lock/unlock and no pin/unpin button anywhere (the sidebar strip, the phone grip row, the phone tab menu) +// E2 the filter row ALWAYS sticks; Properties' placeholder is the object's name alone +// E3 a deselect keeps Properties on the PREVIOUS object (wide posture); the kept object leaving the scene closes it +// E4 another tab in front of the column stays in front on later selections; a double-click / "Properties" brings +// Properties forward +// E6 the minimal scrollbar runs BELOW the sticky filter row instead of sliding under it +// This file replaces 42 I1's lock suite (the lock is gone: E1/E2). Desktop 1440x900, folded N6 390x896, unfolded 770x850. +// Counterfactual (v1.65.0): the lock/pin exist (E1 red), the placeholder reads "Filter properties of …" (E2 red), a +// deselect closes Properties (E3 red), the thumb starts at the scroller's top under the filter row (E6 red). +// OUT= writes screenshots (dark + light). const h = require('./helpers.cjs'); const path = require('path'); @@ -18,7 +19,7 @@ const DEVICES = [ { name: 'unfolded', context: { viewport: { width: 770, height: 850 }, deviceScaleFactor: 2.9, hasTouch: true, isMobile: true }, touch: true } ]; -/** the header's place against its scroller; scrolls the scroller to `to` first ('top'|'bottom'|null) @param {any} page @param {string|null} to */ +/** the header's place against its scroller, plus the thumb; scrolls the scroller to `to` first @param {any} page @param {string|null} to */ const measure = (page, to) => page.evaluate((where) => { const head = /** @type {HTMLElement|null} */ (document.querySelector('#drawer-label')); @@ -29,8 +30,11 @@ const measure = (page, to) => if (!sc) return { scroller: false, sticky: head.dataset.sticky }; if (where === 'top') sc.scrollTop = 0; if (where === 'bottom') sc.scrollTop = sc.scrollHeight; + sc.dispatchEvent(new Event('scroll')); const s = sc.getBoundingClientRect(); const r = head.getBoundingClientRect(); + const thumb = /** @type {HTMLElement|null} */ (sc.querySelector(':scope > .tp-scroll-thumb')); + const t = thumb && thumb.style.display !== 'none' ? thumb.getBoundingClientRect() : null; return { scroller: true, sticky: head.dataset.sticky, @@ -39,19 +43,12 @@ const measure = (page, to) => range: Math.round(sc.scrollHeight - sc.clientHeight), headTop: Math.round(r.top - s.top), headBottom: Math.round(r.bottom - s.top), - headH: Math.round(r.height) + thumbTop: t ? Math.round(t.top - s.top) : null, + thumbBottom: t ? Math.round(t.bottom - s.top) : null, + viewH: Math.round(sc.clientHeight) }; }, to); -/** @param {any} page @param {boolean} touch */ -async function pressLock(page, touch) { - const b = page.locator('#inspector-sticky-head'); - await b.waitFor({ state: 'visible', timeout: 8000 }); - if (touch) await b.tap(); - else await b.click(); - await page.waitForTimeout(350); -} - /** a scene with enough rows to scroll, the box selected and its Properties open @param {any} page */ async function openProperties(page) { await page.evaluate(() => { @@ -59,17 +56,18 @@ async function openProperties(page) { w.commandsHandler.sceneCommand('/create box'); }); await page.waitForTimeout(900); - await page.evaluate(() => { + const uuid = await page.evaluate(() => { const w = /** @type {any} */ (window).__stores; let g; w.objectsGroup.subscribe((/** @type {any} */ v) => (g = v))(); const o = g.children[g.children.length - 1]; + o.name = 'Crate 7'; w.objectActions.selectObject(o.uuid, true); - w.inspectorPinned.set(true); - w.showSidebar('properties'); + return o.uuid; }); - await page.waitForSelector('#inspector-sticky-head', { state: 'visible', timeout: 10000 }).catch(() => {}); + await page.waitForSelector('#inspector-search', { state: 'visible', timeout: 10000 }).catch(() => {}); await page.waitForTimeout(900); + return uuid; } /** @param {any} page @param {string} name */ @@ -78,124 +76,176 @@ async function shots(page, name) { for (const t of ['dark', 'light']) { await page.evaluate((th) => window.__stores.themes.theme.set(th), t); await page.waitForTimeout(350); - await page.screenshot({ path: path.join(OUT, `${name}-${t}.png`) }); + await page.screenshot({ path: path.join(OUT, `e-${name}-${t}.png`) }); } await page.evaluate(() => window.__stores.themes.theme.set('dark')); } +/** @param {any} page @param {string} name */ +const store = (page, name) => + page.evaluate((n) => { + let v; + window.__stores[n].subscribe((/** @type {any} */ x) => (v = x))(); + return v; + }, name); + h.run(async () => { const browser = await h.launch(); for (const dev of DEVICES) { const P = await h.setupPage(browser, dev.name, { context: dev.context }); const page = P.page; const tag = dev.name; - await openProperties(page); - const has = !!(await page.$('#inspector-sticky-head')); - h.check(has, `${tag}: the Properties header has a lock button`); - if (!has) { - await P.ctx.close(); - continue; - } - // default: locked (sticky), the header stays at the top of the scrolled panel + const uuid = await openProperties(page); + + // E1 — no lock, no pin + const chrome = await page.evaluate(() => ({ + lock: document.querySelectorAll('#inspector-sticky-head').length, + pin: document.querySelectorAll('#inspector-pin, [data-testid="inspector-pin"]').length + })); + h.check(chrome.lock === 0 && chrome.pin === 0, `${tag} E1: no header lock and no pin (${JSON.stringify(chrome)})`); + + // E2 — the placeholder is the name; the row sticks + const ph = await page.evaluate(() => document.querySelector('#inspector-search')?.getAttribute('placeholder')); + h.check(ph === 'Crate 7', `${tag} E2: the Properties filter placeholder is the object's name alone ("${ph}")`); let m = await measure(page, 'bottom'); h.check(!!m?.scroller && (m?.range ?? 0) > 40, `${tag}: (premise) the Properties panel scrolls (${JSON.stringify(m)})`); - h.check(m?.sticky === 'true' && m.scrollTop > 0 && m.headTop >= -1 && m.headTop <= 2, `${tag}: by default the header sticks — scrolled ${m?.scrollTop}px it is still on top (top ${m?.headTop})`); - const pressed = await page.evaluate(() => document.querySelector('#inspector-sticky-head')?.getAttribute('aria-pressed')); - h.check(pressed === 'true', `${tag}: the lock reads pressed (${pressed})`); - await measure(page, 'top'); - await shots(page, `i1-${tag}-properties-locked`); - // unlock: the header scrolls away with the properties - await pressLock(page, dev.touch); - m = await measure(page, 'bottom'); - h.check(m?.sticky === 'false' && m.position !== 'sticky' && m.headBottom <= 0, `${tag}: unlocked, scrolling takes the header away (bottom ${m?.headBottom}, ${m?.position})`); + h.check(m?.sticky === 'true' && m.position === 'sticky' && m.scrollTop > 0 && m.headTop >= -1 && m.headTop <= 2, `${tag} E2: scrolled ${m?.scrollTop}px, the filter row is still on top (top ${m?.headTop})`); + // E6 — the thumb runs below the filter row, at both ends of the scroll + h.check(m?.thumbTop !== null && (m?.thumbTop ?? 0) >= (m?.headBottom ?? 0) && (m?.thumbBottom ?? 0) <= (m?.viewH ?? 0) + 1, `${tag} E6: at the end the thumb sits below the filter row (${JSON.stringify(m)})`); m = await measure(page, 'top'); - h.check(m?.headTop >= -1 && m.headTop <= 2, `${tag}: …and it is back at the top of the panel (${m?.headTop})`); - await shots(page, `i1-${tag}-properties-unlocked-top`); - await measure(page, 'bottom'); - await shots(page, `i1-${tag}-properties-unlocked-scrolled`); - // per panel: Configure Scene keeps its own (default) setting + h.check(m?.thumbTop !== null && (m?.thumbTop ?? 0) >= (m?.headBottom ?? 0), `${tag} E6: at the top the thumb starts below the filter row (${m?.thumbTop} >= ${m?.headBottom})`); + await shots(page, `${tag}-properties`); + // Configure Scene: the same sticky row and its own placeholder await page.evaluate(() => window.__stores.showSidebar('scene')); await page.waitForTimeout(900); - await page.waitForSelector('#scene-quality-preset, #environment-presets', { state: 'visible', timeout: 10000 }).catch(() => {}); m = await measure(page, 'bottom'); - h.check(m?.sticky === 'true' && m.headTop >= -1 && m.headTop <= 2, `${tag}: Configure Scene keeps ITS header sticky — remembered per panel (top ${m?.headTop})`); - const stored = await page.evaluate(() => localStorage.getItem('panels:stickyHeads')); - h.check(stored === '{"scene":true,"properties":false}', `${tag}: saved on this device (${stored})`); + const sph = await page.evaluate(() => document.querySelector('#inspector-search')?.getAttribute('placeholder')); + h.check(m?.sticky === 'true' && m.headTop >= -1 && m.headTop <= 2 && sph === 'Filter scene settings', `${tag} E2: Configure Scene's filter row sticks too (top ${m?.headTop}, "${sph}")`); + h.check(m?.thumbTop !== null && (m?.thumbTop ?? 0) >= (m?.headBottom ?? 0), `${tag} E6: Configure Scene's thumb runs below its filter row (${m?.thumbTop} >= ${m?.headBottom})`); + await shots(page, `${tag}-scene`); + if (dev.name === 'desktop') { - // the deep link lands a section right at the top when the header scrolls away - await pressLock(page, false); - // 78-int PL17: the device sections start closed, so open the ones below the target first — a - // deep link can only land a section at the top when there is a panel's height of content under it - for (const label of ['Snapping', 'Grid', 'Camera', 'View']) { - await page.evaluate((l) => window.__stores.openSceneSection(l), label); - await page.waitForTimeout(500); - } - await page.waitForTimeout(400); - const land = await page.evaluate(() => { - // the deep link lands the SECTION element (Section.svelte's findTarget) - const sec = [...document.querySelectorAll('section.sec-panel')].find((e) => /^View\b/.test(e.textContent?.trim() ?? '')); - const head = document.querySelector('#drawer-label'); - let sc = head?.parentElement ?? null; - while (sc && !(/(auto|scroll)/.test(getComputedStyle(sc).overflowY) && sc.scrollHeight > sc.clientHeight + 4)) sc = sc.parentElement; - return sec && sc ? Math.round(sec.getBoundingClientRect().top - sc.getBoundingClientRect().top) : null; - }); - h.check(land !== null && land >= 0 && land <= 40, `${tag}: a deep link with the header unlocked lands the section at the top, not a header's height below (${land})`); - await pressLock(page, false); - // Settings ▸ Interface ▸ Sticky panel headers: mixed while they differ, one press sets both - await page.evaluate(() => { - const w = /** @type {any} */ (window).__stores; - w.settingsSection.set('interface'); - w.settingsOpen.set(true); - }); - await page.waitForSelector('#sticky-panel-heads', { timeout: 10000 }); - const st = await page.evaluate(() => document.querySelector('#sticky-panel-heads')?.getAttribute('aria-pressed')); - h.check(st === 'mixed', `${tag}: Settings reads mixed while the two panels differ (${st})`); - await page.click('#sticky-panel-heads'); + // E3 — a deselect keeps Properties on the last object + await page.evaluate((u) => window.__stores.objectActions.selectObject(u, true), uuid); + await page.waitForTimeout(600); + await page.evaluate(() => window.__stores.objectActions.deselectObject()); + await page.waitForTimeout(600); + const kept = await page.evaluate(() => ({ + search: document.querySelector('#inspector-search')?.getAttribute('placeholder') ?? null, + shown: !!document.querySelector('#inspector') + })); + h.check((await store(page, 'inspectorClose')) === false && (await store(page, 'inspectorKind')) === 'selection' && kept.search === 'Crate 7', `E3: after a deselect Properties still shows the last object (${JSON.stringify(kept)})`); + h.check(((await store(page, 'selectedObjects')) ?? []).length === 0, 'E3: (premise) nothing is selected'); + // the kept object is still editable: a Name edit lands on it + await page.fill('#inspector input[aria-label="Name"], #inspector #object-name', 'Crate 8').catch(() => {}); + await page.keyboard.press('Enter').catch(() => {}); await page.waitForTimeout(300); - let both = await page.evaluate(() => { + // it leaves the scene (a peer's delete arrives as commandsHandler.deleteObject): the panel closes + await page.evaluate((u) => window.__stores.commandsHandler.deleteObject(u), uuid); + await page.waitForTimeout(700); + h.check((await store(page, 'inspectorClose')) === true || (await store(page, 'inspectorKind')) !== 'selection', 'E3: when the kept object is deleted, Properties closes'); + + // E5 — Configure Scene moves to the other sidebar ALONE + const e5 = await openProperties(page); + await page.evaluate(() => window.__stores.showSidebar('scene')); + await page.waitForTimeout(700); + const sides0 = await page.evaluate(() => { let v; - window.__stores.panelHeaderPrefs.stickyPanelHeads.subscribe((/** @type {any} */ x) => (v = x))(); + window.__stores.sidebars.sidebarTabs.subscribe((x) => (v = x))(); return v; }); - h.check(both?.scene === true && both?.properties === true, `${tag}: one press makes both sticky (${JSON.stringify(both)})`); - await page.click('#sticky-panel-heads'); + const home = sides0.right.includes('inspector') ? 'right' : 'left'; + const away = home === 'right' ? 'left' : 'right'; + h.check(sides0[home].includes('inspector') && sides0[home].includes('scene'), `E5: (premise) Properties and Configure Scene share the ${home} column (${JSON.stringify(sides0)})`); + await page.evaluate((s) => window.__stores.sidebars.moveSidebarTab('scene', s), away); + await page.waitForTimeout(900); + const dual = await page.evaluate(() => { + let t; + window.__stores.sidebars.sidebarTabs.subscribe((x) => (t = x))(); + const vis = (/** @type {Element|null} */ e) => !!e && e.getClientRects().length > 0 && !e.hasAttribute('data-sidebar-hidden'); + const main = document.querySelector('#inspector'); + const scene = document.querySelector('#inspector-scene'); + return { + tabs: t, + main: vis(main) ? main.getAttribute('data-sidebar-side') : null, + mainPh: main?.querySelector('#inspector-search')?.getAttribute('placeholder') ?? null, + scene: vis(scene) ? scene.getAttribute('data-sidebar-side') : null, + scenePh: scene?.querySelector('#inspector-search')?.getAttribute('placeholder') ?? null + }; + }); + h.check(dual.tabs[away].includes('scene') && !dual.tabs[away].includes('inspector') && dual.tabs[home].includes('inspector'), `E5: only Configure Scene moved to the ${away} column (${JSON.stringify(dual.tabs)})`); + h.check(dual.main === home && dual.mainPh === 'Crate 7', `E5: Properties stays on the ${home} side showing the object (${dual.main}, "${dual.mainPh}")`); + h.check(dual.scene === away && dual.scenePh === 'Filter scene settings', `E5: Configure Scene shows in the ${away} column at the same time (${dual.scene}, "${dual.scenePh}")`); + await shots(page, 'e5-dual'); + // the burger's Configure Scene keeps working in the dual state (it brings its tab forward, Properties stays) + await page.evaluate(() => window.__stores.showSidebar('scene')); + await page.waitForTimeout(500); + h.check((await store(page, 'inspectorKind')) !== 'scene' && !!(await page.$('#inspector-scene')), 'E5: asking for Configure Scene in the dual state keeps Properties in its element'); + // back together: one element again + await page.evaluate((s) => window.__stores.sidebars.moveSidebarTab('scene', s), home); + await page.waitForTimeout(900); + const one = await page.evaluate(() => ({ second: !!document.querySelector('#inspector-scene') })); + h.check(!one.second, 'E5: moved back beside Properties, the second instance is gone'); + await page.evaluate((u) => window.__stores.objectActions.selectObject(u, true), e5); + await page.waitForTimeout(400); + + // E4 — another tab in front stays in front on later selections + const b2 = await openProperties(page); + await page.evaluate(() => window.__stores.objectListClose?.set?.(false)); await page.waitForTimeout(300); - both = await page.evaluate(() => { + const tabs = await page.evaluate(() => { let v; - window.__stores.panelHeaderPrefs.stickyPanelHeads.subscribe((/** @type {any} */ x) => (v = x))(); + window.__stores.sidebars.sidebarTabs.subscribe((x) => (v = x))(); return v; }); - h.check(both?.scene === false && both?.properties === false, `${tag}: …and another unsticks both (${JSON.stringify(both)})`); - await page.fill('#settings-search', 'sticky header'); - await page.waitForTimeout(700); - const found = await page.evaluate(() => { - const r = document.querySelector('#row-sticky-panel-heads'); - return !!r && /** @type {HTMLElement} */ (r).offsetParent !== null; + const side = tabs.left.includes('inspector') ? 'left' : 'right'; + const other = tabs[side].find((/** @type {string} */ k) => k !== 'inspector' && k !== 'scene') ?? null; + if (!other) { + // put the Explorer into the Properties column so a second tab exists + // open it first (a closed panel is no tab to move), then move it beside Properties + await page.evaluate(() => window.__stores.explorerClose?.set?.(false)); + await page.waitForTimeout(600); + await page.evaluate((s) => window.__stores.sidebars.moveSidebarTab?.('explorer', s), side); + await page.waitForTimeout(600); + } + const tabs2 = await page.evaluate(() => { + let v; + window.__stores.sidebars.sidebarTabs.subscribe((x) => (v = x))(); + return v; }); - h.check(found, `${tag}: Settings search "sticky header" finds the row`); - await page.fill('#settings-search', 'filter properties'); - await page.waitForTimeout(700); - const found2 = await page.evaluate(() => !!document.querySelector('#row-sticky-panel-heads') && /** @type {any} */ (document.querySelector('#row-sticky-panel-heads')).offsetParent !== null); - h.check(found2, `${tag}: …and "filter properties" too (keywords)`); - await page.evaluate(() => window.__stores.settingsOpen.set(false)); - // survives a reload (both unstuck now) - await h.freshReload(P); - await page.locator('#restore-session-dismiss').click({ timeout: 6000 }).catch(() => {}); - await page.evaluate(() => window.__stores.showSidebar('scene')); - await page.waitForSelector('#inspector-sticky-head', { state: 'visible', timeout: 10000 }); - await page.waitForTimeout(700); - m = await measure(page, 'bottom'); - h.check(m?.sticky === 'false' && m.headBottom <= 0, `${tag}: after a reload the choice holds (${m?.sticky}, bottom ${m?.headBottom})`); - await page.evaluate(() => window.__stores.panelHeaderPrefs.setAllStickyHeads(true)); - await page.waitForTimeout(300); - h.check((await page.evaluate(() => localStorage.getItem('panels:stickyHeads'))) === null, `${tag}: back to the default stores nothing`); - } else { - // put Properties back for the next device, through the header again (a tap) - await page.evaluate(() => window.__stores.showSidebar('properties')); - await page.waitForTimeout(900); - await pressLock(page, dev.touch); - m = await measure(page, 'bottom'); - h.check(m?.sticky === 'true' && m.headTop >= -1 && m.headTop <= 2, `${tag}: a second tap locks it again (top ${m?.headTop})`); + const front = tabs2[side].find((/** @type {string} */ k) => k !== 'inspector' && k !== 'scene') ?? null; + h.check(!!front, `E4: (premise) a second tab shares the Properties column (${JSON.stringify(tabs2)})`); + if (front) { + await page.evaluate((k) => window.__stores.sidebars.activateSidebarTab(k), front); + await page.waitForTimeout(400); + await page.evaluate(() => window.__stores.commandsHandler.sceneCommand('/create sphere')); + await page.waitForTimeout(800); + const s2 = await page.evaluate(() => { + const w = /** @type {any} */ (window).__stores; + let g; + w.objectsGroup.subscribe((/** @type {any} */ v) => (g = v))(); + return g.children[g.children.length - 1].uuid; + }); + for (const u of [s2, b2]) { + await page.evaluate((x) => window.__stores.objectActions.selectObject(x), u); + await page.waitForTimeout(400); + } + let shown = await page.evaluate((s) => { + let v; + window.__stores.sidebars.sidebarShown.subscribe((x) => (v = x))(); + return v[s]; + }, side); + h.check(shown === front, `E4: plain selections leave "${front}" in front (${shown})`); + // the context menu's "Properties" (selectObject(.., true)) brings Properties forward + await page.evaluate((x) => window.__stores.objectActions.selectObject(x, true), s2); + await page.waitForTimeout(500); + shown = await page.evaluate((s) => { + let v; + window.__stores.sidebars.sidebarShown.subscribe((x) => (v = x))(); + return v[s]; + }, side); + h.check(shown === 'inspector', `E4: an explicit "Properties" brings the Properties tab forward (${shown})`); + } } h.check(h.pageErrors(P).length === 0, `${tag}: no page errors (${JSON.stringify(h.pageErrors(P)).slice(0, 200)})`); await P.ctx.close(); diff --git a/tests/e2e/ui-fixes-15lmno.test.cjs b/tests/e2e/ui-fixes-15lmno.test.cjs index 371c60ed..c4df2a06 100644 --- a/tests/e2e/ui-fixes-15lmno.test.cjs +++ b/tests/e2e/ui-fixes-15lmno.test.cjs @@ -96,8 +96,8 @@ h.run(async () => { }); await A.page.waitForTimeout(400); h.check( - await A.page.evaluate(() => !!document.querySelector('#inspector-pin')), - 'the header renders a pin toggle' + await A.page.evaluate(() => !document.querySelector('#inspector-pin')), + 'uir-polish E1: no pin toggle any more (the pinned posture stays a runtime switch)' ); // ---------- L3: no duplicate hex textboxes under the pickers ---------- diff --git a/tests/unit/minimalScroll.test.js b/tests/unit/minimalScroll.test.js index b8d7b188..9d8d9b13 100644 --- a/tests/unit/minimalScroll.test.js +++ b/tests/unit/minimalScroll.test.js @@ -24,4 +24,14 @@ describe('thumbGeometry', () => { const g = thumbGeometry({ scrollTop: 900, scrollHeight: 800, clientHeight: 400 }); expect(g.top + g.height).toBeCloseTo(900 + 400 - 2, 5); }); + it('runs below a sticky header (uir-polish E6)', () => { + const top = thumbGeometry({ scrollTop: 0, scrollHeight: 800, clientHeight: 400 }, 24, 50); + expect(top.top).toBe(52); + const end = thumbGeometry({ scrollTop: 400, scrollHeight: 800, clientHeight: 400 }, 24, 50); + expect(end.top + end.height).toBeCloseTo(400 + 400 - 2, 5); + // a header taller than the viewport still leaves a minimum thumb + const tiny = thumbGeometry({ scrollTop: 0, scrollHeight: 800, clientHeight: 100 }, 24, 500); + expect(tiny.height).toBeGreaterThanOrEqual(24); + expect(tiny.top + tiny.height).toBeLessThanOrEqual(100); + }); }); From 32b1142e04132d00a6ddcedb200d6a8c701eec3b Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Wed, 11 Nov 2026 01:34:38 +0200 Subject: [PATCH 05/13] uir-polish G: one Dock + pop-out pair in every floating header G1 docked Code has no title row or pop-out; floating Code pops out from its header. G2 every floating editor and hosted window (objects, stats, chat, assets, mesh generator, preview) carries an icon-only Dock button (HeadDockPop), plus pop-out where supported. G3 the word "Dock" is gone from headers. G4 hosted windows in the bottom dock show no close, Dock or pop-out of their own. Co-Authored-By: Claude Opus 5.5 --- src/components/Flow.svelte | 3 +- src/components/editors/AnimationWindow.svelte | 3 +- src/components/editors/AssetBrowser.svelte | 4 + src/components/editors/CodeWorkspace.svelte | 18 +- src/components/editors/Explorer.svelte | 3 +- .../editors/FilePreviewWindow.svelte | 5 +- src/components/editors/HudEditor.svelte | 3 +- src/components/editors/Profiler.svelte | 3 +- src/components/editors/ShaderEditor.svelte | 3 +- src/components/editors/TimelineEditor.svelte | 8 +- src/components/editors/UvEditor.svelte | 3 +- src/components/menu/Chat.svelte | 3 + src/components/menu/Controls.svelte | 5 + src/components/menu/MeshGenWindow.svelte | 5 +- src/components/menu/StatsOverlay.svelte | 4 + src/components/ui/HeadDockPop.svelte | 44 ++++ src/styles/windows.css | 7 + tests/e2e/code-files.test.cjs | 8 + tests/e2e/code-popout.test.cjs | 13 +- tests/e2e/flow-dock-tabs.test.cjs | 2 +- tests/e2e/panel-windows.test.cjs | 10 +- tests/e2e/profiler-ui.test.cjs | 2 +- tests/e2e/uir-polish-fg.test.cjs | 213 ++++++++++++++++++ tests/e2e/union-50.test.cjs | 8 + 24 files changed, 348 insertions(+), 32 deletions(-) create mode 100644 src/components/ui/HeadDockPop.svelte create mode 100644 tests/e2e/uir-polish-fg.test.cjs diff --git a/src/components/Flow.svelte b/src/components/Flow.svelte index 941ecfa1..de9bdcbd 100644 --- a/src/components/Flow.svelte +++ b/src/components/Flow.svelte @@ -15,6 +15,7 @@ import DockTabs from './DockTabs.svelte'; import WindowChrome from './ui/WindowChrome.svelte'; import Icon from './ui/Icon.svelte'; + import HeadDockPop from './ui/HeadDockPop.svelte'; // uir-polish G2/G3 import { dragWindow } from '$lib/dragWindow'; import { focusStack } from '$lib/windowFocus'; import { tabbable, resizeGroup, tabGroups } from '$lib/windowTabs'; @@ -254,7 +255,7 @@ > {#snippet actions()} - + setDocked(true)} dockId="flow-dock" /> {/snippet}
diff --git a/src/components/editors/AnimationWindow.svelte b/src/components/editors/AnimationWindow.svelte index 649b1a17..de189e22 100644 --- a/src/components/editors/AnimationWindow.svelte +++ b/src/components/editors/AnimationWindow.svelte @@ -37,6 +37,7 @@ import { openApplyClip } from '$lib/characterUi.js'; import ClipToolsPanel from './ClipToolsPanel.svelte'; // 57 M3/M8 import Icon from '../ui/Icon.svelte'; + import HeadDockPop from '../ui/HeadDockPop.svelte'; // uir-polish G2/G3 import PaneHandle from '../ui/PaneHandle.svelte'; // showhide-v8: the one pane-handle pattern import { readPanelOpen, writePanelOpen } from '$lib/ui/handheldPanels.js'; import { minimalScroll } from '$lib/ui/minimalScroll.js'; @@ -2848,7 +2849,7 @@ {/snippet} {#snippet actions()} - + setDocked(true)} /> {/snippet} {#if compact && target}
{@render paneSwitch()}
{/if} diff --git a/src/components/editors/AssetBrowser.svelte b/src/components/editors/AssetBrowser.svelte index 18837715..2ac7711c 100644 --- a/src/components/editors/AssetBrowser.svelte +++ b/src/components/editors/AssetBrowser.svelte @@ -8,6 +8,8 @@ // every download lands through $lib/cc0/cc0Import — the Explorer's own import entry for files, // the environment picker's write for a sky, the material preset library for a material. import Icon from '../ui/Icon.svelte'; + import HeadDockPop from '../ui/HeadDockPop.svelte'; // uir-polish G2 + import { dockTab } from '$lib/dockMenu'; import Button from '../ui/Button.svelte'; import Segmented from '../ui/Segmented.svelte'; import Tabs from '../ui/Tabs.svelte'; @@ -390,8 +392,10 @@ aria-label="Refresh" onclick={refresh}>
{:else} @@ -874,12 +867,11 @@ @@ -888,7 +880,7 @@ {/snippet} {#snippet actions()} - + setDocked(true)} onpopout={popOut} popId="code-ws-popout" /> {/snippet}
{@render main()}
diff --git a/src/components/editors/Explorer.svelte b/src/components/editors/Explorer.svelte index 3827dc66..d1ff3abd 100644 --- a/src/components/editors/Explorer.svelte +++ b/src/components/editors/Explorer.svelte @@ -3,6 +3,7 @@ import { showChoice, showConfirm } from '$lib/confirmDialog.js'; import { safeHtml } from '$lib/sandbox/sanitize.js'; import Icon from '../ui/Icon.svelte'; + import HeadDockPop from '../ui/HeadDockPop.svelte'; // uir-polish G2/G3 import EmptyState from '../ui/EmptyState.svelte'; import { FILE_TEMPLATES } from '$lib/codeFilesCore.js'; import { isViewer } from '$lib/objectPermissions'; @@ -9004,7 +9005,7 @@
{/snippet} {#snippet actions()} - + setDocked(true)} dockId="explorer-dock" /> {/snippet}
diff --git a/src/components/editors/FilePreviewWindow.svelte b/src/components/editors/FilePreviewWindow.svelte index 51abd08b..a4d89a29 100644 --- a/src/components/editors/FilePreviewWindow.svelte +++ b/src/components/editors/FilePreviewWindow.svelte @@ -1,5 +1,7 @@