Compare commits

...

8 Commits

Author SHA1 Message Date
Aaron Po
1cda5779a4 add restart tolerance 2026-10-04 20:29:37 -04:00
Aaron Po
0d74c5f47d Add theme picker stories and cover the try-out card chrome 2026-10-04 20:23:16 -04:00
Aaron Po
3e1ed5643e Refactor FeatureNotAvailable component interfaces and improve code formatting 2026-10-04 20:12:46 -04:00
Aaron Po
bd01f47df5 Enhance theme selection UI with mood descriptors and improved styling 2026-10-04 20:02:55 -04:00
Aaron Po
cc52ad3d5c Remove beer emoji from the closing call-to-action heading 2026-10-04 19:56:01 -04:00
Aaron Po
632a3df3dc update docs 2026-10-04 19:51:14 -04:00
Aaron Po
2291fd12a9 docker updates 2026-10-04 19:50:50 -04:00
Aaron Po
c187c475ab update fonts, add favicon 2026-10-04 19:37:50 -04:00
25 changed files with 433 additions and 71 deletions

View File

@@ -566,10 +566,11 @@ are not seeded either.
**Environments**: **Environments**:
| Environment | Compose file | | Environment | Compose file |
| ----------- | -------------------------- | | --------------- | ------------------------------- |
| Development | `docker-compose.dev.yaml` | | Development | `docker-compose.dev.yaml` |
| Testing | `docker-compose.test.yaml` | | Testing | `docker-compose.test.yaml` |
| Production | `docker-compose.prod.yaml` | | Production | `docker-compose.prod.yaml` |
| Production seed | `docker-compose.prod.seed.yaml` |
For details, see [Docker Guide](docker.md). 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). | | **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. | | **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: `biergartendata-prod` and `seaweedfsdata-prod` (both external, so `down -v` keeps them), plus `nuget-cache-prod`. Startup order matches dev: SQL Server (health check) → migrations → seed → API → frontend, with `frontend` the only service publishing a host port. | | **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), plus `nuget-cache-prod`. Startup order: SQL Server (health check) → migrations → API → frontend, with `frontend` the only service publishing a host port. Seeding runs on demand from `docker-compose.prod.seed.yaml`, as its own Compose project joining `biergarten-prodnet`. |
| **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. | | **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. | | **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. | | `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

@@ -5,7 +5,8 @@ sequenceDiagram
participant ProdAPI as API Container<br/>ASP.NET Core 10<br/>:8080 HTTP / :8081 HTTPS participant ProdAPI as API Container<br/>ASP.NET Core 10<br/>:8080 HTTP / :8081 HTTPS
end end
ProdMig-->>ProdDB: depends_on ProdMig-->>ProdDB: depends_on: sqlserver healthy
ProdMig->>ProdDB: Run migrations ProdMig->>ProdDB: Run migrations
ProdAPI-->>ProdDB: depends_on: sql-server healthy (not migrations/seed) ProdAPI-->>ProdMig: depends_on: migrations completed
ProdAPI->>ProdDB: Connect and serve ProdAPI->>ProdDB: Connect and serve
Note over ProdDB: Seeding is a separate run-once job<br/>(docker-compose.prod.seed.yaml)

View File

@@ -134,21 +134,23 @@ containers, which would end the run prematurely. See
- Production logging levels - Production logging levels
- No database clearing - No database clearing
- Optimized build configurations - Optimized build configurations
- Health checks enabled - Health checks on every long-running service
- Restart policies (unless-stopped) - Restart policies that survive a reboot (see
[Restart tolerance](#restart-tolerance))
- Security hardening - Security hardening
- Only `frontend` publishes a port to the host, bound to `127.0.0.1` so a reverse - 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 proxy can sit in front of it; `sqlserver`, `api.core` and `seaweedfs` are
reachable only from other containers on `prodnet` reachable only from other containers on `prodnet` (named `biergarten-prodnet`
- Seeded catalogue, with its AI-generated origin disclosed in the app. See so the seed job can join it from its own Compose project)
[Production data](#production-data) below. - 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**: **Services**:
```yaml ```yaml
sqlserver # Production SQL Server (internal only) sqlserver # Production SQL Server (internal only)
database.migrations # Schema updates only database.migrations # Schema updates only
database.seed # Loads the catalogue, runs once
api.core # Production API (internal only, no published ports) api.core # Production API (internal only, no published ports)
seaweedfs # S3-compatible object storage (internal only) seaweedfs # S3-compatible object storage (internal only)
frontend # Production website (127.0.0.1:5656, the only host binding) frontend # Production website (127.0.0.1:5656, the only host binding)
@@ -169,10 +171,62 @@ 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 SELinux, so the host needs no `chown` and no `:z`/`:Z` mount options on
enforcing distributions such as RHEL, AlmaLinux and Fedora. enforcing distributions such as RHEL, AlmaLinux and Fedora.
#### 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`). `database.migrations` keeps `restart: "no"`: it
is a one-shot job that runs once per deploy and stays exited across a reboot, so
the schema is migrated on deploy rather than on every boot.
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 and `sqlserver` is healthy:
```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 — 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 #### Production data
Production runs `database.seed`, so the catalogue it serves is the AI-generated A production deployment seeded this way serves the AI-generated fixture data
fixture data produced by the pipeline. Per produced by the pipeline. Per
[Pipeline ethics, bias, and known issues](../pipeline/ETHICS-AND-KNOWN-ISSUES.md), [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 anyone interacting with an application seeded from it has to be told upfront that
the content is AI-generated. the content is AI-generated.
@@ -184,8 +238,9 @@ on. It is part of the server-rendered HTML rather than mounted after hydration,
the disclosure is on screen with the first paint. Acknowledging it hides the 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. dialog for the remainder of that page load; a new load shows it again.
Keep `database.seed` and the dialog together. A production deployment that seeds Keep the seed job and the dialog together: any deployment that runs
the catalogue without the disclosure does not satisfy the ethics documentation. `docker-compose.prod.seed.yaml` needs the disclosure in place to satisfy the
ethics documentation.
### 4. Database only (`docker-compose.db.yaml`) ### 4. Database only (`docker-compose.db.yaml`)
@@ -247,6 +302,12 @@ graph TD
In development, `api.core` also depends on `seaweedfs` In development, `api.core` also depends on `seaweedfs`
(`condition: service_started`, not a health check) alongside `database.seed`. (`condition: service_started`, not a health check) alongside `database.seed`.
In production the chain is gated on health instead: `api.core` waits for
`seaweedfs` to pass its health check and for `database.migrations` to exit
successfully, 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): **Health Check Example** (SQL Server):
```yaml ```yaml

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.dev.yaml` → `web/.env.dev`
- `web/docker-compose.test.yaml` → `web/.env.test` - `web/docker-compose.test.yaml` → `web/.env.test`
- `web/docker-compose.prod.yaml` → `web/.env.prod` - `web/docker-compose.prod.yaml` → `web/.env.prod`
- `web/docker-compose.prod.seed.yaml` → `web/.env.prod`
## Variable reference table ## Variable reference table

