Files
deepdrft/PLAN.md
T

30 KiB
Raw Blame History

PLAN.md — DeepDrftHome forward roadmap

Forward-looking roadmap. Sits alongside CONTEXT.md (architecture orientation) and COMPLETED.md (history). Per CONTEXT.md §6, items move from here to COMPLETED.md when work lands; do not delete completed entries.

Organised by theme, not by date. Themes are roughly ordered by current product weight, not commitment. Nothing here carries a timeline unless it explicitly says so.


In-flight — Two-app architectural split

The public site and the CMS are being split into two independent Blazor applications. Design locked by Daniel 2026-05-19 at design/TWO-APP-SPLIT.md §10. All ten open questions resolved. Implementation phases ready to schedule in the phased rollout at §8.

Names locked: DeepDrftPublic (public host), DeepDrftManager (CMS host), DeepDrftShared.Client (shared RCL). Subdomain topology: deepdrft.com (public) and manage.deepdrft.com (CMS).

Supersedes the host-shape pieces of CMS-PLAN.md §2; the CMS feature waves in CMS-PLAN.md survive unchanged and move to the new host.

Phases 14 landed (Wave 2): DeepDrftManager host created, AuthBlocks stripped from DeepDrftWeb, DeepDrftShared.Client RCL extracted, and DeepDrftWeb / DeepDrftWeb.Client renamed to DeepDrftPublic / DeepDrftPublic.Client. Phase 5 (nginx/deploy topology, dev-ops territory) is next.


0. Baseline — what just landed

A two-part audit (design + streaming) ran on 2026-05-17 and the fixes for Critical, Major, and Minor findings are now on dev. The remainder of this plan assumes that baseline. In summary the audit-pass fixed:

  • Index concurrencyVaultIndexDirectory no longer drops the lock before its async disk write; the index file can no longer be clobbered by interleaved writers.
  • Repository semanticsTrackRepository.Update now fails-fast when an Id is not found instead of silently issuing an INSERT.
  • Streaming Criticals — concurrent-seek race in the client, dirty trailing bytes leaking out of the ArrayPool-rented buffer, final-tail audio dropped at EOF below the minimum decode frame, and the assumption that the first network chunk contains the whole WAV header.
  • 17 design and streaming Majors/Minors across all eight projects — format-validation alignment between processor/offset/decoder, IAsyncDisposable on the player provider, cancellation tokens threaded through the HTTP path, structured logging into the FileDatabase subsystem, sort-sentinel cleanup, sundry DRY/SRP tightenings.

What this means for the roadmap: the streaming substrate is solid. Future work can build on top of it rather than around it. The remaining items in TODO-V2.md that did not land are deferred as features, not bugs — they are captured below under Phase 1.


Phase 1 — Streaming features deferred from the audit

These were flagged during the audit but classified as feature work, not defect fixes. They are listed in rough order of user-visible impact.

1.1 Backward seek

  • What: Seeking to a position below playbackOffset currently clamps silently to the start of the in-memory buffer segment instead of going to the user's chosen time. The forward "seek beyond buffer" path already exists in WavOffsetService + the client's offset-request path; backward seek is the missing mirror.
  • Why it matters: The single highest-impact missing feature in the player. Scrub-bar drags backward feel broken — they appear to seek but land in the wrong place.
  • Shape: Reuse the existing GET api/track/{id}?offset= pathway. The client decision becomes "is the target inside the decoded window?" — if yes, jump within the buffer (existing behaviour); if no (forward or backward), tear down the decoder and re-request from the byte-aligned offset.
  • Prerequisite: None — the substrate exists.
  • Constraint: Backward seek must observe the same blockAlign rounding-down as forward seek (already enforced in WavOffsetService.alignedOffset and StreamDecoder.calculateByteOffset). The teardown/reinit must respect the generation-counter pattern introduced by the concurrent-seek fix.

1.2 Audio format diversity

  • What: Today AudioProcessor, WavOffsetService, and the JS decoder are PCM/WAV-only. MimeTypeExtensions already maps MP3, FLAC, Ogg, AAC, M4A — none are wired.
  • Why it matters: WAV-only is a real ceiling for any non-internal release. Distribution-grade formats (MP3, FLAC at minimum) are table stakes for a music site.
  • Shape: Two seams need a strategy pattern.
    • Server side: replace AudioProcessor.ProcessWavFileAsync with a format-router that selects a per-format processor; replace WavOffsetService with a per-format offset strategy (some formats — MP3, Ogg — have natural frame boundaries; FLAC has block headers; AAC has ADTS).
    • Client side: the JS decoder is currently a WAV byte-walker. For non-WAV, the simplest path is decodeAudioData over the full payload (loses streaming-start). The richer path is per-format chunked decoders. Worth a design pass before committing.
  • Prerequisite: None functionally, but consider settling Phase 4 (HTTP Range) first — native range/cache is much more important for large MP3s than for WAVs.
  • Constraint: Spectrum FFT tap currently relies on raw AudioBuffers through decodeAudioData. If a future path uses MediaElementAudioSourceNode (see 4.1), the FFT tap still works but the early-playback story changes.

