feat: [frontend] Add shadcn-svelte

This commit is contained in:
wilson 2026-04-17 07:19:41 +01:00
parent 517e2bf90e
commit dd069c470e
9 changed files with 568 additions and 416 deletions

20
frontend/components.json Normal file
View file

@ -0,0 +1,20 @@
{
"$schema": "https://shadcn-svelte.com/schema.json",
"tailwind": {
"css": "src/app.css",
"baseColor": "neutral"
},
"aliases": {
"components": "$lib/components",
"utils": "$lib/utils",
"ui": "$lib/components/ui",
"hooks": "$lib/hooks",
"lib": "$lib"
},
"typescript": true,
"registry": "https://shadcn-svelte.com/registry",
"style": "vega",
"iconLibrary": "lucide",
"menuColor": "default",
"menuAccent": "subtle"
}

View file

@ -1,6 +1,6 @@
# Frontend Architecture # Frontend Architecture
This document describes the software architecture and aptterns for the web application for language learning application. This document describes the software architecture and aptterns for the web application for language learning application.
This is a web application built using Svelte Kit v5, running on the NodeJS adapter. This is a web application built using Svelte Kit v5, running on the NodeJS adapter.
@ -12,19 +12,21 @@ Where possible, this application will use Progressive Web App technologies, to i
This application runs on the NodeJS adapter for Svelte-Kit, meaning it has both a client and server available, and it makes use of both. This application runs on the NodeJS adapter for Svelte-Kit, meaning it has both a client and server available, and it makes use of both.
The main other component in the language learning app system is the Python-written fastapi HTTP API. The best place to understand all components of the system is through [the root docker-compose](../../docker-compose.yml) The main other component in the language learning app system is the Python-written fastapi HTTP API. The best place to understand all components of the system is through [the root docker-compose](../../docker-compose.yml)
## Authentication ## Authentication
Authentication with the HTTP server is through the `Authorization` header, which contains a JWT token. Authentication with the HTTP server is through the `Authorization` header, which contains a JWT token.
This token contains server-validated information, e.g. account roles. We must therefore verify the integrety of this token with the `PRIVATE_JWT_SECRET` environment variable. This token contains server-validated information, e.g. account roles. We must therefore verify the integrety of this token with the `PRIVATE_JWT_SECRET` environment variable.
Token and role checking is centralised into the `src/hooks.server.ts` file, which allows authentication on _every_ request. Token and role checking is centralised into the `src/hooks.server.ts` file, which allows authentication on _every_ request.
## Components ## Components
It is bad practice to simply have a `+page.svelte` component contain all aspects of a page. When convenient, code should be split into smaller component files. For "boring" screens (settings pages, admin pages, cms-like pages) use shadcn-svelte components to create sensible defaults and uninteresting User Interfaces.
It is bad practice to simply have a `+page.svelte` component contain all aspects of a page. When convenient, code should be split into smaller component files.
Where components aren't shared outside of a single page, they live as siblings to the `+page.svelte` file. Where components aren't shared outside of a single page, they live as siblings to the `+page.svelte` file.
@ -34,6 +36,6 @@ Where components are shared, or are likely to be, they live in `src/components`
Read [design.md](./design.md) for aesthetic information. Read [design.md](./design.md) for aesthetic information.
Application-wide styles live in `src/app.css`. This is where e.g. form and typographic information live, as well as a lot of design tokens, usually as custom values (i.e. variables in CSS). Application-wide styles live in `src/app.css`. This is where e.g. form and typographic information live, as well as a lot of design tokens, usually as custom values (i.e. variables in CSS).
Component-level styling should use CSS, which should be object/component oriented, rather than utility-class driven. Where possible, design tokens for spacing, colours, etc. should be used for consistenty. Component-level styling should use CSS, which should be object/component oriented, rather than utility-class driven. Where possible, design tokens for spacing, colours, etc. should be used for consistenty.

View file

