Compare commits
7 Commits
261b11436e
...
5298cab9b1
| Author | SHA1 | Date | |
|---|---|---|---|
| 5298cab9b1 | |||
| e05d93a67b | |||
| fd4fdd2624 | |||
| 639f4741e6 | |||
| d7071fdbc2 | |||
| 37cf19c405 | |||
| 37bbfb947f |
@@ -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
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"Upload": {
|
||||
"StagingPath": ""
|
||||
},
|
||||
"CorsSettings": {
|
||||
"AllowedOrigins": [
|
||||
"https://localhost:12778",
|
||||
|
||||
@@ -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 ▶"/>
|
||||
<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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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 1–5. 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 T2–T4 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.
|
||||
Reference in New Issue
Block a user