+
+
diff --git a/src/components/ContextMenu.svelte b/src/components/ContextMenu.svelte
index 5a352697..a7f0a54b 100644
--- a/src/components/ContextMenu.svelte
+++ b/src/components/ContextMenu.svelte
@@ -6,7 +6,17 @@
import { autofocusOk, typeToFocus } from '$lib/inputDevice';
// Generic context menu. items: [{ label, action?, disabled?, tooltip?, danger?,
- // icon?, hint?, checked?, children?: items[] } | { section } | { header }]
+ // icon?, hint?, checked?, keepOpen?, rowActions?, children?: items[] } |
+ // { section } | { header }]
+ //
+ // W1: `keepOpen: true` runs the action and LEAVES THE MENU UP — opt-in per item,
+ // so every other menu in the app closes on a click exactly as before. It exists for
+ // a menu that is a CHECKLIST rather than a command list (the toolbar's Customize
+ // pane): toggling one button's visibility used to dismiss the list you were working
+ // through. Outside click and Escape still close. The item array is a prop, so a
+ // consumer that wants the rows to show the state it just wrote must pass a REACTIVE
+ // array (Controls derives its Customize items from the layout record) — re-rendering
+ // in place keeps the menu's position and scroll, because the node is never replaced.
// Submenus (any depth) open on hover, marked with ▸. Flips up/left near screen edges.
//
// 15-Q: dense menus grew a TYPE-TO-FILTER row (flattened command-palette matches).
@@ -53,6 +63,7 @@
return;
}
item.action?.();
+ if (item.keepOpen) return;
dispatch('close');
}
diff --git a/src/components/ContextMenuItems.svelte b/src/components/ContextMenuItems.svelte
index d9496ea9..c41b0b64 100644
--- a/src/components/ContextMenuItems.svelte
+++ b/src/components/ContextMenuItems.svelte
@@ -15,9 +15,36 @@
// 16-P3 adds `checked: true` — the ACTIVE choice of a group (bold + accent),
// which replaced the old '● ' label prefix.
//
+ // W1 adds two more:
+ // keepOpen: true the row's action runs and the menu STAYS UP (owned by
+ // ContextMenu's `run`, documented there)
+ // rowActions: [{ icon, label, tooltip?, disabled?, run }]
+ // small trailing controls INSIDE the row — the toolbar's
+ // Customize list needs a reorder pair beside each button, and
+ // a row that both toggles and reorders cannot say that with a
+ // label. They are inline CONTROLS, not menu commands: the
+ // click never reaches the row (stopPropagation) and never
+ // closes the menu, so `keepOpen` does not apply to them.
+ // `label` is both the tooltip and the accessible name.
+ //
+ // W8b adds one:
+ // key: 'move' a STABLE identity for a row in a list that REORDERS itself.
+ // The each block below is keyed on it, so when a `keepOpen`
+ // action rewrites the array into a new order svelte MOVES each
+ // row's DOM node instead of rewriting the labels in place. That
+ // is what makes a per-row reorder control usable: the button
+ // you just pressed travels with its row, stays focused, and a
+ // second press walks the same item another slot. Without it the
+ // node stays put, the row under your finger becomes a DIFFERENT
+ // item, and pressing again undoes what you just did.
+ // Optional and namespaced against the index fallback, so every
+ // menu that passes no key is keyed by position — byte-identical
+ // to the unkeyed block this replaces.
+ //
// 16-P1: which submenu is open (`openPath`) and where the keyboard cursor sits
// (`navPath` + `highlight`) are owned by ContextMenu — ONE truth shared by mouse
// and keyboard. Hover-intent lives here: 120ms to open, 150ms to close.
+ import { tick } from 'svelte';
import Icon from './ui/Icon.svelte';
export let items: any[] = [];
export let onrun: (item: any) => void;
@@ -37,6 +64,26 @@
let openTimer: any = null;
let closeTimer: any = null;
+ /** W8b: run a row's inline control and KEEP THE KEYBOARD ON IT.
+ *
+ * A reorder control rewrites the list it lives in, and the keyed each block then
+ * RELOCATES this row's node to its new slot — which Chromium treats as a blur, so
+ * the control you just pressed silently stops being focused and a repeat press has
+ * nowhere to go. The node survives (that is what the key buys); only the focus does
+ * not. Re-focusing it by its own accessible NAME is what makes "press again to move
+ * it again" true for the keyboard as well as the mouse — the label names the BUTTON,
+ * so it follows the item wherever the move put it, and a control that has become
+ * disabled at an end of the list is simply not re-focused. */
+ async function runRowAction(act: any, host: HTMLElement) {
+ act.run?.();
+ await tick();
+ const root = host.closest('[role="menu"]') ?? document;
+ const again = root.querySelector(
+ `button.ctx-act[aria-label="${String(act.label).replace(/"/g, '\\"')}"]`
+ );
+ if (again && !again.disabled) again.focus({ preventScroll: true });
+ }
+
/** the child submenu open at THIS level (null = none) */
$: openChild = openPath.length > path.length ? openPath[path.length] : null;
/** is the keyboard cursor on this level? */
@@ -113,7 +160,7 @@
const disabledClass = 'cursor-default px-3 py-1.5 text-gray-400 dark:text-gray-500 whitespace-nowrap';
-{#each items as item}
+{#each items as item, rowAt (item?.key != null ? 'k:' + item.key : 'i:' + rowAt)}
{#if item.header}
@@ -187,6 +234,7 @@
data-ctx-active={atNav && indexOf.get(item) === highlight}
role="menuitem"
title={item.tooltip ?? ''}
+ aria-label={item.rowActions ? item.label : undefined}
on:mouseenter={() => hoverRow(item, indexOf.get(item) ?? -1)}
on:mouseleave={leaveRow}
on:click={() => onrun(item)}
@@ -199,6 +247,22 @@
{#if item.hint}
{item.hint}
{/if}
+ {#if item.rowActions}
+
+ {#each item.rowActions as act}
+
+ {/each}
+
+ {/if}
{/if}
@@ -269,6 +333,33 @@
display: inline-flex;
color: rgb(148 163 184);
}
+ /* W1: inline controls at the end of a row (the Customize list's reorder pair).
+ Muted until hovered so the row still reads as its label first. */
+ .ctx-actions {
+ flex: 0 0 auto;
+ display: inline-flex;
+ align-items: center;
+ gap: 2px;
+ margin-left: 10px;
+ }
+ .ctx-act {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 18px;
+ height: 18px;
+ border-radius: 4px;
+ color: rgb(148 163 184);
+ background: transparent;
+ }
+ .ctx-act:hover:not(:disabled) {
+ color: inherit;
+ background: rgb(148 163 184 / 0.25);
+ }
+ .ctx-act:disabled {
+ opacity: 0.35;
+ cursor: default;
+ }
.ctx-hint {
flex: 0 0 auto;
margin-left: 12px;
diff --git a/src/components/DockTabs.svelte b/src/components/DockTabs.svelte
index aa110165..3bdaf4bf 100644
--- a/src/components/DockTabs.svelte
+++ b/src/components/DockTabs.svelte
@@ -1,43 +1,235 @@
-
{#if addMenu}
(addMenu = null)} />
{/if}
+{#if tabMenu}
+ (tabMenu = null)} />
+{/if}
diff --git a/src/components/Flow.svelte b/src/components/Flow.svelte
index 7577ab89..01a0305f 100644
--- a/src/components/Flow.svelte
+++ b/src/components/Flow.svelte
@@ -2,9 +2,9 @@
// Flow host: the Node editor. DOCKED mode is a Flow-family TAB in the shared bottom
// dock (DockTabs strip; shares dockHeight with Flow Code + Animation; only the
// visible tab renders). UNDOCKED mode is a floating, resizable window. Both persist.
- import { flowGraphClose, flowCodeClose, animationClose, uvEditorClose, mobileUndockAllowed, shaderEditorClose, hudEditorClose } from '../stores/appStore.js';
+ import { flowGraphClose, mobileUndockAllowed } from '../stores/appStore.js';
import { get } from 'svelte/store';
- import { onMount } from 'svelte';
+ import { onMount, untrack } from 'svelte';
import { SvelteFlowProvider } from '@xyflow/svelte';
import ContextMenu from './ContextMenu.svelte';
import Nodes from './editors/Nodes.svelte';
@@ -16,7 +16,9 @@
import { tabbable, resizeGroup, tabGroups } from '$lib/windowTabs';
import { clampWinSize, clampResize, anchorOf } from '$lib/windowSize';
import { dockable } from '$lib/docking';
- import { setDockOccupant, dockHeight, visibleDockKey, activateDock } from '$lib/bottomDock';
+ import { setDockOccupant, dockHeight, visibleDockKey, dockMinimized, activateDock, dockModeArm } from '$lib/bottomDock';
+ import { bottomDockable } from '$lib/bottomDockDrop';
+ import { dockAddItems } from '$lib/dockMenu';
import { fly } from 'svelte/transition';
const clampH = (h: number) => Math.min(Math.max(h || 320, 200), Math.round(window.innerHeight * 0.8));
@@ -66,6 +68,20 @@
if (v) activateDock('flow'); // re-docking makes it the visible tab
}
+ // W5: consume the shared dock-mode arm — the tab strip's right-click menu asks
+ // through it (the Explorer has had this exact effect since 4b). `docked` is read
+ // from localStorage ONCE at mount, so writing that flag from outside is inert;
+ // `setDocked` owns the mode and is what has to run. Cleared as it is acted on.
+ $effect(() => {
+ const arm = $dockModeArm;
+ if (!arm || arm.key !== 'flow') return;
+ dockModeArm.set(null);
+ untrack(() => {
+ if (arm.docked !== docked) setDocked(arm.docked);
+ flowGraphClose.set(false);
+ });
+ });
+
// tab-grouped windows share one size: show the group's rect so a resize on any
// member updates every tab, not just the active one.
const myGroup = $derived($tabGroups.find((g: any) => g.members.includes('flow')) ?? null);
@@ -73,15 +89,10 @@
const effH = $derived(myGroup ? myGroup.rect.height : winH);
// Flow "+" (floating window only — docked mode uses the DockTabs strip): open
- // another Flow-family view. They start docked, so they appear as dock tabs.
+ // another dock view. They start docked, so they appear as dock tabs. Same list the
+ // strip's "+" renders ($lib/dockMenu) — they used to be two copies that drifted.
let addMenu: { x: number; y: number } | null = $state(null);
- const addItems = [
- { label: '+ Flow Code', tooltip: 'Edit the graph as JSON', action: () => { flowCodeClose.set(false); activateDock('flowcode'); } },
- { label: '+ Animation', tooltip: 'Animate the selected object', action: () => { animationClose.set(false); activateDock('animation'); } },
- { label: '+ UV editor', tooltip: 'Edit the selected mesh’s UV map and textures', action: () => { uvEditorClose.set(false); activateDock('uv'); } },
- { label: '+ Shader editor', tooltip: 'Drive this material from a node graph', action: () => { shaderEditorClose.set(false); activateDock('shader'); } },
- { label: '+ HUD editor', tooltip: 'Lay out the on-screen HUD its nodes drive', action: () => { hudEditorClose.set(false); activateDock('hud'); } }
- ];
+ const addItems = dockAddItems();
function openAddMenu(e: MouseEvent) {
const r = (e.currentTarget as HTMLElement).getBoundingClientRect();
addMenu = { x: r.left, y: r.bottom + 4 };
@@ -93,7 +104,9 @@
setDockOccupant('flow', !$flowGraphClose && docked, $dockHeight);
return () => setDockOccupant('flow', false);
});
- const dockVisible = $derived($visibleDockKey === 'flow');
+ // W2: a MINIMIZED dock renders nothing while every tab stays open (the occupant
+ // report above is untouched, so the strip comes back with its tabs intact)
+ const dockVisible = $derived($visibleDockKey === 'flow' && !$dockMinimized);
// --- docked: top-edge resize (shared dock height, persisted by the store) ---
let resizing = $state(false);
@@ -159,7 +172,7 @@
>
setDocked(false)}>⧉
-
+
@@ -184,8 +197,9 @@
id="flow-window"
class="ui-panel fixed flex flex-col overflow-hidden"
use:dragWindow={{ key: 'flowWin', defaultRect: { left: 120, top: 90 } }}
- use:focusStack
+ use:focusStack={'flow'}
use:tabbable={{ key: 'flow', title: 'Node editor', openStore: flowGraphClose, isOpen: (v) => !v, close: () => flowGraphClose.set(true) }}
+ use:bottomDockable={{ key: 'flow' }}
use:dockable={{ key: 'flow' }}
style="z-index: var(--z-window)"
style:width="{effW}px"
diff --git a/src/components/Scene.svelte b/src/components/Scene.svelte
index 3387d1ef..d1e5e6b9 100644
--- a/src/components/Scene.svelte
+++ b/src/components/Scene.svelte
@@ -22,6 +22,7 @@
} from '$lib/objectActions';
// 85: what a double-click does (a LOCAL pref; store-only leaf, no cycle)
import { doubleClickAction } from '$lib/selectionPrefs';
+ import { noteXRSessionStarted } from '$lib/playMode';
import { recordTransform } from '$lib/history';
import { suspendAnimation, resumeAnimation, pumpFlowTick } from '$lib/flowRuntime';
import { holdBody, releaseBody } from '$lib/physics';
@@ -533,6 +534,14 @@
const onSourcesChange = () => onInputSourcesChange();
const onSessionStart = () => {
renderer.xr.getSession()?.addEventListener('inputsourceschange', onSourcesChange);
+ // W3: the AUTHORITATIVE half of the VR flag. playMode sets it optimistically
+ // one line before the click (the VR configuration has to be armed before the
+ // first XR frame) and arms a watchdog to undo that GUESS if no session ever
+ // starts. This is the event that proves one did, so it cancels the watchdog
+ // and asserts the flag. Asserting rather than trusting the guess is what lets
+ // a session accepted AFTER the watchdog already fired still come up in VR.
+ noteXRSessionStarted();
+ $isVRMode = true;
};
renderer.xr.addEventListener('sessionstart', onSessionStart);
diff --git a/src/components/editors/AnimationWindow.svelte b/src/components/editors/AnimationWindow.svelte
index e119e789..ca65c7e1 100644
--- a/src/components/editors/AnimationWindow.svelte
+++ b/src/components/editors/AnimationWindow.svelte
@@ -40,12 +40,16 @@
import ContextMenu from '../ContextMenu.svelte';
import DockTabs from '../DockTabs.svelte';
import { createGesture } from '$lib/modalGrab';
+ // W5: the BINDING for this pane's grab key lives in the shortcut registry (an
+ // `external` row), so Settings can move it; the key itself is answered here.
+ import { comboOf, bindingOf } from '$lib/shortcuts';
import { dragWindow } from '$lib/dragWindow';
import DragRow from '../ui/DragRow.svelte';
import { focusStack } from '$lib/windowFocus';
import { tabbable, resizeGroup, tabGroups } from '$lib/windowTabs';
import { clampWinSize, clampResize, anchorOf } from '$lib/windowSize';
- import { setDockOccupant, dockHeight, visibleDockKey, activateDock } from '$lib/bottomDock';
+ import { setDockOccupant, dockHeight, visibleDockKey, dockMinimized, activateDock, dockModeArm } from '$lib/bottomDock';
+ import { bottomDockable } from '$lib/bottomDockDrop';
// live-follow the primary selection (keeps a truthy [] before the first select)
const target = $derived($selectedObject && $selectedObject.uuid ? $selectedObject : null);
@@ -227,6 +231,20 @@
if (v) activateDock('animation');
}
+ // W5: consume the shared dock-mode arm — the tab strip's right-click menu asks
+ // through it (the Explorer has had this exact effect since 4b). `docked` is read
+ // from localStorage ONCE at mount, so writing that flag from outside is inert;
+ // `setDocked` owns the mode and is what has to run. Cleared as it is acted on.
+ $effect(() => {
+ const arm = $dockModeArm;
+ if (!arm || arm.key !== 'animation') return;
+ dockModeArm.set(null);
+ untrack(() => {
+ if (arm.docked !== docked) setDocked(arm.docked);
+ animationClose.set(false);
+ });
+ });
+
// tab-grouped windows share one size: show the group's rect so a resize on any
// member updates every tab, not just the active one.
const myGroup = $derived($tabGroups.find((g) => g.members.includes('animation')) ?? null);
@@ -236,7 +254,9 @@
setDockOccupant('animation', !$animationClose && docked, $dockHeight);
return () => setDockOccupant('animation', false);
});
- const dockVisible = $derived($visibleDockKey === 'animation');
+ // W2: a MINIMIZED dock renders nothing while every tab stays open (the occupant
+ // report above is untouched, so the strip comes back with its tabs intact)
+ const dockVisible = $derived($visibleDockKey === 'animation' && !$dockMinimized);
// Switching objects LEAVES the previous one where it was: its playhead, its
// pose and its clip all stay put (they live per uuid in `playback`), so coming
@@ -519,7 +539,10 @@
/** @type {any} */ (window).__animationDebug = {
selKeys: () => selKeys.map(([id, i]) => id + ':' + i),
marqueeMode: () => marqMode,
- markerCount: () => clipMarkers.length
+ markerCount: () => clipMarkers.length,
+ // W5: the armed transform is component state too, and the G row is the one
+ // thing about it a check has no store for
+ xform: () => xform
};
return () => delete /** @type {any} */ (window).__animationDebug;
});
@@ -1113,6 +1136,8 @@
* Ctrl+Space add the key at the playhead to the selection
* Esc drop the selection (or cancel a grab)
* 1 / 2 arm Move / Scale
+ * G arm Move (Blender's grab; the combo is the `animation.grab` row
+ * in the shortcut registry, so Settings can move it)
* Shift+arrows transform the selection: ←/→ in time, ↑/↓ in value
* Del remove the selection
* @param {HTMLElement} node
@@ -1131,6 +1156,17 @@
const mult = e.ctrlKey || e.metaKey ? 10 : e.shiftKey ? 100 : 1;
const arrow = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, 1], ArrowDown: [0, -1] }[e.key];
+ // W5: Blender's G arms Move. The same key is Move on the gizmo and Arm Move
+ // in the UV editor; all three coexist because this handler claims (and stops)
+ // the press while the plot holds focus, which is what `scope: 'animation'`
+ // records in the registry. The COMBO comes from the registry so the Settings
+ // row really moves the key; tested before the other commands so the binding
+ // is authoritative wherever the user puts it.
+ if (comboOf(e) === bindingOf('animation.grab')) {
+ claim(e);
+ xform = 'move';
+ return;
+ }
if (e.key === 'Delete' || e.key === 'Backspace') {
if (!selKeys.length) return;
claim(e);
@@ -2432,7 +2468,7 @@
style="z-index: var(--z-bottom); height: {$dockHeight}px; border-top: 1px solid rgb(55 65 81 / 0.6)"
>
!v, close: () => animationClose.set(true) }}
+ use:bottomDockable={{ key: 'animation' }}
style="z-index: var(--z-window); max-width: 96vw; max-height: 88vh"
style:width="{effW}px"
style:height="{effH}px"
diff --git a/src/components/editors/Explorer.svelte b/src/components/editors/Explorer.svelte
index 0d0a7e01..74d31cdd 100644
--- a/src/components/editors/Explorer.svelte
+++ b/src/components/editors/Explorer.svelte
@@ -4,8 +4,9 @@
// Explorer (95, tree v2 in 106): dockable asset browser — real file-manager
// tree on the left (inline create/rename, expand/collapse, drag re-parent,
// cascade delete, resizable), thumbnail grid on the right (subfolder cards
- // + items), drag files in to import. Shares the bottom dock with the Flow
- // editor as notebook tabs (bottomDock.js); undocks into a floating window.
+ // + items), drag files in to import. It is an ordinary bottom-dock TAB beside
+ // the Flow-family views (bottomDock.js), sharing their strip and their height;
+ // undocks into a floating window.
import { get } from 'svelte/store';
import { tick, untrack } from 'svelte';
import { explorerClose, mobileUndockAllowed, explorerSceneSaveArm, peers } from '../../stores/appStore.js';
@@ -156,12 +157,14 @@
import { sceneAssets } from '$lib/sceneAssets';
import { setNodeData } from '$lib/nodesHandler';
import { findNodeAnyGraph } from '../../stores/flowStore';
- import { bottomDockActive, visibleDockKey, setDockOccupant } from '$lib/bottomDock';
+ import { bottomDockActive, visibleDockKey, dockMinimized, setDockOccupant, dockHeight, dockModeArm } from '$lib/bottomDock';
+ import { bottomDockable } from '$lib/bottomDockDrop';
import { dragWindow } from '$lib/dragWindow';
import { focusStack } from '$lib/windowFocus';
import { tabbable, resizeGroup, tabGroups } from '$lib/windowTabs';
import { dockable } from '$lib/docking';
import ContextMenu from '../ContextMenu.svelte';
+ import DockTabs from '../DockTabs.svelte';
import WindowShell from '../shared/WindowShell.svelte';
import { clampWinSize, clampResize, anchorOf } from '$lib/windowSize';
import { fly } from 'svelte/transition';
@@ -173,7 +176,6 @@
const WIN_MIN = { minW: 420, minH: 280 };
const WIN_DEFAULT = { w: 720, h: 440 };
- let height = $state(300);
let inlineStats: any = $state(null); // N4: poly stats for the Properties inline preview
let docked = $state(true);
let winW = $state(720);
@@ -187,7 +189,16 @@
let shell = $state(null);
let selected = $state(null);
if (typeof localStorage !== 'undefined') {
- height = clampH(parseInt(localStorage.getItem('explorerHeight') ?? '300'));
+ // one-shot migration: the Explorer used to keep a docked height of its own
+ // ('explorerHeight'). It is a dock TAB now, so the dock's shared height owns
+ // it — adopt the old value once, then drop the key.
+ try {
+ const legacyH = localStorage.getItem('explorerHeight');
+ if (legacyH) {
+ dockHeight.set(clampH(parseInt(legacyH) || 300));
+ localStorage.removeItem('explorerHeight');
+ }
+ } catch {}
docked = localStorage.getItem('explorerDocked') !== 'false';
// 18-B: a size saved on a bigger screen must not come back oversized —
// that is the state whose resize grip sits off-screen. Fitted BEFORE the
@@ -219,15 +230,35 @@
if (v) bottomDockActive.set('explorer'); // re-docking makes it the visible panel
}
- // The Explorer is the dock's separate (exclusive) panel — it reports docked+open
- // (+height for the --bottom-inset) and is visible only when it owns the dock. It is
- // mutually exclusive with the Flow-family tabs (activating a Flow tab closes it), so
- // it shows NO tab strip of its own.
+ // 4b: CONSUME the dock arm. The Controls toolbar's Explorer menu offers "Open as
+ // dock tab" / "Open as floating window", and `docked` above is read from
+ // localStorage exactly ONCE, at mount — so the toolbar writing that flag would be
+ // inert at a live panel and the row would read as a dead button. It asks through
+ // the store instead and `setDocked` (which owns the flag, this branch and the dock
+ // occupancy together) is what acts. Same write-once shape as `explorerSceneSaveArm`.
+ // W5: the seam is GENERAL now (`dockModeArm`, keyed by dock key) so the tab strip's
+ // own context menu can undock any tab; the Explorer-only `explorerDockArm` it used
+ // to own is gone, and the Controls menu asks through this one.
+ $effect(() => {
+ const arm = $dockModeArm;
+ if (!arm || arm.key !== 'explorer') return;
+ dockModeArm.set(null);
+ untrack(() => {
+ if (arm.docked !== docked) setDocked(arm.docked);
+ explorerClose.set(false); // the rows say "Open as …", so open it
+ });
+ });
+
+ // A dock tab like any other: report docked+open (+ the SHARED dock height, which
+ // feeds --bottom-inset) so the strip lists it, and render only while it is the
+ // visible tab. Being covered by another tab closes nothing — this stays open.
$effect(() => {
- setDockOccupant('explorer', !$explorerClose && docked, height);
+ setDockOccupant('explorer', !$explorerClose && docked, $dockHeight);
return () => setDockOccupant('explorer', false);
});
- const dockVisible = $derived($visibleDockKey === 'explorer');
+ // W2: a MINIMIZED dock renders nothing while every tab stays open (the occupant
+ // report above is untouched, so the strip comes back with its tabs intact)
+ const dockVisible = $derived($visibleDockKey === 'explorer' && !$dockMinimized);
// tab-grouped windows share one size: show the group's rect so a resize on any
// member updates every tab, not just the active one.
@@ -235,7 +266,7 @@
const effW = $derived(myGroup ? myGroup.rect.width : winW);
const effH = $derived(myGroup ? myGroup.rect.height : winH);
- // --- docked: top-edge resize (Flow pattern) ---
+ // --- docked: top-edge resize (shared dock height, persisted by the store) ---
let resizing = $state(false);
function startResize(e: any) {
resizing = true;
@@ -244,13 +275,12 @@
}
function doResize(e: any) {
if (!resizing) return;
- height = clampH(height - e.movementY);
+ dockHeight.update((h) => clampH(h - e.movementY));
}
function endResize(e: any) {
if (!resizing) return;
resizing = false;
e.currentTarget.releasePointerCapture?.(e.pointerId);
- localStorage.setItem('explorerHeight', String(height));
}
// --- undocked: corner resize ---
@@ -4543,7 +4573,7 @@
id="explorer-list"
transition:fly={{ y: 300, duration: 200 }}
class="fixed inset-x-0 bottom-0 bg-white p-2 dark:bg-gray-800 {dockVisible ? '' : 'hidden'}"
- style="z-index: var(--z-bottom); height: {height}px; border-top: 1px solid rgb(55 65 81 / 0.6)"
+ style="z-index: var(--z-bottom); height: {$dockHeight}px; border-top: 1px solid rgb(55 65 81 / 0.6)"
ondragover={(e) => {
if (canAccept(e)) return;
e.preventDefault();
@@ -4554,16 +4584,21 @@
role="region"
>
+
{@render content()}
diff --git a/src/components/editors/FlowCode.svelte b/src/components/editors/FlowCode.svelte
index 7b7efed2..8d34366f 100644
--- a/src/components/editors/FlowCode.svelte
+++ b/src/components/editors/FlowCode.svelte
@@ -3,6 +3,7 @@
// tab in the bottom dock (Apply + Reload buttons in its toolbar); UNDOCKED mode is a
// floating, resizable window. Apply parses the text and REPLACES the graph locally +
// broadcasts so peers converge.
+ import { untrack } from 'svelte';
import { get } from 'svelte/store';
import CodeEditor from './CodeEditor.svelte';
import { flowNodes, flowEdges } from '../../stores/flowStore';
@@ -12,7 +13,8 @@
import { dragWindow } from '$lib/dragWindow';
import { focusStack } from '$lib/windowFocus';
import { tabbable, resizeGroup, tabGroups } from '$lib/windowTabs';
- import { setDockOccupant, dockHeight, visibleDockKey, activateDock } from '$lib/bottomDock';
+ import { setDockOccupant, dockHeight, visibleDockKey, dockMinimized, activateDock, dockModeArm } from '$lib/bottomDock';
+ import { bottomDockable } from '$lib/bottomDockDrop';
let text = $state('');
let error = $state('');
@@ -30,9 +32,23 @@
if (v) activateDock('flowcode');
}
+ // W5: consume the shared dock-mode arm — the tab strip's right-click menu asks
+ // through it (the Explorer has had this exact effect since 4b). `docked` is read
+ // from localStorage ONCE at mount, so writing that flag from outside is inert;
+ // `setDocked` owns the mode and is what has to run. Cleared as it is acted on.
+ $effect(() => {
+ const arm = $dockModeArm;
+ if (!arm || arm.key !== 'flowcode') return;
+ dockModeArm.set(null);
+ untrack(() => {
+ if (arm.docked !== docked) setDocked(arm.docked);
+ flowCodeClose.set(false);
+ });
+ });
+
// when tab-grouped, ALL members share one size — display the group's rect so a
// resize on any member shows on every tab (not just the active one).
- const myGroup = $derived($tabGroups.find((g) => g.members.includes('flowCode')) ?? null);
+ const myGroup = $derived($tabGroups.find((g) => g.members.includes('flowcode')) ?? null);
const effW = $derived(myGroup ? myGroup.rect.width : winW);
const effH = $derived(myGroup ? myGroup.rect.height : winH);
@@ -55,7 +71,9 @@
setDockOccupant('flowcode', !$flowCodeClose && docked, $dockHeight);
return () => setDockOccupant('flowcode', false);
});
- const dockVisible = $derived($visibleDockKey === 'flowcode');
+ // W2: a MINIMIZED dock renders nothing while every tab stays open (the occupant
+ // report above is untouched, so the strip comes back with its tabs intact)
+ const dockVisible = $derived($visibleDockKey === 'flowcode' && !$dockMinimized);
function apply() {
let parsed;
@@ -104,7 +122,7 @@
const baseH = myGroup ? myGroup.rect.height : winH;
winW = Math.min(Math.max(320, baseW + e.movementX), window.innerWidth - 8);
winH = Math.min(Math.max(240, baseH + e.movementY), window.innerHeight);
- resizeGroup('flowCode', winW, winH); // if grouped, resize the whole group (no-op otherwise)
+ resizeGroup('flowcode', winW, winH); // if grouped, resize the whole group (no-op otherwise)
}
function endWinResize(/** @type {any} */ e) {
if (!winResizing) return;
@@ -145,7 +163,7 @@
style="z-index: var(--z-bottom); height: {$dockHeight}px; border-top: 1px solid rgb(55 65 81 / 0.6)"
>
{:else}
+
!v, close: () => flowCodeClose.set(true) }}
+ use:focusStack={'flowcode'}
+ use:tabbable={{ key: 'flowcode', title: 'Flow Code', openStore: flowCodeClose, isOpen: (v) => !v, close: () => flowCodeClose.set(true) }}
+ use:bottomDockable={{ key: 'flowcode' }}
style="z-index: var(--z-window); max-width: 96vw; max-height: 85vh"
style:width="{effW}px"
style:height="{effH}px"
diff --git a/src/components/editors/HudEditor.svelte b/src/components/editors/HudEditor.svelte
index 94d01bd5..86de22fd 100644
--- a/src/components/editors/HudEditor.svelte
+++ b/src/components/editors/HudEditor.svelte
@@ -66,7 +66,8 @@
import { focusStack } from '$lib/windowFocus';
import { tabbable, resizeGroup, tabGroups } from '$lib/windowTabs';
import { clampWinSize, clampResize, anchorOf } from '$lib/windowSize';
- import { setDockOccupant, dockHeight, visibleDockKey, activateDock } from '$lib/bottomDock';
+ import { setDockOccupant, dockHeight, visibleDockKey, dockMinimized, activateDock, dockModeArm } from '$lib/bottomDock';
+ import { bottomDockable } from '$lib/bottomDockDrop';
// 21-D5: WHICH document is being authored. `hudDocs` was already keyed
// `'scene' | objectUuid`, so "attach this HUD to a camera" is simply authoring the
@@ -129,6 +130,20 @@
localStorage.setItem('hudDocked', String(v));
if (v) activateDock('hud');
}
+
+ // W5: consume the shared dock-mode arm — the tab strip's right-click menu asks
+ // through it (the Explorer has had this exact effect since 4b). `docked` is read
+ // from localStorage ONCE at mount, so writing that flag from outside is inert;
+ // `setDocked` owns the mode and is what has to run. Cleared as it is acted on.
+ $effect(() => {
+ const arm = $dockModeArm;
+ if (!arm || arm.key !== 'hud') return;
+ dockModeArm.set(null);
+ untrack(() => {
+ if (arm.docked !== docked) setDocked(arm.docked);
+ hudEditorClose.set(false);
+ });
+ });
const myGroup = $derived($tabGroups.find((g) => g.members.includes('hud')) ?? null);
const effW = $derived(myGroup ? myGroup.rect.width : winW);
const effH = $derived(myGroup ? myGroup.rect.height : winH);
@@ -136,7 +151,9 @@
setDockOccupant('hud', !$hudEditorClose && docked, $dockHeight);
return () => setDockOccupant('hud', false);
});
- const dockVisible = $derived($visibleDockKey === 'hud');
+ // W2: a MINIMIZED dock renders nothing while every tab stays open (the occupant
+ // report above is untouched, so the strip comes back with its tabs intact)
+ const dockVisible = $derived($visibleDockKey === 'hud' && !$dockMinimized);
// While the editor is open the artboard shows the screen being EDITED, so the runtime
// layer is pointed at it too — otherwise you would lay out one screen and watch
@@ -1410,7 +1427,7 @@
>
!v, close: () => hudEditorClose.set(true) }}
+ use:bottomDockable={{ key: 'hud' }}
style="z-index: var(--z-window); max-width: 96vw; max-height: 88vh"
style:width="{effW}px"
style:height="{effH}px"
diff --git a/src/components/editors/ShaderEditor.svelte b/src/components/editors/ShaderEditor.svelte
index 753d1670..6b398bf6 100644
--- a/src/components/editors/ShaderEditor.svelte
+++ b/src/components/editors/ShaderEditor.svelte
@@ -13,6 +13,7 @@
// `shaderGraphs` is the Nodes.svelte shape — SvelteFlow 1.x binds PLAIN $state.raw
// arrays, not stores, so the two are mirrored both ways behind a re-entrancy guard.
import { untrack } from 'svelte';
+ import { get } from 'svelte/store';
import { Info, Settings, Trash2 } from '@lucide/svelte';
import {
SvelteFlow,
@@ -26,7 +27,7 @@
import '@xyflow/svelte/dist/style.css';
import '../../styles/flow.css';
import { selectedObjects, objectsGroup } from '../../stores/sceneStore';
- import { shaderEditorClose, showToast } from '../../stores/appStore.js';
+ import { shaderEditorClose, showToast, mobileUndockAllowed } from '../../stores/appStore.js';
import {
shaderGraphs,
shaderErrors,
@@ -38,7 +39,19 @@
} from '$lib/shaderGraph';
import { beginShaderGesture, endShaderGesture } from '$lib/shaderSync';
import { shaderNodeDefs, shaderNodeDef, SURFACE_NODE } from '$lib/shaderCatalog';
- import { setDockOccupant, dockHeight, visibleDockKey } from '$lib/bottomDock';
+ import {
+ setDockOccupant,
+ dockHeight,
+ visibleDockKey,
+ dockMinimized,
+ activateDock,
+ dockModeArm
+ } from '$lib/bottomDock';
+ import { bottomDockable } from '$lib/bottomDockDrop';
+ import { dragWindow } from '$lib/dragWindow';
+ import { focusStack } from '$lib/windowFocus';
+ import { tabbable, resizeGroup, tabGroups } from '$lib/windowTabs';
+ import { clampWinSize, clampResize, anchorOf } from '$lib/windowSize';
import DockTabs from '../DockTabs.svelte';
import ContextMenu from '../ContextMenu.svelte';
import ShaderNode from './nodes/ShaderNode.svelte';
@@ -348,41 +361,171 @@
});
}
- // dock presence
+ // ---- docked vs floating -----------------------------------------------------
+ // UvEditor's split verbatim. This editor was the ONE dock view with no floating
+ // mode at all — no `docked` flag, no window chrome, and an occupancy report that
+ // never asked the question — which two other modules then had to code around
+ // (`panelToggles`' `dockOnly` and `dockMenu` withholding Undock). Both of those
+ // exceptions are gone with this block; the seventh view now behaves like its six
+ // siblings and nothing has to know it is special.
+ let docked = $state(true);
+ const WIN_MIN = { minW: 380, minH: 280 };
+ const WIN_DEFAULT = { w: 720, h: 480 };
+ let winW = $state(720);
+ let winH = $state(480);
+ if (typeof localStorage !== 'undefined') {
+ docked = localStorage.getItem('shaderDocked') !== 'false';
+ // 18-B: a size saved on a bigger screen must not come back oversized. Fitted
+ // BEFORE the assignment so nothing reads $state during init.
+ const savedWin = clampWinSize(
+ parseInt(localStorage.getItem('shaderWinW') ?? '720') || 720,
+ parseInt(localStorage.getItem('shaderWinH') ?? '480') || 480,
+ WIN_MIN
+ );
+ winW = savedWin.w;
+ winH = savedWin.h;
+ }
+ // touch / limited-width: keep the editor docked (no room to float; undock hidden),
+ // unless the user opted into undocking on touch (Settings > Allow undocking)
+ if (
+ typeof window !== 'undefined' &&
+ window.matchMedia?.('(pointer: coarse)').matches &&
+ !get(mobileUndockAllowed)
+ )
+ docked = true;
+
+ function setDocked(/** @type {boolean} */ v) {
+ docked = v;
+ localStorage.setItem('shaderDocked', String(v));
+ if (v) activateDock('shader'); // re-docking makes it the visible tab
+ }
+
+ // W5: consume the shared dock-mode arm — the tab strip's right-click menu and its
+ // drag-a-tab-out both ask through it, and `docked` above is read from localStorage
+ // exactly ONCE at mount, so writing that flag from outside is inert; `setDocked`
+ // owns the mode and is what has to run. Cleared as it is acted on. THIS is the seam
+ // that makes "Undock" reach this view at all — the row was withheld until now
+ // precisely because there was nothing here to consume the ask.
+ $effect(() => {
+ const arm = $dockModeArm;
+ if (!arm || arm.key !== 'shader') return;
+ dockModeArm.set(null);
+ untrack(() => {
+ if (arm.docked !== docked) setDocked(arm.docked);
+ shaderEditorClose.set(false);
+ });
+ });
+
+ const myGroup = $derived($tabGroups.find((g) => g.members.includes('shader')) ?? null);
+ const effW = $derived(myGroup ? myGroup.rect.width : winW);
+ const effH = $derived(myGroup ? myGroup.rect.height : winH);
+
+ // dock presence — `docked` is part of the question now: a floating Shader editor is
+ // open and is NOT a dock tab, and reporting it as one leaves a phantom in the strip.
$effect(() => {
- setDockOccupant('shader', !$shaderEditorClose, $dockHeight);
+ setDockOccupant('shader', !$shaderEditorClose && docked, $dockHeight);
return () => setDockOccupant('shader', false);
});
- const dockVisible = $derived($visibleDockKey === 'shader');
+ // W2: a MINIMIZED dock renders nothing while every tab stays open (the occupant
+ // report above is untouched, so the strip comes back with its tabs intact)
+ const dockVisible = $derived($visibleDockKey === 'shader' && !$dockMinimized);
+
+ // W6: the top-edge dock resize, which this panel ALONE never had — its six siblings
+ // have carried it since the dock existed, so the shared height silently froze
+ // whenever the Shader editor was the tab on screen. Same handlers, same shared
+ // `dockHeight`, same clamp (FlowCode is the reference), so the seven behave
+ // identically. It now pairs with a corner grip in the floating mode, exactly as the
+ // siblings do — the top edge sizes the shared dock, the corner sizes this window.
+ const clampH = (/** @type {number} */ h) =>
+ Math.min(Math.max(h || 320, 200), Math.round(window.innerHeight * 0.8));
+ let resizing = $state(false);
+ let winResizing = $state(false);
+ function startResize(/** @type {any} */ e) {
+ resizing = true;
+ e.currentTarget.setPointerCapture(e.pointerId);
+ e.preventDefault();
+ }
+ function doResize(/** @type {any} */ e) {
+ if (resizing) dockHeight.update((h) => clampH(h - e.movementY));
+ }
+ function endResize(/** @type {any} */ e) {
+ if (resizing) {
+ resizing = false;
+ e.currentTarget.releasePointerCapture?.(e.pointerId);
+ }
+ }
+ function startWinResize(/** @type {any} */ e) {
+ winResizing = true;
+ e.currentTarget.setPointerCapture(e.pointerId);
+ e.preventDefault();
+ e.stopPropagation();
+ }
+ function doWinResize(/** @type {any} */ e) {
+ if (!winResizing) return;
+ const baseW = myGroup ? myGroup.rect.width : winW;
+ const baseH = myGroup ? myGroup.rect.height : winH;
+ const at = anchorOf(e.currentTarget.parentElement);
+ const fit = clampResize(baseW + e.movementX, baseH + e.movementY, at.left, at.top, WIN_MIN);
+ winW = fit.w;
+ winH = fit.h;
+ resizeGroup('shader', winW, winH);
+ }
+ function endWinResize(/** @type {any} */ e) {
+ if (!winResizing) return;
+ winResizing = false;
+ e.currentTarget.releasePointerCapture?.(e.pointerId);
+ saveWinSize();
+ }
+ function saveWinSize() {
+ localStorage.setItem('shaderWinW', String(winW));
+ localStorage.setItem('shaderWinH', String(winH));
+ }
+ function resetWinSize() {
+ const fit = clampWinSize(WIN_DEFAULT.w, WIN_DEFAULT.h, WIN_MIN);
+ winW = fit.w;
+ winH = fit.h;
+ resizeGroup('shader', winW, winH);
+ saveWinSize();
+ }
+ // 18-B: a window bigger than the screen can never be shrunk again, so re-fit on
+ // every viewport change rather than only at load
+ function fitToViewport() {
+ const fit = clampWinSize(winW, winH, WIN_MIN);
+ if (fit.w === winW && fit.h === winH) return;
+ winW = fit.w;
+ winH = fit.h;
+ resizeGroup('shader', winW, winH);
+ }
-{#if !$shaderEditorClose && dockVisible}
-
{/if}
@@ -664,9 +884,6 @@
flex: 1;
min-height: 0;
display: flex;
- /* docked panels shrink by the Controls HUD footprint on folded screens, so the
- canvas is never hidden behind it (the --dock-inset contract) */
- padding-bottom: var(--dock-inset, 0px);
}
.shader-side {
flex: 0 0 148px;
diff --git a/src/components/editors/UvEditor.svelte b/src/components/editors/UvEditor.svelte
index cf953ea9..ef9a986f 100644
--- a/src/components/editors/UvEditor.svelte
+++ b/src/components/editors/UvEditor.svelte
@@ -31,6 +31,9 @@
} from '$lib/uvEditor';
// the timeline's gesture engine: snapshot, re-apply the total, commit or revert once
import { createGesture } from '$lib/modalGrab';
+ // W5: the BINDING for this editor's grab key lives in the shortcut registry (an
+ // `external` row), so Settings can move it; the key itself is answered here.
+ import { comboOf, bindingOf } from '$lib/shortcuts';
import ContextMenu from '../ContextMenu.svelte';
// read-only: the Edit Mesh pick is what scopes the UV view (UV5)
import { faceEditSelectedTris, faceEditObject, triangleCount } from '$lib/faceEdit';
@@ -41,7 +44,8 @@
import { focusStack } from '$lib/windowFocus';
import { tabbable, resizeGroup, tabGroups } from '$lib/windowTabs';
import { clampWinSize, clampResize, anchorOf } from '$lib/windowSize';
- import { setDockOccupant, dockHeight, visibleDockKey, activateDock } from '$lib/bottomDock';
+ import { setDockOccupant, dockHeight, visibleDockKey, dockMinimized, activateDock, dockModeArm } from '$lib/bottomDock';
+ import { bottomDockable } from '$lib/bottomDockDrop';
/** the armed transform modes, in 1/2/3 order */
const MODES = /** @type {['move'|'rotate'|'scale', string, string][]} */ ([
@@ -140,6 +144,20 @@
if (v) activateDock('uv');
}
+ // W5: consume the shared dock-mode arm — the tab strip's right-click menu asks
+ // through it (the Explorer has had this exact effect since 4b). `docked` is read
+ // from localStorage ONCE at mount, so writing that flag from outside is inert;
+ // `setDocked` owns the mode and is what has to run. Cleared as it is acted on.
+ $effect(() => {
+ const arm = $dockModeArm;
+ if (!arm || arm.key !== 'uv') return;
+ dockModeArm.set(null);
+ untrack(() => {
+ if (arm.docked !== docked) setDocked(arm.docked);
+ uvEditorClose.set(false);
+ });
+ });
+
const myGroup = $derived($tabGroups.find((g) => g.members.includes('uv')) ?? null);
const effW = $derived(myGroup ? myGroup.rect.width : winW);
const effH = $derived(myGroup ? myGroup.rect.height : winH);
@@ -147,7 +165,9 @@
setDockOccupant('uv', !$uvEditorClose && docked, $dockHeight);
return () => setDockOccupant('uv', false);
});
- const dockVisible = $derived($visibleDockKey === 'uv');
+ // W2: a MINIMIZED dock renders nothing while every tab stays open (the occupant
+ // report above is untouched, so the strip comes back with its tabs intact)
+ const dockVisible = $derived($visibleDockKey === 'uv' && !$dockMinimized);
// Arming the brush opens the Tool panel, so its colour + size are reachable
// without hunting for the tab (WindowShell's showSecondary is exactly this
@@ -1019,6 +1039,18 @@
e.preventDefault();
e.stopPropagation();
};
+ // W5: Blender's G arms Move — the same key the gizmo and the timeline take, and
+ // all three can have it because this handler runs in CAPTURE phase on the wrap
+ // and stops the event, so the global registry never sees the press (which is
+ // exactly what `scope: 'uv'` records over there). The COMBO is asked of the
+ // registry rather than written as a letter here, so rebinding the row in
+ // Settings really moves the key. Tested first, so the binding is authoritative
+ // whatever the user moves it onto.
+ if (comboOf(e) === bindingOf('uv.grab')) {
+ claim();
+ armXform('move');
+ return;
+ }
if (!ctrl && !e.altKey && (e.key === '1' || e.key === '2' || e.key === '3')) {
claim();
armXform(e.key === '1' ? 'move' : e.key === '2' ? 'rotate' : 'scale');
@@ -1896,7 +1928,7 @@
>
!v, close: () => uvEditorClose.set(true) }}
+ use:bottomDockable={{ key: 'uv' }}
style="z-index: var(--z-window); max-width: 96vw; max-height: 88vh"
style:width="{effW}px"
style:height="{effH}px"
diff --git a/src/components/menu/CameraPipWindow.svelte b/src/components/menu/CameraPipWindow.svelte
index 23d983f4..f9b44f9b 100644
--- a/src/components/menu/CameraPipWindow.svelte
+++ b/src/components/menu/CameraPipWindow.svelte
@@ -19,6 +19,7 @@
autoPosition,
clampPosition
} from '$lib/cameraPip';
+ import { viewportInset } from '$lib/bottomDock';
const object = $derived($pipTarget ? ($objectsGroup?.getObjectByProperty('uuid', $pipTarget) ?? null) : null);
const size = $derived(object ? pipSize(object) : { w: 0, h: 0 });
@@ -36,10 +37,23 @@
panelWidth = !$inspectorClose && rect && rect.width < vw * 0.6 ? rect.width : 0;
});
+ /**
+ * W9: every position is measured against the CANVAS, not the window. The picture
+ * inside this frame is drawn by the renderer into `pipRect` read as canvas pixels
+ * (`glRect` counts y from the canvas BOTTOM), so a frame dragged into the dock band
+ * would produce a negative gl y and simply stop drawing — the clamp is what makes
+ * that unreachable, and the auto-park then clears the dock with no separate
+ * clearance argument.
+ *
+ * The canvas is anchored at the window's top-left (App.svelte insets only its
+ * bottom), so canvas coordinates and the `position: fixed` frame's own left/top are
+ * the same numbers; only the HEIGHT differs, which is exactly what the clamp needs.
+ */
+ const canvasH = $derived(Math.max(0, vh - $viewportInset));
const position = $derived(
$pipPosition
- ? clampPosition($pipPosition, size, { width: vw, height: vh })
- : autoPosition(size, { width: vw, height: vh }, panelWidth)
+ ? clampPosition($pipPosition, size, { width: vw, height: canvasH })
+ : autoPosition(size, { width: vw, height: canvasH }, panelWidth)
);
// publish the rect the renderer draws into (null while hidden)
@@ -83,7 +97,7 @@
clampPosition(
{ x: origin.x + (event.clientX - startX), y: origin.y + (event.clientY - startY) },
size,
- { width: vw, height: vh }
+ { width: vw, height: canvasH }
)
);
event.preventDefault();
diff --git a/src/components/menu/ColocationBadge.svelte b/src/components/menu/ColocationBadge.svelte
index f3e93b95..d23e8a0b 100644
--- a/src/components/menu/ColocationBadge.svelte
+++ b/src/components/menu/ColocationBadge.svelte
@@ -21,7 +21,8 @@
.colocation-badge {
position: fixed;
left: 12px;
- bottom: 12px;
+ /* rides above the bottom dock with the rest of the bottom chrome */
+ bottom: calc(var(--bottom-inset, 0px) + 12px);
z-index: var(--z-hud, 45);
display: flex;
align-items: center;
diff --git a/src/components/menu/Controls.svelte b/src/components/menu/Controls.svelte
index 81b6de4a..639e012c 100644
--- a/src/components/menu/Controls.svelte
+++ b/src/components/menu/Controls.svelte
@@ -1,8 +1,8 @@
- over the `visibleCells` roster, no longer flowbite's
+ `BottomNav` (whose inner grid column count must be a JIT literal, which a
+ customizable cell count cannot be). The class list is flowbite's own RESOLVED
+ output for `position="absolute" navType="application"` plus this component's
+ overrides, read off the rendered DOM — same border, surface, radius and 40px
+ height. `bottom-4` stays in it purely as the class the inline style overrides,
+ exactly as before. The cells are `w-10` literals now: the grid's content-sized
+ `fr` columns used to take their width from the spacer, and a flex row has to say
+ it out loud. -->
+