feat(frontend): keep layout invariants correct while scrolling #1316

Open
opened 2026-09-22 05:07:37 +00:00 by PlasticDigits · 2 comments

Summary

One product surface: layout invariants that must stay true while the user scrolls the CL8Y DEX dApp.

Bundle (do not split):

  1. Portaled pickers — an open MenuSelect / TokenSelect / TokenSearchSelect / pair search listbox stays anchored to its trigger when the document or an inner scrollport moves.
  2. Sticky shell — scrolled page copy does not bleed through .app-top-sticky, and the Trade heading still clears the header.
  3. Trade ticket — the money CTA stays docked to the ticket card while trade-order-ticket-scroll moves; it does not float over visible Pay / Receive / Expiry / Advanced.
  4. Chart time scale — a user zoom or scroll on the price chart is preserved across the 30s candle refresh. This pass must not reintroduce fitContent() on routine updates.

Related closed work stays closed: #181, #336, #482, #500, #527, #632, #705. Those tickets fixed individual glitches. None of them is a regression that the invariants still hold together after scroll. Do not reopen them.

Current codebase

Scroll-linked rules already live in docs/frontend.md:

  • Sticky header clearance. .app-top-sticky in frontend-dapp/src/components/common/Layout.tsx uses an opaque --bg-0 so scrolled copy cannot show through. Trade H1 clears the header by about 16px at scrollY = 0.
  • Portal listbox. usePortalListbox reads getBoundingClientRect() during render and bumps a reducer on window scroll (capture), resize, and visualViewport scroll/resize. computePortalListboxStyle is a pure fixed-position function. Viewport insets (tab bar, in-app browser, finger gap) come from readPortalListboxViewport (#632).
  • Trade ticket dock. Money CTAs are a flex shrink-0 footer, sibling of trade-order-ticket-scroll, not position: sticky (T527-1, T527-5). Overlap checks must clip to the visible scrollport.
  • Chart. priceChartLightweightSeriesSync.ts uses series.update() for the live bar. timeScale().fitContent() is only for mount, indicator toggle, and interval switch (#336 / #705).

What is missing is a single check that these four rules still hold after scroll, plus a fix for whichever of them fails that check. The capture-phase scroll listener on the portal hook is not itself the bug to rewrite.

Why this is needed

Chrome changes keep landing on the same surfaces (header stack, ticket footer, portaled menus, chart timescale) and each time a scroll invariant regresses in isolation. A user who scrolls /trade or / with a picker open, or who has zoomed a chart, should keep the same alignment the docs already promise. Folding that into one pass avoids another round of one-off sticky/scroll tickets.

This is frontend layout only. No pair, router, factory, indexer, or wallet-signing change.

Constraints

  • Keep T527-5: do not put the ticket money CTA back on position: sticky, position: fixed, or a document portal.
  • Keep #705: do not call fitContent() on a 30s candle refresh or a sliding newest-N window at the same interval.
  • Keep #632 viewport math: size menus from visualViewport plus DEX / in-app insets, not innerHeight alone. Coarse/narrow browse still must not focus a text field.
  • Keep #498 leading-logo padding: do not toggle logo or trigger padding on open.
  • html { scrollbar-gutter: stable } stays.
  • One chrome layer per region (#653). No new nested shell-panel / card-glass.
  • Do not change swap math, limit matching, tax, or signing.

Relevant files

  • frontend-dapp/src/components/ui/PortalListbox.tsx
  • frontend-dapp/src/components/ui/portalListboxPosition.ts
  • frontend-dapp/src/lib/portalListboxViewport.ts
  • frontend-dapp/src/components/common/Layout.tsx
  • frontend-dapp/src/index.css (.app-top-sticky, ticket scroll/footer)
  • frontend-dapp/src/components/charts/priceChartLightweightSeriesSync.ts
  • docs/frontend.md (portal listbox, sticky header, T527, chart time scale)
  • Existing tests: portalListboxPosition.test.ts, e2e/trade-pair-select-cls.spec.ts, e2e/swap-token-select-viewport.spec.ts, e2e/trade-page-responsive.spec.ts

Touch the chart sync file only if a scroll-invariant test shows fitContent() on refresh. Prefer not to widen the diff into quote, route, or wallet modules.

  1. Add a focused regression that scrolls the document and trade-order-ticket-scroll while a portaled listbox is open, and asserts the menu’s top/left track the trigger (tolerance a few pixels) and stay inside the visible band above the mobile tab bar.
  2. Assert sticky-header opacity/clearance after scrollY > 0 on a long route, and T527-1 after ticket-body scroll at ~1280×720 Chromium.
  3. Assert a non-default chart visible range survives one candle refetch.
  4. Fix only the invariant that fails. If the portal capture listener already tracks inner scroll, leave that listener in place and document the scroll case next to the existing Fixed-menu row in docs/frontend.md.
  5. Ship one MR. Do not split header vs ticket vs chart into sibling issues.

Acceptance criteria

Given /trade or / at about 1280×720 and about 375×667, with a portaled token or pair listbox open
When the user scrolls the document or the nearest inner scrollport that contains the trigger
Then the listbox’s fixed top/left follow the trigger’s updated getBoundingClientRect(), the menu stays inside the visible viewport band, and it does not cover the sticky header or the mobile tab bar.

Given /trade Limit ticket at scroll-top on Chromium, about 1280×720
When the user scrolls trade-order-ticket-scroll
Then the money CTA bottom stays within 8px of trade-order-ticket-card bottom, and it does not overlap controls that are actually visible inside the scrollport.

Given a price chart whose time scale the user has zoomed or scrolled away from fitContent()
When the 30s candle refresh runs at the same interval
Then the visible logical range is unchanged, and historical bars are not rewritten solely because of that refresh.

Given a long route with scrollY > 0
When page copy passes under the sticky header
Then that copy is not readable through .app-top-sticky, and the header controls remain reachable.

Test plan

  • Unit: extend portalListboxPosition.test.ts only if the pure geometry needs a new case (anchor moved, insets unchanged). Do not assert DOM listeners in that file.
  • Playwright (PLAYWRIGHT_SKIP_CHAIN=1, 5 workers), alongside the existing trade/swap scroll specs: open a listbox, scroll the ancestor, compare trigger vs menu boxes; ticket scroll vs docked CTA; one chart range snapshot across a refetch. No chain, no broadcast.
  • docs/frontend.md gains an explicit “after scroll” sentence on the Fixed-menu row and points at the new spec. Do not restate closed issues as open work.

Verification criteria

  • The four Given/When/Then cases pass locally on Chromium at the two viewports above.
  • make target or the existing Vitest files for portal position and chart series sync stay green.
  • Diff does not include contract, indexer, router, or wallet-session files.
  • Closed issues #336, #482, #500, #527, #632, and #705 are not reopened.

First-pass model recommendation

Recommendation: grok-high

Rationale: The failing criterion is scope. Scroll behavior crosses the portal listbox, sticky shell, trade-ticket scrollport, and chart time scale — more than one subsystem, and more than three production files if more than the portal hook is wrong. There is no single known local edit yet; the first pass has to measure which documented invariant breaks and leave the others alone. Chart fitContent() is easy to regress while fixing layout. Verify with the new Playwright scroll spec plus the existing portal and series-sync unit tests.

## Summary One product surface: **layout invariants that must stay true while the user scrolls** the CL8Y DEX dApp. Bundle (do not split): 1. **Portaled pickers** — an open `MenuSelect` / `TokenSelect` / `TokenSearchSelect` / pair search listbox stays anchored to its trigger when the document or an inner scrollport moves. 2. **Sticky shell** — scrolled page copy does not bleed through `.app-top-sticky`, and the Trade heading still clears the header. 3. **Trade ticket** — the money CTA stays docked to the ticket card while `trade-order-ticket-scroll` moves; it does not float over visible Pay / Receive / Expiry / Advanced. 4. **Chart time scale** — a user zoom or scroll on the price chart is preserved across the 30s candle refresh. This pass must not reintroduce `fitContent()` on routine updates. Related closed work stays closed: [#181](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/181), [#336](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/336), [#482](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/482), [#500](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/500), [#527](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/527), [#632](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/632), [#705](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/705). Those tickets fixed individual glitches. None of them is a regression that the invariants still hold together after scroll. Do not reopen them. ## Current codebase Scroll-linked rules already live in [`docs/frontend.md`](docs/frontend.md): - **Sticky header clearance.** `.app-top-sticky` in [`frontend-dapp/src/components/common/Layout.tsx`](frontend-dapp/src/components/common/Layout.tsx) uses an opaque `--bg-0` so scrolled copy cannot show through. Trade H1 clears the header by about 16px at `scrollY = 0`. - **Portal listbox.** [`usePortalListbox`](frontend-dapp/src/components/ui/PortalListbox.tsx) reads `getBoundingClientRect()` during render and bumps a reducer on `window` scroll (capture), `resize`, and `visualViewport` scroll/resize. [`computePortalListboxStyle`](frontend-dapp/src/components/ui/portalListboxPosition.ts) is a pure fixed-position function. Viewport insets (tab bar, in-app browser, finger gap) come from [`readPortalListboxViewport`](frontend-dapp/src/lib/portalListboxViewport.ts) (#632). - **Trade ticket dock.** Money CTAs are a flex `shrink-0` footer, sibling of `trade-order-ticket-scroll`, not `position: sticky` (**T527-1**, **T527-5**). Overlap checks must clip to the visible scrollport. - **Chart.** [`priceChartLightweightSeriesSync.ts`](frontend-dapp/src/components/charts/priceChartLightweightSeriesSync.ts) uses `series.update()` for the live bar. `timeScale().fitContent()` is only for mount, indicator toggle, and interval switch (#336 / #705). What is missing is a single check that these four rules still hold **after** scroll, plus a fix for whichever of them fails that check. The capture-phase scroll listener on the portal hook is not itself the bug to rewrite. ## Why this is needed Chrome changes keep landing on the same surfaces (header stack, ticket footer, portaled menus, chart timescale) and each time a scroll invariant regresses in isolation. A user who scrolls `/trade` or `/` with a picker open, or who has zoomed a chart, should keep the same alignment the docs already promise. Folding that into one pass avoids another round of one-off sticky/scroll tickets. This is frontend layout only. No pair, router, factory, indexer, or wallet-signing change. ## Constraints - Keep **T527-5**: do not put the ticket money CTA back on `position: sticky`, `position: fixed`, or a document portal. - Keep **#705**: do not call `fitContent()` on a 30s candle refresh or a sliding newest-N window at the same interval. - Keep **#632** viewport math: size menus from `visualViewport` plus DEX / in-app insets, not `innerHeight` alone. Coarse/narrow browse still must not focus a text field. - Keep **#498** leading-logo padding: do not toggle logo or trigger padding on `open`. - `html { scrollbar-gutter: stable }` stays. - One chrome layer per region (#653). No new nested `shell-panel` / `card-glass`. - Do not change swap math, limit matching, tax, or signing. ## Relevant files - `frontend-dapp/src/components/ui/PortalListbox.tsx` - `frontend-dapp/src/components/ui/portalListboxPosition.ts` - `frontend-dapp/src/lib/portalListboxViewport.ts` - `frontend-dapp/src/components/common/Layout.tsx` - `frontend-dapp/src/index.css` (`.app-top-sticky`, ticket scroll/footer) - `frontend-dapp/src/components/charts/priceChartLightweightSeriesSync.ts` - `docs/frontend.md` (portal listbox, sticky header, T527, chart time scale) - Existing tests: `portalListboxPosition.test.ts`, `e2e/trade-pair-select-cls.spec.ts`, `e2e/swap-token-select-viewport.spec.ts`, `e2e/trade-page-responsive.spec.ts` Touch the chart sync file only if a scroll-invariant test shows `fitContent()` on refresh. Prefer not to widen the diff into quote, route, or wallet modules. ## Recommended direction 1. Add a focused regression that scrolls the document and `trade-order-ticket-scroll` while a portaled listbox is open, and asserts the menu’s top/left track the trigger (tolerance a few pixels) and stay inside the visible band above the mobile tab bar. 2. Assert sticky-header opacity/clearance after `scrollY > 0` on a long route, and **T527-1** after ticket-body scroll at ~1280×720 Chromium. 3. Assert a non-default chart visible range survives one candle refetch. 4. Fix only the invariant that fails. If the portal capture listener already tracks inner scroll, leave that listener in place and document the scroll case next to the existing Fixed-menu row in `docs/frontend.md`. 5. Ship one MR. Do not split header vs ticket vs chart into sibling issues. ## Acceptance criteria **Given** `/trade` or `/` at about 1280×720 and about 375×667, with a portaled token or pair listbox open **When** the user scrolls the document or the nearest inner scrollport that contains the trigger **Then** the listbox’s fixed top/left follow the trigger’s updated `getBoundingClientRect()`, the menu stays inside the visible viewport band, and it does not cover the sticky header or the mobile tab bar. **Given** `/trade` Limit ticket at scroll-top on Chromium, about 1280×720 **When** the user scrolls `trade-order-ticket-scroll` **Then** the money CTA bottom stays within 8px of `trade-order-ticket-card` bottom, and it does not overlap controls that are actually visible inside the scrollport. **Given** a price chart whose time scale the user has zoomed or scrolled away from `fitContent()` **When** the 30s candle refresh runs at the same interval **Then** the visible logical range is unchanged, and historical bars are not rewritten solely because of that refresh. **Given** a long route with `scrollY > 0` **When** page copy passes under the sticky header **Then** that copy is not readable through `.app-top-sticky`, and the header controls remain reachable. ## Test plan - Unit: extend `portalListboxPosition.test.ts` only if the pure geometry needs a new case (anchor moved, insets unchanged). Do not assert DOM listeners in that file. - Playwright (`PLAYWRIGHT_SKIP_CHAIN=1`, 5 workers), alongside the existing trade/swap scroll specs: open a listbox, scroll the ancestor, compare trigger vs menu boxes; ticket scroll vs docked CTA; one chart range snapshot across a refetch. No chain, no broadcast. - `docs/frontend.md` gains an explicit “after scroll” sentence on the Fixed-menu row and points at the new spec. Do not restate closed issues as open work. ## Verification criteria - The four Given/When/Then cases pass locally on Chromium at the two viewports above. - `make` target or the existing Vitest files for portal position and chart series sync stay green. - Diff does not include contract, indexer, router, or wallet-session files. - Closed issues #336, #482, #500, #527, #632, and #705 are not reopened. ## First-pass model recommendation Recommendation: grok-high Rationale: The failing criterion is scope. Scroll behavior crosses the portal listbox, sticky shell, trade-ticket scrollport, and chart time scale — more than one subsystem, and more than three production files if more than the portal hook is wrong. There is no single known local edit yet; the first pass has to measure which documented invariant breaks and leave the others alone. Chart `fitContent()` is easy to regress while fixing layout. Verify with the new Playwright scroll spec plus the existing portal and series-sync unit tests.
Author
Owner

cl8y-agent-control: queued design_author job 32e4f7a0-7156-4047-960d-8537ec6c8731 (not executed; no Hetzner VM).

cl8y-agent-control: queued `design_author` job `32e4f7a0-7156-4047-960d-8537ec6c8731` (not executed; no Hetzner VM).
Author
Owner

cl8y-agent-control: needs_human inbox card POST failed. Job stays parked.

cl8y-agent-control: needs_human inbox card POST failed. Job stays parked.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
code/cl8y-dex-terraclassic#1316
No description provided.