1.3 Preload / prefetch of the next track

  • What: No mechanism to begin the next track's stream during the tail of the current. Each play is a cold fetch.
  • Why it matters: Prerequisite for both crossfade (1.4) and gapless (1.5). Also a perceived-latency win on its own — track-change feels instant when the bytes are already in flight.
  • Shape: A second HttpClient request kicked off when the current track passes a configurable threshold (e.g. last 10 seconds). Bytes accumulate into a staged StreamDecoder instance rather than the live one. Promotion to "current" happens at end-of-stream or on user-selected next.
  • Prerequisite: Requires a notion of "next track" — today the player only knows the current one. That implies either a playlist/queue model in IPlayerService or a passive "what was the next row in the gallery" inference.
  • Open question: Does a queue model belong in IPlayerService, or is the player a single-slot device that a future PlaylistService orchestrates above? Worth a design note before implementation. Capture in product notes when picked up.

1.4 Crossfade

  • What: Smooth A→B transition with overlapping fade-out / fade-in.
  • Why it matters: DJ/mix aesthetic that fits the DeepDrft collective's electronic-music context. Distinguishing UX from generic "next track."
  • Shape: Architecturally two simultaneous PlaybackScheduler instances suffice — each owns its own gain node, crossfaded via GainNode.gain.linearRampToValueAtTime. The wiring is the work, not the audio graph itself.
  • Prerequisite: 1.3 (Preload) — there is nothing to fade into without prefetch.

1.5 Gapless playback

  • What: Eliminate the inter-track silence that exists today.
  • Why it matters: Important for live-set rips, mix tapes, anything authored to flow continuously.
  • Shape: The decoder must be able to start the next track's first buffer scheduled exactly at the end of the current one's last buffer (sample-accurate, not wall-clock). With PlaybackScheduler's existing 500 ms lookahead this is mechanically achievable — the next track's first AudioBufferSourceNode.start(t) is set to the previous track's end time.
  • Prerequisite: 1.3 (Preload). Also needs to play nicely with 1.2 because gapless across formats is hard (encoder padding/priming on MP3 in particular).
  • Constraint: Truly sample-accurate gapless requires knowing the priming/padding sample counts of the source format. Out of scope for WAV-only; revisit when format diversity lands.

1.6 Track-skip on error

  • What: A failed processStreamingChunk aborts the entire load with no recovery path.
  • Why it matters: One corrupt frame at byte 4M of a 100 MB stream currently means the listener loses the entire track. Should at minimum surface a clear error and (optionally) skip past the bad region.
  • Shape: Two-level response.
    • Cheap: catch in the streaming loop, surface a user-visible error, advance the gallery to the next track if a queue exists.
    • Richer: byte-scan forward to the next valid frame header for the format and resume. Format-dependent — only worth doing once 1.2 lands.

1.7 Safari compatibility

  • What: Two known Safari edge cases.
    • webkitAudioContext.close() is async-but-not-Promise on older Safari (≤ ~14); await resolves immediately and the next initialize() can run against a not-yet-closed context.
    • iOS Safari < 15 had streaming-fetch quirks; HttpCompletionOption.ResponseHeadersRead behaviour is not guaranteed there.
  • Why it matters: Real listener share. iOS in particular is a primary listening surface for music.
  • Shape: For the close() race — detect webkitAudioContext and poll state === "closed" with a short timeout instead of trusting the await. For the fetch quirks — first decide the minimum supported iOS version; if pre-15 is in scope, fall back to a non-streaming fetch path and accept the latency.
  • Open question: What's the floor? Decide before designing the fallback. iOS 15+ as the floor would let us drop the second concern entirely.

These follow from CONTEXT.md §5. Direction is strongly implied but no specific UI has been committed.

