From d82f8708b3d705eeed390c5f691bcdf3ec49179a Mon Sep 17 00:00:00 2001
From: GenWave Radio
Date: Thu, 6 Aug 2026 09:07:39 -0600
Subject: [PATCH 1/2] =?UTF-8?q?fix(ui):=20catalog=20font=20detail=20?=
=?UTF-8?q?=E2=80=94=20licence=20trio,=20honest=20installed=20state;=20War?=
=?UTF-8?q?drobe=20rename=20(v3.1.1=20font=20half)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Dean's T204 demo feedback. (1) The pre-install trust surface now shows
the licence: CatalogEntryResponse projects FontLicense/Version/Subset via
the hardened serializer (all six font args passed BY NAME — the null-soup
guard), FontDetailPanel renders the same 'licence · version · subset'
line the Wardrobe page uses. (2) Installed-state truth: /api/fonts rides
the page load (fail-closed to [] — documented direction: a stale
'Install' costs an idempotent re-install; a false 'Installed' costs an
operator a missing font), Installed chip + Re-install label, the
specimen caption is state-neutral in both states, and a successful
install flips state locally (failure flips nothing). (3) /library ->
/wardrobe: nav 'Wardrobe', WardrobeClient/WardrobePage/WardrobeIcon,
media-Libraries docstring disambiguated; backend route + DTO names stay
(wire contracts don't chase UI labels).
---
.../font-pack-shelf-specimen.spec.tsx | 85 ++++++++++++++++++-
...y.spec.tsx => font-pack-wardrobe.spec.tsx} | 28 +++---
.../__specs__/persona-catalog-page.spec.tsx | 38 +++++++++
admin-ui/app/(authed)/_components/icons.tsx | 12 +--
.../app/(authed)/_components/nav-items.ts | 25 ++++--
.../persona-catalog/FontDetailPanel.tsx | 57 +++++++++++--
.../persona-catalog/PersonaCatalogClient.tsx | 41 +++++++--
.../persona-catalog/SpecimenBlock.tsx | 2 +-
.../(authed)/persona-catalog/font-format.ts | 29 +++++++
.../app/(authed)/persona-catalog/page.tsx | 39 ++++++++-
.../app/(authed)/persona-catalog/types.ts | 19 +++--
.../WardrobeClient.tsx} | 39 +++------
.../(authed)/{library => wardrobe}/page.tsx | 16 ++--
.../(authed)/{library => wardrobe}/types.ts | 2 +-
src/GenWave.Host/Api/CatalogController.cs | 7 +-
src/GenWave.Host/Api/CatalogEntryResponse.cs | 18 +++-
.../Specs/Story279_FontKindAssets.cs | 18 ++++
17 files changed, 386 insertions(+), 89 deletions(-)
rename admin-ui/__specs__/{font-pack-library.spec.tsx => font-pack-wardrobe.spec.tsx} (74%)
rename admin-ui/app/(authed)/{library/FontLibraryClient.tsx => wardrobe/WardrobeClient.tsx} (72%)
rename admin-ui/app/(authed)/{library => wardrobe}/page.tsx (87%)
rename admin-ui/app/(authed)/{library => wardrobe}/types.ts (93%)
diff --git a/admin-ui/__specs__/font-pack-shelf-specimen.spec.tsx b/admin-ui/__specs__/font-pack-shelf-specimen.spec.tsx
index 0b9d4b08..cb190e93 100644
--- a/admin-ui/__specs__/font-pack-shelf-specimen.spec.tsx
+++ b/admin-ui/__specs__/font-pack-shelf-specimen.spec.tsx
@@ -137,6 +137,19 @@ const FONT_DETAIL: CatalogEntryDetailDto = {
fontFamily: "Space Grotesk",
fontByteTotal: 7844,
fontSpecimenFile: "libre-grotesk-variable-latin.woff2",
+ fontLicense: "OFL-1.1",
+ fontVersion: "2.000",
+ fontSubset: "latin",
+};
+
+// PLAN T204 (Dean's post-v3.1.0 review): "no mention of license anywhere in the panel" — the
+// all-null edge, mirroring a manifest that failed to parse server-side (CatalogController's own
+// degrade-not-500 posture).
+const FONT_DETAIL_WITHOUT_LICENCE: CatalogEntryDetailDto = {
+ ...FONT_DETAIL,
+ fontLicense: null,
+ fontVersion: null,
+ fontSubset: null,
};
const ENTRY_URL = "/api/catalog/entries/libre-grotesk";
@@ -266,13 +279,20 @@ describe("Feature: packs on the shelf with an honest specimen", () => {
});
/** Opens Libre Grotesk's detail panel via the shelf card's own title text (the fontFamily "Space
- * Grotesk" — see this file's own fixture remarks for why it deliberately differs from the slug). */
- async function openLibreGroteskDetail(fetchMock: jest.MockedFunction): Promise {
+ * Grotesk" — see this file's own fixture remarks for why it deliberately differs from the slug).
+ * `installedFontSlugs` (PLAN T204) defaults to `[]`, the same "not installed" default
+ * `PersonaCatalogClient`'s own prop carries — pass `["libre-grotesk"]` to exercise the
+ * already-installed path. */
+ async function openLibreGroteskDetail(
+ fetchMock: jest.MockedFunction,
+ installedFontSlugs: string[] = []
+ ): Promise {
global.fetch = fetchMock;
render(
<>
>
@@ -425,6 +445,67 @@ describe("Feature: packs on the shelf with an honest specimen", () => {
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
expect(await screen.findByText('"Space Grotesk" installed.')).toBeInTheDocument();
});
+
+ it("flips the detail panel to Installed/Re-install locally once the install succeeds, no reload (PLAN T204)", async () => {
+ const fetchMock = fontFlowFetchMock();
+ // Starts NOT installed — the default `installedFontSlugs=[]` — so the button starts "Install".
+ await openInstallDialog(fetchMock);
+
+ const dialog = within(screen.getByRole("dialog"));
+ await act(async () => {
+ fireEvent.click(dialog.getByRole("button", { name: "Confirm install" }));
+ await Promise.resolve();
+ });
+ await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
+
+ // The detail panel itself (still open — only the confirm dialog closed) now reads installed,
+ // with no second fetch and no page reload: PersonaCatalogClient.handleFontInstalled flips its
+ // own local state on the toast, the cheap path this task's own spec calls for.
+ expect(screen.getByText("Installed")).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Re-install" })).toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: "Install" })).not.toBeInTheDocument();
+ });
+ });
+
+ describe("Scenario: the licence is visible before install (PLAN T204, Dean's post-v3.1.0 review)", () => {
+ it("shows the licence · version · subset line on the pre-install review panel", async () => {
+ const fetchMock = fontFlowFetchMock();
+ await openLibreGroteskDetail(fetchMock);
+
+ expect(await screen.findByText("OFL-1.1 · v2.000 · latin")).toBeInTheDocument();
+ });
+
+ it("degrades to 'Licence unknown' rather than a blank line when the manifest carries none", async () => {
+ const fetchMock = fontFlowFetchMock({ entry: makeJsonResponse(200, FONT_DETAIL_WITHOUT_LICENCE) });
+ await openLibreGroteskDetail(fetchMock);
+
+ expect(await screen.findByText("Licence unknown")).toBeInTheDocument();
+ });
+ });
+
+ describe("Scenario: installed-state awareness (PLAN T204, Dean's post-v3.1.0 review)", () => {
+ it("shows Install and a state-neutral specimen caption when the pack is not installed", async () => {
+ const fetchMock = fontFlowFetchMock();
+ await openLibreGroteskDetail(fetchMock);
+ await screen.findByTestId("font-specimen");
+
+ expect(screen.getByRole("button", { name: "Install" })).toBeInTheDocument();
+ expect(screen.queryByText("Installed")).not.toBeInTheDocument();
+ expect(screen.getByText("Transient specimen — previewing installs nothing")).toBeInTheDocument();
+ });
+
+ it("shows an Installed chip, Re-install, and the SAME neutral caption when the pack is already installed", async () => {
+ const fetchMock = fontFlowFetchMock();
+ await openLibreGroteskDetail(fetchMock, ["libre-grotesk"]);
+ await screen.findByTestId("font-specimen");
+
+ expect(screen.getByText("Installed")).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Re-install" })).toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: "Install" })).not.toBeInTheDocument();
+ // The specimen caption never claims install state either way (F104.4's own "transient,
+ // installs nothing" fact is true regardless) — see SpecimenBlock's own remarks.
+ expect(screen.getByText("Transient specimen — previewing installs nothing")).toBeInTheDocument();
+ });
});
// ── SAD PATH ────────────────────────────────────────────────────────────
diff --git a/admin-ui/__specs__/font-pack-library.spec.tsx b/admin-ui/__specs__/font-pack-wardrobe.spec.tsx
similarity index 74%
rename from admin-ui/__specs__/font-pack-library.spec.tsx
rename to admin-ui/__specs__/font-pack-wardrobe.spec.tsx
index d6c28e12..5693fa17 100644
--- a/admin-ui/__specs__/font-pack-library.spec.tsx
+++ b/admin-ui/__specs__/font-pack-wardrobe.spec.tsx
@@ -1,7 +1,9 @@
// @jest-environment jsdom
-// STORY-284 — The library is inspectable (SPEC F104.7 · PLAN T203); AC2 is the 🖐️ T204 gate.
+// STORY-284 — The wardrobe is inspectable (SPEC F104.7 · PLAN T203); AC2 is the 🖐️ T204 gate.
+// Nav label/route renamed "Library" → "Wardrobe" at PLAN T204 (Dean's ruling) — this file was
+// font-pack-library.spec.tsx before that rename; no behavior changed, only names.
//
-// Runner: Jest. FontLibraryClient renders GET /api/fonts's own listing — family (title), faces
+// Runner: Jest. WardrobeClient renders GET /api/fonts's own listing — family (title), faces
// (style + byte size via the shared font-format.ts helper), the licence/version/subset line, and
// the "Installed · · " db/25 provenance chip (AC1). `timeZone="UTC"` is pinned
// explicitly (the StatusTiles/BoothLogFeed/PersonasClient/SettingsForm house idiom, T105/T187) so
@@ -10,8 +12,8 @@
import { describe, it, expect } from "@jest/globals";
import { render, screen, within } from "@testing-library/react";
import "@testing-library/jest-dom";
-import { FontLibraryClient } from "../app/(authed)/library/FontLibraryClient";
-import type { FontLibraryPackDto } from "../app/(authed)/library/types";
+import { WardrobeClient } from "../app/(authed)/wardrobe/WardrobeClient";
+import type { FontLibraryPackDto } from "../app/(authed)/wardrobe/types";
const SPACE_GROTESK_PACK: FontLibraryPackDto = {
slug: "space-grotesk",
@@ -25,10 +27,10 @@ const SPACE_GROTESK_PACK: FontLibraryPackDto = {
importedAt: "2026-08-05T12:00:00Z",
};
-describe("Feature: the library is inspectable", () => {
- describe("Scenario: the library lists installed packs", () => {
+describe("Feature: the wardrobe is inspectable", () => {
+ describe("Scenario: the wardrobe lists installed packs", () => {
it("shows family, faces, byte sizes, and licence per pack (T203, AC1)", () => {
- render();
+ render();
const list = screen.getByRole("list", { name: "Installed font packs" });
const card = within(list).getByText("Space Grotesk").closest("li");
@@ -40,18 +42,18 @@ describe("Feature: the library is inspectable", () => {
});
it("shows 'Installed · · ' provenance per pack (T203, AC1)", () => {
- render();
+ render();
expect(screen.getByText("Installed · space-grotesk · Aug 5, 2026")).toBeInTheDocument();
});
});
- describe("Scenario: the library is empty", () => {
+ describe("Scenario: the wardrobe is empty", () => {
// T203 review finding F3: the empty-state CTA must not point at /persona-catalog when the
- // catalog is disabled — that route itself 404s off-catalog, the exact dead end the Library nav
+ // catalog is disabled — that route itself 404s off-catalog, the exact dead end the Wardrobe nav
// item's own deliberate ungating (SPEC F104.8) exists to let an operator avoid.
it("names the reason and offers the Community Catalog CTA when the catalog is enabled", () => {
- render();
+ render();
expect(screen.getByText("No packs installed")).toBeInTheDocument();
expect(screen.getByRole("link", { name: "Browse the Community Catalog" })).toHaveAttribute(
@@ -61,7 +63,7 @@ describe("Feature: the library is inspectable", () => {
});
it("points at Settings instead of the catalog when the catalog is disabled", () => {
- render();
+ render();
expect(screen.getByText("No packs installed")).toBeInTheDocument();
expect(
@@ -82,7 +84,7 @@ describe("Feature: the library is inspectable", () => {
version: null,
subset: null,
};
- render();
+ render();
expect(screen.getByText("Licence unknown")).toBeInTheDocument();
});
diff --git a/admin-ui/__specs__/persona-catalog-page.spec.tsx b/admin-ui/__specs__/persona-catalog-page.spec.tsx
index df7b69ca..73c2a652 100644
--- a/admin-ui/__specs__/persona-catalog-page.spec.tsx
+++ b/admin-ui/__specs__/persona-catalog-page.spec.tsx
@@ -507,6 +507,44 @@ describe("Feature: The Persona Catalog server page", () => {
});
});
+ describe("Scenario: installed-font-slug wiring (PLAN T204, Dean's post-v3.1.0 review)", () => {
+ const indexBody: CatalogIndexResponseDto = {
+ entries: [EVERYONE_ENTRY],
+ fetchedAt: "2026-07-26T00:00:00Z",
+ unreachable: false,
+ };
+
+ it("threads GET /api/fonts's own slugs into PersonaCatalogClient as installedFontSlugs", async () => {
+ global.fetch = jest.fn().mockImplementation(async (input) => {
+ const url = String(input);
+ if (url.endsWith("/api/catalog/index")) return makeJsonResponse(200, indexBody);
+ if (url.endsWith("/api/fonts")) return makeJsonResponse(200, [{ slug: "space-grotesk" }, { slug: "libre-grotesk" }]);
+ throw new Error(`unexpected fetch ${url}`);
+ }) as unknown as typeof fetch;
+
+ const { default: PersonaCatalogPage } = await import("../app/(authed)/persona-catalog/page");
+ const node = await PersonaCatalogPage();
+
+ const clientEl = findElementByType(node, PersonaCatalogClient);
+ expect(clientEl?.props["installedFontSlugs"]).toEqual(["space-grotesk", "libre-grotesk"]);
+ });
+
+ it("degrades to an empty list — never crashing the page — when GET /api/fonts fails", async () => {
+ global.fetch = jest.fn().mockImplementation(async (input) => {
+ const url = String(input);
+ if (url.endsWith("/api/catalog/index")) return makeJsonResponse(200, indexBody);
+ if (url.endsWith("/api/fonts")) return makeJsonResponse(500, {});
+ throw new Error(`unexpected fetch ${url}`);
+ }) as unknown as typeof fetch;
+
+ const { default: PersonaCatalogPage } = await import("../app/(authed)/persona-catalog/page");
+ const node = await PersonaCatalogPage();
+
+ const clientEl = findElementByType(node, PersonaCatalogClient);
+ expect(clientEl?.props["installedFontSlugs"]).toEqual([]);
+ });
+ });
+
describe("Scenario: disabled surface is a bare 404 (SPEC F90.1, sad path)", () => {
it("renders an inline 'Not found' page when GET /api/catalog/index 404s", async () => {
global.fetch = jest
diff --git a/admin-ui/app/(authed)/_components/icons.tsx b/admin-ui/app/(authed)/_components/icons.tsx
index c6fb8d99..55e6fd7f 100644
--- a/admin-ui/app/(authed)/_components/icons.tsx
+++ b/admin-ui/app/(authed)/_components/icons.tsx
@@ -244,11 +244,13 @@ export function ScheduleIcon(props: IconProps): ReactNode {
);
}
-/** Library nav glyph (PLAN T203, SPEC F104.7) — an open book: two pages meeting at a center
- * spine, reading as "the station's own shelf of installed components" — distinct from
- * {@link PersonaCatalogIcon}'s three record-like shelf cards (the browsable Community Catalog,
- * a different surface entirely) and {@link CatalogIcon}'s plain rule lines (the media library). */
-export function LibraryIcon(props: IconProps): ReactNode {
+/** Wardrobe nav glyph (PLAN T203, SPEC F104.7; nav item renamed "Library" → "Wardrobe" at PLAN
+ * T204, Dean's ruling — this glyph itself is unchanged, only its export name follows the rename) —
+ * an open book: two pages meeting at a center spine, reading as "the station's own shelf of
+ * installed components" — distinct from {@link PersonaCatalogIcon}'s three record-like shelf cards
+ * (the browsable Community Catalog, a different surface entirely) and {@link CatalogIcon}'s plain
+ * rule lines (the media library). */
+export function WardrobeIcon(props: IconProps): ReactNode {
return (
diff --git a/admin-ui/app/(authed)/_components/nav-items.ts b/admin-ui/app/(authed)/_components/nav-items.ts
index 8f46ad0f..f3fb668b 100644
--- a/admin-ui/app/(authed)/_components/nav-items.ts
+++ b/admin-ui/app/(authed)/_components/nav-items.ts
@@ -4,13 +4,13 @@ import {
CatalogIcon,
DashboardIcon,
HealthIcon,
- LibraryIcon,
LiveIcon,
PersonaCatalogIcon,
PersonaIcon,
SafeContentIcon,
ScheduleIcon,
SettingsIcon,
+ WardrobeIcon,
type IconProps,
} from "./icons";
@@ -30,9 +30,15 @@ export interface NavItem {
/**
* Sidebar sections per SPEC F28.5, shared by the persistent desktop
* `Sidebar` (≥1024px) and the `MobileNav` drawer (<1024px, SPEC F28.13) so
- * the two never drift. Libraries is deliberately absent — it lives under
- * the Catalog page's Libraries tab (Q7, SPEC F28.11); /libraries is now
- * only a redirect into that tab, never its own rendered route.
+ * the two never drift.
+ *
+ * "Libraries" (plural — the MEDIA library, Q7, SPEC F28.11) is deliberately absent from this list:
+ * it lives under the Catalog page's Libraries tab, and /libraries is only a redirect into that tab,
+ * never its own rendered route. Do not confuse it with "Wardrobe" below — a DIFFERENT feature
+ * entirely (SPEC F104.7, installed font packs; named "Library" through v3.1.0, renamed "Wardrobe" at
+ * PLAN T204, Dean's ruling) that this same stale note used to read as ruling out too (PLAN T203
+ * review finding, closed here): the two features share no code, and the naming collision was never
+ * intentional.
*/
export const NAV_ITEMS: NavItem[] = [
{ href: "/dashboard", label: "Dashboard", Icon: DashboardIcon },
@@ -42,11 +48,12 @@ export const NAV_ITEMS: NavItem[] = [
{ href: "/personas", label: "Personas", Icon: PersonaIcon },
{ href: "/schedule", label: "Schedule", Icon: ScheduleIcon },
{ href: "/persona-catalog", label: "Community Catalog", Icon: PersonaCatalogIcon, requiresCatalog: true },
- // Library (PLAN T203, SPEC F104.7) is deliberately NOT gated by `requiresCatalog` — unlike the
- // Community Catalog browse surface, an installed pack keeps serving with the catalog disabled or
- // unreachable (SPEC F104.8's offline floor), so the page that inspects what's ALREADY installed
- // must stay reachable on that same axis too.
- { href: "/library", label: "Library", Icon: LibraryIcon },
+ // Wardrobe (PLAN T203, SPEC F104.7; renamed from "Library" at PLAN T204, Dean's ruling — nav label
+ // and route only, see this file's own class remarks) is deliberately NOT gated by
+ // `requiresCatalog` — unlike the Community Catalog browse surface, an installed pack keeps serving
+ // with the catalog disabled or unreachable (SPEC F104.8's offline floor), so the page that
+ // inspects what's ALREADY installed must stay reachable on that same axis too.
+ { href: "/wardrobe", label: "Wardrobe", Icon: WardrobeIcon },
{ href: "/booth-log", label: "Booth log", Icon: BoothLogIcon },
{ href: "/health", label: "Health", Icon: HealthIcon },
{ href: "/settings", label: "Settings", Icon: SettingsIcon },
diff --git a/admin-ui/app/(authed)/persona-catalog/FontDetailPanel.tsx b/admin-ui/app/(authed)/persona-catalog/FontDetailPanel.tsx
index 688085d3..9ab3e975 100644
--- a/admin-ui/app/(authed)/persona-catalog/FontDetailPanel.tsx
+++ b/admin-ui/app/(authed)/persona-catalog/FontDetailPanel.tsx
@@ -2,7 +2,7 @@
import type { ReactNode } from "react";
import { Button } from "@/components/ui/button";
-import { formatFontByteTotal } from "./font-format";
+import { formatFontByteTotal, licenceLine } from "./font-format";
import { prettifySlug } from "./format-slug";
import { SpecimenBlock } from "./SpecimenBlock";
import type { CatalogEntryDetailDto } from "./types";
@@ -10,6 +10,11 @@ import type { CatalogEntryDetailDto } from "./types";
export interface FontDetailPanelProps {
slug: string;
detail: CatalogEntryDetailDto;
+ /** Whether THIS slug already has an installed pack (PLAN T204, Dean's post-v3.1.0 review:
+ * reopening an installed pack's detail panel showed no sign it was already installed). Sourced
+ * from `GET /api/fonts`'s own listing — see `PersonaCatalogClient`'s own remarks for where that
+ * read happens and how a fresh install flips this without a reload. */
+ isInstalled: boolean;
onInstallClick: () => void;
}
@@ -35,16 +40,38 @@ export interface FontDetailPanelProps {
* surface to install FROM. The T186 preview→confirm→POST precedent is the natural, minimal home,
* so this panel's Install button opens `FontInstallModal` (confirm/cancel semantics mirrored from
* `ThemeInstallModal`) rather than posting anything itself.
+ *
+ * Licence line (PLAN T204, Dean's post-v3.1.0 review). "<licence> · v<version> ·
+ * <subset>" via the shared `licenceLine` helper (`font-format.ts`) — the SAME line the
+ * Wardrobe page's own installed-pack cards render, so the one trust fact a PRE-install review most
+ * needs (what licence am I about to agree to?) reads identically whether the pack is already
+ * installed or not. Degrades to "Licence unknown" rather than an empty line — see that helper's own
+ * remarks.
+ *
+ * Installed-state awareness (PLAN T204). Reopening an already-installed pack's detail panel
+ * used to show no sign of that — `SpecimenBlock`'s OLD "Admin-only specimen — not installed" caption
+ * read as a status claim it never was (it only ever described the SPECIMEN, an always-transient
+ * preview, never the pack itself), so that caption is now state-neutral in BOTH states (see
+ * `SpecimenBlock`'s own remarks) and the installed signal moved here instead. `isInstalled` (sourced
+ * by `PersonaCatalogClient` from `GET /api/fonts`, see its own remarks) drives an "Installed" chip —
+ * the same quiet bordered-pill treatment the Wardrobe page's own provenance chip uses — and the
+ * button's own label: "Re-install" when a pack under this slug is already installed
+ * (`FontPackController.Install` upserts, PLAN T199, so a re-install is a genuinely supported,
+ * non-destructive action), "Install" otherwise.
*/
-export function FontDetailPanel({ slug, detail, onInstallClick }: FontDetailPanelProps): ReactNode {
+export function FontDetailPanel({ slug, detail, isInstalled, onInstallClick }: FontDetailPanelProps): ReactNode {
return (
-
{prettifySlug(slug)}
- {/* Install (scope addition, see this component's own remarks) opens FontInstallModal's
- confirm step — this click itself issues no request; the modal POSTs on confirm only. */}
+
+
{prettifySlug(slug)}
+ {isInstalled && }
+
+ {/* Install/Re-install (scope addition, see this component's own remarks) opens
+ FontInstallModal's confirm step — this click itself issues no request; the modal POSTs
+ on confirm only. */}
+
{/* Plain text ONLY (mirrors DetailPanel's own persona-description rule, SPEC F90.6) — a bare
`{detail.description}` JSX child, React's default escaping, never dangerouslySetInnerHTML. */}
{detail.description !== null && detail.description !== "" && (
@@ -68,3 +99,17 @@ export function FontDetailPanel({ slug, detail, onInstallClick }: FontDetailPane
);
}
+
+/** "Installed" chip (PLAN T204) — the SAME quiet bordered-pill treatment the Wardrobe page's own
+ * `ProvenanceChip` uses (`app/(authed)/wardrobe/WardrobeClient.tsx`), reused here as a plain status
+ * marker rather than a provenance stamp (no slug/date — this panel already names the slug in its own
+ * heading): a genuine shared component would need editing both files for a shape that already
+ * differs (provenance text vs a bare status word), the same reasoning that chip's own remarks give
+ * for not sharing with the persona/theme chips either. */
+function InstalledChip(): ReactNode {
+ return (
+
+ Installed
+
+ );
+}
diff --git a/admin-ui/app/(authed)/persona-catalog/PersonaCatalogClient.tsx b/admin-ui/app/(authed)/persona-catalog/PersonaCatalogClient.tsx
index 6bf8b1fb..4007e60e 100644
--- a/admin-ui/app/(authed)/persona-catalog/PersonaCatalogClient.tsx
+++ b/admin-ui/app/(authed)/persona-catalog/PersonaCatalogClient.tsx
@@ -27,6 +27,17 @@ import type {
interface PersonaCatalogClientProps {
/** The index this page's server component already fetched (SPEC F90.2, F90.4). */
initialIndex: CatalogIndexResponseDto;
+ /**
+ * Slugs already installed, per `GET /api/fonts` (PLAN T204, Dean's post-v3.1.0 review: reopening
+ * an installed pack's detail panel showed no sign it was already installed). The page's own server
+ * component fetches this ALONGSIDE the index (the smaller diff over a lazy per-open fetch — one
+ * extra `Promise.all` leg server-side, versus threading a second client-side fetch/loading state
+ * through `loadDetail` for font entries only) and hands the slug list straight through; defaults to
+ * `[]` — fail closed, matching this file's own `catalogEnabled` default posture elsewhere in the
+ * app — so an isolated render with no live signal never CLAIMS a pack is installed that it has no
+ * evidence for.
+ */
+ installedFontSlugs?: string[];
}
type DetailState =
@@ -68,12 +79,17 @@ type DetailState =
* dedicated install-button task for M1, and T204's exit-check checklist has no other UI surface to
* install a pack from.
*/
-export function PersonaCatalogClient({ initialIndex }: PersonaCatalogClientProps): ReactNode {
+export function PersonaCatalogClient({ initialIndex, installedFontSlugs = [] }: PersonaCatalogClientProps): ReactNode {
const router = useRouter();
const [detail, setDetail] = useState({ kind: "idle" });
const [reviewing, setReviewing] = useState(false);
const [installingTheme, setInstallingTheme] = useState(false);
const [installingFont, setInstallingFont] = useState(false);
+ // Seeded from the server-fetched prop above, then flipped locally the instant an install
+ // succeeds (handleFontInstalled below) — cheap, no reload/re-fetch needed for a set this small.
+ // `useState(() => ...)` (lazy initializer): this only needs to run once, not re-derive the Set on
+ // every render.
+ const [installedSlugs, setInstalledSlugs] = useState>(() => new Set(installedFontSlugs));
// Request token (T102 review, HIGH): loadDetail's fetch is not the only thing that can change
// `detail` between when a request starts and when it resolves — the operator can also collapse
@@ -171,11 +187,13 @@ export function PersonaCatalogClient({ initialIndex }: PersonaCatalogClientProps
}
/** SPEC F104.5's success path — mirrors `handleThemeInstalled`'s own remarks: no dedicated
- * library-list page exists on THIS task's own owned files for the panel to route to (PLAN T203
- * builds that separately); closing the modal and toasting the family that just entered the
- * station's library is the whole client-side job. */
- function handleFontInstalled(result: FontInstallResult): void {
+ * wardrobe-list page exists on THIS task's own owned files for the panel to route to (PLAN T203
+ * builds that separately); closing the modal, toasting the family that just entered the station's
+ * Wardrobe, AND (PLAN T204) marking `slug` installed in local state — so `FontDetailPanel` flips
+ * to "Installed"/"Re-install" immediately, no reload — is the whole client-side job. */
+ function handleFontInstalled(slug: string, result: FontInstallResult): void {
setInstallingFont(false);
+ setInstalledSlugs((prev) => new Set(prev).add(slug));
toast.success(`"${result.family}" installed.`);
}
@@ -247,7 +265,12 @@ export function PersonaCatalogClient({ initialIndex }: PersonaCatalogClientProps
return setReviewing(true)} />;
case "font":
return (
- setInstallingFont(true)} />
+ setInstallingFont(true)}
+ />
);
default:
return null;
@@ -309,7 +332,11 @@ export function PersonaCatalogClient({ initialIndex }: PersonaCatalogClientProps
the theme block above): FontInstallModal posts no body of its own, so it has nothing to
read off `detail.detail.card` at all — only `selectedEntry?.kind === "font"` gates it. */}
{installingFont && detail.kind === "loaded" && selectedEntry?.kind === "font" && (
- setInstallingFont(false)} onInstalled={handleFontInstalled} />
+ setInstallingFont(false)}
+ onInstalled={(result) => handleFontInstalled(detail.slug, result)}
+ />
)}
);
diff --git a/admin-ui/app/(authed)/persona-catalog/SpecimenBlock.tsx b/admin-ui/app/(authed)/persona-catalog/SpecimenBlock.tsx
index 8d859897..1ed69ea3 100644
--- a/admin-ui/app/(authed)/persona-catalog/SpecimenBlock.tsx
+++ b/admin-ui/app/(authed)/persona-catalog/SpecimenBlock.tsx
@@ -158,7 +158,7 @@ export function SpecimenBlock({ slug, specimenFile }: SpecimenBlockProps): React
AaBbCcDdEe 0123456789
);
diff --git a/admin-ui/app/(authed)/persona-catalog/font-format.ts b/admin-ui/app/(authed)/persona-catalog/font-format.ts
index 30122efb..7f069f74 100644
--- a/admin-ui/app/(authed)/persona-catalog/font-format.ts
+++ b/admin-ui/app/(authed)/persona-catalog/font-format.ts
@@ -12,3 +12,32 @@ export function formatFontByteTotal(bytes: number): string {
if (bytes >= kib) return `${Math.round(bytes / kib)} KiB`;
return `${bytes} B`;
}
+
+/** The trio a font pack's licence line reads off (PLAN T204) — shared shape between the CATALOG
+ * detail wire (`CatalogEntryDetailDto.fontLicense`/`fontVersion`/`fontSubset`) and the installed
+ * Wardrobe wire (`FontLibraryPackDto.license`/`version`/`subset`): different DTOs, same three field
+ * names and the same "degrades gracefully, never blank" contract, so this shape only names what
+ * `licenceLine` below actually needs. */
+export interface FontLicenceFields {
+ license: string | null;
+ version: string | null;
+ subset: string | null;
+}
+
+/** One pack's licence line — "<licence> · v<version> · <subset>" (PLAN T204,
+ * mirrors the Library/Wardrobe page's own pre-existing line) — omitting whichever of the three is
+ * absent (`version` is genuinely optional even on a cleanly-parsed manifest; `license`/`subset` are
+ * `null` only when the manifest failed to parse, degrade, or — on the catalog wire — the entry is
+ * unreachable). Degrades to "Licence unknown" rather than an empty line on the all-null edge — a
+ * reviewer never sees a blank fact where the panel's whole point is showing this one. Shared by
+ * `FontDetailPanel` (pre-install review) and the Wardrobe page's own pack cards so the two can never
+ * drift on the same wording. */
+export function licenceLine(fields: FontLicenceFields): string {
+ const parts = [
+ fields.license,
+ fields.version !== null && fields.version !== "" ? `v${fields.version}` : null,
+ fields.subset,
+ ].filter((part): part is string => part !== null && part !== "");
+
+ return parts.length > 0 ? parts.join(" · ") : "Licence unknown";
+}
diff --git a/admin-ui/app/(authed)/persona-catalog/page.tsx b/admin-ui/app/(authed)/persona-catalog/page.tsx
index 3cf8f864..ff677baa 100644
--- a/admin-ui/app/(authed)/persona-catalog/page.tsx
+++ b/admin-ui/app/(authed)/persona-catalog/page.tsx
@@ -10,9 +10,44 @@ import type { CatalogIndexResponseDto } from "./types";
export const dynamic = "force-dynamic";
export const fetchCache = "force-no-store";
+/** Wire shape of a `GET /api/fonts` row (SPEC F104.7) — only the one field this page reads; mirrors
+ * `wardrobe/page.tsx`'s own local `SettingRow` idiom (cast to the one field a caller needs rather
+ * than importing the Wardrobe page's full `FontLibraryPackDto` for a single string). */
+interface InstalledFontPackRow {
+ slug: string;
+}
+
+/**
+ * Every already-installed pack's slug (PLAN T204, Dean's post-v3.1.0 review: reopening an installed
+ * pack's detail panel showed no sign it was already installed) — fetched ALONGSIDE the index below,
+ * in the SAME server component, the smaller diff over a lazy per-open client fetch (the alternative
+ * this task's own dispatch note weighed): one more `Promise.all` leg here, versus a second
+ * client-side fetch/loading state threaded through `PersonaCatalogClient.loadDetail` for font
+ * entries only. Independent of `Community:CatalogIndexUrl` (SPEC F104.8's offline floor — an
+ * installed pack outlives the catalog, the same reasoning `wardrobe/page.tsx`'s own ungated nav item
+ * follows) — fetched unconditionally, never gated on the catalog being enabled. Any failure
+ * (network error, non-200, or an unexpected non-array body) degrades to `[]` — fail closed, matching
+ * `fetchCatalogEnabled`'s own posture below: no live signal means no pack gets FALSELY claimed
+ * installed.
+ */
+async function fetchInstalledFontSlugs(cookieHeader: string): Promise {
+ try {
+ const response = await apiGet("/api/fonts", { cookies: cookieHeader });
+ if (!response.ok) return [];
+ const rows = (await response.json()) as InstalledFontPackRow[];
+ return Array.isArray(rows) ? rows.map((row) => row.slug) : [];
+ } catch {
+ return [];
+ }
+}
+
export default async function PersonaCatalogPage(): Promise {
const cookieStore = await cookies();
- const response = await apiGet("/api/catalog/index", { cookies: cookieStore.toString() });
+ const cookieHeader = cookieStore.toString();
+ const [response, installedFontSlugs] = await Promise.all([
+ apiGet("/api/catalog/index", { cookies: cookieHeader }),
+ fetchInstalledFontSlugs(cookieHeader),
+ ]);
// Disabled (SPEC F90.1): CatalogController serves a bare, zero-byte 404 here — the same
// per-resource 404 shape MediaDetailPage's own "Not found" branch renders inline for (house
@@ -43,7 +78,7 @@ export default async function PersonaCatalogPage(): Promise {
Community Catalog
-
+
);
diff --git a/admin-ui/app/(authed)/persona-catalog/types.ts b/admin-ui/app/(authed)/persona-catalog/types.ts
index 49c8a32c..9739e4c4 100644
--- a/admin-ui/app/(authed)/persona-catalog/types.ts
+++ b/admin-ui/app/(authed)/persona-catalog/types.ts
@@ -58,12 +58,16 @@ export interface CatalogIndexResponseDto {
* reads the already-projected fields below, but `card` is exactly what `PersonaCardReviewModal`
* (SPEC F90.5/F90.6, PLAN T103) both renders in full and POSTs byte-for-byte on confirm; `meta`
* stays unused by this page. Every field but `unreachable` is `null` exactly when `unreachable` is
- * `true`. `fontFamily`/`fontByteTotal`/`fontSpecimenFile` are `null` for every non-font entry
- * (T202) — `fontFamily` here is parsed straight from `card` (the manifest, T194), a DETAIL-side
- * sibling of `CatalogShelfEntryDto.fontFamily`'s own index-sourced field of the same name, not the
- * same fetch; see `FontDetailPanel`'s own remarks for why this value is never interpolated into
- * CSS. `fontSpecimenFile` is the bare filename `SpecimenBlock` passes to
- * `GET /api/catalog/entries/{slug}/assets/{file}` to render the real face (SPEC F104.4). */
+ * `true`. `fontFamily`/`fontByteTotal`/`fontSpecimenFile`/`fontLicense`/`fontVersion`/`fontSubset`
+ * are `null` for every non-font entry (T202, T204) — `fontFamily` here is parsed straight from
+ * `card` (the manifest, T194), a DETAIL-side sibling of `CatalogShelfEntryDto.fontFamily`'s own
+ * index-sourced field of the same name, not the same fetch; see `FontDetailPanel`'s own remarks for
+ * why this value is never interpolated into CSS. `fontSpecimenFile` is the bare filename
+ * `SpecimenBlock` passes to `GET /api/catalog/entries/{slug}/assets/{file}` to render the real face
+ * (SPEC F104.4). `fontLicense`/`fontVersion`/`fontSubset` (PLAN T204, Dean's post-v3.1.0 review: the
+ * pre-install review panel showed no licence anywhere) are the SAME manifest trio a Wardrobe pack's
+ * own `license`/`version`/`subset` carry once installed (`FontLibraryPackDto`) — see
+ * `font-format.ts`'s shared `licenceLine` for the one place both render identically. */
export interface CatalogEntryDetailDto {
card: string | null;
meta: string | null;
@@ -77,4 +81,7 @@ export interface CatalogEntryDetailDto {
fontFamily: string | null;
fontByteTotal: number | null;
fontSpecimenFile: string | null;
+ fontLicense: string | null;
+ fontVersion: string | null;
+ fontSubset: string | null;
}
diff --git a/admin-ui/app/(authed)/library/FontLibraryClient.tsx b/admin-ui/app/(authed)/wardrobe/WardrobeClient.tsx
similarity index 72%
rename from admin-ui/app/(authed)/library/FontLibraryClient.tsx
rename to admin-ui/app/(authed)/wardrobe/WardrobeClient.tsx
index 2e0ea975..cd22cbf6 100644
--- a/admin-ui/app/(authed)/library/FontLibraryClient.tsx
+++ b/admin-ui/app/(authed)/wardrobe/WardrobeClient.tsx
@@ -3,10 +3,10 @@
import type { ReactNode } from "react";
import { EmptyState } from "@/components/ui/empty-state";
import { formatDateStamp } from "@/lib/format-clock";
-import { formatFontByteTotal } from "../persona-catalog/font-format";
+import { formatFontByteTotal, licenceLine } from "../persona-catalog/font-format";
import type { FontLibraryPackDto } from "./types";
-export interface FontLibraryClientProps {
+export interface WardrobeClientProps {
/** Every installed pack, from `GET /api/fonts` (SPEC F104.7). */
packs: FontLibraryPackDto[];
/** Test-only injection point for the provenance chip's `formatDateStamp` call; production omits
@@ -15,7 +15,7 @@ export interface FontLibraryClientProps {
timeZone?: string;
/**
* Whether the Community Catalog is currently enabled (SPEC F90.1, `Community:CatalogIndexUrl`
- * non-empty) — swaps the empty-state CTA (PLAN T203 review finding F3). The Library nav item is
+ * non-empty) — swaps the empty-state CTA (PLAN T203 review finding F3). The Wardrobe nav item is
* deliberately ungated (see nav-items.ts's own remarks: installed packs outlive the catalog,
* F104.8), so a disabled catalog must never leave this page's own empty state pointing at
* `/persona-catalog` — that page 404s off-catalog, the exact dead end the ungated nav exists to
@@ -26,22 +26,6 @@ export interface FontLibraryClientProps {
catalogEnabled?: boolean;
}
-/** One pack's licence line — "<licence> · v<version> · <subset>" — omitting
- * whichever of the three the pack's stored `definition` carries no value for (`version` is
- * genuinely optional even on a cleanly-parsed manifest; `license`/`subset` are `null` only if that
- * manifest failed to re-parse server-side, see `FontLibraryPackDto`'s own remarks). Degrades to
- * "Licence unknown" rather than an empty line on the all-null edge — this page never renders a
- * blank fact where a reader would expect one. */
-function licenceLine(pack: FontLibraryPackDto): string {
- const parts = [
- pack.license,
- pack.version !== null && pack.version !== "" ? `v${pack.version}` : null,
- pack.subset,
- ].filter((part): part is string => part !== null && part !== "");
-
- return parts.length > 0 ? parts.join(" · ") : "Licence unknown";
-}
-
/** Provenance chip — "Installed · <slug> · <date>" (SPEC F104.7 AC1, the db/25 pattern)
* — mirrors `PersonasClient`'s own `ProvenanceBadge`/`SettingsForm`'s own `ThemeProvenanceBadge`
* treatment (quiet bordered chip, T105/T187) rather than importing either: this page sits outside
@@ -68,12 +52,15 @@ function ProvenanceChip({
}
/**
- * The Library page's client half (SPEC F104.7, STORY-284, PLAN T203) — every installed font pack,
- * each rendered as its own card: family (title), faces with style + byte size (the shared
- * `font-format.ts` helper, mirroring the shelf card's own T201 byte-total treatment), the licence
- * line, and the "Installed · ⟨slug⟩ · ⟨date⟩" provenance chip (AC1). Read-only — this page lists
- * what T199's install route already wrote; it issues no requests of its own. On an empty library,
- * `catalogEnabled` picks the empty-state CTA (T203 review finding F3) — see that prop's own remarks.
+ * The Wardrobe page's client half (SPEC F104.7, STORY-284, PLAN T203; renamed "Library" → "Wardrobe"
+ * at PLAN T204, Dean's ruling — nav label and route only, see nav-items.ts's own remarks; the wire
+ * (`GET /api/fonts`, `FontLibraryPackDto`) keeps its name, backend DTOs don't chase UI labels) —
+ * every installed font pack, each rendered as its own card: family (title), faces with style + byte
+ * size (the shared `font-format.ts` helper, mirroring the shelf card's own T201 byte-total
+ * treatment), the licence line, and the "Installed · ⟨slug⟩ · ⟨date⟩" provenance chip (AC1).
+ * Read-only — this page lists what T199's install route already wrote; it issues no requests of its
+ * own. On an empty wardrobe, `catalogEnabled` picks the empty-state CTA (T203 review finding F3) —
+ * see that prop's own remarks.
*
* PLAIN TEXT ONLY (the T199/T200 stored-family/style obligation, closed here).
* `pack.family` and each face's `style` are unbounded free-form prose (see `FontLibraryPackDto`'s
@@ -81,7 +68,7 @@ function ProvenanceChip({
* never `dangerouslySetInnerHTML` and never interpolated into an inline `style` attribute or any
* other CSS context anywhere on this page.
*/
-export function FontLibraryClient({ packs, timeZone, catalogEnabled = false }: FontLibraryClientProps): ReactNode {
+export function WardrobeClient({ packs, timeZone, catalogEnabled = false }: WardrobeClientProps): ReactNode {
if (packs.length === 0) {
return catalogEnabled ? (
{
}
}
-export default async function LibraryPage(): Promise {
+export default async function WardrobePage(): Promise {
const cookieStore = await cookies();
const cookieHeader = cookieStore.toString();
const [response, catalogEnabled] = await Promise.all([
@@ -55,8 +55,8 @@ export default async function LibraryPage(): Promise {
if (!response.ok) {
return (
-
);
diff --git a/admin-ui/app/(authed)/library/types.ts b/admin-ui/app/(authed)/wardrobe/types.ts
similarity index 93%
rename from admin-ui/app/(authed)/library/types.ts
rename to admin-ui/app/(authed)/wardrobe/types.ts
index b5a3c90d..deaa697e 100644
--- a/admin-ui/app/(authed)/library/types.ts
+++ b/admin-ui/app/(authed)/wardrobe/types.ts
@@ -16,7 +16,7 @@ export interface FontLibraryFaceDto {
*
* `family`/`FontLibraryFaceDto.style` are UNBOUNDED free-form prose (the T199/T200 stored-family/
* style obligation) — this page renders both as plain text ONLY, never interpolated into a
- * stylesheet or inline `style` attribute (see `FontLibraryClient`'s own remarks). */
+ * stylesheet or inline `style` attribute (see `WardrobeClient`'s own remarks). */
export interface FontLibraryPackDto {
slug: string;
family: string;
diff --git a/src/GenWave.Host/Api/CatalogController.cs b/src/GenWave.Host/Api/CatalogController.cs
index 60c36f78..0d039216 100644
--- a/src/GenWave.Host/Api/CatalogController.cs
+++ b/src/GenWave.Host/Api/CatalogController.cs
@@ -129,7 +129,7 @@ public async Task Entry(string slug, CancellationToken ct)
CatalogEntryFetchResult.Ok ok => Ok(ToEntryResponse(ok)),
CatalogEntryFetchResult.NotFound => NotFound(UnknownEntryProblem(slug)),
CatalogEntryFetchResult.Unreachable => Ok(new CatalogEntryResponse(
- null, null, null, Unreachable: true, null, null, null, null, null, null, null, null, null)),
+ null, null, null, Unreachable: true, null, null, null, null, null, null, null, null, null, null, null, null)),
CatalogEntryFetchResult.HashMismatch =>
StatusCode(StatusCodes.Status502BadGateway, WithheldProblem("failed its integrity check")),
CatalogEntryFetchResult.Oversize =>
@@ -297,7 +297,10 @@ static CatalogEntryResponse ToEntryResponse(CatalogEntryFetchResult.Ok ok)
meta.SamplePatter ?? [],
FontFamily: fontManifest?.Family,
FontByteTotal: isFont ? ok.Content.Assets.Sum(a => a.Bytes) : null,
- FontSpecimenFile: ResolveSpecimenFile(fontManifest, ok.Content.Assets));
+ FontSpecimenFile: ResolveSpecimenFile(fontManifest, ok.Content.Assets),
+ FontLicense: fontManifest?.License,
+ FontVersion: fontManifest?.Version,
+ FontSubset: fontManifest?.Subset);
}
///
diff --git a/src/GenWave.Host/Api/CatalogEntryResponse.cs b/src/GenWave.Host/Api/CatalogEntryResponse.cs
index 313032ee..e2ecc165 100644
--- a/src/GenWave.Host/Api/CatalogEntryResponse.cs
+++ b/src/GenWave.Host/Api/CatalogEntryResponse.cs
@@ -65,6 +65,19 @@ namespace GenWave.Host.Api;
/// straight to that route to render the transient specimen preview. for every
/// non-font entry, when unreachable, or when no upright face resolves.
///
+///
+/// A font entry's licence identifier (PLAN T204, Dean's post-v3.1.0 review: the pre-install review
+/// panel showed no licence at all) — parsed off the SAME hash-verified manifest
+/// already reads, via the same
+/// call, zero extra cost. Paired with / to mirror the
+/// admin UI's Wardrobe page's own "licence · version · subset" line (FontLibraryPackDto — the
+/// wire DTO keeps its pre-rename name; only the UI label/route became "Wardrobe") so the SAME trust
+/// fact reads identically whether an operator is reviewing a pack pre-install or inspecting one
+/// already installed. for every non-font entry, when unreachable, or when the
+/// manifest fails to parse (degrades — never a 500, see that method's own remarks).
+///
+/// A font entry's manifest version (PLAN T204) — genuinely optional even on a cleanly-parsed manifest ('s own shape); for every non-font entry, when unreachable, or when absent/unparseable.
+/// A font entry's manifest subset, e.g. "latin" (PLAN T204) — for every non-font entry, when unreachable, or when the manifest fails to parse.
public sealed record CatalogEntryResponse(
string? Card,
string? Meta,
@@ -78,4 +91,7 @@ public sealed record CatalogEntryResponse(
IReadOnlyList? SamplePatter,
string? FontFamily,
long? FontByteTotal,
- string? FontSpecimenFile);
+ string? FontSpecimenFile,
+ string? FontLicense,
+ string? FontVersion,
+ string? FontSubset);
diff --git a/tests/GenWave.Host.Tests/Specs/Story279_FontKindAssets.cs b/tests/GenWave.Host.Tests/Specs/Story279_FontKindAssets.cs
index bade17d7..f6dc6969 100644
--- a/tests/GenWave.Host.Tests/Specs/Story279_FontKindAssets.cs
+++ b/tests/GenWave.Host.Tests/Specs/Story279_FontKindAssets.cs
@@ -580,6 +580,24 @@ public async Task TheDetailRouteResolvesTheSpecimenFileToTheDeclaredAsset()
Assert.Equal(FontShelfFixtures.AssetFile, body!.FontSpecimenFile);
}
+ [Fact]
+ public async Task TheDetailRouteProjectsTheLicenceVersionAndSubsetTrio()
+ {
+ // PLAN T204 (Dean's post-v3.1.0 review): the pre-install review panel showed no licence
+ // at all — FontLicense/FontVersion/FontSubset are parsed off the SAME hash-verified
+ // manifest FontFamily already reads (golden.font.json: "OFL-1.1"/"2.000"/"text"), so this
+ // trust fact reaches the panel at zero extra fetch cost.
+ await using var factory = new FontShelfWebFactory();
+ var client = await FontShelfWebFactory.LoggedInClientAsync(factory);
+
+ var response = await client.GetAsync($"/api/catalog/entries/{FontShelfFixtures.FontSlug}");
+
+ var body = await response.Content.ReadFromJsonAsync();
+ Assert.Equal("OFL-1.1", body!.FontLicense);
+ Assert.Equal("2.000", body.FontVersion);
+ Assert.Equal("text", body.FontSubset);
+ }
+
[Fact]
public async Task TheShelfRouteProjectsTheSameByteTotalAsTheDetailRoute()
{
From 6dbb8eb86e7976600a0dc3b6c74b183fd9e8ca12 Mon Sep 17 00:00:00 2001
From: GenWave Radio
Date: Thu, 6 Aug 2026 09:45:20 -0600
Subject: [PATCH 2/2] fix(ui): theme catalog detail tells the installed truth
(gh-#375; v3.1.1 theme half)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Mirrors the font half (d82f870) for themes: the detail panel shows an
Installed chip + 'Imported · · ' provenance (catalog slug
or 'file'; NULL provenance — the future authored case — renders nothing,
never 'Imported · null'), the button reads Re-install when installed, and
a successful install flips state locally (2xx only; failed installs flip
nothing — spec-pinned on BOTH kinds, closing the font-half N3 gap).
Installed source = the existing /api/settings Station:Theme choices
(importedFrom/importedAt already ride that wire — no new route), projected
to three fields before the RSC payload, fail-closed to [] with the
documented degrade direction. Inherited obligations closed:
CatalogEntryResponse.UnreachableCatalog() factory kills the 16-null
literal (CS0102 forced the name); shared extracted and adopted at
all five duplicated sites (PersonasClient's ml-2 equivalence verified).
ThemeImportResponse carries ImportedAt via a store READ-BACK after upsert
— never a fabricated UtcNow; the spec pins response == stored row ==
settings choice, the whole gh-#375 contract in one walk.
---
.../font-pack-shelf-specimen.spec.tsx | 24 +++++
.../__specs__/persona-catalog-page.spec.tsx | 69 ++++++++++++++
.../theme-catalog-preview-install.spec.tsx | 91 ++++++++++++++++++-
.../persona-catalog/FontDetailPanel.tsx | 22 ++---
.../persona-catalog/PersonaCatalogClient.tsx | 84 +++++++++++++++--
.../persona-catalog/ThemeInstallModal.tsx | 12 ++-
.../app/(authed)/persona-catalog/page.tsx | 70 +++++++++++++-
.../app/(authed)/persona-catalog/types.ts | 22 +++++
.../app/(authed)/personas/PersonasClient.tsx | 16 ++--
.../app/(authed)/settings/SettingsForm.tsx | 25 ++---
.../app/(authed)/wardrobe/WardrobeClient.tsx | 24 +++--
admin-ui/components/ui/chip.tsx | 37 ++++++++
src/GenWave.Host/Api/CatalogController.cs | 3 +-
src/GenWave.Host/Api/CatalogEntryResponse.cs | 38 +++++++-
src/GenWave.Host/Api/ThemeImportResponse.cs | 12 ++-
.../Api/ThemesImportController.cs | 14 ++-
.../Specs/Story272_ThemeImport.cs | 54 +++++++++++
17 files changed, 545 insertions(+), 72 deletions(-)
create mode 100644 admin-ui/components/ui/chip.tsx
diff --git a/admin-ui/__specs__/font-pack-shelf-specimen.spec.tsx b/admin-ui/__specs__/font-pack-shelf-specimen.spec.tsx
index cb190e93..eee0721a 100644
--- a/admin-ui/__specs__/font-pack-shelf-specimen.spec.tsx
+++ b/admin-ui/__specs__/font-pack-shelf-specimen.spec.tsx
@@ -561,4 +561,28 @@ describe("Feature: packs on the shelf with an honest specimen", () => {
expect(fetchMock.mock.calls.some(([url]) => String(url) === INSTALL_URL)).toBe(false);
});
});
+
+ describe("Scenario: a failed install flips nothing (gh-#375 review carry-forward, N3)", () => {
+ it("flips nothing locally — the detail panel behind the dialog still reads Install, not Installed", async () => {
+ const fetchMock = fontFlowFetchMock({
+ install: makeJsonResponse(409, { detail: "This pack is already installed under a different family." }),
+ });
+ await openInstallDialog(fetchMock);
+
+ const dialog = within(screen.getByRole("dialog"));
+ await act(async () => {
+ fireEvent.click(dialog.getByRole("button", { name: "Confirm install" }));
+ await Promise.resolve();
+ });
+ await screen.findByRole("alert");
+
+ // `onInstalled` (PersonaCatalogClient.handleFontInstalled) only ever fires on
+ // FontInstallModal's own resp.ok branch — a 409 never reaches it, so the detail panel's own
+ // Install button, still present behind the open dialog, never flips to Re-install.
+ // `getByText`, not `getByRole` (Radix marks the background `aria-hidden` while the dialog is
+ // open, which `*ByRole` correctly excludes but a plain text query does not).
+ expect(screen.getByText("Install")).toBeInTheDocument();
+ expect(screen.queryByText("Installed")).not.toBeInTheDocument();
+ });
+ });
});
diff --git a/admin-ui/__specs__/persona-catalog-page.spec.tsx b/admin-ui/__specs__/persona-catalog-page.spec.tsx
index 73c2a652..a87105d0 100644
--- a/admin-ui/__specs__/persona-catalog-page.spec.tsx
+++ b/admin-ui/__specs__/persona-catalog-page.spec.tsx
@@ -545,6 +545,75 @@ describe("Feature: The Persona Catalog server page", () => {
});
});
+ describe("Scenario: installed-theme-provenance wiring (gh-#375 — the theme half of PLAN T204's font-half fix)", () => {
+ const indexBody: CatalogIndexResponseDto = {
+ entries: [EVERYONE_ENTRY],
+ fetchedAt: "2026-07-26T00:00:00Z",
+ unreachable: false,
+ };
+
+ /** A `GET /api/settings` body carrying `Station:Theme`'s own choices (SPEC F103.11, PLAN
+ * T187) — only the fields `fetchInstalledThemeProvenance` reads. */
+ const settingsBody = [
+ {
+ key: "Station:Theme",
+ value: "cats-whisker",
+ source: "default",
+ applyMode: "live",
+ kind: "choice",
+ unit: "",
+ choices: [
+ { value: "cats-whisker", label: "Cat's Whisker", isDefault: true },
+ {
+ value: "midnight-drive",
+ label: "Midnight Drive",
+ importedFrom: "midnight-drive-catalog-entry",
+ importedAt: "2026-07-21T09:05:00Z",
+ },
+ { value: "aurora-glow", label: "Aurora Glow", importedFrom: "file", importedAt: "2026-07-20T14:32:00Z" },
+ ],
+ },
+ ];
+
+ it("threads Station:Theme's own imported choices into PersonaCatalogClient as installedThemeProvenance", async () => {
+ global.fetch = jest.fn().mockImplementation(async (input) => {
+ const url = String(input);
+ if (url.endsWith("/api/catalog/index")) return makeJsonResponse(200, indexBody);
+ if (url.endsWith("/api/fonts")) return makeJsonResponse(200, []);
+ if (url.endsWith("/api/settings")) return makeJsonResponse(200, settingsBody);
+ throw new Error(`unexpected fetch ${url}`);
+ }) as unknown as typeof fetch;
+
+ const { default: PersonaCatalogPage } = await import("../app/(authed)/persona-catalog/page");
+ const node = await PersonaCatalogPage();
+
+ const clientEl = findElementByType(node, PersonaCatalogClient);
+ // Every choice carrying provenance rides through — a shipped default (no importedFrom) does
+ // not, mirroring the theme-provenance-badge.spec.tsx precedent's own "shipped default never
+ // gets a row" rule one layer up.
+ expect(clientEl?.props["installedThemeProvenance"]).toEqual([
+ { slug: "midnight-drive", importedFrom: "midnight-drive-catalog-entry", importedAt: "2026-07-21T09:05:00Z" },
+ { slug: "aurora-glow", importedFrom: "file", importedAt: "2026-07-20T14:32:00Z" },
+ ]);
+ });
+
+ it("degrades to an empty list — never crashing the page — when GET /api/settings fails", async () => {
+ global.fetch = jest.fn().mockImplementation(async (input) => {
+ const url = String(input);
+ if (url.endsWith("/api/catalog/index")) return makeJsonResponse(200, indexBody);
+ if (url.endsWith("/api/fonts")) return makeJsonResponse(200, []);
+ if (url.endsWith("/api/settings")) return makeJsonResponse(500, {});
+ throw new Error(`unexpected fetch ${url}`);
+ }) as unknown as typeof fetch;
+
+ const { default: PersonaCatalogPage } = await import("../app/(authed)/persona-catalog/page");
+ const node = await PersonaCatalogPage();
+
+ const clientEl = findElementByType(node, PersonaCatalogClient);
+ expect(clientEl?.props["installedThemeProvenance"]).toEqual([]);
+ });
+ });
+
describe("Scenario: disabled surface is a bare 404 (SPEC F90.1, sad path)", () => {
it("renders an inline 'Not found' page when GET /api/catalog/index 404s", async () => {
global.fetch = jest
diff --git a/admin-ui/__specs__/theme-catalog-preview-install.spec.tsx b/admin-ui/__specs__/theme-catalog-preview-install.spec.tsx
index 8f29a051..d28a3beb 100644
--- a/admin-ui/__specs__/theme-catalog-preview-install.spec.tsx
+++ b/admin-ui/__specs__/theme-catalog-preview-install.spec.tsx
@@ -136,7 +136,12 @@ function themeFlowFetchMock(overrides: {
if (url === IMPORT_URL) {
return (
overrides.importResponse ??
- makeJsonResponse(200, { slug: "golden-frequency", name: "Golden Frequency", importedFrom: "golden-frequency" })
+ makeJsonResponse(200, {
+ slug: "golden-frequency",
+ name: "Golden Frequency",
+ importedFrom: "golden-frequency",
+ importedAt: "2026-08-06T00:00:00Z",
+ })
);
}
throw new Error(`unexpected fetch ${url}`);
@@ -151,12 +156,22 @@ function cardFor(name: string): HTMLElement {
return card;
}
-/** Opens Golden Frequency's detail panel and waits for the live composed preview to render. */
-async function openGoldenFrequencyPreview(fetchMock: jest.MockedFunction): Promise {
+/** Opens Golden Frequency's detail panel and waits for the live composed preview to render.
+ * `installedThemeProvenance` (gh-#375) defaults to `[]`, the same "not installed" default
+ * `PersonaCatalogClient`'s own prop carries — pass a row naming this entry's own slug to exercise
+ * the already-installed path. */
+async function openGoldenFrequencyPreview(
+ fetchMock: jest.MockedFunction,
+ installedThemeProvenance: { slug: string; importedFrom: string; importedAt: string }[] = []
+): Promise {
global.fetch = fetchMock;
render(
<>
-
+
>
);
@@ -299,6 +314,52 @@ describe("Feature: previewing and installing a catalog theme", () => {
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
expect(await screen.findByText('"Golden Frequency" installed.')).toBeInTheDocument();
});
+
+ it("flips the detail panel to Installed/Re-install with the real provenance locally, no reload (gh-#375)", async () => {
+ const fetchMock = themeFlowFetchMock();
+ // Starts NOT installed — the default `installedThemeProvenance=[]` — so the button starts
+ // "Install" and no provenance line renders yet.
+ await openInstallDialog(fetchMock);
+
+ const dialog = within(screen.getByRole("dialog"));
+ await act(async () => {
+ fireEvent.click(dialog.getByRole("button", { name: "Confirm install" }));
+ await Promise.resolve();
+ });
+ await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
+
+ // The detail panel itself (still open — only the confirm dialog closed) now reads installed,
+ // with the REAL provenance the import response carried (never a fabricated "just now") and
+ // no second fetch: PersonaCatalogClient.handleThemeInstalled flips its own local state on the
+ // toast, the same cheap path the font half's own T204 spec calls for.
+ expect(screen.getByText("Installed")).toBeInTheDocument();
+ expect(screen.getByText("Imported · golden-frequency · Aug 6, 2026")).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Re-install" })).toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: "Install" })).not.toBeInTheDocument();
+ });
+ });
+
+ describe("Scenario: installed-state awareness (gh-#375 — the theme half of Dean's demo feedback)", () => {
+ it("shows Install and no provenance line when the theme is not installed", async () => {
+ const fetchMock = themeFlowFetchMock();
+ await openGoldenFrequencyPreview(fetchMock);
+
+ expect(screen.getByRole("button", { name: "Install" })).toBeInTheDocument();
+ expect(screen.queryByText("Installed")).not.toBeInTheDocument();
+ expect(screen.queryByText(/^Imported ·/)).not.toBeInTheDocument();
+ });
+
+ it('shows an Installed chip, "Imported · · ", and Re-install when the theme is already installed', async () => {
+ const fetchMock = themeFlowFetchMock();
+ await openGoldenFrequencyPreview(fetchMock, [
+ { slug: "golden-frequency", importedFrom: "golden-frequency", importedAt: "2026-07-21T09:05:00Z" },
+ ]);
+
+ expect(screen.getByText("Installed")).toBeInTheDocument();
+ expect(screen.getByText("Imported · golden-frequency · Jul 21, 2026")).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Re-install" })).toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: "Install" })).not.toBeInTheDocument();
+ });
});
// ── SAD PATH ────────────────────────────────────────────────────────────
@@ -362,5 +423,27 @@ describe("Feature: previewing and installing a catalog theme", () => {
// The dialog stays open — a failed confirm is not a crash, and the operator can still cancel.
expect(screen.getByRole("dialog")).toBeInTheDocument();
});
+
+ it("flips nothing locally — the detail panel behind the dialog still reads Install, not Installed (gh-#375)", async () => {
+ const fetchMock = themeFlowFetchMock({
+ importResponse: makeJsonResponse(409, { detail: '"golden-frequency" is a shipped theme\'s slug and cannot be overwritten by an import (SPEC F103.8).' }),
+ });
+ await openInstallDialog(fetchMock);
+
+ const dialog = within(screen.getByRole("dialog"));
+ await act(async () => {
+ fireEvent.click(dialog.getByRole("button", { name: "Confirm install" }));
+ await Promise.resolve();
+ });
+ await screen.findByRole("alert");
+
+ // `onInstalled` (PersonaCatalogClient.handleThemeInstalled) only ever fires on
+ // ThemeInstallModal's own 2xx branch — a 409 never reaches it, so the detail panel's own
+ // Install button, still present behind the open dialog, never flips to Re-install.
+ // `getByText`, not `getByRole` (Radix marks the background `aria-hidden` while the dialog is
+ // open, which `*ByRole` correctly excludes but a plain text query does not).
+ expect(screen.getByText("Install")).toBeInTheDocument();
+ expect(screen.queryByText("Installed")).not.toBeInTheDocument();
+ });
});
});
diff --git a/admin-ui/app/(authed)/persona-catalog/FontDetailPanel.tsx b/admin-ui/app/(authed)/persona-catalog/FontDetailPanel.tsx
index 9ab3e975..c159b896 100644
--- a/admin-ui/app/(authed)/persona-catalog/FontDetailPanel.tsx
+++ b/admin-ui/app/(authed)/persona-catalog/FontDetailPanel.tsx
@@ -2,6 +2,7 @@
import type { ReactNode } from "react";
import { Button } from "@/components/ui/button";
+import { Chip } from "@/components/ui/chip";
import { formatFontByteTotal, licenceLine } from "./font-format";
import { prettifySlug } from "./format-slug";
import { SpecimenBlock } from "./SpecimenBlock";
@@ -54,7 +55,7 @@ export interface FontDetailPanelProps {
* preview, never the pack itself), so that caption is now state-neutral in BOTH states (see
* `SpecimenBlock`'s own remarks) and the installed signal moved here instead. `isInstalled` (sourced
* by `PersonaCatalogClient` from `GET /api/fonts`, see its own remarks) drives an "Installed" chip —
- * the same quiet bordered-pill treatment the Wardrobe page's own provenance chip uses — and the
+ * the shared `Chip` component (`components/ui/chip.tsx`, gh-#375 extraction) — and the
* button's own label: "Re-install" when a pack under this slug is already installed
* (`FontPackController.Install` upserts, PLAN T199, so a re-install is a genuinely supported,
* non-destructive action), "Install" otherwise.
@@ -65,7 +66,10 @@ export function FontDetailPanel({ slug, detail, isInstalled, onInstallClick }: F
{prettifySlug(slug)}
- {isInstalled && }
+ {/* Bare status word, not a provenance stamp (no slug/date — this panel already names the
+ slug in its own heading) — mirrors WardrobeClient's own ProvenanceChip shape one level
+ up, just narrower content. */}
+ {isInstalled && Installed}
{/* Install/Re-install (scope addition, see this component's own remarks) opens
FontInstallModal's confirm step — this click itself issues no request; the modal POSTs
@@ -99,17 +103,3 @@ export function FontDetailPanel({ slug, detail, isInstalled, onInstallClick }: F
);
}
-
-/** "Installed" chip (PLAN T204) — the SAME quiet bordered-pill treatment the Wardrobe page's own
- * `ProvenanceChip` uses (`app/(authed)/wardrobe/WardrobeClient.tsx`), reused here as a plain status
- * marker rather than a provenance stamp (no slug/date — this panel already names the slug in its own
- * heading): a genuine shared component would need editing both files for a shape that already
- * differs (provenance text vs a bare status word), the same reasoning that chip's own remarks give
- * for not sharing with the persona/theme chips either. */
-function InstalledChip(): ReactNode {
- return (
-
- Installed
-
- );
-}
diff --git a/admin-ui/app/(authed)/persona-catalog/PersonaCatalogClient.tsx b/admin-ui/app/(authed)/persona-catalog/PersonaCatalogClient.tsx
index 4007e60e..d4392e56 100644
--- a/admin-ui/app/(authed)/persona-catalog/PersonaCatalogClient.tsx
+++ b/admin-ui/app/(authed)/persona-catalog/PersonaCatalogClient.tsx
@@ -3,9 +3,11 @@
import { useRouter } from "next/navigation";
import { useRef, useState, type ReactNode } from "react";
import { Button } from "@/components/ui/button";
+import { Chip } from "@/components/ui/chip";
import { EmptyState } from "@/components/ui/empty-state";
import { Skeleton } from "@/components/ui/skeleton";
import { toast } from "@/components/ui/toast";
+import { formatDateStamp } from "@/lib/format-clock";
import { readErrorMessage } from "@/lib/problem-details";
import { cn } from "@/lib/utils";
import { PersonaCardReviewModal, type PersonaCardReviewImportResult } from "../_components/PersonaCardReviewModal";
@@ -22,6 +24,7 @@ import type {
CatalogShelfEntryDto,
CatalogThemePreview,
CatalogThemeSwatchSet,
+ ThemeCatalogProvenanceDto,
} from "./types";
interface PersonaCatalogClientProps {
@@ -38,6 +41,19 @@ interface PersonaCatalogClientProps {
* evidence for.
*/
installedFontSlugs?: string[];
+ /**
+ * Every catalog-imported theme's provenance, per `GET /api/settings`'s own `Station:Theme` choices
+ * (gh-#375 — the theme half of the same reopening-shows-no-installed-state complaint the
+ * font half above already closed). Mirrors `installedFontSlugs`'s own shape and defaults ([]) —
+ * fail closed, so an isolated render with no live signal never CLAIMS a theme is installed that it
+ * has no evidence for — see `ThemeCatalogProvenanceDto`'s own remarks for why this rides
+ * `/api/settings` rather than a new backend route.
+ */
+ installedThemeProvenance?: ThemeCatalogProvenanceDto[];
+ /** Test-only injection point for the theme provenance line's `formatDateStamp` call (gh-#375);
+ * production omits this and gets the browser's local zone — the same SettingsForm/WardrobeClient/
+ * PersonasClient idiom, not a bespoke one. */
+ timeZone?: string;
}
type DetailState =
@@ -79,7 +95,12 @@ type DetailState =
* dedicated install-button task for M1, and T204's exit-check checklist has no other UI surface to
* install a pack from.
*/
-export function PersonaCatalogClient({ initialIndex, installedFontSlugs = [] }: PersonaCatalogClientProps): ReactNode {
+export function PersonaCatalogClient({
+ initialIndex,
+ installedFontSlugs = [],
+ installedThemeProvenance = [],
+ timeZone,
+}: PersonaCatalogClientProps): ReactNode {
const router = useRouter();
const [detail, setDetail] = useState({ kind: "idle" });
const [reviewing, setReviewing] = useState(false);
@@ -90,6 +111,12 @@ export function PersonaCatalogClient({ initialIndex, installedFontSlugs = [] }:
// `useState(() => ...)` (lazy initializer): this only needs to run once, not re-derive the Set on
// every render.
const [installedSlugs, setInstalledSlugs] = useState>(() => new Set(installedFontSlugs));
+ // Same lazy-initializer/local-flip shape as `installedSlugs` above, keyed by slug — a Map, not a
+ // Set, because the theme detail panel's provenance line needs the WHOLE row
+ // (importedFrom/importedAt), not just a boolean.
+ const [installedThemes, setInstalledThemes] = useState>(
+ () => new Map(installedThemeProvenance.map((provenance) => [provenance.slug, provenance]))
+ );
// Request token (T102 review, HIGH): loadDetail's fetch is not the only thing that can change
// `detail` between when a request starts and when it resolves — the operator can also collapse
@@ -179,10 +206,19 @@ export function PersonaCatalogClient({ initialIndex, installedFontSlugs = [] }:
/** SPEC F103.6's success path: no `/themes` list page exists to land on (unlike Personas' own
* `router.push` above) — `Station:Theme`'s choice list widening is a server-side fact the next
- * `GET /api/settings` read already reflects (PLAN T183/T184), nothing this component needs to
- * fetch or thread. Closing the modal and toasting is the whole client-side job. */
- function handleThemeInstalled(result: ThemeInstallResult): void {
+ * `GET /api/settings` read already reflects (PLAN T183/T184). Closing the modal, toasting, AND
+ * (gh-#375 — mirrors `handleFontInstalled`'s own local flip) marking `slug` installed
+ * in local state — so `ThemeDetailPanel` flips to "Installed"/"Re-install" with the real
+ * provenance line immediately, no reload — is the whole client-side job. A failed install never
+ * reaches this function at all (`ThemeInstallModal` only calls `onInstalled` on its own 2xx
+ * branch), so a rejected confirm flips nothing here, same as the font half. */
+ function handleThemeInstalled(slug: string, result: ThemeInstallResult): void {
setInstallingTheme(false);
+ setInstalledThemes((prev) => {
+ const next = new Map(prev);
+ next.set(slug, { slug, importedFrom: result.importedFrom, importedAt: result.importedAt });
+ return next;
+ });
toast.success(`"${result.name}" installed.`);
}
@@ -258,6 +294,8 @@ export function PersonaCatalogClient({ initialIndex, installedFontSlugs = [] }:
setInstallingTheme(true)}
/>
);
@@ -322,7 +360,7 @@ export function PersonaCatalogClient({ initialIndex, installedFontSlugs = [] }:
slug={detail.slug}
manifestText={detail.detail.card}
onCancel={() => setInstallingTheme(false)}
- onInstalled={handleThemeInstalled}
+ onInstalled={(result) => handleThemeInstalled(detail.slug, result)}
/>
)}
@@ -360,26 +398,54 @@ function detailSectionAriaLabel(kind: CatalogEntryKind | undefined): string {
}
}
+/**
+ * A theme entry's detail panel (SPEC F103.5, F103.6, PLAN T186; installed-state awareness gh-#375
+ * — the theme half of Dean's demo feedback, mirroring `FontDetailPanel`'s own
+ * `isInstalled`/Re-install treatment). `provenance` is `null` for a theme with no `station.theme`
+ * row under this catalog slug (never installed, or the shipped default it happens to share a slug
+ * with — see `ThemeCatalogProvenanceDto`'s own remarks); non-null drives the SAME "Installed" chip
+ * `FontDetailPanel` uses (the shared `Chip` component) plus an "Imported · ⟨source⟩ · ⟨date⟩"
+ * provenance line — the T187 copy verbatim, minus the leading label `SettingsForm`'s own
+ * `ThemeProvenanceBadge` folds in (this panel already names the theme in its own heading, exactly
+ * the same reasoning `FontDetailPanel`'s own bare-word chip gives) — and the Install→Re-install
+ * button label. `importedFrom` renders VERBATIM, same provenance rule every other chip in this
+ * codebase follows.
+ */
function ThemeDetailPanel({
slug,
manifestText,
+ provenance,
+ timeZone,
onInstallClick,
}: {
slug: string;
manifestText: string;
+ provenance: ThemeCatalogProvenanceDto | null;
+ timeZone?: string;
onInstallClick: () => void;
}): ReactNode {
return (
-
{prettifySlug(slug)}
- {/* Install (SPEC F103.6) opens ThemeInstallModal's confirm/cancel step — this click itself
- issues no request; the modal POSTs the SAME manifestText already reviewed here. */}
+
+
{prettifySlug(slug)}
+ {provenance !== null && Installed}
+
+ {/* Install/Re-install (SPEC F103.6; label gh-#375) opens ThemeInstallModal's
+ confirm/cancel step — this click itself issues no request; the modal POSTs the SAME
+ manifestText already reviewed here. Re-install is a genuinely supported, non-destructive
+ action — ThemesImportController.Import upserts by slug (SPEC F103.7). */}
);
diff --git a/admin-ui/app/(authed)/persona-catalog/ThemeInstallModal.tsx b/admin-ui/app/(authed)/persona-catalog/ThemeInstallModal.tsx
index 7700ce85..484baefa 100644
--- a/admin-ui/app/(authed)/persona-catalog/ThemeInstallModal.tsx
+++ b/admin-ui/app/(authed)/persona-catalog/ThemeInstallModal.tsx
@@ -8,12 +8,22 @@ import { prettifySlug } from "./format-slug";
export interface ThemeInstallResult {
name: string;
+ /** The provenance stamp the import route actually wrote (SPEC F103.11) — always this modal's own
+ * `slug` in practice (see `ThemeCatalogProvenanceDto`'s own remarks), read off the response
+ * rather than assumed, mirroring `ThemeImportSuccessBody`'s own already-present field. */
+ importedFrom: string;
+ /** When {@link importedFrom} was stamped (gh-#375) — a server read-back
+ * (`ThemesImportController`'s own remarks), never a client-side `Date.now()` guess, so
+ * `PersonaCatalogClient`'s post-install local flip can show the SAME provenance line a fresh
+ * `GET /api/settings` read would. */
+ importedAt: string;
}
interface ThemeImportSuccessBody {
slug: string;
name: string;
importedFrom: string;
+ importedAt: string;
}
export interface ThemeInstallModalProps {
@@ -67,7 +77,7 @@ export function ThemeInstallModal({ slug, manifestText, onCancel, onInstalled }:
if (resp.ok) {
const body = (await resp.json()) as ThemeImportSuccessBody;
- onInstalled({ name: body.name });
+ onInstalled({ name: body.name, importedFrom: body.importedFrom, importedAt: body.importedAt });
return;
}
diff --git a/admin-ui/app/(authed)/persona-catalog/page.tsx b/admin-ui/app/(authed)/persona-catalog/page.tsx
index ff677baa..3d108e56 100644
--- a/admin-ui/app/(authed)/persona-catalog/page.tsx
+++ b/admin-ui/app/(authed)/persona-catalog/page.tsx
@@ -2,7 +2,7 @@ import type { ReactNode } from "react";
import { cookies } from "next/headers";
import { apiGet } from "@/lib/api";
import { PersonaCatalogClient } from "./PersonaCatalogClient";
-import type { CatalogIndexResponseDto } from "./types";
+import type { CatalogIndexResponseDto, ThemeCatalogProvenanceDto } from "./types";
// The underlying Community:CatalogIndexUrl setting is live-editable from Settings (SPEC F90.1),
// and the shelf itself can flip disabled<->enabled or gain/lose entries between visits — always
@@ -41,12 +41,72 @@ async function fetchInstalledFontSlugs(cookieHeader: string): Promise
}
}
+/** Wire shape of one `Station:Theme` choice, off `GET /api/settings` (SPEC F103.11, PLAN T187) —
+ * only the fields this page reads; mirrors `InstalledFontPackRow`'s own narrow-cast idiom above
+ * rather than importing `settings/settings-types.ts`'s full `SettingChoice` for three fields. */
+interface StationThemeChoiceRow {
+ value: string;
+ importedFrom?: string | null;
+ importedAt?: string | null;
+}
+
+/** Wire shape of one `GET /api/settings` row — only the one field this page reads (mirrors
+ * `wardrobe/page.tsx`'s own local `SettingRow` idiom for the SAME endpoint, a different key). */
+interface SettingRow {
+ key: string;
+ choices?: StationThemeChoiceRow[];
+}
+
+const STATION_THEME_KEY = "Station:Theme";
+
+/**
+ * Every catalog-imported theme's provenance (gh-#375, Dean's demo feedback — the theme
+ * half of the font half's own T204 "reopening an installed pack shows no installed state" fix).
+ *
+ * Route choice (this task's own dispatch note): `GET /api/settings`, not a new `GET /api/themes`
+ * route. `Station:Theme`'s choices already widen to shipped ∪ owner themes with
+ * `importedFrom`/`importedAt` per choice (SPEC F103.7/F103.11, PLAN T183/T187,
+ * `StationSettingsAllowlist.ThemeChoices`/`SettingChoice`) — the exact data this page needs already
+ * rides an existing, generic endpoint, so a dedicated `GET /api/themes` listing (the font half's own
+ * `/api/fonts` shape) would duplicate that projection for zero new capability. The one thing this
+ * costs over a dedicated route: this page reads the WHOLE settings document (every allowlisted key,
+ * not only `Station:Theme`) to reach one field. That document is small (one row per allowlisted
+ * key, no large payloads — SPEC F55.3's full-coverage allowlist is still a few dozen rows) and
+ * already fetched wholesale by other authed pages for a single key each (`wardrobe/page.tsx`'s own
+ * `fetchCatalogEnabled`, `layout.tsx`'s own `Station:Theme` read for the header's ThemeSwitcher) —
+ * this is that same established shape, not a new pattern.
+ *
+ * Fetched ALONGSIDE the index and `installedFontSlugs`, in the SAME server component (one more
+ * `Promise.all` leg, the smaller diff over a lazy per-open client fetch). Any failure (network
+ * error, non-200, an unexpected shape) degrades to `[]` — fail closed, matching
+ * `fetchInstalledFontSlugs`'s own posture above: no live signal means no theme gets FALSELY claimed
+ * installed.
+ */
+async function fetchInstalledThemeProvenance(cookieHeader: string): Promise {
+ try {
+ const response = await apiGet("/api/settings", { cookies: cookieHeader });
+ if (!response.ok) return [];
+ const settings = (await response.json()) as SettingRow[];
+ const themeSetting = settings.find((row) => row.key === STATION_THEME_KEY);
+ const choices = themeSetting?.choices ?? [];
+ return choices
+ .filter(
+ (choice): choice is StationThemeChoiceRow & { importedFrom: string; importedAt: string } =>
+ choice.importedFrom != null && choice.importedAt != null
+ )
+ .map((choice) => ({ slug: choice.value, importedFrom: choice.importedFrom, importedAt: choice.importedAt }));
+ } catch {
+ return [];
+ }
+}
+
export default async function PersonaCatalogPage(): Promise {
const cookieStore = await cookies();
const cookieHeader = cookieStore.toString();
- const [response, installedFontSlugs] = await Promise.all([
+ const [response, installedFontSlugs, installedThemeProvenance] = await Promise.all([
apiGet("/api/catalog/index", { cookies: cookieHeader }),
fetchInstalledFontSlugs(cookieHeader),
+ fetchInstalledThemeProvenance(cookieHeader),
]);
// Disabled (SPEC F90.1): CatalogController serves a bare, zero-byte 404 here — the same
@@ -78,7 +138,11 @@ export default async function PersonaCatalogPage(): Promise {
Community Catalog
-
+
);
diff --git a/admin-ui/app/(authed)/persona-catalog/types.ts b/admin-ui/app/(authed)/persona-catalog/types.ts
index 9739e4c4..7012c0ba 100644
--- a/admin-ui/app/(authed)/persona-catalog/types.ts
+++ b/admin-ui/app/(authed)/persona-catalog/types.ts
@@ -85,3 +85,25 @@ export interface CatalogEntryDetailDto {
fontVersion: string | null;
fontSubset: string | null;
}
+
+/**
+ * One catalog-imported theme's provenance (gh-#375, Dean's demo feedback — the theme half
+ * of the v3.1.1 polish, mirroring the font half's `installedFontSlugs` — see
+ * `PersonaCatalogClient`'s own remarks). Sourced from `GET /api/settings`'s own `Station:Theme`
+ * choices (SPEC F103.11, PLAN T187's `SettingChoice.importedFrom`/`importedAt`), never a new
+ * backend route: `persona-catalog/page.tsx` extracts every choice carrying provenance and hands the
+ * list straight through — the smaller diff over adding a dedicated `GET /api/themes` listing (this
+ * task's own dispatch weighed both; see that file's own remarks for the full reasoning).
+ *
+ * `slug` is the catalog entry's own slug, kept as its own field distinct from `importedFrom` even
+ * though the two are always equal today (`ThemeInstallModal` always installs a theme under its own
+ * catalog slug, threading that SAME value as both the import route's target slug and its
+ * `?catalogSlug=`) — a caller keyed on `slug` never has to assume that equality holds, the same
+ * "read the real field, don't infer it" discipline `WardrobeClient`'s own `ProvenanceChip` remarks
+ * state for its own always-equal `importedFrom`/`slug` pair.
+ */
+export interface ThemeCatalogProvenanceDto {
+ slug: string;
+ importedFrom: string;
+ importedAt: string;
+}
diff --git a/admin-ui/app/(authed)/personas/PersonasClient.tsx b/admin-ui/app/(authed)/personas/PersonasClient.tsx
index 7111dea7..7b18c81b 100644
--- a/admin-ui/app/(authed)/personas/PersonasClient.tsx
+++ b/admin-ui/app/(authed)/personas/PersonasClient.tsx
@@ -2,6 +2,7 @@
import { Fragment, useRef, useState, type FormEvent, type ReactNode } from "react";
import { Button } from "@/components/ui/button";
+import { Chip } from "@/components/ui/chip";
import { EmptyState } from "@/components/ui/empty-state";
import { toast } from "@/components/ui/toast";
import { formatDateStamp } from "@/lib/format-clock";
@@ -107,7 +108,14 @@ function displayVoice(voice: string): string {
* `lib/format-clock.ts`'s bare-calendar-date formatter — NOT `formatUpSince` (that one folds its
* own `HH:MM · Mon D` pair, no year, into the string, which would both break the badge's literal
* three-field shape and silently collide two imports a year apart). `timeZone` is a plain
- * pass-through from the page prop, the house test-injection idiom. */
+ * pass-through from the page prop, the house test-injection idiom.
+ *
+ * The shared `Chip` component (`components/ui/chip.tsx`, gh-#375 extraction — adjudicated
+ * compatible: this badge's own className differed from the other four sites only by `ml-2` in
+ * place of `w-fit`, and `inline-flex` already sizes to its content with no explicit width utility
+ * either way, so adding `Chip`'s own `w-fit` here changes nothing rendered) with an `ml-2` override
+ * — this badge sits inline immediately after the persona's name text (unlike every other chip
+ * site's standalone placement), so it alone needs the extra left margin. */
function ProvenanceBadge({
importedFrom,
importedAt,
@@ -117,11 +125,7 @@ function ProvenanceBadge({
importedAt: string;
timeZone?: string;
}): ReactNode {
- return (
-
- {`Hired · ${importedFrom} · ${formatDateStamp(importedAt, { timeZone })}`}
-
- );
+ return {`Hired · ${importedFrom} · ${formatDateStamp(importedAt, { timeZone })}`};
}
/** On The Air badge (SPEC F94.1, STORY-246, PLAN T127) — the roster's replacement for the retired
diff --git a/admin-ui/app/(authed)/settings/SettingsForm.tsx b/admin-ui/app/(authed)/settings/SettingsForm.tsx
index f51dba38..a90de3fa 100644
--- a/admin-ui/app/(authed)/settings/SettingsForm.tsx
+++ b/admin-ui/app/(authed)/settings/SettingsForm.tsx
@@ -9,6 +9,7 @@ import {
type ReactNode,
} from "react";
import { Button } from "@/components/ui/button";
+import { Chip } from "@/components/ui/chip";
import { useConfirm } from "@/components/ui/confirm-dialog";
import { toast } from "@/components/ui/toast";
import { formatDateStamp } from "@/lib/format-clock";
@@ -1161,9 +1162,9 @@ function ThemeProvenanceList({
*
* Kept as its own small component in this file rather than merged with `PersonasClient`'s
* `ProvenanceBadge` into one shared component (PLAN T187 review F1's "cheap, do it" note): the
- * shapes differ (this one folds the label into the same chip; persona's leaves the name outside
- * it) and a genuine merge would mean editing `PersonasClient.tsx` too, which sits outside this
- * task's file partition — noted rather than done silently.
+ * TEXT shapes differ (this one folds the label into the same chip; persona's leaves the name
+ * outside it) — only the visual chip styling was ever duplicated, and `Chip`
+ * (`components/ui/chip.tsx`, gh-#375 extraction) now owns that once, shared by both.
*/
function ThemeProvenanceBadge({
label,
@@ -1176,23 +1177,17 @@ function ThemeProvenanceBadge({
importedAt: string;
timeZone?: string;
}): ReactNode {
- return (
-
- {`${label} — Imported · ${importedFrom} · ${formatDateStamp(importedAt, { timeZone })}`}
-
- );
+ return {`${label} — Imported · ${importedFrom} · ${formatDateStamp(importedAt, { timeZone })}`};
}
-/** 3px-radius bordered chip for the source tag, per design-aesthetic chip conventions. */
+/** 3px-radius bordered chip for the source tag, per design-aesthetic chip conventions — the shared
+ * `Chip` component, with `aria-label`/`data-source` passed straight through via its own `...props`
+ * spread. */
function SourceChip({ source }: { source: SettingDto["source"] }): ReactNode {
return (
-
+
[{sourceLabel(source)}]
-
+
);
}
diff --git a/admin-ui/app/(authed)/wardrobe/WardrobeClient.tsx b/admin-ui/app/(authed)/wardrobe/WardrobeClient.tsx
index cd22cbf6..0cfdf3d3 100644
--- a/admin-ui/app/(authed)/wardrobe/WardrobeClient.tsx
+++ b/admin-ui/app/(authed)/wardrobe/WardrobeClient.tsx
@@ -1,6 +1,7 @@
"use client";
import type { ReactNode } from "react";
+import { Chip } from "@/components/ui/chip";
import { EmptyState } from "@/components/ui/empty-state";
import { formatDateStamp } from "@/lib/format-clock";
import { formatFontByteTotal, licenceLine } from "../persona-catalog/font-format";
@@ -27,14 +28,15 @@ export interface WardrobeClientProps {
}
/** Provenance chip — "Installed · <slug> · <date>" (SPEC F104.7 AC1, the db/25 pattern)
- * — mirrors `PersonasClient`'s own `ProvenanceBadge`/`SettingsForm`'s own `ThemeProvenanceBadge`
- * treatment (quiet bordered chip, T105/T187) rather than importing either: this page sits outside
- * both files' own partitions, and the shape here ("Installed", no leading label) differs from both
- * ("Hired"/"<label> — Imported") enough that a genuine shared component would need editing
- * either file anyway. `importedFrom` renders VERBATIM — this is provenance, not decoration, same
- * rule the persona/theme chips already follow — even though it is always equal to the pack's own
- * `slug` today (a pack has no authored-in-place path); reading it off its own field rather than
- * `pack.slug` keeps this chip honest about which column IS the provenance stamp. */
+ * — the shared `Chip` component (`components/ui/chip.tsx`, gh-#375 extraction) rather than
+ * `PersonasClient`'s own `ProvenanceBadge`/`SettingsForm`'s own `ThemeProvenanceBadge` directly:
+ * this page sits outside both files' own partitions, and the TEXT shape here ("Installed", no
+ * leading label) differs from both ("Hired"/"<label> — Imported") — only the visual chip
+ * styling itself was ever duplicated, which `Chip` now owns once. `importedFrom` renders VERBATIM —
+ * this is provenance, not decoration, same rule the persona/theme chips already follow — even
+ * though it is always equal to the pack's own `slug` today (a pack has no authored-in-place path);
+ * reading it off its own field rather than `pack.slug` keeps this chip honest about which column IS
+ * the provenance stamp. */
function ProvenanceChip({
importedFrom,
importedAt,
@@ -44,11 +46,7 @@ function ProvenanceChip({
importedAt: string;
timeZone?: string;
}): ReactNode {
- return (
-
- {`Installed · ${importedFrom} · ${formatDateStamp(importedAt, { timeZone })}`}
-
- );
+ return {`Installed · ${importedFrom} · ${formatDateStamp(importedAt, { timeZone })}`};
}
/**
diff --git a/admin-ui/components/ui/chip.tsx b/admin-ui/components/ui/chip.tsx
new file mode 100644
index 00000000..58c8d5b9
--- /dev/null
+++ b/admin-ui/components/ui/chip.tsx
@@ -0,0 +1,37 @@
+import type { HTMLAttributes, ReactNode } from "react";
+import { cn } from "@/lib/utils";
+
+export interface ChipProps extends HTMLAttributes {
+ children?: ReactNode;
+}
+
+/**
+ * The quiet bordered-pill chip (design-aesthetic skill) — 3px radius, `--line` border, `--mute`
+ * text — used across the admin UI for a status word or a provenance stamp: source tags
+ * (`SettingsForm`'s `SourceChip`), imported-theme provenance (`SettingsForm`'s
+ * `ThemeProvenanceBadge`, `PersonaCatalogClient`'s theme detail panel), imported-persona provenance
+ * (`PersonasClient`'s `ProvenanceBadge`), an installed font pack's provenance
+ * (`WardrobeClient`'s `ProvenanceChip`), and a catalog font pack's bare "Installed" status
+ * (`FontDetailPanel`). Every one of those five pre-existing sites carried its OWN copy of the same
+ * className string (gh-#375 review carry-forward, N4) — this is the one extraction, children and
+ * an optional `className` override are the only thing that ever varied. `className` merges via
+ * `cn()` (the `Button`/`EmptyState` precedent) rather than replacing the base styling outright, so a
+ * caller can ADD a layout concern (e.g. `PersonasClient`'s own `ml-2` — it sits inline right after a
+ * name, unlike every other site's standalone placement) without repeating the visual treatment
+ * itself. Every other native `` attribute (`aria-label`, `data-source`, `data-testid`, …)
+ * passes straight through via `...props`, the same `Button` idiom — `SourceChip`'s own
+ * `aria-label`/`data-source` pair needs both.
+ */
+export function Chip({ children, className, ...props }: ChipProps): ReactNode {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/src/GenWave.Host/Api/CatalogController.cs b/src/GenWave.Host/Api/CatalogController.cs
index 0d039216..80b7dc63 100644
--- a/src/GenWave.Host/Api/CatalogController.cs
+++ b/src/GenWave.Host/Api/CatalogController.cs
@@ -128,8 +128,7 @@ public async Task Entry(string slug, CancellationToken ct)
{
CatalogEntryFetchResult.Ok ok => Ok(ToEntryResponse(ok)),
CatalogEntryFetchResult.NotFound => NotFound(UnknownEntryProblem(slug)),
- CatalogEntryFetchResult.Unreachable => Ok(new CatalogEntryResponse(
- null, null, null, Unreachable: true, null, null, null, null, null, null, null, null, null, null, null, null)),
+ CatalogEntryFetchResult.Unreachable => Ok(CatalogEntryResponse.UnreachableCatalog()),
CatalogEntryFetchResult.HashMismatch =>
StatusCode(StatusCodes.Status502BadGateway, WithheldProblem("failed its integrity check")),
CatalogEntryFetchResult.Oversize =>
diff --git a/src/GenWave.Host/Api/CatalogEntryResponse.cs b/src/GenWave.Host/Api/CatalogEntryResponse.cs
index e2ecc165..dee111d5 100644
--- a/src/GenWave.Host/Api/CatalogEntryResponse.cs
+++ b/src/GenWave.Host/Api/CatalogEntryResponse.cs
@@ -94,4 +94,40 @@ public sealed record CatalogEntryResponse(
string? FontSpecimenFile,
string? FontLicense,
string? FontVersion,
- string? FontSubset);
+ string? FontSubset)
+{
+ ///
+ /// The graceful "catalog currently unreachable" shape (SPEC F90.4, =
+ /// , every other field ) — a named factory (gh-#375,
+ /// inherited from the font half's own review, finding N1) replacing the 15-positional-null literal
+ /// used to construct inline. That literal predates this record's own by-name discipline (PLAN
+ /// T204's already names every argument it
+ /// passes) and had grown a silent trap: every widening of this record (three fields added at
+ /// T204 alone) meant one more null slotted into that same literal with nothing to catch a
+ /// miscount or a misordered pair of same-typed fields (two adjacent ?
+ /// positions swap silently). A one-time factory means the NEXT widening only ever touches this
+ /// method's own body, never a call site that has to be found and recounted. Named
+ /// UnreachableCatalog, not the record's own property name — C#
+ /// forbids a method and a property sharing one identifier on the same type (CS0102), so the
+ /// dispatch's literal CatalogEntryResponse.Unreachable() naming could not compile as
+ /// written; this is the closest unambiguous name that still reads as "the unreachable shape" at
+ /// the call site.
+ ///
+ public static CatalogEntryResponse UnreachableCatalog() => new(
+ Card: null,
+ Meta: null,
+ FetchedAt: null,
+ Unreachable: true,
+ Kind: null,
+ Audience: null,
+ BestFor: null,
+ Author: null,
+ Description: null,
+ SamplePatter: null,
+ FontFamily: null,
+ FontByteTotal: null,
+ FontSpecimenFile: null,
+ FontLicense: null,
+ FontVersion: null,
+ FontSubset: null);
+}
diff --git a/src/GenWave.Host/Api/ThemeImportResponse.cs b/src/GenWave.Host/Api/ThemeImportResponse.cs
index dfa757a6..275d720a 100644
--- a/src/GenWave.Host/Api/ThemeImportResponse.cs
+++ b/src/GenWave.Host/Api/ThemeImportResponse.cs
@@ -15,4 +15,14 @@ namespace GenWave.Host.Api;
/// The manifest's own display name.
/// The provenance stamp actually written: the catalogSlug query
/// value, or "file" for a direct upload (SPEC F103.6/F103.11).
-public sealed record ThemeImportResponse(string Slug, string Name, string ImportedFrom);
+///
+/// The moment was stamped (gh-#375, Dean's gh-#375 demo feedback — the
+/// theme half of the v3.1.1 polish, mirroring the font half's own FontPackInstallResponse
+/// shape) — read back from straight after the upsert
+/// commits, the same value GET /api/settings's own
+/// Station:Theme choices will report from the next read, never a client-approximated
+/// guess: the admin UI's catalog detail panel flips to "Installed"
+/// entirely from THIS response (no second fetch, PersonaCatalogClient's own local-flip precedent),
+/// so a fabricated timestamp here would be a lie the very next settings read could contradict.
+///
+public sealed record ThemeImportResponse(string Slug, string Name, string ImportedFrom, DateTime ImportedAt);
diff --git a/src/GenWave.Host/Api/ThemesImportController.cs b/src/GenWave.Host/Api/ThemesImportController.cs
index 3fcaf81f..3463ef5f 100644
--- a/src/GenWave.Host/Api/ThemesImportController.cs
+++ b/src/GenWave.Host/Api/ThemesImportController.cs
@@ -240,7 +240,19 @@ public async Task Import(string slug, [FromQuery] string? catalog
LogSafeText.Sanitize(slug),
LogSafeText.Sanitize(importedFrom));
- return Ok(new ThemeImportResponse(normalized.Slug, normalized.Name, importedFrom));
+ // gh-#375 (gh-#375, ThemeImportResponse's own remarks) — a read-back, not a client-side
+ // DateTime.UtcNow guess: the store just stamped imported_at unconditionally (both the
+ // insert and the update ON CONFLICT branch, see IThemeStore.UpsertAsync's own remarks) with
+ // an importedFrom that is NEVER null on this path, so OwnerTheme's own "ImportedAt is null
+ // exactly when ImportedFrom is" invariant guarantees a value here — a null read-back would
+ // mean the write this request just awaited never actually committed, worth surfacing loudly
+ // rather than papering over with a fabricated timestamp the admin UI would show as fact.
+ var stored = await themeStore.GetBySlugAsync(slug, ct)
+ ?? throw new InvalidOperationException($"Theme '{slug}' was upserted but could not be read back.");
+ var importedAt = stored.ImportedAt
+ ?? throw new InvalidOperationException($"Theme '{slug}' was imported but carries no imported_at stamp.");
+
+ return Ok(new ThemeImportResponse(normalized.Slug, normalized.Name, importedFrom, importedAt));
}
// ── Helpers ──────────────────────────────────────────────────────────────
diff --git a/tests/GenWave.Host.Tests/Specs/Story272_ThemeImport.cs b/tests/GenWave.Host.Tests/Specs/Story272_ThemeImport.cs
index ac476558..32d63d75 100644
--- a/tests/GenWave.Host.Tests/Specs/Story272_ThemeImport.cs
+++ b/tests/GenWave.Host.Tests/Specs/Story272_ThemeImport.cs
@@ -353,6 +353,60 @@ public async Task TheImportedSlugIsListedAndAcceptedAsStationTheme()
}
}
+ public sealed class ScenarioTheResponseCarriesImportedAt
+ {
+ [Fact]
+ public async Task TheImportResponseImportedAtMatchesTheStoredRow()
+ {
+ // Given a freshly imported catalog theme (gh-#375: the admin UI's catalog
+ // detail panel flips to "Installed" straight off THIS response, no second fetch — see
+ // ThemeImportResponse's own remarks — so it needs a real, store-sourced imported_at,
+ // never a client-side DateTime.UtcNow approximation),
+ var themeStore = new FakeThemeStore();
+ await using var factory = new ThemeImportWebFactory(themeStore);
+ var client = await LoggedInClientAsync(factory);
+
+ // When it is imported,
+ var response = await PostManifestAsync(
+ client, "midnight-drive", ThemeImportFixture.ValidManifestJson("midnight-drive"),
+ catalogSlug: "midnight-drive-catalog-entry");
+ var body = await response.Content.ReadFromJsonAsync();
+
+ // Then the response's own ImportedAt is the SAME value the store actually persisted —
+ // a read-back, not a guess.
+ Assert.True(response.IsSuccessStatusCode, await response.Content.ReadAsStringAsync());
+ var stored = await themeStore.GetBySlugAsync("midnight-drive", CancellationToken.None);
+ Assert.Equal(stored?.ImportedAt, body?.ImportedAt);
+ Assert.NotEqual(default, body?.ImportedAt);
+ }
+
+ [Fact]
+ public async Task TheSettingsSurfaceReportsTheSameImportedAtForTheChoice()
+ {
+ // Given a freshly imported catalog theme,
+ var settingsStore = new FakeThemeSettingsStore();
+ await using var factory = new ThemeImportWebFactory(settingsStore: settingsStore);
+ var client = await LoggedInClientAsync(factory);
+ var importResponse = await PostManifestAsync(
+ client, "aurora-glow", ThemeImportFixture.ValidManifestJson("aurora-glow"),
+ catalogSlug: "aurora-glow-catalog-entry");
+ var importBody = await importResponse.Content.ReadFromJsonAsync();
+ Assert.True(importResponse.IsSuccessStatusCode, await importResponse.Content.ReadAsStringAsync());
+
+ // When Station:Theme's own choices are read back — the SAME seam gh-#375's own admin-ui
+ // catalog page reads to derive its installed-provenance list, no new backend route,
+ var getResponse = await client.GetAsync("/api/settings");
+ var settings = await getResponse.Content.ReadFromJsonAsync>();
+ var themeSetting = settings!.Single(s => s.Key.Equals("Station:Theme", StringComparison.OrdinalIgnoreCase));
+ var choice = themeSetting.Choices!.Single(c => c.Value == "aurora-glow");
+
+ // Then the choice's own importedFrom/importedAt agree with the import response — the
+ // two reads can never silently disagree about when this theme was imported.
+ Assert.Equal("aurora-glow-catalog-entry", choice.ImportedFrom);
+ Assert.Equal(importBody?.ImportedAt, choice.ImportedAt);
+ }
+ }
+
// ── SAD PATH ────────────────────────────────────────────────────────────
public sealed class ScenarioRejectingBadImports