View File

@@ -0,0 +1,40 @@
# One-shot seed job for the production stack, kept out of
# docker-compose.prod.yaml so that bringing production up, or restarting it,
# never reloads the catalogue. Run it deliberately, once the prod stack is
# healthy:
#
# docker compose --env-file web/.env.prod -f web/docker-compose.prod.seed.yaml run --rm --build database.seed
#
# Its own project name keeps it from treating the prod stack's containers as
# orphans; it joins the prod network to reach sqlserver and seaweedfs.
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

@@ -20,6 +20,8 @@ services:
timeout: 5s timeout: 5s
retries: 12 retries: 12
start_period: 30s start_period: 30s
restart: unless-stopped
stop_grace_period: 60s
networks: networks:
- prodnet - prodnet
@@ -46,44 +48,15 @@ services:
networks: networks:
- prodnet - prodnet
database.seed:
env_file: ".env.prod"
image: database.seed
container_name: prod-env-database-seed
depends_on:
database.migrations:
condition: service_completed_successfully
seaweedfs:
condition: service_started
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
api.core: api.core:
env_file: ".env.prod" env_file: ".env.prod"
image: api.core image: api.core
container_name: prod-env-api-core container_name: prod-env-api-core
depends_on: depends_on:
database.seed: database.migrations:
condition: service_completed_successfully condition: service_completed_successfully
seaweedfs: seaweedfs:
condition: service_started condition: service_healthy
build: build:
context: ./backend context: ./backend
dockerfile: API/API.Core/Dockerfile dockerfile: API/API.Core/Dockerfile
@@ -108,6 +81,20 @@ services:
SEAWEEDFS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}" SEAWEEDFS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}"
SEAWEEDFS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}" SEAWEEDFS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}"
SEAWEEDFS_BUCKET: "${SEAWEEDFS_BUCKET}" SEAWEEDFS_BUCKET: "${SEAWEEDFS_BUCKET}"
healthcheck:
# The .NET runtime image carries bash but no HTTP client, so the probe speaks
# HTTP to /health over bash's /dev/tcp rather than adding curl to the image.
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 restart: unless-stopped
networks: networks:
- prodnet - prodnet
@@ -126,6 +113,14 @@ services:
S3_BUCKET: "${SEAWEEDFS_BUCKET}" S3_BUCKET: "${SEAWEEDFS_BUCKET}"
volumes: volumes:
- seaweedfsdata-prod:/data - seaweedfsdata-prod:/data
healthcheck:
# Probes the S3 gateway, the one component api.core talks to; it comes up
# last, after the master, volume and filer servers are serving.
test: ["CMD", "curl", "-fsS", "-o", "/dev/null", "http://localhost:8333/status"]
interval: 15s
timeout: 5s
retries: 5
start_period: 20s
restart: unless-stopped restart: unless-stopped
networks: networks:
- prodnet - prodnet
@@ -136,7 +131,7 @@ services:
container_name: prod-env-frontend container_name: prod-env-frontend
depends_on: depends_on:
api.core: api.core:
condition: service_started condition: service_healthy
build: build:
context: ./frontend context: ./frontend
dockerfile: Dockerfile dockerfile: Dockerfile
@@ -147,6 +142,14 @@ services:
PORT: "5656" PORT: "5656"
API_BASE_URL: "http://api.core:8080" API_BASE_URL: "http://api.core:8080"
SESSION_SECRET: "${SESSION_SECRET}" SESSION_SECRET: "${SESSION_SECRET}"
healthcheck:
# /healthz answers from the Express server alone, so the frontend stays
# healthy while api.core is still starting and serves its own error states.
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 restart: unless-stopped
networks: networks:
- prodnet - prodnet
@@ -164,4 +167,7 @@ volumes:
networks: networks:
prodnet: prodnet:
# Fixed name so docker-compose.prod.seed.yaml, which runs as its own Compose
# project, can attach to this network as an external one.
name: biergarten-prodnet
driver: bridge driver: bridge

