feat: full-stack MCP surface — client template, npm CI, 13 repos

- build.py: vendor templates/client into mcp/templates/client with
  client.json manifest (16 kinds); register drone-npm as a third CI shape
  (types: pypi/npm/docker); root *.py stale doc numbers fixed
- registry.py: registry://client resource + list_client_components /
  get_client_component / get_client_template tools; build prompt gains the
  client scaffold step (docs://modules/ui_admin); compliance prompt covers
  npm packages; drone docs describe all three shapes
- repos.json (+mcp): doc-weaver, react-auth, react-openapi (npm Type B,
  package.json version source), vet (app Type C, VERSION)
- docs: 90_drone_pipelines gains Type B npm section; 91_versioning covers
  package.json as a version source; 94_repository_inventory gains the npm
  package table and services/apps Type C; lib index updated

Verified: bundle regen (24 modules, client 16, drone 3, repos 13); registry
client readers exercised against the vendored bundle.
This commit is contained in:
2026-10-20 12:02:31 +05:30
parent 406affcfcf
commit d7c344c9aa
44 changed files with 3251 additions and 94 deletions

102
standards/mcp/client.json Normal file
View File

@@ -0,0 +1,102 @@
{
"project": "standards",
"template": "templates/client",
"components": [
{
"kind": "client-package",
"path": "package.json",
"placeholder": "",
"description": "npm manifest: @aetoskia deps, scripts (dev/build/preview)."
},
{
"kind": "client-npmrc",
"path": ".npmrc",
"placeholder": "",
"description": "Private registry auth pointing at npm.aetoskia.com."
},
{
"kind": "client-env",
"path": ".env.example",
"placeholder": "",
"description": "VITE_API_BASE_URL / VITE_AUTH_BASE_URL seed."
},
{
"kind": "client-vite-config",
"path": "vite.config.ts",
"placeholder": "",
"description": "Vite config (dev server, build)."
},
{
"kind": "client-tsconfig",
"path": "tsconfig.json",
"placeholder": "",
"description": "TypeScript config for the client."
},
{
"kind": "client-index-html",
"path": "index.html",
"placeholder": "",
"description": "HTML entry point."
},
{
"kind": "client-entry",
"path": "src/main.jsx",
"placeholder": "",
"description": "React entry: router + providers + theme."
},
{
"kind": "client-openapi-config",
"path": "src/openapi-config.ts",
"placeholder": "",
"description": "Points the admin renderer at the app spec + auth-server spec."
},
{
"kind": "client-home",
"path": "src/Home.tsx",
"placeholder": "",
"description": "Auto-listing home page (ResourceCard + LiveTable per resource)."
},
{
"kind": "client-header",
"path": "src/Header.tsx",
"placeholder": "",
"description": "App header bar (brand + nav)."
},
{
"kind": "client-footer",
"path": "src/Footer.tsx",
"placeholder": "",
"description": "App footer."
},
{
"kind": "client-vite-env",
"path": "src/vite-env.d.ts",
"placeholder": "",
"description": "Vite client type declarations."
},
{
"kind": "client-theme",
"path": "src/shared-theme/AppTheme.tsx",
"placeholder": "",
"description": "Theme provider wrapper (light/dark)."
},
{
"kind": "client-theme-config",
"path": "src/shared-theme/themeConfig.ts",
"placeholder": "",
"description": "Palette / typography tokens."
},
{
"kind": "client-theme-primitives",
"path": "src/shared-theme/themePrimitives.ts",
"placeholder": "",
"description": "MUI theme primitives (shadows, accents)."
},
{
"kind": "client-theme-shim",
"path": "src/shared-theme/mui.d.ts",
"placeholder": "",
"description": "MUI v5 type shims (Theme.vars, SelectClasses.focused)."
}
]
}

View File

@@ -3,6 +3,7 @@
"template": "templates/drone",
"types": [
"pypi",
"npm",
"docker"
],
"components": [
@@ -12,6 +13,12 @@
"placeholder": "",
"description": "Drone pipeline: build + publish a Python package to the private PyPI index."
},
{
"kind": "drone-npm",
"path": "npm-lib.yaml",
"placeholder": "",
"description": "Drone pipeline: build + publish an npm package to the private npm registry."
},
{
"kind": "drone-service",
"path": "service-docker.yaml",

File diff suppressed because one or more lines are too long

View File

@@ -2,7 +2,7 @@
"module": "index",
"content": {
"path": "index",
"docstring": "# Standards Reference\n\nThe reference half of the Standards description: the enforceable rules,\npatterns, and the internal library catalog that agents consult while coding.\n\nThe reference is organized as an **anatomy** — one entry checklist and four\npillars describing what an application is made of, plus delivery/meta pages\nand an optional pipelines layer.\n\n## Anatomy\n\n- **Build Recipe** — the ordered, agentic checklist for a new app; the entry\n point that walks the whole anatomy.\n\n## Pillar 1 — Domains\n\n- **Layering Rules** — the strict request → domain → repository layering and\n the rules that enforce it.\n\n## Pillar 2 — OpenAPI Spec (the contract)\n\n- **Composition Root** — a single composition point wiring the whole app.\n- **Spec-First Routing** — the OpenAPI contract as the routing source of truth.\n- **CRUD Template** — the standardized generic resource endpoint scaffold.\n- **Auth Integration** — consuming the shared auth-server service (spec-driven,\n no auth code).\n\n## Pillar 3 — Interfaces & Infra\n\n- **Infra Adapters** — how infrastructure connects without leaking into domain.\n\n## Pillar 4 — UI\n\n- **UI: Auto-Rendered Admin & Custom Pages** — the `@aetoskia/react-openapi`\n admin rendered from the spec's x- extensions, and `@aetoskia/react-auth`\n for auth pages; the client template scaffold.\n\n## Pipelines (optional layer)\n\n- **Pipeline Scaffold** — the deterministic pipeline skeleton for multi-stage\n jobs.\n- **Worker & Progress** — queued work, progress reporting, and visibility.\n- **Human-in-the-Loop** — guardrails and approvals inside automations.\n\n## Delivery & Meta\n\n- **Drone Pipelines** — the two CI shapes: a PyPI/npm package or a Docker\n deployment.\n- **Versioning & Changelog** — SemVer, the tag↔version contract, and the\n release changelog.\n- **Repository Inventory** — every covered repo, its type, version source, and\n Drone shape.\n- **Genericity Matrix** — what is library-backed, template-provided, or\n authored.\n- **Library Registry** — the catalog of internal libraries behind all of it.\n\nRead the **Wiki** for the philosophy and rationale; these pages are the\nreference that mirrors the `standards` MCP bundle.\n",
"docstring": "# Standards Reference\n\nThe reference half of the Standards description: the enforceable rules,\npatterns, and the internal library catalog that agents consult while coding.\n\nThe reference is organized as an **anatomy** — one entry checklist and four\npillars describing what an application is made of, plus delivery/meta pages\nand an optional pipelines layer.\n\n## Anatomy\n\n- **Build Recipe** — the ordered, agentic checklist for a new app; the entry\n point that walks the whole anatomy.\n\n## Pillar 1 — Domains\n\n- **Layering Rules** — the strict request → domain → repository layering and\n the rules that enforce it.\n\n## Pillar 2 — OpenAPI Spec (the contract)\n\n- **Composition Root** — a single composition point wiring the whole app.\n- **Spec-First Routing** — the OpenAPI contract as the routing source of truth.\n- **CRUD Template** — the standardized generic resource endpoint scaffold.\n- **Auth Integration** — consuming the shared auth-server service (spec-driven,\n no auth code).\n\n## Pillar 3 — Interfaces & Infra\n\n- **Infra Adapters** — how infrastructure connects without leaking into domain.\n\n## Pillar 4 — UI\n\n- **UI: Auto-Rendered Admin & Custom Pages** — the `@aetoskia/react-openapi`\n admin rendered from the spec's x- extensions, and `@aetoskia/react-auth`\n for auth pages; the client template scaffold.\n\n## Pipelines (optional layer)\n\n- **Pipeline Scaffold** — the deterministic pipeline skeleton for multi-stage\n jobs.\n- **Worker & Progress** — queued work, progress reporting, and visibility.\n- **Human-in-the-Loop** — guardrails and approvals inside automations.\n\n## Delivery & Meta\n\n- **Drone Pipelines** — the three CI shapes: a PyPI package, an npm package,\n or a Docker deployment.\n- **Versioning & Changelog** — SemVer, the tag↔version contract, and the\n release changelog.\n- **Repository Inventory** — every covered repo, its type, version source, and\n Drone shape.\n- **Genericity Matrix** — what is library-backed, template-provided, or\n authored.\n- **Library Registry** — the catalog of internal libraries behind all of it.\n\nRead the **Wiki** for the philosophy and rationale; these pages are the\nreference that mirrors the `standards` MCP bundle.\n",
"objects": {}
}
}

View File

@@ -2,7 +2,7 @@
"module": "repository_inventory",
"content": {
"path": "repository_inventory",
"docstring": "# Repository Inventory\n\n## Summary\n\nThe canonical list of Aetos repositories covered by the CI and versioning\nstandards, with the artifact each one produces, its version source, and its\nDrone shape. Every entry maps to the contract in\n[91 – Versioning & Changelog](91_versioning_and_changelog.md) and the two CI\nfiles in [90 – Drone Pipelines](90_drone_pipelines.md).\n\nThe same data is published machine-readably as the `registry://repos` MCP\nresource (and the `list_repos` tool), sourced from `standards/src/repos.json`.\nPaths are relative to the `aetos/` workspace root.\n\n---\n\n## Packages (Type A)\n\nA wheel + sdist published to the private index. Version source is\n`pyproject.toml`; Drone file is `python-lib.yaml` (tag-triggered).\n\n| Repo | Path | Docs | MCP server |\n|------|------|------|------------|\n| `dagpipe` | `dagpipe` | lib, wiki | `dagpipe:8006` |\n| `doc-forge` | `doc-forge` | lib, wiki | `docforge:8001` |\n| `hexa` | `hexa` | lib, wiki | `hexa:8008` |\n| `mail-intake` | `mail-intake` | lib, wiki | `mail_intake:8002` |\n| `mongo-ops` | `mongo-ops` | lib, wiki | `mongo_ops:8007` |\n| `omniread` | `omniread` | lib, wiki | `omniread:8003` |\n| `openapi-first` | `openapi-first` | lib, wiki | `openapi_first:8004` |\n| `py-jwt` | `py-jwt` | lib, wiki | `jwtlib:8005` |\n\n---\n\n## Services (Type B)\n\nA container image pushed to the registry and run on the Docker host. Version\nsource is the `VERSION` file; Drone file is `service-docker.yaml`.\n\n| Repo | Path | Trigger | Docs |\n|------|------|---------|------|\n| `auth-server` | `auth-server` | tag | api, lib, wiki |\n\nTag-triggered and enforces `tag == VERSION`.\n\n---\n\n## Required files per repo\n\n| Type | Version source | Changelog | Drone |\n|------|----------------|-----------|-------|\n| package | `pyproject.toml` | `CHANGELOG.md` | `python-lib.yaml` |\n| service | `VERSION` | `CHANGELOG.md` | `service-docker.yaml` |\n\nEvery covered repository must be able to satisfy `tag == version`:\nthe version file (never a commit id) is bumped in the release MR, the MR is\nmerged, and the tag is created on the merged commit.\n\n---\n\n## Reading this at runtime\n\n- Resource: `registry://repos`\n- Tool: `list_repos()`\n- Humans: this page (mirror of `standards/src/repos.json`).\n\n---\n\n## Related\n\n- [90 – Drone Pipelines](90_drone_pipelines.md) · [91 – Versioning & Changelog](91_versioning_and_changelog.md) · [93 – Library Registry](93_library_registry.md)\n",
"docstring": "# Repository Inventory\n\n## Summary\n\nThe canonical list of Aetos repositories covered by the CI and versioning\nstandards, with the artifact each one produces, its version source, and its\nDrone shape. Every entry maps to the contract in\n[91 – Versioning & Changelog](91_versioning_and_changelog.md) and the three CI\nfiles in [90 – Drone Pipelines](90_drone_pipelines.md).\n\nThe same data is published machine-readably as the `registry://repos` MCP\nresource (and the `list_repos` tool), sourced from `standards/src/repos.json`.\nPaths are relative to the `aetos/` workspace root.\n\n---\n\n## Packages (Type A — PyPI)\n\nA wheel + sdist published to the private index. Version source is\n`pyproject.toml`; Drone file is `python-lib.yaml` (tag-triggered).\n\n| Repo | Path | Docs | MCP server |\n|------|------|------|------------|\n| `dagpipe` | `dagpipe` | lib, wiki | `dagpipe:8006` |\n| `doc-forge` | `doc-forge` | lib, wiki | `docforge:8001` |\n| `hexa` | `hexa` | lib, wiki | `hexa:8008` |\n| `mail-intake` | `mail-intake` | lib, wiki | `mail_intake:8002` |\n| `mongo-ops` | `mongo-ops` | lib, wiki | `mongo_ops:8007` |\n| `omniread` | `omniread` | lib, wiki | `omniread:8003` |\n| `openapi-first` | `openapi-first` | lib, wiki | `openapi_first:8004` |\n| `py-jwt` | `py-jwt` | lib, wiki | `jwtlib:8005` |\n\n---\n\n## Packages (Type B — npm)\n\nA package published to the private npm registry (`npm.aetoskia.com`). Version\nsource is `package.json`; Drone file is `npm-lib.yaml` (tag-triggered).\n\n| Repo | Path | Docs | MCP server |\n|------|------|------|------------|\n| `doc-weaver` | `doc-weaver` | lib, wiki | `doc-weaver:8010` |\n| `react-auth` | `react/react-auth` | lib, wiki | `react-auth:8011` |\n| `react-openapi` | `react/react-openapi` | lib, wiki | `react-openapi:8012` |\n\n---\n\n## Services & Apps (Type C)\n\nA container image pushed to the registry and run on the Docker host. Version\nsource is the `VERSION` file; Drone file is `service-docker.yaml`.\n\n| Repo | Path | Type | Trigger | Docs |\n|------|------|------|---------|------|\n| `auth-server` | `auth-server` | service | tag | api, lib, wiki |\n| `vet` | `react/vet` | app | tag | lib, wiki |\n\nTag-triggered and enforces `tag == VERSION`.\n\n---\n\n## Required files per repo\n\n| Type | Version source | Changelog | Drone |\n|------|----------------|-----------|-------|\n| package (PyPI) | `pyproject.toml` | `CHANGELOG.md` | `python-lib.yaml` |\n| package (npm) | `package.json` | `CHANGELOG.md` | `npm-lib.yaml` |\n| service / app | `VERSION` | `CHANGELOG.md` | `service-docker.yaml` |\n\nEvery covered repository must be able to satisfy `tag == version`:\nthe version file (never a commit id) is bumped in the release MR, the MR is\nmerged, and the tag is created on the merged commit.\n\n---\n\n## Reading this at runtime\n\n- Resource: `registry://repos`\n- Tool: `list_repos()`\n- Humans: this page (mirror of `standards/src/repos.json`).\n\n---\n\n## Related\n\n- [90 – Drone Pipelines](90_drone_pipelines.md) · [91 – Versioning & Changelog](91_versioning_and_changelog.md) · [93 – Library Registry](93_library_registry.md)\n",
"objects": {}
}
}

