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**:
| 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: `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. |
| **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

@@ -5,7 +5,8 @@ sequenceDiagram
participant ProdAPI as API Container<br/>ASP.NET Core 10<br/>:8080 HTTP / :8081 HTTPS
end
ProdMig-->>ProdDB: depends_on
ProdMig-->>ProdDB: depends_on: sqlserver healthy
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
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
- 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, 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`
- Seeded catalogue, with its AI-generated origin disclosed in the app. See
[Production data](#production-data) below.
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
database.seed # Loads the catalogue, runs once
api.core # Production API (internal only, no published ports)
seaweedfs # S3-compatible object storage (internal only)
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
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 runs `database.seed`, so the catalogue it serves is the AI-generated
fixture data produced by the pipeline. Per
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.
@@ -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
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
the catalogue without the disclosure does not satisfy the ethics documentation.
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`)
@@ -247,6 +302,12 @@ graph TD
In development, `api.core` also depends on `seaweedfs`
(`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):
```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.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,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
retries: 12
start_period: 30s
restart: unless-stopped
stop_grace_period: 60s
networks:
- prodnet
@@ -46,44 +48,15 @@ services:
networks:
- 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:
env_file: ".env.prod"
image: api.core
container_name: prod-env-api-core
depends_on:
database.seed:
database.migrations:
condition: service_completed_successfully
seaweedfs:
condition: service_started
condition: service_healthy
build:
context: ./backend
dockerfile: API/API.Core/Dockerfile
@@ -108,6 +81,20 @@ services:
SEAWEEDFS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}"
SEAWEEDFS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}"
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
networks:
- prodnet
@@ -126,6 +113,14 @@ services:
S3_BUCKET: "${SEAWEEDFS_BUCKET}"
volumes:
- 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
networks:
- prodnet
@@ -136,7 +131,7 @@ services:
container_name: prod-env-frontend
depends_on:
api.core:
condition: service_started
condition: service_healthy
build:
context: ./frontend
dockerfile: Dockerfile
@@ -147,6 +142,14 @@ services:
PORT: "5656"
API_BASE_URL: "http://api.core:8080"
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
networks:
- prodnet
@@ -164,4 +167,7 @@ volumes:
networks:
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

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

@@ -4,14 +4,18 @@ import { Link } from 'react-router';
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 }>;
title: string;
description: string;
actions: Action[];
};
}
const CONTENT: Record<Reason, Content> = {
soon: {
@@ -32,7 +36,8 @@ const CONTENT: Record<Reason, Content> = {
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.',
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' },
@@ -40,9 +45,9 @@ const CONTENT: Record<Reason, Content> = {
},
};
type FeatureNotAvailableProps = {
interface FeatureNotAvailableProps {
reason?: Reason;
};
}
const FeatureNotAvailable = ({ reason = 'soon' }: FeatureNotAvailableProps) => {
const { Icon, title, description, actions } = CONTENT[reason];

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

@@ -21,9 +21,10 @@ export const meta = ({}: Route.MetaArgs) => [
export const loader = async ({ request }: Route.LoaderArgs) => {
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 {
recentBreweries = await getBreweries(PARTNER_BREWERY_COUNT, 0);
} 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 = ({
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);
}}
/>
<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

@@ -17,6 +17,11 @@ 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',

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(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'),
});