diff --git a/docs/screenshots/sideways/after-chart-320.png b/docs/screenshots/sideways/after-chart-320.png new file mode 100644 index 00000000..2d04a902 Binary files /dev/null and b/docs/screenshots/sideways/after-chart-320.png differ diff --git a/docs/screenshots/sideways/after-markets-desktop.png b/docs/screenshots/sideways/after-markets-desktop.png new file mode 100644 index 00000000..7e988a3e Binary files /dev/null and b/docs/screenshots/sideways/after-markets-desktop.png differ diff --git a/docs/screenshots/sideways/after-slide-desktop.png b/docs/screenshots/sideways/after-slide-desktop.png new file mode 100644 index 00000000..10a9dfa5 Binary files /dev/null and b/docs/screenshots/sideways/after-slide-desktop.png differ diff --git a/docs/screenshots/sideways/after-slide-mobile.png b/docs/screenshots/sideways/after-slide-mobile.png new file mode 100644 index 00000000..987e9401 Binary files /dev/null and b/docs/screenshots/sideways/after-slide-mobile.png differ diff --git a/docs/screenshots/sideways/after-white-peers-desktop.png b/docs/screenshots/sideways/after-white-peers-desktop.png new file mode 100644 index 00000000..b88e657e Binary files /dev/null and b/docs/screenshots/sideways/after-white-peers-desktop.png differ diff --git a/docs/screenshots/sideways/after-white-peers-mobile.png b/docs/screenshots/sideways/after-white-peers-mobile.png new file mode 100644 index 00000000..faf35df9 Binary files /dev/null and b/docs/screenshots/sideways/after-white-peers-mobile.png differ diff --git a/docs/screenshots/sideways/before-overlay-desktop.png b/docs/screenshots/sideways/before-overlay-desktop.png new file mode 100644 index 00000000..371164fc Binary files /dev/null and b/docs/screenshots/sideways/before-overlay-desktop.png differ diff --git a/docs/screenshots/sideways/before-overlay-mobile.png b/docs/screenshots/sideways/before-overlay-mobile.png new file mode 100644 index 00000000..81e63ace Binary files /dev/null and b/docs/screenshots/sideways/before-overlay-mobile.png differ diff --git a/docs/sideways-chart-previews.md b/docs/sideways-chart-previews.md new file mode 100644 index 00000000..ff6ca688 --- /dev/null +++ b/docs/sideways-chart-previews.md @@ -0,0 +1,42 @@ +# Sideways chart previews + +## Latest light-mode refinement + +At Lukas's request, displaced inactive cards fade toward **white** in light mode (a 60% white veil), not gray. Dark mode explicitly retains the original gray mask; no event handling, geometry, data or publication boundary changes. + +These focused gallery crops are from the local development candidate containing this follow-up's CSS. They supersede the gray-mask appearance in the historical production captures below; final production and hosted checks are recorded in the PR. + + + + +## Scope + +This preview-only change adapts the horizontal chart-deck interaction from `protocol/plneuro.xyz#23` (reference head `2a066e3ce7e577a6063e5617f24cb29b51c1e3eb`) to PLRD's existing chart system. + +- Multi-view groups slide sibling previews sideways from beneath the cover rather than opening an overlay over their neighbors. +- The selected group expands within a horizontal viewport, physically displacing and dimming neighboring groups and separating them with a divider. +- Mobile uses tap-to-spread and local horizontal scrolling with the next card visible. Single-view cards open their chart directly; evidence-only cards retain their existing explanation. +- PLRD's visual language, selected-field data, two-face chart/source cards and shareable chart URLs remain its own. This is not a port of PL Neuro's methodology or navigation changes. + +## Publication boundary + +The base includes merged hide PR #154. All four public focus-area detail pages remain without field-velocity mounts or links to the cryptic preview. `/impact-preview-eb61fba1b98e/` remains noindex and absent from site navigation, sitemap, search and RSS. No `/impact/` route is promoted. Draft restoration PR #155 remains a separate launch decision. + +## Verification record + +The full interaction matrix passed at application revision `083b694c028854404fa1415736a41658702d1da3`: 83 Node tests, typecheck, production build, and 88 headed-browser cases across all four focus areas at 1440/390/320px. The browser checked real intermediate slide positions, peer displacement, every chart/evidence destination, both data/chart faces, touch swipes, local-scroll geometry and reduced motion. + +A subsequent focused fix at `e19063a415b1623d82d71d13f0898b167f8e4e40` keeps the restored cover visible inside the local viewport after closing a horizontally scrolled spread. A failing-first test reproduced the offscreen-focus case; all 84 Node tests and typecheck passed after the fix. This does not change colors, dimensions, data, routes or chart URLs. The PR description records the final build, independent review and browser replays. + +Repeatable local checks: `npx --yes pnpm@10 install --frozen-lockfile`, `npx --yes pnpm@10 test`, `npx --yes pnpm@10 exec tsc --noEmit`, `npx --yes pnpm@10 build`. Node/DOM tests are not evidence of real visual motion; the parent browser matrix supplies that evidence. + +Known baseline: the existing full-width page band contributes 8px of document width beyond the client area at 1440/390px; the site's 320px minimum width contributes 15px at a 320px viewport with native scrollbars. The port must not increase these existing deltas. It must not use document scrolling as its mobile gallery interaction. + +Before screenshots are local production captures at `c4628b3dfd337dd4afd2ff07b1176efa8ac5986e`, whose entire `src` tree was verified identical to the post-#154 base `e1923a6e693eb3b45fcbaaa0efea196d279ca577`. After screenshots are local production captures at `083b694c028854404fa1415736a41658702d1da3`, before the focus-restoration-only fix. Neither is a claim that this change was merged to production. + +- `screenshots/sideways/before-overlay-desktop.png` and `before-overlay-mobile.png`: previous overlay fan. +- `screenshots/sideways/after-slide-desktop.png` and `after-slide-mobile.png`: sideways performance previews and local-scroll behavior. +- `screenshots/sideways/after-markets-desktop.png`: all four real AI Markets views, independently selectable. +- `screenshots/sideways/after-chart-320.png`: selected recording-hours chart at 320px. + +No native screen-reader or physical touch-device test is claimed. Mobile touch gestures were exercised through CDP in the existing headed Chrome. diff --git a/scripts/velocity/chart-selection-ui.test.mjs b/scripts/velocity/chart-selection-ui.test.mjs index 27bbdb19..d030fcf7 100644 --- a/scripts/velocity/chart-selection-ui.test.mjs +++ b/scripts/velocity/chart-selection-ui.test.mjs @@ -30,31 +30,32 @@ test('focus-area tabs are horizontal above a normal-width overview', async () => } finally { await unmount() } }) -test('one, two and three preview fans share a card width and shrink their outer frame', async () => { +test('single covers and two/three-view spreads share the same bounded horizontal card dimensions', async () => { const unmount = await mount({ fixedArea: 'neurotech' }) - const oldWidth = window.innerWidth try { - const groups = ['latency_compression', 'revealed_commitments', 'performance_curves'] - for (const viewport of [1440, 768, 390, 320]) { - window.innerWidth = viewport - const available = Math.min(860, viewport - 64) - const widths = [] - for (const [index, group] of groups.entries()) { - const deck = document.querySelector(`[data-chart-deck="${group}"]`) - deck.getBoundingClientRect = () => ({ left: 24, top: 100, width: 200, height: 250 }) - deck.parentElement.getBoundingClientRect = () => ({ left: 24, right: viewport - 24, width: viewport - 48 }) - await click(deck.querySelector('[data-instrument]')) - const width = parseFloat(deck.querySelector('[data-chart-fan]').style.getPropertyValue('--fan-width')) - widths.push(width) - const columns = viewport <= 639 ? 1 : index + 1 - const cardWidth = (width - 26 - (columns - 1) * 12) / columns - const expected = viewport <= 639 ? available - 26 : (available - 50) / 3 - assert.ok(Math.abs(cardWidth - expected) < .01, `${viewport}px / ${columns} columns: ${cardWidth} should equal ${expected}`) + const { readFileSync } = await import('node:fs') + const css = readFileSync('src/app/globals.css', 'utf8') + const rule = selector => css.slice(css.indexOf(`${selector} {`)).split('}')[0] + assert.match(rule('.chart-deck'), /width: var\(--gallery-card-width\)/) + assert.match(rule('.chart-fan-card'), /width: var\(--gallery-card-width\)/) + assert.match(rule('.instrument-preview'), /height: 100%/) + assert.match(rule('.chart-fan-card'), /height: 100%/) + assert.match(rule('.chart-deck'), /height: var\(--gallery-card-height\)/) + for (const [index, group] of ['latency_compression', 'revealed_commitments', 'performance_curves'].entries()) { + const deck = document.querySelector(`[data-chart-deck="${group}"]`) + const cover = deck.querySelector('[data-instrument]') + assert.equal(Number(cover.dataset.viewCount), index + 1) + await click(cover) + if (index === 0) { + assert.equal(deck.querySelector('[data-chart-fan]'), null, 'single views open directly') + assert.ok(document.querySelector('[role="dialog"]')) + await click(document.querySelector('[aria-label="Close gallery"]')) + } else { + assert.equal(deck.querySelector('[data-chart-fan]').style.getPropertyValue('--fan-count'), String(index + 1)) + assert.equal(deck.parentElement.style.getPropertyValue('--gallery-slots'), String(5 + index)) } - if (viewport > 639) assert.ok(widths[0] < widths[1] && widths[1] < widths[2]) - else assert.equal(new Set(widths).size, 1, 'stacked mobile cards use the same available width') } - } finally { window.innerWidth = oldWidth; await unmount() } + } finally { await unmount() } }) test('multi-view covers visibly retain layered backs at rest while single charts do not', async () => { @@ -76,8 +77,9 @@ test('latency animal-model qualifier appears only on the data face, not chart ti await click(cover) const target = cover.closest('[data-chart-deck]').querySelector('[data-chart-target]') assert.doesNotMatch(target.textContent, /Most entries are/) - await click(target) + assert.ok(target === cover, 'single latency chart opens directly, without another preview') const dialog = document.querySelector('[role="dialog"]') + assert.equal(dialog.querySelector('[data-gallery-item]').dataset.galleryItem, cover.dataset.chartTarget) assert.doesNotMatch(dialog.querySelector('h2').textContent, /Most entries are/) assert.doesNotMatch(dialog.querySelector('[data-face="chart"]').textContent, /Most entries are/) await click(dialog.querySelector('[data-flip-action]')) @@ -96,7 +98,7 @@ test('opening another deck replaces a pinned or focused fan for every input mode } const openDecks = () => [...document.querySelectorAll('[data-chart-deck][data-expanded="true"]')].map(e => e.dataset.chartDeck) try { - const a = document.querySelector('[data-instrument="latency_compression"]') + const a = document.querySelector('[data-instrument="performance_curves"]') const b = document.querySelector('[data-instrument="revealed_commitments"]') await click(a) const priorTarget = a.closest('[data-chart-deck]').querySelector('[data-chart-target]') @@ -110,7 +112,7 @@ test('opening another deck replaces a pinned or focused fan for every input mode await pointer(a, 'pointerout', document.body) assert.deepEqual(openDecks(), ['revealed_commitments'], 'a late leave from the old deck cannot close the new deck') await act(() => { a.blur(); a.focus() }) - assert.deepEqual(openDecks(), ['latency_compression'], 'keyboard focus replaces the hovered fan') + assert.deepEqual(openDecks(), ['performance_curves'], 'keyboard focus replaces the hovered fan') await click(b) assert.deepEqual(openDecks(), ['revealed_commitments'], 'touch/click replaces the other fan') } finally { await unmount() } @@ -277,24 +279,25 @@ test('every available chart has a unique fresh URL; invalid, unavailable and cro try { assert.ok(!document.querySelector('[role="dialog"]'), 'dialog must be closed') } finally { await unmount() } }) -test('the spread is clamped horizontally and vertically when its cover is at viewport edges', async () => { +test('edge spreads stay in the local horizontal viewport instead of positioning a vertical popover', async () => { const unmount = await mount({ fixedArea: 'neurotech' }) try { const deck = document.querySelector('[data-chart-deck="performance_curves"]') const fan = deck.querySelector('[data-chart-fan]') - deck.getBoundingClientRect = () => ({ left: 900, top: 700, width: 100, height: 250 }) - deck.parentElement.getBoundingClientRect = () => ({ left: 24, right: 1000, width: 976 }) - Object.defineProperty(fan, 'scrollHeight', { configurable: true, value: 400 }) + const frame = deck.closest('[data-chart-viewport]') + const before = { x: window.scrollX, y: window.scrollY, overflow: document.body.style.overflow } + assert.ok(frame) + deck.getBoundingClientRect = () => ({ left: 900, top: 700, width: 100, height: 304 }) await click(deck.querySelector('[data-instrument]')) - const width = parseFloat(fan.style.getPropertyValue('--fan-width')) - const left = 900 + parseFloat(fan.style.getPropertyValue('--fan-left')) - const top = 700 + parseFloat(fan.style.getPropertyValue('--fan-top')) - assert.ok(left >= 12 && left + width <= window.innerWidth - 12) - assert.ok(top >= 12 && top + 402 <= window.innerHeight - 12, 'entire spread stays inside the viewport, not below a bottom-row cover') + for (const property of ['--fan-top', '--fan-left', '--fan-width']) assert.equal(fan.style.getPropertyValue(property), '') + assert.equal(fan.style.getPropertyValue('--fan-count'), '3') + assert.deepEqual({ x: window.scrollX, y: window.scrollY, overflow: document.body.style.overflow }, before) + const { readFileSync } = await import('node:fs') + const css = readFileSync('src/app/globals.css', 'utf8') + assert.match(css, /\.instrument-preview-viewport\s*\{[^}]*overflow-x: auto;[^}]*overflow-y: hidden;/) } finally { await unmount() } }) - test('close keeps the modal input boundary until its pending history traversal finishes', async () => { const unmount = await mount({ fixedArea: 'neurotech' }) const back = window.history.back.bind(window.history) diff --git a/scripts/velocity/contact-sheet.test.mjs b/scripts/velocity/contact-sheet.test.mjs index cef75878..45111167 100644 --- a/scripts/velocity/contact-sheet.test.mjs +++ b/scripts/velocity/contact-sheet.test.mjs @@ -6,8 +6,8 @@ import { readFileSync } from 'node:fs' test('selectable preview cards animate from a stack, with reduced-motion support', () => { const css = readFileSync('src/app/globals.css', 'utf8') assert.match(css, /\.chart-fan-card\s*\{[^}]*transition:[^}]*transform/s) - assert.match(css, /\.chart-fan-card\s*\{[^}]*rotate\(/s) - assert.match(css, /\.chart-deck\[data-expanded="true"\] \.chart-fan-card[^}]*transform: none/s) + assert.doesNotMatch(css, /\.chart-fan-card\s*\{[^}]*rotate\(/s, 'sideways previews do not rotate or scale') + assert.match(css, /\.chart-deck\[data-expanded="true"\] \.chart-fan-card[^}]*transform: translateX\(calc\(var\(--fan-index\) \* var\(--gallery-step\)\)\)/s) assert.match(css, /prefers-reduced-motion: reduce[\s\S]*\.chart-fan-card[^}]*transition: none/s) }) diff --git a/scripts/velocity/instrument-gallery-ui.test.mjs b/scripts/velocity/instrument-gallery-ui.test.mjs index f1df7193..5958cceb 100644 --- a/scripts/velocity/instrument-gallery-ui.test.mjs +++ b/scripts/velocity/instrument-gallery-ui.test.mjs @@ -55,8 +55,8 @@ const load = () => source('lib/field-velocity-data.ts').loadFieldVelocity(async const click = async node => { assert.ok(node, 'click target exists'); await act(async () => { node.click(); await historyTasks() }) } const open = async cover => { await click(cover) - const target = cover.closest('[data-chart-deck]').querySelector('[data-chart-target]:not([hidden])') - if (target) await click(target) + const target = cover.closest('[data-chart-deck]').querySelector('[data-chart-target]') + if (target && target !== cover) await click(target) return target ?? cover } const mount = async props => { @@ -84,7 +84,7 @@ test('all four overview tabs and Neuro detail select every real view once with e assert.equal(targets.length, Number(cover.dataset.viewCount)) let chartCount = 0 const seen = new Set() - if (targets.length) await click(cover) + if (targets.length > 1) await click(cover) for (const target of targets.length ? targets : [cover]) { await click(target) const dialog = document.querySelector('[role="dialog"]') @@ -138,7 +138,7 @@ test('every gallery kind flips to locally contained data and back with only the const unmount = await mount({ ...data, marketSignals, recordsByArea: { neurotech: records }, fixedArea: 'neurotech' }) try { for (const trigger of document.querySelectorAll('button[data-instrument]')) { - await click(trigger) + if (Number(trigger.dataset.viewCount) > 1) await click(trigger) for (const target of trigger.closest('[data-chart-deck]').querySelectorAll('[data-chart-target]')) { await click(target) for (const card of document.querySelectorAll('[data-gallery-item]')) { @@ -292,7 +292,7 @@ test('gallery styling uses a physical two-faced 3D rotation with reduced-motion assert.match(css, /@media \(prefers-reduced-motion: reduce\)[\s\S]*\.gallery-card-face[^}]*animation: none/) }) -test('large inventories page at most three titled previews, while every modal stays single-chart', async () => { +test('large inventories expose every horizontal sibling without pagination, while every modal stays single-chart', async () => { const data = await load() const areaPoints = source('lib/field-velocity.ts').INFLECTION_POINTS.filter(p => p.area === 'ai-robotics') const marketSignals = Object.fromEntries(areaPoints.slice(0, 2).map((p, i) => [p.title, { prob: 0.4, platform: 'polymarket', question: `Question ${i}?`, resolutionDate: '2027-01-01', url: `https://polymarket.com/${i}` }])) @@ -300,14 +300,22 @@ test('large inventories page at most three titled previews, while every modal st try { const deck = document.querySelector('[data-chart-deck="markets"]') await click(deck.querySelector('[data-instrument]')) - assert.equal(deck.querySelectorAll('[data-chart-target]:not([hidden])').length, 3) - await click([...deck.querySelectorAll('button')].find(button => button.textContent === 'More previews')) - assert.equal(deck.querySelectorAll('[data-chart-target]:not([hidden])').length, 1) - const target = deck.querySelector('[data-chart-target]:not([hidden])') - await click(target) - assert.equal(document.querySelectorAll('[data-gallery-item]').length, 1) - assert.equal(document.querySelector('.instrument-gallery-grid').dataset.columns, '1') - assert.equal(document.querySelector('[data-gallery-item]').dataset.galleryItem, target.dataset.chartTarget) + const targets = [...deck.querySelectorAll('[data-chart-target]')] + assert.equal(targets.length, 4) + assert.equal(deck.parentElement.style.getPropertyValue('--gallery-slots'), '8') + assert.ok(targets.every(target => !target.hidden)) + assert.equal(deck.querySelector('.chart-fan-pages'), null) + const css = readFileSync('src/app/globals.css', 'utf8') + assert.match(css, /\.chart-fan\s*\{[^}]*z-index: 1/, 'fan stacking is isolated below its cover even with more than three views') + for (const [index, target] of targets.entries()) { + assert.equal(target.style.getPropertyValue('--fan-index'), String(index)) + await click(target) + assert.equal(document.querySelectorAll('[data-gallery-item]').length, 1) + assert.equal(document.querySelector('.instrument-gallery-grid').dataset.columns, '1') + assert.equal(document.querySelector('[data-gallery-item]').dataset.galleryItem, target.dataset.chartTarget) + await click(document.querySelector('[aria-label="Close gallery"]')) + assert.ok(document.activeElement === target) + } } finally { await unmount() } }) diff --git a/scripts/velocity/sideways-chart-decks.test.mjs b/scripts/velocity/sideways-chart-decks.test.mjs new file mode 100644 index 00000000..8050b9b2 --- /dev/null +++ b/scripts/velocity/sideways-chart-decks.test.mjs @@ -0,0 +1,246 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import React, { act } from 'react' +import { JSDOM } from 'jsdom' +import { source } from './test-source-loader.mjs' + +const dom = new JSDOM('
', { url: 'http://localhost/impact-preview-eb61fba1b98e/?qa=sideways' }) +for (const key of ['window', 'document', 'HTMLElement', 'Element', 'Node', 'KeyboardEvent', 'MouseEvent']) globalThis[key] = dom.window[key] +globalThis.IS_REACT_ACT_ENVIRONMENT = true +const { createRoot } = await import('react-dom/client') +const Dashboard = source('components/ImpactDashboardV2.tsx').default +const areas = source('lib/field-velocity.ts').FOCUS_AREAS +const historyTasks = async () => { for (let i = 0; i < 4; i++) await new Promise(resolve => setTimeout(resolve, 0)) } +const click = async node => { assert.ok(node, 'click target exists'); await act(async () => { node.click(); await historyTasks() }) } +const pointer = async (target, type, relatedTarget = null, pointerType = 'mouse') => { + const event = new MouseEvent(type, { bubbles: true, relatedTarget }) + Object.defineProperty(event, 'pointerType', { value: pointerType }) + await act(() => target.dispatchEvent(event)) +} +const mount = async props => { + const data = await source('lib/field-velocity-data.ts').loadFieldVelocity(async () => ({})) + const root = createRoot(document.getElementById('app')) + await act(() => root.render(React.createElement(Dashboard, { ...data, initialArea: 'neurotech', ...props }))) + return async () => { await act(() => root.unmount()); window.history.replaceState(null, '', '/impact-preview-eb61fba1b98e/?qa=sideways') } +} +const css = readFileSync('src/app/globals.css', 'utf8') +const rule = selector => css.slice(css.indexOf(`${selector} {`)).split('}')[0] + +test('inactive decks fade toward white in light mode and retain the original dark-mode mask', () => { + const selector = '.chart-deck[data-subdued="true"]::after' + assert.match(rule(selector), /background: color-mix\(in srgb, #fff 60%, transparent\)/) + assert.match(rule(`html.dark ${selector}`), /background: color-mix\(in srgb, var\(--color-gray-500\) 30%, transparent\)/) + assert.match(rule(selector), /pointer-events: none/) +}) + +test('single-view cards open their exact chart directly and evidence gaps never become dead covers', async () => { + const unmount = await mount() + try { + for (const area of areas) { + await click([...document.querySelectorAll('[role="tab"]')].find(tab => tab.textContent.includes(area.label))) + for (const cover of document.querySelectorAll('[data-instrument]')) { + if (Number(cover.dataset.viewCount) > 1) continue + const deck = cover.closest('[data-chart-deck]') + await pointer(cover, 'pointerover') + await act(() => cover.focus({ preventScroll: true })) + assert.equal(deck.dataset.expanded, 'false', 'a single view needs no intermediate fan') + assert.equal(cover.getAttribute('aria-expanded'), null) + assert.equal(cover.getAttribute('aria-haspopup'), 'dialog') + assert.equal(deck.querySelector('[data-chart-fan]'), null) + const id = cover.dataset.chartTarget ?? 'evidence' + if (Number(cover.dataset.viewCount) === 1) assert.ok(cover.dataset.chartTarget) + await click(cover) + const dialog = document.querySelector('[role="dialog"]') + assert.ok(dialog, 'one activation opens the chart/status immediately') + assert.equal(dialog.querySelector('[data-gallery-item]')?.dataset.galleryItem, id === 'evidence' ? undefined : id) + const hash = `#fv/${area.key}/${cover.dataset.instrument}/${encodeURIComponent(id)}` + assert.equal(window.location.hash, hash) + assert.equal(document.querySelector('[data-chart-direct]').href, window.location.href) + let copied + Object.defineProperty(window.navigator, 'clipboard', { configurable: true, value: { writeText: async value => { copied = value } } }) + await click(document.querySelector('[data-chart-copy]')) + assert.equal(copied, window.location.href) + const flip = dialog.querySelector('[data-flip-action]') + if (flip) { + await click(flip) + assert.equal(dialog.querySelector('[data-face="data"]').getAttribute('aria-hidden'), 'false') + await click(flip) + assert.equal(dialog.querySelector('[data-face="chart"]').getAttribute('aria-hidden'), 'false') + } + await click(dialog.querySelector('[aria-label="Close gallery"]')) + assert.ok(document.activeElement === cover, 'focus is on the expected visible control') + assert.equal(deck.dataset.expanded, 'false') + } + } + } finally { await unmount() } +}) + +test('keyboard focus enters a visible preview, recovers on peer takeover, and returns safely from every modal', async () => { + const unmount = await mount({ fixedArea: 'neurotech' }) + try { + const a = document.querySelector('[data-chart-deck="performance_curves"]') + const b = document.querySelector('[data-chart-deck="revealed_commitments"]') + const aCover = a.querySelector('[data-instrument]') + const bCover = b.querySelector('[data-instrument]') + const aTarget = a.querySelector('[data-chart-target]') + await act(() => aCover.focus()) + assert.ok(document.activeElement === aTarget, 'focus follows the fading cover onto its first real view') + assert.equal(aCover.tabIndex, -1, 'the transparent cover cannot remain a keyboard stop') + assert.equal(a.querySelector('[data-chart-fan]').hasAttribute('inert'), false) + await click(aTarget) + await click(document.querySelector('[aria-label="Close gallery"]')) + assert.ok(document.activeElement === aTarget, 'focus is on the expected visible control') + assert.equal(a.dataset.expanded, 'true') + await pointer(bCover, 'pointerover', document.body) + assert.equal(b.dataset.expanded, 'true') + assert.equal(a.dataset.expanded, 'false') + assert.ok(document.activeElement === aCover, 'peer takeover rescues focused previews before they become inert') + assert.equal(aCover.tabIndex, 0) + await pointer(aCover, 'pointerout', document.body) + assert.equal(b.dataset.expanded, 'true', 'late events from the old owner do not close the new one') + await act(() => { aCover.blur(); aCover.focus() }) + assert.ok(document.activeElement === aTarget, 'focus is on the expected visible control') + assert.equal(b.dataset.expanded, 'false') + await act(() => aTarget.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }))) + assert.ok(document.activeElement === aCover, 'Escape restores the visible cover') + assert.equal(a.dataset.expanded, 'false', 'Escape cannot reopen the cover it focuses') + await pointer(bCover, 'pointerover', document.body, 'touch') + assert.equal(b.dataset.expanded, 'false', 'touch hover does not unexpectedly replace the set') + await click(bCover) + assert.equal(b.dataset.expanded, 'true', 'tap spreads') + assert.ok(document.activeElement === b.querySelector('[data-chart-target]'), 'focus is on the expected visible control') + await pointer(bCover, 'pointerout', document.body, 'touch') + assert.equal(b.dataset.expanded, 'true', 'tap pins until selection/dismissal') + await pointer(document.body, 'pointerdown', null, 'touch') + assert.equal(b.dataset.expanded, 'false') + } finally { await unmount() } + + window.history.replaceState(null, '', '#fv/neurotech/performance_curves/tissue-mapped') + const unmountFresh = await mount() + try { + await click(document.querySelector('[aria-label="Close gallery"]')) + const cover = document.querySelector('[data-instrument="performance_curves"]') + assert.ok(document.activeElement === cover, 'fresh links return to a visible, closed cover') + assert.equal(cover.closest('[data-chart-deck]').dataset.expanded, 'false') + await act(() => { cover.blur(); cover.focus() }) + assert.equal(cover.closest('[data-chart-deck]').dataset.expanded, 'true', 'normal keyboard entry still works after fresh-link dismissal') + } finally { await unmountFresh() } +}) + +test('right-edge touch spreads reveal only the local viewport on expansion and resize, not modal/history updates', async () => { + const unmount = await mount({ fixedArea: 'ai-robotics' }) + try { + const frame = document.querySelector('[data-chart-viewport]') + assert.ok(frame, 'gallery owns a local horizontal scroll viewport') + const row = frame.querySelector('.instrument-previews') + const deck = frame.querySelector('[data-chart-deck="markets"]') + const cover = deck.querySelector('[data-instrument]') + assert.ok(Number(cover.dataset.viewCount) > 1) + const calls = [] + frame.scrollTo = options => { calls.push(options); frame.scrollLeft = options.left } + Object.defineProperty(frame, 'clientWidth', { configurable: true, value: 320 }) + Object.defineProperty(row, 'offsetLeft', { configurable: true, value: 12 }) + Object.defineProperty(deck, 'offsetLeft', { configurable: true, value: 1056 }) + Object.defineProperty(deck, 'offsetWidth', { configurable: true, value: 240 }) + row.style.columnGap = '24px' + await pointer(cover, 'pointerover', document.body, 'touch') + await click(cover) + assert.deepEqual(calls.at(-1), { left: 1068, behavior: 'instant' }, 'reveal begins with the active card, leaving room to peek at its next sibling') + assert.equal(frame.scrollLeft, 1068) + frame.scrollLeft = 1160 + await click(deck.querySelector('[data-chart-target]')) + await click(document.querySelector('[aria-label="Close gallery"]')) + assert.equal(frame.scrollLeft, 1160, 'modal/history updates do not undo a user scroll inside the active set') + Object.defineProperty(frame, 'clientWidth', { configurable: true, value: 800 }) + await act(() => window.dispatchEvent(new dom.window.Event('resize'))) + const count = Number(cover.dataset.viewCount) + assert.deepEqual(calls.at(-1), { left: 1068, behavior: 'instant' }) + frame.scrollLeft = 0 + await act(() => window.dispatchEvent(new dom.window.Event('resize'))) + assert.equal(frame.scrollLeft, Math.max(0, 1068 + Math.min(count * 264 - 24, 800) - 800), 'desktop reveals the entire set when it fits') + assert.match(rule('.instrument-preview-viewport'), /overflow-x: auto/) + assert.match(rule('.instrument-preview-viewport'), /overflow-y: hidden/) + assert.match(rule('.instrument-preview-viewport'), /max-width: 100%/) + assert.match(rule('.instrument-previews'), /--gallery-card-width: min\(300px, 78cqw\)/, 'mobile reserves a visible next card instead of stacking') + assert.match(rule('.chart-deck'), /width: var\(--gallery-card-width\)/) + assert.match(rule('.chart-fan-card'), /width: var\(--gallery-card-width\)/) + assert.match(rule('.chart-deck'), /height: var\(--gallery-card-height\)/) + assert.match(rule('.chart-fan-card'), /height: 100%/) + assert.match(css, /prefers-reduced-motion: reduce[\s\S]*\.chart-deck[^}]*\.chart-fan-card[^}]*transition: none/) + } finally { await unmount() } +}) + +test('scrolled dismissal brings the restored cover back into the local viewport without scrolling the page', async () => { + const unmount = await mount({ fixedArea: 'neurotech' }) + try { + const frame = document.querySelector('[data-chart-viewport]') + const deck = document.querySelector('[data-chart-deck="performance_curves"]') + const cover = deck.querySelector('[data-instrument]') + const last = deck.querySelector('[data-chart-target="neural-recording-hours"]') + frame.getBoundingClientRect = () => ({ left: 16, right: 359 }) + cover.getBoundingClientRect = () => ({ left: 29 - frame.scrollLeft, right: 276 - frame.scrollLeft }) + frame.scrollTo = ({ left }) => { frame.scrollLeft = left } + for (const method of ['Escape', 'close', 'outside']) { + await act(() => { cover.blur(); cover.focus() }) + frame.scrollLeft = 548 + await act(() => last.focus({ preventScroll: true })) + if (method === 'Escape') await act(() => last.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }))) + else if (method === 'close') await click(deck.querySelector('[aria-label="Collapse Performance curves previews"]')) + else await pointer(document.body, 'pointerdown') + assert.equal(deck.dataset.expanded, 'false') + assert.equal(document.activeElement, cover) + assert.equal(frame.scrollLeft, 13, `${method} reveals the focused cover horizontally`) + assert.equal(window.scrollY, 0, 'the document does not move') + } + } finally { await unmount() } +}) + +test('all four tabs spread real sibling slots horizontally and displace/dim only the neighboring decks', async () => { + const unmount = await mount() + try { + for (const area of areas) { + await click([...document.querySelectorAll('[role="tab"]')].find(tab => tab.textContent.includes(area.label))) + const row = document.querySelector('.instrument-previews') + const decks = [...row.querySelectorAll('[data-chart-deck]')] + assert.equal(decks.length, 5) + for (const [index, deck] of decks.entries()) { + const cover = deck.querySelector('[data-instrument]') + const count = Number(cover.dataset.viewCount) + if (count < 2) continue + await pointer(cover, 'pointerover', document.body) + assert.equal(row.dataset.activeGroup, cover.dataset.instrument, 'the shared row owns its active set') + assert.equal(row.style.getPropertyValue('--gallery-slots'), String(decks.length + count - 1)) + for (const [peerIndex, peer] of decks.entries()) { + assert.equal(peer.dataset.expanded, String(peer === deck)) + assert.equal(peer.dataset.subdued, String(peer !== deck)) + assert.equal(peer.style.getPropertyValue('--deck-shift'), String(peerIndex > index ? count - 1 : 0)) + } + const fan = deck.querySelector('[data-chart-fan]') + assert.equal(fan.style.getPropertyValue('--fan-count'), String(count)) + const targets = [...fan.querySelectorAll('[data-chart-target]')] + assert.equal(targets.length, count) + for (const [viewIndex, target] of targets.entries()) { + assert.equal(target.hidden, false, 'every sibling stays in the horizontal scroll inventory') + assert.equal(target.style.getPropertyValue('--fan-index'), String(viewIndex)) + assert.ok(target.querySelector('[data-preview-title]').textContent.trim()) + assert.ok(target.getAttribute('href').startsWith(`#fv/${area.key}/${cover.dataset.instrument}/`)) + } + await pointer(cover, 'pointerout', fan) + await pointer(fan, 'pointerover', cover) + assert.equal(deck.dataset.expanded, 'true', 'the fan is one continuous hover region including card gaps') + await pointer(fan, 'pointerout', document.body) + assert.equal(row.dataset.activeGroup, undefined) + assert.equal(row.style.getPropertyValue('--gallery-slots'), '5') + } + } + // jsdom does not lay out CSS: these pin the actual reference motion/geometry + // contract; browser QA must still measure intermediate positions and overflow. + assert.match(rule('.chart-deck'), /transform: translateX\(calc\(var\(--deck-shift, 0\) \* var\(--gallery-step\)\)\)/) + assert.match(rule('.chart-deck[data-expanded="true"] .chart-fan-card'), /transform: translateX\(calc\(var\(--fan-index\) \* var\(--gallery-step\)\)\)/) + assert.doesNotMatch(rule('.chart-fan-card'), /rotate\(|translateY\(|scale\(/) + assert.match(rule('.chart-deck[data-subdued="true"]::after'), /pointer-events: none/) + assert.match(rule('.chart-deck[data-expanded="true"] .chart-fan::after'), /width: 1px/) + assert.match(rule('.chart-deck[data-expanded="true"] > .instrument-preview'), /pointer-events: none/) + } finally { await unmount() } +}) diff --git a/src/app/globals.css b/src/app/globals.css index 11eb25fd..2638c47d 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -112,16 +112,37 @@ .field-velocity-content { container: velocity / inline-size; } .field-velocity-dashboard { display: block; } -/* Compact covers expand into individually selectable, real chart previews. */ -.instrument-previews { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: .875rem; - padding: .75rem 1.25rem 1.25rem .75rem; +/* Shared PL Neuro sideways interaction, retaining PLRD's surfaces and copy. + Only this local viewport scrolls; covers and their siblings share one size. */ +.instrument-preview-viewport { + position: relative; + container-type: inline-size; + min-width: 0; + max-width: 100%; + overflow-x: auto; + overflow-y: hidden; + overscroll-behavior-x: contain; + scrollbar-width: thin; + scrollbar-color: var(--color-gray-400) transparent; + --gallery-gap: 1.5rem; + --gallery-card-height: 304px; + /* Space for at-rest back sheets and non-overlay horizontal scrollbars. */ + height: calc(var(--gallery-card-height) + 3rem); + padding: .5rem .75rem 1.25rem; border: 1px solid var(--color-gray-300); border-radius: 1rem; background: var(--color-gray-100); } +.instrument-previews { + --gallery-card-width: min(300px, 78cqw); + --gallery-step: calc(var(--gallery-card-width) + var(--gallery-gap)); + position: relative; + display: flex; + flex-wrap: nowrap; + gap: var(--gallery-gap); + width: calc(var(--gallery-slots) * var(--gallery-step) - var(--gallery-gap)); + height: var(--gallery-card-height); +} .instrument-preview { position: relative; width: 100%; @@ -179,42 +200,69 @@ font-weight: 600; } .instrument-preview-plot svg { width: 100%; height: 2.75rem; } -@container velocity (min-width: 40rem) { .instrument-previews { grid-template-columns: repeat(3, minmax(0, 1fr)); } } -@container velocity (min-width: 66rem) { .instrument-previews { grid-template-columns: repeat(5, minmax(0, 1fr)); } } -/* Absolutely positioned spread: the chart inventory never reflows the page. - One continuous hit region includes headings, gaps and all three targets. */ -.chart-deck { position: relative; min-width: 0; } -.chart-deck[data-expanded="true"] { z-index: 30; } +@container velocity (min-width: 40rem) { .instrument-previews { --gallery-card-width: calc((100cqw - 3rem) / 3); } } +@container velocity (min-width: 66rem) { .instrument-previews { --gallery-card-width: calc((100cqw - 6rem) / 5); } } +/* Siblings begin underneath the cover and slide along the same horizontal step + that displaces peers. The fan's full-width hit region includes every gap. */ +.chart-deck { + position: relative; + flex: 0 0 var(--gallery-card-width); + width: var(--gallery-card-width); + height: var(--gallery-card-height); + min-width: 0; + transform: translateX(calc(var(--deck-shift, 0) * var(--gallery-step))); + transition: transform 420ms cubic-bezier(.22, 1, .36, 1); +} +.chart-deck[data-expanded="true"] { z-index: 8; } +.chart-deck[data-subdued="true"]::after { + content: ""; + position: absolute; + inset: 0; + z-index: 5; + border-radius: .75rem; + background: color-mix(in srgb, #fff 60%, transparent); + pointer-events: none; +} +html.dark .chart-deck[data-subdued="true"]::after { + background: color-mix(in srgb, var(--color-gray-500) 30%, transparent); +} +.chart-deck > .instrument-preview { z-index: 4; transition: opacity 160ms ease; } +.chart-deck .instrument-preview-face { min-height: 0; } +.chart-deck[data-expanded="true"] > .instrument-preview { opacity: 0; pointer-events: none; } .chart-fan { position: absolute; - top: var(--fan-top); - left: var(--fan-left); - width: var(--fan-width); - max-width: calc(100vw - 24px); - max-height: min(72dvh, 36rem); - overflow-y: auto; - overscroll-behavior: contain; - scrollbar-gutter: stable; - padding: .75rem; - border: 1px solid var(--color-blue); - border-radius: 1rem; - background: var(--color-gray-200); - box-shadow: 0 16px 40px #0003; - visibility: hidden; + z-index: 1; + inset: 0 auto 0 0; + width: 100%; + height: 100%; pointer-events: none; - opacity: 0; - transition: opacity 180ms ease, visibility 180ms; -} -.chart-deck[data-expanded="true"] .chart-fan { visibility: visible; pointer-events: auto; opacity: 1; } -.chart-fan-heading { display: flex; align-items: center; justify-content: space-between; gap: .5rem; margin-bottom: .5rem; font-size: .875rem; color: var(--color-black); } -.chart-fan-heading button, .chart-fan-pages button { min-height: 2.75rem; padding: .4rem; font-size: .75rem; color: var(--color-blue); cursor: pointer; } -.chart-fan-cards { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: .75rem; } -.chart-fan-cards[data-count="1"] { grid-template-columns: minmax(0, 1fr); } -.chart-fan-cards[data-count="2"] { grid-template-columns: repeat(2, minmax(0, 1fr)); } +} +.chart-deck[data-expanded="true"] .chart-fan { + width: calc(var(--fan-count) * var(--gallery-step) - var(--gallery-gap)); + pointer-events: auto; +} +.chart-deck[data-expanded="true"] .chart-fan::after { + content: ""; + position: absolute; + left: calc(100% + var(--gallery-gap) / 2); + top: .5rem; + bottom: .5rem; + width: 1px; + background: color-mix(in srgb, var(--color-blue) 50%, transparent); +} +.chart-fan-heading { position: absolute; left: calc(var(--gallery-card-width) - 3rem); top: .25rem; z-index: calc(var(--fan-count) + 1); visibility: hidden; } +.chart-deck[data-expanded="true"] .chart-fan-heading { visibility: visible; } +.chart-fan-heading button { width: 44px; height: 44px; font-size: 1.4rem; color: var(--color-blue); cursor: pointer; } +.chart-fan-cards { height: 100%; gap: var(--gallery-gap); } .chart-fan-card { + position: absolute; + left: 0; + top: 0; display: flex; flex-direction: column; gap: .65rem; + width: var(--gallery-card-width); + height: 100%; min-width: 0; padding: .875rem; border: 1px solid color-mix(in srgb, var(--color-blue) 35%, transparent); @@ -223,33 +271,26 @@ color: var(--color-black); text-align: left; overflow-wrap: anywhere; - transform: translateX(calc(var(--fan-index) * -70%)) rotate(calc(var(--fan-index) * -5deg)) scale(.9); - transform-origin: 50% 90%; - transition: transform 360ms cubic-bezier(.22,.68,0,1.05), border-color 180ms, box-shadow 180ms; + z-index: calc(var(--fan-count) - var(--fan-index)); + transform: translateX(calc(var(--fan-index) * 4px)); + transition: transform 420ms cubic-bezier(.22, 1, .36, 1), border-color 180ms, box-shadow 180ms; } -.chart-fan-card[hidden] { display: none; } -.chart-deck[data-expanded="true"] .chart-fan-card { transform: none; } +.chart-deck[data-expanded="true"] .chart-fan-card { transform: translateX(calc(var(--fan-index) * var(--gallery-step))); } .chart-fan-card:is(:hover, :focus-visible) { border-color: var(--color-blue); box-shadow: 0 5px 15px #1982f428; } -.chart-fan-card [data-preview-title] { font-size: .9rem; font-weight: 600; line-height: 1.35; } +.chart-fan-group { min-height: 2rem; font-size: .65rem; line-height: 1.4; color: var(--color-gray-600); } +.chart-fan-card:first-child .chart-fan-group { padding-right: 2rem; } +.chart-fan-card [data-preview-title] { font-size: .85rem; font-weight: 600; line-height: 1.4; } .chart-fan-plot { display: flex; flex-direction: column; gap: .5rem; margin-top: auto; min-width: 0; } .chart-fan-plot > span { display: block; width: 100%; } -.chart-fan-plot svg { display: block; width: 100%; height: 7rem; } +.chart-fan-plot svg { display: block; width: 100%; height: 5.375rem; } .chart-fan-plot meter { width: 100%; height: 2rem; } .chart-preview-caption { font-size: .6875rem; line-height: 1.4; color: var(--color-gray-600); } -.chart-preview-reading { padding-block: 1rem; font-size: 1.25rem; } -.chart-fan-pages { display: flex; justify-content: space-between; align-items: center; font-size: .75rem; } -.chart-fan-pages button:disabled { color: var(--color-gray-500); opacity: .5; cursor: default; } +.chart-preview-reading { padding-block: .5rem; font-size: 1.1rem; } .chart-share-controls { display: flex; flex-wrap: wrap; gap: .75rem; margin-bottom: .75rem; } .chart-share-controls :is(a, button) { padding: .5rem .65rem; min-height: 2.75rem; color: var(--color-blue); background: var(--color-gray-200); border-radius: .5rem; font-size: .8rem; cursor: pointer; } -.chart-deck :is(a, button):focus-visible { outline: 2px solid var(--color-blue); outline-offset: 2px; } -@media (max-width: 639px) { - .chart-fan-cards, .chart-fan-cards[data-count="2"] { grid-template-columns: minmax(0, 1fr); } - .chart-fan-card { gap: .35rem; padding: .75rem; } - .chart-fan-plot svg { height: 5rem; } -} +.chart-deck :is(a, button):focus-visible { outline: 2px solid var(--color-blue); outline-offset: -3px; border-radius: .75rem; } @media (prefers-reduced-motion: reduce) { - .chart-fan, .chart-fan-card { transition: none; } - .chart-fan-card { transform: none; } + .chart-deck, .chart-deck > .instrument-preview, .chart-fan-card { transition: none; } } .instrument-gallery-backdrop { position: fixed; diff --git a/src/components/ImpactDashboardV2.tsx b/src/components/ImpactDashboardV2.tsx index bae648a3..6178abdf 100644 --- a/src/components/ImpactDashboardV2.tsx +++ b/src/components/ImpactDashboardV2.tsx @@ -95,6 +95,12 @@ export default function ImpactDashboardV2({ const [active, setActive] = useState