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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions .agents/rules/frontend/shared.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@ mutation.mutate({ text, clientId: crypto.randomUUID() });

## Routing (`routes.tsx`, `App.tsx`)

- `createBrowserRouter`, flat config. Pages are **named exports** loaded via a `lazyNamed(importer, name)` helper (adapts named → `React.lazy`'s default contract). No default exports.
- `createBrowserRouter`, flat config. Pages are **named exports** loaded via a `lazyNamed(importer, name, namespaces)` helper (adapts named → `React.lazy`'s default contract, and loads the route's lazy translation namespaces alongside the chunk). No default exports.
- **Initial bundle**: `vite.config.ts` groups everything the entry statically imports (Rolldown's `$initial` tag) into one `vendor` and one `app` chunk; route pages stay lazy. Keep heavy, post-login-only deps (SignalR, zod forms, cmdk) out of the static graph of `main.tsx` with a dynamic `import()`.
- Nesting: public auth routes → `<ProtectedRoute/>` → `<AppShell/>` → page children. `errorElement: <RouteError/>`.
- Provider tree: `ThemeProvider > QueryClientProvider > AuthProvider > … > RouterProvider` + `sonner` `<Toaster/>`.

Expand Down Expand Up @@ -76,7 +77,7 @@ So "neutrals must be chroma 0" is a **dashboard** rule. Admin neutrals are inten

Every string a user reads goes through `t()`; English and Portuguese ship today. The backend negotiates its own messages from the `Accept-Language` that `apiFetch` sends, so ProblemDetails text is shown as-is (`.agents/rules/localization.md`).

- **One namespace per area**: `src/locales/en-US/{ns}.json`, registered with one line in the catalog map in `src/i18n.ts` (`CATALOGS` in admin, `catalogs` in dashboard). `src/locales/pt-BR/{ns}.json` is optional: `src/locales/pt-BR.ts` picks every file up into one lazy chunk, and a missing key renders in English.
- **One namespace per area**: `src/locales/en-US/{ns}.json`, registered with one line in `src/i18n.ts`. A namespace the app shell renders with (layout, sign-in, banners, notification bell; plus admin's landing dashboard) goes in the eager map (`EAGER_CATALOGS` in admin, `eagerCatalogs` in dashboard) and ships in the main bundle. Every other namespace goes in the lazy map (`LAZY_CATALOGS` / `lazyCatalogs`) as `() => import(...)`, and **every route whose page or components use it lists it** in `lazyNamed(importer, name, ["ns", …])`, which loads it in parallel with the page chunk. A route that forgets a namespace renders guessed labels and logs `[i18n] missing key` in dev. `src/locales/pt-BR/{ns}.json` is optional: `src/locales/pt-BR.ts` picks every file up into one lazy chunk, and a missing key renders in English.
- **Keys are flat and dotted** inside the file (`"header.title"`, `"create.validation.url"`). Interpolate with `{{name}}`; counts use `_one` / `_other` plus `{{count, number}}` and are called with `{ count }`.
- **In a component**: `const { t } = useTranslation("ns")`. Another namespace through the list form, `useTranslation(["activity", "audits"])` with `t("audits:…")`, or through a second translator when a helper requires one bound to it (`const { t: tCommon } = useTranslation("common")` for `describeUploadError`). Never prefix a key with the namespace the translator is already bound to.
- **Keys are typed.** `src/i18next.d.ts` types every key against the en-US catalogs, so add the key to the JSON first: a key the namespace lacks fails `tsc -b`. A helper that receives the translator takes `TFunction<"ns">`; a key stored in data (nav items, tabs, enum labels) is `ParseKeys<"ns">`, and an enum label goes through a map typed against the enum (`satisfies Record<Status, ParseKeys<"ns">>`) rather than a template key, so a new member without a label stops compiling. Billing and impersonation statuses have their maps in `src/lib/status-keys.ts`, read through `statusKey()`, which returns `undefined` for a status added after the build so the caller can show the raw value; `tests/i18n/status-keys.spec.ts` checks every member of the backend enum against those maps and both catalogs. A template key still compiles when the call passes `defaultValue`, but its key is not checked; a few remain.
Expand All @@ -95,7 +96,7 @@ Every string a user reads goes through `t()`; English and Portuguese ship today.

1. API: extend `src/api/{feature}.ts` — hand-written types + `apiFetch` calls.
2. Page: `src/pages/{area}/{name}.tsx`, **named** export. `useQuery` with hierarchical key; `useMutation` invalidating in `onSuccess`, passing per-call data via `mutate(arg)`. Text through `t()` from the area's namespace, with the keys added to `src/locales/en-US/{ns}.json` (see Translations).
3. Route: add `const X = lazyNamed(() => import("@/pages/area/name"), "XPage")` and a child route under `AppShell`.
3. Route: add `const X = lazyNamed(() => import("@/pages/area/name"), "XPage", ["ns"])` (its lazy translation namespaces) and a child route under `AppShell`.
4. Test: `tests/{area}/{name}.spec.ts` with seed + shell mocks + page mocks.

Then apply the app-specific steps in `admin.md` / `dashboard.md` (forms, permission gating, suspense, etc.).
7 changes: 5 additions & 2 deletions .agents/skills/add-react-page/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@ export async function create{Resource}(input: Create{Resource}Input) {

Every string the page shows is a key in its area's namespace. Add the English entries **before** using
them: keys are typed against the en-US catalogs, so `t("{resources}.title")` fails `tsc -b` until the
key exists. A new area gets a new `{ns}.json` plus one line in the catalog map in `src/i18n.ts`.
key exists. A new area gets a new `{ns}.json` plus one line in the lazy catalog map in `src/i18n.ts`
(`LAZY_CATALOGS` in admin, `lazyCatalogs` in dashboard), and its routes list `{ns}` (Step 5).
`src/locales/pt-BR/{ns}.json` is optional; a missing key renders in English.

```json
Expand Down Expand Up @@ -102,7 +103,9 @@ If you need to track the in-flight item (e.g. a per-row busy state), use `onMuta
## Step 5 — Register the route (`routes.tsx`)

```tsx
const {Resource}ListPage = lazyNamed(() => import("@/pages/{area}/list"), "{Resource}ListPage");
// The last argument lists the lazy translation namespaces the page and its components use;
// they load alongside the page chunk. Shell namespaces (common, auth, …) are always loaded.
const {Resource}ListPage = lazyNamed(() => import("@/pages/{area}/list"), "{Resource}ListPage", ["{ns}"]);
// admin — under AppShell.children, gated:
{ path: "{resources}", element: <RouteGuard perms={[{Module}Permissions.{Resources}.View]}><{Resource}ListPage /></RouteGuard> },
// dashboard — under AppShell.children, suspense only:
Expand Down
97 changes: 65 additions & 32 deletions clients/admin/src/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,51 +5,83 @@ import { fallbackChain, missingKeyFallback } from "@/lib/i18n-fallback";
import enCommon from "@/locales/en-US/common.json";
import enNav from "@/locales/en-US/nav.json";
import enAuth from "@/locales/en-US/auth.json";
import enSettings from "@/locales/en-US/settings.json";
import enSessions from "@/locales/en-US/sessions.json";
import enUsers from "@/locales/en-US/users.json";
import enRoles from "@/locales/en-US/roles.json";
import enImpersonation from "@/locales/en-US/impersonation.json";
import enBilling from "@/locales/en-US/billing.json";
import enTenants from "@/locales/en-US/tenants.json";
import enWebhooks from "@/locales/en-US/webhooks.json";
import enAudits from "@/locales/en-US/audits.json";
import enNotifications from "@/locales/en-US/notifications.json";
import enHealth from "@/locales/en-US/health.json";
import enDashboard from "@/locales/en-US/dashboard.json";

// Canonical tags: specific (what the switcher offers, User.Locale persists, the claim carries).
export const SUPPORTED = ["en-US", "pt-BR"] as const;

type Catalog = Record<string, string>;

// Translation namespaces keyed by name → en-US catalog. This map is the single source
// string-migration waves extend: to add a namespace, import its en-US catalog and add one entry
// here — `resources` and `ns` below derive from it, so no other wiring changes. Its pt-BR file
// (optional) is picked up by src/locales/pt-BR.ts; a namespace or key pt-BR lacks renders in
// English through fallbackChain. Its type is also what src/i18next.d.ts checks every t() key against.
const CATALOGS = {
// en-US catalogs the app shell renders with (layout, sign-in, notification bell, the landing
// dashboard). They ship in the main bundle so the first paint never waits on a catalog.
const EAGER_CATALOGS = {
common: enCommon,
nav: enNav,
auth: enAuth,
settings: enSettings,
sessions: enSessions,
users: enUsers,
roles: enRoles,
impersonation: enImpersonation,
billing: enBilling,
tenants: enTenants,
webhooks: enWebhooks,
audits: enAudits,
notifications: enNotifications,
health: enHealth,
dashboard: enDashboard,
} satisfies Record<string, Catalog>;

export type Catalogs = typeof CATALOGS;
// en-US catalogs only route pages use. Each is its own small chunk, loaded by loadNamespaces()
// in parallel with the page's chunk (see `lazyNamed` in src/routes.tsx), so the page never
// renders before its text is there.
//
// To add a namespace: add its en-US JSON here (or to EAGER_CATALOGS when the shell uses it) and
// list it on the routes that use it. `resources`, `ns` and the t() key types all derive from
// these two maps. Its pt-BR file (optional) is picked up by src/locales/pt-BR.ts; a namespace or
// key pt-BR lacks renders in English through fallbackChain.
const LAZY_CATALOGS = {
settings: () => import("@/locales/en-US/settings.json"),
sessions: () => import("@/locales/en-US/sessions.json"),
users: () => import("@/locales/en-US/users.json"),
roles: () => import("@/locales/en-US/roles.json"),
impersonation: () => import("@/locales/en-US/impersonation.json"),
billing: () => import("@/locales/en-US/billing.json"),
tenants: () => import("@/locales/en-US/tenants.json"),
webhooks: () => import("@/locales/en-US/webhooks.json"),
audits: () => import("@/locales/en-US/audits.json"),
health: () => import("@/locales/en-US/health.json"),
} satisfies Record<string, () => Promise<{ default: Catalog }>>;

// en-US ships in the main bundle: it is the terminal fallback and must resolve synchronously.
// Every other language is one lazy chunk, fetched before the UI switches to it.
export type LazyNamespace = keyof typeof LAZY_CATALOGS;

// What src/i18next.d.ts checks every t() key against: the eager and lazy catalogs together.
export type Catalogs = typeof EAGER_CATALOGS & {
[K in LazyNamespace]: Awaited<ReturnType<(typeof LAZY_CATALOGS)[K]>>["default"];
};

const NAMESPACES = [...Object.keys(EAGER_CATALOGS), ...Object.keys(LAZY_CATALOGS)];

const pendingNamespaces = new Map<LazyNamespace, Promise<void>>();

// Loads route namespaces into en-US, the terminal fallback. Other languages need no work here:
// loadLanguage adds every namespace of a language in one go. A failed load is forgotten so the
// next navigation retries it.
export function loadNamespaces(namespaces: readonly LazyNamespace[]): Promise<void> {
return Promise.all(
namespaces.map((ns) => {
let pending = pendingNamespaces.get(ns);
if (!pending) {
pending = LAZY_CATALOGS[ns]().then(
({ default: catalog }) => {
i18n.addResourceBundle("en-US", ns, catalog, true, true);
},
(error: unknown) => {
pendingNamespaces.delete(ns);
throw error;
},
);
pendingNamespaces.set(ns, pending);
}
return pending;
}),
).then(() => undefined);
}

// en-US is the terminal fallback: its shell catalogs ship in the main bundle and its route
// catalogs load with their routes. Every other language is one lazy chunk, fetched before the UI
// switches to it.
const LAZY_LANGUAGES: Partial<
Record<(typeof SUPPORTED)[number], () => Promise<{ default: Record<string, Catalog> }>>
> = {
Expand All @@ -61,7 +93,7 @@ async function loadLanguage(lng: string) {
const load = LAZY_LANGUAGES[lng as (typeof SUPPORTED)[number]];
if (!load || loadedLanguages.has(lng)) return;
const { default: catalogs } = await load();
for (const ns of Object.keys(CATALOGS)) {
for (const ns of NAMESPACES) {
const catalog = catalogs[ns];
if (catalog) i18n.addResourceBundle(lng, ns, catalog, true, true);
}
Expand Down Expand Up @@ -113,8 +145,9 @@ export async function initI18n(deploymentDefault: string) {
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources: { "en-US": CATALOGS },
ns: Object.keys(CATALOGS),
// Route catalogs are added later through loadNamespaces().
resources: { "en-US": EAGER_CATALOGS },
ns: NAMESPACES,
fallbackLng: fallbackChain(deploymentDefault, SUPPORTED),
supportedLngs: [...SUPPORTED],
defaultNS: "common",
Expand Down
38 changes: 23 additions & 15 deletions clients/admin/src/realtime/realtime-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,19 @@
useState,
type ReactNode,
} from "react";
import {
HubConnection,
HubConnectionBuilder,
HubConnectionState,
HttpTransportType,
LogLevel,
} from "@microsoft/signalr";
import type { HubConnection } from "@microsoft/signalr";
import { env } from "@/env";
import { tokenStore } from "@/auth/token-store";

// SignalR is about 25 KB gzip. It's imported on first connect, so the sign-in page and the
// initial bundle don't carry it. Same approach as the dashboard's provider.
type SignalRModule = typeof import("@microsoft/signalr");
let signalRPromise: Promise<SignalRModule> | null = null;
function loadSignalR(): Promise<SignalRModule> {
if (!signalRPromise) signalRPromise = import("@microsoft/signalr");
return signalRPromise;
}

export type RealtimeStatus = "idle" | "connecting" | "connected" | "reconnecting" | "error";

type Listener = (payload: unknown) => void;
Expand Down Expand Up @@ -50,15 +53,16 @@
stoppedRef.current = false;
const backoffs = [0, 2_000, 5_000, 10_000, 30_000];

const buildConnection = () => {
const buildConnection = async () => {
const sr = await loadSignalR();
const url = `${env.apiBase || ""}/api/v1/realtime/hub`;
return new HubConnectionBuilder()
return new sr.HubConnectionBuilder()
.withUrl(url, {
accessTokenFactory: () => tokenStore.getAccessToken() ?? "",
transport:
HttpTransportType.WebSockets |
HttpTransportType.ServerSentEvents |
HttpTransportType.LongPolling,
sr.HttpTransportType.WebSockets |
sr.HttpTransportType.ServerSentEvents |
sr.HttpTransportType.LongPolling,
})
// SignalR's automatic reconnect re-negotiates internally, bypassing connect()'s
// token guard. When the user is signed out (token cleared/expired) every reconnect
Expand All @@ -70,7 +74,7 @@
? (backoffs.slice(1)[ctx.previousRetryCount] ?? null)
: null,
})
.configureLogging(LogLevel.Warning)
.configureLogging(sr.LogLevel.Warning)
.build();
};

Expand Down Expand Up @@ -99,7 +103,9 @@
return;
}
setStatus("connecting");
const conn = buildConnection();
const conn = await buildConnection();
// Torn down while the SignalR chunk was loading: don't open a socket we'd have to stop.
if (stoppedRef.current) return;
connectionRef.current = conn;
wireListeners(conn);

Expand Down Expand Up @@ -128,7 +134,9 @@
stoppedRef.current = true;
const conn = connectionRef.current;
connectionRef.current = null;
if (conn && conn.state !== HubConnectionState.Disconnected) {
// HubConnectionState is a string enum; comparing the literal avoids importing the
// lazy-loaded module here.
if (conn && (conn.state as string) !== "Disconnected") {
void conn.stop();
}
setStatus("idle");
Expand All @@ -153,7 +161,7 @@
return <RealtimeContext.Provider value={value}>{children}</RealtimeContext.Provider>;
}

export function useRealtime() {

Check warning on line 164 in clients/admin/src/realtime/realtime-context.tsx

View workflow job for this annotation

GitHub Actions / Lint & Build (admin)

Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components
const ctx = useContext(RealtimeContext);
if (!ctx) throw new Error("useRealtime must be used within RealtimeProvider");
return ctx;
Expand All @@ -163,7 +171,7 @@
* Typed subscription hook. The handler is automatically re-bound when it changes
* via a ref so callers don't have to memoize.
*/
export function useRealtimeEvent<T = unknown>(

Check warning on line 174 in clients/admin/src/realtime/realtime-context.tsx

View workflow job for this annotation

GitHub Actions / Lint & Build (admin)

Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components
event: string,
handler: (payload: T) => void,
deps: ReadonlyArray<unknown> = [],
Expand Down
Loading
Loading