File diff suppressed because one or more lines are too long

View File

@@ -1,6 +1,6 @@
{
"project": "standards",
"note": "Machine-readable repository inventory. Mirrors standards/src/lib/15_repository_inventory.md. Read it with the registry://repos resource or the list_repos tool.",
"note": "Machine-readable repository inventory. Mirrors standards/src/lib/94_repository_inventory.md. Read it with the registry://repos resource or the list_repos tool.",
"path_convention": "relative to the aetos/ workspace root",
"repos": [
{
@@ -35,6 +35,22 @@
"port": 8001
}
},
{
"name": "doc-weaver",
"type": "package",
"path": "doc-weaver",
"version_source": "package.json",
"drone": "npm-lib.yaml",
"trigger": "tag",
"docs": [
"lib",
"wiki"
],
"mcp": {
"server": "doc-weaver",
"port": 8010
}
},
{
"name": "hexa",
"type": "package",
@@ -131,6 +147,38 @@
"port": 8005
}
},
{
"name": "react-auth",
"type": "package",
"path": "react/react-auth",
"version_source": "package.json",
"drone": "npm-lib.yaml",
"trigger": "tag",
"docs": [
"lib",
"wiki"
],
"mcp": {
"server": "react-auth",
"port": 8011
}
},
{
"name": "react-openapi",
"type": "package",
"path": "react/react-openapi",
"version_source": "package.json",
"drone": "npm-lib.yaml",
"trigger": "tag",
"docs": [
"lib",
"wiki"
],
"mcp": {
"server": "react-openapi",
"port": 8012
}
},
{
"name": "auth-server",
"type": "service",
@@ -144,6 +192,19 @@
"wiki"
],
"mcp": null
},
{
"name": "vet",
"type": "app",
"path": "react/vet",
"version_source": "VERSION",
"drone": "service-docker.yaml",
"trigger": "tag",
"docs": [
"lib",
"wiki"
],
"mcp": null
}
]
}