2.1 Cover art / image vault wired through

  • What: MediaVaultType.Image is implemented end-to-end and exercised by tests, but the production surface only registers a tracks vault of type Audio. ImagePath on TrackEntity is a free-form URL string today; it should resolve to an entry in an image vault served by DeepDrftContent.
  • Why it matters: Prerequisite for any album/release/genre view that wants to look like a music site rather than a list of rows. Also closes a free-form-string surface area that will otherwise calcify.
  • Shape:
    • Register a second vault (images or art, type Image) in Startup.ConfigureDomainServices and in the CLI.
    • Add GET api/image/{entryKey} (unauthenticated, mirrors track read) and PUT api/image/{entryKey} (ApiKey, mirrors track write) on DeepDrftContent.
    • Change TrackEntity.ImagePath semantics from "URL" to "image vault entry key" (column rename optional — could remain image_path with semantic shift, or could become image_entry_key for clarity).
    • Add an image processor sibling of AudioProcessor.
  • Prerequisite: None.
  • Constraint: This is a small schema-semantics migration. Existing rows have null ImagePath in production so there is no data to migrate, but commit before the field has real content to avoid a backfill.

2.2 Album and genre views

  • What: TrackCard already renders album/genre/release date; the data is there. Missing are gallery groupings (album view, genre view), filters, and the API-side support for filter expressions in TrackService.GetPaged.
  • Why it matters: The track gallery is the only working content surface. Multiple views over the same library is how it earns the "gallery" name.
  • Shape: Per CONTEXT.md §6, the convention is one source of truth, multiple views over it. New views should consume the same TracksViewModel / PagedResult<TrackEntity> and differ only at the rendering layer.
    • TrackService.GetPaged extended to accept a filter expression (or a simple structured filter DTO).
    • PagingParameters<T> extended with a Where: Expression<Func<T, bool>>? or a parallel FilterParameters<T> — pick one to avoid drift.
    • New routes (/albums, /genres) consume the same VM with different grouping / filter inputs.
  • Prerequisite: 2.1 for any view that prominently features cover art (album view especially is impoverished without it).
  • What: TracksViewModel exposes sort but no filter. TrackService.GetPaged accepts only sort. Simple text search across TrackName / Artist / Album is the obvious first cut.
  • Why it matters: Once the library has more than ~30 entries, sort-only browsing is friction.
  • Shape: Same extension to GetPaged as 2.2. UI is a debounced text input bound to the VM's filter property. EF Core translates Contains to SQLite LIKE.
  • Prerequisite: Fold into 2.2 if both are being done — the same GetPaged extension serves both. Doing them separately doubles the API churn.

Phase 3 — New content kinds

3.1 Live / session content

  • What: The home page advertises "Live Sessions" and "Video Content (coming soon)". No data model exists for these.
  • Why it matters: Honour the home page copy. Also differentiates the site from a generic track gallery — live sessions and video are the collective's authored output.
  • Shape: Speculative; no commitment yet.
    • Likely new entity table(s) sibling to TrackEntity (SessionEntity, VideoEntity?) — or a polymorphic MediaEntity with discriminator. The choice affects how much code in TrackService / TrackController can be reused.
    • New vault type(s). MediaVaultType.Media exists and is the obvious home for video; sessions are probably still Audio.
    • New routes, new UI surfaces, new player considerations (video has its own playback element and does not go through the WAV decoder).
  • Prerequisite: Probably 2.1 (vault wiring proof) and a decision on the entity model before any code lands.
  • [speculative] — direction inferred from home-page copy, not a Daniel-confirmed commitment.

Phase 4 — Infrastructure / delivery

4.1 HTTP Range + CDN caching

  • What: Today's ?offset= query parameter defeats HTTP caching — a CDN sees ?offset=1234567 as a distinct URL from the un-offset request. The architecture re-invents byte-range on top of a custom query param.
  • Why it matters: Material once the site has real listener traffic. Also relevant to non-WAV formats (1.2) where decoder-side seek is cheaper natively.
  • Shape: Two intertwined moves.
    • Server: LoadResourceStreamAsync returning an open FileStream instead of LoadResourceAsync materialising the whole buffer. File(stream, mime, enableRangeProcessing: true). The WavOffsetService synthesised-header path becomes a special-case rather than the default.
    • Client: consider MediaElementAudioSourceNode instead of (or alongside) decodeAudioData-fed AudioBufferSourceNodes. Native seek, native range, native cache; FFT tap on the audio graph still works for the spectrum visualiser.
  • Prerequisite: None functionally, but the audit explicitly flagged this trade-off as architecture-intentional — the current path was chosen because spectrum analysis wants AudioBuffers. Re-deciding the trade-off is itself part of the work.
  • Constraint: A move to MediaElementAudioSourceNode changes the early-playback story (the element handles buffering, not us). Worth a design pass.

