Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
148 changes: 148 additions & 0 deletions admin-ui/__specs__/wardrobe-tabs.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
// @jest-environment jsdom
// gh-#393 β€” The Wardrobe silos the catalog kinds by tab (the gh-#372 shelf treatment applied to
// the installed side): Personas | Themes | Fonts | Shows, URL-driven via `?tab=` (the CatalogTabs/
// BoothLogTabs idiom, now the shared components/ui/tab-strip.tsx), every tab present even when
// empty (Dean's ruling on the issue β€” an empty kind shows its own empty state, never a hidden tab).
//
// Runner: Jest. Three surfaces: resolveWardrobeTab (the `?tab=` β†’ tab mapping), WardrobeTabs (the
// strip itself), and InstalledEntriesList (the read-only per-kind listing the Personas/Themes/Shows
// tabs share β€” fonts keep their own WardrobeClient, covered by font-pack-wardrobe.spec.tsx
// unchanged). `timeZone="UTC"` pins the provenance chip's date half, the house idiom (T105/T187).

import { describe, it, expect } from "@jest/globals";
import { render, screen, within } from "@testing-library/react";
import "@testing-library/jest-dom";
import { InstalledEntriesList } from "../app/(authed)/wardrobe/InstalledEntriesList";
import { resolveWardrobeTab, WardrobeTabs } from "../app/(authed)/wardrobe/WardrobeTabs";
import type { InstalledEntryRow } from "../app/(authed)/wardrobe/types";

const HIRED_PERSONA: InstalledEntryRow = {
slug: "big-tony-marconi",
name: "Big Tony Marconi",
detail: null,
importedFrom: "big-tony-marconi",
importedAt: "2026-08-05T12:00:00Z",
};

const IMPORTED_SHOW: InstalledEntryRow = {
slug: "til-sunrise",
name: "'Til Sunrise",
detail: "Overnights, unhurried.",
importedFrom: "til-sunrise",
importedAt: "2026-08-11T09:00:00Z",
};

