diff --git a/DeepDrftPublic.Client/Common/SeoEnvironment.cs b/DeepDrftPublic.Client/Common/SeoEnvironment.cs
new file mode 100644
index 0000000..a9e017d
--- /dev/null
+++ b/DeepDrftPublic.Client/Common/SeoEnvironment.cs
@@ -0,0 +1,33 @@
+using Microsoft.AspNetCore.Components;
+
+namespace DeepDrftPublic.Client.Common;
+
+///
+/// Environment-gated robots bridge (Phase 22 remediation §4). The beta/staging site is web-hosted and must
+/// not be crawled, so the default robots directive is environment-gated: index,follow only in
+/// Production, noindex,nofollow everywhere else. A per-page override
+/// still wins — this only sets the default.
+///
+///
+/// Crawlers read the server-prerendered HTML, so correctness lives in the server prerender pass — but the
+/// value must be identical across the InteractiveAuto double render (AC6), so the WASM pass has to resolve
+/// the same flag. The WASM assembly has no IWebHostEnvironment (config comes from the server). This
+/// mirrors the DarkMode bridge exactly: a scoped service the server seeds during prerender (from
+/// IWebHostEnvironment.IsProduction()) and [PersistentState] rounds to the client, so both
+/// passes resolve the identical value. SeoHead injects this rather than an environment dependency,
+/// honouring the no-environment-in-the-component constraint.
+///
+///
+public class SeoEnvironment
+{
+ ///
+ /// True only in Production. Seeded server-side and persisted across the WASM boot. Defaults to
+ /// false so the fail-safe is "do not index" — a missing bridge never accidentally opens a
+ /// non-production site to crawlers.
+ ///
+ [PersistentState]
+ public bool IsProduction { get; set; }
+
+ /// The environment-gated default robots directive. Explicit page values override this.
+ public string DefaultRobots => IsProduction ? "index,follow" : "noindex,nofollow";
+}
diff --git a/DeepDrftPublic.Client/Common/SeoJsonLd.cs b/DeepDrftPublic.Client/Common/SeoJsonLd.cs
new file mode 100644
index 0000000..9c256fa
--- /dev/null
+++ b/DeepDrftPublic.Client/Common/SeoJsonLd.cs
@@ -0,0 +1,127 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace DeepDrftPublic.Client.Common;
+
+///
+/// Typed schema.org JSON-LD nodes (Phase 22, OQ5 — the typed-builder option). Each record mirrors one
+/// schema.org type; renders a node to the <script type="application/ld+json">
+/// body. Keeping the shape in C# (not hand-written JSON in pages) is what makes the medium→type mapping
+/// live in one place (DRY, §4.3) and the output unit-testable (AC5) rather than a manual validator pass.
+///
+///
+/// All nodes share so the @context/@type pair serialises first and
+/// once. Null properties are omitted (the serializer ignores nulls) so partial data never emits an empty
+/// or broken node (C6/AC4).
+///
+///
+public static class SeoJsonLd
+{
+ private static readonly JsonSerializerOptions Options = new()
+ {
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ // schema.org keys are PascalCase ("@type", "byArtist", "datePublished"); JsonPropertyName drives
+ // each. Encoder relaxed so the JSON sits inline in HTML without over-escaping apostrophes etc.
+ // Note: the relaxed encoder leaves <, >, & raw — InlineSafe re-escapes exactly those before the
+ // body is injected into the
+ }
+
+
+@code {
+ /// The page's resolved SEO input, built via a factory.
+ [Parameter, EditorRequired] public required SeoModel Model { get; set; }
+
+ private string _fullTitle = string.Empty;
+ private string _description = string.Empty;
+ private string _canonical = string.Empty;
+ private string _robots = string.Empty;
+ private string _ogType = "website";
+ private string _image = string.Empty;
+ private bool _hasCover;
+ private string? _isoDuration;
+
+ protected override void OnParametersSet()
+ {
+ _fullTitle = $"{Model.Title} · {Seo.TitleSuffix}";
+ _description = string.IsNullOrWhiteSpace(Model.Description) ? Seo.DefaultDescription : Model.Description;
+ // Default robots is environment-gated (non-production → noindex,nofollow) so beta/staging is never
+ // crawled; an explicit per-page Robots still wins (e.g. the 404's / soft-404's noindex,follow).
+ _robots = string.IsNullOrWhiteSpace(Model.Robots) ? SeoEnv.DefaultRobots : Model.Robots;
+ _ogType = OgTypeString(Model.OgType);
+
+ // Canonical: BaseUrl + the model's path, defaulting to the current relative path. The origin is
+ // always config (no browser API) so prerender and WASM agree (§5).
+ var path = Model.CanonicalPath ?? RelativePath();
+ _canonical = SeoUrls.Absolute(Seo, path);
+
+ _hasCover = !string.IsNullOrWhiteSpace(Model.ImagePath);
+ _image = SeoUrls.CoverOrDefault(Seo, Model.ImagePath);
+ _isoDuration = SeoUrls.IsoDuration(Model.DurationSeconds);
+ }
+
+ private string RelativePath()
+ {
+ var path = Nav.ToBaseRelativePath(Nav.Uri);
+ var query = path.IndexOf('?');
+ if (query >= 0) path = path[..query];
+ return "/" + path;
+ }
+
+ private static string OgTypeString(SeoOgType type) => type switch
+ {
+ SeoOgType.MusicAlbum => "music.album",
+ SeoOgType.MusicSong => "music.song",
+ _ => "website",
+ };
+}
diff --git a/DeepDrftPublic.Client/Pages/About.razor b/DeepDrftPublic.Client/Pages/About.razor
index 57247ca..5e13d18 100644
--- a/DeepDrftPublic.Client/Pages/About.razor
+++ b/DeepDrftPublic.Client/Pages/About.razor
@@ -2,8 +2,9 @@
@using DeepDrftPublic.Client.Controls
@implements IAsyncDisposable
@inject IJSRuntime JsRuntime
+@inject SeoOptions Seo
-The Collective - Deep DRFT
+
@* ──────────────────────────────────────────────────────────────────────────────
THE LINER NOTES — a numbered three-movement editorial essay.
diff --git a/DeepDrftPublic.Client/Pages/AlbumsView.razor b/DeepDrftPublic.Client/Pages/AlbumsView.razor
index 071ee68..d9d2a1a 100644
--- a/DeepDrftPublic.Client/Pages/AlbumsView.razor
+++ b/DeepDrftPublic.Client/Pages/AlbumsView.razor
@@ -1,7 +1,9 @@
@page "/cuts"
+@using DeepDrftModels.Enums
@using DeepDrftPublic.Client.Controls
+@inject SeoOptions Seo
-DeepDrft Cuts
+
@* The shared release-card grid; each card routes to /cuts/{entryKey} via the one ReleaseRoutes table.
Cuts show a track count where other media show the artist, supplied via SubtitleResolver. *@
diff --git a/DeepDrftPublic.Client/Pages/ArchiveView.razor b/DeepDrftPublic.Client/Pages/ArchiveView.razor
index 2a5069e..9b121d4 100644
--- a/DeepDrftPublic.Client/Pages/ArchiveView.razor
+++ b/DeepDrftPublic.Client/Pages/ArchiveView.razor
@@ -1,8 +1,9 @@
@page "/archive"
@using DeepDrftModels.Enums
@using DeepDrftPublic.Client.Controls
+@inject SeoOptions Seo
-DeepDrft Archive
+
diff --git a/DeepDrftPublic.Client/Pages/CutDetail.razor b/DeepDrftPublic.Client/Pages/CutDetail.razor
index 8d2809b..67721de 100644
--- a/DeepDrftPublic.Client/Pages/CutDetail.razor
+++ b/DeepDrftPublic.Client/Pages/CutDetail.razor
@@ -3,8 +3,7 @@
@using DeepDrftPublic.Client.Controls
@using DeepDrftPublic.Client.Services
@inherits CutDetailBase
-
-@(ViewModel.Release?.Title ?? "Cut") - DeepDrft
+@inject SeoOptions Seo
@if (ViewModel.IsLoading)
{
@@ -17,6 +16,9 @@
}
else if (ViewModel.NotFound || ViewModel.Release is null)
{
+ @* Soft-404: a bad key renders a 200 "not found" view, so it must carry noindex so it is not indexed
+ (mirrors the dedicated /404 NotFound page). *@
+