Lossless Changelog
product

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.

Michael StatonMusic Editoraugmented with Claude Code on Claude Opus 4.7 (1M context)

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-UI suffix convention (paired-sibling discipline, reserves space for a future DeckFrame--Scroll-UI if 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 block Section 01 · Cover, inline STATUS rating buttons (Urgent / Non-urgent / Passable / Perfect / Clear), 1/16 counter. The section title appears for the first time in shell chrome — <DeckChrome> only shows the number.

  • Bottom bar carries: ← Audit link, keyboard hint strip, "Switch Variant ▾" popover menu.

  • Inline status writes to surface="play" via the same /api/slide-rank POST contract the <SlideRankPill> uses, with optimistic UI + revert-on-error. Server-side hydration reads the current status from audit.ranks[key].play.status so 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 / PageDown set, adds ↑ ↓ for variant cycle (still works when menu is closed; menu-open state takes over), and adds 1 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-4 and ↑↓ are inert.

  • Plumbing decision: the consumer's route reads Astro.url.searchParams.get("intent"). ?intent=presentation on 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=presentation a 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) and intent: "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-UI for paired-mode siblings; __Scroll-or-Play reserved for compound-mode single files like calmstorm's SlideshowMode__Scroll-or-Play.astro), the correct shape is DeckFrame--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. The C key 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__top stays scoped (it's DeckFrame's own element). The same pattern fix applied to DeckChrome.astro for 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 the astro-knots skill 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 + marked current; the others show switch → 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), Enter activates the highlighted item, Esc closes.

    • The component script keeps a menuOpen boolean 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 an isCurrent flag 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

TS
// 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:

ASTRO
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.

JS
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-UI sibling. The --Play-UI suffix 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 in intent="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 intent and honor it. Worth a references/intent-and-audience.md in the astro-knots skill (or the new candidate maintain-design-systems).

  • Astro scoped-CSS cross-component pattern. The :global() wrapper for parent-attribute selectors is general; add a one-paragraph reference to the astro-knots skill so the next time a child component needs to react to a parent's attribute, the answer is in the skill.

  • ?intent=presentation smoke 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=presentation renders cleanly under their viewer role.

  • 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.

  • [[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 + intent props; suppresses <SlideRankPill> when the inline status replaces it.

  • astro-knots skill — candidate destination for both new lessons (audience-intent pattern + Astro scoped-CSS :global() for cross-component attribute selectors).