describe("Feature: the Wardrobe silos kinds by tab (gh-#393)", () => {
describe("Scenario: ?tab= resolves to a wardrobe tab", () => {
it("defaults to personas when absent", () => {
expect(resolveWardrobeTab(undefined)).toBe("personas");
});

it("passes each named tab through", () => {
expect(["themes", "fonts", "shows"].map(resolveWardrobeTab)).toEqual(["themes", "fonts", "shows"]);
});

it("falls back to personas on anything unrecognised", () => {
expect(resolveWardrobeTab("hats")).toBe("personas");
expect(resolveWardrobeTab(["fonts", "shows"])).toBe("personas");
});
});

describe("Scenario: the tab strip lists every kind, always", () => {
it("renders all four tabs with their ?tab= hrefs", () => {
render(<WardrobeTabs activeTab="personas" />);

const nav = screen.getByRole("navigation", { name: "Wardrobe sections" });
expect(within(nav).getByRole("link", { name: "Personas" })).toHaveAttribute("href", "/wardrobe");
expect(within(nav).getByRole("link", { name: "Themes" })).toHaveAttribute("href", "/wardrobe?tab=themes");
expect(within(nav).getByRole("link", { name: "Fonts" })).toHaveAttribute("href", "/wardrobe?tab=fonts");
expect(within(nav).getByRole("link", { name: "Shows" })).toHaveAttribute("href", "/wardrobe?tab=shows");
});

it("marks only the active tab with aria-current", () => {
render(<WardrobeTabs activeTab="fonts" />);

expect(screen.getByRole("link", { name: "Fonts" })).toHaveAttribute("aria-current", "page");
expect(screen.getByRole("link", { name: "Personas" })).not.toHaveAttribute("aria-current");
});
});

describe("Scenario: a kind tab lists its installed entries", () => {
it("renders a card per row with the kind's own provenance verb", () => {
render(
<InstalledEntriesList
rows={[HIRED_PERSONA]}
ariaLabel="Hired personas"
provenanceVerb="Hired"
emptyTitle="No personas hired"
emptyReason="unused here"
timeZone="UTC"
/>
);

const list = screen.getByRole("list", { name: "Hired personas" });
expect(within(list).getByText("Big Tony Marconi")).toBeInTheDocument();
expect(within(list).getByText("Hired Β· big-tony-marconi Β· Aug 5, 2026")).toBeInTheDocument();
});

it("renders the secondary line only when a row carries one", () => {
render(
<InstalledEntriesList
rows={[IMPORTED_SHOW]}
ariaLabel="Imported shows"
provenanceVerb="Imported"
emptyTitle="No shows imported"
emptyReason="unused here"
timeZone="UTC"
/>
);

const list = screen.getByRole("list", { name: "Imported shows" });
expect(within(list).getByText("Overnights, unhurried.")).toBeInTheDocument();
expect(within(list).getByText("Imported Β· til-sunrise Β· Aug 11, 2026")).toBeInTheDocument();
});
});

describe("Scenario: a kind tab is empty", () => {
// The T203 review finding F3 CTA swap, inherited from WardrobeClient's own empty state: a
// disabled catalog must never leave an empty tab pointing at /persona-catalog (it 404s
// off-catalog).
it("names the reason and offers the catalog CTA when the catalog is enabled", () => {
render(
<InstalledEntriesList
rows={[]}
ariaLabel="Hired personas"
provenanceVerb="Hired"
emptyTitle="No personas hired"
emptyReason="Browse the Community Catalog to hire a DJ for this station."
catalogEnabled
timeZone="UTC"
/>
);

expect(screen.getByText("No personas hired")).toBeInTheDocument();
expect(screen.getByRole("link", { name: "Browse the Community Catalog" })).toHaveAttribute(
"href",
"/persona-catalog"
);
});

it("points at Settings instead when the catalog is disabled", () => {
render(
<InstalledEntriesList
rows={[]}
ariaLabel="Hired personas"
provenanceVerb="Hired"
emptyTitle="No personas hired"
emptyReason="unused when disabled"
catalogEnabled={false}
timeZone="UTC"
/>
);

expect(screen.getByText("No personas hired")).toBeInTheDocument();
expect(screen.queryByRole("link", { name: "Browse the Community Catalog" })).not.toBeInTheDocument();
expect(screen.getByRole("link", { name: "Open Settings" })).toHaveAttribute("href", "/settings");
});
});
});
43 changes: 9 additions & 34 deletions admin-ui/app/(authed)/booth-log/BoothLogTabs.tsx
Original file line number Diff line number Diff line change
@@ -1,50 +1,25 @@
import Link from "next/link";
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
import { TabStrip, type TabStripTab } from "@/components/ui/tab-strip";

export type BoothLogTab = "log" | "llm-calls";

interface BoothLogTabsProps {
activeTab: BoothLogTab;
}

interface TabDef {
id: BoothLogTab;
label: string;
href: string;
}

