From a113e488802d4ab07181d725b50f3196a60188da Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sun, 30 Aug 2026 18:49:10 +0200 Subject: [PATCH] feat(housekeeping): finish accessible command deck --- .../[domain]/[[...segments]]/page.tsx | 3 + src/app/ase-next/[domain]/layout.tsx | 3 + src/app/ase-next/page.tsx | 4 + src/app/globals.css | 36 + .../operations/pages/personalization.tsx | 49 +- .../operations/pages/workspace.test.tsx | 49 +- .../domains/operations/pages/workspace.tsx | 88 ++- .../domains/operations/route-handlers.ts | 116 ++- .../foundation/accessibility.test.tsx | 210 ++++++ .../foundation/localization-contract.test.ts | 411 ++++++----- .../page/housekeeping-page-shell.tsx | 12 +- .../page/housekeeping-page-state.test.tsx | 31 + .../page/housekeeping-page-state.tsx | 18 +- .../foundation/preview-route-contract.test.ts | 6 +- .../foundation/responsive-contract.test.tsx | 97 +++ .../foundation/search/navigation-search.ts | 10 +- .../foundation/shell/command-deck.tsx | 19 +- .../foundation/shell/context-nav.tsx | 8 +- .../foundation/shell/domain-rail.tsx | 8 +- .../foundation/shell/favorites.tsx | 33 +- .../shell/housekeeping-shell.test.tsx | 1 + .../foundation/shell/housekeeping-shell.tsx | 44 +- .../shell/operational-inbox.test.tsx | 4 +- .../foundation/shell/operational-inbox.tsx | 27 +- .../foundation/shell/operator-summary.tsx | 6 +- .../foundation/shell/personalization.test.tsx | 5 +- .../foundation/shell/recent-work.tsx | 14 +- .../foundation/shell/widget-grid.tsx | 39 +- .../foundation/shell/widget-settings.tsx | 15 +- src/features/housekeeping/route-handlers.ts | 1 + src/messages/ar.json | 647 +++++++++++++++++ src/messages/bg.json | 647 +++++++++++++++++ src/messages/cs.json | 647 +++++++++++++++++ src/messages/da.json | 647 +++++++++++++++++ src/messages/de.json | 647 +++++++++++++++++ src/messages/el.json | 647 +++++++++++++++++ src/messages/en.json | 672 +++++++++++++++--- src/messages/es.json | 647 +++++++++++++++++ src/messages/fi.json | 647 +++++++++++++++++ src/messages/fr.json | 647 +++++++++++++++++ src/messages/hr.json | 647 +++++++++++++++++ src/messages/hu.json | 647 +++++++++++++++++ src/messages/it.json | 653 ++++++++++++++--- src/messages/ja.json | 647 +++++++++++++++++ src/messages/nl.json | 647 +++++++++++++++++ src/messages/no.json | 647 +++++++++++++++++ src/messages/pl.json | 647 +++++++++++++++++ src/messages/pt.json | 647 +++++++++++++++++ src/messages/ro.json | 647 +++++++++++++++++ src/messages/ru.json | 647 +++++++++++++++++ src/messages/sk.json | 647 +++++++++++++++++ src/messages/sr.json | 647 +++++++++++++++++ src/messages/sv.json | 647 +++++++++++++++++ src/messages/tr.json | 647 +++++++++++++++++ src/messages/uk.json | 647 +++++++++++++++++ 55 files changed, 17064 insertions(+), 509 deletions(-) create mode 100644 src/features/housekeeping/foundation/accessibility.test.tsx create mode 100644 src/features/housekeeping/foundation/responsive-contract.test.tsx diff --git a/src/app/ase-next/[domain]/[[...segments]]/page.tsx b/src/app/ase-next/[domain]/[[...segments]]/page.tsx index 6b2c0a52..c84a9b34 100644 --- a/src/app/ase-next/[domain]/[[...segments]]/page.tsx +++ b/src/app/ase-next/[domain]/[[...segments]]/page.tsx @@ -1,4 +1,5 @@ import { notFound } from "next/navigation"; +import { getTranslations } from "next-intl/server"; import { satisfiesCapability } from "@/features/housekeeping/foundation/capability-context"; import { createHousekeepingRegistry } from "@/features/housekeeping/foundation/registry"; import { matchHousekeepingRoute } from "@/features/housekeeping/foundation/routing/match-route"; @@ -40,11 +41,13 @@ export default async function HousekeepingPreviewRoutePage({ ) { notFound(); } + const translate = await getTranslations("pages.housekeeping"); return handler.render({ context, match, surface: "preview", + translate: (key) => translate(key as never), ...(searchParams ? { searchParams: await searchParams } : {}), }); } diff --git a/src/app/ase-next/[domain]/layout.tsx b/src/app/ase-next/[domain]/layout.tsx index 3e534e13..bb8a591c 100644 --- a/src/app/ase-next/[domain]/layout.tsx +++ b/src/app/ase-next/[domain]/layout.tsx @@ -54,6 +54,9 @@ export default async function HousekeepingPreviewDomainLayout({ command: translate("preview.commandDisabled"), preview: translate("preview.badge"), backToSite: translate("preview.backToSite"), + operatorRank: translate("navigation.operatorRank", { + rank: context.actor.rank, + }), commandDeck: { placeholder: translate("commandDeck.placeholder"), navigation: translate("commandDeck.navigation"), diff --git a/src/app/ase-next/page.tsx b/src/app/ase-next/page.tsx index ae626b1c..ff02ce7d 100644 --- a/src/app/ase-next/page.tsx +++ b/src/app/ase-next/page.tsx @@ -73,6 +73,7 @@ export default async function HousekeepingPreviewRootPage() { context, match, surface: "preview", + translate: (key) => translate(key as never), }); return ( @@ -87,6 +88,9 @@ export default async function HousekeepingPreviewRootPage() { command: translate("preview.commandDisabled"), preview: translate("preview.badge"), backToSite: translate("preview.backToSite"), + operatorRank: translate("navigation.operatorRank", { + rank: context.actor.rank, + }), commandDeck: { placeholder: translate("commandDeck.placeholder"), navigation: translate("commandDeck.navigation"), diff --git a/src/app/globals.css b/src/app/globals.css index 39463b0d..4b00dc2d 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1618,6 +1618,42 @@ details[open] > summary .details-open\:rotate-180 { } } +/* Housekeeping command deck: keyboard focus, narrow screens, and motion safety. */ +[data-housekeeping-root] :is(a, button, input, select, textarea):focus-visible { + outline: 2px solid var(--admin-accent); + outline-offset: 2px; +} + +#housekeeping-content { + max-width: 100%; + overflow-x: clip; +} + +#housekeeping-content table { + display: block; + max-width: 100%; + overflow-x: auto; + overscroll-behavior-inline: contain; +} + +#housekeeping-content :is(pre, code, img, svg, canvas) { + max-width: 100%; +} + +@media (prefers-reduced-motion: reduce) { + [data-housekeeping-root], + [data-housekeeping-root] * { + /* biome-ignore lint/complexity/noImportantStyles: accessibility preference must override component styles */ + scroll-behavior: auto !important; + /* biome-ignore lint/complexity/noImportantStyles: accessibility preference must override component styles */ + animation-duration: 0.01ms !important; + /* biome-ignore lint/complexity/noImportantStyles: accessibility preference must override component styles */ + animation-iteration-count: 1 !important; + /* biome-ignore lint/complexity/noImportantStyles: accessibility preference must override component styles */ + transition-duration: 0.01ms !important; + } +} + /* ── Premium effects ── */ @keyframes float { 0%, diff --git a/src/features/housekeeping/domains/operations/pages/personalization.tsx b/src/features/housekeeping/domains/operations/pages/personalization.tsx index ad49f69f..f8c40fa5 100644 --- a/src/features/housekeeping/domains/operations/pages/personalization.tsx +++ b/src/features/housekeeping/domains/operations/pages/personalization.tsx @@ -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>; + readonly widgetTitles: Readonly>; 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} /> @@ -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} /> ); diff --git a/src/features/housekeeping/domains/operations/pages/workspace.test.tsx b/src/features/housekeeping/domains/operations/pages/workspace.test.tsx index 2b237307..ca5b8499 100644 --- a/src/features/housekeeping/domains/operations/pages/workspace.test.tsx +++ b/src/features/housekeeping/domains/operations/pages/workspace.test.tsx @@ -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} />, ); diff --git a/src/features/housekeeping/domains/operations/pages/workspace.tsx b/src/features/housekeeping/domains/operations/pages/workspace.tsx index b50e38d6..8e19dd19 100644 --- a/src/features/housekeeping/domains/operations/pages/workspace.tsx +++ b/src/features/housekeeping/domains/operations/pages/workspace.tsx @@ -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>; + readonly widgetTitles: Readonly>; + readonly inboxTitles?: Readonly>; readonly savePreferences?: HousekeepingPreferenceSaver; }) { if (routes.length === 0) { return (
-

- No operational domains are available -

+

{labels.noDomainsTitle}

- Your account can open Housekeeping, but no operational domain is - currently authorized. + {labels.noDomainsDescription}

@@ -44,25 +76,17 @@ export function OperationsWorkspacePage({ return (
{result.errors.length > 0 ? (
-

- Some workspace sections are unavailable -

-
    - {result.errors.map((error) => ( -
  • - {error.section}: {error.code} -
  • - ))} -
+

{labels.partial}

) : null} @@ -70,25 +94,14 @@ export function OperationsWorkspacePage({
@@ -98,6 +111,9 @@ export function OperationsWorkspacePage({ widgets={widgetDefinitions} widgetResult={result.model.widgets} surface={surface} + labels={labels} + routeTitles={routeTitles} + widgetTitles={widgetTitles} {...(savePreferences ? { savePreferences } : {})} /> diff --git a/src/features/housekeeping/domains/operations/route-handlers.ts b/src/features/housekeeping/domains/operations/route-handlers.ts index fade8b00..cb71f312 100644 --- a/src/features/housekeeping/domains/operations/route-handlers.ts +++ b/src/features/housekeeping/domains/operations/route-handlers.ts @@ -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[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 } : {}), diff --git a/src/features/housekeeping/foundation/accessibility.test.tsx b/src/features/housekeeping/foundation/accessibility.test.tsx new file mode 100644 index 00000000..e81b0174 --- /dev/null +++ b/src/features/housekeeping/foundation/accessibility.test.tsx @@ -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( + + +

Inbox

+
+
, + ); + + expect(html).toContain('data-housekeeping-root="true"'); + expect(html).toContain("Rank 6"); + 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" ? ( + + ) : ( + + ); + 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( + , + ); + const widgets = renderToStaticMarkup( + , + ); + + 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( + , + ); + + 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"); + }); +}); diff --git a/src/features/housekeeping/foundation/localization-contract.test.ts b/src/features/housekeeping/foundation/localization-contract.test.ts index 4e57efe6..8d9430bb 100644 --- a/src/features/housekeeping/foundation/localization-contract.test.ts +++ b/src/features/housekeeping/foundation/localization-contract.test.ts @@ -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((value, segment) => { if (value === null || typeof value !== "object") return undefined; - return (value as Record)[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.", + ); }); }); diff --git a/src/features/housekeeping/foundation/page/housekeeping-page-shell.tsx b/src/features/housekeeping/foundation/page/housekeeping-page-shell.tsx index 07a48921..8e910764 100644 --- a/src/features/housekeeping/foundation/page/housekeeping-page-shell.tsx +++ b/src/features/housekeeping/foundation/page/housekeeping-page-shell.tsx @@ -16,10 +16,16 @@ export function HousekeepingPageShell({ children, }: HousekeepingPageShellProps) { return ( -
+
-

+

{title}

@@ -29,7 +35,7 @@ export function HousekeepingPageShell({

{primaryAction ?
{primaryAction}
: null}
-
{children}
+
{children}
); } diff --git a/src/features/housekeeping/foundation/page/housekeeping-page-state.test.tsx b/src/features/housekeeping/foundation/page/housekeeping-page-state.test.tsx index 13c02396..9ad0b92e 100644 --- a/src/features/housekeeping/foundation/page/housekeeping-page-state.test.tsx +++ b/src/features/housekeeping/foundation/page/housekeeping-page-state.test.tsx @@ -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" ? ( + + ) : ( + + ); + const html = renderToStaticMarkup(pageState); + + expect(html).toContain(`data-page-state="${state}"`); + expect(html).toContain(`aria-live="${live}"`); + }); + it("announces loading politely", () => { const loading = renderToStaticMarkup( ; + 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 (
{ "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", diff --git a/src/features/housekeeping/foundation/responsive-contract.test.tsx b/src/features/housekeeping/foundation/responsive-contract.test.tsx new file mode 100644 index 00000000..c1101590 --- /dev/null +++ b/src/features/housekeeping/foundation/responsive-contract.test.tsx @@ -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( + +
Body
+
, + ); + + 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( + , + ); + + 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"); + }); +}); diff --git a/src/features/housekeeping/foundation/search/navigation-search.ts b/src/features/housekeeping/foundation/search/navigation-search.ts index d58919ba..424baeae 100644 --- a/src/features/housekeeping/foundation/search/navigation-search.ts +++ b/src/features/housekeeping/foundation/search/navigation-search.ts @@ -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}`, diff --git a/src/features/housekeeping/foundation/shell/command-deck.tsx b/src/features/housekeeping/foundation/shell/command-deck.tsx index 2e1da2ba..5f443796 100644 --- a/src/features/housekeeping/foundation/shell/command-deck.tsx +++ b/src/features/housekeeping/foundation/shell/command-deck.tsx @@ -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)]" > {labels.trigger} Ctrl+K @@ -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]" > -
+
@@ -316,10 +316,17 @@ export function CommandDeck({ placeholder={labels.placeholder} aria-label={labels.placeholder} /> - + {loading ?

{labels.loading}

: null} {response.errors.length > 0 ? ( -

+

{labels.partial}

) : null} diff --git a/src/features/housekeeping/foundation/shell/context-nav.tsx b/src/features/housekeeping/foundation/shell/context-nav.tsx index d89baae1..8aff6ed7 100644 --- a/src/features/housekeeping/foundation/shell/context-nav.tsx +++ b/src/features/housekeeping/foundation/shell/context-nav.tsx @@ -7,13 +7,17 @@ interface ContextNavProps { export function ContextNav({ items, ariaLabel }: ContextNavProps) { return ( -