mirror of
https://github.com/block/buzz.git
synced 2026-11-06 09:21:45 +00:00
Scrolling UP a fully-loaded channel was subtly jumpy while scrolling down was smooth. Root cause (Eva's H1): rows above the opening viewport have never painted, so they sit at their `contain-intrinsic-size` reserve under `content-visibility: auto`. Scrolling up, each row realizes at its true height the instant it enters the realization band; the delta (true - reserve) shifts content above the reading row. On shipped WKWebView there is no `overflow-anchor` to correct it, so every realization lands raw on the reading position — one lurch per row. The fix makes the conversation scroller own its reading position through a single same-frame compensation writer: - Snapshot the reading anchor (first fully-visible row + its top offset) on every scroll event, before the ResizeObserver fires. The RO runs after layout and before paint, so the "before" position can only come from the last scroll event — never from inside the RO callback. - Observe each `.timeline-row-cv` row (CV realization is a resize of the row's own box and does not surface as an ancestor resize in Blink), seeded at observe time with the browser's current layout height read from `contain-intrinsic-block-size` — the reserve for an unpainted row. Seeding from the reserve is what turns the first realization into a measurable `realized - reserve` delta instead of an unmeasurable first sighting. - On a real height change, re-pin the reading anchor to its saved offset with a same-frame `scrollBy(drift)`. A single measured correction: the layout engine already summed the above-anchor deltas into the row's top, and rows resizing below the anchor don't move it, so they're excluded for free. Re-baseline after the correction. - Force `overflow-anchor: none` on the scroller so the writer is the single scroll authority on every engine — Chromium's native anchoring can't double-correct behind the writer's back, and WKWebView has none anyway. This also aligns CI (Chromium) with macOS (WKWebView), which ran different scroll contracts — how the jitter shipped unseen. Verified against the committed T1.1 gate (upscroll-jitter.perf.ts) on jitter-corpus cold-load, sync fixed-step actuation, overflow-anchor forced off: RED without this writer (peak-dev 46 / rms 23), GREEN with it (peak-dev 0.00 / rms 0.00, reproducible), and the non-gating wheel felt-mode pass also 0.00/0.00. The non-gating estimate-error R diagnostic stays at 75 (T3's lane; this writer compensates motion, it does not shrink the reserve error). Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
117 lines
3.9 KiB
CSS
117 lines
3.9 KiB
CSS
@layer utilities {
|
|
/*
|
|
* Skips rendering (layout, paint) for offscreen rows while keeping them in
|
|
* the DOM — unlike windowing, in-DOM state (open `<details>`, drag-and-drop,
|
|
* deep-link `querySelector`) survives. The intrinsic size is a height
|
|
* placeholder so the scrollbar stays stable before a row is first rendered.
|
|
*/
|
|
.content-visibility-auto {
|
|
content-visibility: auto;
|
|
contain-intrinsic-size: auto 200px;
|
|
}
|
|
|
|
/* The conversation scroller owns its reading position through the JS
|
|
compensation writer in `useAnchoredScroll` (same-frame `scrollBy` on
|
|
realization/reflow deltas). Native scroll anchoring must be OFF so it
|
|
can't double-correct the same delta behind the writer's back: the shipped
|
|
WKWebView has no `overflow-anchor` at all, while Chromium does — leaving it
|
|
on means CI (Chromium) and macOS users (WKWebView) run different scroll
|
|
contracts, which is how the jitter shipped unseen. Forcing it off makes the
|
|
writer the single scroll authority on every engine. See L-A/L-C findings. */
|
|
[data-buzz-conversation-scroll] {
|
|
overflow-anchor: none;
|
|
}
|
|
|
|
/* Timeline rows re-pin by measured offset on prepend; the 200px fallback
|
|
over-reserves the common row and shifts that anchor, so reserve low. */
|
|
.timeline-row-cv {
|
|
content-visibility: auto;
|
|
contain-intrinsic-size: auto 60px;
|
|
}
|
|
|
|
/* paint containment clips the action bar's upward bleed; un-pause the active row */
|
|
.timeline-row-cv:is(:hover, :focus-within) {
|
|
content-visibility: visible;
|
|
}
|
|
|
|
.content-visibility-auto-interactive {
|
|
content-visibility: auto;
|
|
contain-intrinsic-size: auto 200px;
|
|
}
|
|
|
|
.content-visibility-auto-interactive:is(:hover, :focus-within) {
|
|
content-visibility: visible;
|
|
}
|
|
|
|
/* Compact variant for short single-line rows (sidebar channels, etc). */
|
|
.content-visibility-auto-row {
|
|
content-visibility: auto;
|
|
contain-intrinsic-size: auto 2rem;
|
|
}
|
|
|
|
.buzz-huddle-tooltip {
|
|
--primary: var(
|
|
--huddle-tooltip-surface,
|
|
var(--huddle-control-surface, 0 0% 20%)
|
|
);
|
|
--primary-foreground: var(
|
|
--huddle-tooltip-foreground,
|
|
var(--huddle-control-foreground, 0 0% 98%)
|
|
);
|
|
|
|
background: hsl(var(--primary));
|
|
border: 1px solid
|
|
hsl(
|
|
var(
|
|
--huddle-control-chevron-hover-surface,
|
|
var(--huddle-control-chevron-surface, 0 0% 22%)
|
|
) /
|
|
0.72
|
|
);
|
|
box-shadow: 0 10px 22px rgb(0 0 0 / 35%);
|
|
color: hsl(var(--primary-foreground));
|
|
}
|
|
|
|
.buzz-huddle-drawer .buzz-huddle-control-button,
|
|
.buzz-huddle-drawer .buzz-huddle-split-main {
|
|
background: hsl(var(--huddle-control-surface, var(--secondary)));
|
|
}
|
|
|
|
.buzz-huddle-drawer .buzz-huddle-control-button:hover,
|
|
.buzz-huddle-drawer .buzz-huddle-split-main:hover {
|
|
background: hsl(
|
|
var(
|
|
--huddle-control-hover-surface,
|
|
var(--huddle-control-surface, var(--secondary))
|
|
)
|
|
);
|
|
}
|
|
|
|
.buzz-huddle-drawer .buzz-huddle-split-chevron {
|
|
background: hsl(var(--huddle-control-chevron-surface, var(--secondary)));
|
|
}
|
|
|
|
.buzz-huddle-drawer .buzz-huddle-split-chevron:hover {
|
|
background: hsl(
|
|
var(
|
|
--huddle-control-chevron-hover-surface,
|
|
var(--huddle-control-chevron-surface, var(--secondary))
|
|
)
|
|
);
|
|
}
|
|
|
|
/*
|
|
* Clamp HDR (gain-map) images to the standard SDR range. Some uploaded
|
|
* avatars carry HDR gain maps, which capable displays render brighter than
|
|
* SDR white — making the avatar glow relative to the rest of the UI. This
|
|
* pins their luminance to the SDR envelope so they sit at the same level as
|
|
* everything else. `dynamic-range-limit` is the purpose-built CSS property
|
|
* (Chromium 133+, WebKit/Safari 26+); on engines that don't support it the
|
|
* declaration is simply ignored (avatars render as before), so it's a safe
|
|
* progressive enhancement.
|
|
*/
|
|
.avatar-sdr-clamp {
|
|
dynamic-range-limit: standard;
|
|
}
|
|
}
|