diff --git a/docs/web/architecture.md b/docs/web/architecture.md index ee389bd5..0d3e6c64 100644 --- a/docs/web/architecture.md +++ b/docs/web/architecture.md @@ -565,11 +565,12 @@ are not seeded either. **Environments**: -| Environment | Compose file | -| ----------- | -------------------------- | -| Development | `docker-compose.dev.yaml` | -| Testing | `docker-compose.test.yaml` | -| Production | `docker-compose.prod.yaml` | +| Environment | Compose file | +| --------------- | ------------------------------- | +| Development | `docker-compose.dev.yaml` | +| Testing | `docker-compose.test.yaml` | +| Production | `docker-compose.prod.yaml` | +| Production seed | `docker-compose.prod.seed.yaml` | For details, see [Docker Guide](docker.md). diff --git a/docs/web/diagrams/deployment.md b/docs/web/diagrams/deployment.md index ec52c4c9..69427d7f 100644 --- a/docs/web/diagrams/deployment.md +++ b/docs/web/diagrams/deployment.md @@ -41,7 +41,7 @@ no data flowing over them. | ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Dev network** (`devnet`, bridge) | Internal DNS resolves service names such as `sqlserver` and `api.core`. Volumes: `sqlserverdata-dev`, `nuget-cache-dev`, `seaweedfsdata-dev`. Startup order: SQL Server (health check) → migrations (run-once) → seed (run-once, depends on `seaweedfs`) → API (long-running, depends on seed). | | **Test network** (`testnet`, bridge) | All test components are isolated; fresh DB instance each run (`CLEAR_DATABASE=true`, ephemeral `sqlserverdata-test` / `seaweedfsdata-test` volumes). `api.specs`, `unit.tests`, and `frontend.tests` all run as separate containers, in parallel once seeding completes; results are aggregated. | -| **Prod network** (`prodnet`, bridge) | Isolated from dev/test networks. Volumes: `sqlserverdata-prod`, `nuget-cache-prod`. No seed step in production. | +| **Prod network** (`prodnet`, bridge, named `biergarten-prodnet`) | Isolated from dev/test networks. Volumes: `biergartendata-prod` and `seaweedfsdata-prod`, both external, so `down -v` keeps them. Startup order: SeaweedFS (health check) → API (health check) → frontend, with `frontend` the only service publishing a host port, bound to `127.0.0.1`. Migrating and seeding run on demand as their own run-once jobs joining `biergarten-prodnet`, so an `up` or a reboot replays neither. | | **Database-only environment** | Same devnet volumes/images as `docker-compose.dev.yaml`, minus `api.core`, `frontend`, and `mailpit` — for working against the database (and object storage, since `seaweedfs` is still included) without running the API or website. | | **Minimal environment** | Database + migrations + seed + `seaweedfs` + mailpit, no API or frontend container — for frontend development against an `API.Core` process run outside Docker. | | `unit.tests` | Builds `Dockerfile.tests`, which restores/builds against `Biergarten.API.slnx` and then runs `dotnet test` for every `Features/*.Tests` project in turn (`Features.Users.Tests`, `Features.Breweries.Tests`, `Features.Emails.Tests`, `Features.PhotoUpload.Tests`), mocking each handler's repository/service dependencies with Moq — no real database needed. | diff --git a/docs/web/diagrams/deployment/prod.mmd b/docs/web/diagrams/deployment/prod.mmd index 6c267b92..e45283ea 100644 --- a/docs/web/diagrams/deployment/prod.mmd +++ b/docs/web/diagrams/deployment/prod.mmd @@ -1,11 +1,13 @@ sequenceDiagram box Production Environment (docker-compose.prod.yaml) participant ProdDB as SQL Server 2022
Biergarten Database
no host port published - participant ProdMig as Migrations (run-once) + participant ProdS3 as SeaweedFS
S3 gateway :8333
no host port published participant ProdAPI as API Container
ASP.NET Core 10
:8080 HTTP / :8081 HTTPS + participant ProdWeb as Frontend Container
Express + React Router
127.0.0.1:5656 end - ProdMig-->>ProdDB: depends_on - ProdMig->>ProdDB: Run migrations - ProdAPI-->>ProdDB: depends_on: sql-server healthy (not migrations/seed) + ProdAPI-->>ProdS3: depends_on: seaweedfs healthy + ProdWeb-->>ProdAPI: depends_on: api.core healthy ProdAPI->>ProdDB: Connect and serve + ProdAPI->>ProdS3: Store and read photos + Note over ProdDB: Migrating and seeding are separate run-once jobs,
run at deploy time rather than by the stack diff --git a/docs/web/docker.md b/docs/web/docker.md index ef05717b..537e495f 100644 --- a/docs/web/docker.md +++ b/docs/web/docker.md @@ -134,27 +134,136 @@ containers, which would end the run prematurely. See - Production logging levels - No database clearing - Optimized build configurations -- Health checks enabled -- Restart policies (unless-stopped) +- Health checks on every long-running service +- Restart policies that survive a reboot (see + [Restart tolerance](#restart-tolerance)) - Security hardening -- Only `frontend` publishes a port to the host; `sqlserver` and `api.core` are - reachable only from other containers on `prodnet` +- Only `frontend` publishes a port to the host, bound to `127.0.0.1` so a reverse + proxy can sit in front of it; `sqlserver`, `api.core` and `seaweedfs` are + reachable only from other containers on `prodnet` (named `biergarten-prodnet` + so the seed job can join it from its own Compose project) +- Seeding is a separate, explicitly run compose file + (`docker-compose.prod.seed.yaml`), so an `up` or a restart leaves the existing + catalogue as it is. See [Production data](#production-data) below. **Services**: ```yaml -sqlserver # Production SQL Server (internal only) -database.migrations # Schema updates only +sqlserver # Production SQL Server (internal only) api.core # Production API (internal only, no published ports) -frontend # Production website (port 3000, the only public binding) +seaweedfs # S3-compatible object storage (internal only) +frontend # Production website (127.0.0.1:5656, the only host binding) ``` +Migrating and seeding are run-once jobs kept outside this file, so an `up` or a +restart touches neither the schema nor the catalogue. Run them explicitly at +deploy time: see [Migrating production](#migrating-production) and +[Seeding production](#seeding-production-docker-composeprodseedyaml). + **Deploy Production**: ```bash +# One time, before the first `up`: the data volumes are external, so Compose +# expects them to exist already. +docker volume create biergartendata-prod +docker volume create seaweedfsdata-prod + docker compose --env-file web/.env.prod -f web/docker-compose.prod.yaml up -d ``` +Docker seeds each volume with the ownership the image expects and labels it for +SELinux, so the host needs no `chown` and no `:z`/`:Z` mount options on +enforcing distributions such as RHEL, AlmaLinux and Fedora. + +#### Migrating production + +`database.migrations` is a run-once job rather than a service in the prod stack, +so the schema is migrated when an operator asks for it instead of on every boot. +Build the image and run it against the network the prod stack creates: + +```bash +docker build -t database.migrations \ + -f web/backend/Database/Database.Migrations/Dockerfile \ + --build-arg BUILD_CONFIGURATION=Release --build-arg APP_UID=1000 \ + web/backend + +docker run --rm --env-file web/.env.prod --network biergarten-prodnet \ + -e DOTNET_RUNNING_IN_CONTAINER=true database.migrations +``` + +DbUp records every script it has applied, so a re-run applies only what is new. +Run this with `sqlserver` already up and healthy, and before the seed job. + +#### Restart tolerance + +`sqlserver`, `api.core`, `seaweedfs` and `frontend` run under +`restart: unless-stopped`, so the stack returns on its own after a container +crash, a Docker daemon restart or a host reboot, provided Docker itself starts at +boot (`systemctl enable docker`). The migration and seed jobs stay out of the +stack, so a reboot replays neither. + +The daemon replays restart policies without replaying the `depends_on` graph, so +after a reboot the containers come back in arbitrary order and each one tolerates +a dependency that arrives late. `api.core` opens its database and S3 connections +per request rather than at startup, and `frontend` answers with its own error +states while `api.core` is still coming up. + +Health checks report when the stack has converged: + +| Service | Probe | +| --- | --- | +| `sqlserver` | `sqlcmd -Q 'SELECT 1'`, with a 30s start period and 12 retries | +| `api.core` | `GET /health` spoken over bash's `/dev/tcp` — the .NET runtime image carries bash and no HTTP client | +| `seaweedfs` | `curl http://localhost:8333/status`, the S3 gateway, which serves once the master, volume and filer servers are up | +| `frontend` | `wget --spider http://127.0.0.1:5656/healthz`, answered by Express alone and so independent of the API | + +`sqlserver` also carries `stop_grace_period: 60s`, giving a checkpoint time to +finish on shutdown instead of being recovered on the next start. + +Deploy and wait for the stack to report healthy, or check convergence after a +reboot: + +```bash +docker compose --env-file web/.env.prod -f web/docker-compose.prod.yaml up -d --wait +docker compose --env-file web/.env.prod -f web/docker-compose.prod.yaml ps +``` + +#### Seeding production (`docker-compose.prod.seed.yaml`) + +`database.seed` lives in its own compose file, run on demand once the prod stack +is up, `sqlserver` is healthy, and the schema is migrated: + +```bash +docker compose --env-file web/.env.prod -f web/docker-compose.prod.seed.yaml \ + run --rm --build database.seed +``` + +The job runs as the `biergarten-prod-seed` Compose project and attaches to the +external `biergarten-prodnet` network, which the main prod stack creates, so it +reaches `sqlserver` and `seaweedfs` under the same hostnames the API uses. It +exits when seeding finishes; `--rm` removes the container. Bring the prod stack +up first, because the seed project has no visibility into the other project's +health checks, so a run started before SQL Server is accepting connections fails. + +#### Production data + +A production deployment seeded this way serves the AI-generated fixture data +produced by the pipeline. Per +[Pipeline ethics, bias, and known issues](../pipeline/ETHICS-AND-KNOWN-ISSUES.md), +anyone interacting with an application seeded from it has to be told upfront that +the content is AI-generated. + +The website meets that obligation with `AiDisclosureDialog` +(`web/frontend/app/features/home/components/AiDisclosureDialog.tsx`), which +the root route renders on every full page load, whichever route the visitor lands +on. It is part of the server-rendered HTML rather than mounted after hydration, so +the disclosure is on screen with the first paint. Acknowledging it hides the +dialog for the remainder of that page load; a new load shows it again. + +Keep the seed job and the dialog together: any deployment that runs +`docker-compose.prod.seed.yaml` needs the disclosure in place to satisfy the +ethics documentation. + ### 4. Database only (`docker-compose.db.yaml`) **Purpose**: Run just the database for local backend development outside Docker @@ -215,6 +324,12 @@ graph TD In development, `api.core` also depends on `seaweedfs` (`condition: service_started`, not a health check) alongside `database.seed`. +Production has a shorter chain, because the migration and seed jobs run outside +the stack: `api.core` waits for `seaweedfs` to pass its health check, and +`frontend` waits for `api.core` to report healthy. This ordering applies to +`docker compose up`; see [Restart tolerance](#restart-tolerance) for what happens +on a host reboot. + **Health Check Example** (SQL Server): ```yaml @@ -255,8 +370,10 @@ api.core: **Production**: -- `sqlserverdata-prod` - Production database files -- `nuget-cache-prod` - Production NuGet cache +- `biergartendata-prod` - Database files, external; survives `down -v` +- `seaweedfsdata-prod` - SeaweedFS object storage, external; survives `down -v` + +See [Production data volumes](#production-data-volumes). ### Mounted volumes @@ -269,6 +386,41 @@ volumes: Test results are written to host filesystem for CI/CD integration. +### Production data volumes + +`biergartendata-prod` and `seaweedfsdata-prod` are declared `external: true`. +Compose removes only the volumes it owns, so `docker compose down -v` tears the +stack down and leaves the database and the uploaded photos in place. Deleting +them takes an explicit `docker volume rm`. + +The trade-off is that Compose creates neither one, and `up` fails with a clear +error until both exist, hence the `docker volume create` step in +[Deploy Production](#3-production-docker-composeprodyaml). External volumes also +carry their literal name rather than a `_` prefix, so `docker volume ls` +shows `biergartendata-prod` and `seaweedfsdata-prod` unprefixed, unlike the +`web_`-prefixed volumes of the dev and test stacks. + +Both live under `/var/lib/docker/volumes`. To put them on a specific disk, point +the daemon's `data-root` at it in `/etc/docker/daemon.json`, or mount the disk at +`/var/lib/docker`. + +Back up the database through SQL Server rather than by copying its files, since a +file copy taken while the engine is running can be torn: + +```bash +docker exec prod-env-sqlserver /opt/mssql-tools18/bin/sqlcmd \ + -S localhost -U sa -P '' -C \ + -Q "BACKUP DATABASE [] TO DISK = '/var/opt/mssql/data/backup.bak'" +docker cp prod-env-sqlserver:/var/opt/mssql/data/backup.bak . +``` + +Object storage can be archived directly, with the stack stopped: + +```bash +docker run --rm -v seaweedfsdata-prod:/data -v "$PWD:/backup" \ + alpine tar czf /backup/seaweedfs.tar.gz -C /data . +``` + ## Networks Each environment uses isolated bridge networks: diff --git a/docs/web/environment-variables.md b/docs/web/environment-variables.md index d47afe53..656d0cc1 100644 --- a/docs/web/environment-variables.md +++ b/docs/web/environment-variables.md @@ -317,6 +317,7 @@ from the template in place; edit them manually if you go that route. - `web/docker-compose.dev.yaml` → `web/.env.dev` - `web/docker-compose.test.yaml` → `web/.env.test` - `web/docker-compose.prod.yaml` → `web/.env.prod` +- `web/docker-compose.prod.seed.yaml` → `web/.env.prod` ## Variable reference table diff --git a/web/docker-compose.prod.seed.yaml b/web/docker-compose.prod.seed.yaml new file mode 100644 index 00000000..bc46f2af --- /dev/null +++ b/web/docker-compose.prod.seed.yaml @@ -0,0 +1,32 @@ +# docker compose --env-file web/.env.prod -f web/docker-compose.prod.seed.yaml run --rm --build database.seed +name: biergarten-prod-seed + +services: + database.seed: + env_file: ".env.prod" + image: database.seed + container_name: prod-env-database-seed + build: + context: ./backend + dockerfile: Database/Database.Seed/Dockerfile + args: + BUILD_CONFIGURATION: Release + APP_UID: 1000 + environment: + DOTNET_RUNNING_IN_CONTAINER: "true" + DB_SERVER: "${DB_SERVER}" + DB_NAME: "${DB_NAME}" + DB_USER: "${DB_USER}" + DB_PASSWORD: "${DB_PASSWORD}" + SEAWEEDFS_SERVICE_URL: "${SEAWEEDFS_SERVICE_URL}" + SEAWEEDFS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}" + SEAWEEDFS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}" + SEAWEEDFS_BUCKET: "${SEAWEEDFS_BUCKET}" + restart: "no" + networks: + - prodnet + +networks: + prodnet: + name: biergarten-prodnet + external: true diff --git a/web/docker-compose.prod.yaml b/web/docker-compose.prod.yaml index d2248d07..4b99d938 100644 --- a/web/docker-compose.prod.yaml +++ b/web/docker-compose.prod.yaml @@ -6,10 +6,10 @@ services: container_name: prod-env-sqlserver environment: ACCEPT_EULA: "Y" - SA_PASSWORD: "${DB_PASSWORD}" + MSSQL_SA_PASSWORD: "${DB_PASSWORD}" MSSQL_PID: "Express" volumes: - - sqlserverdata-prod:/var/opt/mssql + - biergartendata-prod:/var/opt/mssql healthcheck: test: [ @@ -20,29 +20,8 @@ services: timeout: 5s retries: 12 start_period: 30s - networks: - - prodnet - - database.migrations: - env_file: ".env.prod" - image: database.migrations - container_name: prod-env-database-migrations - depends_on: - sqlserver: - condition: service_healthy - build: - context: ./backend - dockerfile: Database/Database.Migrations/Dockerfile - args: - BUILD_CONFIGURATION: Release - APP_UID: 1000 - environment: - DOTNET_RUNNING_IN_CONTAINER: "true" - DB_SERVER: "${DB_SERVER}" - DB_NAME: "${DB_NAME}" - DB_USER: "${DB_USER}" - DB_PASSWORD: "${DB_PASSWORD}" - restart: "no" + restart: unless-stopped + stop_grace_period: 60s networks: - prodnet @@ -51,7 +30,7 @@ services: image: api.core container_name: prod-env-api-core depends_on: - sqlserver: + seaweedfs: condition: service_healthy build: context: ./backend @@ -59,8 +38,6 @@ services: args: BUILD_CONFIGURATION: Release APP_UID: 1000 - # No published ports: only the frontend service is reachable from outside - # the Docker network in production. api.core stays internal to prodnet. environment: ASPNETCORE_ENVIRONMENT: "Production" ASPNETCORE_URLS: "http://0.0.0.0:8080" @@ -73,11 +50,45 @@ services: REFRESH_TOKEN_SECRET: "${REFRESH_TOKEN_SECRET}" CONFIRMATION_TOKEN_SECRET: "${CONFIRMATION_TOKEN_SECRET}" WEBSITE_BASE_URL: "${WEBSITE_BASE_URL}" + SEAWEEDFS_SERVICE_URL: "${SEAWEEDFS_SERVICE_URL}" + SEAWEEDFS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}" + SEAWEEDFS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}" + SEAWEEDFS_BUCKET: "${SEAWEEDFS_BUCKET}" + healthcheck: + test: + [ + "CMD", + "bash", + "-c", + 'exec 3<>/dev/tcp/127.0.0.1/8080; printf "GET /health HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n" >&3; head -1 <&3 | grep -q " 200 "', + ] + interval: 15s + timeout: 5s + retries: 5 + start_period: 30s restart: unless-stopped networks: - prodnet + + seaweedfs: + env_file: ".env.prod" + image: chrislusf/seaweedfs + container_name: prod-env-seaweedfs + environment: + AWS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}" + AWS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}" + S3_BUCKET: "${SEAWEEDFS_BUCKET}" volumes: - - nuget-cache-prod:/root/.nuget/packages + - seaweedfsdata-prod:/data + healthcheck: + test: ["CMD", "curl", "-fsS", "-o", "/dev/null", "http://localhost:8333/status"] + interval: 15s + timeout: 5s + retries: 5 + start_period: 20s + restart: unless-stopped + networks: + - prodnet frontend: env_file: ".env.prod" @@ -85,27 +96,34 @@ services: container_name: prod-env-frontend depends_on: api.core: - condition: service_started + condition: service_healthy build: context: ./frontend dockerfile: Dockerfile ports: - - "3000:3000" + - "127.0.0.1:5656:5656" environment: NODE_ENV: "production" - PORT: "3000" + PORT: "5656" API_BASE_URL: "http://api.core:8080" SESSION_SECRET: "${SESSION_SECRET}" + healthcheck: + test: ["CMD", "wget", "--quiet", "--spider", "--tries=1", "http://127.0.0.1:5656/healthz"] + interval: 15s + timeout: 5s + retries: 5 + start_period: 15s restart: unless-stopped networks: - prodnet volumes: - sqlserverdata-prod: - driver: local - nuget-cache-prod: - driver: local + biergartendata-prod: + external: true + seaweedfsdata-prod: + external: true networks: prodnet: + name: biergarten-prodnet driver: bridge diff --git a/web/frontend/app/app.css b/web/frontend/app/app.css index 8967dbd6..b9035c60 100644 --- a/web/frontend/app/app.css +++ b/web/frontend/app/app.css @@ -34,38 +34,38 @@ h6, --color-base-100: oklch(96% 0.012 82); /* warm parchment */ --color-base-200: oklch(92% 0.018 80); /* brushed paper */ --color-base-300: oklch(87% 0.025 78); /* tinted linen */ - --color-base-content: oklch(28% 0.025 58); /* dark brown ink — 15.6:1 on base-100 */ + --color-base-content: oklch(28% 0.025 58); /* dark brown ink */ - --color-primary: oklch(65% 0.085 62); /* mellow amber */ - --color-primary-content: oklch(97% 0.02 62); /* warm near-white — 7.2:1 on primary */ + --color-primary: oklch(47.5% 0.085 62); /* mellow amber */ + --color-primary-content: oklch(97% 0.02 62); /* warm near-white */ --color-secondary: oklch(42% 0.05 42); /* softened mahogany */ - --color-secondary-content: oklch(96% 0.01 76); /* off-white — 14.2:1 on secondary */ + --color-secondary-content: oklch(96% 0.01 76); /* off-white */ --color-accent: oklch(93% 0.015 90); /* frothy cream */ - --color-accent-content: oklch(28% 0.025 58); /* dark brown — 12.8:1 on accent */ + --color-accent-content: oklch(28% 0.025 58); /* dark brown */ --color-neutral: oklch(28% 0.02 46); /* warm roast dark */ - --color-neutral-content: oklch(92% 0.012 80); /* pale parchment — 12.0:1 on neutral */ + --color-neutral-content: oklch(92% 0.012 80); /* pale parchment */ --color-info: oklch(46% 0.065 145); /* muted hop green */ - --color-info-content: oklch(97% 0.008 145); /* near-white — 14.2:1 on info */ + --color-info-content: oklch(97% 0.008 145); /* near-white */ - --color-success: oklch(70% 0.06 122); /* soft barley gold */ - --color-success-content: oklch(97% 0.02 122); /* warm near-white — 5.7:1 on success */ + --color-success: oklch(46.5% 0.06 122); /* soft barley gold */ + --color-success-content: oklch(97% 0.02 122); /* warm near-white */ - --color-warning: oklch(72% 0.09 56); /* toned amber */ - --color-warning-content: oklch(97% 0.02 56); /* warm near-white — 4.7:1 on warning */ + --color-warning: oklch(47.5% 0.09 56); /* toned amber */ + --color-warning-content: oklch(97% 0.02 56); /* warm near-white */ - --color-error: oklch(54% 0.09 22); /* restrained cherry */ - --color-error-content: oklch(97% 0.006 15); /* near-white — 11.1:1 on error */ + --color-error: oklch(48% 0.09 22); /* restrained cherry */ + --color-error-content: oklch(97% 0.006 15); /* near-white */ --color-surface: oklch(88% 0.02 82); /* mid parchment, elevated cards */ - --color-surface-content: oklch(28% 0.025 58); /* dark brown — 9.2:1 on surface */ + --color-surface-content: oklch(28% 0.025 58); /* dark brown */ - --color-muted: oklch(42% 0.055 62); /* amber-brown — 14.2:1 on base-100, 8.3:1 on surface */ + --color-muted: oklch(42% 0.055 62); /* amber-brown */ --color-highlight: oklch(78% 0.055 65); /* warm amber, hover and active states */ - --color-highlight-content: oklch(22% 0.025 55); /* dark brown — 4.9:1 on highlight */ + --color-highlight-content: oklch(22% 0.025 55); /* dark brown */ --radius-selector: 0.375rem; --radius-field: 0.5rem; @@ -91,38 +91,38 @@ h6, --color-base-100: oklch(14% 0.006 45); /* charred barrel black */ --color-base-200: oklch(18% 0.008 43); /* roasted malt dark */ --color-base-300: oklch(23% 0.01 42); /* deep brown */ - --color-base-content: oklch(88% 0.008 75); /* warm off-white — 9.4:1 on base-100 */ + --color-base-content: oklch(88% 0.008 75); /* warm off-white */ --color-primary: oklch(68% 0.055 60); /* golden amber */ - --color-primary-content: oklch(92% 0.012 50); /* warm off-white — 4.6:1 on primary */ + --color-primary-content: oklch(30.5% 0.012 50); /* warm off-white */ - --color-secondary: oklch(48% 0.035 40); /* deep mahogany ale */ - --color-secondary-content: oklch(97% 0.005 75); /* near-white — 13.9:1 on secondary */ + --color-secondary: oklch(62.5% 0.035 40); /* deep mahogany ale */ + --color-secondary-content: oklch(23.5% 0.005 75); /* near-white */ --color-accent: oklch(82% 0.01 88); /* frothy cream head */ - --color-accent-content: oklch(18% 0.012 55); /* near-black — 6.2:1 on accent */ + --color-accent-content: oklch(18% 0.012 55); /* near-black */ --color-neutral: oklch(20% 0.008 45); /* near-black with warmth */ - --color-neutral-content: oklch(88% 0.007 78); /* warm off-white — 9.3:1 on neutral */ + --color-neutral-content: oklch(88% 0.007 78); /* warm off-white */ - --color-info: oklch(60% 0.04 145); /* cool hop green */ - --color-info-content: oklch(86% 0.006 145); /* pale green-white — 4.6:1 on info */ + --color-info: oklch(61.5% 0.04 145); /* cool hop green */ + --color-info-content: oklch(23% 0.006 145); /* pale green-white */ --color-success: oklch(66% 0.038 120); /* fresh barley */ - --color-success-content: oklch(90% 0.012 120); /* pale barley-white — 4.6:1 on success */ + --color-success-content: oklch(28.5% 0.012 120); /* pale barley-white */ --color-warning: oklch(70% 0.055 55); /* amber harvest */ - --color-warning-content: oklch(94% 0.012 55); /* warm near-white — 4.7:1 on warning */ + --color-warning-content: oklch(32.5% 0.012 55); /* warm near-white */ - --color-error: oklch(50% 0.06 20); /* deep cherry kriek */ - --color-error-content: oklch(97% 0.004 15); /* near-white — 13.1:1 on error */ + --color-error: oklch(62.5% 0.06 20); /* deep cherry kriek */ + --color-error-content: oklch(23% 0.004 15); /* near-white */ --color-surface: oklch(26% 0.012 45); /* elevated dark panel */ - --color-surface-content: oklch(88% 0.008 75); /* warm off-white — 9.2:1 on surface */ + --color-surface-content: oklch(88% 0.008 75); /* warm off-white */ - --color-muted: oklch(78% 0.018 72); /* warm grey — 4.7:1 on base-100, 4.6:1 on surface */ + --color-muted: oklch(78% 0.018 72); /* warm grey */ --color-highlight: oklch(32% 0.025 48); /* warm dark brown, hover and active states */ - --color-highlight-content: oklch(88% 0.008 75); /* warm off-white — 9.0:1 on highlight */ + --color-highlight-content: oklch(88% 0.008 75); /* warm off-white */ --radius-selector: 0.375rem; --radius-field: 0.5rem; @@ -148,40 +148,38 @@ h6, --color-base-100: oklch(13% 0.01 295); /* blackberry-stained near-black */ --color-base-200: oklch(17% 0.013 292); /* deep purple-black */ --color-base-300: oklch(22% 0.016 290); /* dark grape */ - --color-base-content: oklch(90% 0.014 300); /* pale lavender-white — 10.7:1 on base-100 */ + --color-base-content: oklch(90% 0.014 300); /* pale lavender-white */ --color-primary: oklch(72% 0.075 295); /* cassis berry purple */ - --color-primary-content: oklch(95% 0.01 295); /* pale lavender — 4.5:1 on primary */ + --color-primary-content: oklch(34% 0.01 295); /* pale lavender */ --color-secondary: oklch(68% 0.06 10); /* sour cherry rose */ - --color-secondary-content: oklch(92% 0.006 10); /* warm near-white — 4.6:1 on secondary */ + --color-secondary-content: oklch(30% 0.006 10); /* warm near-white */ --color-accent: oklch(75% 0.045 130); /* tart lime zest */ - --color-accent-content: oklch(98.5% 0.01 130); /* near-white — 4.8:1 on accent */ + --color-accent-content: oklch(37.5% 0.01 130); /* near-white */ --color-neutral: oklch(18% 0.016 290); /* deep blackened grape */ - --color-neutral-content: oklch(88% 0.01 295); /* pale lavender — 9.3:1 on neutral */ + --color-neutral-content: oklch(88% 0.01 295); /* pale lavender */ --color-info: oklch(62% 0.04 250); /* muted indigo */ - --color-info-content: oklch(88% 0.008 250); /* pale indigo-white — 4.8:1 on info */ + --color-info-content: oklch(23% 0.008 250); /* pale indigo-white */ --color-success: oklch(65% 0.04 145); /* elderberry green */ - --color-success-content: oklch(90% 0.008 145); /* pale green-white — 4.7:1 on success */ + --color-success-content: oklch(27.5% 0.008 145); /* pale green-white */ --color-warning: oklch(70% 0.05 65); /* sour apricot */ - --color-warning-content: oklch(97% 0.03 65); /* near-white — 5.6:1 on warning */ + --color-warning-content: oklch(32.5% 0.03 65); /* near-white */ - --color-error: oklch(50% 0.055 22); /* kriek red */ - --color-error-content: oklch(97% 0.006 22); /* near-white — 13.2:1 on error */ + --color-error: oklch(62% 0.055 22); /* kriek red */ + --color-error-content: oklch(22% 0.006 22); /* near-white */ --color-surface: oklch(27% 0.022 292); /* lifted purple-black panel */ - --color-surface-content: oklch(90% 0.014 300); /* pale lavender-white — 10.4:1 on surface */ + --color-surface-content: oklch(90% 0.014 300); /* pale lavender-white */ - --color-muted: oklch( - 77.6% 0.022 300 - ); /* desaturated lavender — 4.6:1 on base-100, 4.5:1 on surface */ + --color-muted: oklch(77.6% 0.022 300); /* desaturated lavender */ --color-highlight: oklch(35% 0.04 295); /* cassis-tinted hover and active state */ - --color-highlight-content: oklch(90% 0.014 300); /* pale lavender-white — 10.1:1 on highlight */ + --color-highlight-content: oklch(90% 0.014 300); /* pale lavender-white */ --radius-selector: 0.5rem; --radius-field: 0.5rem; @@ -207,38 +205,38 @@ h6, --color-base-100: oklch(99% 0.007 112); /* near-white with faint barley-green tint */ --color-base-200: oklch(96% 0.012 114); /* pale barley wash */ --color-base-300: oklch(92% 0.019 116); /* light straw */ - --color-base-content: oklch(20% 0.022 122); /* deep green-black — 19.5:1 on base-100 */ + --color-base-content: oklch(20% 0.022 122); /* deep green-black */ - --color-primary: oklch(52% 0.085 118); /* fresh-cut barley green */ - --color-primary-content: oklch(97% 0.005 118); /* near-white — 12.5:1 on primary */ + --color-primary: oklch(50% 0.085 118); /* fresh-cut barley green */ + --color-primary-content: oklch(97% 0.005 118); /* near-white */ --color-secondary: oklch(44% 0.055 128); /* muted sage stem */ - --color-secondary-content: oklch(97% 0.005 128); /* near-white — 14.8:1 on secondary */ + --color-secondary-content: oklch(97% 0.005 128); /* near-white */ --color-accent: oklch(93% 0.03 148); /* pale morning dew */ - --color-accent-content: oklch(22% 0.022 148); /* deep green — 13.4:1 on accent */ + --color-accent-content: oklch(22% 0.022 148); /* deep green */ --color-neutral: oklch(76% 0.028 118); /* dried straw, surface differentiation */ - --color-neutral-content: oklch(98.9% 0.005 118); /* near-white — 4.6:1 on neutral */ + --color-neutral-content: oklch(38.4% 0.005 118); /* near-white */ --color-info: oklch(38% 0.065 232); /* clear summer sky */ - --color-info-content: oklch(98% 0.005 232); /* near-white — 16.8:1 on info */ + --color-info-content: oklch(98% 0.005 232); /* near-white */ --color-success: oklch(38% 0.085 145); /* young shoot green */ - --color-success-content: oklch(98% 0.005 145); /* near-white — 16.8:1 on success */ + --color-success-content: oklch(98% 0.005 145); /* near-white */ - --color-warning: oklch(68% 0.1 76); /* ripening grain amber */ - --color-warning-content: oklch(92.5% 0.005 72); /* warm near-white — 4.5:1 on warning */ + --color-warning: oklch(51% 0.1 76); /* ripening grain amber */ + --color-warning-content: oklch(92.5% 0.005 72); /* warm near-white */ - --color-error: oklch(52% 0.1 18); /* dusty rose red */ - --color-error-content: oklch(98% 0.005 15); /* near-white — 12.5:1 on error */ + --color-error: oklch(51.5% 0.1 18); /* dusty rose red */ + --color-error-content: oklch(98% 0.005 15); /* near-white */ --color-surface: oklch(94% 0.012 112); /* soft barley-wash panel */ - --color-surface-content: oklch(20% 0.022 122); /* deep green-black — 14.0:1 on surface */ + --color-surface-content: oklch(20% 0.022 122); /* deep green-black */ - --color-muted: oklch(38% 0.055 120); /* sage green — 18.1:1 on base-100, 13.0:1 on surface */ + --color-muted: oklch(38% 0.055 120); /* sage green */ --color-highlight: oklch(85% 0.04 118); /* green-tinted hover and active state */ - --color-highlight-content: oklch(20% 0.022 122); /* deep green-black — 7.8:1 on highlight */ + --color-highlight-content: oklch(20% 0.022 122); /* deep green-black */ --radius-selector: 2rem; --radius-field: 2rem; @@ -249,3 +247,33 @@ h6, --depth: 0; --noise: 0; } + +/* WCAG 1.4.11: form-field borders at >= 3:1 against every base layer */ +:is(.input, .select, .textarea):not( + .input-neutral, + .input-primary, + .input-secondary, + .input-accent, + .input-info, + .input-success, + .input-warning, + .input-error, + .select-neutral, + .select-primary, + .select-secondary, + .select-accent, + .select-info, + .select-success, + .select-warning, + .select-error, + .textarea-neutral, + .textarea-primary, + .textarea-secondary, + .textarea-accent, + .textarea-info, + .textarea-success, + .textarea-warning, + .textarea-error +) { + --input-color: color-mix(in oklab, var(--color-base-content) 56%, #0000); +} diff --git a/web/frontend/app/components/ui/navbar/Navbar.tsx b/web/frontend/app/components/ui/navbar/Navbar.tsx index b781b464..a91423a6 100644 --- a/web/frontend/app/components/ui/navbar/Navbar.tsx +++ b/web/frontend/app/components/ui/navbar/Navbar.tsx @@ -49,8 +49,8 @@ const Navbar = ({ auth }: NavbarProps) => ( - - 🍺 The Biergarten App + + The Biergarten App diff --git a/web/frontend/app/features/account/components/AuthFlowDemoCard.tsx b/web/frontend/app/features/account/components/AuthFlowDemoCard.tsx index 37898699..0ad5bc82 100644 --- a/web/frontend/app/features/account/components/AuthFlowDemoCard.tsx +++ b/web/frontend/app/features/account/components/AuthFlowDemoCard.tsx @@ -11,7 +11,7 @@ const AuthFlowDemoCard = () => (
  • Login

    -

    +

    POST to /api/auth/login with username & password

    @@ -20,7 +20,7 @@ const AuthFlowDemoCard = () => (
  • Register

    -

    +

    POST to /api/auth/register with full user details

    @@ -29,7 +29,7 @@ const AuthFlowDemoCard = () => (
  • Session

    -

    +

    JWT access & refresh tokens stored in an HTTP-only cookie

    @@ -37,7 +37,7 @@ const AuthFlowDemoCard = () => (
  • Protected Routes

    -

    +

    This dashboard requires authentication via{' '} requireAuth()

    @@ -46,7 +46,7 @@ const AuthFlowDemoCard = () => (
  • Token Refresh

    -

    +

    POST to /api/auth/refresh with the refresh token in an X-Refresh-Token{' '} header @@ -56,7 +56,7 @@ const AuthFlowDemoCard = () => (

  • Account Management

    -

    +

    PATCH /api/auth/username,{' '} /email,{' '} /password,{' '} diff --git a/web/frontend/app/features/account/components/SectionCard.tsx b/web/frontend/app/features/account/components/SectionCard.tsx index b8d108ad..993e817f 100644 --- a/web/frontend/app/features/account/components/SectionCard.tsx +++ b/web/frontend/app/features/account/components/SectionCard.tsx @@ -14,7 +14,7 @@ const SectionCard = ({ icon, title, description, open, onToggle, children }: Sec

    - {icon} + {icon}

    {title}

    {description}

    diff --git a/web/frontend/app/features/account/components/SessionInfoCard.tsx b/web/frontend/app/features/account/components/SessionInfoCard.tsx index 4c7d4981..a215ebe2 100644 --- a/web/frontend/app/features/account/components/SessionInfoCard.tsx +++ b/web/frontend/app/features/account/components/SessionInfoCard.tsx @@ -15,7 +15,7 @@ const SessionInfoCard = ({ username, userAccountId }: SessionInfoCardProps) => (

    -

    +

    Session Info

    diff --git a/web/frontend/app/features/account/routes/account.tsx b/web/frontend/app/features/account/routes/account.tsx index 91f4a3d4..c26191ec 100644 --- a/web/frontend/app/features/account/routes/account.tsx +++ b/web/frontend/app/features/account/routes/account.tsx @@ -208,11 +208,13 @@ const AccountPage = ({ loaderData, actionData }: Route.ComponentProps) => { return (
    -
    +