diff --git a/frontend/components.json b/frontend/components.json new file mode 100644 index 0000000..44fad99 --- /dev/null +++ b/frontend/components.json @@ -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" +} diff --git a/frontend/docs/architecture.md b/frontend/docs/architecture.md index 6ac34c5..39c3b97 100644 --- a/frontend/docs/architecture.md +++ b/frontend/docs/architecture.md @@ -1,6 +1,6 @@ # 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. @@ -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. -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. -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. ## 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. @@ -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. -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. diff --git a/frontend/package.json b/frontend/package.json index 23ede1c..a0bf68f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,14 +17,17 @@ "test": "npm run test:unit -- --run" }, "devDependencies": { - "@eslint/compat": "^2.0.2", - "@eslint/js": "^9.39.2", + "@eslint/compat": "latest", + "@eslint/js": "latest", + "@fontsource-variable/inter": "^5.2.8", "@hey-api/openapi-ts": "0.94.4", + "@lucide/svelte": "^1.8.0", "@sveltejs/adapter-node": "^5.5.4", "@sveltejs/kit": "^2.50.2", "@sveltejs/vite-plugin-svelte": "^6.2.4", "@types/node": "^22", "@vitest/browser-playwright": "^4.1.0", + "clsx": "^2.1.1", "eslint": "^9.39.2", "eslint-config-prettier": "^10.1.8", "eslint-plugin-svelte": "^3.14.0", @@ -32,8 +35,12 @@ "playwright": "^1.58.2", "prettier": "^3.8.1", "prettier-plugin-svelte": "^3.4.1", + "shadcn-svelte": "^1.2.7", "svelte": "^5.51.0", "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-eslint": "^8.54.0", "vite": "^7.3.1", @@ -42,6 +49,8 @@ }, "dependencies": { "deepl-node": "^1.24.0", + "jose": "^6.2.2", + "tailwindcss": "^4.2.2", "valibot": "^1.3.1" } } diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 0405688..8a956a1 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -11,19 +11,31 @@ importers: deepl-node: specifier: ^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: specifier: ^1.3.1 version: 1.3.1(typescript@5.9.3) devDependencies: '@eslint/compat': - specifier: ^2.0.2 + specifier: latest version: 2.0.3(eslint@9.39.4(jiti@2.6.1)) '@eslint/js': - specifier: ^9.39.2 + specifier: latest version: 9.39.4 + '@fontsource-variable/inter': + specifier: ^5.2.8 + version: 5.2.8 '@hey-api/openapi-ts': specifier: 0.94.4 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': 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))) @@ -39,6 +51,9 @@ importers: '@vitest/browser-playwright': 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) + clsx: + specifier: ^2.1.1 + version: 2.1.1 eslint: specifier: ^9.39.2 version: 9.39.4(jiti@2.6.1) @@ -60,12 +75,24 @@ importers: prettier-plugin-svelte: specifier: ^3.4.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: specifier: ^5.51.0 version: 5.54.1 svelte-check: specifier: ^4.4.2 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: specifier: ^5.9.3 version: 5.9.3 @@ -294,6 +321,9 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} 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': resolution: {integrity: sha512-DGd9yeSQzflOWO3Y5mt1GRXkXH9O/yIMgbxPjwLI3jwu/3nAjoXXD26lEeFb6tclYlg0JAqTIs5d930G/qxHeA==} engines: {node: '>=20.19.0'} @@ -351,6 +381,11 @@ packages: '@jsdevtools/ono@7.1.3': 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': resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==} engines: {node: '>=18'} @@ -1198,6 +1233,9 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true + jose@6.2.2: + resolution: {integrity: sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==} + js-yaml@4.1.1: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true @@ -1467,6 +1505,12 @@ packages: set-cookie-parser@3.1.0: 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: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -1525,6 +1569,22 @@ packages: resolution: {integrity: sha512-ow8tncN097Ty8U1H+C3bM1xNlsCbnO2UZeN0lWBnv8f3jKho7QTTQ2LWbMXrPQDodLjH91n4kpNnLolyRhVE6A==} 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: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -1550,6 +1610,9 @@ packages: peerDependencies: typescript: '>=4.8.4' + tw-animate-css@1.4.0: + resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==} + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -1855,6 +1918,8 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 + '@fontsource-variable/inter@5.2.8': {} + '@hey-api/codegen-core@0.7.4': dependencies: '@hey-api/types': 0.1.4 @@ -1930,6 +1995,10 @@ snapshots: '@jsdevtools/ono@7.1.3': {} + '@lucide/svelte@1.8.0(svelte@5.54.1)': + dependencies: + svelte: 5.54.1 + '@playwright/test@1.58.2': dependencies: playwright: 1.58.2 @@ -2775,6 +2844,8 @@ snapshots: jiti@2.6.1: {} + jose@6.2.2: {} + js-yaml@4.1.1: dependencies: argparse: 2.0.1 @@ -3015,6 +3086,14 @@ snapshots: 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: dependencies: shebang-regex: 3.0.0 @@ -3086,6 +3165,16 @@ snapshots: magic-string: 0.30.21 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: {} tinyexec@1.0.4: {} @@ -3103,6 +3192,8 @@ snapshots: dependencies: typescript: 5.9.3 + tw-animate-css@1.4.0: {} + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 diff --git a/frontend/src/app.css b/frontend/src/app.css index 38214a2..336acbe 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -1,405 +1,15 @@ -/* ========================================================================== - Design System: The Editorial Stillness - ========================================================================== */ +@import "tw-animate-css"; +@import "shadcn-svelte/tailwind.css"; +@import "@fontsource-variable/inter"; -/* -------------------------------------------------------------------------- - 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; -} +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } + html { + @apply font-sans; + } +} \ No newline at end of file diff --git a/frontend/src/app.d.ts b/frontend/src/app.d.ts index 5a95820..08dc1b5 100644 --- a/frontend/src/app.d.ts +++ b/frontend/src/app.d.ts @@ -8,6 +8,7 @@ declare global { interface Locals { apiClient?: ApiClient; authToken: string | null; + isAdmin: boolean; } // interface PageData {} // interface PageState {} diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts new file mode 100644 index 0000000..55b3a91 --- /dev/null +++ b/frontend/src/lib/utils.ts @@ -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 extends { child?: any } ? Omit : T; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type WithoutChildren = T extends { children?: any } ? Omit : T; +export type WithoutChildrenOrChild = WithoutChildren>; +export type WithElementRef = T & { ref?: U | null }; diff --git a/frontend/src/styles.css b/frontend/src/styles.css new file mode 100644 index 0000000..38214a2 --- /dev/null +++ b/frontend/src/styles.css @@ -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; +} diff --git a/frontend/svelte.config.js b/frontend/svelte.config.js index d406d31..31738ce 100644 --- a/frontend/svelte.config.js +++ b/frontend/svelte.config.js @@ -8,7 +8,8 @@ const config = { remoteFunctions: true }, alias: { - '@client': 'src/client/client.gen.ts' + '@client': 'src/client/client.gen.ts', + '@/**': './src/lib/*', } }, compilerOptions: {