fix(theater): replace max-height collapse with grid-rows + visibility; fix keyboard-focus leak when collapsed

This commit is contained in:
daniel-c-harvey
2026-06-21 09:12:24 -04:00
parent 9716092805
commit 6e12d0161a
5 changed files with 44 additions and 9 deletions
@@ -20,7 +20,9 @@ else
{ {
var nowShowing = VisualizerControlState.TheaterMode; var nowShowing = VisualizerControlState.TheaterMode;
<div class="dd-theater-collapsible @(nowShowing ? null : "dd-theater-collapsed")"> <div class="dd-theater-collapsible @(nowShowing ? null : "dd-theater-collapsed")">
<div class="dd-theater-collapsible-inner">
<NowShowingPanel Release="CurrentTrack.Release" /> <NowShowingPanel Release="CurrentTrack.Release" />
</div>
</div> </div>
} }
@@ -75,6 +75,7 @@ else
a collapsing wrapper so it does not pop — IsContentHidden collapses it to zero height when a collapsing wrapper so it does not pop — IsContentHidden collapses it to zero height when
Theater is on AND this Cut is the playing release. OFF eases it back to its normal layout. *@ Theater is on AND this Cut is the playing release. OFF eases it back to its normal layout. *@
<div class="dd-theater-collapsible @(IsContentHidden ? "dd-theater-collapsed" : null)"> <div class="dd-theater-collapsible @(IsContentHidden ? "dd-theater-collapsed" : null)">
<div class="dd-theater-collapsible-inner">
@* Header split: meta + Play/Share on the LEFT, bordered cover on the RIGHT (spec §3.1). *@ @* Header split: meta + Play/Share on the LEFT, bordered cover on the RIGHT (spec §3.1). *@
<div class="cut-detail-header"> <div class="cut-detail-header">
<div class="cut-detail-meta"> <div class="cut-detail-meta">
@@ -134,10 +135,12 @@ else
</div> </div>
</div> </div>
</div> </div>
</div>
</Header> </Header>
<BodyContent> <BodyContent>
@* Theater Mode (Wave 2 §2): eased collapse, mirroring the Header region. *@ @* Theater Mode (Wave 2 §2): eased collapse, mirroring the Header region. *@
<div class="dd-theater-collapsible @(IsContentHidden ? "dd-theater-collapsed" : null)"> <div class="dd-theater-collapsible @(IsContentHidden ? "dd-theater-collapsed" : null)">
<div class="dd-theater-collapsible-inner">
@* Blurb sits between the header and the track-list divider. *@ @* Blurb sits between the header and the track-list divider. *@
<ReleaseDescription Description="@release.Description" /> <ReleaseDescription Description="@release.Description" />
<MudDivider Class="cut-detail-divider" /> <MudDivider Class="cut-detail-divider" />
@@ -169,6 +172,7 @@ else
</div> </div>
} }
</div> </div>
</div>
</BodyContent> </BodyContent>
</ReleaseDetailScaffold> </ReleaseDetailScaffold>
</div> </div>
@@ -75,6 +75,7 @@ else
a collapsing wrapper so it does not pop — collapsed to zero height when Theater is on AND a collapsing wrapper so it does not pop — collapsed to zero height when Theater is on AND
this Mix is the playing release. OFF eases the full-bleed visualizer back behind the hero. *@ this Mix is the playing release. OFF eases the full-bleed visualizer back behind the hero. *@
<div class="dd-theater-collapsible @(IsContentHidden ? "dd-theater-collapsed" : null)"> <div class="dd-theater-collapsible @(IsContentHidden ? "dd-theater-collapsed" : null)">
<div class="dd-theater-collapsible-inner">
@* Cover-as-background hero with all metadata overlaid, square `mix-hero` sizing. The @* Cover-as-background hero with all metadata overlaid, square `mix-hero` sizing. The
cover art IS the background, so no separate cover thumbnail (CoverThumbKey defaults cover art IS the background, so no separate cover thumbnail (CoverThumbKey defaults
to null). Share and play ride in as slots, matching Sessions. *@ to null). Share and play ride in as slots, matching Sessions. *@
@@ -99,12 +100,15 @@ else
</PlayContent> </PlayContent>
</ReleaseHeroOverlay> </ReleaseHeroOverlay>
</div> </div>
</div>
</Hero> </Hero>
<BodyContent> <BodyContent>
@* Theater Mode (Wave 2 §2): eased collapse, mirroring the Hero region. *@ @* Theater Mode (Wave 2 §2): eased collapse, mirroring the Hero region. *@
<div class="dd-theater-collapsible @(IsContentHidden ? "dd-theater-collapsed" : null)"> <div class="dd-theater-collapsible @(IsContentHidden ? "dd-theater-collapsed" : null)">
<div class="dd-theater-collapsible-inner">
@* Blurb sits below the hero, inside the scaffold's foreground stacking context. *@ @* Blurb sits below the hero, inside the scaffold's foreground stacking context. *@
<ReleaseDescription Description="@release.Description" /> <ReleaseDescription Description="@release.Description" />
</div>
</div> </div>
</BodyContent> </BodyContent>
</ReleaseDetailScaffold> </ReleaseDetailScaffold>
@@ -72,6 +72,7 @@ else
collapsing wrapper so they do not pop — collapsed to zero height when Theater is on AND this collapsing wrapper so they do not pop — collapsed to zero height when Theater is on AND this
Session is the playing release. The top row above stays. OFF eases this region back in. *@ Session is the playing release. The top row above stays. OFF eases this region back in. *@
<div class="dd-theater-collapsible @(IsContentHidden ? "dd-theater-collapsed" : null)"> <div class="dd-theater-collapsible @(IsContentHidden ? "dd-theater-collapsed" : null)">
<div class="dd-theater-collapsible-inner">
@* The overlay shows the cover thumbnail only when it differs from the resolved hero image — @* The overlay shows the cover thumbnail only when it differs from the resolved hero image —
when there is no dedicated hero, heroImage already falls back to release.ImagePath, so the when there is no dedicated hero, heroImage already falls back to release.ImagePath, so the
thumb would duplicate the background. That logic lives in ReleaseHeroOverlay. *@ thumb would duplicate the background. That logic lives in ReleaseHeroOverlay. *@
@@ -98,6 +99,7 @@ else
<ReleaseDescription Description="@release.Description" /> <ReleaseDescription Description="@release.Description" />
</div> </div>
</div>
</MudContainer> </MudContainer>
} }
@@ -370,21 +370,44 @@ h2, h3, h4, h5, h6,
} }
/* Eased content collapse for Theater Mode (Phase 20 Wave 2 §2). The detail content stays mounted and /* Eased content collapse for Theater Mode (Phase 20 Wave 2 §2). The detail content stays mounted and
collapses to zero height (and fades) when .dd-theater-collapsed is applied, so toggling Theater eases collapses smoothly when .dd-theater-collapsed is applied, so toggling Theater eases both directions
both directions instead of popping — when collapsed the content is fully out of the way and the instead of popping — when collapsed the content is fully out of the way and the visualizer is
visualizer is unobstructed. overflow:hidden clips the content during the transition; the large open unobstructed. The same pattern drives the player-bar "now showing" band so the bar grows/shrinks
max-height accommodates any realistic content height (it is a ceiling, not a fixed size). The same smoothly too.
pattern drives the player-bar "now showing" band so the bar grows/shrinks smoothly too. */
Technique: grid-template-rows 1fr → 0fr interpolates the REAL content height (no 400vh ceiling
artifact / delayed-start that the old max-height approach had). The direct child receives
overflow:hidden + min-height:0 so it actually clips during the transition (the grid child is the
collapsing unit). visibility:hidden removes all descendants from the tab order and from pointer/
keyboard interaction once collapsed — this fixes the Major accessibility defect where Tab could
reach hidden controls. transition-behavior:allow-discrete makes visibility flip discretely: it
flips to hidden AFTER the ease-out finishes (so the animation plays fully), and flips back to
visible BEFORE the ease-in starts (so content is immediately interactive on the way back in).
The visibility transition is listed as 0s so it is instant when it fires; the allow-discrete
behaviour determines which end of the transition that instant flip occurs on. */
.dd-theater-collapsible { .dd-theater-collapsible {
overflow: hidden; display: grid;
max-height: 400vh; grid-template-rows: 1fr;
opacity: 1; opacity: 1;
transition: max-height 0.45s ease, opacity 0.3s ease; visibility: visible;
transition: grid-template-rows 0.45s ease, opacity 0.3s ease, visibility 0s;
transition-behavior: allow-discrete;
}
/* The single direct child clips itself during the grid-row collapse. min-height:0 overrides the
implicit min-height:auto that would prevent the row from shrinking past the content's intrinsic
height. overflow:hidden clips painted content when the row is partially collapsed. */
.dd-theater-collapsible > * {
overflow: hidden;
min-height: 0;
} }
.dd-theater-collapsed { .dd-theater-collapsed {
max-height: 0; grid-template-rows: 0fr;
opacity: 0; opacity: 0;
/* visibility flips to hidden instantly, but allow-discrete defers it to AFTER the ease-out
completes (at the end of the 0.45s transition), so the animation still plays in full. */
visibility: hidden;
} }
/* Honor reduced-motion: collapse still happens (it is layout, not decoration) but instantly, matching /* Honor reduced-motion: collapse still happens (it is layout, not decoration) but instantly, matching