@ -17,14 +17,17 @@
"test": "npm run test:unit -- --run" "test": "npm run test:unit -- --run"
}, },
"devDependencies": { "devDependencies": {
"@eslint/compat": "^2.0.2", "@eslint/compat": "latest",
"@eslint/js": "^9.39.2", "@eslint/js": "latest",
"@fontsource-variable/inter": "^5.2.8",
"@hey-api/openapi-ts": "0.94.4", "@hey-api/openapi-ts": "0.94.4",
"@lucide/svelte": "^1.8.0",
"@sveltejs/adapter-node": "^5.5.4", "@sveltejs/adapter-node": "^5.5.4",
"@sveltejs/kit": "^2.50.2", "@sveltejs/kit": "^2.50.2",
"@sveltejs/vite-plugin-svelte": "^6.2.4", "@sveltejs/vite-plugin-svelte": "^6.2.4",
"@types/node": "^22", "@types/node": "^22",
"@vitest/browser-playwright": "^4.1.0", "@vitest/browser-playwright": "^4.1.0",
"clsx": "^2.1.1",
"eslint": "^9.39.2", "eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.14.0", "eslint-plugin-svelte": "^3.14.0",
@ -32,8 +35,12 @@
"playwright": "^1.58.2", "playwright": "^1.58.2",
"prettier": "^3.8.1", "prettier": "^3.8.1",
"prettier-plugin-svelte": "^3.4.1", "prettier-plugin-svelte": "^3.4.1",
"shadcn-svelte": "^1.2.7",
"svelte": "^5.51.0", "svelte": "^5.51.0",
"svelte-check": "^4.4.2", "svelte-check": "^4.4.2",
"tailwind-merge": "^3.5.0",
"tailwind-variants": "^3.2.2",
"tw-animate-css": "^1.4.0",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"typescript-eslint": "^8.54.0", "typescript-eslint": "^8.54.0",
"vite": "^7.3.1", "vite": "^7.3.1",
@ -42,6 +49,8 @@
}, },
"dependencies": { "dependencies": {
"deepl-node": "^1.24.0", "deepl-node": "^1.24.0",
"jose": "^6.2.2",
"tailwindcss": "^4.2.2",
"valibot": "^1.3.1" "valibot": "^1.3.1"
} }
} }

View file

