diff --git a/frontend/src/app/pages/Dashboard/desktop/DesktopDock.tsx b/frontend/src/app/pages/Dashboard/desktop/DesktopDock.tsx
index b66f3079..60725fb4 100644
--- a/frontend/src/app/pages/Dashboard/desktop/DesktopDock.tsx
+++ b/frontend/src/app/pages/Dashboard/desktop/DesktopDock.tsx
@@ -192,6 +192,7 @@ function DesktopDock({
))}
- {hoveredEntry && }
+ {hoveredEntry && (
+
+ )}
);
}
diff --git a/frontend/src/app/pages/Dashboard/desktop/DockActionTiles.tsx b/frontend/src/app/pages/Dashboard/desktop/DockActionTiles.tsx
index 08129d25..9bf40ba8 100644
--- a/frontend/src/app/pages/Dashboard/desktop/DockActionTiles.tsx
+++ b/frontend/src/app/pages/Dashboard/desktop/DockActionTiles.tsx
@@ -36,6 +36,7 @@ function DockActionTiles({ tile, onAddBrowser, onApplications, onHoverAway }: Do
0 && top > railHeight / 2;
return (
{
+ const cy = bases[ENTRIES];
+ const { transforms, scales } = computeMagnifyTransforms(bases, groups, cy, TILE);
+ for (let i = 0; i < ENTRIES; i += 1) {
+ assert.equal(scales[i], 1, `entry ${i} scaled to ${scales[i]}`);
+ assert.equal(transforms[i], '');
+ }
+ assert.ok(scales[ENTRIES] > 2, `hovered action should magnify, got ${scales[ENTRIES]}`);
+});
+
+test('hovering the last chat entry never inflates the action buttons below the divider', () => {
+ const cy = bases[ENTRIES - 1];
+ const { transforms, scales } = computeMagnifyTransforms(bases, groups, cy, TILE);
+ for (let i = ENTRIES; i < bases.length; i += 1) {
+ assert.equal(scales[i], 1, `action ${i - ENTRIES} scaled to ${scales[i]}`);
+ assert.equal(transforms[i], '');
+ }
+ assert.ok(scales[ENTRIES - 1] > 2, 'hovered entry should magnify');
+ assert.ok(scales[ENTRIES - 2] > 1, 'in-group neighbor keeps the macOS curve');
+});
+
+test('the curve itself still works mid-rail (no regression from the group mask)', () => {
+ const cy = bases[20];
+ const { scales } = computeMagnifyTransforms(bases, groups, cy, TILE);
+ assert.ok(scales[20] > 2.5, 'center tile approaches the 44px target');
+ assert.ok(scales[19] > scales[17], 'falloff is monotone toward the cursor');
+});
diff --git a/frontend/src/app/pages/Dashboard/desktop/useDockLayout.ts b/frontend/src/app/pages/Dashboard/desktop/useDockLayout.ts
index 424941d8..3d048d5c 100644
--- a/frontend/src/app/pages/Dashboard/desktop/useDockLayout.ts
+++ b/frontend/src/app/pages/Dashboard/desktop/useDockLayout.ts
@@ -44,6 +44,7 @@ function columnHeight(tile: number, tiles: number, dividers: number): number {
interface DockGeom {
els: HTMLElement[];
bases: number[];
+ groups: string[];
rootTop: number;
written: string[];
}
@@ -56,11 +57,50 @@ function beginGesture(root: HTMLElement, box: HTMLDivElement | null): DockGeom {
return {
els,
bases: els.map((t) => (box?.contains(t) ? boxShift : 0) + t.offsetTop + t.offsetHeight / 2),
+ groups: els.map((t) => t.dataset.dockGroup || ''),
rootTop: root.getBoundingClientRect().top,
written: els.map(() => ''),
};
}
+/** The dock's bell-curve magnify as pure math. The rail holds two species (chat entries above the
+ * divider, action buttons below) and the curve must never cross that divider: hovering Browsers
+ * used to inflate the neighboring chat (ENG-331). Tiles outside the cursor's group get '' (reset). */
+export function computeMagnifyTransforms(
+ bases: number[], groups: string[], cy: number, size: number,
+): { transforms: string[]; scales: number[] } {
+ const transforms: string[] = bases.map(() => '');
+ const scales: number[] = bases.map(() => 1);
+ if (bases.length === 0) return { transforms, scales };
+ const boost = MAGNIFY_TARGET / size - 1;
+ const falloff = size * FALLOFF_RATIO;
+ let nearest = 0;
+ for (let i = 1; i < bases.length; i += 1) {
+ if (Math.abs(cy - bases[i]) < Math.abs(cy - bases[nearest])) nearest = i;
+ }
+ const activeGroup = groups[nearest];
+ const idx: number[] = [];
+ groups.forEach((g, i) => { if (g === activeGroup) idx.push(i); });
+ const sub = idx.map((i) => 1 + boost * Math.exp(-(((cy - bases[i]) / falloff) ** 2)));
+ const extra = sub.map((s) => size * (s - 1));
+ const total = extra.reduce((a, b) => a + b, 0);
+ // Bases run in DOM order, top to bottom, so "everything before me" is a running sum, not an inner loop.
+ const head = (extra[0] - total) / 2;
+ const tail = (total - extra[extra.length - 1]) / 2;
+ const span = bases[idx[idx.length - 1]] - bases[idx[0]];
+ // Apple's Dock never grows longer than its rail: pin both ends and let the spread squeeze the middle.
+ const slope = span > 0 ? (tail - head) / span : 0;
+ let before = 0;
+ idx.forEach((elI, k) => {
+ const raw = before - (total - before - extra[k]);
+ const shift = raw / 2 - head - slope * (bases[elI] - bases[idx[0]]);
+ before += extra[k];
+ transforms[elI] = `translateY(${shift.toFixed(1)}px) scale(${sub[k].toFixed(3)})`;
+ scales[elI] = sub[k];
+ });
+ return { transforms, scales };
+}
+
/** macOS Dock sizing: tiles shrink to fit the column and only scroll once they hit the floor. */
export function useDockLayout({ cardCount, actionCount, dividerCount }: DockLayoutInput): DockLayout {
const dockRef = useRef(null);
@@ -145,30 +185,20 @@ export function useDockLayout({ cardCount, actionCount, dividerCount }: DockLayo
}
const geom = geomRef.current ?? beginGesture(root, scrollRef.current);
geomRef.current = geom;
- const { els, bases } = geom;
+ const { els, bases, groups } = geom;
if (els.length === 0) return;
- const size = tileRef.current;
- const boost = MAGNIFY_TARGET / size - 1;
- const falloff = size * FALLOFF_RATIO;
const cy = clientY - geom.rootTop;
- const scales = bases.map((b) => 1 + boost * Math.exp(-(((cy - b) / falloff) ** 2)));
- const extra = scales.map((s) => size * (s - 1));
- const total = extra.reduce((a, b) => a + b, 0);
- // Bases run in DOM order, top to bottom, so "everything before me" is a running sum, not an inner loop.
- const head = (extra[0] - total) / 2;
- const tail = (total - extra[els.length - 1]) / 2;
- const span = bases[els.length - 1] - bases[0];
- // Apple's Dock never grows longer than its rail: pin both ends and let the spread squeeze the middle.
- const slope = span > 0 ? (tail - head) / span : 0;
- let before = 0;
+ const { transforms, scales } = computeMagnifyTransforms(bases, groups, cy, tileRef.current);
els.forEach((t, i) => {
- const raw = before - (total - before - extra[i]);
- const shift = raw / 2 - head - slope * (bases[i] - bases[0]);
- before += extra[i];
- const next = `translateY(${shift.toFixed(1)}px) scale(${scales[i].toFixed(3)})`;
+ const next = transforms[i];
// Tiles outside the bell curve land on the same transform move after move, and a no-op style write is not free.
if (geom.written[i] === next) return;
geom.written[i] = next;
+ if (next === '') {
+ t.style.transform = '';
+ t.style.zIndex = '';
+ return;
+ }
t.style.transform = next;
t.style.zIndex = String(10 + Math.round((scales[i] - 1) * 100));
});