View File

@@ -0,0 +1,2 @@
VITE_API_BASE_URL=http://localhost:8000
VITE_AUTH_BASE_URL=http://localhost:8000

View File

@@ -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/

View File

@@ -0,0 +1 @@
registry=https://npm.aetoskia.com

View File

@@ -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).
<!--
Keep [Unreleased] current as you land changes. On release, reconcile it against
the change (git log / git diff since the last tag), add any missing user-facing
entries, and get the user's approval before promoting it into a version heading.
See standards: Versioning & Changelog (registry://release).
-->
## [Unreleased]

View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>App Title</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

View File

@@ -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"
}
}

View File

@@ -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 (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
<Link color="text.secondary" href="https://www.aetoskia.com/">
{'Copyright © Aetoskia Internal Infrastructure — All rights reserved.'}
</Link>
&nbsp;
{new Date().getFullYear()}
</Typography>
);
}
/**
* Fixed bottom app bar showing the site copyright.
*
* @returns The footer `AppBar` element.
*/
export default function Footer() {
return (
<AppBar
position="fixed"
color="default"
sx={{
top: 'auto',
bottom: 0,
zIndex: (theme) => theme.zIndex.drawer + 1,
backdropFilter: 'blur(8px)',
boxShadow: 'none',
borderTop: '1px solid',
borderColor: 'divider',
}}
>
<Toolbar>
<Container
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: { xs: 2, sm: 4 },
textAlign: { sm: 'center', md: 'left' },
}}
>
<Copyright />
</Container>
</Toolbar>
</AppBar>
);
}