@ -11,19 +11,31 @@ importers:
deepl-node: deepl-node:
specifier: ^1.24.0 specifier: ^1.24.0
version: 1.24.0 version: 1.24.0
jose:
specifier: ^6.2.2
version: 6.2.2
tailwindcss:
specifier: ^4.2.2
version: 4.2.2
valibot: valibot:
specifier: ^1.3.1 specifier: ^1.3.1
version: 1.3.1(typescript@5.9.3) version: 1.3.1(typescript@5.9.3)
devDependencies: devDependencies:
'@eslint/compat': '@eslint/compat':
specifier: ^2.0.2 specifier: latest
version: 2.0.3(eslint@9.39.4(jiti@2.6.1)) version: 2.0.3(eslint@9.39.4(jiti@2.6.1))
'@eslint/js': '@eslint/js':
specifier: ^9.39.2 specifier: latest
version: 9.39.4 version: 9.39.4
'@fontsource-variable/inter':
specifier: ^5.2.8
version: 5.2.8
'@hey-api/openapi-ts': '@hey-api/openapi-ts':
specifier: 0.94.4 specifier: 0.94.4
version: 0.94.4(typescript@5.9.3) version: 0.94.4(typescript@5.9.3)
'@lucide/svelte':
specifier: ^1.8.0
version: 1.8.0(svelte@5.54.1)
'@sveltejs/adapter-node': '@sveltejs/adapter-node':
specifier: ^5.5.4 specifier: ^5.5.4
version: 5.5.4(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.54.1)(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)))(svelte@5.54.1)(typescript@5.9.3)(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1))) version: 5.5.4(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.54.1)(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)))(svelte@5.54.1)(typescript@5.9.3)(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)))
@ -39,6 +51,9 @@ importers:
'@vitest/browser-playwright': '@vitest/browser-playwright':
specifier: ^4.1.0 specifier: ^4.1.0
version: 4.1.0(playwright@1.58.2)(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1))(vitest@4.1.0) version: 4.1.0(playwright@1.58.2)(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1))(vitest@4.1.0)
clsx:
specifier: ^2.1.1
version: 2.1.1
eslint: eslint:
specifier: ^9.39.2 specifier: ^9.39.2
version: 9.39.4(jiti@2.6.1) version: 9.39.4(jiti@2.6.1)
@ -60,12 +75,24 @@ importers:
prettier-plugin-svelte: prettier-plugin-svelte:
specifier: ^3.4.1 specifier: ^3.4.1
version: 3.5.1(prettier@3.8.1)(svelte@5.54.1) version: 3.5.1(prettier@3.8.1)(svelte@5.54.1)
shadcn-svelte:
specifier: ^1.2.7
version: 1.2.7(svelte@5.54.1)
svelte: svelte:
specifier: ^5.51.0 specifier: ^5.51.0
version: 5.54.1 version: 5.54.1
svelte-check: svelte-check:
specifier: ^4.4.2 specifier: ^4.4.2
version: 4.4.5(picomatch@4.0.3)(svelte@5.54.1)(typescript@5.9.3) version: 4.4.5(picomatch@4.0.3)(svelte@5.54.1)(typescript@5.9.3)
tailwind-merge:
specifier: ^3.5.0
version: 3.5.0
tailwind-variants:
specifier: ^3.2.2
version: 3.2.2(tailwind-merge@3.5.0)(tailwindcss@4.2.2)
tw-animate-css:
specifier: ^1.4.0
version: 1.4.0
typescript: typescript:
specifier: ^5.9.3 specifier: ^5.9.3
version: 5.9.3 version: 5.9.3
@ -294,6 +321,9 @@ packages:
resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@fontsource-variable/inter@5.2.8':
resolution: {integrity: sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ==}
'@hey-api/codegen-core@0.7.4': '@hey-api/codegen-core@0.7.4':
resolution: {integrity: sha512-DGd9yeSQzflOWO3Y5mt1GRXkXH9O/yIMgbxPjwLI3jwu/3nAjoXXD26lEeFb6tclYlg0JAqTIs5d930G/qxHeA==} resolution: {integrity: sha512-DGd9yeSQzflOWO3Y5mt1GRXkXH9O/yIMgbxPjwLI3jwu/3nAjoXXD26lEeFb6tclYlg0JAqTIs5d930G/qxHeA==}
engines: {node: '>=20.19.0'} engines: {node: '>=20.19.0'}
@ -351,6 +381,11 @@ packages:
'@jsdevtools/ono@7.1.3': '@jsdevtools/ono@7.1.3':
resolution: {integrity: sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==} resolution: {integrity: sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==}
'@lucide/svelte@1.8.0':
resolution: {integrity: sha512-+zYQUKqEOVP5lxbGmxL1OVgGMQtRK91eIJ0bR+3Cr1ts4oQEsQfxyzzd5X47psJlblAuGFrl2xm4YuATjR9oaA==}
peerDependencies:
svelte: ^5
'@playwright/test@1.58.2': '@playwright/test@1.58.2':
resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==} resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==}
engines: {node: '>=18'} engines: {node: '>=18'}
@ -1198,6 +1233,9 @@ packages:
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
hasBin: true hasBin: true
jose@6.2.2:
resolution: {integrity: sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==}
js-yaml@4.1.1: js-yaml@4.1.1:
resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
hasBin: true hasBin: true
@ -1467,6 +1505,12 @@ packages:
set-cookie-parser@3.1.0: set-cookie-parser@3.1.0:
resolution: {integrity: sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==} resolution: {integrity: sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==}
shadcn-svelte@1.2.7:
resolution: {integrity: sha512-mWuQk4H4gtV+J2wJQ7nEPKNnB/v86AALFryZU0SSM7ChHmJJMZ1kH+qIuxYKrXm9vOOOcSWHRsWzPDB71DnjYA==}
hasBin: true
peerDependencies:
svelte: ^5.0.0
shebang-command@2.0.0: shebang-command@2.0.0:
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
engines: {node: '>=8'} engines: {node: '>=8'}
@ -1525,6 +1569,22 @@ packages:
resolution: {integrity: sha512-ow8tncN097Ty8U1H+C3bM1xNlsCbnO2UZeN0lWBnv8f3jKho7QTTQ2LWbMXrPQDodLjH91n4kpNnLolyRhVE6A==} resolution: {integrity: sha512-ow8tncN097Ty8U1H+C3bM1xNlsCbnO2UZeN0lWBnv8f3jKho7QTTQ2LWbMXrPQDodLjH91n4kpNnLolyRhVE6A==}
engines: {node: '>=18'} engines: {node: '>=18'}
tailwind-merge@3.5.0:
resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==}
tailwind-variants@3.2.2:
resolution: {integrity: sha512-Mi4kHeMTLvKlM98XPnK+7HoBPmf4gygdFmqQPaDivc3DpYS6aIY6KiG/PgThrGvii5YZJqRsPz0aPyhoFzmZgg==}
engines: {node: '>=16.x', pnpm: '>=7.x'}
peerDependencies:
tailwind-merge: '>=3.0.0'
tailwindcss: '*'
peerDependenciesMeta:
tailwind-merge:
optional: true
tailwindcss@4.2.2:
resolution: {integrity: sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==}
tinybench@2.9.0: tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
@ -1550,6 +1610,9 @@ packages:
peerDependencies: peerDependencies:
typescript: '>=4.8.4' typescript: '>=4.8.4'
tw-animate-css@1.4.0:
resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==}
type-check@0.4.0: type-check@0.4.0:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
@ -1855,6 +1918,8 @@ snapshots:
'@eslint/core': 0.17.0 '@eslint/core': 0.17.0
levn: 0.4.1 levn: 0.4.1
'@fontsource-variable/inter@5.2.8': {}
'@hey-api/codegen-core@0.7.4': '@hey-api/codegen-core@0.7.4':
dependencies: dependencies:
'@hey-api/types': 0.1.4 '@hey-api/types': 0.1.4
@ -1930,6 +1995,10 @@ snapshots:
'@jsdevtools/ono@7.1.3': {} '@jsdevtools/ono@7.1.3': {}
'@lucide/svelte@1.8.0(svelte@5.54.1)':
dependencies:
svelte: 5.54.1
'@playwright/test@1.58.2': '@playwright/test@1.58.2':
dependencies: dependencies:
playwright: 1.58.2 playwright: 1.58.2
@ -2775,6 +2844,8 @@ snapshots:
jiti@2.6.1: {} jiti@2.6.1: {}
jose@6.2.2: {}
js-yaml@4.1.1: js-yaml@4.1.1:
dependencies: dependencies:
argparse: 2.0.1 argparse: 2.0.1
@ -3015,6 +3086,14 @@ snapshots:
set-cookie-parser@3.1.0: {} set-cookie-parser@3.1.0: {}
shadcn-svelte@1.2.7(svelte@5.54.1):
dependencies:
commander: 14.0.3
node-fetch-native: 1.6.7
postcss: 8.5.8
svelte: 5.54.1
tailwind-merge: 3.5.0
shebang-command@2.0.0: shebang-command@2.0.0:
dependencies: dependencies:
shebang-regex: 3.0.0 shebang-regex: 3.0.0
@ -3086,6 +3165,16 @@ snapshots:
magic-string: 0.30.21 magic-string: 0.30.21
zimmerframe: 1.1.4 zimmerframe: 1.1.4
tailwind-merge@3.5.0: {}
tailwind-variants@3.2.2(tailwind-merge@3.5.0)(tailwindcss@4.2.2):
dependencies:
tailwindcss: 4.2.2
optionalDependencies:
tailwind-merge: 3.5.0
tailwindcss@4.2.2: {}
tinybench@2.9.0: {} tinybench@2.9.0: {}
tinyexec@1.0.4: {} tinyexec@1.0.4: {}
@ -3103,6 +3192,8 @@ snapshots:
dependencies: dependencies:
typescript: 5.9.3 typescript: 5.9.3
tw-animate-css@1.4.0: {}
type-check@0.4.0: type-check@0.4.0:
dependencies: dependencies:
prelude-ls: 1.2.1 prelude-ls: 1.2.1

