Motion & B8 performance: jank scoreboard, transition gating, immersive hero, nav-drawer perf - #4
Open
aa-hh wants to merge 29 commits into
Open
Motion & B8 performance: jank scoreboard, transition gating, immersive hero, nav-drawer perf#4aa-hh wants to merge 29 commits into
aa-hh wants to merge 29 commits into
Conversation
…oss-screen fade-through, caps-motion-rich tier) - Add Material 3 easing/duration tokens to :root; migrate the deprecated M2 curve cubic-bezier(0.4,0,0.2,1) + bare ease (sheets/drawers -> emphasized- decelerate, scrim/sidebar-width -> standard). - Staggered screen-enter reveal: home-feed rows rise+fade as they mount (homeScreen.js applyRowEnterStagger + .row-section--enter), capped <=240ms. - Cross-screen fade-through: fresh screen-host fades in (router.js), Back snaps. - New caps-motion-rich tier (webOS5+/sim/dev): focused media-card lift + elevation shadow the B8 skips. app.js applyMotionCapabilityClass emits both. - Registry: Motion foundation section (tokens, tiers, pattern map). Chrome53/webOS4-safe: transform/opacity only, no JS/rAF springs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- New src/perf/animationTiming.js: timeAnimation(el, label, data) times the ACTUAL animationend wall-clock duration (not the declared CSS duration), so B8 compositor/decode contention shows up as real elapsed time. Reports via both the in-memory perf-mark buffer and the remote tvLog sink. - router.js: times the cross-screen fade (anim:screen-enter-fade); adds screen:contentPainted (waits for in-DOM <img>s to finish loading, bounded 4s) to separate 'DOM built' from 'posters actually decoded'; mirrors route:render / screen:firstPaint to tvLog so they reach tv.log on-device. - homeScreen.js: times the home-feed row-stagger cascade's completion via the last row's animationend (anim:row-stagger-complete). - Registry: Motion instrumentation note. All self-gate on existing flags (?perf=1 / plax_perf_enabled + debug/logSink), so cost is near-zero when disabled. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
version-gate/motion-capability currently resolve major/osMajor=5 on hardware the engine diagnostic (from Plex client identity, a separate source) reports as webOS 4/Chrome 53 — same failure class as the historical caps-motion-gate-bug (firmware build number mistaken for webOS platform major). console.info is unreliable on-device, so mirror versionMajor/ platformVersionMajor/sdkVersion/version/firmwareVersion/platformVersion/ modelName to the tvLog sink to see exactly which field is wrong on next boot. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…tier Remote diagnostics (device-info-raw in tv.log) from a real 2018 LG B8 (modelName OLED55B8LLA) proved deviceInfo().versionMajor itself reports 5 on real hardware -- it mirrors LG's firmware build number (version:'05.50.70'), not the actual webOS platform major. strictWebosMajor() previously trusted versionMajor/platformVersionMajor exclusively, believing them distinct from firmware fields -- wrong, and it silently turned caps-motion-rich ON for a B8. - New src/platform/webosSdkVersion.js: dependency-free fetchSdkVersion + parseWebOSVersionMajor, shared by webos.js and versionGate.js without creating a circular module graph (moved out of webos.js, which versionGate would otherwise have had to import back into, closing a cycle through clientIdentity.js). - versionGate.js: fetches sdkVersion via the same authoritative luna getSystemInfo call webos.js already trusts for playback capability detection (bounded 1200ms timeout), and stamps device.sdkVersionMajor before resolving the gate. - app.js strictWebosMajor(): prefers device.sdkVersionMajor when present; falls back to the old versionMajor/platformVersionMajor scan only if the sdkVersion fetch truly failed (simulator/dev-browser edge cases). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…n stall Real-device measurement (anim:screen-enter-fade in tv.log) showed a B8 consistently taking 372-408ms wall-clock for a fade declared at 200ms — even on profile-picker (24ms build, 0 images), leaving ~150-200ms unaccounted for by build time + declared duration. That gap matches Chromium 53's cost to synchronously allocate a new compositor layer the first time an element's animation kicks in. - app.css: will-change: opacity on .screen-host--enter, pre-promoting the layer before the animation starts instead of during it. - router.js: strips the screen-host--enter class on animationend so a retained host (which can live the rest of the session) doesn't keep an unnecessary compositor layer promoted indefinitely. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…n + jank scoreboard
Real-B8 finding: the enter fade (declared 200ms) measured 372-1695ms wall-
clock, scaling with screen weight — the animations were compositor-fine; the
MAIN THREAD was congested by work each screen build kicked off alongside them
(poster/hero JPEG decode, JSON prefetch), which is also remote-input dead
time. Fix is scheduling, not styling: one thing at a time.
- New src/ui/transitionGate.js: beginTransition/endTransition/onIdle. Router
opens a 400ms protected window per fresh navigation (closed early on fade
animationend); homeScreen opens a 550ms window per row cascade (closed on
the last row's animationend).
- posterImages.js: single choke point in bindPosterImage defers ALL poster
binds to the window's close — placed after the already-bound/already-loading
guards (retained posters still reveal synchronously) and before any slot
bookkeeping (a deferred bind can never hold or leak a concurrency slot).
- detailScreen.js: ultrablur backdrop fetch+decode deferred behind the gate
(bgTok keeps the deferred run harmless after re-navigation).
- homeScreen.js: metadata/library prefetch deferred behind the cascade.
- Elevation for the B8 baseline: card focus now lifts translateY(-4px) and
casts a shadow pre-rastered ONCE on an empty ::after, with focus animating
only its OPACITY — never box-shadow (Chrome 53 re-rasters animated blur
every frame). Supersedes the rich-tier-only static lift-shadow.
- Instrumentation: animationTiming now splits startDelayMs (creation →
animationstart = congestion/input-dead-time proxy) from runMs; new
src/perf/frameJank.js samples rAF gaps for 1.5s per navigation and reports
{frames, dropped, frozen, worstMs, busyMs} as jank:navigation — the
scoreboard every future motion change gets judged against.
- Registry: Motion section updated (choreography, elevation pattern, jank
scoreboard); media-card focus spec updated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…reboard) First on-device scoreboard run showed the gate fixed WHEN, not HOW MUCH: home froze ~1.1s building 3 rows synchronously (3 rAF frames in 1.5s window, fade startDelayMs 1166) and detail hit a 1950ms worst gap after the gate closed. This round spreads the work itself: - homeScreen: render ONE row per macrotask — first row renders synchronously, the rest self-schedule through the existing append path (render-token aborts on refresh). Frames and remote input interleave between rows; the row-enter cascade now comes from real mount spacing. Every pass extends the transition window; only the final pass hooks the early close so an earlier row's animationend can't drain the poster queue mid-cascade. - detailScreen: hydrate episode thumbs 6-per-250ms instead of a whole season in one burst (decode is synchronous-with-raster on Chrome 53); guards abort on season switch. Breadcrumbs (detail:backdrop-swap, detail:episodes-appended) bracket the suspects on the tv.log timeline. - frameJank: worstEndedAgoMs places the worst gap's end on the log timeline for attribution against those breadcrumbs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Brings in the Android TV nav-drawer sizing/scrim/brand fixes (commits 5fb6694 + 6703222). Conflicts resolved by keeping our one-row-per-macrotask home render + pre-rastered ::after shadow while adding main's scrim CSS, improved modal timing (emphasized-decelerate/short4), and fuller Foundations section in the design registry. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The new Android-TV nav drawer animates `width` (a layout property) over
180ms and reveals 8 labels (display:none→block) on focusin — the one motion
in the app that deliberately breaks the transform/opacity-only rule. It was
uninstrumented, so we had no on-device frame data to confirm the "absolute
overlay = cheap reflow" assumption in the CSS holds on the B8.
Fire sampleFrames('jank:sidebar', {dir}) from syncExpanded on a real
expanded-state change (700ms window). Self-gates like every other sampler
(no-op unless perf/debug is on); a navigation right after supersedes it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
We measured focus-commit LATENCY (input:keydown/input:focusCommitted) but
not the SMOOTHNESS of the glide that follows — smoothScrollCarousel/Vertical
write scrollLeft/scrollTop every frame for 150ms (a per-frame layout on
Chrome 53), and whether that holds 60fps is the "does moving along the rail
feel smooth" question the latency marks can't answer.
Fire sampleFrames('jank:rail-scroll', {axis}) from a throttled sampleGlide()
at each glide start (500ms window, one per burst so a held arrow reports one
number not dozens). Dual-gated like the other samplers, so unlike the
perfMark-only input:* marks it reaches tv.log in a plain debug session.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ro, drawer snap Self-contained execution spec for sequential Opus/Sonnet agents: ground rules (worktree isolation, no-stash, chrome53 constraints, registry protocol, no deploys), measured B8 baselines + targets per channel, and per-phase specs including the canvas-taint landmine (file:// → blob XHR) and the one-decode- three-uses hero architecture. Parent session validates on-device between phases. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…er-frame reflow Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ecode
The detail-screen backdrop fetched 4 corner colors from PMS, painted a CSS
gradient, then "upgraded" to a server-rendered 1280x720 JPEG that was just
that same gradient plus noise dithering — its synchronous decode measured
1212ms on a real B8. Both ingredients (corner-color blending, noise
dithering) are reproducible natively via CSS: src/ui/colorWash.js builds 4
corner-anchored radial-gradient layers plus a tiled noise-PNG layer
(generated once by scripts/gen-noise-png.cjs, hand-built PNG chunks, no new
dep) and detailScreen.js paints them the instant PMS colors arrive, deleting
the new Image()/decode/swap branch entirely. ultrablur.js drops the
now-unused image-URL builders; loadUltraBlurBackdrop keeps returning
{colors} only (grep-confirmed no other callers depended on imageUrl). The
detail:backdrop-swap breadcrumb still fires at wash-apply time so the
jank-attribution timeline is unbroken.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
# Conflicts: # docs/design-system/component-registry.md
…circle Two defects caught reviewing the Wave-1 agent branches against spec: 1. colorWash NOISE_TILE_URL baked ` repeat` into the constant — that's background-SHORTHAND grammar; inside background-image it invalidates the ENTIRE declaration, so the detail backdrop would have silently painted nothing (flat corner color only). Stripped; tiling was already correctly handled by detailScreen's per-layer background-repeat. Test hardened to anchor the constant at both ends so this class of bug is caught. 2. The drawer's take-3 fixed-256px-item geometry painted the collapsed .active pill (item background, 999px stadium) as a flat-clipped bar at the 96px rail edge — not the registry's 72×72 circle. Collapsed items now SNAP to the 72px content box (no transition; same single reflow frame as the host snap, zero per-frame layout) restoring the circle. Icon x stays 28px in both states. Registry pass-3 claims corrected to pass 4. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ero swap Wave-1 drive session showed detail STILL freezing 1.2-1.7s LATE (worstEndedAgoMs:0) despite the wash killing the JPEG: the FIFO idle queue drained home's now-hidden poster binds AHEAD of detail's own images, and the ~4ms setTimeout(0) chain let their decodes batch into one raster frame. - posterImages: deferred binds re-check at drain time, drop when the img's host is hidden (offsetParent null); recovered via onResume re-prime - homeScreen: onResume primes visible posters; onSuspend cancels the pending/ in-flight 720px hero art swap so it can't decode behind detail - transitionGate: drain ticks paced 48ms (~3 frames) apart so the compositor gets frames between decodes; gate tests widened for the new tick - focus: NAV_SCROLL_MS 150ms -> 120ms (user-felt "rail movement could be faster") Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…breadcrumb
Round-2 data: jank fixed (detail worstMs 1677->327) but the backdrop swap
measured 6s after entry — the user's "incredibly slow to load" is CONTENT
latency, not jank. Two mechanical causes:
- every DROPPED stale bind still cost a full 48ms drain tick; dozens per
scroll burst = seconds of queue delay ahead of detail's own deferred work.
Drain callbacks may now return false ("no work done") to chain unpaced;
real work keeps the ~3-frame spacing
- already-queued poster load jobs kept pumping after navigation, burning the
6-per-host connections against detail's metadata fetch; the pump now drops
hidden imgs (recovered by onResume re-prime, same as deferred binds)
New detail:content-rendered {sinceBuildMs} breadcrumb measures skeleton-to-
content so content latency is attributable separately from jank. Cast & crew
kept — the data does not implicate it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
detail:content-rendered measured 431ms (content path fixed) but the wash still landed 2.6s after entry: gate-wait (~0.8s) + PMS's on-demand /services/ultrablur/colors analysis (~1.8s), serialized. The onIdle wrapper around the FETCH was a JPEG-decode-era leftover — the fetch is network work and must overlap the enter fade, not queue behind it. Only the paint still respects the gate (one 1080p gradient raster mid-fade could hitch the B8). Cache-hit colors now paint at fade end; cache-miss floor is the PMS round trip alone. Phase 3's client-side palette sampling kills that dependency. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…p subject
Three full-screen layers behind the home content (ambient corner-wash <
soft full-bleed art < crisp top-right box), cheapest first, color-leads.
ONE art fetch + ONE decode per focus-settle reused three ways via new
src/ui/palette.js (blob-XHR → same-origin objectURL → 8x8 canvas corner-
average, dodging the file:// canvas-taint). LRU (max 12, revoke-on-evict/
destroy). Bleed is html.caps-motion-gated + il--no-bleed kill-switchable.
Opacity-only crossfades; onSuspend token-guard keeps nothing decoding behind
a covering screen. New home:hero-swap {cached} breadcrumb. Backend-agnostic.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ll-switch Two defects caught reviewing the Phase 3 branch against spec: 1. Stacking: the bleed + scrim shipped at z-index:1 with no stacking context and no feed z — per CSS2.1 App. E, positive-z positioned layers paint ABOVE non-positioned inline content, so the 0.45-opacity art + 0.55-0.85 scrim would have veiled the entire home UI the moment a hero loaded. .home-layout is now an explicit stacking context (position:relative + z-index:0) and the layers use NEGATIVE z (-3/-2/-1): above the body's surface-dim, categorically under every piece of home content. 2. Kill-switch: the swap writes bleed/scrim opacity + background-image INLINE, which beats plain class rules — the il--no-bleed kill-switch and the html:not(.caps-motion) gate both silently lost. The suppress rules now carry !important, making the one-line disable real. Registry anatomy corrected to match (the recorded z-order was the broken one). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ro band home:hero-swap fired every focus (code path fine) but the background never changed: the immersive layers sit at negative z BEHIND the home content, and .il-hero painted an OPAQUE surface-dim across the top 540px — occluding the ambient wash + bleed exactly in the hero region. .il-hero is now transparent; the ambient wash provides the neutral base it used to, and body surface-dim shows through before first focus (cold-load look unchanged). The other home wrappers were already transparent. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…e blob-XHR
"Focus does nothing" persisted after the z-index/transparent fixes because
the real failure was upstream: Phase 3 rewired the crisp corner box (and the
bleed) onto palette.js's cross-origin blob-XHR objectURL, which produces null
on the B8, blanking the WHOLE hero — including the box that worked fine before
Phase 3 via a plain <img>.
Decouple the visible layers from the fragile path:
- crisp box + bleed: plain art URL as CSS background, <img>-preloaded (display
only, no CORS/canvas — the pre-Phase-3 mechanism)
- ambient wash colors: PMS /services/ultrablur/colors via loadUltraBlurBackdrop
(same as the detail backdrop, proven on-device; home already prefetches it
on focus so the settle is usually cache-warm)
palette.js + tests are parked (unused) for a future CORS-validated revival.
New home:hero-swap {colorsFrom} breadcrumb reports whether the wash resolved.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…gn bar On-device verdict: the soft full-bleed layer reads as a duplicated poster behind the crisp corner box (720->1920 upscale is not soft enough to register as ambient glow). The immersive response is carried by the ambient color wash + crisp subject. Kill-switch was built for exactly this call; re-enable only with a real blur source, never the raw upscale. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…matching color On-device screenshot showed the crisp corner box ending in a hard, unblended edge. Root cause: the box's own edge-melt relied on painting a hardcoded var(--surface-dim) overlay, assuming .il-hero's surrounding area was always that same flat color -- true before this phase, false now that .il-hero is transparent (to let the ambient wash show through). The painted color and the true (ambient-tinted) background no longer match, producing a visible seam exactly at the box boundary. Fix: fade the backdrop IMAGE's own opacity via -webkit-mask-image (two intersecting directional gradients, destination-in composite -- old WebKit, Chrome53-safe) instead of painting a color. The scrim now only adds light text-legibility darkening and fades to true transparency. Correct regardless of active theme or ambient tint, since nothing is hardcoded to match. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…b field name Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ash + real seam/scrim fixes Undoes the plan-implementation detour that made the hero/ambient seam and sidebar scrim worse: drops the Plex-only loadUltraBlurBackdrop path in homeScreen and reinstates the backend-agnostic loadAmbientColors contract (Plex → ultrablur, Jellyfin → BlurHash corners) so both providers get a color-matched wash again. Also lands the actual seam/scrim fixes evidenced by on-device logging: - Longer, edge-exact backdrop mask + softened bleed-scrim gradients so the hero photo never ends in a hard rectangle against the ambient wash. - Ambient/bleed/bleed-scrim layers use negative safe-area inset instead of 100vw + transform, avoiding the compositor stall that froze the sidebar expand on the B8. - Collapsed nav rail goes transparent (translucent per-icon discs for contrast) so it no longer double-darkens under the ambient wash, with the ::before scrim fade gated to the rich motion tier only. Co-authored-by: Cursor <cursoragent@cursor.com>
Two on-device issues after the Cursor round: 1. Drawer scrim landed ~3s after expand: an opacity-0 layer is not kept rastered, so the 50vw multi-stop gradient (~1M px) rasterized FROM SCRATCH on first expand, queued behind ambient-wash raster work. The visual job was only ever the soft falloff at the drawer edge, so paint only that: a 140px feather strip at left:var(--nav-rail-w-expanded) (~8x fewer pixels, rasters within the expand frame). Half-screen dim dropped by design. 2. Hero art still met the ambient wash at a clear border: transparency-only melts hand dark photo pixels to an averaged wash — a hue/luminance cliff no alpha ramp fixes. New ilTintHeroScrim rebuilds .il-hero__scrim inline per swap from the item's own topRight palette color, rising over the same band the mask fades the photo out: ghosting hides under the tint and hue stays continuous into the wash's top-right radial. One string per swap, no new layers; static CSS scrim remains the pre-colors default. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
A full pass on making the LG B8 (webOS 4 / Chromium 53) feel snappy with real motion, validated against on-device frame data rather than feel — plus a new layered immersive home hero.
Instrumentation (foundation for everything else):
jank:navigation/jank:sidebar/jank:rail-scroll— rAF frame-gap scoreboards per interaction (frames/dropped/frozen/worstMs/busyMs)animationTimingintostartDelayMs(main-thread congestion) vsrunMs(actual animation performance)versionMajormirrors LG's build number, not the webOS platform version — now trustssdkVersionvia luna)Transition choreography ("one thing at a time"):
transitionGate.js: protected window per navigation + D-pad rail glide; poster decode, backdrop loads, and metadata prefetch all queue behind it instead of competing with the enter fadeDetail screen backdrop — killed a 1200ms decode:
colorWash.js: nativeradial-gradients + a tiny tiled noise PNG, built entirely from the colors already fetched — same look, ~0ms.Layered immersive home hero:
file://cross-origin wall on the B8 and is parked for a future CORS-validated revivalil--no-bleed) — read as a duplicated poster echo rather than ambient glow; the raw 720→1920 upscale isn't soft enough. Re-enable only with a real blur source.Nav drawer perf:
widthtransition (a layout property, measured 323–723ms of dropped/frozen frames per expand/collapse) — width now snaps in one reflow, and the drawer's felt motion is a compositor-only label/brand opacity+transform cascade insteadProcess note: several pieces here (colorWash, palette sampler, nav-drawer snap+cascade) were built by parallel background agents against a shared plan doc (
docs/plans/2026-07-04-b8-snappy-agent-plan.md), then reviewed and merged — two review passes caught a shipping-breaking CSS bug (a background-shorthand value that would have silently blanked the backdrop) and a broken stacking context (positive z-index veiling all home content) before they reached the device.Test plan
npm testgreen throughout (693 tests: eslint no-undef, Chrome53 CSS-compat lint, unit tests)tv.logscoreboard (not simulator-only)jank:sidebarworstMs 323–723 → confirmed improved after nav-drawer fixjank:navigation(detail) worstMs 1212+ → confirmed improved after backdrop fixjank:rail-scroll→ confirmed improved after transition-gate fix🤖 Generated with Claude Code