View File

@@ -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 (
<AppBar
position="fixed"
color="default"
sx={{
zIndex: (theme) => theme.zIndex.drawer + 1,
backdropFilter: "blur(8px)",
boxShadow: "none",
borderBottom: "1px solid",
borderColor: "divider",
}}
>
<Toolbar>
<DashboardIcon sx={{ mr: 1.5, color: "primary.main" }} />
<Typography
variant="h6"
noWrap
sx={{ fontWeight: "bold", cursor: "pointer" }}
onClick={() => navigate("/")}
>
App Title
</Typography>
<span style={{ flexGrow: 1 }} />
{currentUser && (
<Chip
size="small"
label={currentUser.username ?? currentUser.email ?? "Signed in"}
sx={{ mr: 1 }}
/>
)}
<Button
color="inherit"
onClick={() => navigate("/admin")}
sx={{ textTransform: "none", fontWeight: 500, mr: 1 }}
>
Admin
</Button>
{currentUser && (
<Tooltip title="Log out">
<IconButton size="small" onClick={() => logout()} aria-label="Log out">
<LogoutIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
</Toolbar>
</AppBar>
);
}

View File

@@ -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/<resource>`.
*
* Props:
* - `resource` - the `ResourceConfig` to summarize.
*/
function ResourceCard({ resource }: { resource: ResourceConfig }) {
const navigate = useNavigate();
const { list } = useResource(resource.name);
const [count, setCount] = useState<number | null>(null);
useEffect(() => {
if (resource.streaming) return;
list({ limit: 0 }).then((res) => setCount(res.total ?? res.items?.length ?? 0));
}, [resource.name]);
return (
<Paper
elevation={0}
onClick={() => 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 },
}}
>
<Typography variant="subtitle1" fontWeight={700}>
{resource.displayName}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
{resource.fields.length} fields
</Typography>
<Box sx={{ mt: 1.5, display: "flex", alignItems: "center", gap: 1 }}>
{resource.streaming ? (
<SseConnectionStatus resourceName={resource.name} />
) : (
<Chip
label={count != null ? `${count} records` : "..."}
size="small"
color={count != null && count > 0 ? "primary" : "default"}
variant="outlined"
/>
)}
<Chip label={resource.displayFormat} size="small" variant="outlined" />
</Box>
</Paper>
);
}
/**
* 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<any[] | null>(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 (
<Box sx={{ display: "flex", justifyContent: "center", py: 4 }}>
<CircularProgress size={24} />
</Box>
);
}
if (rows.length === 0) {
return <Typography variant="body2" color="text.secondary">No records found.</Typography>;
}
return (
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow>
{columns.map((col) => (
<TableCell key={col.name} sx={{ fontWeight: 700 }}>
{col.label}
</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{rows.map((row, i) => (
<TableRow key={i}>
{columns.map((col) => {
const displayFormat = col.fk
? (allResources.find((r) => r.name === col.fk!.resource)?.displayFormat ?? resource.displayFormat)
: resource.displayFormat;
return (
<TableCell key={col.name}>
<ListCellRenderer
field={col}
value={row[col.name]}
displayFormat={displayFormat}
/>
</TableCell>
);
})}
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
);
}
/**
* 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 (
<Container maxWidth="lg" sx={{ py: 6, textAlign: "center" }}>
<Typography color="text.secondary">
No resources found in the OpenAPI spec.
</Typography>
</Container>
);
}
return (
<Container maxWidth="lg" sx={{ py: 4 }}>
<Box sx={{ mb: 4, textAlign: "center" }}>
<Typography variant="h4" fontWeight={800} gutterBottom>
App Title
</Typography>
<Typography variant="body1" color="text.secondary" sx={{ maxWidth: 600, mx: "auto" }}>
Auto-generated CRUD from an OpenAPI 3.1 spec using{" "}
<Typography component="span" fontWeight={700} color="primary">
@aetoskia/react-openapi
</Typography>
.
</Typography>
</Box>
<Typography variant="h6" fontWeight={700} sx={{ mb: 2 }}>
Resources
</Typography>
<Grid container spacing={2} sx={{ mb: 5 }}>
{topLevel.map((r) => (
<Grid key={r.name} item xs={12} sm={6} md={3}>
<ResourceCard resource={r} />
</Grid>
))}
</Grid>
<Typography variant="h6" fontWeight={700} sx={{ mb: 2 }}>
Live Preview
{previewResource && (
<Typography variant="caption" color="text.secondary" sx={{ ml: 1 }}>
— first 5 records from <strong>{previewResource.displayName}</strong>
</Typography>
)}
</Typography>
{previewResource && <LiveTable resource={previewResource} />}
</Container>
);
}

View File

@@ -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 (
<AppTheme>
<CssBaseline enableColorScheme />
<AuthPage
mode={mode}
onBack={() => {}}
onSwitchMode={() => setMode((m) => (m === "login" ? "register" : "login"))}
login={auth.login}
register={auth.register}
loading={auth.loading}
error={auth.error}
currentUser={auth.currentUser}
/>
</AppTheme>
);
}
return (
<AppProvider specConfiguration={specConfiguration}>
<BrowserRouter>
<AppTheme>
<CssBaseline enableColorScheme />
<Header />
<Box sx={{ pb: 8 }}>
<Toolbar />
<Routes>
<Route path="/" element={<Home />} />
<Route path="/admin/*" element={<Admin basePath="/admin" />} />
</Routes>
</Box>
<Footer />
</AppTheme>
</BrowserRouter>
</AppProvider>
);
}
const rootElement = document.getElementById('root');
const root = createRoot(rootElement);
root.render(
<AuthProvider authConfig={authConfig}>
<LoginGate />
</AuthProvider>
);

View File

@@ -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(),
};

View File

@@ -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<ColorModeContextValue>({
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
* <AppTheme defaultMode="dark">
* <CssBaseline />
* <App />
* </AppTheme>
* ```
*/
export default function AppTheme({
children,
defaultMode = "light",
}: AppThemeProps) {
const [mode, setMode] =
React.useState<ColorMode>(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 (
<ColorModeContext.Provider value={contextValue}>
<ThemeProvider theme={theme}>
<CssBaseline />
<Box
sx={{
"--bg-page": semantic.surface.page,
"--bg-card": semantic.surface.card,
"--bg-elevated": semantic.surface.elevated,
"--border-default": semantic.border.default,
"--border-subtle": semantic.border.subtle,
"--text-primary": semantic.text.primary,
"--text-secondary": semantic.text.secondary,
"--text-muted": semantic.text.muted,
}}
>
{children}
</Box>
</ThemeProvider>
</ColorModeContext.Provider>
);
}

View File

@@ -0,0 +1,243 @@
/**
* MUI style overrides for data-display components.
*
* Exports `dataDisplayCustomizations`, a `Components<Theme>` 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<Theme> = {
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',
},
},
],
},
},
},
};

View File

@@ -0,0 +1,56 @@
/**
* MUI style overrides for feedback components.
*
* Exports `feedbackCustomizations`, a `Components<Theme>` 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<Theme> = {
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],
}),
}),
},
},
};

View File

@@ -0,0 +1,456 @@
/**
* MUI style overrides for form inputs and buttons.
*
* Exports `inputsCustomizations`, a `Components<Theme>` 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<Theme> = {
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: (
<CheckBoxOutlineBlankRoundedIcon sx={{ color: 'hsla(210, 0%, 0%, 0.0)' }} />
),
checkedIcon: <CheckRoundedIcon sx={{ height: 14, width: 14 }} />,
indeterminateIcon: <RemoveRoundedIcon sx={{ height: 14, width: 14 }} />,
},
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,
}),
},
},
};

View File

@@ -0,0 +1,290 @@
/**
* MUI style overrides for navigation components.
*
* Exports `navigationCustomizations`, a `Components<Theme>` 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<Theme> = {
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<SVGSVGElement, SvgIconProps>((props, ref) => (
<UnfoldMoreRoundedIcon fontSize="small" {...props} ref={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 }),
},
}),
},
},
};

View File

@@ -0,0 +1,123 @@
/**
* MUI style overrides for surface components.
*
* Exports `surfacesCustomizations`, a `Components<Theme>` 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<Theme> = {
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,
},
},
},
};

View File

@@ -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;
}
}

View File

@@ -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],
},
};
}

View File

@@ -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;

View File

@@ -0,0 +1,10 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_BASE_URL: string;
readonly VITE_AUTH_BASE_URL: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

View File

@@ -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"]
}

View File

@@ -0,0 +1,6 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
});