From 39930a41862d8635b00f9f51e9b65357a2d98b8c Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Tue, 31 Mar 2026 09:55:02 -0700 Subject: [PATCH] Fix fisheye: absolute positioning with scroll-compensated centering Rewrote fisheye layout to use position:absolute items with accumulated scaled heights. Active item always stays at vertical center by adding scrollTop to the offset calculation. Spacing compresses naturally for scaled-down items. Gentler opacity fade (0.3 floor) so size is the primary visual cue. Tighter CSS mask edges. Co-Authored-By: Claude Opus 4.6 (1M context) --- public/css/styles.css | 10 +-- public/css/workflow.css | 12 ++-- public/js/components/glass-terminal.js | 96 +++++++++++++++++++------- 3 files changed, 84 insertions(+), 34 deletions(-) diff --git a/public/css/styles.css b/public/css/styles.css index 11f3da7a9..b09f0ad03 100644 --- a/public/css/styles.css +++ b/public/css/styles.css @@ -131,13 +131,14 @@ position: relative; height: 520px; overflow: hidden; - mask-image: linear-gradient(to bottom, transparent, black 15%, black 85%, transparent); - -webkit-mask-image: linear-gradient(to bottom, transparent, black 15%, black 85%, transparent); + mask-image: linear-gradient(to bottom, transparent 2%, black 12%, black 88%, transparent 98%); + -webkit-mask-image: linear-gradient(to bottom, transparent 2%, black 12%, black 88%, transparent 98%); } .fisheye-scroll { height: 100%; overflow-y: auto; scrollbar-width: none; + position: relative; } .fisheye-scroll::-webkit-scrollbar { display: none; @@ -150,8 +151,9 @@ color: var(--color-ink); background: none; border: none; - padding: 4px 0; - height: 39px; + padding: 0; + position: absolute; + left: 0; cursor: pointer; white-space: nowrap; text-align: left; diff --git a/public/css/workflow.css b/public/css/workflow.css index 348ac51ef..9d13c5029 100644 --- a/public/css/workflow.css +++ b/public/css/workflow.css @@ -38,15 +38,15 @@ position: relative; height: 520px; overflow: hidden; - mask-image: linear-gradient(to bottom, transparent, black 15%, black 85%, transparent); - -webkit-mask-image: linear-gradient(to bottom, transparent, black 15%, black 85%, transparent); + mask-image: linear-gradient(to bottom, transparent 2%, black 12%, black 88%, transparent 98%); + -webkit-mask-image: linear-gradient(to bottom, transparent 2%, black 12%, black 88%, transparent 98%); } .fisheye-scroll { height: 100%; overflow-y: auto; scrollbar-width: none; - /* Padding set dynamically by JS based on container height */ + position: relative; } .fisheye-scroll::-webkit-scrollbar { display: none; } @@ -59,9 +59,9 @@ color: var(--color-ink); background: none; border: none; - /* Fixed height: 1.5rem * 1.3 line-height = 31.2px + 8px padding = ~39px */ - padding: 4px 0; - height: 39px; + padding: 0; + position: absolute; + left: 0; cursor: pointer; white-space: nowrap; text-align: left; diff --git a/public/js/components/glass-terminal.js b/public/js/components/glass-terminal.js index 58af4a090..33b6e0953 100644 --- a/public/js/components/glass-terminal.js +++ b/public/js/components/glass-terminal.js @@ -253,28 +253,79 @@ function setupFisheyeList(commands) { if (!list || !scroll || !items.length) return; // Fixed item height (matches CSS). All math is index-based. - const ITEM_H = 39; // px per item (font 1.5rem * 1.3 line-height + 4px*2 padding) - const RADIUS = 4; // items on each side affected by fisheye + // -- Fisheye with absolute positioning -- + // Each item is placed absolutely. Their Y positions are computed by + // accumulating scaled heights, so small items cluster together + // and the center item gets full space. Scroll position maps linearly + // to a fractional "center index" which drives everything. + + const BASE_H = 36; // height of the center (scale=1) item + const MIN_SCALE = 0.35; + const RADIUS = 5; const count = items.length; + const listH = list.clientHeight; + const centerY = listH / 2; let currentActive = -1; - // Convert scroll position to fractional center index - const scrollToFractional = () => { - // Scroll area: padding-top puts index 0 at center when scrollTop=0 - return scroll.scrollTop / ITEM_H; + // Total scroll range: one "step" per item + const STEP = 30; // px of scroll per item advance + const totalScroll = (count - 1) * STEP; + + // Set scroll container height to accommodate the range + centering padding + const spacer = document.createElement('div'); + spacer.style.height = `${totalScroll + listH}px`; + scroll.appendChild(spacer); + // Initial scroll to center first item + scroll.scrollTop = 0; + + // Map scrollTop to fractional center index + const getCenterIndex = () => scroll.scrollTop / STEP; + + // Compute eased scale for a given distance from center + const getScale = (dist) => { + const ratio = Math.max(0, 1 - dist / RADIUS); + const eased = ratio * ratio * (3 - 2 * ratio); // smoothstep + return MIN_SCALE + eased * (1 - MIN_SCALE); }; - // Apply fisheye visual based on fractional center - const applyFisheye = (center) => { - items.forEach((item, i) => { + // Layout: position all items based on current center + const layout = (center) => { + // First, compute the Y position for each item by accumulating + // scaled heights, centered around the center item + const heights = items.map((_, i) => { const dist = Math.abs(i - center); - const ratio = Math.max(0, 1 - dist / RADIUS); - // Strong ease-out for dramatic center pop - const eased = ratio * ratio * (3 - 2 * ratio); // smoothstep - const scale = 0.35 + eased * 0.65; - const opacity = 0.06 + eased * 0.94; + return BASE_H * getScale(dist); + }); + + // Find the Y offset so the fractional center position lands at centerY. + // Interpolate between the integer positions for smooth scrolling. + const floorIdx = Math.max(0, Math.min(count - 1, Math.floor(center))); + const frac = center - floorIdx; + + let yAtFloor = 0; + for (let i = 0; i < floorIdx; i++) yAtFloor += heights[i]; + yAtFloor += heights[floorIdx] / 2; + + // If between two items, blend toward the next + let yAtCeil = yAtFloor; + if (floorIdx < count - 1) { + yAtCeil = yAtFloor + heights[floorIdx] / 2 + heights[floorIdx + 1] / 2; + } + const yAtCenter = yAtFloor + (yAtCeil - yAtFloor) * frac; + const offset = centerY - yAtCenter + scroll.scrollTop; + + // Position each item + let y = offset; + items.forEach((item, i) => { + const h = heights[i]; + const dist = Math.abs(i - center); + const scale = getScale(dist); + const opacity = 0.25 + (scale - MIN_SCALE) / (1 - MIN_SCALE) * 0.75; + + item.style.top = `${y}px`; item.style.transform = `scale(${scale})`; item.style.opacity = opacity; + y += h; }); }; @@ -288,20 +339,21 @@ function setupFisheyeList(commands) { const scrollToIndex = (idx, behavior = 'smooth') => { idx = Math.max(0, Math.min(count - 1, idx)); - scroll.scrollTo({ top: idx * ITEM_H, behavior }); + scroll.scrollTo({ top: idx * STEP, behavior }); }; - // Scroll: update fisheye + activate nearest + // Scroll handler let raf = null; scroll.addEventListener('scroll', () => { if (raf) cancelAnimationFrame(raf); raf = requestAnimationFrame(() => { - const center = scrollToFractional(); - applyFisheye(center); + const center = getCenterIndex(); + layout(center); activate(Math.round(center)); }); }, { passive: true }); + // Click to jump items.forEach((item, i) => { item.addEventListener('click', () => scrollToIndex(i)); @@ -313,12 +365,8 @@ function setupFisheyeList(commands) { // Init const startIdx = magazineState.currentIndex; currentActive = -1; - // Set padding so index 0 sits at center and last index sits at center - const padH = list.clientHeight / 2 - ITEM_H / 2; - scroll.style.paddingTop = `${padH}px`; - scroll.style.paddingBottom = `${padH}px`; - scroll.scrollTop = startIdx * ITEM_H; - applyFisheye(startIdx); + scroll.scrollTop = startIdx * STEP; + layout(startIdx); activate(startIdx); }