33 - Use theme tokens in primitives and replace template boilerplate

This commit is contained in:
myrmidex 2026-06-28 11:03:16 +02:00
parent 79367b3de0
commit f76c7b9ecd
8 changed files with 57 additions and 40 deletions

View file

@ -1,32 +1,38 @@
# React + TypeScript + Vite # Buckets — Frontend
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules. Standalone React SPA for the Buckets budgeting app. Talks to the Symfony +
API Platform backend over a cookie-session API (`/api`).
Currently, two official plugins are available: ## Stack
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) - Vite + React + TypeScript (strict)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) - Tailwind 4 (`@tailwindcss/vite`) — dark-only "80s terminal" theme
- oxlint (lint) + Prettier (format)
## React Compiler ## Development
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). The frontend runs as the `frontend` service in the project's compose stack
(podman-compose, dev-only — defined in `compose.override.yaml`). Bring the stack
up from the repo root (`dev-up`) and the Vite dev server is published on
**http://localhost:5173**.
## Expanding the Oxlint configuration The dev server proxies `/api` to the backend container (`http://php:80`) so the
session cookie flows same-origin — no CORS in dev.
If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`: Run commands inside the container:
```json ```
{ podman exec buckets-budget_frontend_1 npm run build # tsc -b && vite build
"$schema": "./node_modules/oxlint/configuration_schema.json", podman exec buckets-budget_frontend_1 npm run lint # oxlint
"plugins": ["react", "typescript", "oxc"], podman exec buckets-budget_frontend_1 npm run format # prettier --write
"options": { podman exec buckets-budget_frontend_1 npm run format:check # prettier --check
"typeAware": true
},
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
``` ```
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories. ## Structure
- `src/theme.css` — Tailwind import + the 80s terminal theme (OKLCH tokens, glow,
7-segment font)
- `src/components/ui/` — shared primitives (Button, Panel, DigitalProgressBar)
- `src/components/layout/` — the app shell (AppLayout)
- `src/lib/api.ts` — cookie-session API client (get/post/patch/delete, JSON-LD)
- `src/types/api.ts` — Hydra/JSON-LD envelope types

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 242 B

View file

@ -3,6 +3,8 @@ import Button from './components/ui/Button'
import DigitalProgressBar from './components/ui/DigitalProgressBar' import DigitalProgressBar from './components/ui/DigitalProgressBar'
import Panel from './components/ui/Panel' import Panel from './components/ui/Panel'
// Scaffold-only demo composition proving the primitives + theme render.
// Replaced by real routed pages in the frontend feature tickets (#52#56).
function App() { function App() {
return ( return (
<AppLayout> <AppLayout>

View file

@ -7,7 +7,7 @@ interface AppLayoutProps {
export default function AppLayout({ children }: AppLayoutProps) { export default function AppLayout({ children }: AppLayoutProps) {
return ( return (
<div className="bg-background text-foreground min-h-screen font-mono"> <div className="bg-background text-foreground min-h-screen font-mono">
<header className="glow-red border-b-4 border-red-500 px-6 py-4"> <header className="glow-red border-primary border-b-4 px-6 py-4">
<h1 className="text-primary text-2xl font-bold uppercase tracking-widest"> <h1 className="text-primary text-2xl font-bold uppercase tracking-widest">
BUCKETS BUCKETS
</h1> </h1>

View file

@ -9,7 +9,7 @@ export default function Button({ glow, className, ...props }: ButtonProps) {
return ( return (
<button <button
className={cn( className={cn(
'border-2 border-red-500 bg-black px-4 py-2 font-mono font-bold uppercase tracking-wider text-red-500 transition-colors hover:bg-red-500 hover:text-black', 'border-primary text-primary hover:bg-primary hover:text-primary-foreground border-2 bg-background px-4 py-2 font-mono font-bold uppercase tracking-wider transition-colors',
glow && 'glow-red', glow && 'glow-red',
className, className,
)} )}

View file

@ -5,19 +5,21 @@ interface DigitalProgressBarProps {
const BAR_COUNT = 20 const BAR_COUNT = 20
// Zone colors are driven by the theme's chart tokens (chart-1 red → chart-2 /**
// amber → chart-3 green) so they stay in sync with the palette; the glow is * Color zones by bar index, driven by the theme's chart tokens (chart-1 red
// derived from the same token via color-mix rather than a duplicated literal. * chart-2 amber chart-3 green) so they stay in sync with the palette. `token`
function getZoneToken(index: number): string { * feeds the glow via color-mix; `className` is the Tailwind utility for the fill
if (index < 10) return 'var(--chart-1)' * both reference the same source so they can't drift. `upTo` is the exclusive
if (index < 18) return 'var(--chart-2)' * upper bound of each zone.
return 'var(--chart-3)' */
} const ZONES = [
{ upTo: 10, token: 'var(--chart-1)', className: 'bg-chart-1' },
{ upTo: 18, token: 'var(--chart-2)', className: 'bg-chart-2' },
{ upTo: BAR_COUNT, token: 'var(--chart-3)', className: 'bg-chart-3' },
] as const
function getBarColor(index: number): string { function getZone(index: number) {
if (index < 10) return 'bg-chart-1' return ZONES.find((zone) => index < zone.upTo) ?? ZONES[ZONES.length - 1]
if (index < 18) return 'bg-chart-2'
return 'bg-chart-3'
} }
export default function DigitalProgressBar({ export default function DigitalProgressBar({
@ -40,6 +42,7 @@ export default function DigitalProgressBar({
> >
{Array.from({ length: BAR_COUNT }, (_, i) => { {Array.from({ length: BAR_COUNT }, (_, i) => {
const filled = i < filledBars const filled = i < filledBars
const zone = getZone(i)
return ( return (
<div <div
key={i} key={i}
@ -47,10 +50,10 @@ export default function DigitalProgressBar({
borderRadius: '3px', borderRadius: '3px',
transition: 'background-color 300ms, box-shadow 300ms', transition: 'background-color 300ms, box-shadow 300ms',
boxShadow: filled boxShadow: filled
? `0 0 8px color-mix(in oklch, ${getZoneToken(i)} 60%, transparent)` ? `0 0 8px color-mix(in oklch, ${zone.token} 60%, transparent)`
: 'none', : 'none',
}} }}
className={filled ? getBarColor(i) : 'bg-chart-1/10'} className={filled ? zone.className : 'bg-chart-1/10'}
/> />
) )
})} })}

View file

@ -6,7 +6,7 @@ type PanelProps = HTMLAttributes<HTMLDivElement>
export default function Panel({ className, ...props }: PanelProps) { export default function Panel({ className, ...props }: PanelProps) {
return ( return (
<div <div
className={cn('glow-red border-4 border-red-500 bg-black p-6', className)} className={cn('glow-red border-primary border-4 bg-card p-6', className)}
{...props} {...props}
/> />
) )

View file

@ -22,6 +22,9 @@
box-shadow: 0 0 25px rgba(239, 68, 68, 0.6); box-shadow: 0 0 25px rgba(239, 68, 68, 0.6);
} }
/* Reserved: the app is dark-only (palette lives unconditionally in :root), so
* no `dark:` utilities are used yet. Kept for forward-compatibility if a light
* mode is ever added. */
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
@theme { @theme {