4.2 Server-side stream from disk (no buffer materialisation)

  • What: LoadResourceAsync<AudioBinary> reads the entire file into memory before File(file.Buffer, mimeType) returns it. A 100 MB WAV is a 100 MB LOH allocation per request.
  • Why it matters: Scaling ceiling. Currently fine for a small audience and small library; not fine if either grows.
  • Shape: Folds into 4.1 — the same LoadResourceStreamAsync overload solves both. Listed separately because either could land without the other (you could stream from disk while still using the ?offset= query path, or you could move to Range headers while still buffering).

4.3 Dual-write rollback / dead-letter log

  • What: If content-side write succeeds and SQL-side write fails, audio is orphaned in the vault. No compensating mechanism exists.
  • Why it matters: A latent data-integrity issue. Materially riskier once web upload (2.4) exists.
  • Shape: Audit suggested a DeadLetterLog recording orphaned entryKeys for a periodic maintenance pass. Lighter than full transactional rollback (which the dual-database split fundamentally cannot give us).
  • Prerequisite: None. Worth landing alongside or just before 2.4.

Phase 5 — Documentation backlog

5.1 Folder-level CLAUDE.md sweep

  • What: Eight folder-level CLAUDE.md files need writing/rewriting per the brief in DOC_PLAN.md. Five are rewrites (drift from the .NET 10 upgrade and structural moves); three are new (DeepDrftWeb.Services, DeepDrftContent.Services — the two libraries where most domain logic now lives — plus the open question on DeepDrftContent.Services/FileDatabase/README.md).
  • Why it matters: The agent guidance files are how every future implementer (human or agent) gets oriented in a directory. They are currently misleading in ways that will cause wrong assumptions on first contact — claiming .NET 9, referencing MediaPath that has been EntryKey for two migrations, describing a FileDatabase/ tree inside DeepDrftContent that has moved out, and missing entirely for the two *.Services libraries.
  • Shape: Doc-keeper executes against DOC_PLAN.md. Order of operations and the per-folder briefs are already specified there.
  • Prerequisite: None. Can run fully in parallel with any feature work.
  • Constraint: Wait on Daniel for the DeepDrftContent.Services/FileDatabase/README.md judgement call before that file changes (retire, keep + refresh, or replace with a CLAUDE.md). The other seven can proceed without that decision.

Cross-cutting / not yet themed

A small set of items that are real but don't fit a phase yet. Surface them when they become relevant rather than committing now.

  • Identity / accounts. Currently no user concept. Needed before web upload (2.4); also a precondition for favourites, listening history, per-user playlists. Decide the shape before any of those lands. [speculative] until Daniel signals interest.
  • ITrackService interface. Audit-suggested. Low value today (one consumer pair); higher value when the test surface expands beyond FileDatabase.
  • Test coverage outside FileDatabase. Tests today cover the FileDatabase subsystem comprehensively and nothing else. As features in Phases 14 land, test scope should expand — at minimum WavOffsetService, AudioProcessor, TrackService (both sides), and the streaming player services. Not a phase of its own; an attached cost to feature work.

Working with this file

  • Add items by extending an existing phase first; only create a new phase when the addition genuinely doesn't fit any of 15. Phase numbers are organisational, not sequencing.
  • When something lands, move it to COMPLETED.md rather than deleting it. Keep the original "What / Why / Shape" body intact so the history reads as a record of the decision, not just the outcome.
  • Mark genuinely uncertain items [speculative] so future readers can tell what is direction vs. commitment.
  • Open questions belong in the item that raises them, not in a separate "questions" list — they expire when the item does.

AudioPlayerBar Responsive Unification

Goal: Collapse the two divergent Razor trees in AudioPlayerBar.razor (@if (_isDesktop) / @else) into one markup tree where CSS — not a runtime breakpoint flag — does the responsive work. Removes the first-render layout flash caused by the async BrowserViewportService subscription, and deletes the inline duplication of the transport cluster that the mobile branch carries today.

This is presentation-layer cleanup, not a feature. It fits the CONTEXT.md §6 "one source of truth, multiple views" principle applied at the markup level: one composition, two rendered shapes, divergence pushed entirely into rendering (CSS) rather than into a branch in the component.

The core observation

