Compare commits

...

2 Commits

56 changed files with 901 additions and 207 deletions

View File

@@ -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).

View File

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

View File

@@ -1,11 +1,13 @@
sequenceDiagram
box Production Environment (docker-compose.prod.yaml)
participant ProdDB as SQL Server 2022<br/>Biergarten Database<br/>no host port published
participant ProdMig as Migrations (run-once)
participant ProdS3 as SeaweedFS<br/>S3 gateway :8333<br/>no host port published
participant ProdAPI as API Container<br/>ASP.NET Core 10<br/>:8080 HTTP / :8081 HTTPS
participant ProdWeb as Frontend Container<br/>Express + React Router<br/>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,<br/>run at deploy time rather than by the stack

View File

@@ -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 `<project>_` 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 '<db-password>' -C \
-Q "BACKUP DATABASE [<db-name>] 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:

View File

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

View File

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

View File

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

View File

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

View File

@@ -49,8 +49,8 @@ const Navbar = ({ auth }: NavbarProps) => (
</svg>
</DisclosureButton>
<Link to="/" className="text-xl font-bold">
🍺 The Biergarten App
<Link to="/" className="lg:text-xl text-lg font-bold">
The Biergarten App
</Link>
</div>

View File

@@ -11,7 +11,7 @@ const AuthFlowDemoCard = () => (
<li className="list-row">
<div>
<p className="font-semibold">Login</p>
<p className="text-sm text-base-content/60">
<p className="text-sm text-base-content/70">
POST to <code className="kbd kbd-sm">/api/auth/login</code> with
username &amp; password
</p>
@@ -20,7 +20,7 @@ const AuthFlowDemoCard = () => (
<li className="list-row">
<div>
<p className="font-semibold">Register</p>
<p className="text-sm text-base-content/60">
<p className="text-sm text-base-content/70">
POST to <code className="kbd kbd-sm">/api/auth/register</code> with full
user details
</p>
@@ -29,7 +29,7 @@ const AuthFlowDemoCard = () => (
<li className="list-row">
<div>
<p className="font-semibold">Session</p>
<p className="text-sm text-base-content/60">
<p className="text-sm text-base-content/70">
JWT access &amp; refresh tokens stored in an HTTP-only cookie
</p>
</div>
@@ -37,7 +37,7 @@ const AuthFlowDemoCard = () => (
<li className="list-row">
<div>
<p className="font-semibold">Protected Routes</p>
<p className="text-sm text-base-content/60">
<p className="text-sm text-base-content/70">
This dashboard requires authentication via{' '}
<code className="kbd kbd-sm">requireAuth()</code>
</p>
@@ -46,7 +46,7 @@ const AuthFlowDemoCard = () => (
<li className="list-row">
<div>
<p className="font-semibold">Token Refresh</p>
<p className="text-sm text-base-content/60">
<p className="text-sm text-base-content/70">
POST to <code className="kbd kbd-sm">/api/auth/refresh</code> with the
refresh token in an <code className="kbd kbd-sm">X-Refresh-Token</code>{' '}
header
@@ -56,7 +56,7 @@ const AuthFlowDemoCard = () => (
<li className="list-row">
<div>
<p className="font-semibold">Account Management</p>
<p className="text-sm text-base-content/60">
<p className="text-sm text-base-content/70">
PATCH <code className="kbd kbd-sm">/api/auth/username</code>,{' '}
<code className="kbd kbd-sm">/email</code>,{' '}
<code className="kbd kbd-sm">/password</code>,{' '}

View File

@@ -14,7 +14,7 @@ const SectionCard = ({ icon, title, description, open, onToggle, children }: Sec
<div className="card-body">
<div className="flex w-full items-center justify-between gap-5">
<div className="flex items-start gap-3">
<span className="text-base-content/60 mt-1">{icon}</span>
<span className="text-base-content/70 mt-1">{icon}</span>
<div>
<h2 className="card-title text-lg">{title}</h2>
<p className="text-sm text-base-content/70">{description}</p>

View File

@@ -15,7 +15,7 @@ const SessionInfoCard = ({ username, userAccountId }: SessionInfoCardProps) => (
</p>
<div className="bg-base-200 rounded-box p-4 mt-2">
<p className="text-xs font-semibold uppercase tracking-widest text-base-content/50 mb-3">
<p className="text-xs font-semibold uppercase tracking-widest text-base-content/70 mb-3">
Session Info
</p>
<div className="stats stats-vertical w-full">

View File

@@ -208,11 +208,13 @@ const AccountPage = ({ loaderData, actionData }: Route.ComponentProps) => {
return (
<div className="min-h-screen bg-base-200">
<div className="mx-auto max-w-2xl px-6 py-10 space-y-6">
<div className="mx-auto max-w-7xl px-5 pt-10 pb-10 space-y-6">
<div className="flex items-center gap-3">
<Settings className="size-7" aria-hidden="true" />
<div>
<h1 className="text-3xl font-bold">Account Settings</h1>
<h1 className="font-serif text-5xl font-bold leading-tight">
Account Settings
</h1>
<p className="text-base-content/70">
Manage the account details for{' '}
<span className="font-mono">{username}</span>
@@ -257,7 +259,7 @@ const AccountPage = ({ loaderData, actionData }: Route.ComponentProps) => {
<Link
to="/dashboard"
className="link link-hover text-sm text-base-content/60 inline-block"
className="link link-hover text-sm text-base-content/70 inline-block"
>
← Back to dashboard
</Link>

View File

@@ -20,7 +20,7 @@ const Dashboard = ({ loaderData }: Route.ComponentProps) => {
return (
<div className="min-h-screen bg-base-200">
<div className="mx-auto max-w-4xl px-6 py-10 space-y-6">
<div className="mx-auto max-w-7xl px-5 pt-10 pb-10 space-y-6">
<SessionInfoCard username={username} userAccountId={userAccountId} />
<AuthFlowDemoCard />
</div>

View File

@@ -10,7 +10,7 @@ const ConfirmSuccess = ({ confirmedDate }: ConfirmSuccessProps) => (
<h1 className="card-title text-2xl">Email Confirmed!</h1>
<p className="text-base-content/70">Your email address has been successfully verified.</p>
<div className="bg-base-200 rounded-box w-full p-3 text-sm text-left">
<span className="text-base-content/50 text-xs uppercase tracking-widest font-semibold">
<span className="text-base-content/70 text-xs uppercase tracking-widest font-semibold">
Confirmed at
</span>
<p className="font-mono mt-1">{new Date(confirmedDate).toLocaleString()}</p>

View File

@@ -8,7 +8,7 @@ const LoginCallout = () => (
<Link to="/login" className="btn btn-outline btn-sm w-full">
Sign in
</Link>
<Link to="/" className="link link-hover text-sm text-base-content/60">
<Link to="/" className="link link-hover text-sm text-base-content/70">
Back to home
</Link>
</div>

View File

@@ -11,7 +11,7 @@ const RegisterCallout = () => (
</Link>
<Link
to="/"
className="link link-hover text-sm text-base-content/60 inline-flex items-center gap-1"
className="link link-hover text-sm text-base-content/70 inline-flex items-center gap-1"
>
<HomeSimpleDoor className="size-4" aria-hidden="true" />
Back to home

View File

@@ -23,12 +23,12 @@ const BeerListCard = ({ beers }: BeerListCardProps) => (
>
<div className="flex flex-col gap-0.5 min-w-0">
<span className="font-bold text-sm">{beer.name}</span>
<span className="text-xs text-base-content/60">{beer.description}</span>
<span className="text-xs text-base-content/70">{beer.description}</span>
</div>
<span className="badge badge-ghost bg-base-300 text-xs font-semibold whitespace-nowrap">
{beer.style}
</span>
<span className="text-xs text-base-content/60 tabular-nums whitespace-nowrap">
<span className="text-xs text-base-content/70 tabular-nums whitespace-nowrap">
{beer.abv.toFixed(1)}% ABV
</span>
<StarRating value={beer.rating} size="xs" />

View File

@@ -17,7 +17,7 @@ const BeerStyleBreakdownCard = ({ beers }: BeerStyleBreakdownCardProps) => {
<div key={style} className="flex flex-col gap-1">
<div className="flex items-baseline justify-between gap-2">
<span className="text-sm font-semibold">{style}</span>
<span className="text-xs text-base-content/60 tabular-nums">{count}</span>
<span className="text-xs text-base-content/70 tabular-nums">{count}</span>
</div>
<progress
className="progress progress-primary h-1.5"

View File

@@ -8,7 +8,7 @@ interface BreweryDetailsCardProps {
const DetailRow = ({ label, value }: { label: string; value: string | number }) => (
<div className="flex items-center justify-between gap-2 text-sm">
<span className="text-base-content/60">{label}</span>
<span className="text-base-content/70">{label}</span>
<span className="font-semibold">{value}</span>
</div>
);

View File

@@ -26,16 +26,18 @@ const BreweryHeaderCard = ({
<div className="card-body gap-4 p-7">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs font-bold uppercase tracking-widest text-base-content/50">
<span className="text-xs font-bold uppercase tracking-widest text-base-content/70">
Brewery
</span>
<span className="badge bg-[var(--color-highlight)] text-[var(--color-highlight-content)]">
Est. {foundedYear}
</span>
</div>
<h1 className="text-4xl leading-tight m-0">{brewery.breweryName}</h1>
<h1 className="font-serif text-5xl font-bold leading-tight m-0">
{brewery.breweryName}
</h1>
{brewery.location && (
<p className="text-sm text-base-content/60 flex items-center gap-1 m-0">
<p className="text-sm text-base-content/70 flex items-center gap-1 m-0">
<MapPin className="size-4" aria-hidden="true" />
{formatBreweryAddress(brewery.location)}
</p>
@@ -57,7 +59,7 @@ const BreweryHeaderCard = ({
<div className="flex items-baseline gap-1.5">
<StarRating value={avgRating} size="sm" />
<span className="font-bold">{avgRating.toFixed(1)}</span>
<span className="text-sm text-base-content/60">({ratingsCount} ratings)</span>
<span className="text-sm text-base-content/70">({ratingsCount} ratings)</span>
</div>
</div>
<p className="text-base-content/70 leading-relaxed text-pretty m-0">

View File

@@ -8,7 +8,7 @@ interface CommunityStatsCardProps {
const StatRow = ({ label, value }: { label: string; value: number }) => (
<div className="flex items-center justify-between gap-2 text-sm">
<span className="text-base-content/60">{label}</span>
<span className="text-base-content/70">{label}</span>
<span className="font-semibold tabular-nums">{value}</span>
</div>
);

View File

@@ -10,7 +10,7 @@ const YourRatingCard = ({ value, onChange }: YourRatingCardProps) => (
<div className="card-body flex-row flex-wrap items-center justify-between gap-4 p-6">
<div className="flex flex-col gap-0.5">
<h3 className="text-xl m-0">Your rating</h3>
<p className="text-sm text-base-content/60 m-0">
<p className="text-sm text-base-content/70 m-0">
{value
? `You rated this ${value} of 5. Tap the same star to clear.`
: 'Tap a star to rate this brewery.'}

View File

@@ -31,14 +31,14 @@ const CommentItem = ({ comment, onToggleLike }: CommentItemProps) => {
<span className="font-bold text-sm">{comment.user}</span>
)}
<StarRating value={comment.rating} size="xs" />
<span className="text-xs text-base-content/60">{comment.time}</span>
<span className="text-xs text-base-content/70">{comment.time}</span>
</div>
<p className="text-sm leading-relaxed m-0">{comment.text}</p>
<button
type="button"
onClick={() => onToggleLike(comment.id)}
className={`self-start inline-flex items-center gap-1 text-xs font-semibold ${
comment.liked ? 'text-primary' : 'text-base-content/60'
comment.liked ? 'text-primary' : 'text-base-content/70'
}`}
>
{comment.liked ? (

View File

@@ -24,7 +24,7 @@ const BreweryCard = ({ brewery }: BreweryCardProps) => (
</p>
)}
{brewery.location && (
<p className="text-sm text-base-content/50 mt-2">
<p className="text-sm text-base-content/70 mt-2">
{formatBreweryAddress(brewery.location)}
</p>
)}

View File

@@ -19,14 +19,14 @@ const FeaturedBreweryCard = ({ brewery, onShowOnMap }: FeaturedBreweryCardProps)
<div className="card-body gap-3.5 p-8 md:grid md:grid-cols-[1.35fr_1fr] md:gap-10 md:items-start">
<div className="flex flex-col gap-3.5">
{brewery.location && (
<div className="text-sm font-semibold text-base-content/60">
<div className="text-sm font-semibold text-base-content/70">
{brewery.location.cityName}, {brewery.location.stateProvinceCode}
</div>
)}
<h3 className="font-serif text-4xl leading-tight m-0">{brewery.breweryName}</h3>
<p className="text-lg leading-snug text-pretty m-0">{brewery.description}</p>
{brewery.location && (
<p className="text-sm text-base-content/60 m-0">
<p className="text-sm text-base-content/70 m-0">
{formatBreweryAddress(brewery.location)}
</p>
)}

View File

@@ -17,7 +17,7 @@ const RecentBreweries = ({ breweries }: RecentBreweriesProps) => {
<div className="card-body gap-1.5">
<h3 className="card-title font-serif text-xl">{brewery.breweryName}</h3>
{brewery.location && (
<div className="text-sm text-base-content/60">
<div className="text-sm text-base-content/70">
{brewery.location.cityName},{' '}
{brewery.location.stateProvinceCode}
</div>

View File

@@ -20,7 +20,7 @@ const NearbyBreweriesHeader = ({
return (
<div className="flex items-baseline justify-between gap-4 flex-wrap mb-3.5">
<h2 className="font-serif text-2xl m-0">Breweries near you</h2>
<span className="text-sm text-base-content/60">
<span className="text-sm text-base-content/70">
{center
? `${withDistance.length} within ${formatDistance(radiusKm * 1000, unit)} of ${centerLabel}`
: 'Finding breweries near you…'}

View File

@@ -23,7 +23,7 @@ const NearbyBreweryList = ({
return (
<>
{withDistance.length === 0 && center && (
<p className="text-sm text-base-content/60">
<p className="text-sm text-base-content/70">
No partner breweries within {formatDistance(radiusKm * 1000, unit)}.
</p>
)}

View File

@@ -37,7 +37,7 @@ const NearbyBreweryListItem = ({
</span>
</div>
{brewery.location && (
<div className="text-sm text-base-content/60">
<div className="text-sm text-base-content/70">
{brewery.location.cityName}, {brewery.location.stateProvinceCode}
</div>
)}

View File

@@ -8,7 +8,7 @@ interface SidebarCardProps {
const SidebarCard = ({ title, children }: SidebarCardProps) => (
<div className="card bg-base-100 shadow">
<div className="card-body gap-3 p-6">
<p className="text-xs font-bold uppercase tracking-widest text-base-content/50 m-0">
<p className="text-xs font-bold uppercase tracking-widest text-base-content/70 m-0">
{title}
</p>
{children}

View File

@@ -44,8 +44,8 @@ const Breweries = ({ loaderData }: Route.ComponentProps) => {
return (
<div className="min-h-screen bg-base-200 text-base-content pb-16">
<div className="max-w-7xl mx-auto px-5 pt-10">
<h1 className="font-serif text-5xl leading-tight mb-2">Breweries</h1>
<p className="text-lg text-base-content/60 max-w-xl m-0">
<h1 className="font-serif text-5xl font-bold leading-tight mb-2">Breweries</h1>
<p className="text-lg text-base-content/70 max-w-xl m-0">
Discover our partner breweries — start with this week&apos;s feature, then see
what&apos;s pouring near you.
</p>

View File

@@ -80,15 +80,15 @@ const BreweryDetail = ({ loaderData }: Route.ComponentProps) => {
return (
<div className="min-h-screen bg-base-200">
<div className="max-w-4xl mx-auto px-6 pt-8 pb-16">
<div className="mx-auto max-w-7xl px-5 pt-10 pb-16">
<Link
to="/breweries"
className="link link-hover text-sm text-base-content/60 mb-4 inline-block"
className="link link-hover text-sm text-base-content/70 mb-4 inline-block"
>
&larr; Back to breweries
</Link>
<div className="grid grid-cols-1 lg:grid-cols-[1fr_19rem] gap-5 items-start">
<div className="grid grid-cols-1 lg:grid-cols-[1fr_23rem] gap-5 items-start">
<div className="flex flex-col gap-5">
<BreweryHeaderCard
brewery={brewery}

View File

@@ -72,14 +72,16 @@ const Breweries = ({ loaderData }: Route.ComponentProps) => {
return (
<div className="min-h-screen bg-base-200">
<div className="container mx-auto p-14">
<div className="mx-auto max-w-7xl px-5 pt-10">
<Link
to="/breweries"
className="link link-hover text-sm text-base-content/60 mb-4 inline-block"
className="link link-hover text-sm text-base-content/70 mb-4 inline-block"
>
&larr; Back to Breweries
</Link>
<h1 className="text-4xl font-bold mb-4">Full brewery directory</h1>
<h1 className="font-serif text-5xl font-bold leading-tight mb-4">
Full brewery directory
</h1>
<p className="text-base-content/70 mb-6">
All of our partner breweries, newest first.
</p>
@@ -93,7 +95,7 @@ const Breweries = ({ loaderData }: Route.ComponentProps) => {
</ClientOnly>
{allBreweries.length === 0 ? (
<p className="text-center text-base-content/60 mt-12">
<p className="text-center text-base-content/70 mt-12">
No breweries found yet.
</p>
) : (

View File

@@ -0,0 +1,77 @@
import { Clock, Lock, Tools } from 'iconoir-react';
import type { ComponentType } from 'react';
import { Link } from 'react-router';
type Reason = 'soon' | 'maintenance' | 'auth';
interface Action {
to: string;
label: string;
className: string;
}
interface Content {
Icon: ComponentType<{ className?: string }>;
title: string;
description: string;
actions: Action[];
}
const CONTENT: Record<Reason, Content> = {
soon: {
Icon: Clock,
title: 'This feature is not available yet',
description: 'This feature is still in development.',
actions: [
{ to: '/breweries', label: 'Browse breweries', className: 'btn-primary' },
{ to: '/', label: 'Back to home', className: 'btn-ghost' },
],
},
maintenance: {
Icon: Tools,
title: 'This feature is temporarily unavailable',
description: 'This feature is offline for maintenance. Try again in a few minutes.',
actions: [],
},
auth: {
Icon: Lock,
title: 'Sign in to use this feature',
description:
'This feature requires a valid session. Log in or create an account to continue.',
actions: [
{ to: '/login', label: 'Login', className: 'btn-primary' },
{ to: '/register', label: 'Register', className: 'btn-outline' },
],
},
};
interface FeatureNotAvailableProps {
reason?: Reason;
}
const FeatureNotAvailable = ({ reason = 'soon' }: FeatureNotAvailableProps) => {
const { Icon, title, description, actions } = CONTENT[reason];
return (
<section className="flex flex-col items-center gap-3 rounded-box border border-base-300 bg-base-100 px-6 py-12 text-center">
<div className="flex size-14 items-center justify-center rounded-full bg-base-200 text-base-content/70">
<Icon className="size-6" />
</div>
<div className="flex max-w-md flex-col gap-2">
<h2 className="font-serif text-2xl font-bold leading-tight">{title}</h2>
<p className="text-base-content/70 text-pretty">{description}</p>
</div>
{actions.length > 0 && (
<div className="flex flex-wrap justify-center gap-3 pt-2">
{actions.map(({ to, label, className }) => (
<Link key={to} to={to} className={`btn btn-sm ${className}`}>
{label}
</Link>
))}
</div>
)}
</section>
);
};
export default FeatureNotAvailable;

View File

@@ -1,3 +1,4 @@
import FeatureNotAvailable from '../components/FeatureNotAvailable';
import type { Route } from './+types/beer-styles';
export const meta = ({}: Route.MetaArgs) => {
@@ -7,9 +8,12 @@ export const meta = ({}: Route.MetaArgs) => {
const BeerStyles = () => {
return (
<div className="min-h-screen bg-base-200">
<div className="container mx-auto p-4">
<h1 className="text-4xl font-bold mb-4">Beer Styles</h1>
<div className="mx-auto max-w-7xl px-5 pt-10">
<h1 className="font-serif text-5xl font-bold leading-tight mb-4">Beer Styles</h1>
<p className="text-base-content/70">Learn about different beer styles.</p>
<div className="mt-8">
<FeatureNotAvailable />
</div>
</div>
</div>
);

View File

@@ -1,3 +1,4 @@
import FeatureNotAvailable from '../components/FeatureNotAvailable';
import type { Route } from './+types/beers';
export const meta = ({}: Route.MetaArgs) => {
@@ -7,9 +8,12 @@ export const meta = ({}: Route.MetaArgs) => {
const Beers = () => {
return (
<div className="min-h-screen bg-base-200">
<div className="container mx-auto p-4">
<h1 className="text-4xl font-bold mb-4">Beers</h1>
<div className="mx-auto max-w-7xl px-5 pt-10">
<h1 className="font-serif text-5xl font-bold leading-tight mb-4">Beers</h1>
<p className="text-base-content/70">Explore our collection of beers.</p>
<div className="mt-8">
<FeatureNotAvailable />
</div>
</div>
</div>
);

View File

@@ -0,0 +1,61 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, userEvent, waitFor, within } from 'storybook/test';
import AiDisclosureDialog from './AiDisclosureDialog';
const aiDisclosureDescription = `Tells visitors that the catalogue is AI-generated fixture data before they engage with it, as the pipeline ethics documentation requires. It opens on every full page load and closes for the rest of that load once acknowledged.`;
const meta = {
title: 'Disclosure/AiDisclosureDialog',
component: AiDisclosureDialog,
tags: ['autodocs'],
args: {
defaultOpen: true,
},
parameters: {
layout: 'fullscreen',
docs: {
description: {
component: aiDisclosureDescription,
},
},
},
} satisfies Meta<typeof AiDisclosureDialog>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Open: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await waitFor(async () => {
await expect(
canvas.getByRole('heading', { name: /this content is ai-generated/i }),
).toBeVisible();
});
await expect(
canvas.getByRole('link', { name: /how this data was generated/i }),
).toBeVisible();
},
};
export const Acknowledged: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole('button', { name: /i understand/i }));
await expect(
canvas.queryByRole('heading', { name: /this content is ai-generated/i }),
).toBeNull();
},
};
export const InitiallyDismissed: Story = {
args: {
defaultOpen: false,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(
canvas.queryByRole('heading', { name: /this content is ai-generated/i }),
).toBeNull();
},
};

View File

@@ -0,0 +1,79 @@
import { Sparks } from 'iconoir-react';
import { useEffect, useState } from 'react';
const ethicsDocUrl =
'https://github.com/aaronpo97/the-biergarten-app/blob/main/docs/pipeline/ETHICS-AND-KNOWN-ISSUES.md';
const AiDisclosureDialog = ({ defaultOpen = true }: { defaultOpen?: boolean }) => {
const [open, setOpen] = useState(defaultOpen);
useEffect(() => {
if (!open) {
return;
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setOpen(false);
}
};
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, [open]);
if (!open) {
return null;
}
return (
<div
className="modal modal-open"
role="dialog"
aria-modal="true"
aria-labelledby="ai-disclosure-title"
>
<div className="modal-box max-w-md">
<div className="flex flex-col items-center gap-3 text-center">
<span className="text-primary">
<Sparks className="size-10" aria-hidden="true" />
</span>
<h2 id="ai-disclosure-title" className="text-lg font-bold">
This content is AI-generated
</h2>
<p className="text-sm text-base-content/70">
The Biergarten App is a proof of concept. Every brewery, beer, and user in
this catalogue is fixture data produced by a language model — none of it
describes a real business, person, or beer.
</p>
<div role="alert" className="alert alert-warning alert-soft text-left">
<span className="text-sm">
Brewing details, local-language text, and coordinates are known to
contain fabrications. Treat nothing here as factual.
</span>
</div>
<a
href={ethicsDocUrl}
target="_blank"
rel="noreferrer"
className="link link-primary text-sm"
>
How this data was generated
</a>
</div>
<div className="modal-action">
<button
type="button"
className="btn btn-primary w-full"
autoFocus
onClick={() => setOpen(false)}
>
I understand
</button>
</div>
</div>
</div>
);
};
export default AiDisclosureDialog;

View File

@@ -3,7 +3,7 @@ import { Link } from 'react-router';
const ClosingCta = () => (
<section className="mx-auto flex max-w-3xl flex-col items-center gap-5 px-5 py-20 text-center">
<h2 className="m-0 font-serif text-[clamp(2rem,4vw,2.75rem)] leading-tight text-balance">
🍺 Your next favourite brewery is on the list.
Your next favourite brewery is on the list.
</h2>
<p className="m-0 text-lg leading-[1.55] text-[var(--color-muted)]">

View File

@@ -5,7 +5,7 @@ import { expect, within } from 'storybook/test';
import { biergartenThemes, defaultThemeName } from '../../theme/themes';
import ThemeTryOut from './ThemeTryOut';
const themeTryOutDescription = `Landing page theme band. Hosts the shared \`ThemeSegmentedControl\` with short labels, so a visitor can switch the whole document theme from the marketing page. The control seeds its selection from the root route's loader data, so this story runs behind a memory data router whose \`root\` route supplies a theme.`;
const themeTryOutDescription = `Landing page theme band. Frames the shared \`ThemeSegmentedControl\` in an elevated card with a "Set the mood" eyebrow and a pour count, and renders it with short labels so the four options fit the marketing page's narrower column. Each option carries its \`shortVibe\` mood line, and picking one switches the whole document theme. The control seeds its selection from the root route's loader data, so this story runs behind a memory data router whose \`root\` route supplies a theme. \`Themes/ThemeSegmentedControl\` covers the control itself.`;
const ThemeTryOutHarness = () => {
const [router] = useState(() =>
@@ -48,9 +48,15 @@ export const Default: Story = {
canvas.getByRole('heading', { name: 'Pick a pour that suits you' }),
).toBeInTheDocument();
await expect(canvas.getByText('Set the mood')).toBeVisible();
await expect(canvas.getByText(`${biergartenThemes.length} pours`)).toBeVisible();
const group = canvas.getByRole('radiogroup', { name: 'Theme selector' });
await expect(within(group).getAllByRole('radio')).toHaveLength(biergartenThemes.length);
for (const theme of biergartenThemes) {
await expect(within(group).getByText(theme.shortLabel)).toBeInTheDocument();
await expect(within(group).getByText(theme.shortLabel)).toBeVisible();
await expect(within(group).getByText(theme.shortVibe)).toBeVisible();
}
},
};

View File

@@ -13,11 +13,17 @@ const ThemeTryOut = () => (
</p>
</div>
<div className="rounded-box bg-base-100 p-3 text-base-content">
<ThemeSegmentedControl
labels="short"
className="join join-vertical w-full sm:join-horizontal"
/>
<div className="rounded-box border border-neutral-content/15 bg-base-100 p-4 text-base-content shadow-xl sm:p-5">
<div className="mb-4 flex items-center justify-between gap-4">
<div>
<p className="m-0 text-xs font-bold uppercase tracking-[0.18em] opacity-60">
Set the mood
</p>
<p className="m-0 mt-1 text-sm opacity-75">Your theme follows your pour.</p>
</div>
<span className="badge badge-outline shrink-0">4 pours</span>
</div>
<ThemeSegmentedControl labels="short" />
</div>
</div>
</section>

View File

@@ -31,7 +31,7 @@ const TextTokenSample = ({
}) => {
return (
<div className="rounded-box p-3" style={{ backgroundColor: `var(${background})` }}>
<p className="text-xs font-semibold uppercase tracking-wide text-base-content/50">
<p className="text-xs font-semibold uppercase tracking-wide text-base-content/70">
{label}
</p>
<p className="mt-1 text-sm font-medium" style={{ color: `var(${text})` }}>
@@ -54,7 +54,7 @@ const ThemePanel = ({ label, value, vibe }: { label: string; value: string; vibe
</div>
<div>
<p className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-base-content/50">
<p className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-base-content/70">
Core
</p>
<div className="grid gap-2 sm:grid-cols-2">
@@ -69,7 +69,7 @@ const ThemePanel = ({ label, value, vibe }: { label: string; value: string; vibe
</div>
<div>
<p className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-base-content/50">
<p className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-base-content/70">
Status
</p>
<div className="grid gap-2 grid-cols-2 sm:grid-cols-4">
@@ -81,7 +81,7 @@ const ThemePanel = ({ label, value, vibe }: { label: string; value: string; vibe
</div>
<div>
<p className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-base-content/50">
<p className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-base-content/70">
Content
</p>
<div className="grid gap-2 sm:grid-cols-3">

View File

@@ -0,0 +1,167 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { useMemo } from 'react';
import { createMemoryRouter, RouterProvider } from 'react-router';
import { expect, fn, userEvent, within } from 'storybook/test';
import { biergartenThemes, defaultThemeName, themeCookieName, type ThemeName } from '../themes';
import ThemeSegmentedControl from './ThemeSegmentedControl';
const themeSegmentedControlDescription = `The shared theme picker, used by the theme guide and by the landing page's try-out band. Each option pairs a gradient swatch of the theme's palette with its name and \`shortVibe\` mood line, and selection is carried by both color and elevation. Picking an option sets \`data-theme\` on \`<html>\` and writes the \`${themeCookieName}\` cookie, so the choice survives the next request and the server renders the same theme. The initial selection comes from the root route's loader, so these stories run behind a memory data router whose \`root\` route supplies the theme the cookie would have provided.`;
interface HarnessProps {
labels?: 'full' | 'short';
className?: string;
onChange?: (theme: ThemeName) => void;
/** Theme the root loader hands the control, standing in for the theme cookie. */
seedTheme?: ThemeName;
}
const ThemeSegmentedControlHarness = ({
labels,
className,
onChange,
seedTheme = defaultThemeName,
}: HarnessProps) => {
// Rebuilt per arg change so the docs controls remount the control rather
// than leaving it seeded from the previous render.
const router = useMemo(
() =>
createMemoryRouter([
{
id: 'root',
path: '/',
loader: () => ({ theme: seedTheme }),
element: (
<div className="max-w-md rounded-box bg-base-100 p-4 text-base-content">
<ThemeSegmentedControl
labels={labels}
className={className}
onChange={onChange}
/>
</div>
),
},
]),
[labels, className, onChange, seedTheme],
);
return <RouterProvider router={router} />;
};
const meta = {
title: 'Themes/ThemeSegmentedControl',
component: ThemeSegmentedControlHarness,
tags: ['autodocs'],
args: {
labels: 'full',
seedTheme: defaultThemeName,
onChange: fn(),
},
argTypes: {
labels: { control: 'inline-radio', options: ['full', 'short'] },
seedTheme: {
control: 'select',
options: biergartenThemes.map((theme) => theme.value),
},
},
parameters: {
usesDataRouter: true,
docs: {
description: {
component: themeSegmentedControlDescription,
},
},
},
} satisfies Meta<typeof ThemeSegmentedControlHarness>;
export default meta;
type Story = StoryObj<typeof meta>;
const getOptions = (canvasElement: HTMLElement) => {
const group = within(canvasElement).getByRole('radiogroup', { name: 'Theme selector' });
return { group, radios: within(group).getAllByRole('radio') as HTMLInputElement[] };
};
/** Full labels, as the theme guide renders it. */
export const Default: Story = {
play: async ({ canvasElement }) => {
const { group, radios } = getOptions(canvasElement);
await expect(radios).toHaveLength(biergartenThemes.length);
for (const theme of biergartenThemes) {
await expect(within(group).getByText(theme.label)).toBeVisible();
await expect(within(group).getByText(theme.shortVibe)).toBeVisible();
}
const [lager] = radios;
await expect(lager.value).toBe(defaultThemeName);
await expect(lager.checked).toBe(true);
},
};
/** Short labels, as the landing page renders it in the narrower try-out card. */
export const ShortLabels: Story = {
args: {
labels: 'short',
},
play: async ({ canvasElement }) => {
const { group } = getOptions(canvasElement);
for (const theme of biergartenThemes) {
await expect(within(group).getByText(theme.shortLabel)).toBeVisible();
await expect(within(group).getByText(theme.shortVibe)).toBeVisible();
}
await expect(within(group).queryByText(biergartenThemes[0].label)).toBeNull();
},
};
/** A returning visitor whose cookie already names a theme other than the default. */
export const SeededFromCookie: Story = {
args: {
seedTheme: 'biergarten-cassis',
},
play: async ({ canvasElement }) => {
const { radios } = getOptions(canvasElement);
for (const radio of radios) {
await expect(radio.checked).toBe(radio.value === 'biergarten-cassis');
}
},
};
export const SelectingATheme: Story = {
play: async ({ canvasElement, args }) => {
const previousTheme = document.documentElement.getAttribute('data-theme');
const { group, radios } = getOptions(canvasElement);
await userEvent.click(within(group).getByText('Biergarten Stout'));
for (const radio of radios) {
await expect(radio.checked).toBe(radio.value === 'biergarten-stout');
}
await expect(args.onChange).toHaveBeenCalledWith('biergarten-stout');
await expect(document.documentElement.getAttribute('data-theme')).toBe('biergarten-stout');
await expect(document.cookie).toContain(`${themeCookieName}=biergarten-stout`);
// The control themes the whole document, so hand <html> back to the
// toolbar's theme before the next story mounts.
if (previousTheme === null) {
document.documentElement.removeAttribute('data-theme');
} else {
document.documentElement.setAttribute('data-theme', previousTheme);
}
},
};
/** Single column, for a sidebar or any container too narrow for the paired grid. */
export const SingleColumn: Story = {
args: {
className: 'grid grid-cols-1 gap-2',
},
play: async ({ canvasElement }) => {
const { radios } = getOptions(canvasElement);
await expect(radios).toHaveLength(biergartenThemes.length);
},
};

View File

@@ -19,7 +19,7 @@ interface ThemeSegmentedControlProps {
const ThemeSegmentedControl = ({
labels = 'full',
className = 'join join-vertical sm:join-horizontal',
className = 'grid grid-cols-2 gap-2',
onChange,
}: ThemeSegmentedControlProps) => {
const rootTheme = useRouteLoaderData<typeof rootLoader>('root')?.theme ?? defaultThemeName;
@@ -33,7 +33,11 @@ const ThemeSegmentedControl = ({
return (
<label
key={theme.value}
className={`btn join-item ${checked ? 'btn-primary' : 'btn-outline'}`}
className={`group flex min-h-20 cursor-pointer items-center gap-3 rounded-box border p-3 text-left transition-all duration-200 ${
checked
? 'border-primary bg-primary text-primary-content shadow-md'
: 'border-base-content/15 bg-base-200/45 hover:-translate-y-0.5 hover:border-primary/50 hover:bg-base-200'
}`}
>
<input
type="radio"
@@ -47,7 +51,34 @@ const ThemeSegmentedControl = ({
onChange?.(theme.value);
}}
/>
{labels === 'short' ? theme.shortLabel : theme.label}
<span
aria-hidden="true"
className={`size-9 shrink-0 rounded-full border-4 shadow-inner transition-transform duration-200 group-hover:scale-105 ${
checked ? 'border-primary-content/70' : 'border-base-100'
}`}
style={{
background:
theme.value === 'biergarten-lager'
? 'linear-gradient(135deg, #f5c451 0%, #a96728 100%)'
: theme.value === 'biergarten-stout'
? 'linear-gradient(135deg, #d69b45 0%, #382116 100%)'
: theme.value === 'biergarten-cassis'
? 'linear-gradient(135deg, #c777b9 0%, #4c214e 100%)'
: 'linear-gradient(135deg, #f7e6a5 0%, #82a85d 100%)',
}}
/>
<span className="min-w-0">
<span className="block truncate text-sm font-bold">
{labels === 'short' ? theme.shortLabel : theme.label}
</span>
<span
className={`mt-0.5 block truncate text-xs ${
checked ? 'text-primary-content/75' : 'text-base-content/60'
}`}
>
{theme.shortVibe}
</span>
</span>
</label>
);
})}

View File

@@ -6,6 +6,8 @@ export interface ThemeOption {
label: string;
/** Used where the segmented control is narrow, such as the landing page. */
shortLabel: string;
/** A compact mood descriptor for theme pickers. */
shortVibe: string;
vibe: string;
}
@@ -17,24 +19,28 @@ export const biergartenThemes: ThemeOption[] = [
value: 'biergarten-lager',
label: 'Biergarten Lager',
shortLabel: 'Lager',
shortVibe: 'Muted parchment',
vibe: 'Muted parchment, mellow amber, daytime beer garden',
},
{
value: 'biergarten-stout',
label: 'Biergarten Stout',
shortLabel: 'Stout',
shortVibe: 'Charred barrel',
vibe: 'Charred barrel, deep roast, cozy evening cellar',
},
{
value: 'biergarten-cassis',
label: 'Biergarten Cassis',
shortLabel: 'Cassis',
shortVibe: 'Blackberry barrel',
vibe: 'Blackberry barrel, sour berry dark, vivid night market',
},
{
value: 'biergarten-weizen',
label: 'Biergarten Weizen',
shortLabel: 'Weizen',
shortVibe: 'Bright spring',
vibe: 'Ultra-light young barley, green undertone, bright spring afternoon',
},
];

View File

@@ -1,7 +1,7 @@
const EmptyState = ({ message }: { message: string }) => (
<div className="card bg-base-100 shadow">
<div className="card-body items-center py-12">
<p className="text-sm text-base-content/60 text-center m-0">{message}</p>
<p className="text-sm text-base-content/70 text-center m-0">{message}</p>
</div>
</div>
);

View File

@@ -33,7 +33,7 @@ const ProfileSidebarCard = ({
<div className="card-body gap-4 p-6">
<div>
<h1 className="text-2xl font-bold m-0 leading-tight">{displayName}</h1>
<p className="text-sm text-base-content/60 m-0 mt-0.5">@{username}</p>
<p className="text-sm text-base-content/70 m-0 mt-0.5">@{username}</p>
</div>
{isOwnProfile ? (
@@ -52,7 +52,7 @@ const ProfileSidebarCard = ({
<p className="text-sm leading-normal m-0">{bio}</p>
<div className="flex flex-col gap-1.5 text-sm text-base-content/60">
<div className="flex flex-col gap-1.5 text-sm text-base-content/70">
<div className="flex items-center gap-1.5">
<MapPin className="size-4 shrink-0" aria-hidden="true" />
{location}
@@ -68,7 +68,7 @@ const ProfileSidebarCard = ({
<div className="flex flex-col gap-2">
{stats.map((stat) => (
<div key={stat.label} className="flex items-center justify-between gap-2">
<span className="text-sm text-base-content/60">{stat.label}</span>
<span className="text-sm text-base-content/70">{stat.label}</span>
<span className="font-serif text-lg font-bold tabular-nums">
{stat.value}
</span>

View File

@@ -24,7 +24,7 @@ const ProfileTabs = ({ activeTab, onChange, panels }: ProfileTabsProps) => {
{TABS.map((tab) => (
<Tab
key={tab.id}
className="-mb-px border-b-2 border-transparent py-3 text-sm font-semibold text-base-content/60 transition-colors hover:text-base-content focus:outline-none data-[focus]:outline-2 data-[focus]:outline-offset-2 data-[focus]:outline-primary data-[selected]:border-primary data-[selected]:text-primary"
className="-mb-px border-b-2 border-transparent py-3 text-sm font-semibold text-base-content/70 transition-colors hover:text-base-content focus:outline-none data-[focus]:outline-2 data-[focus]:outline-offset-2 data-[focus]:outline-primary data-[selected]:border-primary data-[selected]:text-primary"
>
{tab.label}
</Tab>

View File

@@ -36,11 +36,11 @@ const ActivityTab = ({ activity }: ActivityTabProps) => {
</div>
)}
{item.comment && (
<p className="text-sm text-base-content/60 italic mt-1.5 mb-0">
<p className="text-sm text-base-content/70 italic mt-1.5 mb-0">
&ldquo;{item.comment}&rdquo;
</p>
)}
<p className="text-xs text-base-content/60 mt-1.5 mb-0">
<p className="text-xs text-base-content/70 mt-1.5 mb-0">
{item.time}
</p>
</div>

View File

@@ -42,7 +42,7 @@ const FollowingTab = ({ following, onToggleFollow }: FollowingTabProps) => {
) : (
<p className="font-semibold text-sm m-0 truncate">{f.name}</p>
)}
<p className="text-xs text-base-content/60 m-0 truncate">{f.meta}</p>
<p className="text-xs text-base-content/70 m-0 truncate">{f.meta}</p>
</div>
<button
type="button"

View File

@@ -18,7 +18,7 @@ const LikedTab = ({ liked }: LikedTabProps) => {
<div className="h-[120px] bg-base-300" />
<div className="card-body p-4 gap-1">
<p className="font-serif font-bold m-0">{item.name}</p>
<p className="text-sm text-base-content/60 m-0">{item.meta}</p>
<p className="text-sm text-base-content/70 m-0">{item.meta}</p>
<div className="mt-1">
<StarRating value={item.rating} size="xs" />
</div>

View File

@@ -75,7 +75,7 @@ const ProfileContent = ({ profile, isOwnProfile }: ProfileContentProps) => {
return (
<div className="min-h-screen bg-base-200 pb-16">
<div className="max-w-[1080px] mx-auto px-6 pt-8">
<div className="mx-auto max-w-7xl px-5 pt-10">
<div className="rounded-box h-[190px] bg-base-300" />
<div className="grid grid-cols-1 lg:grid-cols-[300px_1fr] gap-8 items-start -mt-12">

View File

@@ -13,9 +13,15 @@ import './app.css';
import Navbar from './components/ui/navbar/Navbar';
import ToastProvider from './components/ui/toast/ToastProvider';
import { getOptionalAuth } from './features/auth/auth.server';
import AiDisclosureDialog from './features/home/components/AiDisclosureDialog';
import { defaultThemeName, parseThemeCookie } from './features/theme/themes';
export const links: Route.LinksFunction = () => [
{ rel: 'icon', href: '/favicon.ico', sizes: 'any' },
{ rel: 'icon', type: 'image/png', sizes: '32x32', href: '/favicon-32x32.png' },
{ rel: 'icon', type: 'image/png', sizes: '16x16', href: '/favicon-16x16.png' },
{ rel: 'apple-touch-icon', sizes: '180x180', href: '/apple-touch-icon.png' },
{ rel: 'manifest', href: '/site.webmanifest' },
{ rel: 'preconnect', href: 'https://fonts.googleapis.com' },
{
rel: 'preconnect',
@@ -60,6 +66,7 @@ const App = ({ loaderData }: Route.ComponentProps) => {
<>
<Navbar auth={auth} />
<ToastProvider />
<AiDisclosureDialog />
<Outlet />
</>
);
@@ -84,7 +91,7 @@ export const ErrorBoundary = ({ error }: Route.ErrorBoundaryProps) => {
}
return (
<main className="pt-16 p-4 container mx-auto">
<main className="mx-auto max-w-7xl px-5 pt-10">
<h1>{message}</h1>
<p>{details}</p>
{stack && (

View File

@@ -3948,9 +3948,9 @@
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
"version": "5.0.9",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"version": "5.0.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
"integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -5103,9 +5103,9 @@
}
},
"node_modules/brace-expansion": {
"version": "1.1.18",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
"version": "1.1.21",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz",
"integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -6775,9 +6775,9 @@
}
},
"node_modules/glob/node_modules/brace-expansion": {
"version": "5.0.9",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"version": "5.0.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
"integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -7655,9 +7655,9 @@
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
"integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
"integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==",
"dev": true,
"funding": [
{
@@ -8972,9 +8972,9 @@
}
},
"node_modules/qs": {
"version": "6.15.3",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
"integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
"version": "6.16.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
"integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",
"license": "BSD-3-Clause",
"dependencies": {
"es-define-property": "^1.0.1",

View File

@@ -6,6 +6,12 @@ const app = express();
app.use('/assets', express.static('build/client/assets', { immutable: true, maxAge: '1y' }));
app.use(express.static('build/client', { maxAge: '1h' }));
// Answers from Express alone so the container health check reports on this
// process only, independent of whether the API is reachable yet.
app.get('/healthz', (_req, res) => {
res.status(200).type('text/plain').send('ok');
});
const requestHandler = createRequestHandler({
build: () => import('./build/server/index.js'),
});