diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0ba80fe27..7b28633ce 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,10 +6,29 @@
rendering, so maintainer notes stay out of the user-facing window. No emoji from
1.64 on (owner rule: no emoji in the UI); the app strips them from older entries. -->
+## 1.66.0 — Menus and panels, polished
+
+### Sidebars and the dock
+- **Handles stay out of the way**: when the bottom dock leaves no room above it, the sidebar handles go behind it and come back when it hides; each handle keeps its place in proportion when the window is resized.
+- **Docked windows' side panes**: a closed pane's handle sits flush against the window edge, the main area keeps its scrollbar clear of it, and **Ctrl+B** / **Ctrl+Alt+B** open and close the panes of the docked window under the mouse or holding the focus. **Switch sidebar side** moved into the handles' right-click menu.
+- **Tabs keep their names**: sidebar tabs never shrink; when they do not fit, a **...** button lists them all and the mouse wheel scrolls the strip. A menu row that moves a window reads **Move here**.
+
+### Properties and Configure Scene
+- **No pin, no lock**: Properties follows your selection and keeps showing the last object after you click away (it closes when that object is deleted); its header always stays at the top.
+- **Configure Scene moves on its own**: drag its tab to the other sidebar and Properties stays where it is.
+
+### Windows
+- **Dock and Pop out in every floating header**: one icon-only **Dock** button (and **Pop out** where a window can pop out) in the Node editor, Timeline, Explorer, Objects, Chat, Free assets and the other dockable windows. Docked, a window closes from its dock tab; Code docked has no header row at all.
+
+### Main menu and reports
+- **Layouts** is the first row of **Window**; **What's new** is the version line at the bottom, with a dot when there is something new. The menu is solid to its bottom edge, and **Help** is a grid with a bug icon on **Report a bug**.
+- **One Report a bug view**: the moment report and the bug report are one form. It names the seconds it holds ("the last 16 s"), and with no account it sends through the anonymous channel.
+
## 1.65.1 — Safer loading
- **Objects and materials from other people and from files are checked before they load.** Anything that does not pass loads like a missing file, and the rest of the scene is unchanged.
+
## 1.65.0 — New menus and panels
### Chat and the AI assistant
diff --git a/perf/boot-budget.json b/perf/boot-budget.json
index c26744bea..a1205029b 100644
--- a/perf/boot-budget.json
+++ b/perf/boot-budget.json
@@ -4,12 +4,12 @@
"pages": {
"play": {
"html": "play.html",
- "maxJsGzBytes": 2097152,
+ "maxJsGzBytes": 2162688,
"maxFirstFrameMs": 20000
},
"editor": {
"html": "index.html",
- "maxJsGzBytes": 3407872,
+ "maxJsGzBytes": 3538944,
"maxFirstFrameMs": 32000
}
},
@@ -21,6 +21,13 @@
"editor": "3042.2 KB measured (3133 KB at v1.56.0) -> line 3328 KB, the looser line",
"note": "first lines: the play entry's boot (SP1) and the editor's, gzip -9 initial JS",
"firstFrame": "throttled (slow 4G + 4x CPU, median of 5 on the Deck): play 14641 ms, editor 23647 ms -> loose lines 20000 / 32000 ms (x1.35: a shared runner is noisy; the byte lines are the precise gate)"
+ },
+ {
+ "date": "2026-11-11",
+ "lane": "uir-polish (1.66.0)",
+ "play": "1.63.0 1987.1 KB -> 1.64.0 2066.7 (+79.6, over) -> 1.65.0 2073.0 (+6.3) -> 1.65.1 2082.9 (+9.9) -> 1.66 2083.6 (+0.7) -> line 2112 KB",
+ "editor": "1.63.0 3229.4 KB -> 1.64.0 3383.8 (+154.4, over) -> 1.65.0 3410.0 (+26.2) -> 1.65.1 3421.9 (+11.9) -> 1.66 3424.2 (+2.3) -> line 3456 KB",
+ "note": "main CI was red on this step from 1.64.0 to 1.65.1 (numbers from each tag's ci.yml run). The line catches up with what shipped and leaves ~30 KB of room; it is NOT a new allowance: the 1.64/1.65 growth (levels, the new menus and panels, safer loading) belongs in lazy chunks, a master-plan card, and the lines come back down when that lands"
}
],
"throttle": {
diff --git a/src/App.svelte b/src/App.svelte
index e33d5dc49..93c9f5931 100644
--- a/src/App.svelte
+++ b/src/App.svelte
@@ -55,7 +55,6 @@
// 26-A: the desktop Statistics window + the sampler that feeds it and the status-line
// budget meter. `renderer.info` had exactly one reader before this (the VR plate).
import StatsOverlay from './components/menu/StatsOverlay.svelte'
- import MomentReport from './components/menu/MomentReport.svelte'
import ProblemReport from './components/menu/ProblemReport.svelte' // 37 R20
import { startSceneMetrics, budgetSummary } from './lib/sceneBudget'
import { startPerfRecorder } from './lib/perf/recorder'
@@ -553,7 +552,6 @@ import { startMusicToolbox } from './lib/musicToolbox'
{/if}
-
{#if $assetBrowserOpen}
diff --git a/src/components/ContextMenuItems.svelte b/src/components/ContextMenuItems.svelte
index 6fb8f96df..7b8cb9168 100644
--- a/src/components/ContextMenuItems.svelte
+++ b/src/components/ContextMenuItems.svelte
@@ -12,6 +12,8 @@
// { section: 'Edit' } a quiet uppercase section label
// icon: 'copy' lucide kebab name (ui/Icon.svelte)
// hint: 'Ctrl+D' dimmed right-aligned shortcut hint
+ // hoverHint: 'Move here' (uir-polish F3) replaces `hint` while the row is hovered or
+ // keyboard-highlighted — what a press will DO, where `hint` says where it is
// 16-P3 adds `checked: true` — the ACTIVE choice of a group (bold + accent),
// which replaced the old '● ' label prefix.
//
@@ -285,7 +287,11 @@
{#if rowIcon(item)}{/if}
{/if}
{item.label}
- {#if item.hint}
+ {#if item.hint && item.hoverHint}
+
+ {item.hint}
+ {item.hoverHint}
+ {:else if item.hint}
{item.hint}
{/if}
{#if item.rowActions}
@@ -437,4 +443,16 @@
font-size: var(--fs-badge);
color: var(--text-faint);
}
+ .ctx-hint-hover {
+ display: none;
+ color: var(--accent-text);
+ }
+ .ctx-row:hover .ctx-hint-rest,
+ .ctx-row.ctx-active .ctx-hint-rest {
+ display: none;
+ }
+ .ctx-row:hover .ctx-hint-hover,
+ .ctx-row.ctx-active .ctx-hint-hover {
+ display: inline;
+ }
diff --git a/src/components/Flow.svelte b/src/components/Flow.svelte
index 941ecfa10..de9bdcbdf 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/Menu.svelte b/src/components/Menu.svelte
index 5534917d3..bdb60cf44 100644
--- a/src/components/Menu.svelte
+++ b/src/components/Menu.svelte
@@ -12,6 +12,7 @@
import BudgetChip from './menu/BudgetChip.svelte'; // 46 B1
import Inspector from './menu/Inspector.svelte';
import SidebarStrips from './menu/SidebarStrips.svelte'; // 42 I2/I3
+ import { inspectorDual } from '$lib/sidebars'; // uir-polish E5
import SidebarHandles from './menu/SidebarHandles.svelte'; // showhide-v8: the viewport sidebar handles
import MenuSidebar from './menu/Sidebar.svelte';
import TouchTools from './menu/TouchTools.svelte';
@@ -86,7 +87,7 @@
-{#if !$panelAway.inspector}{/if}
+{#if !$panelAway.inspector}{#if $inspectorDual}{/if}{/if}
diff --git a/src/components/editors/AnimationWindow.svelte b/src/components/editors/AnimationWindow.svelte
index 649b1a173..de189e22a 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 18837715f..2ac7711c5 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}>
+ dockTab('assets')} btnClass="rounded-sm p-1 hover:brightness-150" />
{/if}
diff --git a/src/components/shared/WindowShell.svelte b/src/components/shared/WindowShell.svelte
index ce448c8ff..a5ce4fb5a 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,66 @@
// 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)
+ // (not on a touch handheld: its scrollbars overlay the content, and a stacked layout has no handle on that edge)
+ const mainRoomRight = $derived(narrow || touchHandheld || !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 }
+ }
-