View File

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

View File

@@ -4,14 +4,18 @@ import { Link } from 'react-router';
type Reason = 'soon' | 'maintenance' | 'auth'; type Reason = 'soon' | 'maintenance' | 'auth';
type Action = { to: string; label: string; className: string }; interface Action {
to: string;
label: string;
className: string;
}
type Content = { interface Content {
Icon: ComponentType<{ className?: string }>; Icon: ComponentType<{ className?: string }>;
title: string; title: string;
description: string; description: string;
actions: Action[]; actions: Action[];
}; }
const CONTENT: Record<Reason, Content> = { const CONTENT: Record<Reason, Content> = {
soon: { soon: {
@@ -32,7 +36,8 @@ const CONTENT: Record<Reason, Content> = {
auth: { auth: {
Icon: Lock, Icon: Lock,
title: 'Sign in to use this feature', title: 'Sign in to use this feature',
description: 'This feature requires a valid session. Log in or create an account to continue.', description:
'This feature requires a valid session. Log in or create an account to continue.',
actions: [ actions: [
{ to: '/login', label: 'Login', className: 'btn-primary' }, { to: '/login', label: 'Login', className: 'btn-primary' },
{ to: '/register', label: 'Register', className: 'btn-outline' }, { to: '/register', label: 'Register', className: 'btn-outline' },
@@ -40,9 +45,9 @@ const CONTENT: Record<Reason, Content> = {
}, },
}; };
type FeatureNotAvailableProps = { interface FeatureNotAvailableProps {
reason?: Reason; reason?: Reason;
}; }
const FeatureNotAvailable = ({ reason = 'soon' }: FeatureNotAvailableProps) => { const FeatureNotAvailable = ({ reason = 'soon' }: FeatureNotAvailableProps) => {
const { Icon, title, description, actions } = CONTENT[reason]; const { Icon, title, description, actions } = CONTENT[reason];

View File

@@ -3,7 +3,7 @@ import { Link } from 'react-router';
const ClosingCta = () => ( const ClosingCta = () => (
<section className="mx-auto flex max-w-3xl flex-col items-center gap-5 px-5 py-20 text-center"> <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"> <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> </h2>
<p className="m-0 text-lg leading-[1.55] text-[var(--color-muted)]"> <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 { biergartenThemes, defaultThemeName } from '../../theme/themes';
import ThemeTryOut from './ThemeTryOut'; 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 ThemeTryOutHarness = () => {
const [router] = useState(() => const [router] = useState(() =>
@@ -48,9 +48,15 @@ export const Default: Story = {
canvas.getByRole('heading', { name: 'Pick a pour that suits you' }), canvas.getByRole('heading', { name: 'Pick a pour that suits you' }),
).toBeInTheDocument(); ).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' }); const group = canvas.getByRole('radiogroup', { name: 'Theme selector' });
await expect(within(group).getAllByRole('radio')).toHaveLength(biergartenThemes.length);
for (const theme of biergartenThemes) { 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> </p>
</div> </div>
<div className="rounded-box bg-base-100 p-3 text-base-content"> <div className="rounded-box border border-neutral-content/15 bg-base-100 p-4 text-base-content shadow-xl sm:p-5">
<ThemeSegmentedControl <div className="mb-4 flex items-center justify-between gap-4">
labels="short" <div>
className="join join-vertical w-full sm:join-horizontal" <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>
</div> </div>
</section> </section>

View File

@@ -21,9 +21,10 @@ export const meta = ({}: Route.MetaArgs) => [
export const loader = async ({ request }: Route.LoaderArgs) => { export const loader = async ({ request }: Route.LoaderArgs) => {
const auth = await getOptionalAuth(request); const auth = await getOptionalAuth(request);
let recentBreweries: Brewery[] = [];
const randomOffset = Math.random() * 1.0 // The rest of the landing page stands on its own, so a brewery service
// outage degrades to the empty state instead of an error page.
let recentBreweries: Brewery[] = [];
try { try {
recentBreweries = await getBreweries(PARTNER_BREWERY_COUNT, 0); recentBreweries = await getBreweries(PARTNER_BREWERY_COUNT, 0);
} catch { } catch {

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 = ({ const ThemeSegmentedControl = ({
labels = 'full', labels = 'full',
className = 'join join-vertical sm:join-horizontal', className = 'grid grid-cols-2 gap-2',
onChange, onChange,
}: ThemeSegmentedControlProps) => { }: ThemeSegmentedControlProps) => {
const rootTheme = useRouteLoaderData<typeof rootLoader>('root')?.theme ?? defaultThemeName; const rootTheme = useRouteLoaderData<typeof rootLoader>('root')?.theme ?? defaultThemeName;
@@ -33,7 +33,11 @@ const ThemeSegmentedControl = ({
return ( return (
<label <label
key={theme.value} 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 <input
type="radio" type="radio"
@@ -47,7 +51,34 @@ const ThemeSegmentedControl = ({
onChange?.(theme.value); onChange?.(theme.value);
}} }}
/> />
<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} {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> </label>
); );
})} })}

View File

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

View File

@@ -17,6 +17,11 @@ import AiDisclosureDialog from './features/home/components/AiDisclosureDialog';
import { defaultThemeName, parseThemeCookie } from './features/theme/themes'; import { defaultThemeName, parseThemeCookie } from './features/theme/themes';
export const links: Route.LinksFunction = () => [ 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', href: 'https://fonts.googleapis.com' },
{ {
rel: 'preconnect', rel: 'preconnect',

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 708 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1,19 @@
{
"name": "The Biergarten App",
"short_name": "Biergarten",
"icons": [
{
"src": "/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}

View File

@@ -6,6 +6,12 @@ const app = express();
app.use('/assets', express.static('build/client/assets', { immutable: true, maxAge: '1y' })); app.use('/assets', express.static('build/client/assets', { immutable: true, maxAge: '1y' }));
app.use(express.static('build/client', { maxAge: '1h' })); 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({ const requestHandler = createRequestHandler({
build: () => import('./build/server/index.js'), build: () => import('./build/server/index.js'),
}); });