Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"preview": "vite preview",
"runtime:build": "node scripts/build-runtime.mjs",
"runtime:check": "node scripts/build-runtime.mjs --check",
"lint": "eslint . --max-warnings 64",
"lint": "eslint . --max-warnings 47",
"test:structure": "node scripts/check-structural-test-budget.mjs",
"pretest:lib": "npm run runtime:check && npm run test:structure",
"test": "npm run test:lib && npm run test:hooks",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ test('lightbox fills its actual overlay and dismisses from every backdrop edge',
assert.doesNotMatch(
lightboxCss,
/\.lightbox-(?:content|image)\s*\{[^}]*(?:100vw|100vh|100dvh)/s,
'root-level desktop density makes viewport units smaller than the already-correct fixed overlay',
'the fixed overlay already owns the correct bounds without a second viewport calculation',
)
})

Expand Down
4 changes: 1 addition & 3 deletions frontend/src/components/Drawer/Drawer.css
Original file line number Diff line number Diff line change
Expand Up @@ -435,9 +435,7 @@
touch-action: pan-y pinch-zoom;
}

/* App cards and drawer rows share one action-menu placement path. Pointer
coordinates are converted to root layout space so shell density never shifts
a menu away from the click. */
/* App cards and drawer rows share one action-menu placement path. */
.drawer__item-action-layer {
position: fixed;
inset: 0;
Expand Down
32 changes: 5 additions & 27 deletions frontend/src/components/Drawer/Drawer.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ import { WORKSPACE_SPLITS_ENABLED } from '../Shell/paneModel.js'
import {
DRAWER_HOLD_MS,
PRE_HOLD_MOVE_PX,
clientDeltaToLocal,
} from '../Shell/dragController.js'
import InstallSheet from './InstallSheet.jsx'
import AppsDirectory from './AppsDirectory.jsx'
Expand Down Expand Up @@ -902,27 +901,17 @@ export default function Drawer({
startWidth: clampDesktopSidebarWidth(width),
edgeDirection: handleCenter < panelCenter ? -1 : 1,
width: clampDesktopSidebarWidth(width),
clientRect: panelRect,
localSize: {
w: panel?.offsetWidth || panelRect?.width,
h: panel?.offsetHeight || panelRect?.height,
},
}
e.currentTarget.setPointerCapture(e.pointerId)
drawerRef.current?.classList.add('drawer--resizing')
}

function onResizePointerMove(e) {
if (resizeRef.current?.pointerId !== e.pointerId) return
const delta = clientDeltaToLocal(
{ x: e.clientX - resizeRef.current.startX, y: 0 },
resizeRef.current.clientRect,
resizeRef.current.localSize,
)
applyResizeWidth(drawerWidthFromPointerDelta({
startWidth: resizeRef.current.startWidth,
startX: 0,
currentX: delta.x,
startX: resizeRef.current.startX,
currentX: e.clientX,
edgeDirection: resizeRef.current.edgeDirection,
}))
}
Expand Down Expand Up @@ -1670,7 +1659,6 @@ const DrawerRow = memo(function DrawerRow({
let fromIndex = -1
let src = null
let pinnedSection = null
let layoutDeltaY = deltaY => deltaY
let last = { slotDelta: 0, finalKeys: null, changed: false, shifts: new Map() }

function releaseTouchClaim() {
Expand Down Expand Up @@ -1746,16 +1734,6 @@ const DrawerRow = memo(function DrawerRow({
function measureRows() {
const drawerEl = sourceBtn.closest('#navigation-drawer')
pinnedSection = sourceBtn.closest('.drawer__section')
const drawerClientRect = drawerEl?.getBoundingClientRect()
const drawerLocalSize = {
w: drawerEl?.offsetWidth || drawerClientRect?.width,
h: drawerEl?.offsetHeight || drawerClientRect?.height,
}
layoutDeltaY = deltaY => clientDeltaToLocal(
{ x: 0, y: deltaY },
drawerClientRect,
drawerLocalSize,
).y
const wrapOf = (btn) => btn.closest('.drawer__row') || btn
// Measure once only after reorder intent wins. Ordinary touch scrolls do
// no layout work at pointerdown, keeping the first scroll frame crisp.
Expand Down Expand Up @@ -1821,10 +1799,10 @@ const DrawerRow = memo(function DrawerRow({
}
moveEvent.preventDefault()
last = computePinnedDrag(rows, fromIndex, dy)
src.wrap.style.transform = `translateY(${layoutDeltaY(dy)}px)`
src.wrap.style.transform = `translateY(${dy}px)`
for (const r of rows) {
if (r === src) continue
r.wrap.style.transform = `translateY(${layoutDeltaY(last.shifts.get(r.key) || 0)}px)`
r.wrap.style.transform = `translateY(${last.shifts.get(r.key) || 0}px)`
}
}

Expand All @@ -1844,7 +1822,7 @@ const DrawerRow = memo(function DrawerRow({
}
src.wrap.addEventListener('transitionend', onEnd)
src.wrap.style.transition = 'transform 190ms cubic-bezier(0.2, 0, 0, 1)'
src.wrap.style.transform = `translateY(${layoutDeltaY(commit ? last.slotDelta : 0)}px)`
src.wrap.style.transform = `translateY(${commit ? last.slotDelta : 0}px)`
// Fallback if transitionend never fires (e.g. the offset was already 0).
setTimeout(done, 240)
}
Expand Down
4 changes: 0 additions & 4 deletions frontend/src/components/Drawer/DrawerItemActionMenu.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,10 +83,6 @@ export default function DrawerItemActionMenu({
: rootRect.top + rootRect.height / 2,
},
clientViewport: rootRect,
layoutViewport: {
width: root.offsetWidth || root.clientWidth || rootRect.width,
height: root.offsetHeight || root.clientHeight || rootRect.height,
},
menuSize: {
width: menuRect.offsetWidth,
height: menuRect.offsetHeight,
Expand Down
18 changes: 3 additions & 15 deletions frontend/src/components/Shell/Shell.css
Original file line number Diff line number Diff line change
Expand Up @@ -609,22 +609,10 @@
/* Desktop productivity shell: the workspace starts at the very top, while a
narrow left rail remains as the only persistent chrome. Expanding navigation
turns that same rail into the drawer header (brand + notification bell);
collapsing it leaves a stable Möbius mark plus compact quick actions. */
collapsing it leaves a stable Möbius mark plus compact quick actions. Density
comes from removing redundant chrome while the document remains at native
scale, so viewport, pointer, iframe, and layout geometry share one space. */
@media (min-width: 1024px) {
/* Owner-selected desktop density. Root author zoom relayouts before paint,
unlike transforming .shell, so fixed, dvh, and iframe surfaces still meet
the viewport edges. Pointer-owned shell interactions convert client pixels
through their measured layout boxes (dragController/contextMenuGeometry). */
:root {
zoom: 0.9;
}

/* Preserve the intended painted tab size under the desktop density scale.
Phone keeps the base 12.5px because it does not inherit desktop zoom. */
.shell__tab-open {
font-size: 13.5px;
}

.shell {
--desktop-rail-width: 58px;
--shell-bar-height: 0px;
Expand Down
9 changes: 3 additions & 6 deletions frontend/src/components/Shell/Shell.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ const APP_SETTINGS_SECTIONS = new Set([
'background-agents',
'models',
])
const EMPTY_LIST = Object.freeze([])
// Mode timing lives with the pure snapshot geometry in workspaceView.js; browser
// transition completion owns its lifetime, so Shell has no animation timers.
const SettingsView = lazy(() => import('../SettingsView/SettingsView.jsx'))
Expand Down Expand Up @@ -456,8 +457,8 @@ export default function Shell() {
const chatsQuery = chatQueries.list.useQuery({
reconcile: reconcileCreatedChats,
})
const apps = appsQuery.data ?? []
const chats = chatsQuery.data ?? []
const apps = appsQuery.data ?? EMPTY_LIST
const chats = chatsQuery.data ?? EMPTY_LIST
const appsStatus = apps.length > 0 || appsQuery.isSuccess
? 'success'
: (appsQuery.isError ? 'error' : 'loading')
Expand Down Expand Up @@ -1118,10 +1119,6 @@ export default function Shell() {
const position = placeContextMenu({
clientPoint: { x: tabMenu.x, y: tabMenu.y },
clientViewport: rootRect,
layoutViewport: {
width: root.offsetWidth || root.clientWidth || rootRect.width,
height: root.offsetHeight || root.clientHeight || rootRect.height,
},
menuSize: { width: menu.offsetWidth, height: menu.offsetHeight },
})
menu.style.setProperty('--workspace-menu-x', `${position.x}px`)
Expand Down
16 changes: 4 additions & 12 deletions frontend/src/components/Shell/WorkspaceChrome.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import * as tabModel from './tabModel.js'
import {
projectLayout, STRIP_H,
} from './paneModel.js'
import { clientPointToLocal } from './dragController.js'
import { ARROW_STEP_RATIO } from '../../lib/splitHelper.js'
import { PaneStrip } from './PaneStrip.jsx'
import { modeViewTransitionStyle } from './useModeViewTransition.js'
Expand Down Expand Up @@ -141,11 +140,7 @@ export default function WorkspaceChrome({
// the rects are written imperatively per frame and there is no interpolation to
// suppress. A divider drag also cannot overlap a mode beat — the chrome is inert
// during one.
const clientRect = contentEl.getBoundingClientRect()
const localSize = {
w: contentEl.clientWidth || clientRect.width,
h: contentEl.clientHeight || clientRect.height,
}
const contentBounds = contentEl.getBoundingClientRect()
const { dir, splitId } = divider

// Cache the elements to move; no React render fires during the drag, so the
Expand All @@ -169,12 +164,9 @@ export default function WorkspaceChrome({
let committed = divider.ratio

const paint = (clientX, clientY) => {
const point = clientPointToLocal(
{ x: clientX, y: clientY },
clientRect,
localSize,
)
const axis = dir === 'row' ? point.x : point.y
const axis = dir === 'row'
? clientX - contentBounds.left
: clientY - contentBounds.top
const raw = divider.span > 0 ? (axis - divider.origin) / divider.span : 0.5
const proj = projectLayout(workspace, mode, contentRect, { splitId, ratio: raw })
committed = proj.dividers.find(d => d.splitId === splitId)?.ratio ?? committed
Expand Down
42 changes: 3 additions & 39 deletions frontend/src/components/Shell/__tests__/dragController.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
CHIP_MOUSE_DX, CHIP_MOUSE_DY, CHIP_TOUCH_ABOVE,
EDGE_BAND_MIN, EDGE_BAND_FRACTION,
passedSlop, touchTabMoveIntent, releasedInPlace, chipOffset,
clientPointToLocal, clientDeltaToLocal,
clientPointToLocal,
crossedDrawerExit, edgeBands, edgePreviewRect, caretZone, edgeZone, centerZone,
rootEdgeZone, hitTest, zoneTarget, releaseZone, zoneEq, buildScene,
} from '../dragController.js'
Expand Down Expand Up @@ -34,54 +34,18 @@ function scene(panes, opts = {}) {
}
}

// ── Client-to-layout coordinate bridge ───────────────────────────────────────
// ── Client-to-local coordinate boundary ─────────────────────────────────────

test('clientPointToLocal reverses ancestor scaling before workspace hit-testing', () => {
// The desktop shell paints at 90%: a 1360×960 layout box is observed through
// getBoundingClientRect() as 1224×864, starting 288px into the visual viewport.
const clientRect = { left: 288, top: 0, width: 1224, height: 864 }
const localSize = { w: 1360, h: 960 }

assert.deepEqual(
clientPointToLocal({ x: 738, y: 27 }, clientRect, localSize),
{ x: 500, y: 30 },
)
assert.deepEqual(
clientPointToLocal({ x: clientRect.left + clientRect.width, y: clientRect.height }, clientRect, localSize),
{ x: localSize.w, y: localSize.h },
)
})

test('clientPointToLocal keeps ordinary unscaled coordinates unchanged', () => {
test('clientPointToLocal translates viewport coordinates into the content box', () => {
assert.deepEqual(
clientPointToLocal(
{ x: 235, y: 120 },
{ left: 200, top: 80, width: 500, height: 300 },
{ w: 500, h: 300 },
),
{ x: 35, y: 40 },
)
})

test('clientDeltaToLocal keeps zoomed resize and transform writes under the pointer', () => {
assert.deepEqual(
clientDeltaToLocal(
{ x: 90, y: -45 },
{ width: 1224, height: 864 },
{ w: 1360, h: 960 },
),
{ x: 100, y: -50 },
)
assert.deepEqual(
clientDeltaToLocal(
{ x: 48, y: 24 },
{ width: 500, height: 300 },
{ w: 500, h: 300 },
),
{ x: 48, y: 24 },
)
})

// ── Threshold predicates ─────────────────────────────────────────────────────

test('passedSlop arms only past the slop radius', () => {
Expand Down
13 changes: 6 additions & 7 deletions frontend/src/components/Shell/__tests__/workspaceUi.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1087,14 +1087,13 @@ test('workspace focus, drag label, and cancel visuals remain coherent', () => {
const focused = css.match(/\.workspace__strip--focused \.shell__tab--active \{[\s\S]*?\n\}/)?.[0] || ''
assert.match(focused, /box-shadow: inset 0 -2px 0 0 var\(--accent\)/)
assert.match(focused, /border-color: color-mix\(in srgb, var\(--accent\) 45%, var\(--border-light\)\)/)
// V5: pointer and tab-client measurements cross the scale boundary before the
// preview/chip write layout pixels. The chip then clamps against that same
// layout viewport so its label never clips at the right edge.
assert.match(dragBinding, /return clientPointToLocal\(\{ x: clientX, y: clientY \}, box\.rect, box\.localSize\)/)
// Pointer and tab measurements translate through the one content-box origin.
// Fixed drag chrome remains in viewport coordinates and clamps at that edge.
assert.match(dragBinding, /return clientPointToLocal\(\{ x: clientX, y: clientY \}, box\)/)
assert.match(dragBinding, /left: toLocal\(r\.left, r\.top, box\)\.x/)
assert.match(dragBinding, /chipOffset\(toViewportLayout\(clientX, clientY\), isTouch\)/)
assert.match(dragBinding, /w: root\.offsetWidth \|\| root\.clientWidth \|\| rect\.width/)
assert.match(dragBinding, /const viewportWidth = document\.documentElement\.offsetWidth\s*\n\s*\|\| document\.documentElement\.clientWidth\s*\n\s*\|\| window\.innerWidth/)
assert.match(dragBinding, /chipOffset\(\{ x: clientX, y: clientY \}, isTouch\)/)
assert.doesNotMatch(dragBinding, /toViewportLayout/)
assert.match(dragBinding, /const viewportWidth = document\.documentElement\.clientWidth\s*\n\s*\|\| window\.innerWidth/)
assert.match(dragBinding, /const maxLeft = Math\.max\(margin, viewportWidth - chipWidth - margin\)/)
assert.match(dragBinding, /Math\.max\(margin, Math\.min\(left, maxLeft\)\)/)
// V6: a CANCELLED drag blurs the drag-origin row so its focus ring clears; a
Expand Down
35 changes: 7 additions & 28 deletions frontend/src/components/Shell/dragController.js
Original file line number Diff line number Diff line change
Expand Up @@ -139,35 +139,14 @@ export function crossedDrawerExit(pointX, edgeX, gap = DRAWER_EXIT_PX) {

// ── Small geometry helpers ───────────────────────────────────────────────────

// Pointer events and getBoundingClientRect() report painted/client pixels, while
// projectLayout and inline left/top values use the element's unscaled CSS layout
// pixels. Those spaces are normally identical, but a CSS zoom/scale on an
// ancestor makes them diverge. Bridge that boundary once so hit-testing,
// measured tab edges, and rendered preview rects all use layout-local pixels.
export function clientPointToLocal(point, clientRect, localSize) {
const clientW = Number(clientRect?.width)
const clientH = Number(clientRect?.height)
const localW = Number(localSize?.w)
const localH = Number(localSize?.h)
const scaleX = clientW > 0 && localW > 0 ? localW / clientW : 1
const scaleY = clientH > 0 && localH > 0 ? localH / clientH : 1
// Translate viewport pointer coordinates into an element's local geometry.
// The shell stays at native document scale, so this boundary owns translation
// only; introducing a second scaled coordinate space here would make every
// drag and fixed overlay carry special conversion rules again.
export function clientPointToLocal(point, clientRect) {
return {
x: (Number(point?.x) - (Number(clientRect?.left) || 0)) * scaleX,
y: (Number(point?.y) - (Number(clientRect?.top) || 0)) * scaleY,
}
}

// Convert a painted/client displacement into the CSS-layout displacement an
// inline transform or resize consumes. Gesture thresholds stay in client
// pixels; callers cross this boundary only when writing layout geometry.
export function clientDeltaToLocal(delta, clientRect, localSize) {
const clientW = Number(clientRect?.width)
const clientH = Number(clientRect?.height)
const localW = Number(localSize?.w)
const localH = Number(localSize?.h)
return {
x: Number(delta?.x) * (clientW > 0 && localW > 0 ? localW / clientW : 1),
y: Number(delta?.y) * (clientH > 0 && localH > 0 ? localH / clientH : 1),
x: Number(point?.x) - (Number(clientRect?.left) || 0),
y: Number(point?.y) - (Number(clientRect?.top) || 0),
}
}

Expand Down
Loading
Loading