The mobile branch's top row — [PlayerControls + spinner] [Timestamp] [VolumeControls] — is not a different set of controls from PlayerTransportZone. It is PlayerTransportZone (which already composes PlayerControls + spinner + TimestampLabel) laid out horizontally instead of vertically, with VolumeControls sitting beside it. The desktop branch already uses PlayerTransportZone; the mobile branch hand-rolls the same parts inline only because it needed a different axis and wanted volume adjacent.

So the entire desktop/mobile split reduces to two differences:

  1. PlayerTransportZone internal axis — vertical (controls-over-timestamp) on wide, horizontal (controls-beside-timestamp) on narrow.
  2. VolumeControls position — far-right of the single row on wide; tucked beside the transport cluster on narrow (so the seek zone can take the full width below).

Both are achievable with one markup tree. Recommendation below; two alternatives considered after it.

One outer flex container that wraps, holding three children in source order: PlayerTransportZone, VolumeControls, PlayerSeekZone.

┌─ .player-layout (flex, wrap) ──────────────────────────────────┐
│  [PlayerTransportZone]  [VolumeControls]   [PlayerSeekZone ───] │
└────────────────────────────────────────────────────────────────┘
  • Wide (≥ breakpoint): flex-direction: row; flex-wrap: nowrap. Transport at its natural min-width, VolumeControls at natural width, SeekZone flex-grow: 1 eating the middle. To match today's desktop order (transport — seek — volume), SeekZone is given order: 2 and VolumeControls order: 3 at this breakpoint. Transport stays order: 1.
  • Narrow (< breakpoint): still flex-wrap: wrap, but SeekZone is forced to a new line via flex-basis: 100%. Transport (order: 1) and VolumeControls (order: 2) share the top line; SeekZone (order: 3, flex-basis: 100%) drops full-width below. This reproduces today's mobile shape exactly: [transport] [volume] over [seek full-width].

The order swap is what lets a single source order serve both shapes. Source order is transport → volume → seek (the narrow reading order); order props rewrite it to transport → seek → volume on wide. Keeping source order = narrow order means the DOM reads naturally for the smaller, more constrained surface.

Narrow-viewport answers to the brief's specific questions:

  • Where does VolumeControls go? Beside the transport cluster, on the same top line — left of nothing, right of transport — exactly as today's mobile row. It does not drop below with the seek zone.
  • Does PlayerTransportZone stay vertical or go horizontal? It goes horizontal at narrow, so [controls+spinner] and [timestamp] sit side by side rather than stacked. This is the change that lets the transport cluster + volume fit on one line and matches the current mobile row.

2. PlayerTransportZone changes

PlayerTransportZone needs to flip its internal axis at the breakpoint. Two ways:

  • (Recommended) CSS-only, no new parameter. The component's outer MudStack Row="false" renders a flex column. Scoped CSS in PlayerTransportZone.razor.css overrides flex-direction to row below the breakpoint via a media query, and adjusts align-items / gap to match. No C# parameter, no parent coordination — the component adapts to its own width context. This keeps the component self-contained and the parent ignorant of breakpoints.

    • Caveat: MudBlazor's MudStack emits inline-ish utility classes; overriding flex-direction on the rendered root needs a ::deep rule with sufficient specificity (or a wrapper element the component controls). Confirm the generated class can be overridden cleanly; if not, fall back to wrapping the MudStack in a div the scoped CSS owns.
  • (Alternative) Add a Row (or Vertical) bool parameter. Parent passes the axis explicitly. Rejected as the default because it reintroduces parent-side breakpoint awareness — the parent would need to know "narrow ⇒ Row=true," which is exactly the runtime-flag coupling we are removing. CSS-only keeps the responsive decision in CSS where the brief wants it. Keep this in pocket only if the MudStack override proves intractable.

Net: prefer no parameter change to PlayerTransportZone. CSS in its own scoped file handles the axis flip.

