diff --git a/standards/src/templates/client/.env.example b/standards/src/templates/client/.env.example
new file mode 100644
index 0000000..996e6f8
--- /dev/null
+++ b/standards/src/templates/client/.env.example
@@ -0,0 +1,2 @@
+VITE_API_BASE_URL=http://localhost:8000
+VITE_AUTH_BASE_URL=http://localhost:8000
diff --git a/standards/src/templates/client/.gitignore b/standards/src/templates/client/.gitignore
new file mode 100644
index 0000000..434c113
--- /dev/null
+++ b/standards/src/templates/client/.gitignore
@@ -0,0 +1,21 @@
+# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
+
+.env
+# dependencies
+node_modules
+
+# React Router
+.react-router
+build
+
+# misc
+.DS_Store
+dist
+dist-ssr
+*.local
+.idea
+tsconfig.tsbuildinfo
+
+# generated documentation
+docs/mcp
+site/
diff --git a/standards/src/templates/client/.npmrc b/standards/src/templates/client/.npmrc
new file mode 100644
index 0000000..a412fd6
--- /dev/null
+++ b/standards/src/templates/client/.npmrc
@@ -0,0 +1 @@
+registry=https://npm.aetoskia.com
diff --git a/standards/src/templates/client/CHANGELOG.md b/standards/src/templates/client/CHANGELOG.md
new file mode 100644
index 0000000..3973a5a
--- /dev/null
+++ b/standards/src/templates/client/CHANGELOG.md
@@ -0,0 +1,15 @@
+# Changelog
+
+All notable changes to this project are documented here.
+
+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
+and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+
+
+
+## [Unreleased]
diff --git a/standards/src/templates/client/index.html b/standards/src/templates/client/index.html
new file mode 100644
index 0000000..51be3f3
--- /dev/null
+++ b/standards/src/templates/client/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ App Title
+
+
+
+
+
+
diff --git a/standards/src/templates/client/package.json b/standards/src/templates/client/package.json
new file mode 100644
index 0000000..02fee5a
--- /dev/null
+++ b/standards/src/templates/client/package.json
@@ -0,0 +1,34 @@
+{
+ "name": "client-template",
+ "private": true,
+ "version": "0.1.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc -b && vite build",
+ "preview": "vite preview",
+ "quality-gate": "tsc --noEmit"
+ },
+ "dependencies": {
+ "@aetoskia/react-auth": "^0.1.0",
+ "@aetoskia/react-openapi": "^0.1.0",
+ "@emotion/react": "^11.13.3",
+ "@emotion/styled": "^11.13.0",
+ "@mui/icons-material": "^5.16.7",
+ "@mui/material": "^5.16.7",
+ "axios": "^1.7.7",
+ "js-yaml": "^4.1.0",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1",
+ "react-router-dom": "^6.28.0"
+ },
+ "devDependencies": {
+ "@types/js-yaml": "^4.0.9",
+ "@types/node": "^25.9.3",
+ "@types/react": "^18.3.12",
+ "@types/react-dom": "^18.3.1",
+ "@vitejs/plugin-react": "^4.3.4",
+ "typescript": "~5.6.2",
+ "vite": "^6.0.1"
+ }
+}
diff --git a/standards/src/templates/client/src/Footer.tsx b/standards/src/templates/client/src/Footer.tsx
new file mode 100644
index 0000000..564c0b5
--- /dev/null
+++ b/standards/src/templates/client/src/Footer.tsx
@@ -0,0 +1,66 @@
+/**
+ * Site footer.
+ *
+ * A fixed bottom `AppBar` (pinned above the drawer via
+ * `zIndex.drawer + 1`) with a blurred translucent background and a top
+ * divider, rendering the `Copyright` line. Rendered once by `main.jsx` below
+ * the routed content.
+ *
+ * @packageDocumentation
+ */
+
+import * as React from 'react';
+import Container from '@mui/material/Container';
+import Link from '@mui/material/Link';
+import Typography from '@mui/material/Typography';
+import { AppBar, Toolbar } from "@mui/material";
+
+/** Copyright line linking to aetoskia.com with the current year. */
+function Copyright() {
+ return (
+
+
+ {'Copyright © Aetoskia Internal Infrastructure — All rights reserved.'}
+
+
+ {new Date().getFullYear()}
+
+ );
+}
+
+/**
+ * Fixed bottom app bar showing the site copyright.
+ *
+ * @returns The footer `AppBar` element.
+ */
+export default function Footer() {
+ return (
+ theme.zIndex.drawer + 1,
+ backdropFilter: 'blur(8px)',
+ boxShadow: 'none',
+ borderTop: '1px solid',
+ borderColor: 'divider',
+ }}
+ >
+
+
+
+
+
+
+ );
+}
diff --git a/standards/src/templates/client/src/Header.tsx b/standards/src/templates/client/src/Header.tsx
new file mode 100644
index 0000000..81e9e79
--- /dev/null
+++ b/standards/src/templates/client/src/Header.tsx
@@ -0,0 +1,83 @@
+/**
+ * Top navigation bar.
+ *
+ * A fixed `AppBar` with blurred translucent background and bottom divider,
+ * showing the app title/logo, the signed-in user (or a login button), and
+ * an Admin button. Rendered once by `main.jsx` above the routed content;
+ * the title navigates to `/` and the Admin button to `/admin`.
+ *
+ * @packageDocumentation
+ */
+
+import * as React from "react";
+import { AppBar, Toolbar, Typography, Button, Chip, IconButton, Tooltip } from "@mui/material";
+import { useNavigate } from "react-router-dom";
+import DashboardIcon from "@mui/icons-material/Dashboard";
+import LogoutIcon from "@mui/icons-material/Logout";
+import { useAuth } from "@aetoskia/react-auth";
+
+/**
+ * Fixed top app bar with the title/logo, current user, and an Admin button.
+ *
+ * Uses `useNavigate` and `useAuth`, so it must be rendered inside both a
+ * router and an `AuthProvider`. Clicking the title navigates to `/`; the
+ * Admin button navigates to `/admin`; the logout icon clears the session.
+ *
+ * @returns The header `AppBar` element.
+ */
+export default function Header() {
+ const navigate = useNavigate();
+ const { currentUser, logout } = useAuth();
+
+ return (
+ theme.zIndex.drawer + 1,
+ backdropFilter: "blur(8px)",
+ boxShadow: "none",
+ borderBottom: "1px solid",
+ borderColor: "divider",
+ }}
+ >
+
+
+ navigate("/")}
+ >
+ App Title
+
+
+
+
+ {currentUser && (
+
+ )}
+
+
+
+ {currentUser && (
+
+ logout()} aria-label="Log out">
+
+
+
+ )}
+
+
+ );
+}
diff --git a/standards/src/templates/client/src/Home.tsx b/standards/src/templates/client/src/Home.tsx
new file mode 100644
index 0000000..09430b6
--- /dev/null
+++ b/standards/src/templates/client/src/Home.tsx
@@ -0,0 +1,214 @@
+/**
+ * Landing page: a grid of resource cards and a live preview table.
+ *
+ * Exercises the `@aetoskia/react-openapi` building blocks end-to-end:
+ * - `ResourceCard` - a grid of every top-level resource with a live record
+ * count (or SSE connection status for streaming resources).
+ * - `LiveTable` - preview of the first 5 records of the first non-streaming
+ * resource.
+ *
+ * Add custom pages as sibling routes in `main.jsx`; the auto-generated
+ * admin lives at `/admin/*` regardless.
+ *
+ * @packageDocumentation
+ */
+
+import React, { useEffect, useMemo, useState } from "react";
+import {
+ Box, Container, Typography, Paper, Grid, Chip,
+ Table, TableBody, TableCell, TableContainer, TableHead,
+ TableRow, CircularProgress,
+} from "@mui/material";
+
+import { useNavigate } from "react-router-dom";
+import { useAppContext, useResource, ListCellRenderer, SseConnectionStatus, applyDisplayFormat } from "@aetoskia/react-openapi";
+import type { ResourceConfig, FieldConfig } from "@aetoskia/react-openapi";
+
+/**
+ * Clickable card summarizing one resource.
+ *
+ * Shows the display name, field count and either a record-count chip (loaded
+ * via `useResource().list({ limit: 0 })`) or the SSE connection status for
+ * streaming resources; clicking navigates to `/admin/`.
+ *
+ * Props:
+ * - `resource` - the `ResourceConfig` to summarize.
+ */
+function ResourceCard({ resource }: { resource: ResourceConfig }) {
+ const navigate = useNavigate();
+ const { list } = useResource(resource.name);
+ const [count, setCount] = useState(null);
+
+ useEffect(() => {
+ if (resource.streaming) return;
+ list({ limit: 0 }).then((res) => setCount(res.total ?? res.items?.length ?? 0));
+ }, [resource.name]);
+
+ return (
+ navigate(`/admin/${resource.name}`)}
+ sx={{
+ p: 3,
+ borderRadius: 2,
+ border: "1px solid",
+ borderColor: "divider",
+ cursor: "pointer",
+ transition: "all 0.2s",
+ "&:hover": { borderColor: "primary.main", boxShadow: 1 },
+ }}
+ >
+
+ {resource.displayName}
+
+
+ {resource.fields.length} fields
+
+
+ {resource.streaming ? (
+
+ ) : (
+ 0 ? "primary" : "default"}
+ variant="outlined"
+ />
+ )}
+
+
+
+ );
+}
+
+/**
+ * Preview table of the first 5 records of a resource.
+ *
+ * Columns come from `resource.listColumns`; each cell renders through
+ * `ListCellRenderer` using the resource display format (or the FK target's
+ * format for foreign-key columns). Shows a spinner while loading and a
+ * "No records found." message when empty.
+ *
+ * Props:
+ * - `resource` - the `ResourceConfig` to preview.
+ */
+function LiveTable({ resource }: { resource: ResourceConfig }) {
+ const { list } = useResource(resource.name);
+ const { resources: allResources } = useAppContext();
+ const [rows, setRows] = useState(null);
+ const columns = resource.listColumns
+ .map((n) => resource.fields.find((f) => f.name === n))
+ .filter(Boolean) as FieldConfig[];
+
+ useEffect(() => {
+ list({ limit: 5 }).then((res) => setRows(res.items ?? []));
+ }, [resource.name]);
+
+ if (!rows) {
+ return (
+
+
+
+ );
+ }
+
+ if (rows.length === 0) {
+ return No records found.;
+ }
+
+ return (
+
+
+
+
+ {columns.map((col) => (
+
+ {col.label}
+
+ ))}
+
+
+
+ {rows.map((row, i) => (
+
+ {columns.map((col) => {
+ const displayFormat = col.fk
+ ? (allResources.find((r) => r.name === col.fk!.resource)?.displayFormat ?? resource.displayFormat)
+ : resource.displayFormat;
+ return (
+
+
+
+ );
+ })}
+
+ ))}
+
+
+
+ );
+}
+
+/**
+ * Render the landing page: resource grid and live preview.
+ *
+ * @returns The page container, or a "no resources" notice when the spec
+ * exposes none.
+ */
+export default function Home() {
+ const { resources } = useAppContext();
+ const topLevel = useMemo(() => resources.filter((r) => !r.parent), [resources]);
+ const previewResource = useMemo(() => topLevel.find((r) => !r.streaming) ?? topLevel[0], [topLevel]);
+
+ if (resources.length === 0) {
+ return (
+
+
+ No resources found in the OpenAPI spec.
+
+
+ );
+ }
+
+ return (
+
+
+
+ App Title
+
+
+ Auto-generated CRUD from an OpenAPI 3.1 spec using{" "}
+
+ @aetoskia/react-openapi
+
+ .
+
+
+
+
+ Resources
+
+
+ {topLevel.map((r) => (
+
+
+
+ ))}
+
+
+
+ Live Preview
+ {previewResource && (
+
+ — first 5 records from {previewResource.displayName}
+
+ )}
+
+ {previewResource && }
+
+ );
+}
diff --git a/standards/src/templates/client/src/main.jsx b/standards/src/templates/client/src/main.jsx
new file mode 100644
index 0000000..d0a3229
--- /dev/null
+++ b/standards/src/templates/client/src/main.jsx
@@ -0,0 +1,94 @@
+/**
+ * Application entry point.
+ *
+ * Creates the React root from `#root` and mounts the app: `AuthProvider`
+ * (JWT session against `VITE_AUTH_BASE_URL`) wraps a `LoginGate` (renders
+ * `AuthPage` until authenticated) which wraps `AppProvider` (fed by
+ * `specConfiguration`) → `BrowserRouter` → `AppTheme` → `Header` → routed
+ * content (`Home` at `/`, `Admin` at `/admin/*`) → `Footer`.
+ *
+ * @packageDocumentation
+ */
+
+import * as React from 'react';
+import { createRoot } from 'react-dom/client';
+import { BrowserRouter, Routes, Route } from "react-router-dom";
+import { Box, CssBaseline, Toolbar } from "@mui/material";
+import { AuthProvider, AuthPage, useAuth } from "@aetoskia/react-auth";
+import Home from './Home';
+import { Admin, AppProvider } from '@aetoskia/react-openapi';
+import { specConfiguration } from './openapi-config';
+import Header from './Header';
+import Footer from './Footer';
+import AppTheme from './shared-theme/AppTheme';
+
+const authServer = import.meta.env.VITE_AUTH_BASE_URL;
+
+const authConfig = {
+ serverUrl: authServer,
+ loginPath: "/login",
+ registerPath: "/register",
+ logoutPath: "/logout",
+ mePath: "/me",
+ introspectPath: "/introspect",
+};
+
+/**
+ * Route guard: renders the split-screen `AuthPage` until a user is
+ * authenticated, then renders the full application shell.
+ *
+ * @returns The login gate or the authenticated app.
+ */
+function LoginGate() {
+ const auth = useAuth();
+ const [mode, setMode] = React.useState("login");
+
+ if (!auth.currentUser) {
+ return (
+
+
+ {}}
+ onSwitchMode={() => setMode((m) => (m === "login" ? "register" : "login"))}
+ login={auth.login}
+ register={auth.register}
+ loading={auth.loading}
+ error={auth.error}
+ currentUser={auth.currentUser}
+ />
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ } />
+ } />
+
+
+
+
+
+
+
+ );
+}
+
+const rootElement = document.getElementById('root');
+const root = createRoot(rootElement);
+
+root.render(
+
+
+
+);
diff --git a/standards/src/templates/client/src/openapi-config.ts b/standards/src/templates/client/src/openapi-config.ts
new file mode 100644
index 0000000..240e34b
--- /dev/null
+++ b/standards/src/templates/client/src/openapi-config.ts
@@ -0,0 +1,29 @@
+/**
+ * Runtime OpenAPI configuration.
+ *
+ * Reads the API base URL from `import.meta.env.VITE_API_BASE_URL` and points
+ * `@aetoskia/react-openapi` at `${base}/openapi.json`, sets the app title,
+ * and wires the auth token from `@aetoskia/react-auth` into every API call.
+ *
+ * @packageDocumentation
+ */
+
+import type { SpecConfiguration } from "@aetoskia/react-openapi";
+import { tokenStore } from "@aetoskia/react-auth";
+
+const apiBase = import.meta.env.VITE_API_BASE_URL;
+
+/**
+ * The `SpecConfiguration` passed to `AppProvider`.
+ *
+ * - `specUrl` / `baseApiUrl` - derived from `VITE_API_BASE_URL`.
+ * - `title` - replace "App Title" with your application's name.
+ * - `getToken` - supplies the JWT from `@aetoskia/react-auth` to every
+ * request made through `getApi()` / `useResource()`.
+ */
+export const specConfiguration: SpecConfiguration = {
+ specUrl: `${apiBase}/openapi.json`,
+ baseApiUrl: apiBase,
+ title: "App Title",
+ getToken: () => tokenStore.get(),
+};
diff --git a/standards/src/templates/client/src/shared-theme/AppTheme.tsx b/standards/src/templates/client/src/shared-theme/AppTheme.tsx
new file mode 100644
index 0000000..27ee7c5
--- /dev/null
+++ b/standards/src/templates/client/src/shared-theme/AppTheme.tsx
@@ -0,0 +1,145 @@
+/**
+ * App theme provider and color-mode context.
+ *
+ * Wraps the MUI `ThemeProvider` with the design tokens (`getDesignTokens`),
+ * semantic colors (`getSemanticColors`) and component customizations
+ * (`inputsCustomizations`, `dataDisplayCustomizations`,
+ * `feedbackCustomizations`, `navigationCustomizations`,
+ * `surfacesCustomizations`). Exposes a light/dark `ColorModeContext` and
+ * publishes the semantic colors as CSS custom properties (`--bg-page`,
+ * `--bg-card`, `--text-primary`, ...) for the app subtree.
+ *
+ * @packageDocumentation
+ */
+
+import * as React from "react";
+import {
+ ThemeProvider,
+ createTheme,
+ CssBaseline,
+ Box,
+} from "@mui/material";
+
+import { getDesignTokens } from "./themePrimitives";
+import { getSemanticColors } from "./themeConfig";
+
+import { inputsCustomizations } from "./customizations/inputs";
+import { dataDisplayCustomizations } from "./customizations/dataDisplay";
+import { feedbackCustomizations } from "./customizations/feedback";
+import { navigationCustomizations } from "./customizations/navigation";
+import { surfacesCustomizations } from "./customizations/surfaces";
+
+/** Color mode of the app theme. */
+export type ColorMode = "light" | "dark";
+
+/** Shape of the color-mode context value. */
+type ColorModeContextValue = {
+ /** The current color mode. */
+ mode: ColorMode;
+ /** Set the color mode explicitly. */
+ setMode: (mode: ColorMode) => void;
+ /** Toggle between light and dark. */
+ toggleColorMode: () => void;
+};
+
+/** Context giving the current color mode and its setters. */
+export const ColorModeContext =
+ React.createContext({
+ mode: "light",
+ setMode: () => {},
+ toggleColorMode: () => {},
+ });
+
+/** Props for `AppTheme`. */
+type AppThemeProps = {
+ /** Subtree that receives the theme and color-mode context. */
+ children: React.ReactNode;
+ /** Initial color mode (defaults to `"light"`). */
+ defaultMode?: ColorMode;
+};
+
+/**
+ * Provide the MUI theme, color-mode context and CSS variables to `children`.
+ *
+ * Builds the theme from `getDesignTokens(mode)` + `getSemanticColors(mode)` +
+ * the component customizations, and re-creates it whenever the mode changes.
+ * Also sets `--bg-page`, `--bg-card`, `--bg-elevated`, `--border-default`,
+ * `--border-subtle`, `--text-primary`, `--text-secondary` and `--text-muted`
+ * on a wrapper `Box`.
+ *
+ * Props: `children` and `defaultMode` (see `AppThemeProps`).
+ *
+ * @example
+ * ```tsx
+ *
+ *
+ *
+ *
+ * ```
+ */
+export default function AppTheme({
+ children,
+ defaultMode = "light",
+}: AppThemeProps) {
+ const [mode, setMode] =
+ React.useState(defaultMode);
+
+ const toggleColorMode = React.useCallback(() => {
+ setMode((prev) =>
+ prev === "light" ? "dark" : "light"
+ );
+ }, []);
+
+ const contextValue = React.useMemo(
+ () => ({
+ mode,
+ setMode,
+ toggleColorMode,
+ }),
+ [mode, toggleColorMode]
+ );
+
+ const semantic = React.useMemo(
+ () => getSemanticColors(mode),
+ [mode]
+ );
+
+ const theme = React.useMemo(
+ () =>
+ createTheme({
+ ...getDesignTokens(mode),
+ semantic,
+
+ components: {
+ ...inputsCustomizations,
+ ...dataDisplayCustomizations,
+ ...feedbackCustomizations,
+ ...navigationCustomizations,
+ ...surfacesCustomizations,
+ },
+ }),
+ [mode, semantic]
+ );
+
+ return (
+
+
+
+
+ {children}
+
+
+
+ );
+}
diff --git a/standards/src/templates/client/src/shared-theme/customizations/dataDisplay.tsx b/standards/src/templates/client/src/shared-theme/customizations/dataDisplay.tsx
new file mode 100644
index 0000000..e300922
--- /dev/null
+++ b/standards/src/templates/client/src/shared-theme/customizations/dataDisplay.tsx
@@ -0,0 +1,243 @@
+/**
+ * MUI style overrides for data-display components.
+ *
+ * Exports `dataDisplayCustomizations`, a `Components` map applied by
+ * `AppTheme`, covering lists, chips, table pagination and icons.
+ *
+ * @packageDocumentation
+ */
+
+import { Theme, alpha, Components } from '@mui/material/styles';
+import { svgIconClasses } from '@mui/material/SvgIcon';
+import { typographyClasses } from '@mui/material/Typography';
+import { buttonBaseClasses } from '@mui/material/ButtonBase';
+import { chipClasses } from '@mui/material/Chip';
+import { iconButtonClasses } from '@mui/material/IconButton';
+import { gray, red, green } from '../themePrimitives';
+
+/* eslint-disable import/prefer-default-export */
+/** Component overrides for lists, chips, table pagination and icons. */
+export const dataDisplayCustomizations: Components = {
+ MuiList: {
+ styleOverrides: {
+ root: {
+ padding: '8px',
+ display: 'flex',
+ flexDirection: 'column',
+ gap: 0,
+ },
+ },
+ },
+ MuiListItem: {
+ styleOverrides: {
+ root: ({ theme }) => ({
+ [`& .${svgIconClasses.root}`]: {
+ width: '1rem',
+ height: '1rem',
+ color: (theme.vars || theme).palette.text.secondary,
+ },
+ [`& .${typographyClasses.root}`]: {
+ fontWeight: 500,
+ },
+ [`& .${buttonBaseClasses.root}`]: {
+ display: 'flex',
+ gap: 8,
+ padding: '2px 8px',
+ borderRadius: (theme.vars || theme).shape.borderRadius,
+ opacity: 0.7,
+ '&.Mui-selected': {
+ opacity: 1,
+ backgroundColor: alpha(theme.palette.action.selected, 0.3),
+ [`& .${svgIconClasses.root}`]: {
+ color: (theme.vars || theme).palette.text.primary,
+ },
+ '&:focus-visible': {
+ backgroundColor: alpha(theme.palette.action.selected, 0.3),
+ },
+ '&:hover': {
+ backgroundColor: alpha(theme.palette.action.selected, 0.5),
+ },
+ },
+ '&:focus-visible': {
+ backgroundColor: 'transparent',
+ },
+ },
+ }),
+ },
+ },
+ MuiListItemText: {
+ styleOverrides: {
+ primary: ({ theme }) => ({
+ fontSize: theme.typography.body2.fontSize,
+ fontWeight: 500,
+ lineHeight: theme.typography.body2.lineHeight,
+ }),
+ secondary: ({ theme }) => ({
+ fontSize: theme.typography.caption.fontSize,
+ lineHeight: theme.typography.caption.lineHeight,
+ }),
+ },
+ },
+ MuiListSubheader: {
+ styleOverrides: {
+ root: ({ theme }) => ({
+ backgroundColor: 'transparent',
+ padding: '4px 8px',
+ fontSize: theme.typography.caption.fontSize,
+ fontWeight: 500,
+ lineHeight: theme.typography.caption.lineHeight,
+ }),
+ },
+ },
+ MuiListItemIcon: {
+ styleOverrides: {
+ root: {
+ minWidth: 0,
+ },
+ },
+ },
+ MuiChip: {
+ defaultProps: {
+ size: 'small',
+ },
+ styleOverrides: {
+ root: ({ theme }) => ({
+ border: '1px solid',
+ borderRadius: '999px',
+ [`& .${chipClasses.label}`]: {
+ fontWeight: 600,
+ },
+ variants: [
+ {
+ props: {
+ color: 'default',
+ },
+ style: {
+ borderColor: gray[200],
+ backgroundColor: gray[100],
+ [`& .${chipClasses.label}`]: {
+ color: gray[500],
+ },
+ [`& .${chipClasses.icon}`]: {
+ color: gray[500],
+ },
+ ...theme.applyStyles('dark', {
+ borderColor: gray[700],
+ backgroundColor: gray[800],
+ [`& .${chipClasses.label}`]: {
+ color: gray[300],
+ },
+ [`& .${chipClasses.icon}`]: {
+ color: gray[300],
+ },
+ }),
+ },
+ },
+ {
+ props: {
+ color: 'success',
+ },
+ style: {
+ borderColor: green[200],
+ backgroundColor: green[50],
+ [`& .${chipClasses.label}`]: {
+ color: green[500],
+ },
+ [`& .${chipClasses.icon}`]: {
+ color: green[500],
+ },
+ ...theme.applyStyles('dark', {
+ borderColor: green[800],
+ backgroundColor: green[900],
+ [`& .${chipClasses.label}`]: {
+ color: green[300],
+ },
+ [`& .${chipClasses.icon}`]: {
+ color: green[300],
+ },
+ }),
+ },
+ },
+ {
+ props: {
+ color: 'error',
+ },
+ style: {
+ borderColor: red[100],
+ backgroundColor: red[50],
+ [`& .${chipClasses.label}`]: {
+ color: red[500],
+ },
+ [`& .${chipClasses.icon}`]: {
+ color: red[500],
+ },
+ ...theme.applyStyles('dark', {
+ borderColor: red[800],
+ backgroundColor: red[900],
+ [`& .${chipClasses.label}`]: {
+ color: red[200],
+ },
+ [`& .${chipClasses.icon}`]: {
+ color: red[300],
+ },
+ }),
+ },
+ },
+ {
+ props: { size: 'small' },
+ style: {
+ maxHeight: 20,
+ [`& .${chipClasses.label}`]: {
+ fontSize: theme.typography.caption.fontSize,
+ },
+ [`& .${svgIconClasses.root}`]: {
+ fontSize: theme.typography.caption.fontSize,
+ },
+ },
+ },
+ {
+ props: { size: 'medium' },
+ style: {
+ [`& .${chipClasses.label}`]: {
+ fontSize: theme.typography.caption.fontSize,
+ },
+ },
+ },
+ ],
+ }),
+ },
+ },
+ MuiTablePagination: {
+ styleOverrides: {
+ actions: {
+ display: 'flex',
+ gap: 8,
+ marginRight: 6,
+ [`& .${iconButtonClasses.root}`]: {
+ minWidth: 0,
+ width: 36,
+ height: 36,
+ },
+ },
+ },
+ },
+ MuiIcon: {
+ defaultProps: {
+ fontSize: 'small',
+ },
+ styleOverrides: {
+ root: {
+ variants: [
+ {
+ props: {
+ fontSize: 'small',
+ },
+ style: {
+ fontSize: '1rem',
+ },
+ },
+ ],
+ },
+ },
+ },
+};
diff --git a/standards/src/templates/client/src/shared-theme/customizations/feedback.tsx b/standards/src/templates/client/src/shared-theme/customizations/feedback.tsx
new file mode 100644
index 0000000..48c4401
--- /dev/null
+++ b/standards/src/templates/client/src/shared-theme/customizations/feedback.tsx
@@ -0,0 +1,56 @@
+/**
+ * MUI style overrides for feedback components.
+ *
+ * Exports `feedbackCustomizations`, a `Components` map applied by
+ * `AppTheme`, covering alerts, dialogs and linear progress.
+ *
+ * @packageDocumentation
+ */
+
+import { Theme, alpha, Components } from '@mui/material/styles';
+import { gray, orange } from '../themePrimitives';
+
+/* eslint-disable import/prefer-default-export */
+/** Component overrides for alerts, dialogs and linear progress. */
+export const feedbackCustomizations: Components = {
+ MuiAlert: {
+ styleOverrides: {
+ root: ({ theme }) => ({
+ borderRadius: 10,
+ backgroundColor: orange[100],
+ color: (theme.vars || theme).palette.text.primary,
+ border: `1px solid ${alpha(orange[300], 0.5)}`,
+ '& .MuiAlert-icon': {
+ color: orange[500],
+ },
+ ...theme.applyStyles('dark', {
+ backgroundColor: alpha(orange[900], 0.35),
+ border: `1px solid ${alpha(orange[800], 0.3)}`,
+ }),
+ }),
+ },
+ },
+ MuiDialog: {
+ styleOverrides: {
+ root: ({ theme }) => ({
+ '& .MuiDialog-paper': {
+ borderRadius: '10px',
+ border: '1px solid',
+ borderColor: (theme.vars || theme).palette.divider,
+ },
+ }),
+ },
+ },
+ MuiLinearProgress: {
+ styleOverrides: {
+ root: ({ theme }) => ({
+ height: 8,
+ borderRadius: 8,
+ backgroundColor: gray[200],
+ ...theme.applyStyles('dark', {
+ backgroundColor: gray[800],
+ }),
+ }),
+ },
+ },
+};
diff --git a/standards/src/templates/client/src/shared-theme/customizations/inputs.tsx b/standards/src/templates/client/src/shared-theme/customizations/inputs.tsx
new file mode 100644
index 0000000..757f4e9
--- /dev/null
+++ b/standards/src/templates/client/src/shared-theme/customizations/inputs.tsx
@@ -0,0 +1,456 @@
+/**
+ * MUI style overrides for form inputs and buttons.
+ *
+ * Exports `inputsCustomizations`, a `Components` map applied by
+ * `AppTheme`, covering buttons, icon buttons, toggles, checkbox, outlined
+ * inputs and form labels with light/dark variants.
+ *
+ * @packageDocumentation
+ */
+
+import * as React from 'react';
+import { alpha, Theme, Components } from '@mui/material/styles';
+import { outlinedInputClasses } from '@mui/material/OutlinedInput';
+import { svgIconClasses } from '@mui/material/SvgIcon';
+import { toggleButtonGroupClasses } from '@mui/material/ToggleButtonGroup';
+import { toggleButtonClasses } from '@mui/material/ToggleButton';
+import CheckBoxOutlineBlankRoundedIcon from '@mui/icons-material/CheckBoxOutlineBlankRounded';
+import CheckRoundedIcon from '@mui/icons-material/CheckRounded';
+import RemoveRoundedIcon from '@mui/icons-material/RemoveRounded';
+import { gray, brand } from '../themePrimitives';
+
+/* eslint-disable import/prefer-default-export */
+/** Component overrides for buttons, toggles, inputs and labels. */
+export const inputsCustomizations: Components = {
+ MuiButtonBase: {
+ defaultProps: {
+ disableTouchRipple: true,
+ disableRipple: true,
+ },
+ styleOverrides: {
+ root: ({ theme }) => ({
+ boxSizing: 'border-box',
+ transition: 'all 100ms ease-in',
+ '&:focus-visible': {
+ outline: `3px solid ${alpha(theme.palette.primary.main, 0.5)}`,
+ outlineOffset: '2px',
+ },
+ }),
+ },
+ },
+ MuiButton: {
+ styleOverrides: {
+ root: ({ theme }) => ({
+ boxShadow: 'none',
+ borderRadius: (theme.vars || theme).shape.borderRadius,
+ textTransform: 'none',
+ variants: [
+ {
+ props: {
+ size: 'small',
+ },
+ style: {
+ height: '2.25rem',
+ padding: '8px 12px',
+ },
+ },
+ {
+ props: {
+ size: 'medium',
+ },
+ style: {
+ height: '2.5rem', // 40px
+ },
+ },
+ {
+ props: {
+ color: 'primary',
+ variant: 'contained',
+ },
+ style: {
+ color: 'white',
+ backgroundColor: gray[900],
+ backgroundImage: `linear-gradient(to bottom, ${gray[700]}, ${gray[800]})`,
+ boxShadow: `inset 0 1px 0 ${gray[600]}, inset 0 -1px 0 1px hsl(220, 0%, 0%)`,
+ border: `1px solid ${gray[700]}`,
+ '&:hover': {
+ backgroundImage: 'none',
+ backgroundColor: gray[700],
+ boxShadow: 'none',
+ },
+ '&:active': {
+ backgroundColor: gray[800],
+ },
+ ...theme.applyStyles('dark', {
+ color: 'black',
+ backgroundColor: gray[50],
+ backgroundImage: `linear-gradient(to bottom, ${gray[100]}, ${gray[50]})`,
+ boxShadow: 'inset 0 -1px 0 hsl(220, 30%, 80%)',
+ border: `1px solid ${gray[50]}`,
+ '&:hover': {
+ backgroundImage: 'none',
+ backgroundColor: gray[300],
+ boxShadow: 'none',
+ },
+ '&:active': {
+ backgroundColor: gray[400],
+ },
+ }),
+ },
+ },
+ {
+ props: {
+ color: 'secondary',
+ variant: 'contained',
+ },
+ style: {
+ color: 'white',
+ backgroundColor: brand[300],
+ backgroundImage: `linear-gradient(to bottom, ${alpha(brand[400], 0.8)}, ${brand[500]})`,
+ boxShadow: `inset 0 2px 0 ${alpha(brand[200], 0.2)}, inset 0 -2px 0 ${alpha(brand[700], 0.4)}`,
+ border: `1px solid ${brand[500]}`,
+ '&:hover': {
+ backgroundColor: brand[700],
+ boxShadow: 'none',
+ },
+ '&:active': {
+ backgroundColor: brand[700],
+ backgroundImage: 'none',
+ },
+ },
+ },
+ {
+ props: {
+ variant: 'outlined',
+ },
+ style: {
+ color: (theme.vars || theme).palette.text.primary,
+ border: '1px solid',
+ borderColor: gray[200],
+ backgroundColor: alpha(gray[50], 0.3),
+ '&:hover': {
+ backgroundColor: gray[100],
+ borderColor: gray[300],
+ },
+ '&:active': {
+ backgroundColor: gray[200],
+ },
+ ...theme.applyStyles('dark', {
+ backgroundColor: 'hsla(0, 0%, 100%, 0.06)',
+ borderColor: (theme.vars || theme).palette.divider,
+
+ '&:hover': {
+ backgroundColor: 'hsla(0, 0%, 100%, 0.1)',
+ borderColor: 'hsla(0, 0%, 100%, 0.15)',
+ },
+ '&:active': {
+ backgroundColor: 'hsla(0, 0%, 100%, 0.1)',
+ },
+ }),
+ },
+ },
+ {
+ props: {
+ color: 'secondary',
+ variant: 'outlined',
+ },
+ style: {
+ color: brand[700],
+ border: '1px solid',
+ borderColor: brand[200],
+ backgroundColor: brand[50],
+ '&:hover': {
+ backgroundColor: brand[100],
+ borderColor: brand[400],
+ },
+ '&:active': {
+ backgroundColor: alpha(brand[200], 0.7),
+ },
+ ...theme.applyStyles('dark', {
+ color: brand[50],
+ border: '1px solid',
+ borderColor: brand[900],
+ backgroundColor: alpha(brand[900], 0.3),
+ '&:hover': {
+ borderColor: brand[700],
+ backgroundColor: alpha(brand[900], 0.6),
+ },
+ '&:active': {
+ backgroundColor: alpha(brand[900], 0.5),
+ },
+ }),
+ },
+ },
+ {
+ props: {
+ variant: 'text',
+ },
+ style: {
+ color: gray[600],
+ '&:hover': {
+ backgroundColor: gray[100],
+ },
+ '&:active': {
+ backgroundColor: gray[200],
+ },
+ ...theme.applyStyles('dark', {
+ color: 'hsl(0, 0%, 92%)',
+ '&:hover': {
+ backgroundColor: 'hsla(0, 0%, 100%, 0.08)',
+ },
+ '&:active': {
+ backgroundColor: 'hsla(0, 0%, 100%, 0.12)',
+ },
+ }),
+ },
+ },
+ {
+ props: {
+ color: 'secondary',
+ variant: 'text',
+ },
+ style: {
+ color: brand[700],
+ '&:hover': {
+ backgroundColor: alpha(brand[100], 0.5),
+ },
+ '&:active': {
+ backgroundColor: alpha(brand[200], 0.7),
+ },
+ ...theme.applyStyles('dark', {
+ color: brand[100],
+ '&:hover': {
+ backgroundColor: alpha(brand[900], 0.5),
+ },
+ '&:active': {
+ backgroundColor: alpha(brand[900], 0.3),
+ },
+ }),
+ },
+ },
+ ],
+ }),
+ },
+ },
+ MuiIconButton: {
+ styleOverrides: {
+ root: ({ theme }) => ({
+ boxShadow: 'none',
+ borderRadius: (theme.vars || theme).shape.borderRadius,
+ textTransform: 'none',
+ fontWeight: theme.typography.fontWeightMedium,
+ letterSpacing: 0,
+ color: (theme.vars || theme).palette.text.primary,
+ border: '1px solid ',
+ borderColor: gray[200],
+ backgroundColor: alpha(gray[50], 0.3),
+ '&:hover': {
+ backgroundColor: gray[100],
+ borderColor: gray[300],
+ },
+ '&:active': {
+ backgroundColor: gray[200],
+ },
+ ...theme.applyStyles('dark', {
+ backgroundColor: 'hsla(0, 0%, 100%, 0.06)',
+ borderColor: (theme.vars || theme).palette.divider,
+ '&:hover': {
+ backgroundColor: 'hsla(0, 0%, 100%, 0.1)',
+ borderColor: 'hsla(0, 0%, 100%, 0.15)',
+ },
+ '&:active': {
+ backgroundColor: 'hsla(0, 0%, 100%, 0.1)',
+ },
+ }),
+ variants: [
+ {
+ props: {
+ size: 'small',
+ },
+ style: {
+ width: '2.25rem',
+ height: '2.25rem',
+ padding: '0.25rem',
+ [`& .${svgIconClasses.root}`]: { fontSize: '1rem' },
+ },
+ },
+ {
+ props: {
+ size: 'medium',
+ },
+ style: {
+ width: '2.5rem',
+ height: '2.5rem',
+ },
+ },
+ ],
+ }),
+ },
+ },
+ MuiToggleButtonGroup: {
+ styleOverrides: {
+ root: ({ theme }) => ({
+ borderRadius: '10px',
+ boxShadow: `0 4px 16px ${alpha(gray[400], 0.2)}`,
+ [`& .${toggleButtonGroupClasses.selected}`]: {
+ color: brand[500],
+ },
+ ...theme.applyStyles('dark', {
+ [`& .${toggleButtonGroupClasses.selected}`]: {
+ color: '#fff',
+ },
+ boxShadow: `0 2px 8px ${alpha(brand[700], 0.3)}`,
+ }),
+ }),
+ },
+ },
+ MuiToggleButton: {
+ styleOverrides: {
+ root: ({ theme }) => ({
+ padding: '12px 16px',
+ textTransform: 'none',
+ borderRadius: '10px',
+ fontWeight: 500,
+ ...theme.applyStyles('dark', {
+ color: gray[400],
+ boxShadow: '0 2px 8px rgba(0, 0, 0, 0.25)',
+ [`&.${toggleButtonClasses.selected}`]: {
+ color: brand[300],
+ },
+ }),
+ }),
+ },
+ },
+ MuiCheckbox: {
+ defaultProps: {
+ disableRipple: true,
+ icon: (
+
+ ),
+ checkedIcon: ,
+ indeterminateIcon: ,
+ },
+ styleOverrides: {
+ root: ({ theme }) => ({
+ margin: 10,
+ height: 16,
+ width: 16,
+ borderRadius: 5,
+ border: '1px solid ',
+ borderColor: alpha(gray[300], 0.8),
+ boxShadow: '0 0 0 1.5px hsla(210, 0%, 0%, 0.04) inset',
+ backgroundColor: alpha(gray[100], 0.4),
+ transition: 'border-color, background-color, 120ms ease-in',
+ '&:hover': {
+ borderColor: brand[300],
+ },
+ '&.Mui-focusVisible': {
+ outline: `3px solid ${alpha(brand[500], 0.5)}`,
+ outlineOffset: '2px',
+ borderColor: brand[400],
+ },
+ '&.Mui-checked': {
+ color: 'white',
+ backgroundColor: brand[500],
+ borderColor: brand[500],
+ boxShadow: `none`,
+ '&:hover': {
+ backgroundColor: brand[600],
+ },
+ },
+ ...theme.applyStyles('dark', {
+ borderColor: alpha(gray[700], 0.8),
+ boxShadow: '0 0 0 1.5px hsl(210, 0%, 0%) inset',
+ backgroundColor: alpha(gray[900], 0.8),
+ '&:hover': {
+ borderColor: brand[300],
+ },
+ '&.Mui-focusVisible': {
+ borderColor: brand[400],
+ outline: `3px solid ${alpha(brand[500], 0.5)}`,
+ outlineOffset: '2px',
+ },
+ }),
+ }),
+ },
+ },
+ MuiInputBase: {
+ styleOverrides: {
+ root: {
+ border: 'none',
+ },
+ input: {
+ '&::placeholder': {
+ opacity: 0.7,
+ color: gray[500],
+ },
+ },
+ },
+ },
+ MuiOutlinedInput: {
+ styleOverrides: {
+ input: {
+ padding: 0,
+ },
+ root: ({ theme }) => ({
+ padding: '8px 12px',
+ color: (theme.vars || theme).palette.text.primary,
+ borderRadius: (theme.vars || theme).shape.borderRadius,
+ border: `1px solid ${(theme.vars || theme).palette.divider}`,
+ backgroundColor: (theme.vars || theme).palette.background.default,
+ transition: 'border 120ms ease-in',
+ '&:hover': {
+ borderColor: gray[400],
+ },
+ [`&.${outlinedInputClasses.focused}`]: {
+ outline: `3px solid ${alpha(brand[500], 0.5)}`,
+ borderColor: brand[400],
+ },
+ ...theme.applyStyles('dark', {
+ '&:hover': {
+ borderColor: gray[500],
+ },
+ }),
+ variants: [
+ {
+ props: {
+ size: 'small',
+ },
+ style: {
+ height: '2.25rem',
+ },
+ },
+ {
+ props: {
+ size: 'medium',
+ },
+ style: {
+ height: '2.5rem',
+ },
+ },
+ ],
+ }),
+ notchedOutline: {
+ border: 'none',
+ },
+ },
+ },
+ MuiInputAdornment: {
+ styleOverrides: {
+ root: ({ theme }) => ({
+ color: (theme.vars || theme).palette.grey[500],
+ ...theme.applyStyles('dark', {
+ color: (theme.vars || theme).palette.grey[400],
+ }),
+ }),
+ },
+ },
+ MuiFormLabel: {
+ styleOverrides: {
+ root: ({ theme }) => ({
+ typography: theme.typography.caption,
+ marginBottom: 8,
+ }),
+ },
+ },
+};
diff --git a/standards/src/templates/client/src/shared-theme/customizations/navigation.tsx b/standards/src/templates/client/src/shared-theme/customizations/navigation.tsx
new file mode 100644
index 0000000..d516e3f
--- /dev/null
+++ b/standards/src/templates/client/src/shared-theme/customizations/navigation.tsx
@@ -0,0 +1,290 @@
+/**
+ * MUI style overrides for navigation components.
+ *
+ * Exports `navigationCustomizations`, a `Components` map applied by
+ * `AppTheme`, covering menus, selects, links, drawers, pagination, tabs and
+ * steppers.
+ *
+ * @packageDocumentation
+ */
+
+import * as React from 'react';
+import { Theme, alpha, Components } from '@mui/material/styles';
+import { SvgIconProps } from '@mui/material/SvgIcon';
+import { buttonBaseClasses } from '@mui/material/ButtonBase';
+import { dividerClasses } from '@mui/material/Divider';
+import { menuItemClasses } from '@mui/material/MenuItem';
+import { selectClasses } from '@mui/material/Select';
+import { tabClasses } from '@mui/material/Tab';
+import UnfoldMoreRoundedIcon from '@mui/icons-material/UnfoldMoreRounded';
+import { gray, brand } from '../themePrimitives';
+
+/* eslint-disable import/prefer-default-export */
+/** Component overrides for menus, selects, links, drawers, pagination and tabs. */
+export const navigationCustomizations: Components = {
+ MuiMenuItem: {
+ styleOverrides: {
+ root: ({ theme }) => ({
+ borderRadius: (theme.vars || theme).shape.borderRadius,
+ padding: '6px 8px',
+ [`&.${menuItemClasses.focusVisible}`]: {
+ backgroundColor: 'transparent',
+ },
+ [`&.${menuItemClasses.selected}`]: {
+ [`&.${menuItemClasses.focusVisible}`]: {
+ backgroundColor: alpha(theme.palette.action.selected, 0.3),
+ },
+ },
+ }),
+ },
+ },
+ MuiMenu: {
+ styleOverrides: {
+ list: {
+ gap: '0px',
+ [`&.${dividerClasses.root}`]: {
+ margin: '0 -8px',
+ },
+ },
+ paper: ({ theme }) => ({
+ marginTop: '4px',
+ borderRadius: (theme.vars || theme).shape.borderRadius,
+ border: `1px solid ${(theme.vars || theme).palette.divider}`,
+ backgroundImage: 'none',
+ background: 'hsl(0, 0%, 100%)',
+ boxShadow:
+ 'hsla(220, 30%, 5%, 0.07) 0px 4px 16px 0px, hsla(220, 25%, 10%, 0.07) 0px 8px 16px -5px',
+ [`& .${buttonBaseClasses.root}`]: {
+ '&.Mui-selected': {
+ backgroundColor: alpha(theme.palette.action.selected, 0.3),
+ },
+ },
+ ...theme.applyStyles('dark', {
+ background: (theme.vars || theme).palette.background.paper,
+ boxShadow: '0 4px 16px rgba(0, 0, 0, 0.4), 0 8px 24px rgba(0, 0, 0, 0.3)',
+ }),
+ }),
+ },
+ },
+ MuiSelect: {
+ defaultProps: {
+ IconComponent: React.forwardRef((props, ref) => (
+
+ )),
+ },
+ styleOverrides: {
+ root: ({ theme }) => ({
+ borderRadius: (theme.vars || theme).shape.borderRadius,
+ border: '1px solid',
+ borderColor: gray[200],
+ backgroundColor: (theme.vars || theme).palette.background.paper,
+ boxShadow: `inset 0 1px 0 1px hsla(220, 0%, 100%, 0.6), inset 0 -1px 0 1px hsla(220, 35%, 90%, 0.5)`,
+ '&:hover': {
+ borderColor: gray[300],
+ backgroundColor: (theme.vars || theme).palette.background.paper,
+ boxShadow: 'none',
+ },
+ [`&.${selectClasses.focused}`]: {
+ outlineOffset: 0,
+ borderColor: gray[400],
+ },
+ '&:before, &:after': {
+ display: 'none',
+ },
+
+ ...theme.applyStyles('dark', {
+ borderRadius: (theme.vars || theme).shape.borderRadius,
+ borderColor: (theme.vars || theme).palette.divider,
+ backgroundColor: (theme.vars || theme).palette.background.paper,
+ boxShadow: 'inset 0 1px 0 hsla(0, 0%, 100%, 0.05)',
+ '&:hover': {
+ borderColor: 'hsla(0, 0%, 100%, 0.15)',
+ backgroundColor: (theme.vars || theme).palette.background.paper,
+ boxShadow: 'none',
+ },
+ [`&.${selectClasses.focused}`]: {
+ outlineOffset: 0,
+ borderColor: 'hsl(210, 55%, 55%)',
+ },
+ '&:before, &:after': {
+ display: 'none',
+ },
+ }),
+ }),
+ select: ({ theme }) => ({
+ display: 'flex',
+ alignItems: 'center',
+ ...theme.applyStyles('dark', {
+ display: 'flex',
+ alignItems: 'center',
+ '&:focus-visible': {
+ backgroundColor: (theme.vars || theme).palette.background.default,
+ },
+ }),
+ }),
+ },
+ },
+ MuiLink: {
+ defaultProps: {
+ underline: 'none',
+ },
+ styleOverrides: {
+ root: ({ theme }) => ({
+ color: (theme.vars || theme).palette.text.primary,
+ fontWeight: 500,
+ position: 'relative',
+ textDecoration: 'none',
+ width: 'fit-content',
+ '&::before': {
+ content: '""',
+ position: 'absolute',
+ width: '100%',
+ height: '1px',
+ bottom: 0,
+ left: 0,
+ backgroundColor: (theme.vars || theme).palette.text.secondary,
+ opacity: 0.3,
+ transition: 'width 0.3s ease, opacity 0.3s ease',
+ },
+ '&:hover::before': {
+ width: 0,
+ },
+ '&:focus-visible': {
+ outline: `3px solid ${alpha(brand[500], 0.5)}`,
+ outlineOffset: '4px',
+ borderRadius: '2px',
+ },
+ }),
+ },
+ },
+ MuiDrawer: {
+ styleOverrides: {
+ paper: ({ theme }) => ({
+ backgroundColor: (theme.vars || theme).palette.background.default,
+ borderRight: `1px solid ${(theme.vars || theme).palette.divider}`,
+ }),
+ },
+ },
+ MuiPaginationItem: {
+ styleOverrides: {
+ root: ({ theme }) => ({
+ '&.Mui-selected': {
+ color: 'white',
+ backgroundColor: (theme.vars || theme).palette.grey[900],
+ },
+ ...theme.applyStyles('dark', {
+ '&.Mui-selected': {
+ color: 'black',
+ backgroundColor: (theme.vars || theme).palette.grey[50],
+ },
+ }),
+ }),
+ },
+ },
+ MuiTabs: {
+ styleOverrides: {
+ root: { minHeight: 'fit-content' },
+ indicator: ({ theme }) => ({
+ backgroundColor: (theme.vars || theme).palette.grey[800],
+ ...theme.applyStyles('dark', {
+ backgroundColor: (theme.vars || theme).palette.grey[200],
+ }),
+ }),
+ },
+ },
+ MuiTab: {
+ styleOverrides: {
+ root: ({ theme }) => ({
+ padding: '6px 8px',
+ marginBottom: '8px',
+ textTransform: 'none',
+ minWidth: 'fit-content',
+ minHeight: 'fit-content',
+ color: (theme.vars || theme).palette.text.secondary,
+ borderRadius: (theme.vars || theme).shape.borderRadius,
+ border: '1px solid',
+ borderColor: 'transparent',
+ ':hover': {
+ color: (theme.vars || theme).palette.text.primary,
+ backgroundColor: gray[100],
+ borderColor: gray[200],
+ },
+ [`&.${tabClasses.selected}`]: {
+ color: gray[900],
+ },
+ ...theme.applyStyles('dark', {
+ ':hover': {
+ color: (theme.vars || theme).palette.text.primary,
+ backgroundColor: alpha((theme.vars || theme).palette.common.white, 0.08),
+ borderColor: (theme.vars || theme).palette.divider,
+ },
+ [`&.${tabClasses.selected}`]: {
+ color: '#fff',
+ },
+ }),
+ }),
+ },
+ },
+ MuiStepConnector: {
+ styleOverrides: {
+ line: ({ theme }) => ({
+ borderTop: '1px solid',
+ borderColor: (theme.vars || theme).palette.divider,
+ flex: 1,
+ borderRadius: '99px',
+ }),
+ },
+ },
+ MuiStepIcon: {
+ styleOverrides: {
+ root: ({ theme }) => ({
+ color: 'transparent',
+ border: `1px solid ${gray[400]}`,
+ width: 12,
+ height: 12,
+ borderRadius: '50%',
+ '& text': {
+ display: 'none',
+ },
+ '&.Mui-active': {
+ border: 'none',
+ color: (theme.vars || theme).palette.primary.main,
+ },
+ '&.Mui-completed': {
+ border: 'none',
+ color: (theme.vars || theme).palette.success.main,
+ },
+ ...theme.applyStyles('dark', {
+ border: `1px solid ${gray[700]}`,
+ '&.Mui-active': {
+ border: 'none',
+ color: (theme.vars || theme).palette.primary.light,
+ },
+ '&.Mui-completed': {
+ border: 'none',
+ color: (theme.vars || theme).palette.success.light,
+ },
+ }),
+ variants: [
+ {
+ props: { completed: true },
+ style: {
+ width: 12,
+ height: 12,
+ },
+ },
+ ],
+ }),
+ },
+ },
+ MuiStepLabel: {
+ styleOverrides: {
+ label: ({ theme }) => ({
+ '&.Mui-completed': {
+ opacity: 0.6,
+ ...theme.applyStyles('dark', { opacity: 0.5 }),
+ },
+ }),
+ },
+ },
+};
diff --git a/standards/src/templates/client/src/shared-theme/customizations/surfaces.ts b/standards/src/templates/client/src/shared-theme/customizations/surfaces.ts
new file mode 100644
index 0000000..fb9296c
--- /dev/null
+++ b/standards/src/templates/client/src/shared-theme/customizations/surfaces.ts
@@ -0,0 +1,123 @@
+/**
+ * MUI style overrides for surface components.
+ *
+ * Exports `surfacesCustomizations`, a `Components` map applied by
+ * `AppTheme`, covering accordions, paper, cards and card sections.
+ *
+ * @packageDocumentation
+ */
+
+import { alpha, Theme, Components } from '@mui/material/styles';
+import { gray } from '../themePrimitives';
+
+/* eslint-disable import/prefer-default-export */
+/** Component overrides for accordions, paper and cards. */
+export const surfacesCustomizations: Components = {
+ MuiAccordion: {
+ defaultProps: {
+ elevation: 0,
+ disableGutters: true,
+ },
+ styleOverrides: {
+ root: ({ theme }) => ({
+ padding: 4,
+ overflow: 'clip',
+ backgroundColor: (theme.vars || theme).palette.background.default,
+ border: '1px solid',
+ borderColor: (theme.vars || theme).palette.divider,
+ ':before': {
+ backgroundColor: 'transparent',
+ },
+ '&:not(:last-of-type)': {
+ borderBottom: 'none',
+ },
+ '&:first-of-type': {
+ borderTopLeftRadius: (theme.vars || theme).shape.borderRadius,
+ borderTopRightRadius: (theme.vars || theme).shape.borderRadius,
+ },
+ '&:last-of-type': {
+ borderBottomLeftRadius: (theme.vars || theme).shape.borderRadius,
+ borderBottomRightRadius: (theme.vars || theme).shape.borderRadius,
+ },
+ }),
+ },
+ },
+ MuiAccordionSummary: {
+ styleOverrides: {
+ root: ({ theme }) => ({
+ border: 'none',
+ borderRadius: 8,
+ '&:hover': { backgroundColor: gray[50] },
+ '&:focus-visible': { backgroundColor: 'transparent' },
+ ...theme.applyStyles('dark', {
+ '&:hover': { backgroundColor: alpha(theme.palette.common.white, 0.06) },
+ }),
+ }),
+ },
+ },
+ MuiAccordionDetails: {
+ styleOverrides: {
+ root: { mb: 20, border: 'none' },
+ },
+ },
+ MuiPaper: {
+ defaultProps: {
+ elevation: 0,
+ },
+ },
+ MuiCard: {
+ styleOverrides: {
+ root: ({ theme }) => {
+ return {
+ padding: 16,
+ gap: 16,
+ transition: 'all 100ms ease',
+ backgroundColor: gray[50],
+ borderRadius: (theme.vars || theme).shape.borderRadius,
+ border: `1px solid ${(theme.vars || theme).palette.divider}`,
+ boxShadow: 'none',
+ ...theme.applyStyles('dark', {
+ backgroundColor: (theme.vars || theme).palette.background.paper,
+ }),
+ variants: [
+ {
+ props: {
+ variant: 'outlined',
+ },
+ style: {
+ border: `1px solid ${(theme.vars || theme).palette.divider}`,
+ boxShadow: 'none',
+ background: 'hsl(0, 0%, 100%)',
+ ...theme.applyStyles('dark', {
+ background: alpha((theme.vars || theme).palette.background.paper, 0.6),
+ }),
+ },
+ },
+ ],
+ };
+ },
+ },
+ },
+ MuiCardContent: {
+ styleOverrides: {
+ root: {
+ padding: 0,
+ '&:last-child': { paddingBottom: 0 },
+ },
+ },
+ },
+ MuiCardHeader: {
+ styleOverrides: {
+ root: {
+ padding: 0,
+ },
+ },
+ },
+ MuiCardActions: {
+ styleOverrides: {
+ root: {
+ padding: 0,
+ },
+ },
+ },
+};
diff --git a/standards/src/templates/client/src/shared-theme/mui.d.ts b/standards/src/templates/client/src/shared-theme/mui.d.ts
new file mode 100644
index 0000000..46ca2c3
--- /dev/null
+++ b/standards/src/templates/client/src/shared-theme/mui.d.ts
@@ -0,0 +1,27 @@
+/**
+ * MUI v5 type shims for the shared theme.
+ *
+ * The component customizations use the MUI v6 CSS-variable style
+ * (`theme.vars || theme`), which is runtime-safe on v5 (falls back to the
+ * plain theme) but not covered by the v5 type definitions. These
+ * augmentations teach the v5 types about that pattern so `tsc` passes on
+ * both majors.
+ *
+ * @packageDocumentation
+ */
+
+import "@mui/material/styles";
+
+declare module "@mui/material/styles" {
+ interface Theme {
+ /** CSS-variable slice; undefined on v5 at runtime — always use `(theme.vars || theme)`. */
+ vars: Theme;
+ }
+}
+
+declare module "@mui/material/Select" {
+ interface SelectClasses {
+ /** Present in MUI v6+; undefined on v5 (dead selector, harmless). */
+ focused: string;
+ }
+}
diff --git a/standards/src/templates/client/src/shared-theme/themeConfig.ts b/standards/src/templates/client/src/shared-theme/themeConfig.ts
new file mode 100644
index 0000000..f78daf2
--- /dev/null
+++ b/standards/src/templates/client/src/shared-theme/themeConfig.ts
@@ -0,0 +1,112 @@
+/**
+ * Semantic (mode-aware) color tokens.
+ *
+ * Adds a `semantic` slice to the MUI theme (augmenting `@mui/material/styles`)
+ * with surface/border/text colors, resolved per light/dark mode by
+ * `getSemanticColors`. `AppTheme` publishes these as CSS custom properties.
+ *
+ * @packageDocumentation
+ */
+
+import { gray } from "./themePrimitives";
+import { alpha } from "@mui/material/styles";
+
+declare module "@mui/material/styles" {
+ interface Theme {
+ semantic: SemanticColors;
+ }
+ interface ThemeOptions {
+ semantic?: SemanticColors;
+ }
+}
+
+/** Color mode supported by the semantic tokens. */
+export type SemanticColorMode = "light" | "dark";
+
+/**
+ * Mode-aware semantic color groups attached to the MUI theme as
+ * `theme.semantic` and exposed as CSS custom properties by `AppTheme`.
+ */
+export interface SemanticColors {
+ /** Background surface colors. */
+ surface: {
+ /** Page background (`--bg-page`). */
+ page: string;
+ /** Card background (`--bg-card`). */
+ card: string;
+ /** Elevated surface, e.g. popovers (`--bg-elevated`). */
+ elevated: string;
+ };
+ /** Border colors. */
+ border: {
+ /** Default border (`--border-default`). */
+ default: string;
+ /** Subtle border (`--border-subtle`). */
+ subtle: string;
+ };
+ /** Text colors. */
+ text: {
+ /** Primary text (`--text-primary`). */
+ primary: string;
+ /** Secondary text (`--text-secondary`). */
+ secondary: string;
+ /** Muted text (`--text-muted`). */
+ muted: string;
+ };
+}
+
+const darkBg = 'hsl(0, 0%, 9%)';
+const darkPaper = 'hsl(0, 0%, 14%)';
+const darkElevated = 'hsl(0, 0%, 19%)';
+
+/**
+ * Resolve the semantic color groups for a color mode.
+ *
+ * Dark mode uses fixed HSL values; light mode derives borders/text from the
+ * `gray` ramp (with `alpha`).
+ *
+ * @param mode - `"light"` or `"dark"`.
+ * @returns The `SemanticColors` for that mode.
+ * @example
+ * ```ts
+ * const colors = getSemanticColors("dark");
+ * colors.surface.page; // "hsl(0, 0%, 9%)"
+ * ```
+ */
+export function getSemanticColors(mode: SemanticColorMode): SemanticColors {
+ if (mode === "dark") {
+ return {
+ surface: {
+ page: darkBg,
+ card: darkPaper,
+ elevated: darkElevated,
+ },
+ border: {
+ default: 'hsla(0, 0%, 100%, 0.08)',
+ subtle: 'hsla(0, 0%, 100%, 0.04)',
+ },
+ text: {
+ primary: 'hsl(0, 0%, 92%)',
+ secondary: 'hsl(0, 0%, 60%)',
+ muted: 'hsl(0, 0%, 45%)',
+ },
+ };
+ }
+
+ return {
+ surface: {
+ page: "hsl(0, 0%, 99%)",
+ card: "hsl(220, 35%, 97%)",
+ elevated: gray[100],
+ },
+ border: {
+ default: alpha(gray[300], 0.4),
+ subtle: alpha(gray[200], 0.3),
+ },
+ text: {
+ primary: gray[800],
+ secondary: gray[600],
+ muted: gray[500],
+ },
+ };
+}
diff --git a/standards/src/templates/client/src/shared-theme/themePrimitives.ts b/standards/src/templates/client/src/shared-theme/themePrimitives.ts
new file mode 100644
index 0000000..a1bba19
--- /dev/null
+++ b/standards/src/templates/client/src/shared-theme/themePrimitives.ts
@@ -0,0 +1,480 @@
+/**
+ * Base design tokens and palettes for the app theme.
+ *
+ * Defines the raw color ramps (`brand`, `gray`, `green`, `orange`, `red`), the
+ * light/dark design tokens (`getDesignTokens`), and the `colorSchemes`,
+ * `typography`, `shape` and `shadows` primitives. Also augments MUI's theme
+ * types with the `ColorRange`, `flows` and `baseShadow` extensions.
+ *
+ * @packageDocumentation
+ */
+
+import { createTheme, alpha, Shadows } from '@mui/material/styles';
+import type { PaletteMode } from '@mui/material';
+
+declare module '@mui/material/Paper' {
+ interface PaperPropsVariantOverrides {
+ highlighted: true;
+ }
+}
+declare module '@mui/material/styles' {
+ interface ColorRange {
+ 50: string;
+ 100: string;
+ 200: string;
+ 300: string;
+ 400: string;
+ 500: string;
+ 600: string;
+ 700: string;
+ 800: string;
+ 900: string;
+ }
+
+ interface PaletteColor extends ColorRange {}
+
+ interface Palette {
+ baseShadow: string;
+ flows: {
+ outflows: { primary: string; surface: string; text: string };
+ inflows: { primary: string; surface: string; text: string };
+ };
+ }
+}
+
+const defaultTheme = createTheme();
+
+const customShadows: Shadows = [...defaultTheme.shadows];
+
+/** Brand blue color ramp (50-900). */
+export const brand = {
+ 50: 'hsl(210, 100%, 95%)',
+ 100: 'hsl(210, 100%, 92%)',
+ 200: 'hsl(210, 100%, 80%)',
+ 300: 'hsl(210, 100%, 65%)',
+ 400: 'hsl(210, 98%, 48%)',
+ 500: 'hsl(210, 98%, 42%)',
+ 600: 'hsl(210, 98%, 55%)',
+ 700: 'hsl(210, 100%, 35%)',
+ 800: 'hsl(210, 100%, 16%)',
+ 900: 'hsl(210, 100%, 21%)',
+};
+
+/** Neutral gray color ramp (50-900, plus 750/850). */
+export const gray = {
+ 50: 'hsl(220, 35%, 97%)',
+ 100: 'hsl(220, 30%, 94%)',
+ 200: 'hsl(220, 20%, 88%)',
+ 300: 'hsl(220, 20%, 80%)',
+ 400: 'hsl(220, 20%, 65%)',
+ 500: 'hsl(220, 20%, 42%)',
+ 600: 'hsl(220, 20%, 35%)',
+ 700: 'hsl(220, 20%, 25%)',
+ 750: 'hsl(220, 20%, 18%)',
+ 800: 'hsl(220, 30%, 6%)',
+ 850: 'hsl(220, 22%, 11%)',
+ 900: 'hsl(220, 35%, 3%)',
+};
+
+/** Green (success) color ramp (50-900). */
+export const green = {
+ 50: 'hsl(120, 80%, 98%)',
+ 100: 'hsl(120, 75%, 94%)',
+ 200: 'hsl(120, 75%, 87%)',
+ 300: 'hsl(120, 61%, 77%)',
+ 400: 'hsl(120, 44%, 53%)',
+ 500: 'hsl(120, 59%, 30%)',
+ 600: 'hsl(120, 70%, 25%)',
+ 700: 'hsl(120, 75%, 16%)',
+ 800: 'hsl(120, 84%, 10%)',
+ 900: 'hsl(120, 87%, 6%)',
+};
+
+/** Orange (warning) color ramp (50-900). */
+export const orange = {
+ 50: 'hsl(45, 100%, 97%)',
+ 100: 'hsl(45, 92%, 90%)',
+ 200: 'hsl(45, 94%, 80%)',
+ 300: 'hsl(45, 90%, 65%)',
+ 400: 'hsl(45, 90%, 40%)',
+ 500: 'hsl(45, 90%, 35%)',
+ 600: 'hsl(45, 91%, 25%)',
+ 700: 'hsl(45, 94%, 20%)',
+ 800: 'hsl(45, 95%, 16%)',
+ 900: 'hsl(45, 93%, 12%)',
+};
+
+/** Red (error) color ramp (50-900). */
+export const red = {
+ 50: 'hsl(0, 100%, 97%)',
+ 100: 'hsl(0, 92%, 90%)',
+ 200: 'hsl(0, 94%, 80%)',
+ 300: 'hsl(0, 90%, 65%)',
+ 400: 'hsl(0, 90%, 40%)',
+ 500: 'hsl(0, 90%, 30%)',
+ 600: 'hsl(0, 91%, 25%)',
+ 700: 'hsl(0, 94%, 18%)',
+ 800: 'hsl(0, 95%, 12%)',
+ 900: 'hsl(0, 93%, 6%)',
+};
+
+const darkBg = 'hsl(0, 0%, 9%)';
+const darkPaper = 'hsl(0, 0%, 14%)';
+const darkElevated = 'hsl(0, 0%, 19%)';
+
+/**
+ * Build the MUI design tokens (palette, typography, shape, shadows) for a mode.
+ *
+ * Light and dark palettes share the ramps but override specific slots in dark
+ * mode. Also sets `shadows[1]` (the "baseShadow") per mode. The returned object
+ * is spread into `createTheme` by `AppTheme`.
+ *
+ * @param mode - `"light"` or `"dark"`.
+ * @returns A MUI `ThemeOptions`-shaped object (palette, typography, shape,
+ * shadows).
+ */
+export const getDesignTokens = (mode: PaletteMode) => {
+ customShadows[1] =
+ mode === 'dark'
+ ? '0 4px 16px rgba(0, 0, 0, 0.4), 0 8px 24px rgba(0, 0, 0, 0.3)'
+ : 'hsla(220, 30%, 5%, 0.07) 0px 4px 16px 0px, hsla(220, 25%, 10%, 0.07) 0px 8px 16px -5px';
+
+ return {
+ palette: {
+ mode,
+ primary: {
+ light: brand[200],
+ main: brand[400],
+ dark: brand[700],
+ contrastText: brand[50],
+ ...(mode === 'dark' && {
+ contrastText: brand[50],
+ light: 'hsl(210, 50%, 65%)',
+ main: 'hsl(210, 55%, 55%)',
+ dark: 'hsl(210, 50%, 35%)',
+ }),
+ },
+ info: {
+ light: brand[100],
+ main: brand[300],
+ dark: brand[600],
+ contrastText: gray[50],
+ ...(mode === 'dark' && {
+ contrastText: 'hsl(210, 30%, 80%)',
+ light: 'hsl(210, 40%, 50%)',
+ main: 'hsl(210, 35%, 40%)',
+ dark: 'hsl(210, 30%, 25%)',
+ }),
+ },
+ warning: {
+ light: orange[300],
+ main: orange[400],
+ dark: orange[800],
+ ...(mode === 'dark' && {
+ light: 'hsl(45, 60%, 55%)',
+ main: 'hsl(45, 55%, 45%)',
+ dark: 'hsl(45, 50%, 30%)',
+ }),
+ },
+ error: {
+ light: red[300],
+ main: red[400],
+ dark: red[800],
+ ...(mode === 'dark' && {
+ light: 'hsl(0, 55%, 60%)',
+ main: 'hsl(0, 55%, 50%)',
+ dark: 'hsl(0, 50%, 35%)',
+ }),
+ },
+ success: {
+ light: green[300],
+ main: green[400],
+ dark: green[800],
+ ...(mode === 'dark' && {
+ light: 'hsl(120, 40%, 55%)',
+ main: 'hsl(120, 40%, 45%)',
+ dark: 'hsl(120, 35%, 30%)',
+ }),
+ },
+ grey: {
+ ...gray,
+ },
+ divider: mode === 'dark' ? 'hsla(0, 0%, 100%, 0.08)' : alpha(gray[300], 0.4),
+ background: {
+ default: 'hsl(0, 0%, 99%)',
+ paper: 'hsl(220, 35%, 97%)',
+ ...(mode === 'dark' && { default: darkBg, paper: darkPaper }),
+ },
+ text: {
+ primary: gray[800],
+ secondary: gray[600],
+ warning: orange[400],
+ ...(mode === 'dark' && { primary: 'hsl(0, 0%, 92%)', secondary: 'hsl(0, 0%, 60%)' }),
+ },
+ action: {
+ hover: alpha(gray[200], 0.2),
+ selected: `${alpha(gray[200], 0.3)}`,
+ ...(mode === 'dark' && {
+ hover: 'hsla(0, 0%, 100%, 0.06)',
+ selected: 'hsla(0, 0%, 100%, 0.1)',
+ }),
+ },
+ flows: {
+ outflows: {
+ primary: mode === 'dark' ? 'hsl(0, 55%, 60%)' : '#d32f2f',
+ surface: mode === 'dark' ? 'hsla(0, 35%, 25%, 0.6)' : '#fdecea',
+ text: mode === 'dark' ? 'hsl(0, 60%, 80%)' : '#b71c1c',
+ },
+ inflows: {
+ primary: mode === 'dark' ? 'hsl(120, 40%, 55%)' : '#2e7d32',
+ surface: mode === 'dark' ? 'hsla(120, 25%, 22%, 0.6)' : '#e8f5e9',
+ text: mode === 'dark' ? 'hsl(120, 40%, 78%)' : '#1b5e20',
+ },
+ },
+ },
+ typography: {
+ fontFamily: 'Inter, sans-serif',
+ h1: {
+ fontSize: defaultTheme.typography.pxToRem(48),
+ fontWeight: 600,
+ lineHeight: 1.2,
+ letterSpacing: -0.5,
+ },
+ h2: {
+ fontSize: defaultTheme.typography.pxToRem(36),
+ fontWeight: 600,
+ lineHeight: 1.2,
+ },
+ h3: {
+ fontSize: defaultTheme.typography.pxToRem(30),
+ lineHeight: 1.2,
+ },
+ h4: {
+ fontSize: defaultTheme.typography.pxToRem(24),
+ fontWeight: 600,
+ lineHeight: 1.5,
+ },
+ h5: {
+ fontSize: defaultTheme.typography.pxToRem(20),
+ fontWeight: 600,
+ },
+ h6: {
+ fontSize: defaultTheme.typography.pxToRem(18),
+ fontWeight: 600,
+ },
+ subtitle1: {
+ fontSize: defaultTheme.typography.pxToRem(18),
+ },
+ subtitle2: {
+ fontSize: defaultTheme.typography.pxToRem(14),
+ fontWeight: 500,
+ },
+ body1: {
+ fontSize: defaultTheme.typography.pxToRem(14),
+ },
+ body2: {
+ fontSize: defaultTheme.typography.pxToRem(14),
+ fontWeight: 400,
+ },
+ caption: {
+ fontSize: defaultTheme.typography.pxToRem(12),
+ fontWeight: 400,
+ },
+ },
+ shape: {
+ borderRadius: 8,
+ },
+ shadows: customShadows,
+ };
+};
+
+/** Explicit light/dark `colorSchemes` palette definitions. */
+export const colorSchemes = {
+ light: {
+ palette: {
+ primary: {
+ light: brand[200],
+ main: brand[400],
+ dark: brand[700],
+ contrastText: brand[50],
+ },
+ info: {
+ light: brand[100],
+ main: brand[300],
+ dark: brand[600],
+ contrastText: gray[50],
+ },
+ warning: {
+ light: orange[300],
+ main: orange[400],
+ dark: orange[800],
+ },
+ error: {
+ light: red[300],
+ main: red[400],
+ dark: red[800],
+ },
+ success: {
+ light: green[300],
+ main: green[400],
+ dark: green[800],
+ },
+ grey: {
+ ...gray,
+ },
+ divider: alpha(gray[300], 0.4),
+ background: {
+ default: 'hsl(0, 0%, 99%)',
+ paper: 'hsl(220, 35%, 97%)',
+ },
+ text: {
+ primary: gray[800],
+ secondary: gray[600],
+ warning: orange[400],
+ },
+ action: {
+ hover: alpha(gray[200], 0.2),
+ selected: `${alpha(gray[200], 0.3)}`,
+ },
+ flows: {
+ outflows: {
+ primary: '#d32f2f',
+ surface: '#fdecea',
+ text: '#b71c1c',
+ },
+ inflows: {
+ primary: '#2e7d32',
+ surface: '#e8f5e9',
+ text: '#1b5e20',
+ },
+ },
+ baseShadow:
+ 'hsla(220, 30%, 5%, 0.07) 0px 4px 16px 0px, hsla(220, 25%, 10%, 0.07) 0px 8px 16px -5px',
+ },
+ },
+ dark: {
+ palette: {
+ primary: {
+ contrastText: brand[50],
+ light: 'hsl(210, 50%, 65%)',
+ main: 'hsl(210, 55%, 55%)',
+ dark: 'hsl(210, 50%, 35%)',
+ },
+ info: {
+ contrastText: 'hsl(210, 30%, 80%)',
+ light: 'hsl(210, 40%, 50%)',
+ main: 'hsl(210, 35%, 40%)',
+ dark: 'hsl(210, 30%, 25%)',
+ },
+ warning: {
+ light: 'hsl(45, 60%, 55%)',
+ main: 'hsl(45, 55%, 45%)',
+ dark: 'hsl(45, 50%, 30%)',
+ },
+ error: {
+ light: 'hsl(0, 55%, 60%)',
+ main: 'hsl(0, 55%, 50%)',
+ dark: 'hsl(0, 50%, 35%)',
+ },
+ success: {
+ light: 'hsl(120, 40%, 55%)',
+ main: 'hsl(120, 40%, 45%)',
+ dark: 'hsl(120, 35%, 30%)',
+ },
+ grey: {
+ ...gray,
+ },
+ divider: 'hsla(0, 0%, 100%, 0.08)',
+ background: {
+ default: darkBg,
+ paper: darkPaper,
+ },
+ text: {
+ primary: 'hsl(0, 0%, 92%)',
+ secondary: 'hsl(0, 0%, 60%)',
+ },
+ action: {
+ hover: 'hsla(0, 0%, 100%, 0.06)',
+ selected: 'hsla(0, 0%, 100%, 0.1)',
+ },
+ flows: {
+ outflows: {
+ primary: 'hsl(0, 55%, 60%)',
+ surface: 'hsla(0, 35%, 25%, 0.6)',
+ text: 'hsl(0, 60%, 80%)',
+ },
+ inflows: {
+ primary: 'hsl(120, 40%, 55%)',
+ surface: 'hsla(120, 25%, 22%, 0.6)',
+ text: 'hsl(120, 40%, 78%)',
+ },
+ },
+ baseShadow: '0 4px 16px rgba(0, 0, 0, 0.4), 0 8px 24px rgba(0, 0, 0, 0.3)',
+ },
+ },
+};
+
+/** Typography scale (Inter) used by the theme. */
+export const typography = {
+ fontFamily: 'Inter, sans-serif',
+ h1: {
+ fontSize: defaultTheme.typography.pxToRem(48),
+ fontWeight: 600,
+ lineHeight: 1.2,
+ letterSpacing: -0.5,
+ },
+ h2: {
+ fontSize: defaultTheme.typography.pxToRem(36),
+ fontWeight: 600,
+ lineHeight: 1.2,
+ },
+ h3: {
+ fontSize: defaultTheme.typography.pxToRem(30),
+ lineHeight: 1.2,
+ },
+ h4: {
+ fontSize: defaultTheme.typography.pxToRem(24),
+ fontWeight: 600,
+ lineHeight: 1.5,
+ },
+ h5: {
+ fontSize: defaultTheme.typography.pxToRem(20),
+ fontWeight: 600,
+ },
+ h6: {
+ fontSize: defaultTheme.typography.pxToRem(18),
+ fontWeight: 600,
+ },
+ subtitle1: {
+ fontSize: defaultTheme.typography.pxToRem(18),
+ },
+ subtitle2: {
+ fontSize: defaultTheme.typography.pxToRem(14),
+ fontWeight: 500,
+ },
+ body1: {
+ fontSize: defaultTheme.typography.pxToRem(14),
+ },
+ body2: {
+ fontSize: defaultTheme.typography.pxToRem(14),
+ fontWeight: 400,
+ },
+ caption: {
+ fontSize: defaultTheme.typography.pxToRem(12),
+ fontWeight: 400,
+ },
+};
+
+/** Shape tokens (global border radius). */
+export const shape = {
+ borderRadius: 8,
+};
+
+// @ts-ignore
+const defaultShadows: Shadows = [
+ 'none',
+ 'var(--template-palette-baseShadow)',
+ ...defaultTheme.shadows.slice(2),
+];
+/** Shadow scale (index 0 `none`, index 1 the template `baseShadow`). */
+export const shadows = defaultShadows;
diff --git a/standards/src/templates/client/src/vite-env.d.ts b/standards/src/templates/client/src/vite-env.d.ts
new file mode 100644
index 0000000..9ce06ff
--- /dev/null
+++ b/standards/src/templates/client/src/vite-env.d.ts
@@ -0,0 +1,10 @@
+///
+
+interface ImportMetaEnv {
+ readonly VITE_API_BASE_URL: string;
+ readonly VITE_AUTH_BASE_URL: string;
+}
+
+interface ImportMeta {
+ readonly env: ImportMetaEnv;
+}
diff --git a/standards/src/templates/client/tsconfig.json b/standards/src/templates/client/tsconfig.json
new file mode 100644
index 0000000..f6e9b6f
--- /dev/null
+++ b/standards/src/templates/client/tsconfig.json
@@ -0,0 +1,21 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+ "moduleResolution": "bundler",
+ "isolatedModules": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+ "jsx": "react-jsx",
+ "strict": true,
+ "noUnusedLocals": false,
+ "noUnusedParameters": false,
+ "noFallthroughCasesInSwitch": true,
+ "allowJs": true,
+ "resolveJsonModule": true
+ },
+ "include": ["src"]
+}
diff --git a/standards/src/templates/client/vite.config.ts b/standards/src/templates/client/vite.config.ts
new file mode 100644
index 0000000..0466183
--- /dev/null
+++ b/standards/src/templates/client/vite.config.ts
@@ -0,0 +1,6 @@
+import { defineConfig } from 'vite';
+import react from '@vitejs/plugin-react';
+
+export default defineConfig({
+ plugins: [react()],
+});