const TABS: TabDef[] = [
const TABS: TabStripTab<BoothLogTab>[] = [
{ id: "log", label: "Booth log", href: "/booth-log" },
{ id: "llm-calls", label: "LLM calls", href: "/booth-log?tab=llm-calls" },
];

/**
* Booth log | LLM calls tab strip (PLAN T41, STORY-196) β€” URL-driven via `?tab=`, no client state,
* same shape as the Catalog page's own Tracks | Libraries tabs (CatalogTabs). The LLM call
* inspector folds under this page rather than earning its own sidebar item: the nav was already
* getting full after T40 added Booth log, and both surfaces are the same "operational narrative"
* epic (SPEC F72/F73) β€” a debug tab on an existing operator page, not a new top-level destination.
* Booth log | LLM calls tab strip (PLAN T41, STORY-196) β€” URL-driven via `?tab=`, no client state.
* The LLM call inspector folds under this page rather than earning its own sidebar item: the nav
* was already getting full after T40 added Booth log, and both surfaces are the same "operational
* narrative" epic (SPEC F72/F73) β€” a debug tab on an existing operator page, not a new top-level
* destination. Markup lives in the shared `TabStrip` (gh-#393 extraction) β€” this wrapper owns only
* the tab defs.
*/
export function BoothLogTabs({ activeTab }: BoothLogTabsProps): ReactNode {
return (
<nav aria-label="Booth log sections" className="flex gap-1 border-b-2 border-line">
{TABS.map((tab) => {
const active = tab.id === activeTab;
return (
<Link
key={tab.id}
href={tab.href}
aria-current={active ? "page" : undefined}
className={cn(
"-mb-[2px] flex min-h-10 items-center border-b-2 px-3 py-2 text-[0.82rem] font-semibold transition-colors duration-[120ms] ease-out",
active ? "border-accent text-accent" : "border-transparent text-mute hover:text-ink"
)}
>
{tab.label}
</Link>
);
})}
</nav>
);
return <TabStrip tabs={TABS} activeTab={activeTab} ariaLabel="Booth log sections" />;
}
36 changes: 6 additions & 30 deletions admin-ui/app/(authed)/catalog/CatalogTabs.tsx
Original file line number Diff line number Diff line change
@@ -1,48 +1,24 @@
import Link from "next/link";
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
import { TabStrip, type TabStripTab } from "@/components/ui/tab-strip";

export type CatalogTab = "tracks" | "libraries";

interface CatalogTabsProps {
activeTab: CatalogTab;
}

interface TabDef {
id: CatalogTab;
label: string;
href: string;
}

const TABS: TabDef[] = [
const TABS: TabStripTab<CatalogTab>[] = [
{ id: "tracks", label: "Tracks", href: "/catalog" },
{ id: "libraries", label: "Libraries", href: "/catalog?tab=libraries" },
];

/**
* Tracks | Libraries tab strip for the Catalog page (SPEC F28.11, STORY-089
* AC4) β€” URL-driven via `?tab=`, no client state. Libraries folds under
* Catalog here instead of its own sidebar item (removed at Q3).
* Catalog here instead of its own sidebar item (removed at Q3). Markup lives
* in the shared `TabStrip` (gh-#393 extraction) β€” this wrapper owns only the
* tab defs.
*/
export function CatalogTabs({ activeTab }: CatalogTabsProps): ReactNode {
return (
<nav aria-label="Catalog sections" className="flex gap-1 border-b-2 border-line">
{TABS.map((tab) => {
const active = tab.id === activeTab;
return (
<Link
key={tab.id}
href={tab.href}
aria-current={active ? "page" : undefined}
className={cn(
"-mb-[2px] flex min-h-10 items-center border-b-2 px-3 py-2 text-[0.82rem] font-semibold transition-colors duration-[120ms] ease-out",
active ? "border-accent text-accent" : "border-transparent text-mute hover:text-ink"
)}
>
{tab.label}
</Link>
);
})}
</nav>
);
return <TabStrip tabs={TABS} activeTab={activeTab} ariaLabel="Catalog sections" />;
}
76 changes: 76 additions & 0 deletions admin-ui/app/(authed)/wardrobe/InstalledEntriesList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
"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 type { InstalledEntryRow } from "./types";

export interface InstalledEntriesListProps {
/** Genuinely-imported rows of one kind β€” see `InstalledEntryRow`'s own remarks. */
rows: InstalledEntryRow[];
/** Names the `<ul>` (e.g. "Hired personas") β€” mirrors `WardrobeClient`'s own "Installed font packs". */
ariaLabel: string;
/** The kind's own provenance verb β€” "Hired" for personas (the F90.7/T105 wording), "Imported" for
* themes/shows (the Settings/shelf wording); fonts keep their own "Installed" chip in
* `WardrobeClient`. The chip is otherwise the same db/25 "⟨verb⟩ · ⟨slug⟩ · ⟨date⟩" shape. */
provenanceVerb: string;
/** Empty-state copy (SPEC F28.10 β€” name the reason, calm radio-operator tone). */
emptyTitle: string;
emptyReason: string;
/** Same CTA swap `WardrobeClient` performs (T203 review finding F3): a disabled catalog must never
* leave an empty tab pointing at `/persona-catalog`, which itself 404s off-catalog. */
catalogEnabled?: boolean;
/** Test-only `formatDateStamp` zone pin β€” the PersonasClient/SettingsForm idiom (T105/T187). */
timeZone?: string;
}

/**
* One non-font wardrobe tab's listing (gh-#393): read-only cards β€” name, an optional secondary
* line, and the provenance chip. Deliberately action-free: retiring a persona / removing a theme /
* deleting a show each already live on that kind's own page with their own confirm flows β€” the
* Wardrobe silos WHAT is installed per kind (the gh-#393 ask), it does not become a second place to
* operate on them (the one exception, font uninstall, predates this page's widening and stays on
* the Fonts tab). `name`/`detail` render as plain text nodes ONLY β€” see `InstalledEntryRow`.
*/
export function InstalledEntriesList({
rows,
ariaLabel,
provenanceVerb,
emptyTitle,
emptyReason,
catalogEnabled = false,
timeZone,
}: InstalledEntriesListProps): ReactNode {
if (rows.length === 0) {
return catalogEnabled ? (
<EmptyState
title={emptyTitle}
reason={emptyReason}
cta={{ label: "Browse the Community Catalog", href: "/persona-catalog" }}
/>
) : (
<EmptyState
title={emptyTitle}
reason="The Community Catalog is disabled β€” enable Community:CatalogIndexUrl in Settings to browse the shelf."
cta={{ label: "Open Settings", href: "/settings" }}
/>
);
}

return (
<ul aria-label={ariaLabel} className="flex flex-col gap-3">
{rows.map((row) => (
<li key={row.slug} className="rounded-[6px] border border-line bg-surface p-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<h2 className="font-display text-[1.1rem] text-ink">{row.name}</h2>
<Chip>{`${provenanceVerb} Β· ${row.importedFrom} Β· ${formatDateStamp(row.importedAt, { timeZone })}`}</Chip>
</div>
{row.detail !== null && row.detail !== "" && (
<p className="mt-2 text-[0.85rem] text-mute">{row.detail}</p>
)}
</li>
))}
</ul>
);
}
36 changes: 36 additions & 0 deletions admin-ui/app/(authed)/wardrobe/WardrobeTabs.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import type { ReactNode } from "react";
import { TabStrip, type TabStripTab } from "@/components/ui/tab-strip";

/** The four catalog kinds an entry can be installed as (gh-#393), in the shelf's own kind order. */
export type WardrobeTab = "personas" | "themes" | "fonts" | "shows";

interface WardrobeTabsProps {
activeTab: WardrobeTab;
}

const TABS: TabStripTab<WardrobeTab>[] = [
{ id: "personas", label: "Personas", href: "/wardrobe" },
{ id: "themes", label: "Themes", href: "/wardrobe?tab=themes" },
{ id: "fonts", label: "Fonts", href: "/wardrobe?tab=fonts" },
{ id: "shows", label: "Shows", href: "/wardrobe?tab=shows" },
];

/**
* Resolves `?tab=` to a wardrobe tab (gh-#393) β€” mirrors `catalog/page.tsx`'s own `resolveTab`
* posture: anything unrecognised (absent, an array, a stranger) falls back to the first tab rather
* than erroring. Every tab renders even when empty (Dean's ruling on gh-#393: an empty kind shows
* its own empty state, never a hidden tab β€” unlike `settings-tabs.ts`'s derive-from-data omission).
*/
export function resolveWardrobeTab(raw: string | string[] | undefined): WardrobeTab {
return raw === "themes" || raw === "fonts" || raw === "shows" ? raw : "personas";
}

/**
* Personas | Themes | Fonts | Shows tab strip for the Wardrobe (gh-#393, the gh-#372 shelf-tabs
* treatment applied to the installed side) β€” URL-driven via `?tab=`, no client state, the shared
* `TabStrip` markup. One tab per catalog kind, siloing what was becoming a mixed pile as kinds
* accumulated (the same complaint gh-#372 makes about the shelf itself).
*/
export function WardrobeTabs({ activeTab }: WardrobeTabsProps): ReactNode {
return <TabStrip tabs={TABS} activeTab={activeTab} ariaLabel="Wardrobe sections" />;
}
Loading
Loading