3. CSS strategy

  • AudioPlayerBar.razor.css owns the outer arrangement: the .player-layout flex container, the flex-wrap, the order assignments, and the flex-basis: 100% line-break on the seek zone. This is where the three-zone composition lives, so its responsive rules belong here. Add a single media query block to the existing file (which already carries a max-width: 768px block for the dock/spacer — reuse the same threshold for consistency).

  • PlayerTransportZone.razor.css owns the internal axis flip of the transport cluster (column ⇄ row) and keeps its existing min-width: 200px on .controls-left (note: that min-width should be reviewed — at narrow widths a 200px floor on the transport cluster may crowd VolumeControls; consider relaxing it inside the narrow media query).

  • Breakpoint threshold: Use max-width: 599.98px to mirror MudBlazor's Sm boundary (600px), since the old _isDesktop flag used Breakpoint.Sm (>= Sm = desktop). This preserves the exact switch point users see today. Do not silently inherit the existing 768px block's threshold for the layout switch — 768 is the dock-padding breakpoint and is a different concern; conflating them would move the layout switch point. (If Daniel prefers a single unified breakpoint for the whole component, that is a one-line decision — call it out, don't assume.)

    Decision needed from Daniel: keep the layout switch at the historical 600px (Sm), or unify everything on 768px? Recommendation: keep 600px to preserve current behaviour; revisit only if the 200px transport floor forces an earlier wrap.

4. What AudioPlayerBar.razor.cs loses

All of the following are deleted — they exist only to drive the now-removed @if (_isDesktop) branch:

  • The private bool _isDesktop = true; field.
  • The private Guid _viewportSubscriptionId; field.
  • The [Inject] public required IBrowserViewportService BrowserViewportService { get; set; } injection.
  • The entire OnAfterRenderAsync(bool firstRender) override (its only job is the breakpoint read + subscription).
  • The BrowserViewportService.UnsubscribeAsync(_viewportSubscriptionId) call inside DisposeAsync (the rest of DisposeAsync — the StateChanged detach — stays).
  • The using MudBlazor.Services; import, once IBrowserViewportService / ResizeOptions references are gone (verify nothing else in the file needs it — using MudBlazor; stays for Color, Size, etc.).

IAsyncDisposable stays on the class (still needed for the StateChanged unsubscribe); DisposeAsync remains but loses its viewport line. No other .cs logic changes — playback, seek, volume, minimize, and the StateChanged cascade plumbing are untouched.

Razor consolidation (what the single tree looks like)

Replace the @if (_isDesktop) { ... } else { ... } block (lines 1572) with one composition: the .player-layout flex container holding PlayerTransportZone, VolumeControls, and PlayerSeekZone in that source order. The mobile branch's hand-rolled PlayerControls + spinner + TimestampLabel + inline VolumeControls (lines 4471) is deleted entirely — it is fully subsumed by PlayerTransportZone + the horizontal CSS flip. PlayerWindowControls (top-right minimize/close) and the error MudAlert are unchanged and sit outside .player-layout as they do today.

Alternatives considered (for the outer layout)

  • (A) MudBlazor MudGrid / MudItem with xs/sm breakpoint props instead of a hand-rolled flex container. MudBlazor's grid carries breakpoint props natively (xs="12" sm="auto"), which is idiomatic and avoids custom media queries for the wrap. Viable, and arguably the more "MudBlazor-native" move. Downside: the order swap (seek between transport and volume on wide, but below both on narrow) is awkward in a 12-column grid — you end up fighting column order with order utilities anyway, and the seek zone's full-width-on-narrow / grow-on-wide behaviour is cleaner as a flex flex-grow + flex-basis than as grid columns. Net: grid is tidy for the wrap but clumsy for the reorder. Flex wins on this specific layout.
  • (B) CSS Grid with grid-template-areas swapped per breakpoint. Most declarative — name the three areas and redraw the template in the media query. Cleanest conceptually, and the reorder is trivial (just rewrite the template). Downside: more CSS machinery than this three-element layout warrants, and grid interplay with MudBlazor component roots (which bring their own display) needs care. Reasonable if the flex order approach gets fiddly; otherwise heavier than needed.
  • (Recommended) Flexbox with order + flex-basis: 100% line break. Least machinery, maps directly onto the two differences identified, reuses the component's existing flex-based MudStack mental model. Recommended unless the MudStack override in §2 forces a rethink.

Trade-offs / risks

  • MudStack override specificity. The one real implementation risk is whether scoped ::deep CSS can reliably override MudStack's rendered flex-direction. If it can't, either wrap in a component-owned div (cheap) or fall back to the Row parameter (§2 alternative). Worth a 10-minute spike before committing the CSS-only path.
  • Breakpoint divergence. Two breakpoints now live in the component's CSS (600px for layout, 768px for dock padding). That's pre-existing for the padding; the layout one is new. If this bothers anyone, unify on one value — but that's a behaviour change, so it's Daniel's call, flagged above.
  • No flash, by construction. Because CSS evaluates at first paint (no async round-trip), the first-render flash is gone — this is the whole point and the primary win.
  • min-width: 200px on .controls-left may need relaxing at narrow widths once the transport cluster goes horizontal beside volume; check during implementation.