7 Commits

Author SHA1 Message Date
daniel-c-harvey 5298cab9b1 feature: Re-enable Dark Mode Toggle & App Bar Styles & Mobile App Bar Fixes
Deploy DeepDrftAPI / Build, Publish & Bundle (push) Successful in 2m9s
Deploy DeepDrftPublic / Build & Publish (push) Successful in 4m7s
Deploy DeepDrftAPI / Deploy (push) Successful in 1m34s
Deploy DeepDrftPublic / Deploy (push) Successful in 1m31s
2026-06-19 17:48:26 -04:00
daniel-c-harvey e05d93a67b docs: document upload staging directory and Upload:StagingPath config 2026-06-19 17:45:52 -04:00
daniel-c-harvey fd4fdd2624 docs: add Phase 18 theme/dark-mode remediation plan + product note 2026-06-19 17:41:11 -04:00
daniel-c-harvey 639f4741e6 Merge upload-temp-disk-fix into dev (stage large audio uploads on data disk instead of /tmp) 2026-06-19 17:37:26 -04:00
daniel-c-harvey d7071fdbc2 fix: always delete staging file on mid-copy abort
Build the staging path before the copy in both UploadTrack and ReplaceAudio so the finally block deletes it on cancellation or IO error, not only on success.
2026-06-19 17:36:06 -04:00
daniel-c-harvey 37cf19c405 fix: stage audio uploads on data disk instead of /tmp
Relocate both the framework multipart buffer (via ASPNETCORE_TEMP) and the controller staging file to a configurable data-disk directory, so large WAV/FLAC/MP3 uploads no longer fail on the host's small tmpfs.
2026-06-19 17:25:51 -04:00
daniel-c-harvey 37bbfb947f docs: note footer PRIVACY button + centered MudOverlay privacy modal 2026-06-19 17:09:37 -04:00
13 changed files with 494 additions and 55 deletions
+1 -1
View File
@@ -126,7 +126,7 @@ All projects load secrets via `CredentialTools.ResolvePathOrThrow()` from gitign
- `DeepDrftPublic/appsettings.json`: Logging and URL config. Secrets loaded from `environment/api.json` (DeepDrftAPI base URL via `Api:ContentApiUrl`).
- `DeepDrftManager/appsettings.json`: Logging and URL config. Secrets loaded from `environment/api.json` (DeepDrftAPI base URL via `Api:ContentApiUrl` and API key via `Api:ContentApiKey`). Non-secret upload tunables (in `appsettings.json` itself, not `environment/`): `Upload:IdleTimeoutSeconds` (default 90 — aborts a stalled body-streaming phase) and `Upload:ResponseTimeoutSeconds` (default 1200 — budget for server-side persist after the body is fully sent).
- `DeepDrftAPI/appsettings.json`: Logging and hosting config. Secrets loaded from `environment/filedatabase.json` (FileDatabase vault path), `environment/apikey.json` (API key), `environment/connections.json` (SQL and Auth connection strings), `environment/authblocks.json` (AuthBlocks JWT/email/admin creds).
- `DeepDrftAPI/appsettings.json`: Logging and hosting config. Non-secret upload tunable: `Upload:StagingPath` (default empty → a `staging` subdirectory under the FileDatabase vault path) — the data-disk directory where large audio bodies are staged during upload/replace-audio, kept off the system temp mount (`/tmp` is a small tmpfs on the Linux host); `Startup` also points the framework's multipart buffer here via `ASPNETCORE_TEMP`. Secrets loaded from `environment/filedatabase.json` (FileDatabase vault path), `environment/apikey.json` (API key), `environment/connections.json` (SQL and Auth connection strings), `environment/authblocks.json` (AuthBlocks JWT/email/admin creds).
## Folder-Level Guidance
+7 -5
View File
@@ -16,7 +16,7 @@ Dual-database authority for tracks (SQL metadata + FileDatabase binary), release
- `Controllers/TrackController.cs`: Track endpoints (see below).
- `Controllers/ReleaseController.cs`: Release endpoints (see below).
- `Middleware/ApiKeyAuthenticationMiddleware.cs`, `Middleware/ApiKeyAuthorizeAttribute.cs`: ApiKey validation logic (for track endpoints only).
- `Models/`: Settings POCOs only (`ApiKeySettings`, `CorsSettings`, `FileDatabaseSettings`). No domain code.
- `Models/`: Settings POCOs only (`ApiKeySettings`, `CorsSettings`, `FileDatabaseSettings`, `UploadSettings`, `UploadStagingDirectory`). No domain code.
- `environment/filedatabase.json`: FileDatabase vault path config (loaded via CredentialTools, not in repo).
- `environment/apikey.json`: API key for track endpoints (loaded via CredentialTools, not in repo, must be created locally or at deployment).
- `environment/connections.json`: SQL and Auth connection strings (loaded via CredentialTools, not in repo, format: `{ "ConnectionStrings": { "DefaultConnection": "...", "Auth": "..." } }`).
@@ -168,8 +168,8 @@ Soft-delete a release row. Used by the albums browser to remove an orphaned rele
- `medium` (string, optional): enum `ReleaseMedium` (e.g., `Cut`, `Mix`, `Session`). Defaults to `Cut` if null or unrecognized.
- `trackNumber` (int?, optional): track position within the release (1-based). Defaults to 1 if ≤ 0 or null.
- `releaseId` (long?, optional): the SQL release ID to attach this track to. Omit (null) on the first row of a submit — this is the **CREATE path**, which mints a new release and blocks a pre-existing (title, artist) with 409. Set to the release id returned by row 1 for rows 2..N of a within-batch multi-track Cut — this is the **ATTACH path**, which skips the (title, artist) pre-existing check and attaches directly to the already-created release after validating the id matches the natural key. The upload form is create-only; appending to a pre-existing release must go through the edit tools.
- The upload stream is copied to a temp file under `Path.GetTempPath()` with the appropriate extension (`.wav`, `.mp3`, or `.flac`). The audio processor reads from disk and requires the correct extension for format detection. The temp file is always deleted in a `finally` block — success or failure.
- `[RequestSizeLimit(~1.86 GB / 2_000_000_000)]` + `[RequestFormLimits(MultipartBodyLengthLimit = 2_000_000_000)]` lift the per-request ceiling above the framework default (~28 MB) so production-sized files are accepted. The body is streamed to the temp file, not buffered in memory.
- The upload stream is copied to a staging file under the **upload staging directory** (resolved from `Upload:StagingPath`, defaulting to a `staging` subdirectory under the FileDatabase vault path — on the data disk, **never** `Path.GetTempPath()`) with the appropriate extension (`.wav`, `.mp3`, or `.flac`). The audio processor reads from disk and requires the correct extension for format detection. The staging file is always deleted in a `finally` block — success or failure. The framework's own multipart file-section buffer is relocated off the system temp mount too: `Startup.ConfigureDomainServices` sets the `ASPNETCORE_TEMP` env var to the same staging directory, so neither on-disk copy of a large body lands on `/tmp` (a small RAM-backed tmpfs on the Linux host).
- `[RequestSizeLimit(~1.86 GB / 2_000_000_000)]` + `[RequestFormLimits(MultipartBodyLengthLimit = 2_000_000_000)]` lift the per-request ceiling above the framework default (~28 MB) so production-sized files are accepted. The body is streamed to the staging file, not buffered in memory.
- `UnifiedTrackService.UploadAsync` orchestrates: release resolution (CREATE or ATTACH, see above) → `TrackContentService.AddTrackAsync` (format-agnostic vault write via router) → `TrackManager` (SQL persist with `createdByUserId`). Release resolution runs the cardinality guard on both paths and, on the CREATE path, calls `ITrackService.FindOrCreateRelease` (returns `(ReleaseDto Release, bool WasCreated)`); if `WasCreated` is false, a concurrent upload won the race and the request is rejected as a duplicate rather than silently attaching.
- Returns 200 with the **persisted** `TrackDto` JSON (Id populated) on success. Returns 400 for missing/invalid form fields or unsupported audio format. Returns 409 for two distinct domain conditions: a pre-existing (title, artist) duplicate on the CREATE path (`DUPLICATE_RELEASE:` marker → 409 Conflict), or a track-number conflict within the release (`CARDINALITY_VIOLATION:` marker → 409 Conflict). Returns 500 if processing fails.
@@ -189,7 +189,7 @@ Soft-delete a release row. Used by the albums browser to remove an orphaned rele
- **Header `ApiKey`**: required. Validated by `ApiKeyAuthenticationMiddleware`.
- **Route parameter `id`** (long): the SQL track ID.
- **Form field `audioFile`** (`IFormFile`, required): the replacement audio bytes. File name must end in `.wav`, `.mp3`, or `.flac`.
- `[RequestSizeLimit(~1.86 GB / 2_000_000_000)]` + `[RequestFormLimits(MultipartBodyLengthLimit = 2_000_000_000)]` mirror the upload ceiling. The body is streamed to a temp file (correct extension preserved for the audio processor), always deleted in a `finally` block.
- `[RequestSizeLimit(~1.86 GB / 2_000_000_000)]` + `[RequestFormLimits(MultipartBodyLengthLimit = 2_000_000_000)]` mirror the upload ceiling. The body is streamed to a staging file under the upload staging directory (the same off-`/tmp` data-disk location as the upload path; correct extension preserved for the audio processor), always deleted in a `finally` block.
- Calls `UnifiedTrackService.ReplaceAudioAsync`, which: looks up SQL row by id → calls `TrackContentService.ReplaceTrackAudioAsync(entryKey, tempFilePath)` (registers new audio under the existing `EntryKey`; removes the stale backing file only on a cross-format swap, after the new write succeeds) → regenerates both waveform datums (best-effort; a datum failure is logged and swallowed) → writes the new audio's duration to `DurationSeconds` via `ITrackService.SetDuration` (unconditional overwrite; a failure is surfaced, not swallowed, to prevent derived aggregates like `MixRuntimeSeconds` from silently going stale).
- Returns 200 on success. Returns 400 if the file is missing or the format is unsupported. Returns 404 if the track id is not found. Returns 500 if vault processing fails.
@@ -379,6 +379,7 @@ Configured in `Startup.ConfigureDomainServices()`, applied to all endpoints via
5. Ensure the `images` vault exists (type `MediaVaultType.Image`, created on first boot if missing) via `InitializeImageVault`.
5a. Ensure the `track-waveforms` vault exists (type `MediaVaultType.Media`, created on first boot if missing) — holds per-track high-res visualizer datum keyed by `TrackEntity.EntryKey`.
6. Register singletons: `AudioProcessor`, `ImageProcessor`, `TrackService` (the `DeepDrftContent` version for vault operations), `WaveformProfileService`.
6a. **Upload staging directory** — resolve and create the on-disk staging directory (read `Upload:StagingPath`; if empty, default to a `staging` subdirectory under the FileDatabase vault path via `Startup.ResolveStagingPath`). Set the `ASPNETCORE_TEMP` env var to this directory before any request is served, relocating the framework's multipart file-section buffer (Layer 1) off the system temp mount. Register `UploadStagingDirectory` as a singleton so both `UploadTrack` and `ReplaceAudio` in `TrackController` stage to the same data-disk location (Layer 2) and never write to `/tmp` (a small RAM-backed tmpfs on the Linux host).
**In `Program.cs`** (SQL + AuthBlocks + wiring):
@@ -401,8 +402,9 @@ Mapped in `Development` only. Swagger UI at `/swagger` for testing endpoints loc
## Configuration files
- `appsettings.json`: Logging, hosting, CORS, and AuthBlocks config. **Does not contain secrets.**
- `appsettings.json`: Logging, hosting, CORS, AuthBlocks, and non-secret upload config. **Does not contain secrets.**
- `Logging`: standard ASP.NET structure.
- `Upload:StagingPath`: non-secret string. Empty default → a `staging` subdirectory under the FileDatabase vault path (on the data disk). Override to an absolute path when the vault default is not suitable. Consumed by `Startup.ResolveStagingPath`.
- `CorsSettings.AllowedOrigins`: array of origin URLs allowed to call the API (required; throws on startup if missing).
- `AuthBlocks:Jwt:Issuer`, `AuthBlocks:Jwt:Audience`: JWT validation settings (loaded from `environment/authblocks.json`).
- `environment/filedatabase.json` (required, loaded via CredentialTools, not in repo):
+53 -47
View File
@@ -20,6 +20,7 @@ public class TrackController : ControllerBase
private readonly UnifiedTrackService _unifiedService;
private readonly ITrackService _sqlTrackService;
private readonly WaveformProfileService _waveformProfileService;
private readonly UploadStagingDirectory _stagingDirectory;
private readonly ILogger<TrackController> _logger;
// FileDatabase is injected directly for PutTrack because that endpoint receives a pre-processed
@@ -34,6 +35,7 @@ public class TrackController : ControllerBase
UnifiedTrackService unifiedService,
ITrackService sqlTrackService,
WaveformProfileService waveformProfileService,
UploadStagingDirectory stagingDirectory,
ILogger<TrackController> logger)
{
_trackContentService = trackContentService;
@@ -41,9 +43,48 @@ public class TrackController : ControllerBase
_unifiedService = unifiedService;
_sqlTrackService = sqlTrackService;
_waveformProfileService = waveformProfileService;
_stagingDirectory = stagingDirectory;
_logger = logger;
}
// Builds a unique staging file path on the data disk with the validated extension. The caller MUST
// assign this to the local that its finally block guards BEFORE calling StageUploadAsync — that
// way a mid-copy abort (OperationCanceledException, IO error) still triggers deletion of the
// partially-written file. Staging lives under UploadStagingDirectory, never Path.GetTempPath() —
// on the Linux host /tmp is a small tmpfs that cannot hold a large WAV.
private string BuildStagingPath(string uploadExtension) =>
Path.Combine(_stagingDirectory.Path, Guid.NewGuid().ToString("N") + uploadExtension);
// Streams an uploaded audio body to the pre-allocated staging path. The caller owns the path and
// must delete it in a finally block; separating path generation from the copy ensures the finally
// guard fires even when CopyToAsync throws before returning.
private async Task StageUploadAsync(
IFormFile audioFile, string stagingPath, CancellationToken cancellationToken)
{
await using var stagingStream = new FileStream(
stagingPath, FileMode.CreateNew, FileAccess.Write, FileShare.None,
bufferSize: 81920, useAsync: true);
await using var uploadStream = audioFile.OpenReadStream();
await uploadStream.CopyToAsync(stagingStream, cancellationToken);
}
// Best-effort removal of a staging file. Logs and swallows — a stranded staging file is a
// disk-hygiene concern, not a request failure.
private void DeleteStagingFile(string stagingPath)
{
try
{
if (System.IO.File.Exists(stagingPath))
{
System.IO.File.Delete(stagingPath);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to delete staging file {StagingPath}", stagingPath);
}
}
// --- Literal-segment routes first ---
// These are declared before the parameterized "{trackId}" / "{id:long}" actions so route
// resolution never treats "page", "upload", or "meta" as a trackId.
@@ -319,23 +360,15 @@ public class TrackController : ControllerBase
var resolvedTrackNumber = trackNumber is > 0 ? trackNumber.Value : 1;
// The processor router selects by extension and reads from disk, so the temp file must carry
// the upload's real extension. Path.GetTempFileName() yields .tmp, which the router rejects —
// generate our own path preserving the validated .wav/.mp3/.flac extension.
var tempPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + uploadExtension);
// Build the staging path before the copy so the finally block can delete the partial file
// even if CopyToAsync throws mid-stream (client cancellation, disk-full, IO error).
var stagingPath = BuildStagingPath(uploadExtension);
try
{
await using (var tempStream = new FileStream(
tempPath, FileMode.CreateNew, FileAccess.Write, FileShare.None,
bufferSize: 81920, useAsync: true))
await using (var uploadStream = audioFile.OpenReadStream())
{
await uploadStream.CopyToAsync(tempStream, cancellationToken);
}
await StageUploadAsync(audioFile, stagingPath, cancellationToken);
var result = await _unifiedService.UploadAsync(
tempPath,
stagingPath,
trackName,
artist,
string.IsNullOrWhiteSpace(album) ? null : album,
@@ -381,17 +414,7 @@ public class TrackController : ControllerBase
}
finally
{
try
{
if (System.IO.File.Exists(tempPath))
{
System.IO.File.Delete(tempPath);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "UploadTrack: failed to delete temp file {TempPath}", tempPath);
}
DeleteStagingFile(stagingPath);
}
}
@@ -567,21 +590,14 @@ public class TrackController : ControllerBase
return BadRequest("Uploaded file must have a .wav, .mp3, or .flac extension");
}
// The processor router selects by extension and reads from disk, so the temp file must carry
// the upload's real extension. Mirrors UploadTrack — Path.GetTempFileName() yields .tmp.
var tempPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + uploadExtension);
// Build the staging path before the copy so the finally block can delete the partial file
// even if CopyToAsync throws mid-stream (client cancellation, disk-full, IO error).
var stagingPath = BuildStagingPath(uploadExtension);
try
{
await using (var tempStream = new FileStream(
tempPath, FileMode.CreateNew, FileAccess.Write, FileShare.None,
bufferSize: 81920, useAsync: true))
await using (var uploadStream = audioFile.OpenReadStream())
{
await uploadStream.CopyToAsync(tempStream, cancellationToken);
}
await StageUploadAsync(audioFile, stagingPath, cancellationToken);
var result = await _unifiedService.ReplaceAudioAsync(id, tempPath, cancellationToken);
var result = await _unifiedService.ReplaceAudioAsync(id, stagingPath, cancellationToken);
if (result.Success)
{
_logger.LogInformation("ReplaceAudio succeeded: id={Id}", id);
@@ -604,17 +620,7 @@ public class TrackController : ControllerBase
}
finally
{
try
{
if (System.IO.File.Exists(tempPath))
{
System.IO.File.Delete(tempPath);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "ReplaceAudio: failed to delete temp file {TempPath}", tempPath);
}
DeleteStagingFile(stagingPath);
}
}
+13
View File
@@ -0,0 +1,13 @@
namespace DeepDrftAPI.Models
{
/// <summary>
/// Non-secret upload tunables. <see cref="StagingPath"/> is the directory used to stage the raw
/// audio body during upload/replace-audio. It must live on the data disk, never the system temp
/// mount (on the Linux host <c>/tmp</c> is a small RAM-backed tmpfs that cannot hold a multi-hundred-MB
/// WAV). When null/empty it defaults to a "staging" subdirectory under the FileDatabase vault path.
/// </summary>
public class UploadSettings
{
public string? StagingPath { get; set; }
}
}
@@ -0,0 +1,10 @@
namespace DeepDrftAPI.Models
{
/// <summary>
/// The resolved, on-disk staging directory for upload/replace-audio bodies. Resolved once at
/// startup from <see cref="UploadSettings"/> (or the vault path default) and guaranteed to exist.
/// Injected into <c>TrackController</c> so the upload path never stages on the system temp mount.
/// A typed wrapper rather than a bare string so DI resolves it unambiguously.
/// </summary>
public sealed record UploadStagingDirectory(string Path);
}
+32
View File
@@ -47,9 +47,41 @@ namespace DeepDrftAPI
return db;
});
// Upload staging directory. Large audio bodies (multi-hundred-MB WAVs) must never stage on
// the system temp mount — on the Linux host /tmp is a small RAM-backed tmpfs. We move BOTH
// on-disk copies of an upload off /tmp onto the data disk:
// Layer 1 — the framework's multipart file-section buffer (FileBufferingReadStream), which
// reads its directory from the ASPNETCORE_TEMP env var (falling back to
// Path.GetTempPath()). Setting the var here, before the host runs, relocates it.
// Layer 2 — the controller's own staging file, via the injected UploadStagingDirectory.
// Default location is a "staging" subdirectory beside the vaults; override with
// Upload:StagingPath in appsettings.json.
var uploadSettings = builder.Configuration.GetSection("Upload").Get<UploadSettings>();
var stagingPath = ResolveStagingPath(uploadSettings?.StagingPath, vaultPath);
Directory.CreateDirectory(stagingPath);
// AspNetCoreTempDirectory caches this value on first read and throws if the directory is
// absent, so set it (and create the dir) before any request is served.
Environment.SetEnvironmentVariable("ASPNETCORE_TEMP", stagingPath);
builder.Services.AddSingleton(new UploadStagingDirectory(stagingPath));
return Task.CompletedTask;
}
/// <summary>
/// Resolves the absolute upload-staging directory. An explicit <paramref name="configuredPath"/>
/// (from <c>Upload:StagingPath</c>) wins; otherwise it defaults to a <c>staging</c> subdirectory
/// under <paramref name="vaultPath"/> — on the data disk, never the system temp mount. Pure so
/// the "never <c>/tmp</c>" invariant is unit-testable without standing up the host.
/// </summary>
public static string ResolveStagingPath(string? configuredPath, string vaultPath)
{
var path = string.IsNullOrWhiteSpace(configuredPath)
? Path.Combine(vaultPath, "staging")
: configuredPath;
return Path.GetFullPath(path);
}
private static async Task InitializeTrackVault(FileDatabase fileDatabase)
{
if (!fileDatabase.HasVault(VaultConstants.Tracks))
+3
View File
@@ -7,6 +7,9 @@
}
},
"AllowedHosts": "*",
"Upload": {
"StagingPath": ""
},
"CorsSettings": {
"AllowedOrigins": [
"https://localhost:12778",
+1 -1
View File
@@ -11,7 +11,7 @@ All interactive UI for the site. Blazor WebAssembly. Pages, controls, the stream
## Actual structure
- `Pages/`: Routable components. `Home.razor` (hero/about), `SessionDetail.razor` (session detail — hero-dominant overlay composition rendered via `<ReleaseHeroOverlay>`: large background hero image with darkening gradient shim, cover thumbnail + title + play button overlaid near the hero's bottom, genre/date/share overlaid at the top; uses `MudContainer MaxWidth="Large"`; **does not compose `ReleaseDetailScaffold`**`PlayTrack` is wired directly in its own `@code` block; mounts `<WaveformVisualizer>` ambient engine + `<WaveformVisualizerControlPopover>` directly; renders `<ReleaseDescription>` below the hero for the release's description blurb), `MixDetail.razor` (mix detail — composes `ReleaseDetailScaffold` with `TopRightAction` lava-lamp `<WaveformVisualizerControlPopover>`; hero+meta rendered via `<ReleaseHeroOverlay Class="mix-hero">` in the scaffold's `Hero` slot with `ShowHeader="false"` suppressing the duplicate masthead; square ~600px cover-as-background with metadata overlaid; full-bleed `<WaveformVisualizer>` is the mode-A centerpiece mounted by the page directly; renders `<ReleaseDescription>` below the hero for the release's description blurb), `CutDetail.razor` (album detail — composes `ReleaseDetailScaffold` with the `Ambient` slot carrying `<WaveformVisualizer>` + `<WaveformVisualizerControlPopover>` for mode-B ambient layer; renders `<ReleaseDescription>` below the hero for the release's description blurb; each track row carries a per-track `<SharePopover EntryKey="@track.EntryKey" />` aligned far-right as the last flex child of `.cut-detail-track-row`), `FramePlayer.razor` (embeddable iframe player at `/FramePlayer`, uses `EmbedLayout`; two mutually-exclusive modes via query params: `TrackEntryKey` stages a single track as before; `ReleaseEntryKey` resolves the release's ordered tracks via `FramePlayerViewModel`, stages track 0 via `PlayerService.StageTrack`, and arms the queue via `Queue.Arm` — no JS interop in either path, so both run safely during prerender; the first play gesture in `AudioPlayerBar` routes through `Queue.Start()` which streams the current track and clears the armed state; release embeds expose queue skip-prev/next navigation in the player bar while single-track embeds show none; track-title links open in a new tab so the iframe keeps playing). **No demo pages** (`Counter.razor`, `Weather.razor` do not exist).
- `Layout/`: `MainLayout.razor` (root layout, wraps in `AudioPlayerProvider`, hosts theme switcher), `DeepDrftMenu.razor` (branded menu bar), `NavMenu.razor` (nav list), `Pages.cs` (centralised nav index — `MenuPages` for header, `AllPages` for exhaustive list).
- `Layout/`: `MainLayout.razor` (root layout, wraps in `AudioPlayerProvider`, hosts theme switcher), `DeepDrftMenu.razor` (branded menu bar), `NavMenu.razor` (nav list), `Pages.cs` (centralised nav index — `MenuPages` for header, `AllPages` for exhaustive list), `DeepDrftFooter.razor` (site footer — logo, nav links, copyright; contains a "Privacy" button that opens a screen-centered tinted modal via `MudOverlay` (`DarkBackground="true"`, `Modal="true"`) carrying the anonymous-listener privacy note; trigger-button styling in the co-located `DeepDrftFooter.razor.css`, overlay chrome in the global `deepdrft-styles.css`; follows the `QueueOverlay`/`WaveformVisualizerControlPopover` `MudOverlay` idiom — scrim-click closes, panel stops propagation).
- `Controls/`: Reusable components.
- `TrackCard.razor`: Individual track display (image, name, artist, album, genre, release date). Play/pause icon controlled via `IsPaused` parameter.
- `TracksGallery.razor`: Responsive grid of `TrackCard` items (MudBlazor `MudGrid` with breakpoints). Fully controlled by parent; derives active-track state from cascaded player service.
@@ -42,6 +42,7 @@
<div class="dd-nav-actions">
<StreamNowButton ButtonClass="dd-nav-cta" ButtonLabel="Stream Now &#9654;"/>
<MudIconButton Icon="@(DarkLightModeButtonIcon)" Color="Color.Inherit" OnClick="@DarkModeToggle"/>
</div>
</nav>
</div>
@@ -49,7 +50,21 @@
@* Mobile Menu *@
<div class="d-flex d-sm-none">
<nav class="@NavClass">
<a class="dd-nav-brand" href="/">Deep DRFT</a>
<MudStack Row AlignItems="AlignItems.Center">
<a class="dd-nav-brand" href="/">
<MudImage Src="img/deepdrft-logo-l.webp"
Alt="Deep Drft Ornamental Logo Left"
Width="24"
Height="24 "/>
<span class="mx-2">Deep DRFT</span>
<MudImage Src="img/deepdrft-logo-r.webp"
Alt="Deep Drft Ornamental Logo Right"
Width="24"
Height="24 "/>
</a>
</MudStack>
<div class="dd-nav-actions">
<button type="button"
@@ -59,6 +74,8 @@
@onclick="ToggleMobileMenu">
<span></span><span></span><span></span>
</button>
<MudIconButton Icon="@(DarkLightModeButtonIcon)" Color="Color.Inherit" OnClick="@DarkModeToggle"/>
</div>
@if (_mobileMenuOpen)
@@ -117,6 +134,12 @@
private string DarkLightModeIconSvg => IsDarkMode ? DDIcons.GasLampLit : DDIcons.GasLamp;
private string DarkLightModeButtonIcon => IsDarkMode switch
{
true => DDIcons.GasLampLit,
false => DDIcons.GasLamp,
};
private async Task DarkModeToggle()
{
IsDarkMode = !IsDarkMode;
@@ -50,6 +50,10 @@
color: var(--deepdrft-white);
}
.dd-nav-dark .dd-nav-brand > ::deep img {
filter: invert(1);
}
/* Centred link list */
.dd-nav-links {
display: flex;
+65
View File
@@ -0,0 +1,65 @@
using DeepDrftAPI;
namespace DeepDrftTests;
/// <summary>
/// Guards the upload-staging directory resolution (<see cref="Startup.ResolveStagingPath"/>). The
/// load-bearing invariant: large audio bodies must stage on the data disk, never the system temp
/// mount — on the Linux host /tmp is a small RAM-backed tmpfs that cannot hold a multi-hundred-MB WAV.
/// </summary>
[TestFixture]
public class UploadStagingPathTests
{
[Test]
public void ResolveStagingPath_DefaultsToStagingUnderVault_WhenUnconfigured()
{
var vaultPath = Path.Combine(Path.GetTempPath(), "DeepDrftTests", Guid.NewGuid().ToString());
foreach (var configured in new[] { null, "", " " })
{
var resolved = Startup.ResolveStagingPath(configured, vaultPath);
Assert.Multiple(() =>
{
Assert.That(resolved, Is.EqualTo(Path.GetFullPath(Path.Combine(vaultPath, "staging"))),
"An unset/blank StagingPath must default to a 'staging' subdirectory under the vault path");
Assert.That(Path.IsPathFullyQualified(resolved), Is.True,
"The resolved staging path must be absolute");
});
}
}
[Test]
public void ResolveStagingPath_HonoursExplicitOverride()
{
var vaultPath = Path.Combine("data", "vaults");
var configured = Path.Combine(Path.GetTempPath(), "DeepDrftTests", "custom-staging", Guid.NewGuid().ToString());
var resolved = Startup.ResolveStagingPath(configured, vaultPath);
Assert.That(resolved, Is.EqualTo(Path.GetFullPath(configured)),
"An explicit Upload:StagingPath must win over the vault-path default");
}
[Test]
public void ResolveStagingPath_NeverResolvesIntoSystemTempDirectory_ForDataDiskVault()
{
// A production-shaped vault path on the data disk (the real config is a relative "../Database/Vaults").
// The resolved staging dir must sit under that vault, not under Path.GetTempPath() (= /tmp on Linux).
var vaultPath = Path.Combine("..", "Database", "Vaults");
var resolved = Startup.ResolveStagingPath(configuredPath: null, vaultPath);
var systemTemp = Path.GetFullPath(Path.GetTempPath());
// Note: because vaultPath is relative, Path.GetFullPath resolves it against the CWD, which is
// never the system temp directory. The StartsWith guard therefore catches the case where
// ResolveStagingPath mistakenly uses Path.GetTempPath() directly, rather than proving the
// absolute production path never overlaps with /tmp on any machine. The EndsWith assertion
// is the load-bearing check: it verifies the output is rooted under the vault tree, not
// under a hard-coded temp location.
Assert.That(resolved.StartsWith(systemTemp, StringComparison.Ordinal), Is.False,
"The default staging directory must never live under the system temp mount");
Assert.That(resolved, Does.EndWith(Path.Combine("Database", "Vaults", "staging")),
"The default staging directory must hang off the vault path on the data disk");
}
}
+38
View File
@@ -342,6 +342,44 @@ the open-question set: `product-notes/phase-17-player-queue-view.md`.
---
## Phase 18 — Theme / Dark-Mode Remediation (DRY token pass)
A punch-list of six theming symptoms Daniel reported — five in dark mode, one in light —
that all trace to **three** root causes in how component/page CSS bypasses the theme-aware
token layer and binds *constant* source tokens instead. Resolved as one coherent token pass,
not six per-component patches. Full design, architecture map, root-cause analysis, token
table, and track breakdown: `product-notes/theme-dark-mode-remediation.md`.
**Root-cause collapse (six symptoms → three causes):**
- **Cause 1 — neutral surfaces don't invert.** Home hero-left + footer (#3) and About light
sections (#4) hardcode `background: var(--deepdrft-white)` / text on `--deepdrft-navy`
brand *constants* that are identical in `:root` and `.deepdrft-theme-dark`, so they cannot
flip. Fix: bind a theme-aware `--deepdrft-page-surface` / `--deepdrft-page-text` alias. The
inversion must stay **neutral to the intentionally navy/green decorative sections**
(`.section-dark`, `.split-left`, `.cta-banner`, hero overlays) — a classify-then-recolor job.
- **Cause 2 — play chip binds a constant grey.** `PlayStateIcon.razor.css` `.icon-container`
hardcodes `--deepdrft-soft` (#e3e7ec). One shared component drives the release-hero chip, the
Cut track rows, *and* the player bar — so it reads "greyed-out" over dark heroes (#5) and "too
bright" on the navy player surface (#6). Fix: theme-aware `--deepdrft-play-chip` (moss-green +
navy glyph in dark) with a translucent `--deepdrft-play-chip-soft` override for the player bar.
- **Cause 3 — no theme-aware popover surface.** Light-mode default MudPopovers read "too dark"
(#1); there's no token for the wanted "desaturated navy." Fix: a `--deepdrft-popover-surface`
token; leave the bespoke `--deepdrft-panel-ground` panels alone.
**Sequenced as four tracks, `T1 → {T2, T3, T4}`.** T1 (additive token foundation in
`deepdrft-tokens.css`) is the cold-start prerequisite; T2 (neutral-surface inversion), T3
(play-chip theming), T4 (popover token) fan out behind it and are mutually independent. Pure
CSS-token pass — no source code, data layer, or streaming-seam changes. Prior art:
`product-notes/track-card-theming.md` solved this exact class of theme-aware recolor once
already; this generalizes the fix from one component to the pattern.
**Open questions for Daniel (spec §5):** (1) dark neutral surface = navy *ground* (continuous
field — recommended for footer/hero) vs. *elevated* navy-mid (distinct panels); (2) popover
target distance from white (recommend a light `color-mix(navy ~8%, white)` wash). Exact green
opacity + muted-text mixes are tune-on-screen details, not decision gates.
---
## 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.
@@ -0,0 +1,243 @@
# Theme / Dark-Mode Remediation — DRY token pass
Status: proposed. Author: product-designer. Date: 2026-06-19. Implementer: TBD (separate delegation).
A design analysis of the DeepDrft theme system, focused on the dark theme, with a DRY
remediation plan that resolves a punch-list of six reported symptoms through **shared
theme tokens** rather than per-component patches. Daniel reported the symptoms; this note
maps the architecture, isolates the root causes, and sequences the fix.
Prior art this borrows from: `product-notes/track-card-theming.md` (landed 2026-06-05) —
the same class of problem (theme-aware recolor under `.deepdrft-theme-dark`, legible in
both palettes) solved once already with the same mechanism. This note generalizes that
fix from one component to the recurring pattern behind it.
---
## 1. How the theme system is wired today (the map)
There are **three** colour layers, and the bugs all live in how the third one bypasses the
first two.
### Layer A — MudBlazor palettes (C#)
`DeepDrftShared.Client/Common/DeepDrftPalettes.cs` defines `PaletteLight Light`,
`PaletteDark Dark` (+ `CmsLight`, `EmbedLight`, `EmbedDark`). `MainLayout.razor` mounts
`<MudThemeProvider Theme="DeepDrftPalettes.Default" IsDarkMode="_isDarkMode" />`. MudBlazor
injects these as `--mud-palette-*` CSS variables that **flip automatically** when
`IsDarkMode` toggles. This is the part that works: anything reading `--mud-palette-surface`,
`--mud-palette-background`, `--mud-palette-text-primary` inverts correctly for free.
### Layer B — DeepDrft design tokens (CSS)
`DeepDrftShared.Client/wwwroot/styles/deepdrft-tokens.css` defines two token families:
- **Source tokens** — raw brand colours, *constant across both themes*:
`--deepdrft-navy (#112338)`, `--deepdrft-white (#FAFAF8)`, `--deepdrft-green-accent
(#3D7A68)`, `--deepdrft-soft (#e3e7ec)`, etc. These never change between light and dark.
- **Theme-aware aliases**`--theme-surface`, `--theme-surface-soft`, `--theme-primary…senary`,
`--gradient-base/accent/warm/light`, `--deepdrft-surface`, `--deepdrft-background`. These
**are** redefined inside the `.deepdrft-theme-dark` block (the wrapper class
`MainLayout.ThemeWrapperClass` puts on the root div), so they flip.
The token file's own header comment establishes the intended discipline: source tokens are
"source of truth"; theme-aware aliases are what page CSS is *supposed* to consume so it
"resolve[s] coherently across themes."
### Layer C — component / page CSS
Scoped `*.razor.css` files and the global `DeepDrftPublic/wwwroot/styles/deepdrft-styles.css`.
**This is where the discipline breaks.** Page sections that should track the theme surface
instead reach straight past Layer B and bind to the *constant source tokens* of Layer A
(`--deepdrft-white`, `--deepdrft-navy`, `--deepdrft-soft`). A constant cannot invert — so
these surfaces stay light-on-navy-site no matter the mode.
---
## 2. Root causes (six symptoms → three causes)
The six reported symptoms collapse to **three** root causes. That collapse is the whole
point of doing this as one coherent pass rather than six patches.
### Cause 1 — "neutral surface" sections bind to constant source tokens, so they never invert
*(Symptoms: Home hero-left + footer (#3); About light sections (#4))*
These rules are the smoking gun (all bind a constant, not a theme alias):
- `Home.razor.css``.hero-left`, `.section`, `.section-divider`, `.section-body p`,
`.medium-card`, `.split-right`, `.connect-*``background: var(--deepdrft-white)`,
text `color: var(--deepdrft-navy)`.
- `About.razor.css``.hero-left`, `.hero-image-pane`, `.bio`/process gradients →
`background: var(--deepdrft-white)`, text on `--deepdrft-navy`.
- `DeepDrftFooter.razor.css``.deepdrft-footer``background: var(--deepdrft-white)`,
logo/links text on `--deepdrft-navy` / `--deepdrft-muted`.
`--deepdrft-white` is `#FAFAF8` in **both** `:root` and `.deepdrft-theme-dark` — it is a
brand constant, never re-aliased. So in dark mode these read as bright off-white panels with
dark text floating in a navy site. The fix is **not** to hardcode a dark colour; it is to
**bind these surfaces to a theme-aware alias** that already inverts.
**Critical nuance Daniel flagged:** the fix must be *neutral to the existing navy and green
accent sections.* The page already has sections that are **intentionally** navy/green in
both modes — `.section-dark` (navy), `.split-left` (green), `.cta-banner` (navy), the
`ReleaseHeroOverlay` (dark image). Those are decorative-by-design and must **not** be touched
by the inversion. Only the "default page surface" sections (the ones currently white-because-
light) should flip. This is a *classification* problem first, a recolor second: separate
"neutral surface" from "decorative accent" and only re-token the former.
### Cause 2 — the play-icon chip background binds `--deepdrft-soft` (constant light grey)
*(Symptoms: greyed-out play icon on release heroes / track lists (#5); too-bright player-bar play button (#6))*
`PlayStateIcon.razor.css` `.icon-container` hardcodes `background-color: var(--deepdrft-soft)`
(`#e3e7ec` — a light grey, constant across both themes). `PlayStateIcon` is the **single**
glyph component used by the release heroes, the Cut track rows, *and* the player bar. So one
constant drives all of these:
- Over a **dark hero image / navy track list** → the light-grey chip reads dull and
"greyed-out" (#5). Daniel wants: **moss-green chip background, navy play glyph** in dark mode.
- On the **bright player-surface** → the same light-grey chip reads "very bright" against the
navy dock (#6). Daniel wants: **same green, much less opaque** (a translucent green wash,
not a solid bright fill).
Both are the same `--deepdrft-soft` constant failing to be theme-aware. One component, one
token — fix the token's dark-mode value and both surfaces resolve. Note the two contexts want
*different green treatments* (solid green chip on the hero; translucent green wash in the
player bar), so the chip background should be a **token the player-bar context can override**,
not a single flat value — see §3.
### Cause 3 — popover surface has no theme-aware token; light mode reads "too dark"
*(Symptom: light-theme popover background too dark, wants desaturated navy (#1))*
Two different popover families exist and they are styled inconsistently:
- **Bespoke panels** (visualizer controls, queue, privacy) deliberately use
`--deepdrft-panel-ground` (`#1a1c22`, a dark charcoal) for their dark-glass chrome. These
are *meant* to be dark in both modes — leave them.
- **MudBlazor default popovers** (selects, menus, tooltips, the share popover body) inherit
`--mud-palette-surface`. In light mode `Surface = #FAFAF8`, but elevation-overlay tinting +
the `--deepdrft-panel-ground` charcoal leaking through shared chrome is making them read
darker/muddier than intended. Daniel's ask — "a more desaturated navy" — says the *target*
isn't pure white; it's a **soft desaturated-navy surface**. There is no token for that today,
so each popover improvises.
The fix is a **dedicated theme-aware popover-surface token** (`--deepdrft-popover-surface`)
with a desaturated-navy value in light mode and the existing panel-ground in dark mode, bound
once at the MudPopover surface so every default popover picks it up.
---
## 3. The DRY remediation — token structure
The unifying move: **page/component CSS must bind theme-aware aliases, and any surface that
must invert gets a named alias in `deepdrft-tokens.css` (defined twice — `:root` + `.deepdrft-theme-dark`).**
No surface colour is hardcoded at the component level. This is exactly the Layer-B discipline
the token file's header already declares; the work is making the consumers obey it.
### New / clarified tokens (in `deepdrft-tokens.css`)
| Token | Light (`:root`) | Dark (`.deepdrft-theme-dark`) | Replaces |
|---|---|---|---|
| `--deepdrft-page-surface` | `var(--deepdrft-white)` | `var(--deepdrft-navy)` (ground) or `--deepdrft-navy-mid` (elevated) | the literal `--deepdrft-white` on neutral page sections |
| `--deepdrft-page-text` | `var(--deepdrft-navy)` | `var(--deepdrft-white)` | the literal `--deepdrft-navy` text on neutral sections |
| `--deepdrft-page-text-muted` | `var(--deepdrft-muted)` | `color-mix(... lighter)` | muted body/eyebrow text that must stay legible on dark |
| `--deepdrft-play-chip` | `var(--deepdrft-soft)` | `var(--deepdrft-green-accent)` | `.icon-container` background |
| `--deepdrft-play-glyph` | (current) | `var(--deepdrft-navy)` | play glyph colour in dark |
| `--deepdrft-play-chip-soft` | derived | `color-mix(green-accent ~30%, transparent)` | player-bar translucent variant (#6) |
| `--deepdrft-popover-surface` | desaturated navy (e.g. `color-mix(navy 8%, white)`) | `var(--deepdrft-panel-ground)` | MudPopover default surface (#1) |
Values above are *direction, not final*. Per project memory (decorative-palette contrast
targets the actual WCAG threshold for the element type — large text 3:1, pushing toward
vibrancy), the implementer should tune the exact mixes on screen; the **structure** is the
deliverable here, the hex is theirs to land.
### Why tokens, not per-component fixes
- **One source of truth per concept.** "Neutral page surface," "play chip," "popover surface"
each become *one* token. A future page that needs a neutral surface binds the token and
inverts for free — no new dark-mode rule to remember (the backfill-cliff smell the
*design-for-adaptability* memory warns against).
- **Neutrality to accents is structural, not vigilance-based.** Because only neutral-surface
sections get re-tokened and the decorative navy/green sections keep their explicit brand
colours, the inversion *cannot* accidentally flip a section that's meant to stay navy. The
classification is encoded in *which token a section binds*, not in a reviewer noticing.
- **Player-bar vs. hero divergence is expressible.** Cause 2 needs the same green in two
opacities. A `--deepdrft-play-chip` token + a `--deepdrft-play-chip-soft` override the
player-bar context sets means one green, two contexts, zero duplication.
### What stays untouched (the neutrality guardrail)
`.section-dark`, `.split-left`, `.cta-banner` (Home + About), `ReleaseHeroOverlay` dark-image
chrome, and the bespoke `--deepdrft-panel-ground` panels (visualizer/queue/privacy) keep their
explicit brand colours. They are decorative-by-design and already correct in both modes. The
remediation must **not** route them through the new neutral-surface tokens.
---
## 4. Track / wave breakdown (for clean dispatch)
Sequenced so the token layer lands first and the component re-pointing fans out behind it.
Tracks T2T4 are parallel once T1 is in.
### T1 — Token foundation *(cold-start prerequisite)*
Add the theme-aware tokens from §3 to `deepdrft-tokens.css` — each defined in **both** `:root`
and `.deepdrft-theme-dark`. No component consumes them yet; this is a pure additive token
slice. Tune the dark-mode values on screen. **Load-bearing for everything below.**
- Scope: `DeepDrftShared.Client/wwwroot/styles/deepdrft-tokens.css` only.
- Acceptance: tokens resolve to the right value in each mode (verify via devtools); no visual
change yet (nothing binds them).
### T2 — Neutral-surface inversion *(Cause 1 → symptoms #3, #4)*
Re-point the neutral page-surface sections from constant source tokens to `--deepdrft-page-surface`
/ `--deepdrft-page-text` / `--deepdrft-page-text-muted`. **Classify first** — only the neutral
sections; leave `.section-dark` / `.split-left` / `.cta-banner` / hero-overlay alone.
- Scope: `Home.razor.css`, `About.razor.css`, `DeepDrftFooter.razor.css`.
- Acceptance: in dark mode the Home hero-left, the medium grid, the footer, and the About
light sections render dark-surface/light-text; the navy and green accent sections are
visually unchanged; light mode is pixel-identical to today.
- Risk: the appbar already has dark-mode handling (`deepdrft-styles.css §5`); confirm the
footer/hero changes don't double-invert anything the appbar rules already cover.
### T3 — Play-chip theming *(Cause 2 → symptoms #5, #6)*
Re-point `.icon-container` background from `--deepdrft-soft` to `--deepdrft-play-chip`; set the
dark play glyph to `--deepdrft-play-glyph` (navy); in the **player-bar context only**, override
the chip to the translucent `--deepdrft-play-chip-soft`.
- Scope: `PlayStateIcon.razor.css` (+ a player-bar-scoped override, likely in
`AudioPlayerBar.razor.css` or a context class on the bar's `.icon-container`).
- Acceptance (dark mode): release-hero + Cut-track-row play chips are **moss-green with a navy
glyph**; the player-bar play button is the **same green but markedly less opaque**; light
mode unchanged. Confirm hover states still read.
- Note: `PlayStateIcon` is shared — verify the chip change is acceptable on **every** mount
(heroes, track rows, player bar) and that the player-bar override is the only context-specific
divergence.
### T4 — Popover surface token *(Cause 3 → symptom #1)*
Introduce `--deepdrft-popover-surface` and bind MudBlazor's default popover surface to it so
light-mode popovers read as soft desaturated-navy rather than the current too-dark muddle.
**Do not** touch the bespoke `--deepdrft-panel-ground` panels.
- Scope: `deepdrft-styles.css` (a `.mud-popover` / popover-surface rule binding the new token);
token already added in T1.
- Acceptance: light-mode default popovers (selects/menus/share body) render desaturated-navy;
dark-mode popovers unchanged; the visualizer/queue/privacy panels are untouched.
- Open question (resolve during T4): confirm whether the "too dark" popover is a MudBlazor
elevation-overlay artifact or panel-ground leakage — the fix differs slightly (override the
overlay tint vs. set the surface). One devtools inspection settles it; flagged so the
implementer checks rather than guesses.
### Dependency shape
`T1 → {T2, T3, T4}`. T1 is the only cold-start item. T2/T3/T4 are independent of each other
and can land in any order or in parallel once T1 is in. None of them touch source code, the
data layer, or the streaming seam — this is a pure CSS-token pass.
---
## 5. Open questions for Daniel
1. **Dark neutral-surface = ground or elevated?** Should the inverted Home/About/footer
surfaces be the navy *ground* (`--deepdrft-navy`, matching the site background — sections
dissolve into one continuous dark field) or *elevated* navy-mid (`--deepdrft-navy-mid`
sections read as distinct raised panels)? Recommend **ground** for the footer/hero (continuous
field, less busy) and let the medium-cards stay as bordered panels on that ground. This is a
taste call; flag for Daniel.
2. **Popover target colour (#1).** "Desaturated navy" — how far from white? Recommend a light
wash (`color-mix(navy ~8%, white)`) so it stays clearly a light-mode surface, not a dark one.
Confirm direction on screen.
3. Everything else (exact green opacity for the player-bar chip, exact muted-text mix) is a
tune-on-screen detail, not a decision gate.
These are the only items that change the shape of the work; the rest is mechanical.