Play-UI gets a calmstorm-style full chrome — <DeckFrame--Play-UI>, audience-tier intent, single Switch-Variant menu
After the morning's TOC matrix work and the calmstorm auth detour, the founder pulled up calmstorm-decks' /play surface and named what was missing from the shell side: the rich top-and-bottom border chrome that exposes section title, status rating buttons, variant cycle pill, audit link, keyboard hints, and variant switches. Today's third chunk lands that as `<DeckFrame--Play-UI>` — a new full-chrome component sitting alongside the existing floating `<DeckChrome>` capsule. The bigger move tucked inside it: a new orthogonal dimension on top of the shell's existing `surface` axis — `intent: "workflow" | "presentation"` — that gates which affordances render. Workflow chrome is the VC-firm / Lossless-Group authoring view (variant cycle pill, inline status, audit link, variant switches, 1-4 status keyboard). Presentation chrome is the end-user / LP view (exit, section title, counter, clean nav hints — no workflow controls at all). One URL parameter (`?intent=presentation`) swaps the whole thing. Two follow-ups baked in: an Astro scoped-CSS bug that was silently breaking the `C` chrome-toggle key, and a UX iteration that replaced the row of N-1 "Switch to {variant}" buttons with a single "Variant ▾" popover menu navigable by click or arrow keys.
Play-UI gets a calmstorm-style full chrome — <DeckFrame--Play-UI>, audience-tier intent, single Switch-Variant menu
Why care?
The shell's /play/ surface had two chromes shipped before today: the heavy fixed top + bottom bars (pre-A++.2, since deleted) and the post-A++.2 floating <DeckChrome> capsule that the morning's work landed. The capsule is great for quiet share-tier embeds but it's too quiet for the VC firm iterating a deck. Calmstorm-decks ships the right register: rich top bar with section title + inline status + variant cycle pill, rich bottom bar with audit link + keyboard hints + variant switches. The founder compared the two in-browser this afternoon and asked for that pattern in the shell.
Lifting it in cleanly required surfacing a dimension that hadn't been named yet: audience. The VC firm authoring the deck needs all the workflow controls. Their LP audience — looking at the same deck pages, in the same Play-UI surface — must see none of the workflow controls. Same surface, two audiences, two chromes. Naming the dimension intent: "workflow" | "presentation" and threading it through the overlay means every workflow-tier affordance now ships with an explicit visibility gate, no improvising per-element.
Two smaller-but-load-bearing fixes piggybacked: an Astro scoped-CSS bug that was silently breaking the C chrome-toggle key across all our composing components, and a UX iteration that replaced the row of Switch to {variant} buttons with a single Variant ▾ popover menu.
What's new
<DeckFrame--Play-UI> — full-chrome Play-UI primitive
apps/deck-shell/src/components/DeckFrame--Play-UI.astro— top + bottom bars wrapping the slide canvas. Follows the established--Play-UIsuffix convention (paired-sibling discipline, reserves space for a futureDeckFrame--Scroll-UIif needed). Distinct from<DeckChrome>which remains the floating-capsule primitive.Top bar carries: ✕ exit → TOC, labeled variant cycle pill
‹ ENHANCED-V3 (3/4) ›with disabled arrows at endpoints, section blockSection 01 · Cover, inline STATUS rating buttons (Urgent / Non-urgent / Passable / Perfect / Clear),1/16counter. The section title appears for the first time in shell chrome —<DeckChrome>only shows the number.Bottom bar carries:
← Auditlink, keyboard hint strip, "Switch Variant ▾" popover menu.Inline status writes to
surface="play"via the same/api/slide-rankPOST contract the<SlideRankPill>uses, with optimistic UI + revert-on-error. Server-side hydration reads the current status fromaudit.ranks[key].play.statusso the active button reflects truth on first paint — no click-then-flicker.Keyboard contract preserves the existing
← → / F / C / T / Esc / Home / End / Space / PageUp / PageDownset, adds↑ ↓for variant cycle (still works when menu is closed; menu-open state takes over), and adds1 2 3 4→ rate as urgent / non-urgent / passable / perfect,0→ clear status.Themable via two layers of CSS custom properties:
--ddd-frame-*for the new bar surfaces, falling back to--ddd-chrome-*defaults from Phase A++'s themable contract. Consumers can override at either tier.
intent: "workflow" | "presentation" — the new audience-tier dimension
Conceptually orthogonal to
surface(scroll vs play). All four combinations are valid renders: workflow×play, presentation×play, workflow×scroll (future), presentation×scroll (future). Today's work lands the play side.Workflow chrome (default; preserves today's operator-default behavior): all elements above visible. Keyboard hint includes
1-4 status. Variant switching enabled in both top pill and bottom menu.Presentation chrome: variant cycle pill, inline status, audit link, variant menu — all hidden. Bottom bar collapses to just keyboard-hints + a spacer. Keyboard
1-4and↑↓are inert.Plumbing decision: the consumer's route reads
Astro.url.searchParams.get("intent").?intent=presentationon any/play/...URL flips the render; omit it (or any other value) and the default"workflow"applies. Per-route hardcode + URL override were the alternatives we considered; URL-only won because the same gated route can serve both audiences without doubling the route surface, and the VC firm can?intent=presentationa draft to preview the LP view in one click.<DeckOverlay--Play-UI>gained two coordinated props:chromeStyle: "frame" | "capsule"(default"frame"— the new full-chrome) andintent: "workflow" | "presentation"(default"workflow"). Both pass through to<DeckFrame--Play-UI>. Capsule mode preserves the original<DeckChrome>floating-bottom-right experience for splash / share-tier embeds where a less prominent chrome is wanted;<SlideRankPill>is mounted alongside the capsule when chromeStyle is capsule AND intent is workflow (the legacy classifier path), suppressed otherwise.Naming convention check baked in via the file name: the founder originally floated
Play-UI__BorderMeta.astro. Per the shell's existing suffix-discipline (--Scroll-UI/--Play-UIfor paired-mode siblings;__Scroll-or-Playreserved for compound-mode single files like calmstorm'sSlideshowMode__Scroll-or-Play.astro), the correct shape isDeckFrame--Play-UI.astro. Documented inline in the component's header comment so future agents don't re-litigate.
C chrome-toggle key now actually fades the chrome
The bug: Astro's scoped-CSS compiler requires every element matched by a selector to carry the component's scope marker.
<DeckFrame--Play-UI>had a rule like[data-chrome-hidden="true"] .ddd-frame__top { opacity: 0; ... }where the[data-chrome-hidden]attribute is set on the<div data-play-root>rendered by<DeckOverlay--Play-UI>— a sibling component with a different scope marker. The compiled selector required both ends to match DeckFrame's scope, the root didn't, and the rule was effectively dead. TheCkey flipped the attribute, the CSS rule didn't react, the chrome stayed visible.The fix: wrap the cross-scope parent selector in
:global():CSS:global([data-chrome-hidden="true"]) .ddd-frame__top { ... }Trailing
.ddd-frame__topstays scoped (it's DeckFrame's own element). The same pattern fix applied toDeckChrome.astrofor consistency — the capsule's chrome-hidden behavior was probably silently broken there too, but the capsule's idle-fade dimming masked it.Generalizable lesson: any time a child component's CSS targets an attribute or class set on a parent rendered by a different component, the parent-side selector needs
:global(). Worth a future entry in theastro-knotsskill if the pattern recurs.
Single "Variant ▾" menu replaces the row of Switch to {X} buttons
Previous shape: bottom-right of the chrome carried N-1 buttons (
Switch to Proto · Switch to Enhanced-v1 · Switch to Enhanced-v3 · RFC-001). For a 4-variant deck this is three full-text buttons in a row — visually heavy, label-wrapping risk on narrow viewports.New shape: single button
VARIANT [Enhanced-v3] ▾(eyebrow + current label + chevron). Click opens a popover above the button listing all variants in registry order. Current variant is dimmed + non-clickable + markedcurrent; the others showswitch →and navigate to/play/{deck}/{otherVariant}/{currentSlot}/preserving the slot.Mouse interaction: click trigger to toggle, click item to navigate, click outside to close.
Keyboard interaction (menu-aware state):
With menu closed: existing
↑ ↓keys still cycle prev/next variant in one step (preserves today's behavior).With menu open:
↑ ↓move the highlight within the menu (only clickable items participate — current is skipped),Enteractivates the highlighted item,Esccloses.The component script keeps a
menuOpenboolean and routes the four keys (↑↓,Enter,Esc) through the menu handler first when open; any other key falls through to the global handler.
Visual affordance: chevron rotates 180° when open. Eyebrow ("VARIANT") + label ("Enhanced-v3") makes the current state legible without opening the menu.
API ripple: the prop swap that powers this —
otherVariants→allVariants— adds anisCurrentflag to each entry. The route now passes the full list (current included) so the component can render the active state correctly.
The story
The founder pulled up calmstorm next to dididecks
Today's third arc started when the founder spun up the calmstorm-decks dev server to demonstrate the thin-chrome pattern as reference for the shell's continued primitive extraction. The auth gate was broken (separate detour, captured in 2026-05-17_02.md). With that fixed, the side-by-side comparison made the gap obvious: calmstorm's /play has rich border metadata — section title, status rating right in the bar, variant cycle, audit link, keyboard hints, variant switches — and the dididecks shell had a quiet floating capsule that was lovely but skeletal next to it.
The founder named the gap with a working title: Play-UI__BorderMeta.astro. Right idea, wrong shape per our conventions; the corrected name became DeckFrame--Play-UI.astro. The conversation that followed surfaced a dimension we hadn't yet articulated explicitly: the VC firm authoring the deck and the end-user audience are different audiences of the same Play-UI surface, and the workflow-tier UI cannot leak into the LP view. Naming this intent: "workflow" | "presentation" and threading it through the overlay's prop surface was the actual architectural move; DeckFrame is the first component to honor it but every future workflow-aware chrome should as well.
The C key bug
After landing <DeckFrame--Play-UI> and confirming workflow chrome rendered correctly, the founder pressed C to verify the chrome-toggle. Nothing happened. The attribute flipped (verified in DevTools), the keyboard handler logged correctly, but the CSS rule that should have faded the bars was silently dead.
The diagnosis was an Astro scoped-CSS interaction we hadn't encountered before in this codebase: when component A's CSS targets [data-attr] .a-class, both ends of the selector are required to be in component A's scope. If [data-attr] is rendered by component B (a sibling that owns the wrapping <div data-play-root>), the selector won't match. The :global() wrapper on the parent half is the standard fix. Applied to both <DeckFrame--Play-UI> and the original <DeckChrome> for consistency — the capsule had the same latent bug; the capsule's own idle-fade opacity was probably masking it.
Then the menu
With the full-chrome rendering and the C key working, the bottom-right was a row of Switch to {label} text buttons — one per other-variant. For chroma's 4-variant deck that's three buttons. It worked but looked dense. The founder asked for a single trigger with a popover, navigable by click or arrows. Implementing it required surfacing menu-open state in the inline keyboard handler — the same ↑ ↓ keys should cycle variants when the menu is closed and navigate within the menu when it's open. The state flag + handler routing keeps both behaviors clean.
The otherVariants → allVariants prop rename (with isCurrent flag) was the API-side consequence. The route already had the full registry list available; just had to map it differently. The component now renders the current item as dimmed + non-clickable + marked current, which makes the menu function as both a switcher AND a status indicator (you can see where you are in the variant list without leaving the slot).
How it works (or "Under the hood")
The audience-tier intent flow
// routes/play/[deckSlug]/[variantSlug]/[slot].astro
const intentParam = Astro.url.searchParams.get("intent");
const intent: "workflow" | "presentation" =
intentParam === "presentation" ? "presentation" : "workflow";The route reads the URL param and passes through <DeckOverlay--Play-UI intent={intent}> → <DeckFrame--Play-UI intent={intent}>. Inside DeckFrame:
const showWorkflow = intent === "workflow";
...
{showWorkflow && (
<fieldset class="ddd-frame__status" ...>
<!-- status buttons -->
</fieldset>
)}Every workflow-only element is wrapped in showWorkflow && (...). Presentation mode renders the same DOM shape minus those branches. The keyboard handler also checks showWorkflow before binding 1 2 3 4 0 and the variant-cycle keys. Adding a new audience-tier affordance is a one-line gate — no new branching architecture needed.
Menu-open state in the keyboard handler
let menuOpen = false;
let menuActiveIdx = -1;
if (menuOpen) {
if (e.key === "ArrowDown") { menuSetActive((menuActiveIdx + 1) % menuLinks.length); return; }
if (e.key === "ArrowUp") { menuSetActive((menuActiveIdx - 1 + menuLinks.length) % menuLinks.length); return; }
if (e.key === "Enter") { menuActivate(); return; }
if (e.key === "Escape") { menuClose(); return; }
// Any other key falls through to the global handler below.
}
switch (e.key) { /* global keyboard contract */ }The menu owns four keys when open; everything else falls through. With menu closed, the global handler's existing ↑ ↓ branches cycle variants. The fallthrough pattern means we get menu-navigation behavior for free without duplicating the entire keyboard contract per state.
Why both top cycle pill AND bottom menu coexist
Two different deliberateness levels for the same action.
Top cycle pill
‹ ENHANCED-V3 (3/4) ›is one-step in-flow walking. Click an arrow, you're one variant over. Always visible.Bottom menu
Variant ▾is jump-to-any-variant. Click the trigger, see all options, pick one. Hidden by default; opens deliberately.
For a 4-variant deck the cycle is fast enough. For an 8+ variant deck (hypothetical future) the menu becomes essential. Both share the same underlying registry data; neither depends on the other; suppressing either in intent="presentation" mode is independent.
What's next
DeckFrame--Scroll-UIsibling. The--Play-UIsuffix reserves the slot. Scroll-UI doesn't need full bars the same way — the scroll page itself can carry inline metadata — but the workflow/presentation gating concept absolutely applies (the scroll surface's<SlideRankPill>is a workflow affordance and should hide inintent="presentation"). Land alongside the per-variant/toc/route deprecation work (Phase 6 of the TOC redesign plan).Promote the workflow/presentation pattern. Once the scroll-side variant lands, the dimension is general — every workflow-aware shell component should accept
intentand honor it. Worth areferences/intent-and-audience.mdin theastro-knotsskill (or the new candidatemaintain-design-systems).Astro scoped-CSS cross-component pattern. The
:global()wrapper for parent-attribute selectors is general; add a one-paragraph reference to theastro-knotsskill so the next time a child component needs to react to a parent's attribute, the answer is in the skill.?intent=presentationsmoke test against the LP path end-to-end. Once calmstorm's auth gate is also threaded through the shell (a longer-horizon goal — calmstorm doesn't yet consume the shell), verify that an LP-shareable URL with?intent=presentationrenders cleanly under theirviewerrole.Resume the TOC redesign. Phases 1-5.5+6.5 shipped this morning. Phase 6 (deprecate per-variant
/toc/[deck]/[variant]/route → redirect to deck-level matrix with column focus) and Phase 7 (cross-cutting verify + version bump) are next.
Related
[[2026-05-17_01]] — this morning's bulk shell entry (A++.2, audits v2, DeckMatrix component + extraction, chroma index swap).
<DeckFrame--Play-UI>is downstream of that work.[[2026-05-17_02]] — the calmstorm auth-gate detour that surfaced the prerender trap and unblocked side-by-side calmstorm/dididecks comparison.
[[../context-v/plans/Redesign-TOC-as-Deck-Level-Dual-Surface-Review-Matrix]] — the TOC redesign plan; today's Play-UI chrome work is technically follow-up but uses the same audience-aware thinking the matrix work introduced.
[[../context-v/plans/Restore-Calmstorm-Nav-Elegance-as-Themable-Shell-Primitives]] — the partial-shipped plan whose original "Steps 3 + 4" (
<DeckHeader>+<ModeToggleScrollPlay>) overlap conceptually with DeckFrame; DeckFrame supersedes the DeckHeader stub since it absorbs the same affordances at higher fidelity.[[../apps/deck-shell/src/components/DeckFrame--Play-UI.astro]] — the new component.
[[../apps/deck-shell/src/components/DeckOverlay--Play-UI.astro]] — gained
chromeStyle+intentprops; suppresses<SlideRankPill>when the inline status replaces it.astro-knotsskill — candidate destination for both new lessons (audience-intent pattern + Astro scoped-CSS:global()for cross-component attribute selectors).