View file

@ -1,405 +1,15 @@
/* ========================================================================== @import "tw-animate-css";
Design System: The Editorial Stillness @import "shadcn-svelte/tailwind.css";
========================================================================== */ @import "@fontsource-variable/inter";
/* -------------------------------------------------------------------------- @layer base {
Fonts * {
-------------------------------------------------------------------------- */ @apply border-border outline-ring/50;
}
@font-face { body {
font-family: archivo; /* set name */ @apply bg-background text-foreground;
src: url('/fonts/Archivo-Medium.woff2'); /* url of the font */ }
font-weight: 500; html {
} @apply font-sans;
}
@font-face {
font-family: archivo; /* set name */
src: url('/fonts/Archivo-Regular.woff2'); /* url of the font */
font-weight: normal;
}
/* --------------------------------------------------------------------------
Design Tokens
-------------------------------------------------------------------------- */
:root {
/* --- Color: Primary --- */
--color-primary: #516356;
--color-on-primary: #e9fded;
--color-primary-container: #c8d8c8;
--color-on-primary-container: #2f342e;
/* --- Color: Secondary --- */
--color-secondary-container: #dde4de;
--color-on-secondary-container: #2f342e;
/* --- Color: Surface Hierarchy (light → dark = elevated → recessed) --- */
--color-surface-container-lowest: #ffffff; /* most elevated */
--color-surface-container-low: #f4f4ef;
--color-surface: #faf9f5; /* base */
--color-surface-container: #eeede9;
--color-surface-container-high: #e8e8e3;
--color-surface-container-highest: #e2e1dd;
--color-surface-dim: #d6dcd2; /* recessed utility */
/* --- Color: On-Surface --- */
--color-on-surface: #2f342e; /* replaces pure black */
--color-on-surface-variant: #5c605b;
/* --- Color: Outline --- */
--color-outline: #8c908b;
--color-outline-variant: #afb3ac; /* use at 20% opacity as "ghost border" */
/* --- Typography: Font Families --- */
--font-display: 'archivo', sans-serif;
--font-body: 'Newsreader', serif;
--font-label: 'Inter', sans-serif;
/* --- Typography: Scale --- */
--text-display-lg: 3.5rem; /* article titles */
--text-display-md: 2.75rem;
--text-display-sm: 2.25rem;
--text-headline-lg: 1.875rem;
--text-headline-md: 1.5rem;
--text-headline-sm: 1.25rem;
--text-title-lg: 1.125rem;
--text-title-md: 1rem;
--text-body-xl: 1.25rem; /* long-form reading standard */
--text-body-lg: 1rem;
--text-body-md: 0.9375rem;
--text-body-sm: 0.875rem;
--text-label-lg: 0.875rem;
--text-label-md: 0.75rem; /* metadata, all-caps */
--text-label-sm: 0.6875rem;
/* --- Typography: Weights --- */
--weight-light: 300;
--weight-regular: 400;
--weight-medium: 500;
--weight-semibold: 600;
--weight-bold: 700;
/* --- Typography: Line Height --- */
--leading-tight: 1.2;
--leading-snug: 1.375;
--leading-normal: 1.5;
--leading-relaxed: 1.6; /* "Digital Paper" body text minimum */
--leading-loose: 1.8;
/* --- Typography: Letter Spacing --- */
--tracking-tight: -0.025em;
--tracking-normal: 0em;
--tracking-wide: 0.05rem; /* label-md metadata */
--tracking-wider: 0.08rem;
/* --- Spacing Scale (base-4) --- */
--space-1: 0.25rem; /* 4px */
--space-2: 0.5rem; /* 8px */
--space-3: 1rem; /* 16px — list item separation */
--space-4: 1.4rem; /* ~22px — list item group separation */
--space-5: 1.75rem; /* 28px */
--space-6: 2rem; /* 32px */
--space-8: 3rem; /* 48px */
--space-10: 4rem; /* 64px */
--space-12: 4.5rem; /* 72px */
--space-16: 5.5rem; /* 88px — top-of-page breathability */
/* --- Border Radius --- */
--radius-xs: 0.125rem;
--radius-sm: 0.25rem;
--radius-md: 0.375rem; /* primary button */
--radius-lg: 0.75rem;
--radius-xl: 1.25rem;
--radius-full: 9999px;
/* --- Elevation: Ambient "Tonal Shadow" --- */
--shadow-tonal-sm: 0 4px 16px 0 color-mix(in srgb, var(--color-on-surface) 5%, transparent);
--shadow-tonal-md: 0 8px 32px 0 color-mix(in srgb, var(--color-on-surface) 5%, transparent);
/* --- Motion --- */
--duration-fast: 100ms;
--duration-normal: 200ms;
--duration-slow: 400ms;
--ease-standard: cubic-bezier(0.2, 0, 0, 1);
/* --- Glass / Frosted Effect --- */
--glass-bg: color-mix(in srgb, var(--color-surface) 80%, transparent);
--glass-blur: 24px;
}
/* --------------------------------------------------------------------------
Reset & Base
-------------------------------------------------------------------------- */
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
font-size: 16px;
-webkit-text-size-adjust: 100%;
scroll-behavior: smooth;
}
body {
font-family: var(--font-body);
font-size: var(--text-body-lg);
font-weight: var(--weight-regular);
line-height: var(--leading-relaxed);
color: var(--color-on-surface);
background-color: var(--color-surface);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* --------------------------------------------------------------------------
Typography Utilities
-------------------------------------------------------------------------- */
.display-lg {
font-family: var(--font-display);
font-size: var(--text-display-lg);
font-weight: var(--weight-bold);
line-height: var(--leading-tight);
letter-spacing: var(--tracking-tight);
}
.headline-md {
font-family: var(--font-display);
font-size: var(--text-headline-md);
font-weight: var(--weight-semibold);
line-height: var(--leading-snug);
}
.label-md {
font-family: var(--font-label);
font-size: var(--text-label-md);
font-weight: var(--weight-medium);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
}
.link {
color: var(--color-primary);
text-decoration: none;
font-weight: var(--weight-medium);
transition: opacity var(--duration-fast) var(--ease-standard);
}
.link:hover {
opacity: 0.7;
}
/* --------------------------------------------------------------------------
Component: Form
-------------------------------------------------------------------------- */
.form {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.form-header {
margin-bottom: var(--space-6);
}
.form-eyebrow {
font-family: var(--font-label);
font-size: var(--text-label-md);
font-weight: var(--weight-medium);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
color: var(--color-on-surface-variant);
margin-bottom: var(--space-1);
}
.form-title {
font-family: var(--font-display);
font-size: var(--text-headline-lg);
font-weight: var(--weight-semibold);
line-height: var(--leading-snug);
color: var(--color-on-surface);
}
.form-footer {
display: flex;
align-items: center;
gap: var(--space-2);
margin-top: var(--space-6);
font-family: var(--font-label);
font-size: var(--text-body-sm);
color: var(--color-on-surface-variant);
}
/* --------------------------------------------------------------------------
Component: Button
-------------------------------------------------------------------------- */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-4);
border: none;
border-radius: var(--radius-md);
font-family: var(--font-display);
font-size: var(--text-label-lg);
font-weight: var(--weight-medium);
letter-spacing: var(--tracking-wide);
cursor: pointer;
text-decoration: none;
transition:
opacity var(--duration-normal) var(--ease-standard),
background-color var(--duration-normal) var(--ease-standard);
}
.btn-primary {
background-color: var(--color-primary);
color: var(--color-on-primary);
}
.btn-primary:hover {
opacity: 0.88;
}
.btn-secondary {
background-color: var(--color-secondary-container);
color: var(--color-on-secondary-container);
}
.btn-ghost {
background: none;
color: var(--color-primary);
padding-inline: var(--space-2);
}
/* --------------------------------------------------------------------------
Component: Input
-------------------------------------------------------------------------- */
.field {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.field-label {
font-family: var(--font-label);
font-size: var(--text-label-md);
font-weight: var(--weight-medium);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
color: var(--color-on-surface-variant);
}
.field-input {
background-color: var(--color-surface-container-high);
color: var(--color-on-surface);
border: none;
border-bottom: 1px solid color-mix(in srgb, var(--color-outline-variant) 20%, transparent);
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
padding: var(--space-2) var(--space-3);
font-family: var(--font-body);
font-size: var(--text-body-lg);
line-height: var(--leading-normal);
outline: none;
width: 100%;
transition: border-color var(--duration-fast) var(--ease-standard);
}
.field-disabled {
font-family: var(--font-body);
font-size: var(--text-body-lg);
color: var(--color-on-surface-variant);
padding: var(--space-2) var(--space-3);
background-color: var(--color-surface-container);
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
border-bottom: 1px solid color-mix(in srgb, var(--color-outline-variant) 20%, transparent);
}
.field-input::placeholder {
color: var(--color-outline-variant);
}
.field-input:focus {
border-bottom: 2px solid var(--color-primary);
}
.field-select {
background-color: var(--color-surface-container-high);
color: var(--color-on-surface);
border: none;
border-bottom: 1px solid color-mix(in srgb, var(--color-outline-variant) 20%, transparent);
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
padding: var(--space-2) var(--space-8) var(--space-2) var(--space-3);
font-family: var(--font-body);
font-size: var(--text-body-lg);
line-height: var(--leading-normal);
outline: none;
width: 100%;
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%235c605b' stroke-width='1.5' fill='none' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right var(--space-3) center;
transition: border-color var(--duration-fast) var(--ease-standard);
}
.field-select:focus {
border-bottom: 2px solid var(--color-primary);
}
.field-textarea {
background-color: var(--color-surface-container-high);
color: var(--color-on-surface);
border: none;
border-bottom: 1px solid color-mix(in srgb, var(--color-outline-variant) 20%, transparent);
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
padding: var(--space-2) var(--space-3);
font-family: var(--font-body);
font-size: var(--text-body-lg);
line-height: var(--leading-relaxed);
outline: none;
width: 100%;
min-height: 14rem;
resize: vertical;
transition: border-color var(--duration-fast) var(--ease-standard);
}
.field-textarea::placeholder {
color: var(--color-outline-variant);
}
.field-textarea:focus {
border-bottom: 2px solid var(--color-primary);
}
.field-hint {
font-family: var(--font-label);
font-size: var(--text-label-md);
color: var(--color-on-surface-variant);
margin-top: var(--space-1);
}
/* --------------------------------------------------------------------------
Component: Alert
-------------------------------------------------------------------------- */
.alert {
padding: var(--space-3);
border-radius: var(--radius-md);
font-family: var(--font-label);
font-size: var(--text-body-sm);
}
.alert-error {
background-color: color-mix(in srgb, #b3261e 10%, var(--color-surface));
color: #b3261e;
border-left: 3px solid #b3261e;
} }

View file

@ -8,6 +8,7 @@ declare global {
interface Locals { interface Locals {
apiClient?: ApiClient; apiClient?: ApiClient;
authToken: string | null; authToken: string | null;
isAdmin: boolean;
} }
// interface PageData {} // interface PageData {}
// interface PageState {} // interface PageState {}

13
frontend/src/lib/utils.ts Normal file
View file

@ -0,0 +1,13 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type WithoutChild<T> = T extends { child?: any } ? Omit<T, "child"> : T;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type WithoutChildren<T> = T extends { children?: any } ? Omit<T, "children"> : T;
export type WithoutChildrenOrChild<T> = WithoutChildren<WithoutChild<T>>;
export type WithElementRef<T, U extends HTMLElement = HTMLElement> = T & { ref?: U | null };

405
frontend/src/styles.css Normal file
View file

@ -0,0 +1,405 @@
/* ==========================================================================
Design System: The Editorial Stillness
========================================================================== */
/* --------------------------------------------------------------------------
Fonts
-------------------------------------------------------------------------- */
@font-face {
font-family: archivo; /* set name */
src: url('/fonts/Archivo-Medium.woff2'); /* url of the font */
font-weight: 500;
}
@font-face {
font-family: archivo; /* set name */
src: url('/fonts/Archivo-Regular.woff2'); /* url of the font */
font-weight: normal;
}
/* --------------------------------------------------------------------------
Design Tokens
-------------------------------------------------------------------------- */
:root {
/* --- Color: Primary --- */
--color-primary: #516356;
--color-on-primary: #e9fded;
--color-primary-container: #c8d8c8;
--color-on-primary-container: #2f342e;
/* --- Color: Secondary --- */
--color-secondary-container: #dde4de;
--color-on-secondary-container: #2f342e;
/* --- Color: Surface Hierarchy (light → dark = elevated → recessed) --- */
--color-surface-container-lowest: #ffffff; /* most elevated */
--color-surface-container-low: #f4f4ef;
--color-surface: #faf9f5; /* base */
--color-surface-container: #eeede9;
--color-surface-container-high: #e8e8e3;
--color-surface-container-highest: #e2e1dd;
--color-surface-dim: #d6dcd2; /* recessed utility */
/* --- Color: On-Surface --- */
--color-on-surface: #2f342e; /* replaces pure black */
--color-on-surface-variant: #5c605b;
/* --- Color: Outline --- */
--color-outline: #8c908b;
--color-outline-variant: #afb3ac; /* use at 20% opacity as "ghost border" */
/* --- Typography: Font Families --- */
--font-display: 'archivo', sans-serif;
--font-body: 'Newsreader', serif;
--font-label: 'Inter', sans-serif;
/* --- Typography: Scale --- */
--text-display-lg: 3.5rem; /* article titles */
--text-display-md: 2.75rem;
--text-display-sm: 2.25rem;
--text-headline-lg: 1.875rem;
--text-headline-md: 1.5rem;
--text-headline-sm: 1.25rem;
--text-title-lg: 1.125rem;
--text-title-md: 1rem;
--text-body-xl: 1.25rem; /* long-form reading standard */
--text-body-lg: 1rem;
--text-body-md: 0.9375rem;
--text-body-sm: 0.875rem;
--text-label-lg: 0.875rem;
--text-label-md: 0.75rem; /* metadata, all-caps */
--text-label-sm: 0.6875rem;
/* --- Typography: Weights --- */
--weight-light: 300;
--weight-regular: 400;
--weight-medium: 500;
--weight-semibold: 600;
--weight-bold: 700;
/* --- Typography: Line Height --- */
--leading-tight: 1.2;
--leading-snug: 1.375;
--leading-normal: 1.5;
--leading-relaxed: 1.6; /* "Digital Paper" body text minimum */
--leading-loose: 1.8;
/* --- Typography: Letter Spacing --- */
--tracking-tight: -0.025em;
--tracking-normal: 0em;
--tracking-wide: 0.05rem; /* label-md metadata */
--tracking-wider: 0.08rem;
/* --- Spacing Scale (base-4) --- */
--space-1: 0.25rem; /* 4px */
--space-2: 0.5rem; /* 8px */
--space-3: 1rem; /* 16px — list item separation */
--space-4: 1.4rem; /* ~22px — list item group separation */
--space-5: 1.75rem; /* 28px */
--space-6: 2rem; /* 32px */
--space-8: 3rem; /* 48px */
--space-10: 4rem; /* 64px */
--space-12: 4.5rem; /* 72px */
--space-16: 5.5rem; /* 88px — top-of-page breathability */
/* --- Border Radius --- */
--radius-xs: 0.125rem;
--radius-sm: 0.25rem;
--radius-md: 0.375rem; /* primary button */
--radius-lg: 0.75rem;
--radius-xl: 1.25rem;
--radius-full: 9999px;
/* --- Elevation: Ambient "Tonal Shadow" --- */
--shadow-tonal-sm: 0 4px 16px 0 color-mix(in srgb, var(--color-on-surface) 5%, transparent);
--shadow-tonal-md: 0 8px 32px 0 color-mix(in srgb, var(--color-on-surface) 5%, transparent);
/* --- Motion --- */
--duration-fast: 100ms;
--duration-normal: 200ms;
--duration-slow: 400ms;
--ease-standard: cubic-bezier(0.2, 0, 0, 1);
/* --- Glass / Frosted Effect --- */
--glass-bg: color-mix(in srgb, var(--color-surface) 80%, transparent);
--glass-blur: 24px;
}
/* --------------------------------------------------------------------------
Reset & Base
-------------------------------------------------------------------------- */
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
font-size: 16px;
-webkit-text-size-adjust: 100%;
scroll-behavior: smooth;
}
body {
font-family: var(--font-body);
font-size: var(--text-body-lg);
font-weight: var(--weight-regular);
line-height: var(--leading-relaxed);
color: var(--color-on-surface);
background-color: var(--color-surface);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* --------------------------------------------------------------------------
Typography Utilities
-------------------------------------------------------------------------- */
.display-lg {
font-family: var(--font-display);
font-size: var(--text-display-lg);
font-weight: var(--weight-bold);
line-height: var(--leading-tight);
letter-spacing: var(--tracking-tight);
}
.headline-md {
font-family: var(--font-display);
font-size: var(--text-headline-md);
font-weight: var(--weight-semibold);
line-height: var(--leading-snug);
}
.label-md {
font-family: var(--font-label);
font-size: var(--text-label-md);
font-weight: var(--weight-medium);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
}
.link {
color: var(--color-primary);
text-decoration: none;
font-weight: var(--weight-medium);
transition: opacity var(--duration-fast) var(--ease-standard);
}
.link:hover {
opacity: 0.7;
}
/* --------------------------------------------------------------------------
Component: Form
-------------------------------------------------------------------------- */
.form {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.form-header {
margin-bottom: var(--space-6);
}
.form-eyebrow {
font-family: var(--font-label);
font-size: var(--text-label-md);
font-weight: var(--weight-medium);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
color: var(--color-on-surface-variant);
margin-bottom: var(--space-1);
}
.form-title {
font-family: var(--font-display);
font-size: var(--text-headline-lg);
font-weight: var(--weight-semibold);
line-height: var(--leading-snug);
color: var(--color-on-surface);
}
.form-footer {
display: flex;
align-items: center;
gap: var(--space-2);
margin-top: var(--space-6);
font-family: var(--font-label);
font-size: var(--text-body-sm);
color: var(--color-on-surface-variant);
}
/* --------------------------------------------------------------------------
Component: Button
-------------------------------------------------------------------------- */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-4);
border: none;
border-radius: var(--radius-md);
font-family: var(--font-display);
font-size: var(--text-label-lg);
font-weight: var(--weight-medium);
letter-spacing: var(--tracking-wide);
cursor: pointer;
text-decoration: none;
transition:
opacity var(--duration-normal) var(--ease-standard),
background-color var(--duration-normal) var(--ease-standard);
}
.btn-primary {
background-color: var(--color-primary);
color: var(--color-on-primary);
}
.btn-primary:hover {
opacity: 0.88;
}
.btn-secondary {
background-color: var(--color-secondary-container);
color: var(--color-on-secondary-container);
}
.btn-ghost {
background: none;
color: var(--color-primary);
padding-inline: var(--space-2);
}
/* --------------------------------------------------------------------------
Component: Input
-------------------------------------------------------------------------- */
.field {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.field-label {
font-family: var(--font-label);
font-size: var(--text-label-md);
font-weight: var(--weight-medium);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
color: var(--color-on-surface-variant);
}
.field-input {
background-color: var(--color-surface-container-high);
color: var(--color-on-surface);
border: none;
border-bottom: 1px solid color-mix(in srgb, var(--color-outline-variant) 20%, transparent);
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
padding: var(--space-2) var(--space-3);
font-family: var(--font-body);
font-size: var(--text-body-lg);
line-height: var(--leading-normal);
outline: none;
width: 100%;
transition: border-color var(--duration-fast) var(--ease-standard);
}
.field-disabled {
font-family: var(--font-body);
font-size: var(--text-body-lg);
color: var(--color-on-surface-variant);
padding: var(--space-2) var(--space-3);
background-color: var(--color-surface-container);
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
border-bottom: 1px solid color-mix(in srgb, var(--color-outline-variant) 20%, transparent);
}
.field-input::placeholder {
color: var(--color-outline-variant);
}
.field-input:focus {
border-bottom: 2px solid var(--color-primary);
}
.field-select {
background-color: var(--color-surface-container-high);
color: var(--color-on-surface);
border: none;
border-bottom: 1px solid color-mix(in srgb, var(--color-outline-variant) 20%, transparent);
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
padding: var(--space-2) var(--space-8) var(--space-2) var(--space-3);
font-family: var(--font-body);
font-size: var(--text-body-lg);
line-height: var(--leading-normal);
outline: none;
width: 100%;
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%235c605b' stroke-width='1.5' fill='none' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right var(--space-3) center;
transition: border-color var(--duration-fast) var(--ease-standard);
}
.field-select:focus {
border-bottom: 2px solid var(--color-primary);
}
.field-textarea {
background-color: var(--color-surface-container-high);
color: var(--color-on-surface);
border: none;
border-bottom: 1px solid color-mix(in srgb, var(--color-outline-variant) 20%, transparent);
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
padding: var(--space-2) var(--space-3);
font-family: var(--font-body);
font-size: var(--text-body-lg);
line-height: var(--leading-relaxed);
outline: none;
width: 100%;
min-height: 14rem;
resize: vertical;
transition: border-color var(--duration-fast) var(--ease-standard);
}
.field-textarea::placeholder {
color: var(--color-outline-variant);
}
.field-textarea:focus {
border-bottom: 2px solid var(--color-primary);
}
.field-hint {
font-family: var(--font-label);
font-size: var(--text-label-md);
color: var(--color-on-surface-variant);
margin-top: var(--space-1);
}
/* --------------------------------------------------------------------------
Component: Alert
-------------------------------------------------------------------------- */
.alert {
padding: var(--space-3);
border-radius: var(--radius-md);
font-family: var(--font-label);
font-size: var(--text-body-sm);
}
.alert-error {
background-color: color-mix(in srgb, #b3261e 10%, var(--color-surface));
color: #b3261e;
border-left: 3px solid #b3261e;
}

View file

@ -8,7 +8,8 @@ const config = {
remoteFunctions: true remoteFunctions: true
}, },
alias: { alias: {
'@client': 'src/client/client.gen.ts' '@client': 'src/client/client.gen.ts',
'@/**': './src/lib/*',
} }
}, },
compilerOptions: { compilerOptions: {