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
Binary file added docs/screenshots/sideways/after-chart-320.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/sideways/after-slide-mobile.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
42 changes: 42 additions & 0 deletions docs/sideways-chart-previews.md
Original file line number Diff line number Diff line change
@@ -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.

![Light mode: inactive peers fade toward white](screenshots/sideways/after-white-peers-desktop.png)
![Mobile slide frame, unchanged](screenshots/sideways/after-white-peers-mobile.png)

## 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.
71 changes: 37 additions & 34 deletions scripts/velocity/chart-selection-ui.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -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]'))
Expand All @@ -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]')
Expand All @@ -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() }
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions scripts/velocity/contact-sheet.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})

Expand Down
34 changes: 21 additions & 13 deletions scripts/velocity/instrument-gallery-ui.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand Down Expand Up @@ -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"]')
Expand Down Expand Up @@ -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]')) {
Expand Down Expand Up @@ -292,22 +292,30 @@ 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}` }]))
const unmount = await mount({ ...data, marketSignals, initialArea: 'ai-robotics' })
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() }
})

Expand Down
Loading