feat(housekeeping): finish accessible command deck

This commit is contained in:
Simo committed 2026-08-30 18:49:10 +02:00
1 parent 85ad0452d3
commit a113e48880
55 files changed
+17064 -509

No files matched your search

@@ -14,6 +14,27 @@ import {
WidgetSettings,
} from "../../../foundation/shell/widget-settings";
export interface OperationsPersonalizationLabels {
readonly favorites: {
readonly title: string;
readonly pin: string;
readonly unpin: string;
readonly error: string;
readonly reorderInstructions: string;
};
readonly widgetSettings: {
readonly title: string;
readonly mandatory: string;
readonly error: string;
};
readonly widgets: {
readonly title: string;
readonly partial: string;
readonly error: string;
readonly reorderInstructions: string;
};
}
const persistPreferences: HousekeepingPreferenceSaver = async (preferences) => {
const { saveHousekeepingPreferences } = await import(
"@/actions/housekeeping-preferences"
@@ -27,6 +48,9 @@ export function OperationsPersonalization({
widgets,
widgetResult,
surface,
labels,
routeTitles,
widgetTitles,
savePreferences = persistPreferences,
}: {
readonly preferences: HousekeepingPreferences;
@@ -34,6 +58,9 @@ export function OperationsPersonalization({
readonly widgets: readonly HousekeepingWidgetDefinition[];
readonly widgetResult: HousekeepingWidgetLoadResult;
readonly surface: HousekeepingSurface;
readonly labels: OperationsPersonalizationLabels;
readonly routeTitles: Readonly<Record<string, string>>;
readonly widgetTitles: Readonly<Record<string, string>>;
readonly savePreferences?: HousekeepingPreferenceSaver;
}) {
return (
@@ -44,22 +71,15 @@ export function OperationsPersonalization({
routes={routes}
surface={surface}
savePreferences={savePreferences}
labels={{
title: "Favorites",
pin: "Pin",
unpin: "Unpin",
error: "Preferences could not be saved",
}}
labels={labels.favorites}
titles={routeTitles}
/>
<WidgetSettings
preferences={preferences}
widgets={widgets}
savePreferences={savePreferences}
labels={{
title: "Widget settings",
mandatory: "Mandatory",
error: "Preferences could not be saved",
}}
labels={labels.widgetSettings}
titles={widgetTitles}
/>
</div>
@@ -67,11 +87,8 @@ export function OperationsPersonalization({
preferences={preferences}
result={widgetResult}
savePreferences={savePreferences}
labels={{
title: "Widgets",
partial: "Some widgets are unavailable",
error: "Widget order could not be saved",
}}
labels={labels.widgets}
titles={widgetTitles}
/>
</>
);
@@ -79,6 +79,45 @@ const result: OperationsWorkspaceLoadResult = {
};
const savePreferences = vi.fn(async (value) => ok(value, "saved"));
const labels = {
title: "Operations workspace",
description: "Capability-derived operational home",
noDomainsTitle: "No operational domains are available",
noDomainsDescription: "No authorized domain",
partial: "Some workspace sections are unavailable",
inbox: {
title: "Operational inbox",
domainFilter: "Filter by domain",
stateFilter: "Filter by state",
priorityFilter: "Filter by priority",
all: "All",
loading: "Loading",
empty: "Empty",
partial: "Some sources are unavailable",
open: "Open",
},
recent: { title: "Recent work", empty: "Nothing recent" },
favorites: {
title: "Favorites",
pin: "Pin",
unpin: "Unpin",
error: "Preferences could not be saved",
reorderInstructions: "Press Space, then use arrow keys.",
},
widgetSettings: {
title: "Widget settings",
mandatory: "Mandatory",
error: "Preferences could not be saved",
},
widgets: {
title: "Widgets",
partial: "Some widgets are unavailable",
error: "Widget order could not be saved",
reorderInstructions: "Press Space, then use arrow keys.",
},
} as const;
const routeTitles = { Users: "Users" };
const widgetTitles = { "people.queue": "People queue" };
describe("OperationsWorkspacePage", () => {
it("renders capability-filtered sections, partial failures, and preview links", () => {
@@ -88,6 +127,9 @@ describe("OperationsWorkspacePage", () => {
routes={routes}
widgetDefinitions={widgets}
surface="preview"
labels={labels}
routeTitles={routeTitles}
widgetTitles={widgetTitles}
savePreferences={savePreferences}
/>,
);
@@ -97,8 +139,8 @@ describe("OperationsWorkspacePage", () => {
expect(html).toContain("Favorites");
expect(html).toContain("Widget settings");
expect(html).toContain("Some workspace sections are unavailable");
expect(html).toContain("system.alerts");
expect(html).toContain("system.health");
expect(html).not.toContain("system.alerts");
expect(html).not.toContain("system.health");
expect(html).toContain('href="/ase-next/people/users"');
});
@@ -109,6 +151,9 @@ describe("OperationsWorkspacePage", () => {
routes={[]}
widgetDefinitions={[]}
surface="preview"
labels={labels}
routeTitles={{}}
widgetTitles={{}}
savePreferences={savePreferences}
/>,
);
@@ -8,34 +8,66 @@ import { OperationalInbox } from "../../../foundation/shell/operational-inbox";
import { RecentWork } from "../../../foundation/shell/recent-work";
import type { HousekeepingPreferenceSaver } from "../../../foundation/shell/widget-settings";
import type { OperationsWorkspaceLoadResult } from "../queries";
import { OperationsPersonalization } from "./personalization";
import {
OperationsPersonalization,
type OperationsPersonalizationLabels,
} from "./personalization";
export interface OperationsWorkspaceLabels
extends OperationsPersonalizationLabels {
readonly title: string;
readonly description: string;
readonly noDomainsTitle: string;
readonly noDomainsDescription: string;
readonly partial: string;
readonly inbox: {
readonly title: string;
readonly domainFilter: string;
readonly stateFilter: string;
readonly priorityFilter: string;
readonly all: string;
readonly loading: string;
readonly empty: string;
readonly partial: string;
readonly open: string;
};
readonly recent: {
readonly title: string;
readonly empty: string;
};
}
export function OperationsWorkspacePage({
result,
routes,
widgetDefinitions,
surface,
labels,
routeTitles,
widgetTitles,
inboxTitles = {},
savePreferences,
}: {
readonly result: OperationsWorkspaceLoadResult;
readonly routes: readonly HousekeepingRouteDefinition[];
readonly widgetDefinitions: readonly HousekeepingWidgetDefinition[];
readonly surface: HousekeepingSurface;
readonly labels: OperationsWorkspaceLabels;
readonly routeTitles: Readonly<Record<string, string>>;
readonly widgetTitles: Readonly<Record<string, string>>;
readonly inboxTitles?: Readonly<Record<string, string>>;
readonly savePreferences?: HousekeepingPreferenceSaver;
}) {
if (routes.length === 0) {
return (
<HousekeepingPageShell
title="Operations workspace"
description="Your capability-derived operational home."
title={labels.title}
description={labels.description}
>
<div className="rounded-md border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4">
<h2 className="font-semibold">
No operational domains are available
</h2>
<h2 className="font-semibold">{labels.noDomainsTitle}</h2>
<p className="mt-1 text-sm text-[var(--admin-text-muted)]">
Your account can open Housekeeping, but no operational domain is
currently authorized.
{labels.noDomainsDescription}
</p>
</div>
</HousekeepingPageShell>
@@ -44,25 +76,17 @@ export function OperationsWorkspacePage({
return (
<HousekeepingPageShell
title="Operations workspace"
description="Inbox, recent work, favorites, and widgets for the domains you can operate."
title={labels.title}
description={labels.description}
>
<div className="space-y-6">
{result.errors.length > 0 ? (
<div
role="status"
role="alert"
aria-live="assertive"
className="rounded-md border border-[var(--admin-warning-border)] bg-[var(--admin-warning-subtle)] p-3"
>
<p className="text-[var(--admin-warning)]">
Some workspace sections are unavailable
</p>
<ul className="mt-2 list-disc pl-5 text-sm">
{result.errors.map((error) => (
<li key={error.section}>
{error.section}: {error.code}
</li>
))}
</ul>
<p className="text-[var(--admin-warning)]">{labels.partial}</p>
</div>
) : null}
@@ -70,25 +94,14 @@ export function OperationsWorkspacePage({
<OperationalInbox
response={result.model.inbox}
surface={surface}
labels={{
title: "Operational inbox",
domainFilter: "Filter by domain",
stateFilter: "Filter by state",
priorityFilter: "Filter by priority",
all: "All",
loading: "Loading operational inbox",
empty: "No operational work is waiting",
partial: "Some inbox sources are unavailable",
open: "Open",
}}
labels={labels.inbox}
titles={inboxTitles}
/>
<RecentWork
items={result.model.recent}
surface={surface}
labels={{
title: "Recent work",
empty: "No recent Housekeeping work",
}}
labels={labels.recent}
titles={routeTitles}
/>
</div>
@@ -98,6 +111,9 @@ export function OperationsWorkspacePage({
widgets={widgetDefinitions}
widgetResult={result.model.widgets}
surface={surface}
labels={labels}
routeTitles={routeTitles}
widgetTitles={widgetTitles}
{...(savePreferences ? { savePreferences } : {})}
/>
</div>
@@ -3,12 +3,20 @@ import { satisfiesCapability } from "../../foundation/capability-context";
import { createHousekeepingRegistry } from "../../foundation/registry";
import { HOUSEKEEPING_MANIFESTS } from "../../manifests";
import type { HousekeepingRouteHandler } from "../../route-handlers";
import { OperationsWorkspacePage } from "./pages/workspace";
import {
type OperationsWorkspaceLabels,
OperationsWorkspacePage,
} from "./pages/workspace";
import { operationsWorkspaceQuery } from "./queries";
export async function renderOperationsWorkspacePage(
input: Parameters<HousekeepingRouteHandler["render"]>[0],
) {
const translate = input.translate ?? ((key: string) => key);
const message = (key: string, fallback: string) => {
const value = translate(key);
return value === key ? fallback : value;
};
const registry = createHousekeepingRegistry(HOUSEKEEPING_MANIFESTS);
const accessibleDomains = registry.domains.filter(
(domain) =>
@@ -27,11 +35,115 @@ export async function renderOperationsWorkspacePage(
satisfiesCapability(input.context, widget.capability),
),
);
const labels: OperationsWorkspaceLabels = {
title: message("workspace.title", "Operations workspace"),
description: message(
"workspace.description",
"Inbox, recent work, favorites, and widgets for the domains you can operate.",
),
noDomainsTitle: message(
"workspace.noDomainsTitle",
"No operational domains are available",
),
noDomainsDescription: message(
"workspace.noDomainsDescription",
"No operational domain is authorized for your account.",
),
partial: message(
"workspace.partial",
"Some workspace sections are unavailable",
),
inbox: {
title: message("workspace.inbox.title", "Operational inbox"),
domainFilter: message("workspace.inbox.domainFilter", "Filter by domain"),
stateFilter: message("workspace.inbox.stateFilter", "Filter by state"),
priorityFilter: message(
"workspace.inbox.priorityFilter",
"Filter by priority",
),
all: message("workspace.inbox.all", "All"),
loading: message("workspace.inbox.loading", "Loading operational inbox"),
empty: message("workspace.inbox.empty", "No operational work is waiting"),
partial: message(
"workspace.inbox.partial",
"Some inbox sources are unavailable",
),
open: message("workspace.inbox.open", "Open"),
},
recent: {
title: message("workspace.recent.title", "Recent work"),
empty: message("workspace.recent.empty", "No recent Housekeeping work"),
},
favorites: {
title: message("workspace.favorites.title", "Favorites"),
pin: message("workspace.favorites.pin", "Pin"),
unpin: message("workspace.favorites.unpin", "Unpin"),
error: message(
"workspace.favorites.error",
"Preferences could not be saved",
),
reorderInstructions: message(
"workspace.favorites.reorderInstructions",
"Press Space, then use the arrow keys to reorder.",
),
},
widgetSettings: {
title: message("workspace.widgetSettings.title", "Widget settings"),
mandatory: message("workspace.widgetSettings.mandatory", "Mandatory"),
error: message(
"workspace.widgetSettings.error",
"Preferences could not be saved",
),
},
widgets: {
title: message("workspace.widgets.title", "Widgets"),
partial: message(
"workspace.widgets.partial",
"Some widgets are unavailable",
),
error: message(
"workspace.widgets.error",
"Widget order could not be saved",
),
reorderInstructions: message(
"workspace.widgets.reorderInstructions",
"Press Space, then use the arrow keys to reorder.",
),
},
};
const routeTitles = Object.fromEntries(
routes.map((route) => [
route.labelKey,
message(route.labelKey.slice("pages.housekeeping.".length), route.id),
]),
);
const widgetTitles = Object.fromEntries(
widgetDefinitions.map((widget) => [
widget.id,
message(`widgets.${widget.id}`, widget.id),
]),
);
const workspaceResult = await operationsWorkspaceQuery(input.context);
const inboxTitles = Object.fromEntries(
workspaceResult.model.inbox.items.map((item) => [
item.titleKey,
item.titleKey.startsWith("pages.housekeeping.")
? message(
item.titleKey.slice("pages.housekeeping.".length),
item.domain,
)
: item.titleKey,
]),
);
return createElement(OperationsWorkspacePage, {
result: await operationsWorkspaceQuery(input.context),
result: workspaceResult,
routes,
widgetDefinitions,
surface: input.surface ?? "preview",
labels,
routeTitles,
widgetTitles,
inboxTitles,
...(input.savePreferences
? { savePreferences: input.savePreferences }
: {}),
@@ -0,0 +1,210 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
import { anyCapability, ok } from "./contracts";
import { HousekeepingPageShell } from "./page/housekeeping-page-shell";
import { HousekeepingPageState } from "./page/housekeeping-page-state";
import type { HousekeepingPreferences } from "./preferences/schema";
import { Favorites } from "./shell/favorites";
import { HousekeepingShell } from "./shell/housekeeping-shell";
import { OperationalInbox } from "./shell/operational-inbox";
import { WidgetGrid } from "./shell/widget-grid";
vi.mock("@/actions/housekeeping-search", () => ({
executeHousekeepingSearch: vi.fn(),
}));
const capability = anyCapability("admin.users.view");
const preferences: HousekeepingPreferences = {
schemaVersion: 1,
pinnedRouteIds: ["people.users.list"],
pinnedCommandIds: [],
shortcutOrder: ["people.users.list"],
enabledOptionalWidgetIds: [],
widgetOrder: ["people.queue"],
};
const savePreferences = vi.fn(async (value: HousekeepingPreferences) =>
ok(value, "saved"),
);
const shellLabels = {
skipToContent: "Skip to content",
primaryNavigation: "Primary navigation",
contextualNavigation: "Context navigation",
command: "Search",
preview: "Preview",
backToSite: "Back to site",
operatorRank: "Rank 6",
commandDeck: {
placeholder: "Search",
navigation: "Navigation",
commands: "Commands",
entities: "Entities",
loading: "Loading",
empty: "Empty",
partial: "Partial",
close: "Close",
},
};
describe("housekeeping accessibility contract", () => {
it("renders the shell and page with labelled landmarks and a coherent heading hierarchy", () => {
const html = renderToStaticMarkup(
<HousekeepingShell
actor={{ id: 7, username: "Nora", rank: 6 }}
activeDomainId="operations"
domains={[
{
id: "operations",
href: "/ase/operations",
iconId: "inbox",
label: "Operations",
description: "Daily operations",
items: [],
},
]}
labels={shellLabels}
surface="canonical"
>
<HousekeepingPageShell title="Operations" description="Daily work">
<h2>Inbox</h2>
</HousekeepingPageShell>
</HousekeepingShell>,
);
expect(html).toContain('data-housekeeping-root="true"');
expect(html).toContain("<header");
expect(html).toContain('<nav aria-label="Primary navigation"');
expect(html).toContain('<nav aria-label="Context navigation"');
expect(html).toContain('<main id="housekeeping-content"');
expect(html).toContain('aria-labelledby="housekeeping-page-title"');
expect(html).toContain('<h1 id="housekeeping-page-title"');
expect(html.match(/aria-current="page"/g)).toHaveLength(1);
expect(html).toContain(">Rank 6</span>");
expect(html).toContain("focus-visible:not-sr-only");
});
it.each([
["loading", "status", "polite"],
["empty", "status", "polite"],
["partial", "status", "polite"],
["error", "alert", "assertive"],
["forbidden", "alert", "assertive"],
["ready", "status", "polite"],
] as const)(
"gives the %s page state explicit semantic announcements",
(state, role, live) => {
const pageState =
state === "partial" ? (
<HousekeepingPageState
state="partial"
partialLabel="Partial"
title={`${state} title`}
description={`${state} description`}
/>
) : (
<HousekeepingPageState
state={state}
title={`${state} title`}
description={`${state} description`}
/>
);
const html = renderToStaticMarkup(pageState);
expect(html).toContain(`data-page-state="${state}"`);
expect(html).toContain(`role="${role}"`);
expect(html).toContain(`aria-live="${live}"`);
},
);
it("provides keyboard reorder instructions for favorites and widgets", () => {
const favorites = renderToStaticMarkup(
<Favorites
preferences={preferences}
routes={[
{
id: "people.users.list",
labelKey: "pages.housekeeping.routes.people.users.list",
href: "/ase/people/users",
capability,
},
]}
surface="canonical"
savePreferences={savePreferences}
titles={{
"pages.housekeeping.routes.people.users.list": "Users",
}}
labels={{
title: "Favorites",
pin: "Pin",
unpin: "Unpin",
error: "Could not save",
reorderInstructions:
"Press Space, then use the arrow keys to reorder.",
}}
/>,
);
const widgets = renderToStaticMarkup(
<WidgetGrid
preferences={preferences}
result={{
widgets: [{ id: "people.queue", data: { count: 1 } }],
errors: [],
}}
savePreferences={savePreferences}
titles={{ "people.queue": "People queue" }}
labels={{
title: "Widgets",
partial: "Partial widgets",
error: "Could not save",
reorderInstructions:
"Press Space, then use the arrow keys to reorder.",
}}
/>,
);
expect(favorites).toContain(
'id="housekeeping-favorites-reorder-instructions"',
);
expect(favorites).toContain(
'aria-describedby="housekeeping-favorites-reorder-instructions"',
);
expect(favorites).toContain(">Users<");
expect(favorites).not.toContain(
">pages.housekeeping.routes.people.users.list<",
);
expect(widgets).toContain('id="housekeeping-widgets-reorder-instructions"');
expect(widgets).toContain(
'aria-describedby="housekeeping-widgets-reorder-instructions"',
);
expect(widgets).toContain(">People queue<");
});
it("announces partial provider failures without exposing internal identifiers", () => {
const html = renderToStaticMarkup(
<OperationalInbox
response={{
items: [],
errors: [{ sourceId: "internal.provider", code: "TIMEOUT" }],
correlationId: "private-correlation",
}}
labels={{
title: "Inbox",
domainFilter: "Domain",
stateFilter: "State",
priorityFilter: "Priority",
all: "All",
loading: "Loading",
empty: "Empty",
partial: "Some data is unavailable",
open: "Open",
}}
surface="canonical"
/>,
);
expect(html).toContain('role="alert"');
expect(html).toContain('aria-live="assertive"');
expect(html).not.toContain("internal.provider");
expect(html).not.toContain("TIMEOUT");
expect(html).not.toContain("private-correlation");
});
});
@@ -1,207 +1,256 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
import ar from "@/messages/ar.json";
import bg from "@/messages/bg.json";
import cs from "@/messages/cs.json";
import da from "@/messages/da.json";
import de from "@/messages/de.json";
import el from "@/messages/el.json";
import en from "@/messages/en.json";
import es from "@/messages/es.json";
import fi from "@/messages/fi.json";
import fr from "@/messages/fr.json";
import hr from "@/messages/hr.json";
import hu from "@/messages/hu.json";
import itMessages from "@/messages/it.json";
import ja from "@/messages/ja.json";
import nl from "@/messages/nl.json";
import no from "@/messages/no.json";
import pl from "@/messages/pl.json";
import pt from "@/messages/pt.json";
import ro from "@/messages/ro.json";
import ru from "@/messages/ru.json";
import sk from "@/messages/sk.json";
import sr from "@/messages/sr.json";
import sv from "@/messages/sv.json";
import tr from "@/messages/tr.json";
import uk from "@/messages/uk.json";
import { HOUSEKEEPING_MANIFESTS } from "../manifests";
const locales = {
ar,
bg,
cs,
da,
de,
el,
en,
es,
fi,
fr,
hr,
hu,
it: itMessages,
ja,
nl,
no,
pl,
pt,
ro,
ru,
sk,
sr,
sv,
tr,
uk,
} as const;
const commandFiles = [
"domains/content/commands/content-commands.ts",
"domains/economy/commands/economy-commands.ts",
"domains/hotel/commands/hotel-commands.ts",
"domains/hotel/commands/studio-commands.ts",
"domains/people/commands/user-commands.ts",
"domains/people/commands/community-commands.ts",
"domains/people/commands/support-commands.ts",
"domains/people/commands/moderation-commands.ts",
"domains/system/commands/system-commands.ts",
] as const;
const commandIds = [
...new Set(
commandFiles.flatMap((path) => {
const source = readFileSync(
resolve(process.cwd(), "src/features/housekeeping", path),
"utf8",
);
return [
...source.matchAll(
/"((?:content|economy|hotel|people|system)(?:\.[a-z0-9-]+)+)"/gu,
),
]
.map((match) => match[1])
.filter((id): id is string => id !== undefined);
}),
),
].sort();
const staticKeys = [
"preview.badge",
"preview.commandDisabled",
"preview.backToSite",
"navigation.skipToContent",
"navigation.primary",
"navigation.contextual",
"navigation.operatorRank",
"commandDeck.placeholder",
"commandDeck.navigation",
"commandDeck.commands",
"commandDeck.entities",
"commandDeck.loading",
"commandDeck.empty",
"commandDeck.partial",
"commandDeck.close",
"workspace.title",
"workspace.description",
"workspace.noDomainsTitle",
"workspace.noDomainsDescription",
"workspace.partial",
"workspace.inbox.title",
"workspace.inbox.domainFilter",
"workspace.inbox.stateFilter",
"workspace.inbox.priorityFilter",
"workspace.inbox.all",
"workspace.inbox.loading",
"workspace.inbox.empty",
"workspace.inbox.partial",
"workspace.inbox.open",
"workspace.recent.title",
"workspace.recent.empty",
"workspace.favorites.title",
"workspace.favorites.pin",
"workspace.favorites.unpin",
"workspace.favorites.error",
"workspace.favorites.reorderInstructions",
"workspace.widgetSettings.title",
"workspace.widgetSettings.mandatory",
"workspace.widgetSettings.error",
"workspace.widgets.title",
"workspace.widgets.partial",
"workspace.widgets.error",
"workspace.widgets.reorderInstructions",
] as const;
const stateKeys = [
"loading",
"empty",
"partial",
"error",
"forbidden",
"ready",
].flatMap((state) => [`states.${state}.title`, `states.${state}.description`]);
const manifestKeys = HOUSEKEEPING_MANIFESTS.flatMap((manifest) => [
manifest.labelKey.slice("pages.housekeeping.".length),
manifest.descriptionKey.slice("pages.housekeeping.".length),
...manifest.routes.map((route) =>
route.labelKey.slice("pages.housekeeping.".length),
),
...manifest.widgets.map((widget) => `widgets.${widget.id}`),
]);
const requiredKeys = [
"pages.housekeeping.preview.badge",
"pages.housekeeping.preview.commandDisabled",
"pages.housekeeping.preview.backToSite",
"pages.housekeeping.navigation.skipToContent",
"pages.housekeeping.navigation.primary",
"pages.housekeeping.navigation.contextual",
"pages.housekeeping.commandDeck.placeholder",
"pages.housekeeping.commandDeck.navigation",
"pages.housekeeping.commandDeck.commands",
"pages.housekeeping.commandDeck.entities",
"pages.housekeeping.commandDeck.loading",
"pages.housekeeping.commandDeck.empty",
"pages.housekeeping.commandDeck.partial",
"pages.housekeeping.commandDeck.close",
"pages.housekeeping.states.loading.title",
"pages.housekeeping.states.loading.description",
"pages.housekeeping.states.empty.title",
"pages.housekeeping.states.empty.description",
"pages.housekeeping.states.partial.label",
"pages.housekeeping.states.partial.title",
"pages.housekeeping.states.partial.description",
"pages.housekeeping.states.error.title",
"pages.housekeeping.states.error.description",
...staticKeys,
...stateKeys,
"states.partial.label",
...manifestKeys,
...commandIds.map((id) => `commands.${id}`),
];
const systemRouteMessageKeys = [
"pages.housekeeping.routes.system.access.permissions",
"pages.housekeeping.routes.system.access.permission-detail",
"pages.housekeeping.routes.system.configuration.settings",
"pages.housekeeping.routes.system.configuration.emulator",
"pages.housekeeping.routes.system.observability.analytics",
"pages.housekeeping.routes.system.observability.analytics-activity",
"pages.housekeeping.routes.system.observability.analytics-economy",
"pages.housekeeping.routes.system.observability.devops",
"pages.housekeeping.routes.system.observability.devops-errors",
"pages.housekeeping.routes.system.observability.logs-staff",
"pages.housekeeping.routes.system.observability.logs-audit",
"pages.housekeeping.routes.system.observability.logs-chat",
"pages.housekeeping.routes.system.observability.logs-commands",
"pages.housekeeping.routes.system.observability.logs-trades",
"pages.housekeeping.routes.system.operations.alerts",
"pages.housekeeping.routes.system.operations.command-center",
"pages.housekeeping.routes.system.operations.maintenance",
] as const;
const economyRouteMessageKeys = [
"pages.housekeeping.routes.economy.catalog.overview",
"pages.housekeeping.routes.economy.catalog.detail",
"pages.housekeeping.routes.economy.catalog.builder-club-detail",
"pages.housekeeping.routes.economy.catalog.maintenance",
"pages.housekeeping.routes.economy.items.overview",
"pages.housekeeping.routes.economy.items.detail",
"pages.housekeeping.routes.economy.commerce.shop",
"pages.housekeeping.routes.economy.commerce.shop-create",
"pages.housekeeping.routes.economy.commerce.shop-detail",
"pages.housekeeping.routes.economy.commerce.marketplace",
"pages.housekeeping.routes.economy.commerce.vouchers",
"pages.housekeeping.routes.economy.commerce.subscriptions",
"pages.housekeeping.routes.economy.history.transactions",
"pages.housekeeping.routes.economy.value.rare-values",
"pages.housekeeping.routes.economy.rewards.badges",
"pages.housekeeping.routes.economy.rewards.achievements",
"pages.housekeeping.routes.economy.rewards.sounds",
"pages.housekeeping.routes.economy.rewards.calendar",
"pages.housekeeping.routes.economy.rewards.calendar-detail",
] as const;
const hotelRouteMessageKeys = [
"pages.housekeeping.routes.hotel.rooms",
"pages.housekeeping.routes.hotel.room-detail",
"pages.housekeeping.routes.hotel.room-furni",
"pages.housekeeping.routes.hotel.navigation",
"pages.housekeeping.routes.hotel.radio.overview",
"pages.housekeeping.routes.hotel.radio.settings",
"pages.housekeeping.routes.hotel.radio.monitoring",
"pages.housekeeping.routes.hotel.radio.moderation",
"pages.housekeeping.routes.hotel.radio.autodj",
"pages.housekeeping.routes.hotel.radio.history",
"pages.housekeeping.routes.hotel.radio.points",
"pages.housekeeping.routes.hotel.radio.ranks",
"pages.housekeeping.routes.hotel.radio.api-keys",
"pages.housekeeping.routes.hotel.radio.banners",
"pages.housekeeping.routes.hotel.radio.embed",
] as const;
const expectedDomainMessages = [
{
id: "operations",
labelKey: "pages.housekeeping.domains.operations.title",
descriptionKey: "pages.housekeeping.domains.operations.description",
},
{
id: "people",
labelKey: "pages.housekeeping.domains.people.title",
descriptionKey: "pages.housekeeping.domains.people.description",
},
{
id: "content",
labelKey: "pages.housekeeping.domains.content.title",
descriptionKey: "pages.housekeeping.domains.content.description",
},
{
id: "economy",
labelKey: "pages.housekeeping.domains.economy.title",
descriptionKey: "pages.housekeeping.domains.economy.description",
},
{
id: "hotel",
labelKey: "pages.housekeeping.domains.hotel.title",
descriptionKey: "pages.housekeeping.domains.hotel.description",
},
{
id: "system",
labelKey: "pages.housekeeping.domains.system.title",
descriptionKey: "pages.housekeeping.domains.system.description",
},
] as const;
function resolveMessage(messages: unknown, key: string): unknown {
return key.split(".").reduce<unknown>((value, segment) => {
if (value === null || typeof value !== "object") return undefined;
return (value as Record<string, unknown>)[segment];
}, messages);
}
describe("housekeeping localization contract", () => {
it.each([
["English", en],
["Italian", itMessages],
])("provides the planned housekeeping subtree in %s", (_locale, messages) => {
const housekeeping = resolveMessage(messages, "pages.housekeeping");
expect(housekeeping).toEqual(expect.any(Object));
expect(Object.keys(housekeeping as object)).toEqual([
"preview",
"navigation",
"commandDeck",
"domains",
"routes",
"states",
it("covers exactly the 25 supported locale files", () => {
expect(Object.keys(locales)).toEqual([
"ar",
"bg",
"cs",
"da",
"de",
"el",
"en",
"es",
"fi",
"fr",
"hr",
"hu",
"it",
"ja",
"nl",
"no",
"pl",
"pt",
"ro",
"ru",
"sk",
"sr",
"sv",
"tr",
"uk",
]);
expect(commandIds.length).toBeGreaterThan(100);
expect(commandIds.every((id) => !id.endsWith("."))).toBe(true);
});
for (const key of requiredKeys) {
expect(resolveMessage(messages, key), key).toEqual(expect.any(String));
}
it.each(Object.entries(locales))(
"provides every manifest, route, command, widget, and state key in %s",
(locale, messages) => {
for (const key of requiredKeys) {
const fullKey = `pages.housekeeping.${key}`;
const value = resolveMessage(messages, fullKey);
expect(value, `${locale}: ${fullKey}`).toEqual(expect.any(String));
expect((value as string).trim(), `${locale}: ${fullKey}`).not.toBe("");
expect(value, `${locale}: ${fullKey}`).not.toBe(fullKey);
}
},
);
for (const key of systemRouteMessageKeys) {
const message = resolveMessage(messages, key);
expect(message, key).toEqual(expect.any(String));
expect((message as string).trim(), key).not.toBe("");
}
for (const key of economyRouteMessageKeys) {
const message = resolveMessage(messages, key);
expect(message, key).toEqual(expect.any(String));
expect((message as string).trim(), key).not.toBe("");
}
for (const key of hotelRouteMessageKeys) {
const message = resolveMessage(messages, key);
expect(message, key).toEqual(expect.any(String));
expect((message as string).trim(), key).not.toBe("");
}
for (const [index, expected] of expectedDomainMessages.entries()) {
const manifest = HOUSEKEEPING_MANIFESTS[index];
expect(manifest).toMatchObject(expected);
expect(
resolveMessage(messages, expected.labelKey),
expected.labelKey,
).toEqual(expect.any(String));
expect(
resolveMessage(messages, expected.descriptionKey),
expected.descriptionKey,
).toEqual(expect.any(String));
it("does not use English shell copy as the fallback for another locale", () => {
const operatorKeys = [
"preview.backToSite",
"commandDeck.navigation",
"commandDeck.commands",
"commandDeck.entities",
"commandDeck.close",
"workspace.favorites.pin",
"workspace.favorites.unpin",
"workspace.widgetSettings.mandatory",
] as const;
for (const [locale, messages] of Object.entries(locales)) {
if (locale === "en") continue;
expect(messages.pages.housekeeping.preview.badge).not.toBe(
en.pages.housekeeping.preview.badge,
);
expect(messages.pages.housekeeping.states.loading.description).not.toBe(
en.pages.housekeeping.states.loading.description,
);
for (const key of operatorKeys) {
expect(
resolveMessage(messages.pages.housekeeping, key),
`${locale}: pages.housekeeping.${key}`,
).not.toBe(resolveMessage(en.pages.housekeeping, key));
}
}
});
it("uses idiomatic Italian copy for the housekeeping shell", () => {
expect(resolveMessage(itMessages, "pages.housekeeping.preview.badge")).toBe(
it("keeps idiomatic Italian shell copy", () => {
expect(itMessages.pages.housekeeping.preview.badge).toBe(
"Anteprima della struttura",
);
expect(
resolveMessage(itMessages, "pages.housekeeping.navigation.primary"),
).toBe("Aree housekeeping");
expect(
resolveMessage(itMessages, "pages.housekeeping.commandDeck.placeholder"),
).toBe("Cerca navigazione, comandi ed entità");
expect(
resolveMessage(itMessages, "pages.housekeeping.states.loading.title"),
).toBe("Caricamento in corso");
expect(
resolveMessage(
itMessages,
"pages.housekeeping.states.loading.description",
),
).toBe("Stiamo preparando gli strumenti di housekeeping disponibili.");
expect(itMessages.pages.housekeeping.navigation.primary).toBe(
"Aree housekeeping",
);
expect(itMessages.pages.housekeeping.commandDeck.placeholder).toBe(
"Cerca navigazione, comandi ed entità",
);
expect(itMessages.pages.housekeeping.states.loading.description).toBe(
"Stiamo preparando gli strumenti di housekeeping disponibili.",
);
});
});
@@ -16,10 +16,16 @@ export function HousekeepingPageShell({
children,
}: HousekeepingPageShellProps) {
return (
<section className="space-y-6">
<section
aria-labelledby="housekeeping-page-title"
className="min-w-0 max-w-full space-y-6"
>
<header className="flex flex-wrap items-start justify-between gap-4 border-b border-[var(--admin-border)] pb-4">
<div className="min-w-0 space-y-1">
<h1 className="text-xl font-semibold text-[var(--admin-text)]">
<h1
id="housekeeping-page-title"
className="text-xl font-semibold text-[var(--admin-text)]"
>
{title}
</h1>
<p className="text-sm text-[var(--admin-text-muted)]">
@@ -29,7 +35,7 @@ export function HousekeepingPageShell({
</div>
{primaryAction ? <div>{primaryAction}</div> : null}
</header>
<div>{children}</div>
<div className="min-w-0 max-w-full">{children}</div>
</section>
);
}
@@ -11,6 +11,8 @@ describe("HousekeepingPageState", () => {
["empty", "status"],
["partial", "status"],
["error", "alert"],
["forbidden", "alert"],
["ready", "status"],
] as const)("renders %s with the %s role", (state, role) => {
const pageState =
state === "partial" ? (
@@ -34,6 +36,35 @@ describe("HousekeepingPageState", () => {
expect(html).toContain(`>${state} description<`);
});
it.each([
["loading", "polite"],
["empty", "polite"],
["partial", "polite"],
["error", "assertive"],
["forbidden", "assertive"],
["ready", "polite"],
] as const)("announces %s changes with %s priority", (state, live) => {
const pageState =
state === "partial" ? (
<HousekeepingPageState
state="partial"
partialLabel="Partial"
title={`${state} title`}
description={`${state} description`}
/>
) : (
<HousekeepingPageState
state={state}
title={`${state} title`}
description={`${state} description`}
/>
);
const html = renderToStaticMarkup(pageState);
expect(html).toContain(`data-page-state="${state}"`);
expect(html).toContain(`aria-live="${live}"`);
});
it("announces loading politely", () => {
const loading = renderToStaticMarkup(
<HousekeepingPageState
@@ -6,10 +6,21 @@ interface HousekeepingPageStateBaseProps {
retryAction?: ReactNode;
}
export type HousekeepingPageState =
| "loading"
| "empty"
| "partial"
| "error"
| "forbidden"
| "ready";
type HousekeepingPageStateProps = HousekeepingPageStateBaseProps &
(
| { state: "partial"; partialLabel: string }
| { state: "loading" | "empty" | "error"; partialLabel?: never }
| {
state: Exclude<HousekeepingPageState, "partial">;
partialLabel?: never;
}
);
export function HousekeepingPageState({
@@ -19,13 +30,14 @@ export function HousekeepingPageState({
description,
retryAction,
}: HousekeepingPageStateProps) {
const isError = state === "error";
const isError = state === "error" || state === "forbidden";
const isPartial = state === "partial";
return (
<section
role={isError ? "alert" : "status"}
aria-live={state === "loading" ? "polite" : undefined}
aria-live={isError ? "assertive" : "polite"}
data-page-state={state}
className={`rounded-lg border p-4 ${
isError
? "border-[var(--admin-error)] bg-[var(--admin-surface)]"
@@ -15,6 +15,7 @@ const routeMocks = vi.hoisted(() => {
"navigation.skipToContent": "HK::skip-to-content",
"navigation.primary": "HK::primary-navigation",
"navigation.contextual": "HK::contextual-navigation",
"navigation.operatorRank": "HK::operator-rank",
"commandDeck.placeholder": "HK::command-placeholder",
"commandDeck.navigation": "HK::command-navigation",
"commandDeck.commands": "HK::command-commands",
@@ -802,6 +803,7 @@ describe("/ase-next/[domain]/[[...segments]] page", () => {
expect(routeMocks.renderHousekeepingRoute).toHaveBeenCalledWith({
context,
surface: "preview",
translate: expect.any(Function),
match: {
routeId: "people.users",
domain: "people",
@@ -809,7 +811,7 @@ describe("/ase-next/[domain]/[[...segments]] page", () => {
canonicalHref: "/ase/people/users",
},
});
expect(routeMocks.getTranslations).not.toHaveBeenCalled();
expect(routeMocks.getTranslations).toHaveBeenCalledOnce();
});
it("maps nested preview segments to canonical dynamic parameters", async () => {
@@ -828,6 +830,7 @@ describe("/ase-next/[domain]/[[...segments]] page", () => {
expect(routeMocks.renderHousekeepingRoute).toHaveBeenCalledWith({
context,
surface: "preview",
translate: expect.any(Function),
match: {
routeId: "people.user-detail",
domain: "people",
@@ -853,6 +856,7 @@ describe("/ase-next/[domain]/[[...segments]] page", () => {
expect(routeMocks.renderHousekeepingRoute).toHaveBeenCalledWith({
context,
surface: "preview",
translate: expect.any(Function),
match: {
routeId: "people.literal-tool",
domain: "people",
@@ -0,0 +1,97 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
import { CommandDeck } from "./shell/command-deck";
import { HousekeepingShell } from "./shell/housekeeping-shell";
vi.mock("@/actions/housekeeping-search", () => ({
executeHousekeepingSearch: vi.fn(),
}));
const labels = {
skipToContent: "Skip",
primaryNavigation: "Primary",
contextualNavigation: "Context",
command: "Search",
preview: "Preview",
backToSite: "Back",
operatorRank: "Rank 6",
commandDeck: {
placeholder: "Search",
navigation: "Navigation",
commands: "Commands",
entities: "Entities",
loading: "Loading",
empty: "Empty",
partial: "Partial",
close: "Close",
},
};
describe("housekeeping responsive contract", () => {
it("stacks both navigation levels below lg without removing capabilities", () => {
const html = renderToStaticMarkup(
<HousekeepingShell
actor={{ id: 7, username: "Nora", rank: 6 }}
activeDomainId="operations"
domains={[
{
id: "operations",
href: "/ase/operations",
iconId: "inbox",
label: "Operations",
description: "Operations",
items: [{ id: "workspace", href: "/ase", label: "Workspace" }],
},
]}
labels={labels}
surface="canonical"
>
<div>Body</div>
</HousekeepingShell>,
);
expect(html).toContain('data-responsive-mode="stacked-below-lg"');
expect(html).toContain('data-navigation-collapse="domain"');
expect(html).toContain('data-navigation-collapse="context"');
expect(html).toContain('href="/ase/operations"');
expect(html).toContain('href="/ase"');
expect(html).toContain("overflow-x-auto");
expect(html).toContain("min-w-0");
});
it("keeps the command deck bounded at a 320px viewport", () => {
const source = readFileSync(
resolve(
process.cwd(),
"src/features/housekeeping/foundation/shell/command-deck.tsx",
),
"utf8",
);
const trigger = renderToStaticMarkup(
<CommandDeck
labels={{ ...labels.commandDeck, trigger: "Search" }}
surface="canonical"
/>,
);
expect(trigger).toContain("min-w-0");
expect(source).toContain("max-h-[calc(100dvh-2rem)]");
expect(source).toContain("overflow-hidden");
expect(source).toContain("p-2 sm:p-4");
});
it("provides non-overflow and reduced-motion safeguards for housekeeping content", () => {
const css = readFileSync(
resolve(process.cwd(), "src/app/globals.css"),
"utf8",
);
expect(css).toContain("[data-housekeeping-root]");
expect(css).toContain("#housekeeping-content table");
expect(css).toContain("overflow-x: auto");
expect(css).toContain("@media (prefers-reduced-motion: reduce)");
expect(css).toContain("transition-duration: 0.01ms");
});
});
@@ -58,7 +58,13 @@ function matches(tokens: readonly string[], ...values: readonly string[]) {
return tokens.every((token) => haystack.includes(token));
}
function commandTitle(commandId: string): string {
function commandTitle(
commandId: string,
translate: (key: string) => string,
): string {
const key = `pages.housekeeping.commands.${commandId}`;
const translated = translate(key);
if (translated !== key) return translated;
return commandId
.split(".")
.slice(1)
@@ -120,7 +126,7 @@ export function searchNavigationAndCommands({
(candidate) => candidate.id === command.owner,
);
if (!domain || !satisfiesCapability(context, domain.capability)) continue;
const title = commandTitle(command.id);
const title = commandTitle(command.id, translate);
if (!matches(tokens, command.id, command.owner, title)) continue;
commandsFound.push({
id: `command:${command.id}`,
@@ -276,7 +276,7 @@ export function CommandDeck({
aria-haspopup="dialog"
aria-expanded={open}
onClick={() => setOpen(true)}
className="flex items-center gap-2 rounded-md border border-[var(--admin-border)] bg-[var(--admin-surface)] px-3 py-2 text-sm text-[var(--admin-text)]"
className="flex min-w-0 items-center gap-2 rounded-md border border-[var(--admin-border)] bg-[var(--admin-surface)] px-3 py-2 text-sm text-[var(--admin-text)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--admin-accent)]"
>
<span>{labels.trigger}</span>
<kbd className="text-xs text-[var(--admin-text-muted)]">Ctrl+K</kbd>
@@ -286,14 +286,14 @@ export function CommandDeck({
role="dialog"
aria-modal="true"
aria-label={labels.trigger}
className="fixed inset-0 z-50 flex items-start justify-center bg-[var(--admin-canvas)]/80 p-4 pt-[10vh]"
className="fixed inset-0 z-50 flex items-start justify-center bg-[var(--admin-canvas)]/80 p-2 sm:p-4 pt-4 sm:pt-[10vh]"
>
<div className="w-full max-w-2xl rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] shadow-xl">
<div className="max-h-[calc(100dvh-2rem)] min-w-0 w-full max-w-2xl overflow-hidden rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] shadow-xl">
<div className="flex justify-end border-b border-[var(--admin-border)] p-2">
<button
type="button"
onClick={close}
className="rounded px-2 py-1 text-sm text-[var(--admin-text-muted)]"
className="rounded px-2 py-1 text-sm text-[var(--admin-text-muted)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--admin-accent)]"
>
{labels.close}
</button>
@@ -316,10 +316,17 @@ export function CommandDeck({
placeholder={labels.placeholder}
aria-label={labels.placeholder}
/>
<CommandList aria-busy={loading}>
<CommandList
aria-busy={loading}
className="min-w-0 overflow-y-auto"
>
{loading ? <p role="status">{labels.loading}</p> : null}
{response.errors.length > 0 ? (
<p role="status" className="px-3 py-2 text-sm">
<p
role="alert"
aria-live="assertive"
className="px-3 py-2 text-sm"
>
{labels.partial}
</p>
) : null}
@@ -7,13 +7,17 @@ interface ContextNavProps {
export function ContextNav({ items, ariaLabel }: ContextNavProps) {
return (
<nav aria-label={ariaLabel} className="min-w-0">
<nav
aria-label={ariaLabel}
data-housekeeping-landmark="context-navigation"
className="min-w-0"
>
<ul className="flex gap-2 overflow-x-auto pb-1 lg:flex-col lg:overflow-visible">
{items.map((item) => (
<li key={item.id} className="min-w-max">
<Link
href={item.href}
className="block rounded-md px-3 py-2 text-sm text-[var(--admin-text-muted)] hover:bg-[var(--admin-canvas)] hover:text-[var(--admin-text)]"
className="block min-h-11 rounded-md px-3 py-2 text-sm text-[var(--admin-text-muted)] hover:bg-[var(--admin-canvas)] hover:text-[var(--admin-text)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--admin-accent)]"
>
{item.label}
</Link>
@@ -27,14 +27,18 @@ export function DomainRail({
ariaLabel,
}: DomainRailProps) {
return (
<nav aria-label={ariaLabel} className="w-full lg:w-20">
<nav
aria-label={ariaLabel}
data-housekeeping-landmark="primary-navigation"
className="min-w-0 w-full lg:w-20"
>
<ul className="flex gap-1 overflow-x-auto lg:flex-col lg:overflow-visible">
{domains.map((domain) => (
<li key={domain.id} className="min-w-max lg:min-w-0">
<Link
href={domain.href}
aria-current={domain.id === activeDomainId ? "page" : undefined}
className="flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-[var(--admin-text-muted)] hover:bg-[var(--admin-surface)] hover:text-[var(--admin-text)] lg:justify-center"
className="flex min-h-11 items-center gap-2 rounded-lg px-3 py-2 text-sm text-[var(--admin-text-muted)] hover:bg-[var(--admin-surface)] hover:text-[var(--admin-text)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--admin-accent)] lg:justify-center"
>
<span data-domain-icon={domain.iconId}>
<DomainIcon iconId={domain.iconId} />
@@ -72,14 +72,18 @@ function orderedPinnedRouteIds(preferences: HousekeepingPreferences): string[] {
function SortableFavorite({
route,
title,
surface,
unpin,
unpinLabel,
reorderInstructionsId,
}: {
readonly route: HousekeepingRouteDefinition;
readonly title: string;
readonly surface: HousekeepingSurface;
readonly unpin: () => void;
readonly unpinLabel: string;
readonly reorderInstructionsId: string;
}) {
const { attributes, listeners, setNodeRef, transform, transition } =
useSortable({
@@ -89,27 +93,28 @@ function SortableFavorite({
<li
ref={setNodeRef}
style={{ transform: CSS.Transform.toString(transform), transition }}
className="flex items-center gap-2 rounded-md border border-[var(--admin-border)] p-3"
className="flex min-w-0 items-center gap-2 rounded-md border border-[var(--admin-border)] p-3 motion-reduce:transition-none"
>
<button
type="button"
aria-label={route.labelKey}
className="cursor-grab text-[var(--admin-text-muted)]"
{...attributes}
{...listeners}
aria-label={title}
aria-describedby={reorderInstructionsId}
className="cursor-grab rounded text-[var(--admin-text-muted)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--admin-accent)]"
>
↕
</button>
<Link
className="min-w-0 flex-1"
className="min-w-0 flex-1 break-words rounded focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--admin-accent)]"
href={toHousekeepingHref(route.href, surface)}
>
{route.labelKey}
{title}
</Link>
<button
type="button"
onClick={unpin}
className="rounded-md px-2 py-1 text-sm"
className="rounded-md px-2 py-1 text-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--admin-accent)]"
>
{unpinLabel}
</button>
@@ -122,6 +127,7 @@ export interface FavoritesLabels {
readonly pin: string;
readonly unpin: string;
readonly error: string;
readonly reorderInstructions: string;
}
export function Favorites({
@@ -130,12 +136,14 @@ export function Favorites({
surface,
savePreferences,
labels,
titles = {},
}: {
readonly preferences: HousekeepingPreferences;
readonly routes: readonly HousekeepingRouteDefinition[];
readonly surface: HousekeepingSurface;
readonly savePreferences: HousekeepingPreferenceSaver;
readonly labels: FavoritesLabels;
readonly titles?: Readonly<Record<string, string>>;
}) {
const [preferences, setPreferences] = useState(initialPreferences);
const [error, setError] = useState(false);
@@ -190,6 +198,9 @@ export function Favorites({
<h2 id="housekeeping-favorites-title" className="text-lg font-semibold">
{labels.title}
</h2>
<p id="housekeeping-favorites-reorder-instructions" className="sr-only">
{labels.reorderInstructions}
</p>
{error ? (
<p role="alert" className="text-[var(--admin-danger)]">
{labels.error}
@@ -207,8 +218,10 @@ export function Favorites({
<SortableFavorite
key={id}
route={route}
title={titles[route.labelKey] ?? route.id}
surface={surface}
unpinLabel={labels.unpin}
reorderInstructionsId="housekeeping-favorites-reorder-instructions"
unpin={() => void commit(togglePinnedRoute(preferences, id))}
/>
) : null;
@@ -220,16 +233,18 @@ export function Favorites({
{unpinned.map((route) => (
<li
key={route.id}
className="flex items-center justify-between gap-3 p-2"
className="flex min-w-0 items-center justify-between gap-3 p-2"
>
<span>{route.labelKey}</span>
<span className="min-w-0 break-words">
{titles[route.labelKey] ?? route.id}
</span>
<button
type="button"
disabled={saving}
onClick={() =>
void commit(togglePinnedRoute(preferences, route.id))
}
className="rounded-md px-2 py-1 text-sm"
className="rounded-md px-2 py-1 text-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--admin-accent)]"
>
{labels.pin}
</button>
@@ -17,6 +17,7 @@ const labels = {
command: "Search housekeeping",
preview: "Preview",
backToSite: "Back to site",
operatorRank: "Rank 6",
commandDeck: {
placeholder: "Search navigation, commands, and entities",
navigation: "Navigation",
@@ -8,6 +8,13 @@ import { ContextNav } from "./context-nav";
import { DomainRail } from "./domain-rail";
import { OperatorSummary } from "./operator-summary";
export const HOUSEKEEPING_LANDMARKS = [
"banner",
"primary-navigation",
"context-navigation",
"main",
] as const;
interface HousekeepingShellProps {
actor: HousekeepingActor;
activeDomainId: HousekeepingDomainId;
@@ -19,6 +26,7 @@ interface HousekeepingShellProps {
command: string;
preview: string;
backToSite: string;
operatorRank: string;
commandDeck: {
placeholder: string;
navigation: string;
@@ -45,21 +53,24 @@ export function HousekeepingShell({
const activeDomain = domains.find((domain) => domain.id === activeDomainId);
return (
<div className="min-h-screen bg-[var(--admin-canvas)] text-[var(--admin-text)]">
<div
data-housekeeping-root="true"
className="min-h-screen min-w-0 bg-[var(--admin-canvas)] text-[var(--admin-text)]"
>
<a
href="#housekeeping-content"
className="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-10 rounded-md bg-[var(--admin-surface)] px-3 py-2 text-[var(--admin-text)]"
className="sr-only rounded-md bg-[var(--admin-surface)] px-3 py-2 text-[var(--admin-text)] focus-visible:not-sr-only focus-visible:absolute focus-visible:left-4 focus-visible:top-4 focus-visible:z-10 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--admin-accent)]"
>
{labels.skipToContent}
</a>
<header className="flex flex-wrap items-center justify-between gap-3 border-b border-[var(--admin-border)] bg-[var(--admin-surface)] px-4 py-3">
<div className="flex items-center gap-3">
<header className="flex min-w-0 flex-wrap items-center justify-between gap-3 border-b border-[var(--admin-border)] bg-[var(--admin-surface)] px-4 py-3">
<div className="flex min-w-0 items-center gap-3">
{surface === "preview" ? (
<span className="rounded-full border border-[var(--admin-border)] px-2 py-1 text-xs text-[var(--admin-accent)]">
{labels.preview}
</span>
) : null}
<OperatorSummary actor={actor} />
<OperatorSummary actor={actor} rankLabel={labels.operatorRank} />
</div>
<div className="flex items-center gap-2">
<CommandTrigger
@@ -69,27 +80,40 @@ export function HousekeepingShell({
/>
<Link
href="/"
className="rounded-md px-3 py-2 text-sm text-[var(--admin-text-muted)] hover:bg-[var(--admin-canvas)] hover:text-[var(--admin-text)]"
className="rounded-md px-3 py-2 text-sm text-[var(--admin-text-muted)] hover:bg-[var(--admin-canvas)] hover:text-[var(--admin-text)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--admin-accent)]"
>
{labels.backToSite}
</Link>
</div>
</header>
<div className="flex min-h-[calc(100vh-4rem)] flex-col lg:flex-row">
<aside className="border-b border-[var(--admin-border)] bg-[var(--admin-surface)] p-3 lg:border-b-0 lg:border-r">
<div
data-responsive-mode="stacked-below-lg"
className="flex min-h-[calc(100vh-4rem)] min-w-0 flex-col lg:flex-row"
>
<aside
data-navigation-collapse="domain"
className="min-w-0 border-b border-[var(--admin-border)] bg-[var(--admin-surface)] p-3 lg:border-b-0 lg:border-r"
>
<DomainRail
domains={domains}
activeDomainId={activeDomainId}
ariaLabel={labels.primaryNavigation}
/>
</aside>
<div className="border-b border-[var(--admin-border)] bg-[var(--admin-surface)] p-3 lg:w-56 lg:border-b-0 lg:border-r">
<div
data-navigation-collapse="context"
className="min-w-0 border-b border-[var(--admin-border)] bg-[var(--admin-surface)] p-3 lg:w-56 lg:shrink-0 lg:border-b-0 lg:border-r"
>
<ContextNav
items={activeDomain?.items ?? []}
ariaLabel={labels.contextualNavigation}
/>
</div>
<main id="housekeeping-content" className="min-w-0 flex-1 p-4 lg:p-6">
<main
id="housekeeping-content"
tabIndex={-1}
className="housekeeping-content min-w-0 max-w-full flex-1 p-4 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-[var(--admin-accent)] lg:p-6"
>
{children}
</main>
</div>
@@ -85,7 +85,9 @@ describe("OperationalInbox", () => {
expect(html).toContain('aria-label="Filter by state"');
expect(html).toContain('aria-label="Filter by priority"');
expect(html).toContain("Some sources are unavailable");
expect(html).toContain("content.publication");
expect(html).not.toContain("content.publication");
expect(html).not.toContain("TIMEOUT");
expect(html).toContain('role="alert"');
expect(html).toContain('href="/ase-next/people/users"');
expect(html).not.toContain("Dismiss");
expect(html).not.toContain("Assign");
@@ -90,7 +90,10 @@ export function OperationalInbox({
);
return (
<section aria-labelledby="operational-inbox-title" className="space-y-4">
<section
aria-labelledby="operational-inbox-title"
className="min-w-0 space-y-4"
>
<h2 id="operational-inbox-title" className="text-lg font-semibold">
{labels.title}
</h2>
@@ -102,7 +105,7 @@ export function OperationalInbox({
aria-label={labels.domainFilter}
value={domain}
onChange={(event) => setDomain(event.target.value)}
className="rounded-md border border-[var(--admin-border)] bg-[var(--admin-surface)] px-3 py-2"
className="min-w-0 rounded-md border border-[var(--admin-border)] bg-[var(--admin-surface)] px-3 py-2 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--admin-accent)]"
>
<option value="all">{labels.all}</option>
{domains.map((value) => (
@@ -115,7 +118,7 @@ export function OperationalInbox({
aria-label={labels.stateFilter}
value={state}
onChange={(event) => setState(event.target.value)}
className="rounded-md border border-[var(--admin-border)] bg-[var(--admin-surface)] px-3 py-2"
className="min-w-0 rounded-md border border-[var(--admin-border)] bg-[var(--admin-surface)] px-3 py-2 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--admin-accent)]"
>
<option value="all">{labels.all}</option>
{states.map((value) => (
@@ -128,7 +131,7 @@ export function OperationalInbox({
aria-label={labels.priorityFilter}
value={priority}
onChange={(event) => setPriority(event.target.value)}
className="rounded-md border border-[var(--admin-border)] bg-[var(--admin-surface)] px-3 py-2"
className="min-w-0 rounded-md border border-[var(--admin-border)] bg-[var(--admin-surface)] px-3 py-2 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--admin-accent)]"
>
<option value="all">{labels.all}</option>
{priorities.map((value) => (
@@ -141,17 +144,11 @@ export function OperationalInbox({
{response.errors.length > 0 ? (
<div
role="status"
role="alert"
aria-live="assertive"
className="rounded-md border border-[var(--admin-warning-border)] bg-[var(--admin-warning-subtle)] p-3"
>
<p className="text-[var(--admin-warning)]">{labels.partial}</p>
<ul className="mt-2 list-disc pl-5 text-sm">
{response.errors.map((error) => (
<li key={error.sourceId}>
{error.sourceId}: {error.code}
</li>
))}
</ul>
</div>
) : null}
@@ -160,10 +157,10 @@ export function OperationalInbox({
{items.map((item) => (
<li
key={`${item.sourceId}:${item.itemId}`}
className="rounded-md border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4"
className="min-w-0 rounded-md border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4"
>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="min-w-0 break-words">
<p className="font-medium">
{titles[item.titleKey] ?? item.titleKey}
</p>
@@ -176,7 +173,7 @@ export function OperationalInbox({
</div>
<Link
href={projectOperationalInboxHref(item.href, surface)}
className="rounded-md border border-[var(--admin-border)] px-3 py-2 text-sm"
className="rounded-md border border-[var(--admin-border)] px-3 py-2 text-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--admin-accent)]"
>
{labels.open}
</Link>
@@ -2,9 +2,10 @@ import type { HousekeepingActor } from "../contracts";
interface OperatorSummaryProps {
actor: HousekeepingActor;
rankLabel: string;
}
export function OperatorSummary({ actor }: OperatorSummaryProps) {
export function OperatorSummary({ actor, rankLabel }: OperatorSummaryProps) {
return (
<div className="min-w-0 text-sm text-[var(--admin-text)]">
<div className="truncate font-medium">{actor.username}</div>
@@ -12,7 +13,8 @@ export function OperatorSummary({ actor }: OperatorSummaryProps) {
data-operator-rank={actor.rank}
className="text-[var(--admin-text-muted)]"
>
{actor.rank}
<span className="sr-only">{rankLabel}</span>
<span aria-hidden="true">{actor.rank}</span>
</div>
</div>
);
@@ -157,6 +157,7 @@ describe("housekeeping personalization", () => {
pin: "Pin",
unpin: "Unpin",
error: "Save failed",
reorderInstructions: "Press Space, then use arrow keys.",
}}
/>
<RecentWork
@@ -190,6 +191,7 @@ describe("housekeeping personalization", () => {
title: "Widgets",
partial: "Some widgets failed",
error: "Save failed",
reorderInstructions: "Press Space, then use arrow keys.",
}}
/>
</>,
@@ -198,8 +200,7 @@ describe("housekeeping personalization", () => {
expect(html).toContain("Required");
expect(html).toContain('disabled=""');
expect(html).toContain("Some widgets failed");
expect(html).toContain("people.failing");
expect(html).toContain("TIMEOUT");
expect(html).not.toContain("TIMEOUT");
const gridSource = readFileSync(
resolve(
process.cwd(),
@@ -20,7 +20,10 @@ export function RecentWork({
readonly titles?: Readonly<Record<string, string>>;
}) {
return (
<section aria-labelledby="housekeeping-recent-title" className="space-y-3">
<section
aria-labelledby="housekeeping-recent-title"
className="min-w-0 space-y-3"
>
<h2 id="housekeeping-recent-title" className="text-lg font-semibold">
{labels.title}
</h2>
@@ -29,10 +32,13 @@ export function RecentWork({
{items.map((item) => (
<li
key={item.routeId}
className="rounded-md border border-[var(--admin-border)] bg-[var(--admin-surface)] p-3"
className="min-w-0 rounded-md border border-[var(--admin-border)] bg-[var(--admin-surface)] p-3"
>
<Link href={toHousekeepingHref(item.canonicalHref, surface)}>
{titles[item.labelKey] ?? item.labelKey}
<Link
href={toHousekeepingHref(item.canonicalHref, surface)}
className="break-words rounded focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--admin-accent)]"
>
{titles[item.labelKey] ?? item.routeId}
</Link>
<div className="text-xs text-[var(--admin-text-muted)]">
<span>{item.source}</span>
@@ -26,10 +26,14 @@ import {
function SortableWidget({
id,
title,
data,
reorderInstructionsId,
}: {
readonly id: string;
readonly title: string;
readonly data: unknown;
readonly reorderInstructionsId: string;
}) {
const { attributes, listeners, setNodeRef, transform, transition } =
useSortable({ id });
@@ -37,21 +41,22 @@ function SortableWidget({
<li
ref={setNodeRef}
style={{ transform: CSS.Transform.toString(transform), transition }}
className="rounded-md border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4"
className="min-w-0 rounded-md border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4 motion-reduce:transition-none"
>
<div className="flex items-center gap-2">
<button
type="button"
aria-label={id}
className="cursor-grab text-[var(--admin-text-muted)]"
{...attributes}
{...listeners}
aria-label={title}
aria-describedby={reorderInstructionsId}
className="cursor-grab rounded text-[var(--admin-text-muted)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--admin-accent)]"
>
↕
</button>
<h3 className="font-medium">{id}</h3>
<h3 className="min-w-0 break-words font-medium">{title}</h3>
</div>
<pre className="mt-2 overflow-auto text-xs">
<pre className="mt-2 max-w-full overflow-auto text-xs">
{JSON.stringify(data, null, 2)}
</pre>
</li>
@@ -62,6 +67,7 @@ export interface WidgetGridLabels {
readonly title: string;
readonly partial: string;
readonly error: string;
readonly reorderInstructions: string;
}
export function WidgetGrid({
@@ -69,11 +75,13 @@ export function WidgetGrid({
result,
savePreferences,
labels,
titles = {},
}: {
readonly preferences: HousekeepingPreferences;
readonly result: HousekeepingWidgetLoadResult;
readonly savePreferences: HousekeepingPreferenceSaver;
readonly labels: WidgetGridLabels;
readonly titles?: Readonly<Record<string, string>>;
}) {
const [preferences, setPreferences] = useState(initialPreferences);
const [error, setError] = useState(false);
@@ -122,6 +130,9 @@ export function WidgetGrid({
<h2 id="housekeeping-widget-grid-title" className="text-lg font-semibold">
{labels.title}
</h2>
<p id="housekeeping-widgets-reorder-instructions" className="sr-only">
{labels.reorderInstructions}
</p>
{error ? (
<p role="alert" className="text-[var(--admin-danger)]">
{labels.error}
@@ -129,17 +140,11 @@ export function WidgetGrid({
) : null}
{result.errors.length > 0 ? (
<div
role="status"
role="alert"
aria-live="assertive"
className="rounded-md border border-[var(--admin-warning-border)] bg-[var(--admin-warning-subtle)] p-3"
>
<p className="text-[var(--admin-warning)]">{labels.partial}</p>
<ul className="mt-2 list-disc pl-5 text-sm">
{result.errors.map((item) => (
<li key={item.widgetId}>
{item.widgetId}: {item.code}
</li>
))}
</ul>
</div>
) : null}
<DndContext
@@ -149,7 +154,13 @@ export function WidgetGrid({
<SortableContext items={ids} strategy={verticalListSortingStrategy}>
<ul className="grid gap-3 md:grid-cols-2">
{ids.map((id) => (
<SortableWidget key={id} id={id} data={dataById.get(id)} />
<SortableWidget
key={id}
id={id}
title={titles[id] ?? id}
data={dataById.get(id)}
reorderInstructionsId="housekeeping-widgets-reorder-instructions"
/>
))}
</ul>
</SortableContext>
@@ -92,7 +92,11 @@ export function WidgetSettings({
{labels.title}
</h2>
{error ? (
<p role="alert" className="text-[var(--admin-danger)]">
<p
role="alert"
aria-live="assertive"
className="text-[var(--admin-danger)]"
>
{error === labels.error ? error : labels.error}
</p>
) : null}
@@ -105,16 +109,19 @@ export function WidgetSettings({
return (
<li
key={widget.id}
className="flex items-center justify-between gap-3 rounded-md border border-[var(--admin-border)] p-3"
className="flex min-w-0 items-center justify-between gap-3 rounded-md border border-[var(--admin-border)] p-3"
>
<label className="flex items-center gap-2">
<label className="flex min-w-0 items-center gap-2">
<input
type="checkbox"
checked={checked}
disabled={mandatory || savingId !== null}
onChange={() => void toggle(widget.id)}
className="focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--admin-accent)]"
/>
<span>{titles[widget.id] ?? widget.id}</span>
<span className="min-w-0 break-words">
{titles[widget.id] ?? widget.id}
</span>
</label>
{mandatory ? (
<span className="text-xs text-[var(--admin-text-muted)]">
@@ -20,6 +20,7 @@ export interface HousekeepingPageInput {
>;
readonly surface?: HousekeepingSurface;
readonly savePreferences?: HousekeepingPreferenceSaver;
readonly translate?: (key: string) => string;
}
export interface HousekeepingRouteHandler {