Compare commits

...
Author SHA1 Message Date
dev_agentandClaude Opus 5.5 9a5d0a7912 Match service worker routes on the decoded path
CI / ci (push) Successful in 1m34s
CI / ci (pull_request) Successful in 1m33s
Independent review Codex gpt-6-sol high: APPROVED (0 findings)
Offline navigations to /%61uth/login or /%61pi/v1/settings/household
missed the reserved prefixes and were answered with the precached
index.html. routeFor now percent-decodes the pathname and checks reserved
prefixes before and after dot segments are resolved, as the server does;
a malformed encoding is network-only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 16:30:27 -04:00
dev_agentandClaude Opus 5.5 3e95514053 Route font fetches through the expiring font cache
CI / ci (push) Successful in 1m34s
CI / ci (pull_request) Successful in 1m34s
Register the font route ahead of precacheAndRoute so a font fetch uses
CacheFirst with its ExpirationPlugin instead of being answered by the
precache route. Fonts stay in the precache manifest; when the network
fails and the runtime cache has no copy, the font comes from the
precache. Source tests pin the expiration plugin, the precache fallback
and the route order.

Read the injected precache list in the production-build test as JSON
and assert the index.html entry's revision there, so it no longer
depends on matching the minified sw.js with a regex.

Maestro task: 8fa0d0bd-4a52-4f09-ba68-41bdbdfda63b

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 16:25:08 -04:00
dev_agentandClaude Opus 5.5 fe124f0d52 Service worker caching strategy
CI / ci (pull_request) Successful in 1m34s
CI / ci (push) Successful in 1m33s
Precache the shell, JS, CSS and self-hosted fonts in
mealquest-precache-v1. Route every other request through the pure
src/sw/routes.ts decision: NetworkFirst (3 s timeout) for plans,
shopping, dinners and home; CacheFirst with expiration for /media/* and
font files; NetworkOnly for /auth/*, settings, jobs and every non-GET.
App navigations fall back to the precached index.html offline. On
activate, delete only stale caches named mealquest-*.

Maestro task: 8fa0d0bd-4a52-4f09-ba68-41bdbdfda63b

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 16:20:19 -04:00
dev_agent dd58865323 Web app manifest and icons (#50)
CI / ci (push) Successful in 1m35s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-04 16:16:41 -04:00
approver_agent d80012a821 Merge pull request 'Feature 05: Frontend foundation and design system' (#43) from feature/64e5bb19-c9a2-4c6a-8f0c-aceae43fb732-frontend-foundation into dev
Independent review Codex gpt-6-sol high: APPROVED (0 findings)
CI / ci (pull_request) Successful in 1m32s
CI / ci (push) Successful in 1m36s
2026-10-04 09:18:57 -04:00
dev_agent 5758f198e4 Keep the CSRF token on same-origin API calls and lint colours in the build (#45)
CI / ci (push) Successful in 1m33s
CI / ci (pull_request) Successful in 1m31s
Independent review Codex gpt-6-sol high: APPROVED (0 findings)
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-04 09:08:42 -04:00
dev_agent 048d5dd71a API client, auth bootstrap and job polling (#42)
CI / ci (push) Successful in 1m32s
CI / ci (pull_request) Successful in 1m33s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-04 08:46:40 -04:00
dev_agent c899f07b1f Nav pill slide animation (#39)
CI / ci (push) Successful in 1m29s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-03 18:52:57 -04:00
dev_agent 998658b42c Responsive app shell, routes and navigation (#38)
CI / ci (push) Successful in 1m30s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-03 18:40:34 -04:00
dev_agent a453a84231 Photo and Icon components (#37)
CI / ci (push) Successful in 1m30s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-03 18:29:16 -04:00
dev_agent 732aa92a8c Sheet, ErrorBanner, Skeleton and EmptyState (#36)
CI / ci (push) Successful in 1m30s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-03 18:22:59 -04:00
dev_agent 02f1dd6491 Feature 05 task 2: Core UI primitives (#34)
CI / ci (push) Successful in 1m31s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-03 16:34:59 -04:00
dev_agent 496f2e5375 Feature 05 task 1: Design tokens and self-hosted fonts (#32)
CI / ci (push) Successful in 1m29s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-03 16:16:25 -04:00
approver_agent 7cc96121e4 Merge pull request 'Feature 04: Platform Services - Secrets, Settings, Time and Jobs' (#30) from feature/49e2f2e2-c9c1-4c1b-8d66-753ceeadbf75-platform-services into dev
CI / ci (push) Successful in 1m28s
2026-10-03 15:50:01 -04:00
dev_agent bba9033ac5 Feature 04 integration fix: authenticated job enqueue API and progressive HTTP flow (#31)
CI / ci (push) Successful in 1m29s
Independent review Codex gpt-6-sol high: APPROVED (0 findings)
CI / ci (pull_request) Successful in 1m28s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-03 15:46:19 -04:00
dev_agent e050736178 Feature 04 task 5: Async job runner and GET /api/v1/jobs/{id} (#29)
CI / ci (push) Successful in 1m29s
Independent review Codex gpt-6-sol high: CHANGES_REQUIRED (1 medium)
CI / ci (pull_request) Successful in 1m28s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-03 15:24:01 -04:00
dev_agent 3ba78d47bc Feature 04 task 4: Default seeding for AI settings and notification preferences (#28)
CI / ci (push) Successful in 1m26s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-03 14:58:30 -04:00
dev_agent 8be5c56f6f Feature 04 task 3 (rebased): Household settings seeding and API (#27)
CI / ci (push) Successful in 1m22s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-03 11:50:15 -04:00
dev_agentandClaude Opus 5.5 ea4047a852 Merge origin/dev (Feature 03) into Feature 04 integration
CI / ci (push) Successful in 1m26s
Feature 03 made the OIDC variables required, so the Feature 04 boot tests'
loadConfig helper now sets them alongside the secret key file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 11:38:18 -04:00
approver_agent 41ff2ab114 Merge pull request 'Feature 03: Authentik OIDC Sessions' (#24) from feature/9c4eba48-9a49-4b73-a85f-a4bc19eb4062-authentik-oidc-sessions into dev
CI / ci (push) Successful in 1m30s
2026-10-03 11:36:30 -04:00
dev_agent df692ef214 Feature 03 integration fix: sanitize ID-token verification errors (#26)
CI / ci (push) Successful in 1m26s
Independent review Codex gpt-6-sol high: APPROVED (0 findings)
CI / ci (pull_request) Successful in 1m33s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-03 11:32:36 -04:00
dev_agent 88f7d0a5fa Feature 03 integration fix: sanitize discovery errors and narrow CSRF exemption (#25)
CI / ci (push) Successful in 1m25s
CI / ci (pull_request) Failing after 1m2s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-03 11:22:53 -04:00
dev_agent 5833a73541 Feature 03 task 5: Trusted proxy handling for forwarded headers (#23)
CI / ci (push) Successful in 1m25s
CI / ci (pull_request) Successful in 1m24s
Independent review Codex gpt-6-sol high: CHANGES_REQUIRED (2 medium)
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-03 11:05:33 -04:00
dev_agent 36b975ff5b Feature 03 task 4: CSRF protection and GET /api/v1/me (#22)
CI / ci (push) Successful in 1m26s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-03 10:51:26 -04:00
dev_agent b2201c5da1 Feature 03 task 3: Household group gate and auth middleware (#21)
CI / ci (push) Successful in 1m27s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-03 10:30:07 -04:00
dev_agent f0bdd0ef40 Feature 03 task 2: Server-side sessions, cookie and logout (#19)
CI / ci (push) Successful in 1m24s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-03 10:20:14 -04:00
dev_agent 5e78465d34 Feature 04 task 2: Household week and local-time utilities (#18)
CI / ci (push) Successful in 1m25s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-03 09:58:06 -04:00
dev_agent 99c8f1625f Feature 03 task 1: OIDC login and callback against Authentik (#16)
CI / ci (push) Successful in 1m25s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-03 09:58:05 -04:00
dev_agent 6d5c14f778 Feature 04 task 1: secretbox: AES-256-GCM secrets at rest (#17)
CI / ci (push) Successful in 1m26s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-03 09:46:29 -04:00
approver_agent 19ea8d52d8 Merge pull request 'Feature 02: SQLite Data Layer and Schema' (#14) from feature/b9c21d67-bf72-411a-901d-77e57352d398-sqlite-data-layer-and-schema into dev
CI / ci (push) Successful in 1m23s
2026-10-03 09:30:47 -04:00
dev_agent c1a868c48b Feature 02 integration fix: repository coverage and boot-path tests (#15)
CI / ci (push) Successful in 1m22s
CI / ci (pull_request) Successful in 1m24s
Independent review Codex gpt-6-sol high: APPROVED (0 high/medium; 1 low)
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-03 09:27:22 -04:00
dev_agent 17a69f4de7 Feature 02 task 4: sqlc configuration and core queries (#13)
CI / ci (push) Successful in 1m25s
CI / ci (pull_request) Successful in 1m24s
Independent review Codex gpt-6-sol high: CHANGES_REQUIRED (4 medium)
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-03 09:15:40 -04:00
dev_agent fa7c6b7218 Feature 02 task 3: Initial v1 schema migration (#12)
CI / ci (push) Successful in 50s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-03 09:06:21 -04:00
dev_agent b06d1680a3 Feature 02 task 2: Store helpers: transactions, ULIDs and time conventions (#11)
CI / ci (push) Successful in 48s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-03 08:54:55 -04:00
dev_agent e9ee4e768a Feature 02 task 1: SQLite connection and embedded migration runner (#10)
CI / ci (push) Successful in 49s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-03 00:54:02 -04:00
approver_agent 65b27a7689 Merge pull request 'Feature 01: Repository Scaffold and CI' (#8) from feature/19ad7feb-aba0-4b76-ab70-c47d71ed5e76-repository-scaffold-and-ci into dev
CI / ci (push) Successful in 27s
2026-10-03 00:40:39 -04:00
dev_agent 645b3c00b2 Feature 01 integration fix: CI build parity with make build (#9)
CI / ci (push) Successful in 26s
CI / ci (pull_request) Successful in 25s
Independent review Approved — 0 high findings (feature #8)
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-03 00:36:48 -04:00
dev_agent 9f4537d0cb Feature 01 task 6: AGENTS.md, CLAUDE.md, README and architecture docs (#7)
CI / ci (pull_request) Successful in 24s
CI / ci (push) Successful in 23s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-03 00:26:15 -04:00
dev_agent ec13423b6f Feature 01 task 5: Gitea Actions CI workflow (#6)
CI / ci (push) Successful in 24s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-03 00:12:39 -04:00
dev_agent c28dff18cd Feature 01 task 4: Makefile and multi-stage Dockerfile (#5)
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-02 23:06:58 -04:00
dev_agent 85569b451f Feature 01 task 3: Embed the PWA build with SPA fallback (#4)
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-02 22:47:01 -04:00
dev_agent f0b2696059 Feature 01 task 2: Vite React TypeScript web skeleton (#3)
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-02 22:26:34 -04:00
dev_agent e3d8c4d73d Feature 01 task 1: Go module, fail-fast config and /health (#2)
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-02 22:20:34 -04:00
overseer d2dc58a2ec Merge pull request 'Design files' (#1) from main into dev
Reviewed-on: #1
2026-10-02 15:10:15 -04:00
Joshua King 3a83e6c942 Design files 2026-10-02 15:09:42 -04:00
240 changed files with 36516 additions and 0 deletions
+26
View File
@@ -0,0 +1,26 @@
# Keep the build context small and free of secrets and local state.
.git
.gitignore
.dockerignore
Dockerfile
.remember/
.claude/
design/
**/.DS_Store
node_modules
web/node_modules
web/dist
# Generated embed output; the Go stage takes the PWA from the web stage.
internal/frontend/dist/
bin/
*.db
*.db-*
.env*
**/.env*
deploy/secrets/
**/*.pem
**/*.key
**/*.p12
**/*.pfx
**/*.jks
**/*.keystore
+39
View File
@@ -0,0 +1,39 @@
# Runs on every push and pull request (no branch filter) so feature branches,
# their pull requests, and dev/main are all checked. Runs directly on the
# self-hosted act_runner; no container: key.
#
# The Makefile is the single build definition: CI runs `make test` and
# `make build` so it fails whenever either target would, and the binary
# carries the Makefile's -trimpath and version-stamped -ldflags (the
# `make build` log shows the -X main.version=... value). `make sqlc-check`
# fails when the committed internal/db code is stale against sqlc.yaml,
# migrations/ and migrations/queries/.
name: CI
on: [push, pull_request]
jobs:
ci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: false
- uses: actions/setup-node@v4
with:
node-version: "22"
- name: make test
run: make test
- name: make sqlc-check
run: |
make sqlc-install
make sqlc-check SQLC="$(go env GOPATH)/bin/sqlc"
- name: make build
run: make build
+18
View File
@@ -0,0 +1,18 @@
.DS_Store
design/.DS_Store
# Secrets and local environment
.env*
deploy/secrets/
# Build artifacts and dependencies
node_modules
dist
bin/
# Placeholder so the Go embed compiles on a fresh clone; built assets stay ignored.
!internal/frontend/dist/
internal/frontend/dist/*
!internal/frontend/dist/index.html
# Local databases
*.db
+143
View File
@@ -0,0 +1,143 @@
# AGENTS.md — MealQuest agent guide
This is the authoritative guide for every agent (and human) working in this
repository. `CLAUDE.md` only imports this file. Where this guide and a general
habit disagree, this guide wins.
MealQuest is a single Go service with an embedded React PWA for one
household. The architecture of record is [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md);
product requirements are in [design/MealQuest Requirements.md](design/MealQuest%20Requirements.md).
## House rules
1. **Fail fast.** Invalid configuration, input or state is an error reported
immediately and clearly — the service refuses to start on bad config.
2. **No silent fallbacks.** Never substitute a default, retry quietly, or
swallow an error to "keep going". If something is missing, say so.
3. **One way to do a thing.** No parallel code paths, alternative settings or
"just in case" options. Pick one approach and use it everywhere.
4. **No new third-party dependency without owner approval.** Use only the
approved list below; anything else needs the owner's explicit yes first.
5. **Never use `tea`.** The Gitea CLI on this host is authenticated as the
human owner. Agents act only through their own Gitea identities.
## Approved dependencies (from CTXD)
Backend:
- Go 1.25, built with `CGO_ENABLED=0`.
- `github.com/go-chi/chi/v5` — HTTP router.
- `modernc.org/sqlite` — CGO-free SQLite driver.
- `sqlc` — query code generation (dev tool only).
- `github.com/coreos/go-oidc/v3` — OIDC (Authentik).
- `golang.org/x/oauth2` and `golang.org/x/oauth2/google`.
- `github.com/SherClockHolmes/webpush-go` — Web Push.
- `golang.org/x/image` — photo processing.
- `github.com/oklog/ulid/v2` — IDs.
- Standard library `log/slog` and `net/http`.
- **Not allowed:** LLM vendor SDKs, Google API SDKs (call the HTTP APIs
directly), goose, golang-migrate.
Frontend:
- React 19, TypeScript, Vite.
- React Router 7, TanStack Query 5.
- `vite-plugin-pwa`.
- `lucide-react` icons at stroke width 2.75.
- CSS Modules over the design tokens in
[tokens.css](design/MealQuest%20UI%20Designs/design_handoff_mealquest/tokens.css).
**No Tailwind, no Framer Motion, no hard-coded hex colours.**
- Self-hosted fonts: Bagel Fat One and Figtree.
- Tests: Vitest, Testing Library, jsdom.
## Quality floor
Every change must pass all of these before review (CI in
[.gitea/workflows/ci.yml](.gitea/workflows/ci.yml) enforces them):
```sh
gofmt -l . # must print nothing
go vet ./...
go test -race ./...
cd web && npm run typecheck && npm test && npm run build
```
And the build entry points in the [Makefile](Makefile) and [Dockerfile](Dockerfile):
```sh
make test
make build
docker build .
```
Lint proves code parses, not that it works: where a change has runtime
behaviour, exercise it (hit the endpoint, render the page).
## Repository layout
| Path | Purpose |
| --- | --- |
| `cmd/server` | Entry point. Wiring only — no business logic. |
| `internal/config` | `MEALQUEST_*` environment config; secrets from `*_FILE`. |
| `internal/server` | HTTP server, routes, `/health`. |
| `internal/frontend` | `go:embed` of the built PWA with SPA fallback. |
| `internal/*` (planned) | `auth`, `db`, `store`, `secretbox`, `household`, `dinners`, `recipes`, `ai/llm`, `ai/harness`, `ai/skills`, `suggest`, `plan`, `shopping`, `ideas`, `gcal`, `notify`, `push`, `chat`, `jobs`, `scheduler`. |
| `migrations/` | SQL migrations (planned). |
| `web/` | React PWA source. |
| `deploy/` | Deployment files (planned). |
| `docs/` | Project documentation, including [ARCHITECTURE.md](docs/ARCHITECTURE.md). |
| `design/` | Requirements and UI handoff. **Read-only reference** — do not edit. |
| `Dockerfile` | Multi-stage image build, at the repository root. |
| `Makefile` | `dev`, `test`, `build`, `docker`. |
| `.gitea/workflows/ci.yml` | CI quality floor. |
Design references:
- [design/MealQuest Requirements.md](design/MealQuest%20Requirements.md)
- [design/MealQuest UI Designs/design_handoff_mealquest/README.md](design/MealQuest%20UI%20Designs/design_handoff_mealquest/README.md)
- [design/MealQuest UI Designs/design_handoff_mealquest/tokens.css](design/MealQuest%20UI%20Designs/design_handoff_mealquest/tokens.css)
## Branches, pull requests and merges
- Integration branch is `dev`. `main` is the release branch.
- **Feature branches:** `feature/<feature-uuid>-<slug>`, branched from `dev`.
- **Task branches:** `task/<slug>_<task-uuid>`, branched from the feature branch.
- Task pull requests **squash-merge into the feature branch**.
- The feature pull request merges into `dev`.
- **Only `overseer` merges `dev` → `main`** (a release).
- Never push directly to `dev` or `main`.
- Authors never review, approve, merge or close their own work.
- Never store credentials in the repository; environment or ignored files only.
## Where context lives and where work is tracked
- **Context:** CTXD project `mealquest` — client guide at
<https://ctxd.cubecraftcreations.com>. Project documents: `CONTEXT.MD`
(architecture), `DECISIONS.MD` (decision log, e.g. D-021), `RUNBOOKS.MD`,
`PROMPTS.MD`, `GLOSSARY.MD`.
- **Work tracking:** Maestro at <https://maestro.cubecraftlabs.com>,
workspace **Maniacs-Mansion** → project **MealQuest**. Every change has a
task with a verifiable done-condition.
## Vocabulary (GLOSSARY)
Use these product terms in code names, UI copy, docs and commit messages. Do
not invent synonyms.
| Term | Meaning |
| --- | --- |
| **Regulars** | Dinners the household makes routinely. |
| **Side quest** | An AI-suggested new dinner to try. |
| **Quest log** | The to-try pool of accepted Side quests, scheduled later. |
| **Try it / Pass** | Accept (swipe right) / decline (swipe left) a suggestion. |
| **Rotation check** / **"Keep it in rotation?"** | The prompt after a Quest-log dinner is tried, asking whether it becomes a Regular. Promotion is always the household's choice, never automatic. |
| **Supply run** | The current week's shopping list, grouped by dinner. |
| **Supply archive** | The blog-style history of past shopping lists (by week, month, year). |
| **Draft board** | The mobile weekly plan layout the AI drafts for the household to adjust. |
| **Rebuild week** | Ask the AI to redraft the week's plan. |
| **Push to Calendar** | Push the plan one-way to Google Calendar. |
| **Quick dinner** | Cooked in a crock pot **or** 30 minutes or less from prep to table. |
| **Cook from what I have** | Ideas from a list of ingredients on hand. |
| **Kitchen reading mode** | Tablet recipe view: large text, ingredient checkboxes, step-by-step. |
| **"Why this?"** | The reason shown for a suggestion, referencing the household's Regulars. |
| **Our kitchen / Shared household** | The single shared household identity; no per-person profiles. |
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+42
View File
@@ -0,0 +1,42 @@
# syntax=docker/dockerfile:1
# Base images are pinned by digest (multi-arch index); tags are for readability.
FROM node:22-alpine@sha256:0a7108bf6c7bf5de370ffb1a3ed6be93d405b43ff159f681a8d18c0e2bc2e402 AS web
WORKDIR /src/web
COPY web/package.json web/package-lock.json ./
RUN npm ci
COPY web/ ./
RUN npm run build
FROM golang:1.25-alpine@sha256:1ae0735f00daffa3aaf1363a5184c0d2dc55c78e3db4ec70241cdac97bf84b59 AS server
ARG VERSION=dev
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# Replace the placeholder with the built PWA; recreate the directory so no
# stale file survives into the embed.
RUN rm -rf internal/frontend/dist
COPY --from=web /src/web/dist/ internal/frontend/dist/
RUN CGO_ENABLED=0 go build -trimpath -ldflags "-s -w -X main.version=${VERSION}" \
-o /out/mealquest ./cmd/server
# Alpine ships the CA bundle and busybox wget for the health check.
FROM alpine:3.22@sha256:5291449c3df73caf6ed85e649dec1b9e818b39a5d8c871e97afc13e9cd5e8fa8
RUN addgroup -S -g 10001 mealquest \
&& adduser -S -D -H -u 10001 -G mealquest mealquest \
&& mkdir /data && chown mealquest:mealquest /data
COPY --from=server /out/mealquest /usr/local/bin/mealquest
ENV MEALQUEST_HTTP_ADDR=:8080 \
MEALQUEST_DATA_DIR=/data
VOLUME /data
EXPOSE 8080
USER 10001:10001
# Probe the listener from MEALQUEST_HTTP_ADDR: host is everything before the
# last colon (IPv6 keeps its brackets). Wildcard hosts map to loopback, as
# does localhost: Go listens on its IPv4 address but wget would try ::1.
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD host="${MEALQUEST_HTTP_ADDR%:*}"; \
case "$host" in ""|0.0.0.0|"[::]"|localhost) host=127.0.0.1 ;; esac; \
wget -q -O /dev/null "http://${host}:${MEALQUEST_HTTP_ADDR##*:}/health" || exit 1
ENTRYPOINT ["/usr/local/bin/mealquest"]
+88
View File
@@ -0,0 +1,88 @@
# MealQuest build entry points.
#
# make dev Go server on :8080 plus Vite dev server on :5173 (proxies
# /api, /auth, /media to :8080). Needs the MEALQUEST_* env set.
# make test gofmt check, go vet, go test -race, npm typecheck and test.
# make build Build the PWA, embed it, build the static binary bin/mealquest.
# make docker Build the container image from ./Dockerfile.
# make sqlc Regenerate internal/db from sqlc.yaml with the pinned sqlc.
# make sqlc-check Regenerate; fail if internal/db differs from the commit.
# make sqlc-install Install the pinned sqlc into the Go bin directory.
FRONTEND_DIST := internal/frontend/dist
# Dirty means a tracked change outside the generated embed output, which
# `make embed` rewrites; plain `--dirty` would flag every rebuild.
GIT_DIRTY := $(shell git status --porcelain --untracked-files=no -- . ':(exclude)$(FRONTEND_DIST)' 2>/dev/null)
VERSION ?= $(shell git describe --tags --always 2>/dev/null || echo dev)$(if $(GIT_DIRTY),-dirty)
IMAGE ?= mealquest:$(VERSION)
LDFLAGS := -s -w -X main.version=$(VERSION)
# sqlc is a dev tool only, never a go.mod dependency. Generated code records
# the version that wrote it, so every run must use exactly this one.
SQLC_VERSION := v1.31.1
SQLC ?= sqlc
SQLC_OUT := internal/db
.PHONY: dev test go-test web-test build web embed check-embed-clean docker sqlc sqlc-check sqlc-install
# Run both servers; Ctrl-C (or either exiting) stops both. The binary is built
# first and run directly because `go run` would orphan its child on kill.
dev: SHELL := /bin/bash
dev:
go build -ldflags "$(LDFLAGS)" -o bin/mealquest-dev ./cmd/server
@trap 'kill $$(jobs -p) 2>/dev/null; wait' EXIT; \
bin/mealquest-dev & \
(cd web && exec npm run dev) & \
wait -n
test: go-test web-test
go-test:
@unformatted="$$(gofmt -l .)"; \
if [ -n "$$unformatted" ]; then echo "gofmt needed:"; echo "$$unformatted"; exit 1; fi
go vet ./...
go test -race ./...
web-test:
cd web && npm ci && npm run typecheck && npm test
build: embed
CGO_ENABLED=0 go build -trimpath -ldflags "$(LDFLAGS)" -o bin/mealquest ./cmd/server
web:
cd web && npm ci && npm run build
# Replace the committed placeholder with the built PWA so go:embed picks it up.
# Recreate the whole directory: a glob would skip dotfiles, which all:dist embeds.
embed: web
rm -rf $(FRONTEND_DIST)
mkdir -p $(FRONTEND_DIST)
cp -R web/dist/. $(FRONTEND_DIST)/
# Regression check: a stale hidden file must not survive an embed.
check-embed-clean:
touch $(FRONTEND_DIST)/.stale-embed-check
$(MAKE) embed
test ! -e $(FRONTEND_DIST)/.stale-embed-check
docker:
docker build --build-arg VERSION=$(VERSION) -t $(IMAGE) .
# sqlc $(SQLC_VERSION) needs Go 1.26 to build; GOTOOLCHAIN=auto lets go fetch
# it for this install only, even where the environment pins the local Go.
sqlc-install:
GOTOOLCHAIN=auto go install github.com/sqlc-dev/sqlc/cmd/sqlc@$(SQLC_VERSION)
# Previously generated files are removed first so a dropped query file does
# not leave its stale output behind; hand-written files are kept.
sqlc:
@v="$$($(SQLC) version)"; \
if [ "$$v" != "$(SQLC_VERSION)" ]; then echo "sqlc $(SQLC_VERSION) required, $(SQLC) is $$v"; exit 1; fi
find $(SQLC_OUT) -maxdepth 1 -name '*.go' -exec grep -l -x '// Code generated by sqlc. DO NOT EDIT.' {} + | xargs -r rm --
$(SQLC) generate
# Fails on any difference from the committed generated code, including new
# or deleted files.
sqlc-check: sqlc
@drift="$$(git status --porcelain --untracked-files=all -- $(SQLC_OUT))"; \
if [ -n "$$drift" ]; then echo "internal/db is stale; run make sqlc and commit:"; echo "$$drift"; git diff -- $(SQLC_OUT); exit 1; fi
+80
View File
@@ -0,0 +1,80 @@
# MealQuest
MealQuest is a mobile-first Progressive Web App for weekly dinner planning for
one household. The household lists the dinners it makes regularly, an AI
assistant suggests new dinners to try (swipe "Try it" or "Pass"), and the app
builds a weekly plan, pushes it to Google Calendar, and generates a shopping
list grouped by dinner, kept as a browsable weekly history. It ships as a
single Go service with the React PWA embedded in the binary.
> Status: this repository is the Feature 01 scaffold (Go service with
> `/health`, embedded PWA skeleton, Makefile, Dockerfile, CI). The planned
> application is described in [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
## Quick start
Requirements: Go 1.25 and Node.js `^22.22.2 || ^24.15.0 || >=26.0.0` — that
is, 22.22.2 or later on the 22 line, 24.15.0 or later on the 24 line, or 26.0.0
or later. Node.js 23, 24.0–24.14 and 25 are not supported: this is the
`engines` range of jsdom in the committed lockfile, the strictest of all the
web dependencies.
```sh
(cd web && npm ci)
mkdir -p ~/.mealquest-dev # outside the repository: it holds a secret
chmod 700 ~/.mealquest-dev
head -c 32 /dev/urandom | base64 > ~/.mealquest-dev/secret_key
chmod 600 ~/.mealquest-dev/secret_key
export MEALQUEST_HTTP_ADDR=:8080
export MEALQUEST_PUBLIC_URL=http://localhost:5173
export MEALQUEST_DATA_DIR="$HOME/.mealquest-dev"
export MEALQUEST_SHUTDOWN_TIMEOUT=10s
export MEALQUEST_SECRET_KEY_FILE="$HOME/.mealquest-dev/secret_key"
# Authentik OAuth2/OpenID provider for MealQuest (redirect URI:
# $MEALQUEST_PUBLIC_URL/auth/callback). Save its client secret to the file.
export MEALQUEST_OIDC_ISSUER=https://auth.example.com/application/o/mealquest/
export MEALQUEST_OIDC_CLIENT_ID=mealquest
export MEALQUEST_OIDC_CLIENT_SECRET_FILE="$HOME/.mealquest-dev/oidc_client_secret"
export MEALQUEST_OIDC_ALLOWED_GROUPS=mealquest-admin
# Optional: sessions end after MEALQUEST_SESSION_IDLE unused (default 720h)
# and always MEALQUEST_SESSION_MAX after sign-in (default 4320h).
# export MEALQUEST_SESSION_IDLE=720h
# export MEALQUEST_SESSION_MAX=4320h
# Optional: comma-separated IPs/CIDRs of reverse proxies whose
# X-Forwarded-For/-Proto headers are honoured (production: Traefik). Unset
# trusts no peer, which is right for local development.
# export MEALQUEST_TRUSTED_PROXY=10.60.1.33
export MEALQUEST_DEFAULT_TZ=America/New_York
make dev
```
`make dev` runs the Go server on `:8080` and the Vite dev server on `:5173`
(which proxies `/api`, `/auth` and `/media` to `:8080`). Open
http://localhost:5173. Configuration is environment-only and fails fast:
every variable above is required except the commented-out session
lifetimes (when set they must be positive, with MAX at least IDLE),
trusted proxy list (every entry must be an IP or CIDR) and
`MEALQUEST_DEFAULT_TZ`, and
secrets are read only from files named by `*_FILE` variables. `MEALQUEST_PUBLIC_URL`, `MEALQUEST_OIDC_ISSUER` and the
endpoints the issuer advertises must be `https://`; plain `http://` is
accepted only for `localhost`, `127.0.0.1` and `[::1]`.
`MEALQUEST_DEFAULT_TZ` is the IANA zone the household settings are created
with on first boot: until those settings exist, boot fails unless it is a
valid IANA zone. Later boots ignore it, set or not (change the zone in
household settings instead).
## Common commands
| Command | What it does |
| -------------- | --------------------------------------------------------------- |
| `make dev` | Go server plus Vite dev server. |
| `make test` | `gofmt` check, `go vet`, `go test -race`, web typecheck and tests. |
| `make build` | Build the PWA, embed it, build the static binary `bin/mealquest`. |
| `make docker` | Build the container image from the root `Dockerfile`. |
## Where to read next
- [AGENTS.md](AGENTS.md) — house rules, approved dependencies, conventions.
- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) — architecture of record.
- [design/MealQuest Requirements.md](design/MealQuest%20Requirements.md) — product requirements.
- [design/MealQuest UI Designs/design_handoff_mealquest/README.md](design/MealQuest%20UI%20Designs/design_handoff_mealquest/README.md) — UI handoff.
+370
View File
@@ -0,0 +1,370 @@
package main
import (
"bytes"
"context"
"crypto/rand"
"encoding/base64"
"errors"
"io/fs"
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
"testing/fstest"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/config"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
)
// useBootMigrations swaps bootMigrations for the test and restores it after.
// Tests that call it must not run in parallel.
func useBootMigrations(t *testing.T, m fstest.MapFS) {
t.Helper()
saved := bootMigrations
bootMigrations = m
t.Cleanup(func() { bootMigrations = saved })
}
// loadConfig loads a configuration through config.Load whose secret key file
// holds keyFile. Tests that call it must not run in parallel.
func loadConfig(t *testing.T, keyFile string) config.Config {
t.Helper()
dir := t.TempDir()
keyPath := filepath.Join(dir, "secret_key")
if err := os.WriteFile(keyPath, []byte(keyFile), 0o600); err != nil {
t.Fatal(err)
}
clientSecretPath := filepath.Join(dir, "oidc_client_secret")
if err := os.WriteFile(clientSecretPath, []byte("client-secret\n"), 0o600); err != nil {
t.Fatal(err)
}
t.Setenv(config.EnvHTTPAddr, "127.0.0.1:0")
t.Setenv(config.EnvPublicURL, "http://localhost")
t.Setenv(config.EnvDataDir, t.TempDir())
t.Setenv(config.EnvShutdownTimeout, "1s")
t.Setenv(config.EnvSecretKeyFile, keyPath)
t.Setenv(config.EnvOIDCIssuer, "https://auth.example.com/application/o/mealquest/")
t.Setenv(config.EnvOIDCClientID, "mealquest")
t.Setenv(config.EnvOIDCClientSecretFile, clientSecretPath)
t.Setenv(config.EnvOIDCAllowedGroups, "mealquest-admin")
// MEALQUEST_DEFAULT_TZ is left unset; t.Setenv restores any outer value.
t.Setenv(config.EnvDefaultTZ, "")
if err := os.Unsetenv(config.EnvDefaultTZ); err != nil {
t.Fatal(err)
}
cfg, err := config.Load()
if err != nil {
t.Fatal(err)
}
return cfg
}
// validKeyFile returns secret key file contents as written by the README:
// base64 of 32 random bytes followed by a newline.
func validKeyFile(t *testing.T) string {
t.Helper()
key := make([]byte, secretbox.KeySize)
if _, err := rand.Read(key); err != nil {
t.Fatal(err)
}
return base64.StdEncoding.EncodeToString(key) + "\n"
}
var brokenMigrations = fstest.MapFS{
"0000_good.sql": {Data: []byte("CREATE TABLE good (id INTEGER PRIMARY KEY);")},
"0001_broken.sql": {Data: []byte("CREATE TABLE broken (id INTEGER PRIMARY KEY;")},
}
func TestOpenStoreMigrationFailureStopsBoot(t *testing.T) {
useBootMigrations(t, brokenMigrations)
db, err := openStore(context.Background(), t.TempDir())
if err == nil {
db.Close()
t.Fatal("openStore succeeded with a broken migration")
}
if db != nil {
t.Error("openStore returned a store alongside the error")
}
if !strings.Contains(err.Error(), "0001_broken.sql") {
t.Errorf("error does not name the broken migration: %v", err)
}
}
func TestOpenStoreAppliesEmbeddedMigrations(t *testing.T) {
ctx := context.Background()
db, err := openStore(ctx, t.TempDir())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := db.Close(); err != nil {
t.Error(err)
}
})
var n int
if err := db.Reader.QueryRowContext(ctx, "SELECT count(*) FROM schema_migrations").Scan(&n); err != nil {
t.Fatal(err)
}
files, err := fs.Glob(migrations.FS, "*.sql")
if err != nil {
t.Fatal(err)
}
if n != len(files) {
t.Errorf("schema_migrations has %d rows, want %d", n, len(files))
}
}
func TestRunMigrationFailureReturnsBeforeListening(t *testing.T) {
useBootMigrations(t, brokenMigrations)
var logs bytes.Buffer
cfg := loadConfig(t, validKeyFile(t))
errc := make(chan error, 1)
go func() { errc <- run(cfg, slog.New(slog.NewJSONHandler(&logs, nil))) }()
select {
case err := <-errc:
if err == nil || !strings.Contains(err.Error(), "0001_broken.sql") {
t.Fatalf("run error = %v, want the broken migration's error", err)
}
case <-time.After(10 * time.Second):
t.Fatal("run kept going after a migration failure")
}
if strings.Contains(logs.String(), `"listening"`) {
t.Errorf("run started listening despite the migration failure: %s", logs.String())
}
}
func TestOpenSecretBoxAcceptsReadmeKey(t *testing.T) {
cfg := loadConfig(t, validKeyFile(t))
box, err := openSecretBox(cfg.SecretKey)
if err != nil {
t.Fatal(err)
}
token, err := box.Encrypt([]byte("x"))
if err != nil {
t.Fatal(err)
}
if _, err := box.Decrypt(token); err != nil {
t.Fatal(err)
}
}
func TestRunBadSecretKeyStopsBoot(t *testing.T) {
short := base64.StdEncoding.EncodeToString(make([]byte, 31))
tests := []struct {
name string
keyFile string
want error
}{
{"not base64", "this is not a base64 key!\n", secretbox.ErrKeyEncoding},
{"wrong length", short + "\n", secretbox.ErrKeyLength},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := loadConfig(t, tt.keyFile)
var logs bytes.Buffer
errc := make(chan error, 1)
go func() { errc <- run(cfg, slog.New(slog.NewJSONHandler(&logs, nil))) }()
var err error
select {
case err = <-errc:
case <-time.After(10 * time.Second):
t.Fatal("run kept going with a bad secret key")
}
if !errors.Is(err, tt.want) {
t.Fatalf("run error = %v, want %v", err, tt.want)
}
if !strings.Contains(err.Error(), config.EnvSecretKeyFile) {
t.Errorf("error does not name %s: %v", config.EnvSecretKeyFile, err)
}
if key := strings.TrimSpace(tt.keyFile); strings.Contains(err.Error(), key) {
t.Errorf("error leaks the key: %v", err)
}
if strings.Contains(logs.String(), `"listening"`) {
t.Errorf("run started listening despite the bad key: %s", logs.String())
}
if _, statErr := os.Stat(filepath.Join(cfg.DataDir, store.FileName)); !errors.Is(statErr, fs.ErrNotExist) {
t.Errorf("run opened the database before rejecting the key (stat: %v)", statErr)
}
})
}
}
func TestLoadMissingSecretKeyFileStopsBoot(t *testing.T) {
loadConfig(t, validKeyFile(t)) // sets every other variable
t.Setenv(config.EnvSecretKeyFile, filepath.Join(t.TempDir(), "missing"))
_, err := config.Load()
var cfgErr *config.Error
if !errors.As(err, &cfgErr) || !strings.Contains(err.Error(), config.EnvSecretKeyFile) {
t.Fatalf("Load error = %v, want a config error naming %s", err, config.EnvSecretKeyFile)
}
}
func TestRunWithoutValidDefaultZoneOnFirstBootStops(t *testing.T) {
tests := []struct {
name string
defaultTZ *string // nil leaves MEALQUEST_DEFAULT_TZ unset
want error
}{
{"unset", nil, household.ErrNoDefaultZone},
{"not an IANA zone", ptr("Mars/Olympus_Mons"), household.ErrInvalidDefaultZone},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
loadConfig(t, validKeyFile(t))
if tt.defaultTZ != nil {
t.Setenv(config.EnvDefaultTZ, *tt.defaultTZ)
}
cfg, err := config.Load()
if err != nil {
t.Fatal(err)
}
var logs bytes.Buffer
errc := make(chan error, 1)
go func() { errc <- run(cfg, slog.New(slog.NewJSONHandler(&logs, nil))) }()
select {
case err = <-errc:
case <-time.After(10 * time.Second):
t.Fatal("run kept going with no household settings and no valid default zone")
}
if !errors.Is(err, tt.want) || !strings.Contains(err.Error(), config.EnvDefaultTZ) {
t.Fatalf("run error = %v, want %v naming %s", err, tt.want, config.EnvDefaultTZ)
}
if strings.Contains(logs.String(), `"listening"`) {
t.Errorf("run started listening without household settings: %s", logs.String())
}
})
}
}
// A reboot over existing household settings must not depend on
// MEALQUEST_DEFAULT_TZ: config.Load accepts an invalid value and seeding
// ignores it, keeping the stored zone.
func TestRebootWithSettingsIgnoresInvalidDefaultZone(t *testing.T) {
ctx := context.Background()
cfg := loadConfig(t, validKeyFile(t))
db, err := openStore(ctx, cfg.DataDir)
if err != nil {
t.Fatal(err)
}
if err := seedSettings(ctx, db, "America/Denver"); err != nil {
t.Fatal(err)
}
if err := db.Close(); err != nil {
t.Fatal(err)
}
t.Setenv(config.EnvDefaultTZ, "Mars/Olympus_Mons")
cfg, err = config.Load()
if err != nil {
t.Fatalf("Load with an invalid %s and existing settings: %v", config.EnvDefaultTZ, err)
}
db, err = openStore(ctx, cfg.DataDir)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := db.Close(); err != nil {
t.Error(err)
}
})
if err := seedSettings(ctx, db, cfg.DefaultTZ); err != nil {
t.Fatalf("reboot with %s=%q: %v", config.EnvDefaultTZ, cfg.DefaultTZ, err)
}
got, err := household.NewSettingsService(db).Get(ctx)
if err != nil {
t.Fatal(err)
}
if got.Timezone != "America/Denver" {
t.Errorf("timezone after reboot = %q, want America/Denver", got.Timezone)
}
}
func ptr[T any](v T) *T { return &v }
func TestSeedSettingsAtBoot(t *testing.T) {
ctx := context.Background()
dir := t.TempDir()
// First boot seeds from the default zone.
db, err := openStore(ctx, dir)
if err != nil {
t.Fatal(err)
}
if err := seedSettings(ctx, db, "America/Denver"); err != nil {
t.Fatal(err)
}
if err := db.Close(); err != nil {
t.Fatal(err)
}
// A later boot needs no default zone and keeps the stored one.
db, err = openStore(ctx, dir)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := db.Close(); err != nil {
t.Error(err)
}
})
if err := seedSettings(ctx, db, ""); err != nil {
t.Fatalf("reboot without %s: %v", config.EnvDefaultTZ, err)
}
got, err := household.NewSettingsService(db).Get(ctx)
if err != nil {
t.Fatal(err)
}
if got.Timezone != "America/Denver" {
t.Errorf("timezone after reboot = %q, want America/Denver", got.Timezone)
}
}
func TestSeedDefaultsAtBoot(t *testing.T) {
ctx := context.Background()
db, err := openStore(ctx, t.TempDir())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := db.Close(); err != nil {
t.Error(err)
}
})
if err := seedDefaults(ctx, db, "America/Denver"); err != nil {
t.Fatal(err)
}
if _, err := household.NewSettingsService(db).Get(ctx); err != nil {
t.Errorf("household settings: %v", err)
}
var provider string
if err := db.Reader.QueryRowContext(ctx, `SELECT provider FROM ai_settings`).Scan(&provider); err != nil {
t.Errorf("AI settings: %v", err)
} else if provider != household.SeedAIProvider {
t.Errorf("AI provider = %q, want %q", provider, household.SeedAIProvider)
}
var prefs int
if err := db.Reader.QueryRowContext(ctx, `SELECT count(*) FROM notification_prefs`).Scan(&prefs); err != nil {
t.Fatal(err)
}
if prefs != 5 {
t.Errorf("%d notification preferences, want 5", prefs)
}
// A second boot seeds nothing new and succeeds.
if err := seedDefaults(ctx, db, ""); err != nil {
t.Fatalf("reboot: %v", err)
}
}
+62
View File
@@ -0,0 +1,62 @@
package main
import (
"context"
"io"
"log/slog"
"testing"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
)
// TestNewJobRunnerRunsNoop proves the runner run builds can execute a job:
// a noop job enqueued before Start, as at boot, succeeds with its result.
func TestNewJobRunnerRunsNoop(t *testing.T) {
ctx := context.Background()
db, err := openStore(ctx, t.TempDir())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := db.Close(); err != nil {
t.Error(err)
}
})
runner, kinds, err := newJobRunner(db, slog.New(slog.NewTextHandler(io.Discard, nil)))
if err != nil {
t.Fatal(err)
}
id, err := kinds.builtins.Noop.Enqueue(ctx, jobs.NoopPayload{})
if err != nil {
t.Fatal(err)
}
jobsCtx, stopJobs := context.WithCancel(ctx)
if err := runner.Start(jobsCtx); err != nil {
stopJobs()
t.Fatal(err)
}
t.Cleanup(func() {
stopJobs()
runner.Wait()
})
deadline := time.Now().Add(5 * time.Second)
for {
job, err := runner.Get(ctx, id)
if err != nil {
t.Fatal(err)
}
if job.Status == jobs.StatusSucceeded || job.Status == jobs.StatusFailed {
if job.Kind != jobs.KindNoop || job.Status != jobs.StatusSucceeded || string(job.Result) != `{"ok":true}` || job.Error != nil {
t.Fatalf("noop job = %+v (result %s), want succeeded with {\"ok\":true}", job, job.Result)
}
return
}
if time.Now().After(deadline) {
t.Fatalf("noop job never finished; last %+v", job)
}
time.Sleep(5 * time.Millisecond)
}
}
+227
View File
@@ -0,0 +1,227 @@
// Command server runs the MealQuest HTTP service.
package main
import (
"context"
"errors"
"fmt"
"io/fs"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/config"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/server"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
)
// version is set at build time with -ldflags "-X main.version=<version>".
var version = "dev"
// jobWorkers is the number of background jobs run at once.
const jobWorkers = 2
func main() {
cfg, err := config.Load()
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
logger := slog.New(forwarded.LogHandler(slog.NewJSONHandler(os.Stdout, nil)))
if err := run(cfg, logger); err != nil {
logger.Error("server stopped", "error", err)
os.Exit(1)
}
}
func run(cfg config.Config, logger *slog.Logger) error {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if _, err := openSecretBox(cfg.SecretKey); err != nil {
return err
}
db, err := openStore(ctx, cfg.DataDir)
if err != nil {
return err
}
defer func() {
if err := db.Close(); err != nil {
logger.Error("close database", "error", err)
}
}()
if err := seedDefaults(ctx, db, cfg.DefaultTZ); err != nil {
return err
}
settings := household.NewSettingsService(db)
runner, _, err := newJobRunner(db, logger)
if err != nil {
return err
}
sessions, err := auth.NewSessionManager(db, cfg.SessionIdle, cfg.SessionMax)
if err != nil {
return err
}
authFlow, err := auth.New(ctx, auth.Config{
Issuer: cfg.OIDCIssuer.String(),
ClientID: cfg.OIDCClientID,
ClientSecret: cfg.OIDCClientSecret.Reveal(),
PublicURL: cfg.PublicURL,
SecretKey: []byte(cfg.SecretKey.Reveal()),
AllowedGroups: cfg.OIDCAllowedGroups,
}, sessions, logger)
if err != nil {
return err
}
router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, runner, frontend.Assets())
if err != nil {
return err
}
// Start fails the jobs a previous process left running before any
// worker runs. The workers stop, and are waited for, before the
// database closes.
jobsCtx, stopJobs := context.WithCancel(ctx)
defer func() {
stopJobs()
runner.Wait()
}()
if err := runner.Start(jobsCtx); err != nil {
return err
}
srv := &http.Server{
Addr: cfg.HTTPAddr,
Handler: router,
ReadHeaderTimeout: 10 * time.Second,
ErrorLog: slog.NewLogLogger(logger.Handler(), slog.LevelError),
}
errc := make(chan error, 1)
go func() {
logListening(logger, cfg)
errc <- srv.ListenAndServe()
}()
select {
case err := <-errc:
return err
case <-ctx.Done():
}
logger.Info("shutting down", "timeout", cfg.ShutdownTimeout.String())
shutdownCtx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownTimeout)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
return fmt.Errorf("shutdown: %w", err)
}
if err := <-errc; !errors.Is(err, http.ErrServerClosed) {
return err
}
return nil
}
// openSecretBox builds the secret box from the configured key, naming the
// variable whose file holds a bad key. The key itself is never reported.
func openSecretBox(key config.Secret) (*secretbox.Box, error) {
box, err := secretbox.New(key.Reveal())
if err != nil {
return nil, fmt.Errorf("%s: %w", config.EnvSecretKeyFile, err)
}
return box, nil
}
// bootMigrations is the migration set applied at boot. Tests replace it to
// prove a failing migration stops the boot.
var bootMigrations fs.FS = migrations.FS
// openStore opens the database in dataDir and applies bootMigrations. On a
// migration failure it closes the database and returns the error.
func openStore(ctx context.Context, dataDir string) (*store.Store, error) {
db, err := store.Open(ctx, dataDir)
if err != nil {
return nil, err
}
if err := store.Migrate(ctx, db.Writer, bootMigrations); err != nil {
return nil, errors.Join(err, db.Close())
}
return db, nil
}
// seedSettings creates the household settings on first boot, naming the
// variable that supplies the zone when it is missing or invalid. Once the
// settings exist the variable is ignored.
func seedSettings(ctx context.Context, db *store.Store, defaultTZ string) error {
err := household.SeedSettings(ctx, db, defaultTZ)
if errors.Is(err, household.ErrNoDefaultZone) || errors.Is(err, household.ErrInvalidDefaultZone) {
return fmt.Errorf("%s: a valid IANA zone is required on first boot to create the household settings: %w", config.EnvDefaultTZ, err)
}
return err
}
// seedDefaults creates the household settings, AI settings and notification
// preferences that do not exist yet. Existing rows are never changed.
func seedDefaults(ctx context.Context, db *store.Store, defaultTZ string) error {
if err := seedSettings(ctx, db, defaultTZ); err != nil {
return err
}
if err := household.SeedAISettings(ctx, db); err != nil {
return err
}
return household.SeedNotificationPrefs(ctx, db)
}
// jobKinds enqueues every job kind the server runs.
type jobKinds struct {
builtins *jobs.Builtins
}
// newJobRunner returns the job runner with every job kind registered, ready
// to Start.
func newJobRunner(db *store.Store, logger *slog.Logger) (*jobs.Runner, *jobKinds, error) {
runner, err := jobs.NewRunner(db, logger, jobWorkers)
if err != nil {
return nil, nil, err
}
kinds, err := registerJobKinds(runner)
if err != nil {
return nil, nil, err
}
return runner, kinds, nil
}
// registerJobKinds registers every job kind's handler on runner. Each
// feature's job kinds are registered here.
func registerJobKinds(runner *jobs.Runner) (*jobKinds, error) {
builtins, err := jobs.RegisterBuiltins(runner)
if err != nil {
return nil, err
}
return &jobKinds{builtins: builtins}, nil
}
// logListening logs the startup line. Only the public URL's origin is logged
// so no credential-bearing URL component can reach the log.
func logListening(logger *slog.Logger, cfg config.Config) {
logger.Info("listening",
"addr", cfg.HTTPAddr,
"public_origin", cfg.PublicOrigin(),
"trusted_proxy", cfg.TrustedProxy,
"data_dir", cfg.DataDir,
"version", version,
)
}
+34
View File
@@ -0,0 +1,34 @@
package main
import (
"bytes"
"log/slog"
"net/url"
"strings"
"testing"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/config"
)
func TestLogListeningOmitsURLCredentials(t *testing.T) {
t.Parallel()
// Load rejects such URLs; this proves the log stays safe even if one
// reached the Config some other way.
u, err := url.Parse("https://alice:hunter2@meals.example.com/app?token=t0ken")
if err != nil {
t.Fatal(err)
}
var buf bytes.Buffer
logListening(slog.New(slog.NewJSONHandler(&buf, nil)), config.Config{HTTPAddr: ":8080", PublicURL: u})
out := buf.String()
for _, leak := range []string{"alice", "hunter2", "t0ken"} {
if strings.Contains(out, leak) {
t.Errorf("startup log leaks %q: %s", leak, out)
}
}
if !strings.Contains(out, `"public_origin":"https://meals.example.com"`) {
t.Errorf("startup log missing public origin: %s", out)
}
}
+138
View File
@@ -0,0 +1,138 @@
# MealQuest — Application Requirements
Everything decided while designing MealQuest, written so you can paste it into another LLM. Items marked **[Decided]** were confirmed by the client. Items marked **[Design default]** were proposed by the designer and not challenged; treat them as the current plan, but they can be changed.
---
## 1. Product summary
MealQuest is a **mobile-first Progressive Web App (PWA)** for weekly dinner planning for one household. The household enters the dinners they make regularly. An AI assistant suggests new dinners, which the household accepts or declines with a swipe interface like a dating app. The app builds a weekly dinner plan, pushes it to **Google Calendar**, and generates a **shopping list** for the planned dinners. Shopping lists are kept as a **blog-style history** you can browse by week, month or year. The app can also suggest dinners from a list of ingredients on hand, and offers **quick dinner** ideas. It sends **push notifications** and **Google Chat** messages through a webhook. An **admin page** configures the AI model (LLM).
## 2. Users & access
- [Decided] **One shared household account.** No per-person profiles.
- [Decided] **Anyone in the household can open everything**, including the Admin settings.
- [Design default] The household is shown as a single identity ("Our kitchen · Shared household"). Settings changes apply to everyone.
## 3. Platforms
- [Decided] **Mobile first**, delivered as a **PWA** (installable, with a service worker and app notifications).
- [Decided] **Tablet and desktop layouts** too. On larger screens the focus is **reading ingredients and recipes, managing the calendar, and updating settings**.
- [Design default] Breakpoints: mobile < 768px (bottom nav), tablet 768–1199px (96px icon rail), desktop ≥ 1200px (208px sidebar). Tablet is designed in landscape, for kitchen-counter use.
- [Design default] Works offline for the current plan, shopping list and recipes.
## 4. Navigation
- [Decided] Bottom nav on mobile, five items in this order: **Home · Plan · Discover · Quick · Settings**.
- [Decided] The **Admin page lives inside Settings**. It is not a nav tab.
- [Design default] The Shopping list and Shopping history are reached from Plan and Home (not tabs). The same five items appear in the tablet rail and the desktop sidebar.
## 5. Onboarding — regular dinners
- [Decided] The user enters the dinners the household routinely has ("regulars"). The AI uses these to come up with new suggestions.
- [Design default] Three steps; step 2 is "What's on your usual rotation?" with free-text entry, removable chips and suggested common dinners. Step 3 asks for notification permission.
- [Design default] Regulars can be edited later in Settings → Regular dinners.
## 6. AI dinner suggestions (Discover / swipe)
- [Decided] The AI suggests **new dinners to try** based on the household's regulars.
- [Decided] **Swipe right / "Try it" = accept; swipe left / "Pass" = decline.** Buttons and swipe gestures both work.
- [Decided] **Accepted suggestions go into a "to try" pool** (named the **Quest log**) and get scheduled later, not right away.
- [Decided] Each suggestion card shows: **photo**, **why it was suggested** (referencing the household's regulars), and **difficulty**.
- [Decided] Chosen layout: **stacked photo cards** (mockup 1a).
- [Design default] Declined suggestions are recorded so they aren't suggested again. A "Quest log · N" counter is shown on the screen. Suggestions arrive in batches; a push or Chat message announces new ones.
### Required animations [Decided]
- **Try it:** the card **wiggles, then flies off the right side** of the screen.
- **Pass:** the card **folds over and falls off to the left** of the screen.
- Exact timings and easing are in the developer handoff README. Summary: "Try it!" stamp pops in → ±4° wiggle (420ms) → small pull-back → fly right with a 28° tilt (480ms). Pass: folds on its left edge (rotateY to −80°, shading darkens) → drops and tumbles off left (720ms). The cards behind move forward afterward (300ms). Dragging past 110px triggers the same exits; otherwise the card springs back.
- [Design default] Respect the device's reduced-motion setting (simple fade instead).
## 7. Weekly plan
- [Decided] The **AI builds the week**, mixing **regulars and Quest-log meals**; the **user then adjusts** it (swaps days).
- [Decided] **No fixed rule** for how many new vs. regular dinners go in a week.
- [Decided] The plan covers **dinners** (one per day).
- [Decided] Chosen mobile layout: **draft board** (mockup 1f). It shows an AI note ("I mixed 5 of your regulars with 2 from your Quest log…"), a 2-column grid of day tiles with new dinners highlighted, tap a day to swap, and a "Looks good — push to Calendar" button.
- [Design default] Tablet and desktop: a 7-column week view; drag a dinner from the Quest log or Regulars panel onto a day to swap it in. Includes week navigation, "Rebuild week" and "Push to Calendar".
- [Design default] Crock-pot dinners show a "Start by [time]" hint.
## 8. Google Calendar
- [Decided] The weekly schedule is **placed in a Google Calendar**.
- [Decided] **The app is the source of truth and pushes to Google Calendar (one-way).** Edits made in Google Calendar do not come back into the app.
- [Design default] Connect once with Google sign-in (OAuth) in Settings → Google Calendar; one calendar event per dinner; sync status is shown ("Synced to Google Calendar 2 min ago").
## 9. Shopping list
- [Decided] Generated from the **ingredients needed for the dinners on the schedule**.
- [Decided] **Grouped by dinner** (not by store aisle).
- [Decided] Pantry / on-hand handling: **the user unchecks items they already have** on the list. There is **no separate pantry inventory**.
- [Design default] Checked = needs buying; unchecked = "have it" (struck through, left out of the count). Shows a count like "19 / 23" and can be sent to Google Chat.
## 10. Shopping history (blog format)
- [Decided] Shopping lists are shown in a **blog format**: **each weekly shopping list is a new post**.
- [Decided] Browse by **week / month / year** for historical reference.
- [Design default] Each post shows a week label ("Week 41 · Oct 5 – 11"), an AI-written title, the number of dinners and items, how many items were already on hand, and dinner chips. Past posts are read-only.
## 11. Cook from what I have (ingredients → ideas)
- [Decided] Generate **dinner ideas from a list of available ingredients**.
- [Design default] Ingredient chip input, an "Any time / Quick only" filter, and a "Find ideas" button. Results show how many of your ingredients each idea uses and what's missing, with "Cook tonight" and "Add to Quest log" actions.
## 12. Quick dinners
- [Decided] A way to generate **quick dinner ideas**. "Quick" means **cooked in a crock pot OR 30 minutes or less from prep to table**.
- [Decided] Available in **two places**: a **dedicated entry point** (Quick nav tab plus a Home shortcut) **and** as a **"Quick" filter** on suggestions / ingredient search.
- [Decided] Shown as **two tabs: Crock pot | 30 min or less**.
- [Design default] Each result shows time and difficulty, has a "+" to add to the Quest log, and a "Show me more" option.
## 13. Recipe / dinner detail
- [Decided] A recipe / dinner detail screen.
- [Design default] Photo, tags (Regular/Side quest, difficulty, time), "Why it was picked", ingredients with quantities, steps, "Add to this week", and a favorite (heart) button.
- [Design default] Tablet **kitchen reading mode**: large text, ingredient checkboxes ("3 of 9 set out"), current step highlighted, Previous/Next step buttons, a **Keep screen on** toggle and a text-size control.
## 14. Notifications (push + Google Chat)
- [Decided] The PWA sends **app (push) notifications**.
- [Decided] The app sends **messages to Google Chat using webhooks**.
- [Decided] Events that trigger messages: **New weekly plan ready · Shopping list ready · Day-of dinner reminder · New suggestions to swipe.**
- [Design default] Push and Chat can be switched on or off **separately for each event**. The day-of reminder time can be changed (default 4:30 pm). Settings include the webhook URL, connection status and a "Send test" button.
- [Design default] On iOS, push only works once the app is installed to the home screen, so show an install hint.
## 15. Admin — LLM configuration (inside Settings)
- [Decided] An admin page for configuring the LLM the app uses, including:
- **Provider and model picker**
- **API key management**
- **Temperature / creativity**
- **System prompt editor**
- [Design default] The API key is stored on the server and only the last 4 characters are shown, with a "Replace" button and "Verified" date. Temperature slider from 0 to 1, labeled "Familiar ↔ Adventurous". Reset and Save buttons.
- Not requested: usage/cost tracking, test playground, global dietary restrictions.
## 16. Settings home
- [Design default] Sections: Household (Regular dinners, Google Calendar connection), Alerts (Notifications & Google Chat), Admin (AI assistant). Desktop shows Notifications, Webhook and AI assistant together on one page.
## 17. Visual design
- [Decided] Design system: **Organic** (warm, rounded, playful; pill-shaped buttons and inputs; soft shadows; generous spacing; photos softened and slightly desaturated).
- [Decided] Color palette **"B4": Grape + teal blue on an off-white background.**
- Background `#f9f6f1`, surface `#eee9de`, text `#201e1d`
- Primary (Grape) `#5a358c`; text on grape is always light `#f9f6f1`
- Secondary (Teal blue) `#29889c`
- Full light-to-dark scales are in the handoff `tokens.css`.
- [Decided] Heading font: **Bagel Fat One** (Google Fonts). Body font: **Figtree**.
- [Design default] Lucide icons with heavy strokes (2.75).
- [Decided] **Copy tone: playful and quest-themed.** Terms: *Regulars* (routine dinners), *Side quests* (AI suggestions), *Quest log* (to-try pool), *Supply run* (current shopping list), *Supply archive* (history).
## 18. Navigation animation
- [Decided] The **purple pill in the bottom nav slides to the selected menu item**.
- [Design default] 420ms with a slight overshoot; the other icons glide to their new spots; the label fades in after 120ms.
## 19. Accessibility [Design default]
- Swiping is never the only way to act: there are always buttons, and keyboard ←/→ arrows work.
- Text contrast of at least 4.5:1; tap targets of at least 44px; toggles and checkboxes are announced correctly to screen readers.
## 20. Suggested tech stack [Design default]
React + TypeScript + Vite, vite-plugin-pwa, React Router, TanStack Query, Web Animations API or Framer Motion, lucide-react. The backend handles LLM calls, Google Calendar sign-in and sync, Google Chat webhook posts, and push notifications.
## 21. Core data (summary)
- **Dinner**: name, photo, minutes, method (stovetop/oven/crock pot/other), difficulty, ingredients (name and quantity), steps, source (regular or suggestion).
- **Suggestion**: dinner, reason ("why this?"), status (pending/accepted/declined).
- **Week plan**: week start date, one dinner per day (flagged if new), status (draft/synced), last calendar sync time.
- **Shopping list**: week, items grouped by dinner (each marked needed or have-it), when it was sent to Chat, AI-written post title.
- **Settings**: Push/Chat switch for each event, reminder time, webhook URL, calendar, AI settings (provider, model, temperature, system prompt, last 4 of API key, verified date).
- A dinner counts as quick if it is a crock-pot meal or takes 30 minutes or less.
## 22. Not yet designed / open items
- Empty, loading and error states (suggested patterns are in the handoff README).
- The swap picker for a mobile plan day (suggested: a bottom sheet listing the Quest log and Regulars).
- Settings → Household and Regular dinners management screens.
- Onboarding steps 1 and 3.
- A tablet version of Settings, and portrait tablet layouts.
Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

@@ -0,0 +1,862 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<helmet data-dc-atomics>
<meta name="design_doc_mode" content="canvas">
<link rel="stylesheet" href="_ds/organic-cd54d318-425e-4a60-adfb-d590f70d40bd/styles.css">
<style>body{margin:0;background:#e9e4dc;font-family:var(--font-body)}a{color:var(--color-accent-700)}a:hover{color:var(--color-accent-800)}</style>
</helmet>
<div style="padding:40px;display:flex;flex-direction:column;gap:28px">
<div style="font-family:var(--font-heading);font-size:36px;color:var(--color-text)">MealQuest · purple color schemes</div>
<div style="font-family:var(--font-heading);font-size:26px;color:var(--color-text);margin-top:8px">Round 2 · Purple + blue</div>
<div id="b1" style="--color-accent:oklch(0.5 0.13 310);--color-accent-100:oklch(0.97 0.030 310);--color-accent-200:oklch(0.93 0.060 310);--color-accent-300:oklch(0.86 0.090 310);--color-accent-400:oklch(0.77 0.120 310);--color-accent-500:oklch(0.67 0.140 310);--color-accent-600:oklch(0.57 0.140 310);--color-accent-700:oklch(0.47 0.130 310);--color-accent-800:oklch(0.37 0.110 310);--color-accent-900:oklch(0.27 0.080 310);--on-accent:var(--color-bg);--color-accent-2:oklch(0.62 0.11 235);--color-accent-2-100:oklch(0.97 0.030 235);--color-accent-2-200:oklch(0.93 0.060 235);--color-accent-2-300:oklch(0.86 0.090 235);--color-accent-2-400:oklch(0.77 0.120 235);--color-accent-2-500:oklch(0.67 0.140 235);--color-accent-2-600:oklch(0.57 0.140 235);--color-accent-2-700:oklch(0.47 0.130 235);--color-accent-2-800:oklch(0.37 0.110 235);--color-accent-2-900:oklch(0.27 0.080 235);padding:32px 40px;border-radius:40px;background:var(--color-bg);display:flex;flex-direction:column;gap:20px;color:var(--color-text);font-family:var(--font-body)">
<div style="display:flex;align-items:center;gap:20px;flex-wrap:wrap"><div style="display:flex;flex-direction:column;gap:4px;min-width:280px"><div style="font-family:var(--font-heading);font-size:28px;line-height:1.15;white-space:nowrap">B1 · Plum + sky blue · cream</div><div style="font-size:14px;color:var(--color-neutral-800)">Soft, airy blue; the warm cream keeps it friendly.</div></div><div style="display:flex;gap:8px"><span style="width:44px;height:44px;border-radius:50%;background:var(--color-bg);box-shadow:inset 0 0 0 1px var(--color-divider)"></span><span style="width:44px;height:44px;border-radius:50%;background:var(--color-surface);box-shadow:inset 0 0 0 1px var(--color-divider)"></span><span style="width:44px;height:44px;border-radius:50%;background:var(--color-accent);box-shadow:inset 0 0 0 1px var(--color-divider)"></span><span style="width:44px;height:44px;border-radius:50%;background:var(--color-accent-2);box-shadow:inset 0 0 0 1px var(--color-divider)"></span><span style="width:44px;height:44px;border-radius:50%;background:var(--color-text);box-shadow:inset 0 0 0 1px var(--color-divider)"></span></div><div style="display:flex;gap:3px"><span style="width:18px;height:44px;border-radius:999px;background:var(--color-accent-100)"></span><span style="width:18px;height:44px;border-radius:999px;background:var(--color-accent-200)"></span><span style="width:18px;height:44px;border-radius:999px;background:var(--color-accent-300)"></span><span style="width:18px;height:44px;border-radius:999px;background:var(--color-accent-400)"></span><span style="width:18px;height:44px;border-radius:999px;background:var(--color-accent-500)"></span><span style="width:18px;height:44px;border-radius:999px;background:var(--color-accent-600)"></span><span style="width:18px;height:44px;border-radius:999px;background:var(--color-accent-700)"></span><span style="width:18px;height:44px;border-radius:999px;background:var(--color-accent-800)"></span><span style="width:18px;height:44px;border-radius:999px;background:var(--color-accent-900)"></span></div><div style="display:flex;gap:3px"><span style="width:18px;height:44px;border-radius:999px;background:var(--color-accent-2-100)"></span><span style="width:18px;height:44px;border-radius:999px;background:var(--color-accent-2-200)"></span><span style="width:18px;height:44px;border-radius:999px;background:var(--color-accent-2-300)"></span><span style="width:18px;height:44px;border-radius:999px;background:var(--color-accent-2-400)"></span><span style="width:18px;height:44px;border-radius:999px;background:var(--color-accent-2-500)"></span><span style="width:18px;height:44px;border-radius:999px;background:var(--color-accent-2-600)"></span><span style="width:18px;height:44px;border-radius:999px;background:var(--color-accent-2-700)"></span><span style="width:18px;height:44px;border-radius:999px;background:var(--color-accent-2-800)"></span><span style="width:18px;height:44px;border-radius:999px;background:var(--color-accent-2-900)"></span></div></div>
<div style="display:flex;gap:28px;flex-wrap:wrap"><div style="display:flex;flex-direction:column;gap:8px"><div style="font-size:12px;font-weight:700;color:var(--color-neutral-700)">Home</div><div style="width:360px;height:760px;position:relative;border-radius:36px;background:var(--color-bg);overflow:hidden;box-shadow:var(--shadow-md)">
<div style="height:40px;display:flex;align-items:center;justify-content:space-between;padding:0 26px;font-size:13px;font-weight:700"><span>9:41</span><span style="width:22px;height:10px;border-radius:3px;background:var(--color-text)"></span></div>
<div style="padding:6px 20px 0;display:flex;justify-content:space-between;align-items:flex-start">
<div><div style="font-size:12px;font-weight:700;color:var(--color-neutral-700)">Friday, Oct 2</div><div style="font-family:var(--font-heading);font-size:28px;line-height:1.1">Tonight's quest</div></div>
<div style="position:relative;width:40px;height:40px;border-radius:50%;background:var(--color-surface);display:flex;align-items:center;justify-content:center"><svg viewBox="0 0 24 24" width="19" height="19" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M10.268 21a2 2 0 0 0 3.464 0"></path><path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326"></path></svg><span style="position:absolute;top:8px;right:9px;width:8px;height:8px;border-radius:50%;background:var(--color-accent);color:var(--on-accent)"></span></div>
</div>
<div style="margin:14px 20px 0;border-radius:32px;background:var(--color-surface);overflow:hidden;display:flex;flex-direction:column">
<div style="height:150px;margin:10px;border-radius:24px;background:repeating-linear-gradient(135deg,var(--color-neutral-300) 0 8px,var(--color-neutral-200) 8px 16px);display:flex;align-items:center;justify-content:center;font:11px ui-monospace,Menlo,monospace;color:var(--color-neutral-700)">dish photo</div>
<div style="padding:2px 18px 16px;display:flex;align-items:flex-end;justify-content:space-between;gap:10px"><div><div style="font-family:var(--font-heading);font-size:22px;line-height:1.1">Pizza night</div><div style="font-size:12px;color:var(--color-neutral-700);margin-top:4px">Regular · 40 min</div></div><div style="height:38px;padding:0 16px;border-radius:999px;background:var(--color-text);color:var(--color-bg);display:flex;align-items:center;font-family:var(--font-heading);font-size:13px">Recipe</div></div>
</div>
<div style="margin:12px 20px 0;display:grid;grid-template-columns:1fr 1fr;gap:10px">
<div style="height:104px;padding:14px;border-radius:26px;background:var(--color-accent);color:var(--on-accent);display:flex;flex-direction:column;justify-content:space-between"><svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z"></path></svg><span style="font-family:var(--font-heading);font-size:16px;line-height:1.1">Quick dinner</span></div>
<div style="height:104px;padding:14px;border-radius:26px;background:var(--color-accent-2-300);color:var(--color-accent-2-900);display:flex;flex-direction:column;justify-content:space-between"><svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><circle cx="11" cy="11" r="8"></circle><path d="m21 21-4.3-4.3"></path></svg><span style="font-family:var(--font-heading);font-size:16px;line-height:1.1">Cook from what I have</span></div>
</div>
<div style="margin:12px 20px 0;display:flex;flex-direction:column;gap:8px">
<div style="display:flex;align-items:center;gap:12px;padding:12px 14px;border-radius:20px;background:var(--color-neutral-100)"><svg viewBox="0 0 24 24" width="18" height="18" style="color:var(--color-accent-700);fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><circle cx="12" cy="12" r="10"></circle><path d="m16.24 7.76-1.804 5.411a2 2 0 0 1-1.265 1.265L7.76 16.24l1.804-5.411a2 2 0 0 1 1.265-1.265z"></path></svg><span style="flex:1;font-size:13px;font-weight:700">10 new side quests to swipe</span><svg viewBox="0 0 24 24" width="16" height="16" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="m9 18 6-6-6-6"></path></svg></div>
<div style="display:flex;align-items:center;gap:12px;padding:12px 14px;border-radius:20px;background:var(--color-neutral-100)"><svg viewBox="0 0 24 24" width="18" height="18" style="color:var(--color-accent-700);fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><circle cx="8" cy="21" r="1"></circle><circle cx="19" cy="21" r="1"></circle><path d="M2.05 2.05h2l2.66 12.42a2 2 0 0 0 2 1.58h9.78a2 2 0 0 0 1.95-1.57l1.65-7.43H5.12"></path></svg><span style="flex:1;font-size:13px;font-weight:700">Shopping list ready · Oct 5–11</span><svg viewBox="0 0 24 24" width="16" height="16" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="m9 18 6-6-6-6"></path></svg></div>
</div>
<div style="position:absolute;left:14px;right:14px;bottom:16px;height:62px;border-radius:999px;background:var(--color-text);display:flex;align-items:center;justify-content:space-around;color:var(--color-neutral-400)">
<div style="display:flex;align-items:center;gap:6px;padding:10px 14px;border-radius:999px;background:var(--color-accent);color:var(--on-accent);font-size:13px;font-weight:700"><svg viewBox="0 0 24 24" width="20" height="20" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"></path><path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path></svg>Home</div>
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M8 2v4"></path><path d="M16 2v4"></path><rect width="18" height="18" x="3" y="4" rx="2"></rect><path d="M3 10h18"></path></svg>
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><circle cx="12" cy="12" r="10"></circle><path d="m16.24 7.76-1.804 5.411a2 2 0 0 1-1.265 1.265L7.76 16.24l1.804-5.411a2 2 0 0 1 1.265-1.265z"></path></svg>
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M10 5H3"></path><path d="M12 19H3"></path><path d="M14 3v4"></path><path d="M16 17v4"></path><path d="M21 12h-9"></path><path d="M21 19h-5"></path><path d="M21 5h-7"></path><path d="M8 10v4"></path><path d="M8 12H3"></path></svg>
</div>
</div></div><div style="display:flex;flex-direction:column;gap:8px"><div style="font-size:12px;font-weight:700;color:var(--color-neutral-700)">Discover</div><div style="width:360px;height:760px;position:relative;border-radius:36px;background:var(--color-bg);overflow:hidden;box-shadow:var(--shadow-md)">
<div style="height:40px;display:flex;align-items:center;justify-content:space-between;padding:0 26px;font-size:13px;font-weight:700"><span>9:41</span><span style="width:22px;height:10px;border-radius:3px;background:var(--color-text)"></span></div>
<div style="padding:8px 20px 0;display:flex;align-items:flex-end;justify-content:space-between">
<div><div style="font-size:11px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:var(--color-accent-700)">Discover</div><div style="font-family:var(--font-heading);font-size:30px;line-height:1.1">Side quests</div></div>
<div style="display:flex;align-items:center;gap:6px;padding:7px 12px;border-radius:999px;background:var(--color-accent-2-200);color:var(--color-accent-2-800);font-size:12px;font-weight:700">Quest log · 6</div>
</div>
<div style="position:relative;margin:18px 20px 0;height:440px">
<div style="position:absolute;inset:0;transform:translateY(16px) scale(.9);border-radius:32px;background:var(--color-neutral-300)"></div>
<div style="position:absolute;inset:0;transform:translateY(8px) scale(.95);border-radius:32px;background:var(--color-surface)"></div>
<div style="position:absolute;inset:0;border-radius:32px;background:var(--color-neutral-100);box-shadow:var(--shadow-lg);overflow:hidden;display:flex;flex-direction:column">
<div style="height:220px;margin:10px;border-radius:24px;background:repeating-linear-gradient(135deg,var(--color-neutral-300) 0 8px,var(--color-neutral-200) 8px 16px);display:flex;align-items:center;justify-content:center;font:11px ui-monospace,Menlo,monospace;color:var(--color-neutral-700)">dish photo</div>
<div style="padding:4px 20px 0;display:flex;flex-direction:column;gap:10px">
<div style="display:flex;align-items:center;justify-content:space-between"><div style="font-family:var(--font-heading);font-size:24px;line-height:1.1">Korean beef bowls</div></div>
<div style="display:flex;align-items:center;gap:8px;font-size:12px;font-weight:700;color:var(--color-neutral-700)"><span style="display:flex;gap:3px"><span style="width:8px;height:8px;border-radius:50%;background:var(--color-accent);color:var(--on-accent)"></span><span style="width:8px;height:8px;border-radius:50%;background:var(--color-neutral-300)"></span><span style="width:8px;height:8px;border-radius:50%;background:var(--color-neutral-300)"></span></span>Easy</div>
<div style="padding:12px 14px;border-radius:18px;background:var(--color-accent-2-100);color:var(--color-accent-2-900);font-size:13px;line-height:1.45"><b>Why this?</b> You make Chicken stir-fry and Beef tacos most weeks. Same flavors, sweet-savory twist.</div>
</div>
</div>
</div>
<div style="display:flex;justify-content:center;gap:28px;margin-top:22px">
<div style="display:flex;flex-direction:column;align-items:center;gap:6px;font-size:11px;font-weight:700;color:var(--color-neutral-700)"><div style="width:62px;height:62px;border-radius:50%;background:var(--color-neutral-100);box-shadow:var(--shadow-md);display:flex;align-items:center;justify-content:center;color:var(--color-text)"><svg viewBox="0 0 24 24" width="26" height="26" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M18 6 6 18"></path><path d="m6 6 12 12"></path></svg></div>Pass</div>
<div style="display:flex;flex-direction:column;align-items:center;gap:6px;font-size:11px;font-weight:700;color:var(--color-neutral-700)"><div style="width:62px;height:62px;border-radius:50%;background:var(--color-accent-2-600);box-shadow:var(--shadow-md);display:flex;align-items:center;justify-content:center;color:var(--color-bg)"><svg viewBox="0 0 24 24" width="26" height="26" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z"></path></svg></div>Try it</div>
</div>
<div style="position:absolute;left:14px;right:14px;bottom:16px;height:62px;border-radius:999px;background:var(--color-text);display:flex;align-items:center;justify-content:space-around;color:var(--color-neutral-400)">
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"></path><path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path></svg>
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M8 2v4"></path><path d="M16 2v4"></path><rect width="18" height="18" x="3" y="4" rx="2"></rect><path d="M3 10h18"></path></svg>
<div style="display:flex;align-items:center;gap:6px;padding:10px 14px;border-radius:999px;background:var(--color-accent);color:var(--on-accent);font-size:13px;font-weight:700"><svg viewBox="0 0 24 24" width="20" height="20" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><circle cx="12" cy="12" r="10"></circle><path d="m16.24 7.76-1.804 5.411a2 2 0 0 1-1.265 1.265L7.76 16.24l1.804-5.411a2 2 0 0 1 1.265-1.265z"></path></svg>Discover</div>
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M10 5H3"></path><path d="M12 19H3"></path><path d="M14 3v4"></path><path d="M16 17v4"></path><path d="M21 12h-9"></path><path d="M21 19h-5"></path><path d="M21 5h-7"></path><path d="M8 10v4"></path><path d="M8 12H3"></path></svg>
</div>
</div></div><div style="display:flex;flex-direction:column;gap:8px"><div style="font-size:12px;font-weight:700;color:var(--color-neutral-700)">Weekly plan</div><div style="width:360px;height:760px;position:relative;border-radius:36px;background:var(--color-bg);overflow:hidden;box-shadow:var(--shadow-md)">
<div style="height:40px;display:flex;align-items:center;justify-content:space-between;padding:0 26px;font-size:13px;font-weight:700"><span>9:41</span><span style="width:22px;height:10px;border-radius:3px;background:var(--color-text)"></span></div>
<div style="padding:6px 20px 0;display:flex;align-items:center;justify-content:space-between">
<div><div style="font-size:11px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:var(--color-accent-700)">Week 41</div><div style="font-family:var(--font-heading);font-size:26px;line-height:1.1">Oct 5 – 11</div></div>
<div style="display:flex;gap:6px"><div style="width:36px;height:36px;border-radius:50%;background:var(--color-surface);display:flex;align-items:center;justify-content:center"><svg viewBox="0 0 24 24" width="18" height="18" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="m15 18-6-6 6-6"></path></svg></div><div style="width:36px;height:36px;border-radius:50%;background:var(--color-surface);display:flex;align-items:center;justify-content:center"><svg viewBox="0 0 24 24" width="18" height="18" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="m9 18 6-6-6-6"></path></svg></div></div>
</div>
<div style="margin:14px 20px 0;padding:10px 14px;border-radius:18px;background:var(--color-accent-2-100);color:var(--color-accent-2-900);font-size:12px;line-height:1.4;display:flex;gap:8px;align-items:center"><svg viewBox="0 0 24 24" width="16" height="16" style="flex:none;fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M20 6 9 17l-5-5"></path></svg><span>5 regulars + 2 side quests · synced to Google Calendar</span></div>
<div style="margin:12px 20px 0;display:flex;flex-direction:column;gap:6px">
<div style="display:flex;align-items:center;gap:12px;padding:9px 12px;border-radius:18px;background:var(--color-surface)"><div style="width:36px;text-align:center;font-size:11px;font-weight:700;color:var(--color-neutral-700);line-height:1.1">MON<br><span style="font-size:16px;color:var(--color-text)">5</span></div><div style="flex:1;font-weight:700;font-size:14px">Beef tacos</div><span style="padding:3px 9px;border-radius:999px;background:var(--color-neutral-100);color:var(--color-neutral-800);font-size:10px;font-weight:700">Regular</span><svg viewBox="0 0 24 24" width="16" height="16" style="color:var(--color-neutral-600);fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"></path><path d="M21 3v5h-5"></path><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"></path><path d="M8 16H3v5"></path></svg></div>
<div style="display:flex;align-items:center;gap:12px;padding:9px 12px;border-radius:18px;background:var(--color-surface)"><div style="width:36px;text-align:center;font-size:11px;font-weight:700;color:var(--color-neutral-700);line-height:1.1">TUE<br><span style="font-size:16px;color:var(--color-text)">6</span></div><div style="flex:1;font-weight:700;font-size:14px">Spaghetti bolognese</div><span style="padding:3px 9px;border-radius:999px;background:var(--color-neutral-100);color:var(--color-neutral-800);font-size:10px;font-weight:700">Regular</span><svg viewBox="0 0 24 24" width="16" height="16" style="color:var(--color-neutral-600);fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"></path><path d="M21 3v5h-5"></path><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"></path><path d="M8 16H3v5"></path></svg></div>
<div style="display:flex;align-items:center;gap:12px;padding:9px 12px;border-radius:18px;background:var(--color-accent-100);box-shadow:inset 0 0 0 1.5px var(--color-accent-300)"><div style="width:36px;text-align:center;font-size:11px;font-weight:700;color:var(--color-accent-700);line-height:1.1">WED<br><span style="font-size:16px;color:var(--color-text)">7</span></div><div style="flex:1;font-weight:700;font-size:14px">Crock pot tikka masala</div><span style="padding:3px 9px;border-radius:999px;background:var(--color-accent-200);color:var(--color-accent-800);font-size:10px;font-weight:700">Side quest</span><svg viewBox="0 0 24 24" width="16" height="16" style="color:var(--color-neutral-600);fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"></path><path d="M21 3v5h-5"></path><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"></path><path d="M8 16H3v5"></path></svg></div>
<div style="display:flex;align-items:center;gap:12px;padding:9px 12px;border-radius:18px;background:var(--color-surface)"><div style="width:36px;text-align:center;font-size:11px;font-weight:700;color:var(--color-neutral-700);line-height:1.1">THU<br><span style="font-size:16px;color:var(--color-text)">8</span></div><div style="flex:1;font-weight:700;font-size:14px">Chicken stir-fry</div><span style="padding:3px 9px;border-radius:999px;background:var(--color-neutral-100);color:var(--color-neutral-800);font-size:10px;font-weight:700">Regular</span><svg viewBox="0 0 24 24" width="16" height="16" style="color:var(--color-neutral-600);fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"></path><path d="M21 3v5h-5"></path><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"></path><path d="M8 16H3v5"></path></svg></div>
<div style="display:flex;align-items:center;gap:12px;padding:9px 12px;border-radius:18px;background:var(--color-surface)"><div style="width:36px;text-align:center;font-size:11px;font-weight:700;color:var(--color-neutral-700);line-height:1.1">FRI<br><span style="font-size:16px;color:var(--color-text)">9</span></div><div style="flex:1;font-weight:700;font-size:14px">Pizza night</div><span style="padding:3px 9px;border-radius:999px;background:var(--color-neutral-100);color:var(--color-neutral-800);font-size:10px;font-weight:700">Regular</span><svg viewBox="0 0 24 24" width="16" height="16" style="color:var(--color-neutral-600);fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"></path><path d="M21 3v5h-5"></path><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"></path><path d="M8 16H3v5"></path></svg></div>
<div style="display:flex;align-items:center;gap:12px;padding:9px 12px;border-radius:18px;background:var(--color-accent-100);box-shadow:inset 0 0 0 1.5px var(--color-accent-300)"><div style="width:36px;text-align:center;font-size:11px;font-weight:700;color:var(--color-accent-700);line-height:1.1">SAT<br><span style="font-size:16px;color:var(--color-text)">10</span></div><div style="flex:1;font-weight:700;font-size:14px">Korean beef bowls</div><span style="padding:3px 9px;border-radius:999px;background:var(--color-accent-200);color:var(--color-accent-800);font-size:10px;font-weight:700">Side quest</span><svg viewBox="0 0 24 24" width="16" height="16" style="color:var(--color-neutral-600);fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"></path><path d="M21 3v5h-5"></path><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"></path><path d="M8 16H3v5"></path></svg></div>
<div style="display:flex;align-items:center;gap:12px;padding:9px 12px;border-radius:18px;background:var(--color-surface)"><div style="width:36px;text-align:center;font-size:11px;font-weight:700;color:var(--color-neutral-700);line-height:1.1">SUN<br><span style="font-size:16px;color:var(--color-text)">11</span></div><div style="flex:1;font-weight:700;font-size:14px">Sheet-pan salmon</div><span style="padding:3px 9px;border-radius:999px;background:var(--color-neutral-100);color:var(--color-neutral-800);font-size:10px;font-weight:700">Regular</span><svg viewBox="0 0 24 24" width="16" height="16" style="color:var(--color-neutral-600);fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"></path><path d="M21 3v5h-5"></path><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"></path><path d="M8 16H3v5"></path></svg></div>
</div>
<div style="margin:14px 20px 0;height:50px;border-radius:999px;background:var(--color-accent);display:flex;align-items:center;justify-content:center;gap:8px;font-family:var(--font-heading);font-size:15px;color:var(--on-accent)"><svg viewBox="0 0 24 24" width="18" height="18" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><circle cx="8" cy="21" r="1"></circle><circle cx="19" cy="21" r="1"></circle><path d="M2.05 2.05h2l2.66 12.42a2 2 0 0 0 2 1.58h9.78a2 2 0 0 0 1.95-1.57l1.65-7.43H5.12"></path></svg>Shopping list · 23 items</div>
<div style="position:absolute;left:14px;right:14px;bottom:16px;height:62px;border-radius:999px;background:var(--color-text);display:flex;align-items:center;justify-content:space-around;color:var(--color-neutral-400)">
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"></path><path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path></svg>
<div style="display:flex;align-items:center;gap:6px;padding:10px 14px;border-radius:999px;background:var(--color-accent);color:var(--on-accent);font-size:13px;font-weight:700"><svg viewBox="0 0 24 24" width="20" height="20" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M8 2v4"></path><path d="M16 2v4"></path><rect width="18" height="18" x="3" y="4" rx="2"></rect><path d="M3 10h18"></path></svg>Plan</div>
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><circle cx="12" cy="12" r="10"></circle><path d="m16.24 7.76-1.804 5.411a2 2 0 0 1-1.265 1.265L7.76 16.24l1.804-5.411a2 2 0 0 1 1.265-1.265z"></path></svg>
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M10 5H3"></path><path d="M12 19H3"></path><path d="M14 3v4"></path><path d="M16 17v4"></path><path d="M21 12h-9"></path><path d="M21 19h-5"></path><path d="M21 5h-7"></path><path d="M8 10v4"></path><path d="M8 12H3"></path></svg>
</div>
</div></div><div style="display:flex;flex-direction:column;gap:8px"><div style="font-size:12px;font-weight:700;color:var(--color-neutral-700)">Shopping list</div><div style="width:360px;height:760px;position:relative;border-radius:36px;background:var(--color-bg);overflow:hidden;box-shadow:var(--shadow-md)">
<div style="height:40px;display:flex;align-items:center;justify-content:space-between;padding:0 26px;font-size:13px;font-weight:700"><span>9:41</span><span style="width:22px;height:10px;border-radius:3px;background:var(--color-text)"></span></div>
<div style="padding:6px 20px 0;display:flex;justify-content:space-between;align-items:flex-end"><div><div style="font-size:11px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:var(--color-accent-700)">Supply run · Oct 5–11</div><div style="font-family:var(--font-heading);font-size:28px;line-height:1.1">Shopping list</div></div><div style="font-size:13px;font-weight:700;color:var(--color-neutral-700)">19 / 23</div></div>
<div style="margin:10px 20px 0;font-size:12px;line-height:1.45;color:var(--color-neutral-800)">Uncheck anything you already have — it won't be counted.</div>
<div style="padding:12px 20px 0;display:flex;flex-direction:column;gap:12px">
<div style="padding:14px;border-radius:24px;background:var(--color-surface);display:flex;flex-direction:column;gap:9px">
<div style="display:flex;justify-content:space-between;align-items:baseline"><span style="font-family:var(--font-heading);font-size:16px">Beef tacos</span><span style="font-size:11px;font-weight:700;color:var(--color-neutral-700)">MON</span></div>
<div style="display:flex;align-items:center;gap:10px;font-size:14px"><span style="width:20px;height:20px;border-radius:7px;background:var(--color-accent);color:var(--on-accent);display:flex;align-items:center;justify-content:center"><svg viewBox="0 0 24 24" width="13" height="13" style="fill:none;stroke:currentColor;stroke-width:3.5;stroke-linecap:round;stroke-linejoin:round"><path d="M20 6 9 17l-5-5"></path></svg></span><span style="flex:1">Ground beef</span><span style="color:var(--color-neutral-700);font-size:12px">1 lb</span></div>
<div style="display:flex;align-items:center;gap:10px;font-size:14px"><span style="width:20px;height:20px;border-radius:7px;background:var(--color-accent);color:var(--on-accent);display:flex;align-items:center;justify-content:center"><svg viewBox="0 0 24 24" width="13" height="13" style="fill:none;stroke:currentColor;stroke-width:3.5;stroke-linecap:round;stroke-linejoin:round"><path d="M20 6 9 17l-5-5"></path></svg></span><span style="flex:1">Corn tortillas</span><span style="color:var(--color-neutral-700);font-size:12px">12</span></div>
<div style="display:flex;align-items:center;gap:10px;font-size:14px;color:var(--color-neutral-600)"><span style="width:20px;height:20px;border-radius:7px;border:2px solid var(--color-neutral-400)"></span><span style="flex:1;text-decoration:line-through">Taco seasoning</span><span style="font-size:11px;font-weight:700">have it</span></div>
</div>
<div style="padding:14px;border-radius:24px;background:var(--color-surface);display:flex;flex-direction:column;gap:9px">
<div style="display:flex;justify-content:space-between;align-items:baseline"><span style="font-family:var(--font-heading);font-size:16px">Crock pot tikka masala</span><span style="font-size:11px;font-weight:700;color:var(--color-neutral-700)">WED</span></div>
<div style="display:flex;align-items:center;gap:10px;font-size:14px"><span style="width:20px;height:20px;border-radius:7px;background:var(--color-accent);color:var(--on-accent);display:flex;align-items:center;justify-content:center"><svg viewBox="0 0 24 24" width="13" height="13" style="fill:none;stroke:currentColor;stroke-width:3.5;stroke-linecap:round;stroke-linejoin:round"><path d="M20 6 9 17l-5-5"></path></svg></span><span style="flex:1">Chicken thighs</span><span style="color:var(--color-neutral-700);font-size:12px">2 lb</span></div>
<div style="display:flex;align-items:center;gap:10px;font-size:14px"><span style="width:20px;height:20px;border-radius:7px;background:var(--color-accent);color:var(--on-accent);display:flex;align-items:center;justify-content:center"><svg viewBox="0 0 24 24" width="13" height="13" style="fill:none;stroke:currentColor;stroke-width:3.5;stroke-linecap:round;stroke-linejoin:round"><path d="M20 6 9 17l-5-5"></path></svg></span><span style="flex:1">Tikka masala paste</span><span style="color:var(--color-neutral-700);font-size:12px">1 jar</span></div>
<div style="display:flex;align-items:center;gap:10px;font-size:14px;color:var(--color-neutral-600)"><span style="width:20px;height:20px;border-radius:7px;border:2px solid var(--color-neutral-400)"></span><span style="flex:1;text-decoration:line-through">Basmati rice</span><span style="font-size:11px;font-weight:700">have it</span></div>
</div>
<div style="padding:14px;border-radius:24px;background:var(--color-surface);display:flex;justify-content:space-between;align-items:baseline"><span style="font-family:var(--font-heading);font-size:16px">Chicken stir-fry</span><span style="font-size:11px;font-weight:700;color:var(--color-neutral-700)">THU · 5 items</span></div>
</div>
<div style="position:absolute;left:20px;right:20px;bottom:96px;display:flex;align-items:center;gap:8px;font-size:12px;font-weight:700;color:var(--color-accent-2-800)"><svg viewBox="0 0 24 24" width="15" height="15" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path></svg>Sent to Google Chat · Sat 8:00 am</div>
<div style="position:absolute;left:14px;right:14px;bottom:16px;height:62px;border-radius:999px;background:var(--color-text);display:flex;align-items:center;justify-content:space-around;color:var(--color-neutral-400)">
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"></path><path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path></svg>
<div style="display:flex;align-items:center;gap:6px;padding:10px 14px;border-radius:999px;background:var(--color-accent);color:var(--on-accent);font-size:13px;font-weight:700"><svg viewBox="0 0 24 24" width="20" height="20" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M8 2v4"></path><path d="M16 2v4"></path><rect width="18" height="18" x="3" y="4" rx="2"></rect><path d="M3 10h18"></path></svg>Plan</div>
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><circle cx="12" cy="12" r="10"></circle><path d="m16.24 7.76-1.804 5.411a2 2 0 0 1-1.265 1.265L7.76 16.24l1.804-5.411a2 2 0 0 1 1.265-1.265z"></path></svg>
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M10 5H3"></path><path d="M12 19H3"></path><path d="M14 3v4"></path><path d="M16 17v4"></path><path d="M21 12h-9"></path><path d="M21 19h-5"></path><path d="M21 5h-7"></path><path d="M8 10v4"></path><path d="M8 12H3"></path></svg>
</div>
</div></div></div>
</div>
<div id="b2" style="--color-bg:oklch(0.975 0.008 85);--color-surface:oklch(0.935 0.016 85);--color-accent:oklch(0.5 0.13 310);--color-accent-100:oklch(0.97 0.030 310);--color-accent-200:oklch(0.93 0.060 310);--color-accent-300:oklch(0.86 0.090 310);--color-accent-400:oklch(0.77 0.120 310);--color-accent-500:oklch(0.67 0.140 310);--color-accent-600:oklch(0.57 0.140 310);--color-accent-700:oklch(0.47 0.130 310);--color-accent-800:oklch(0.37 0.110 310);--color-accent-900:oklch(0.27 0.080 310);--on-accent:var(--color-bg);--color-accent-2:oklch(0.58 0.07 255);--color-accent-2-100:oklch(0.97 0.018 255);--color-accent-2-200:oklch(0.93 0.036 255);--color-accent-2-300:oklch(0.86 0.054 255);--color-accent-2-400:oklch(0.77 0.072 255);--color-accent-2-500:oklch(0.67 0.084 255);--color-accent-2-600:oklch(0.57 0.084 255);--color-accent-2-700:oklch(0.47 0.078 255);--color-accent-2-800:oklch(0.37 0.066 255);--color-accent-2-900:oklch(0.27 0.048 255);padding:32px 40px;border-radius:40px;background:var(--color-bg);display:flex;flex-direction:column;gap:20px;color:var(--color-text);font-family:var(--font-body)">
<div style="display:flex;align-items:center;gap:20px;flex-wrap:wrap"><div style="display:flex;flex-direction:column;gap:4px;min-width:280px"><div style="font-family:var(--font-heading);font-size:28px;line-height:1.15;white-space:nowrap">B2 · Plum + slate blue · off-white</div><div style="font-size:14px;color:var(--color-neutral-800)">Muted, grown-up blue on a cleaner ground.</div></div><div style="display:flex;gap:8px"><span style="width:44px;height:44px;border-radius:50%;background:var(--color-bg);box-shadow:inset 0 0 0 1px var(--color-divider)"></span><span style="width:44px;height:44px;border-radius:50%;background:var(--color-surface);box-shadow:inset 0 0 0 1px var(--color-divider)"></span><span style="width:44px;height:44px;border-radius:50%;background:var(--color-accent);box-shadow:inset 0 0 0 1px var(--color-divider)"></span><span style="width:44px;height:44px;border-radius:50%;background:var(--color-accent-2);box-shadow:inset 0 0 0 1px var(--color-divider)"></span><span style="width:44px;height:44px;border-radius:50%;background:var(--color-text);box-shadow:inset 0 0 0 1px var(--color-divider)"></span></div><div style="display:flex;gap:3px"><span style="width:18px;height:44px;border-radius:999px;background:var(--color-accent-100)"></span><span style="width:18px;height:44px;border-radius:999px;background:var(--color-accent-200)"></span><span style="width:18px;height:44px;border-radius:999px;background:var(--color-accent-300)"></span><span style="width:18px;height:44px;border-radius:999px;background:var(--color-accent-400)"></span><span style="width:18px;height:44px;border-radius:999px;background:var(--color-accent-500)"></span><span style="width:18px;height:44px;border-radius:999px;background:var(--color-accent-600)"></span><span style="width:18px;height:44px;border-radius:999px;background:var(--color-accent-700)"></span><span style="width:18px;height:44px;border-radius:999px;background:var(--color-accent-800)"></span><span style="width:18px;height:44px;border-radius:999px;background:var(--color-accent-900)"></span></div><div style="display:flex;gap:3px"><span style="width:18px;height:44px;border-radius:999px;background:var(--color-accent-2-100)"></span><span style="width:18px;height:44px;border-radius:999px;background:var(--color-accent-2-200)"></span><span style="width:18px;height:44px;border-radius:999px;background:var(--color-accent-2-300)"></span><span style="width:18px;height:44px;border-radius:999px;background:var(--color-accent-2-400)"></span><span style="width:18px;height:44px;border-radius:999px;background:var(--color-accent-2-500)"></span><span style="width:18px;height:44px;border-radius:999px;background:var(--color-accent-2-600)"></span><span style="width:18px;height:44px;border-radius:999px;background:var(--color-accent-2-700)"></span><span style="width:18px;height:44px;border-radius:999px;background:var(--color-accent-2-800)"></span><span style="width:18px;height:44px;border-radius:999px;background:var(--color-accent-2-900)"></span></div></div>
<div style="display:flex;gap:28px;flex-wrap:wrap"><div style="display:flex;flex-direction:column;gap:8px"><div style="font-size:12px;font-weight:700;color:var(--color-neutral-700)">Home</div><div style="width:360px;height:760px;position:relative;border-radius:36px;background:var(--color-bg);overflow:hidden;box-shadow:var(--shadow-md)">
<div style="height:40px;display:flex;align-items:center;justify-content:space-between;padding:0 26px;font-size:13px;font-weight:700"><span>9:41</span><span style="width:22px;height:10px;border-radius:3px;background:var(--color-text)"></span></div>
<div style="padding:6px 20px 0;display:flex;justify-content:space-between;align-items:flex-start">
<div><div style="font-size:12px;font-weight:700;color:var(--color-neutral-700)">Friday, Oct 2</div><div style="font-family:var(--font-heading);font-size:28px;line-height:1.1">Tonight's quest</div></div>
<div style="position:relative;width:40px;height:40px;border-radius:50%;background:var(--color-surface);display:flex;align-items:center;justify-content:center"><svg viewBox="0 0 24 24" width="19" height="19" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M10.268 21a2 2 0 0 0 3.464 0"></path><path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326"></path></svg><span style="position:absolute;top:8px;right:9px;width:8px;height:8px;border-radius:50%;background:var(--color-accent);color:var(--on-accent)"></span></div>
</div>
<div style="margin:14px 20px 0;border-radius:32px;background:var(--color-surface);overflow:hidden;display:flex;flex-direction:column">
<div style="height:150px;margin:10px;border-radius:24px;background:repeating-linear-gradient(135deg,var(--color-neutral-300) 0 8px,var(--color-neutral-200) 8px 16px);display:flex;align-items:center;justify-content:center;font:11px ui-monospace,Menlo,monospace;color:var(--color-neutral-700)">dish photo</div>
<div style="padding:2px 18px 16px;display:flex;align-items:flex-end;justify-content:space-between;gap:10px"><div><div style="font-family:var(--font-heading);font-size:22px;line-height:1.1">Pizza night</div><div style="font-size:12px;color:var(--color-neutral-700);margin-top:4px">Regular · 40 min</div></div><div style="height:38px;padding:0 16px;border-radius:999px;background:var(--color-text);color:var(--color-bg);display:flex;align-items:center;font-family:var(--font-heading);font-size:13px">Recipe</div></div>
</div>
<div style="margin:12px 20px 0;display:grid;grid-template-columns:1fr 1fr;gap:10px">
<div style="height:104px;padding:14px;border-radius:26px;background:var(--color-accent);color:var(--on-accent);display:flex;flex-direction:column;justify-content:space-between"><svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z"></path></svg><span style="font-family:var(--font-heading);font-size:16px;line-height:1.1">Quick dinner</span></div>
<div style="height:104px;padding:14px;border-radius:26px;background:var(--color-accent-2-300);color:var(--color-accent-2-900);display:flex;flex-direction:column;justify-content:space-between"><svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><circle cx="11" cy="11" r="8"></circle><path d="m21 21-4.3-4.3"></path></svg><span style="font-family:var(--font-heading);font-size:16px;line-height:1.1">Cook from what I have</span></div>
</div>
<div style="margin:12px 20px 0;display:flex;flex-direction:column;gap:8px">
<div style="display:flex;align-items:center;gap:12px;padding:12px 14px;border-radius:20px;background:var(--color-neutral-100)"><svg viewBox="0 0 24 24" width="18" height="18" style="color:var(--color-accent-700);fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><circle cx="12" cy="12" r="10"></circle><path d="m16.24 7.76-1.804 5.411a2 2 0 0 1-1.265 1.265L7.76 16.24l1.804-5.411a2 2 0 0 1 1.265-1.265z"></path></svg><span style="flex:1;font-size:13px;font-weight:700">10 new side quests to swipe</span><svg viewBox="0 0 24 24" width="16" height="16" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="m9 18 6-6-6-6"></path></svg></div>
<div style="display:flex;align-items:center;gap:12px;padding:12px 14px;border-radius:20px;background:var(--color-neutral-100)"><svg viewBox="0 0 24 24" width="18" height="18" style="color:var(--color-accent-700);fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><circle cx="8" cy="21" r="1"></circle><circle cx="19" cy="21" r="1"></circle><path d="M2.05 2.05h2l2.66 12.42a2 2 0 0 0 2 1.58h9.78a2 2 0 0 0 1.95-1.57l1.65-7.43H5.12"></path></svg><span style="flex:1;font-size:13px;font-weight:700">Shopping list ready · Oct 5–11</span><svg viewBox="0 0 24 24" width="16" height="16" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="m9 18 6-6-6-6"></path></svg></div>
</div>
<div style="position:absolute;left:14px;right:14px;bottom:16px;height:62px;border-radius:999px;background:var(--color-text);display:flex;align-items:center;justify-content:space-around;color:var(--color-neutral-400)">
<div style="display:flex;align-items:center;gap:6px;padding:10px 14px;border-radius:999px;background:var(--color-accent);color:var(--on-accent);font-size:13px;font-weight:700"><svg viewBox="0 0 24 24" width="20" height="20" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"></path><path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path></svg>Home</div>
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M8 2v4"></path><path d="M16 2v4"></path><rect width="18" height="18" x="3" y="4" rx="2"></rect><path d="M3 10h18"></path></svg>
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><circle cx="12" cy="12" r="10"></circle><path d="m16.24 7.76-1.804 5.411a2 2 0 0 1-1.265 1.265L7.76 16.24l1.804-5.411a2 2 0 0 1 1.265-1.265z"></path></svg>
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M10 5H3"></path><path d="M12 19H3"></path><path d="M14 3v4"></path><path d="M16 17v4"></path><path d="M21 12h-9"></path><path d="M21 19h-5"></path><path d="M21 5h-7"></path><path d="M8 10v4"></path><path d="M8 12H3"></path></svg>
</div>
</div></div><div style="display:flex;flex-direction:column;gap:8px"><div style="font-size:12px;font-weight:700;color:var(--color-neutral-700)">Discover</div><div style="width:360px;height:760px;position:relative;border-radius:36px;background:var(--color-bg);overflow:hidden;box-shadow:var(--shadow-md)">
<div style="height:40px;display:flex;align-items:center;justify-content:space-between;padding:0 26px;font-size:13px;font-weight:700"><span>9:41</span><span style="width:22px;height:10px;border-radius:3px;background:var(--color-text)"></span></div>
<div style="padding:8px 20px 0;display:flex;align-items:flex-end;justify-content:space-between">
<div><div style="font-size:11px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:var(--color-accent-700)">Discover</div><div style="font-family:var(--font-heading);font-size:30px;line-height:1.1">Side quests</div></div>
<div style="display:flex;align-items:center;gap:6px;padding:7px 12px;border-radius:999px;background:var(--color-accent-2-200);color:var(--color-accent-2-800);font-size:12px;font-weight:700">Quest log · 6</div>
</div>
<div style="position:relative;margin:18px 20px 0;height:440px">
<div style="position:absolute;inset:0;transform:translateY(16px) scale(.9);border-radius:32px;background:var(--color-neutral-300)"></div>
<div style="position:absolute;inset:0;transform:translateY(8px) scale(.95);border-radius:32px;background:var(--color-surface)"></div>
<div style="position:absolute;inset:0;border-radius:32px;background:var(--color-neutral-100);box-shadow:var(--shadow-lg);overflow:hidden;display:flex;flex-direction:column">
<div style="height:220px;margin:10px;border-radius:24px;background:repeating-linear-gradient(135deg,var(--color-neutral-300) 0 8px,var(--color-neutral-200) 8px 16px);display:flex;align-items:center;justify-content:center;font:11px ui-monospace,Menlo,monospace;color:var(--color-neutral-700)">dish photo</div>
<div style="padding:4px 20px 0;display:flex;flex-direction:column;gap:10px">
<div style="display:flex;align-items:center;justify-content:space-between"><div style="font-family:var(--font-heading);font-size:24px;line-height:1.1">Korean beef bowls</div></div>
<div style="display:flex;align-items:center;gap:8px;font-size:12px;font-weight:700;color:var(--color-neutral-700)"><span style="display:flex;gap:3px"><span style="width:8px;height:8px;border-radius:50%;background:var(--color-accent);color:var(--on-accent)"></span><span style="width:8px;height:8px;border-radius:50%;background:var(--color-neutral-300)"></span><span style="width:8px;height:8px;border-radius:50%;background:var(--color-neutral-300)"></span></span>Easy</div>
<div style="padding:12px 14px;border-radius:18px;background:var(--color-accent-2-100);color:var(--color-accent-2-900);font-size:13px;line-height:1.45"><b>Why this?</b> You make Chicken stir-fry and Beef tacos most weeks. Same flavors, sweet-savory twist.</div>
</div>
</div>
</div>
<div style="display:flex;justify-content:center;gap:28px;margin-top:22px">
<div style="display:flex;flex-direction:column;align-items:center;gap:6px;font-size:11px;font-weight:700;color:var(--color-neutral-700)"><div style="width:62px;height:62px;border-radius:50%;background:var(--color-neutral-100);box-shadow:var(--shadow-md);display:flex;align-items:center;justify-content:center;color:var(--color-text)"><svg viewBox="0 0 24 24" width="26" height="26" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M18 6 6 18"></path><path d="m6 6 12 12"></path></svg></div>Pass</div>
<div style="display:flex;flex-direction:column;align-items:center;gap:6px;font-size:11px;font-weight:700;color:var(--color-neutral-700)"><div style="width:62px;height:62px;border-radius:50%;background:var(--color-accent-2-600);box-shadow:var(--shadow-md);display:flex;align-items:center;justify-content:center;color:var(--color-bg)"><svg viewBox="0 0 24 24" width="26" height="26" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z"></path></svg></div>Try it</div>
</div>
<div style="position:absolute;left:14px;right:14px;bottom:16px;height:62px;border-radius:999px;background:var(--color-text);display:flex;align-items:center;justify-content:space-around;color:var(--color-neutral-400)">
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"></path><path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path></svg>
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M8 2v4"></path><path d="M16 2v4"></path><rect width="18" height="18" x="3" y="4" rx="2"></rect><path d="M3 10h18"></path></svg>
<div style="display:flex;align-items:center;gap:6px;padding:10px 14px;border-radius:999px;background:var(--color-accent);color:var(--on-accent);font-size:13px;font-weight:700"><svg viewBox="0 0 24 24" width="20" height="20" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><circle cx="12" cy="12" r="10"></circle><path d="m16.24 7.76-1.804 5.411a2 2 0 0 1-1.265 1.265L7.76 16.24l1.804-5.411a2 2 0 0 1 1.265-1.265z"></path></svg>Discover</div>
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M10 5H3"></path><path d="M12 19H3"></path><path d="M14 3v4"></path><path d="M16 17v4"></path><path d="M21 12h-9"></path><path d="M21 19h-5"></path><path d="M21 5h-7"></path><path d="M8 10v4"></path><path d="M8 12H3"></path></svg>
</div>
</div></div><div style="display:flex;flex-direction:column;gap:8px"><div style="font-size:12px;font-weight:700;color:var(--color-neutral-700)">Weekly plan</div><div style="width:360px;height:760px;position:relative;border-radius:36px;background:var(--color-bg);overflow:hidden;box-shadow:var(--shadow-md)">
<div style="height:40px;display:flex;align-items:center;justify-content:space-between;padding:0 26px;font-size:13px;font-weight:700"><span>9:41</span><span style="width:22px;height:10px;border-radius:3px;background:var(--color-text)"></span></div>
<div style="padding:6px 20px 0;display:flex;align-items:center;justify-content:space-between">
<div><div style="font-size:11px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:var(--color-accent-700)">Week 41</div><div style="font-family:var(--font-heading);font-size:26px;line-height:1.1">Oct 5 – 11</div></div>
<div style="display:flex;gap:6px"><div style="width:36px;height:36px;border-radius:50%;background:var(--color-surface);display:flex;align-items:center;justify-content:center"><svg viewBox="0 0 24 24" width="18" height="18" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="m15 18-6-6 6-6"></path></svg></div><div style="width:36px;height:36px;border-radius:50%;background:var(--color-surface);display:flex;align-items:center;justify-content:center"><svg viewBox="0 0 24 24" width="18" height="18" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="m9 18 6-6-6-6"></path></svg></div></div>
</div>
<div style="margin:14px 20px 0;padding:10px 14px;border-radius:18px;background:var(--color-accent-2-100);color:var(--color-accent-2-900);font-size:12px;line-height:1.4;display:flex;gap:8px;align-items:center"><svg viewBox="0 0 24 24" width="16" height="16" style="flex:none;fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M20 6 9 17l-5-5"></path></svg><span>5 regulars + 2 side quests · synced to Google Calendar</span></div>
<div style="margin:12px 20px 0;display:flex;flex-direction:column;gap:6px">
<div style="display:flex;align-items:center;gap:12px;padding:9px 12px;border-radius:18px;background:var(--color-surface)"><div style="width:36px;text-align:center;font-size:11px;font-weight:700;color:var(--color-neutral-700);line-height:1.1">MON<br><span style="font-size:16px;color:var(--color-text)">5</span></div><div style="flex:1;font-weight:700;font-size:14px">Beef tacos</div><span style="padding:3px 9px;border-radius:999px;background:var(--color-neutral-100);color:var(--color-neutral-800);font-size:10px;font-weight:700">Regular</span><svg viewBox="0 0 24 24" width="16" height="16" style="color:var(--color-neutral-600);fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"></path><path d="M21 3v5h-5"></path><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"></path><path d="M8 16H3v5"></path></svg></div>
<div style="display:flex;align-items:center;gap:12px;padding:9px 12px;border-radius:18px;background:var(--color-surface)"><div style="width:36px;text-align:center;font-size:11px;font-weight:700;color:var(--color-neutral-700);line-height:1.1">TUE<br><span style="font-size:16px;color:var(--color-text)">6</span></div><div style="flex:1;font-weight:700;font-size:14px">Spaghetti bolognese</div><span style="padding:3px 9px;border-radius:999px;background:var(--color-neutral-100);color:var(--color-neutral-800);font-size:10px;font-weight:700">Regular</span><svg viewBox="0 0 24 24" width="16" height="16" style="color:var(--color-neutral-600);fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"></path><path d="M21 3v5h-5"></path><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"></path><path d="M8 16H3v5"></path></svg></div>
<div style="display:flex;align-items:center;gap:12px;padding:9px 12px;border-radius:18px;background:var(--color-accent-100);box-shadow:inset 0 0 0 1.5px var(--color-accent-300)"><div style="width:36px;text-align:center;font-size:11px;font-weight:700;color:var(--color-accent-700);line-height:1.1">WED<br><span style="font-size:16px;color:var(--color-text)">7</span></div><div style="flex:1;font-weight:700;font-size:14px">Crock pot tikka masala</div><span style="padding:3px 9px;border-radius:999px;background:var(--color-accent-200);color:var(--color-accent-800);font-size:10px;font-weight:700">Side quest</span><svg viewBox="0 0 24 24" width="16" height="16" style="color:var(--color-neutral-600);fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"></path><path d="M21 3v5h-5"></path><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"></path><path d="M8 16H3v5"></path></svg></div>
<div style="display:flex;align-items:center;gap:12px;padding:9px 12px;border-radius:18px;background:var(--color-surface)"><div style="width:36px;text-align:center;font-size:11px;font-weight:700;color:var(--color-neutral-700);line-height:1.1">THU<br><span style="font-size:16px;color:var(--color-text)">8</span></div><div style="flex:1;font-weight:700;font-size:14px">Chicken stir-fry</div><span style="padding:3px 9px;border-radius:999px;background:var(--color-neutral-100);color:var(--color-neutral-800);font-size:10px;font-weight:700">Regular</span><svg viewBox="0 0 24 24" width="16" height="16" style="color:var(--color-neutral-600);fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"></path><path d="M21 3v5h-5"></path><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"></path><path d="M8 16H3v5"></path></svg></div>
<div style="display:flex;align-items:center;gap:12px;padding:9px 12px;border-radius:18px;background:var(--color-surface)"><div style="width:36px;text-align:center;font-size:11px;font-weight:700;color:var(--color-neutral-700);line-height:1.1">FRI<br><span style="font-size:16px;color:var(--color-text)">9</span></div><div style="flex:1;font-weight:700;font-size:14px">Pizza night</div><span style="padding:3px 9px;border-radius:999px;background:var(--color-neutral-100);color:var(--color-neutral-800);font-size:10px;font-weight:700">Regular</span><svg viewBox="0 0 24 24" width="16" height="16" style="color:var(--color-neutral-600);fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"></path><path d="M21 3v5h-5"></path><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"></path><path d="M8 16H3v5"></path></svg></div>
<div style="display:flex;align-items:center;gap:12px;padding:9px 12px;border-radius:18px;background:var(--color-accent-100);box-shadow:inset 0 0 0 1.5px var(--color-accent-300)"><div style="width:36px;text-align:center;font-size:11px;font-weight:700;color:var(--color-accent-700);line-height:1.1">SAT<br><span style="font-size:16px;color:var(--color-text)">10</span></div><div style="flex:1;font-weight:700;font-size:14px">Korean beef bowls</div><span style="padding:3px 9px;border-radius:999px;background:var(--color-accent-200);color:var(--color-accent-800);font-size:10px;font-weight:700">Side quest</span><svg viewBox="0 0 24 24" width="16" height="16" style="color:var(--color-neutral-600);fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"></path><path d="M21 3v5h-5"></path><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"></path><path d="M8 16H3v5"></path></svg></div>
<div style="display:flex;align-items:center;gap:12px;padding:9px 12px;border-radius:18px;background:var(--color-surface)"><div style="width:36px;text-align:center;font-size:11px;font-weight:700;color:var(--color-neutral-700);line-height:1.1">SUN<br><span style="font-size:16px;color:var(--color-text)">11</span></div><div style="flex:1;font-weight:700;font-size:14px">Sheet-pan salmon</div><span style="padding:3px 9px;border-radius:999px;background:var(--color-neutral-100);color:var(--color-neutral-800);font-size:10px;font-weight:700">Regular</span><svg viewBox="0 0 24 24" width="16" height="16" style="color:var(--color-neutral-600);fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"></path><path d="M21 3v5h-5"></path><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"></path><path d="M8 16H3v5"></path></svg></div>
</div>
<div style="margin:14px 20px 0;height:50px;border-radius:999px;background:var(--color-accent);display:flex;align-items:center;justify-content:center;gap:8px;font-family:var(--font-heading);font-size:15px;color:var(--on-accent)"><svg viewBox="0 0 24 24" width="18" height="18" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><circle cx="8" cy="21" r="1"></circle><circle cx="19" cy="21" r="1"></circle><path d="M2.05 2.05h2l2.66 12.42a2 2 0 0 0 2 1.58h9.78a2 2 0 0 0 1.95-1.57l1.65-7.43H5.12"></path></svg>Shopping list · 23 items</div>
<div style="position:absolute;left:14px;right:14px;bottom:16px;height:62px;border-radius:999px;background:var(--color-text);display:flex;align-items:center;justify-content:space-around;color:var(--color-neutral-400)">
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"></path><path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path></svg>
<div style="display:flex;align-items:center;gap:6px;padding:10px 14px;border-radius:999px;background:var(--color-accent);color:var(--on-accent);font-size:13px;font-weight:700"><svg viewBox="0 0 24 24" width="20" height="20" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M8 2v4"></path><path d="M16 2v4"></path><rect width="18" height="18" x="3" y="4" rx="2"></rect><path d="M3 10h18"></path></svg>Plan</div>
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><circle cx="12" cy="12" r="10"></circle><path d="m16.24 7.76-1.804 5.411a2 2 0 0 1-1.265 1.265L7.76 16.24l1.804-5.411a2 2 0 0 1 1.265-1.265z"></path></svg>
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M10 5H3"></path><path d="M12 19H3"></path><path d="M14 3v4"></path><path d="M16 17v4"></path><path d="M21 12h-9"></path><path d="M21 19h-5"></path><path d="M21 5h-7"></path><path d="M8 10v4"></path><path d="M8 12H3"></path></svg>
</div>
</div></div><div style="display:flex;flex-direction:column;gap:8px"><div style="font-size:12px;font-weight:700;color:var(--color-neutral-700)">Shopping list</div><div style="width:360px;height:760px;position:relative;border-radius:36px;background:var(--color-bg);overflow:hidden;box-shadow:var(--shadow-md)">
<div style="height:40px;display:flex;align-items:center;justify-content:space-between;padding:0 26px;font-size:13px;font-weight:700"><span>9:41</span><span style="width:22px;height:10px;border-radius:3px;background:var(--color-text)"></span></div>
<div style="padding:6px 20px 0;display:flex;justify-content:space-between;align-items:flex-end"><div><div style="font-size:11px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:var(--color-accent-700)">Supply run · Oct 5–11</div><div style="font-family:var(--font-heading);font-size:28px;line-height:1.1">Shopping list</div></div><div style="font-size:13px;font-weight:700;color:var(--color-neutral-700)">19 / 23</div></div>
<div style="margin:10px 20px 0;font-size:12px;line-height:1.45;color:var(--color-neutral-800)">Uncheck anything you already have — it won't be counted.</div>
<div style="padding:12px 20px 0;display:flex;flex-direction:column;gap:12px">
<div style="padding:14px;border-radius:24px;background:var(--color-surface);display:flex;flex-direction:column;gap:9px">
<div style="display:flex;justify-content:space-between;align-items:baseline"><span style="font-family:var(--font-heading);font-size:16px">Beef tacos</span><span style="font-size:11px;font-weight:700;color:var(--color-neutral-700)">MON</span></div>
<div style="display:flex;align-items:center;gap:10px;font-size:14px"><span style="width:20px;height:20px;border-radius:7px;background:var(--color-accent);color:var(--on-accent);display:flex;align-items:center;justify-content:center"><svg viewBox="0 0 24 24" width="13" height="13" style="fill:none;stroke:currentColor;stroke-width:3.5;stroke-linecap:round;stroke-linejoin:round"><path d="M20 6 9 17l-5-5"></path></svg></span><span style="flex:1">Ground beef</span><span style="color:var(--color-neutral-700);font-size:12px">1 lb</span></div>
<div style="display:flex;align-items:center;gap:10px;font-size:14px"><span style="width:20px;height:20px;border-radius:7px;background:var(--color-accent);color:var(--on-accent);display:flex;align-items:center;justify-content:center"><svg viewBox="0 0 24 24" width="13" height="13" style="fill:none;stroke:currentColor;stroke-width:3.5;stroke-linecap:round;stroke-linejoin:round"><path d="M20 6 9 17l-5-5"></path></svg></span><span style="flex:1">Corn tortillas</span><span style="color:var(--color-neutral-700);font-size:12px">12</span></div>
<div style="display:flex;align-items:center;gap:10px;font-size:14px;color:var(--color-neutral-600)"><span style="width:20px;height:20px;border-radius:7px;border:2px solid var(--color-neutral-400)"></span><span style="flex:1;text-decoration:line-through">Taco seasoning</span><span style="font-size:11px;font-weight:700">have it</span></div>
</div>
<div style="padding:14px;border-radius:24px;background:var(--color-surface);display:flex;flex-direction:column;gap:9px">
<div style="display:flex;justify-content:space-between;align-items:baseline"><span style="font-family:var(--font-heading);font-size:16px">Crock pot tikka masala</span><span style="font-size:11px;font-weight:700;color:var(--color-neutral-700)">WED</span></div>
<div style="display:flex;align-items:center;gap:10px;font-size:14px"><span style="width:20px;height:20px;border-radius:7px;background:var(--color-accent);color:var(--on-accent);display:flex;align-items:center;justify-content:center"><svg viewBox="0 0 24 24" width="13" height="13" style="fill:none;stroke:currentColor;stroke-width:3.5;stroke-linecap:round;stroke-linejoin:round"><path d="M20 6 9 17l-5-5"></path></svg></span><span style="flex:1">Chicken thighs</span><span style="color:var(--color-neutral-700);font-size:12px">2 lb</span></div>
<div style="display:flex;align-items:center;gap:10px;font-size:14px"><span style="width:20px;height:20px;border-radius:7px;background:var(--color-accent);color:var(--on-accent);display:flex;align-items:center;justify-content:center"><svg viewBox="0 0 24 24" width="13" height="13" style="fill:none;stroke:currentColor;stroke-width:3.5;stroke-linecap:round;stroke-linejoin:round"><path d="M20 6 9 17l-5-5"></path></svg></span><span style="flex:1">Tikka masala paste</span><span style="color:var(--color-neutral-700);font-size:12px">1 jar</span></div>
<div style="display:flex;align-items:center;gap:10px;font-size:14px;color:var(--color-neutral-600)"><span style="width:20px;height:20px;border-radius:7px;border:2px solid var(--color-neutral-400)"></span><span style="flex:1;text-decoration:line-through">Basmati rice</span><span style="font-size:11px;font-weight:700">have it</span></div>
</div>
<div style="padding:14px;border-radius:24px;background:var(--color-surface);display:flex;justify-content:space-between;align-items:baseline"><span style="font-family:var(--font-heading);font-size:16px">Chicken stir-fry</span><span style="font-size:11px;font-weight:700;color:var(--color-neutral-700)">THU · 5 items</span></div>
</div>
<div style="position:absolute;left:20px;right:20px;bottom:96px;display:flex;align-items:center;gap:8px;font-size:12px;font-weight:700;color:var(--color-accent-2-800)"><svg viewBox="0 0 24 24" width="15" height="15" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path></svg>Sent to Google Chat · Sat 8:00 am</div>
<div style="position:absolute;left:14px;right:14px;bottom:16px;height:62px;border-radius:999px;background:var(--color-text);display:flex;align-items:center;justify-content:space-around;color:var(--color-neutral-400)">
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"></path><path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path></svg>
<div style="display:flex;align-items:center;gap:6px;padding:10px 14px;border-radius:999px;background:var(--color-accent);color:var(--on-accent);font-size:13px;font-weight:700"><svg viewBox="0 0 24 24" width="20" height="20" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M8 2v4"></path><path d="M16 2v4"></path><rect width="18" height="18" x="3" y="4" rx="2"></rect><path d="M3 10h18"></path></svg>Plan</div>
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><circle cx="12" cy="12" r="10"></circle><path d="m16.24 7.76-1.804 5.411a2 2 0 0 1-1.265 1.265L7.76 16.24l1.804-5.411a2 2 0 0 1 1.265-1.265z"></path></svg>
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M10 5H3"></path><path d="M12 19H3"></path><path d="M14 3v4"></path><path d="M16 17v4"></path><path d="M21 12h-9"></path><path d="M21 19h-5"></path><path d="M21 5h-7"></path><path d="M8 10v4"></path><path d="M8 12H3"></path></svg>
</div>
</div></div></div>
</div>
<div id="b3" style="--color-accent:oklch(0.42 0.14 300);--color-accent-100:oklch(0.97 0.032 300);--color-accent-200:oklch(0.93 0.063 300);--color-accent-300:oklch(0.86 0.095 300);--color-accent-400:oklch(0.77 0.126 300);--color-accent-500:oklch(0.67 0.147 300);--color-accent-600:oklch(0.57 0.147 300);--color-accent-700:oklch(0.47 0.137 300);--color-accent-800:oklch(0.37 0.116 300);--color-accent-900:oklch(0.27 0.084 300);--on-accent:var(--color-bg);--color-accent-2:oklch(0.64 0.11 275);--color-accent-2-100:oklch(0.97 0.030 275);--color-accent-2-200:oklch(0.93 0.060 275);--color-accent-2-300:oklch(0.86 0.090 275);--color-accent-2-400:oklch(0.77 0.120 275);--color-accent-2-500:oklch(0.67 0.140 275);--color-accent-2-600:oklch(0.57 0.140 275);--color-accent-2-700:oklch(0.47 0.130 275);--color-accent-2-800:oklch(0.37 0.110 275);--color-accent-2-900:oklch(0.27 0.080 275);padding:32px 40px;border-radius:40px;background:var(--color-bg);display:flex;flex-direction:column;gap:20px;color:var(--color-text);font-family:var(--font-body)">
<div style="display:flex;align-items:center;gap:20px;flex-wrap:wrap"><div style="display:flex;flex-direction:column;gap:4px;min-width:280px"><div style="font-family:var(--font-heading);font-size:28px;line-height:1.15;white-space:nowrap">B3 · Grape + periwinkle · cream</div><div style="font-size:14px;color:var(--color-neutral-800)">Purple-leaning blue; the most harmonious, lowest-contrast pair.</div></div><div style="display:flex;gap:8px"><span style="width:44px;height:44px;border-radius:50%;background:var(--color-bg);box-shadow:inset 0 0 0 1px var(--color-divider)"></span><span style="width:44px;height:44px;border-radius:50%;background:var(--color-surface);box-shadow:inset 0 0 0 1px var(--color-divider)"></span><span style="width:44px;height:44px;border-radius:50%;background:var(--color-accent);box-shadow:inset 0 0 0 1px var(--color-divider)"></span><span style="width:44px;height:44px;border-radius:50%;background:var(--color-accent-2);box-shadow:inset 0 0 0 1px var(--color-divider)"></span><span style="width:44px;height:44px;border-radius:50%;background:var(--color-text);box-shadow:inset 0 0 0 1px var(--color-divider)"></span></div><div style="display:flex;gap:3px"><span style="width:18px;height:44px;border-radius:999px;background:var(--color-accent-100)"></span><span style="width:18px;height:44px;border-radius:999px;background:var(--color-accent-200)"></span><span style="width:18px;height:44px;border-radius:999px;background:var(--color-accent-300)"></span><span style="width:18px;height:44px;border-radius:999px;background:var(--color-accent-400)"></span><span style="width:18px;height:44px;border-radius:999px;background:var(--color-accent-500)"></span><span style="width:18px;height:44px;border-radius:999px;background:var(--color-accent-600)"></span><span style="width:18px;height:44px;border-radius:999px;background:var(--color-accent-700)"></span><span style="width:18px;height:44px;border-radius:999px;background:var(--color-accent-800)"></span><span style="width:18px;height:44px;border-radius:999px;background:var(--color-accent-900)"></span></div><div style="display:flex;gap:3px"><span style="width:18px;height:44px;border-radius:999px;background:var(--color-accent-2-100)"></span><span style="width:18px;height:44px;border-radius:999px;background:var(--color-accent-2-200)"></span><span style="width:18px;height:44px;border-radius:999px;background:var(--color-accent-2-300)"></span><span style="width:18px;height:44px;border-radius:999px;background:var(--color-accent-2-400)"></span><span style="width:18px;height:44px;border-radius:999px;background:var(--color-accent-2-500)"></span><span style="width:18px;height:44px;border-radius:999px;background:var(--color-accent-2-600)"></span><span style="width:18px;height:44px;border-radius:999px;background:var(--color-accent-2-700)"></span><span style="width:18px;height:44px;border-radius:999px;background:var(--color-accent-2-800)"></span><span style="width:18px;height:44px;border-radius:999px;background:var(--color-accent-2-900)"></span></div></div>
<div style="display:flex;gap:28px;flex-wrap:wrap"><div style="display:flex;flex-direction:column;gap:8px"><div style="font-size:12px;font-weight:700;color:var(--color-neutral-700)">Home</div><div style="width:360px;height:760px;position:relative;border-radius:36px;background:var(--color-bg);overflow:hidden;box-shadow:var(--shadow-md)">
<div style="height:40px;display:flex;align-items:center;justify-content:space-between;padding:0 26px;font-size:13px;font-weight:700"><span>9:41</span><span style="width:22px;height:10px;border-radius:3px;background:var(--color-text)"></span></div>
<div style="padding:6px 20px 0;display:flex;justify-content:space-between;align-items:flex-start">
<div><div style="font-size:12px;font-weight:700;color:var(--color-neutral-700)">Friday, Oct 2</div><div style="font-family:var(--font-heading);font-size:28px;line-height:1.1">Tonight's quest</div></div>
<div style="position:relative;width:40px;height:40px;border-radius:50%;background:var(--color-surface);display:flex;align-items:center;justify-content:center"><svg viewBox="0 0 24 24" width="19" height="19" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M10.268 21a2 2 0 0 0 3.464 0"></path><path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326"></path></svg><span style="position:absolute;top:8px;right:9px;width:8px;height:8px;border-radius:50%;background:var(--color-accent);color:var(--on-accent)"></span></div>
</div>
<div style="margin:14px 20px 0;border-radius:32px;background:var(--color-surface);overflow:hidden;display:flex;flex-direction:column">
<div style="height:150px;margin:10px;border-radius:24px;background:repeating-linear-gradient(135deg,var(--color-neutral-300) 0 8px,var(--color-neutral-200) 8px 16px);display:flex;align-items:center;justify-content:center;font:11px ui-monospace,Menlo,monospace;color:var(--color-neutral-700)">dish photo</div>
<div style="padding:2px 18px 16px;display:flex;align-items:flex-end;justify-content:space-between;gap:10px"><div><div style="font-family:var(--font-heading);font-size:22px;line-height:1.1">Pizza night</div><div style="font-size:12px;color:var(--color-neutral-700);margin-top:4px">Regular · 40 min</div></div><div style="height:38px;padding:0 16px;border-radius:999px;background:var(--color-text);color:var(--color-bg);display:flex;align-items:center;font-family:var(--font-heading);font-size:13px">Recipe</div></div>
</div>
<div style="margin:12px 20px 0;display:grid;grid-template-columns:1fr 1fr;gap:10px">
<div style="height:104px;padding:14px;border-radius:26px;background:var(--color-accent);color:var(--on-accent);display:flex;flex-direction:column;justify-content:space-between"><svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z"></path></svg><span style="font-family:var(--font-heading);font-size:16px;line-height:1.1">Quick dinner</span></div>
<div style="height:104px;padding:14px;border-radius:26px;background:var(--color-accent-2-300);color:var(--color-accent-2-900);display:flex;flex-direction:column;justify-content:space-between"><svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><circle cx="11" cy="11" r="8"></circle><path d="m21 21-4.3-4.3"></path></svg><span style="font-family:var(--font-heading);font-size:16px;line-height:1.1">Cook from what I have</span></div>
</div>
<div style="margin:12px 20px 0;display:flex;flex-direction:column;gap:8px">
<div style="display:flex;align-items:center;gap:12px;padding:12px 14px;border-radius:20px;background:var(--color-neutral-100)"><svg viewBox="0 0 24 24" width="18" height="18" style="color:var(--color-accent-700);fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><circle cx="12" cy="12" r="10"></circle><path d="m16.24 7.76-1.804 5.411a2 2 0 0 1-1.265 1.265L7.76 16.24l1.804-5.411a2 2 0 0 1 1.265-1.265z"></path></svg><span style="flex:1;font-size:13px;font-weight:700">10 new side quests to swipe</span><svg viewBox="0 0 24 24" width="16" height="16" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="m9 18 6-6-6-6"></path></svg></div>
<div style="display:flex;align-items:center;gap:12px;padding:12px 14px;border-radius:20px;background:var(--color-neutral-100)"><svg viewBox="0 0 24 24" width="18" height="18" style="color:var(--color-accent-700);fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><circle cx="8" cy="21" r="1"></circle><circle cx="19" cy="21" r="1"></circle><path d="M2.05 2.05h2l2.66 12.42a2 2 0 0 0 2 1.58h9.78a2 2 0 0 0 1.95-1.57l1.65-7.43H5.12"></path></svg><span style="flex:1;font-size:13px;font-weight:700">Shopping list ready · Oct 5–11</span><svg viewBox="0 0 24 24" width="16" height="16" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="m9 18 6-6-6-6"></path></svg></div>
</div>
<div style="position:absolute;left:14px;right:14px;bottom:16px;height:62px;border-radius:999px;background:var(--color-text);display:flex;align-items:center;justify-content:space-around;color:var(--color-neutral-400)">
<div style="display:flex;align-items:center;gap:6px;padding:10px 14px;border-radius:999px;background:var(--color-accent);color:var(--on-accent);font-size:13px;font-weight:700"><svg viewBox="0 0 24 24" width="20" height="20" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"></path><path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path></svg>Home</div>
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M8 2v4"></path><path d="M16 2v4"></path><rect width="18" height="18" x="3" y="4" rx="2"></rect><path d="M3 10h18"></path></svg>
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><circle cx="12" cy="12" r="10"></circle><path d="m16.24 7.76-1.804 5.411a2 2 0 0 1-1.265 1.265L7.76 16.24l1.804-5.411a2 2 0 0 1 1.265-1.265z"></path></svg>
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M10 5H3"></path><path d="M12 19H3"></path><path d="M14 3v4"></path><path d="M16 17v4"></path><path d="M21 12h-9"></path><path d="M21 19h-5"></path><path d="M21 5h-7"></path><path d="M8 10v4"></path><path d="M8 12H3"></path></svg>
</div>
</div></div><div style="display:flex;flex-direction:column;gap:8px"><div style="font-size:12px;font-weight:700;color:var(--color-neutral-700)">Discover</div><div style="width:360px;height:760px;position:relative;border-radius:36px;background:var(--color-bg);overflow:hidden;box-shadow:var(--shadow-md)">
<div style="height:40px;display:flex;align-items:center;justify-content:space-between;padding:0 26px;font-size:13px;font-weight:700"><span>9:41</span><span style="width:22px;height:10px;border-radius:3px;background:var(--color-text)"></span></div>
<div style="padding:8px 20px 0;display:flex;align-items:flex-end;justify-content:space-between">
<div><div style="font-size:11px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:var(--color-accent-700)">Discover</div><div style="font-family:var(--font-heading);font-size:30px;line-height:1.1">Side quests</div></div>
<div style="display:flex;align-items:center;gap:6px;padding:7px 12px;border-radius:999px;background:var(--color-accent-2-200);color:var(--color-accent-2-800);font-size:12px;font-weight:700">Quest log · 6</div>
</div>
<div style="position:relative;margin:18px 20px 0;height:440px">
<div style="position:absolute;inset:0;transform:translateY(16px) scale(.9);border-radius:32px;background:var(--color-neutral-300)"></div>
<div style="position:absolute;inset:0;transform:translateY(8px) scale(.95);border-radius:32px;background:var(--color-surface)"></div>
<div style="position:absolute;inset:0;border-radius:32px;background:var(--color-neutral-100);box-shadow:var(--shadow-lg);overflow:hidden;display:flex;flex-direction:column">
<div style="height:220px;margin:10px;border-radius:24px;background:repeating-linear-gradient(135deg,var(--color-neutral-300) 0 8px,var(--color-neutral-200) 8px 16px);display:flex;align-items:center;justify-content:center;font:11px ui-monospace,Menlo,monospace;color:var(--color-neutral-700)">dish photo</div>
<div style="padding:4px 20px 0;display:flex;flex-direction:column;gap:10px">
<div style="display:flex;align-items:center;justify-content:space-between"><div style="font-family:var(--font-heading);font-size:24px;line-height:1.1">Korean beef bowls</div></div>
<div style="display:flex;align-items:center;gap:8px;font-size:12px;font-weight:700;color:var(--color-neutral-700)"><span style="display:flex;gap:3px"><span style="width:8px;height:8px;border-radius:50%;background:var(--color-accent);color:var(--on-accent)"></span><span style="width:8px;height:8px;border-radius:50%;background:var(--color-neutral-300)"></span><span style="width:8px;height:8px;border-radius:50%;background:var(--color-neutral-300)"></span></span>Easy</div>
<div style="padding:12px 14px;border-radius:18px;background:var(--color-accent-2-100);color:var(--color-accent-2-900);font-size:13px;line-height:1.45"><b>Why this?</b> You make Chicken stir-fry and Beef tacos most weeks. Same flavors, sweet-savory twist.</div>
</div>
</div>
</div>
<div style="display:flex;justify-content:center;gap:28px;margin-top:22px">
<div style="display:flex;flex-direction:column;align-items:center;gap:6px;font-size:11px;font-weight:700;color:var(--color-neutral-700)"><div style="width:62px;height:62px;border-radius:50%;background:var(--color-neutral-100);box-shadow:var(--shadow-md);display:flex;align-items:center;justify-content:center;color:var(--color-text)"><svg viewBox="0 0 24 24" width="26" height="26" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M18 6 6 18"></path><path d="m6 6 12 12"></path></svg></div>Pass</div>
<div style="display:flex;flex-direction:column;align-items:center;gap:6px;font-size:11px;font-weight:700;color:var(--color-neutral-700)"><div style="width:62px;height:62px;border-radius:50%;background:var(--color-accent-2-600);box-shadow:var(--shadow-md);display:flex;align-items:center;justify-content:center;color:var(--color-bg)"><svg viewBox="0 0 24 24" width="26" height="26" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z"></path></svg></div>Try it</div>
</div>
<div style="position:absolute;left:14px;right:14px;bottom:16px;height:62px;border-radius:999px;background:var(--color-text);display:flex;align-items:center;justify-content:space-around;color:var(--color-neutral-400)">
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"></path><path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path></svg>
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M8 2v4"></path><path d="M16 2v4"></path><rect width="18" height="18" x="3" y="4" rx="2"></rect><path d="M3 10h18"></path></svg>
<div style="display:flex;align-items:center;gap:6px;padding:10px 14px;border-radius:999px;background:var(--color-accent);color:var(--on-accent);font-size:13px;font-weight:700"><svg viewBox="0 0 24 24" width="20" height="20" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><circle cx="12" cy="12" r="10"></circle><path d="m16.24 7.76-1.804 5.411a2 2 0 0 1-1.265 1.265L7.76 16.24l1.804-5.411a2 2 0 0 1 1.265-1.265z"></path></svg>Discover</div>
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M10 5H3"></path><path d="M12 19H3"></path><path d="M14 3v4"></path><path d="M16 17v4"></path><path d="M21 12h-9"></path><path d="M21 19h-5"></path><path d="M21 5h-7"></path><path d="M8 10v4"></path><path d="M8 12H3"></path></svg>
</div>
</div></div><div style="display:flex;flex-direction:column;gap:8px"><div style="font-size:12px;font-weight:700;color:var(--color-neutral-700)">Weekly plan</div><div style="width:360px;height:760px;position:relative;border-radius:36px;background:var(--color-bg);overflow:hidden;box-shadow:var(--shadow-md)">
<div style="height:40px;display:flex;align-items:center;justify-content:space-between;padding:0 26px;font-size:13px;font-weight:700"><span>9:41</span><span style="width:22px;height:10px;border-radius:3px;background:var(--color-text)"></span></div>
<div style="padding:6px 20px 0;display:flex;align-items:center;justify-content:space-between">
<div><div style="font-size:11px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:var(--color-accent-700)">Week 41</div><div style="font-family:var(--font-heading);font-size:26px;line-height:1.1">Oct 5 – 11</div></div>
<div style="display:flex;gap:6px"><div style="width:36px;height:36px;border-radius:50%;background:var(--color-surface);display:flex;align-items:center;justify-content:center"><svg viewBox="0 0 24 24" width="18" height="18" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="m15 18-6-6 6-6"></path></svg></div><div style="width:36px;height:36px;border-radius:50%;background:var(--color-surface);display:flex;align-items:center;justify-content:center"><svg viewBox="0 0 24 24" width="18" height="18" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="m9 18 6-6-6-6"></path></svg></div></div>
</div>
<div style="margin:14px 20px 0;padding:10px 14px;border-radius:18px;background:var(--color-accent-2-100);color:var(--color-accent-2-900);font-size:12px;line-height:1.4;display:flex;gap:8px;align-items:center"><svg viewBox="0 0 24 24" width="16" height="16" style="flex:none;fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M20 6 9 17l-5-5"></path></svg><span>5 regulars + 2 side quests · synced to Google Calendar</span></div>
<div style="margin:12px 20px 0;display:flex;flex-direction:column;gap:6px">
<div style="display:flex;align-items:center;gap:12px;padding:9px 12px;border-radius:18px;background:var(--color-surface)"><div style="width:36px;text-align:center;font-size:11px;font-weight:700;color:var(--color-neutral-700);line-height:1.1">MON<br><span style="font-size:16px;color:var(--color-text)">5</span></div><div style="flex:1;font-weight:700;font-size:14px">Beef tacos</div><span style="padding:3px 9px;border-radius:999px;background:var(--color-neutral-100);color:var(--color-neutral-800);font-size:10px;font-weight:700">Regular</span><svg viewBox="0 0 24 24" width="16" height="16" style="color:var(--color-neutral-600);fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"></path><path d="M21 3v5h-5"></path><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"></path><path d="M8 16H3v5"></path></svg></div>
<div style="display:flex;align-items:center;gap:12px;padding:9px 12px;border-radius:18px;background:var(--color-surface)"><div style="width:36px;text-align:center;font-size:11px;font-weight:700;color:var(--color-neutral-700);line-height:1.1">TUE<br><span style="font-size:16px;color:var(--color-text)">6</span></div><div style="flex:1;font-weight:700;font-size:14px">Spaghetti bolognese</div><span style="padding:3px 9px;border-radius:999px;background:var(--color-neutral-100);color:var(--color-neutral-800);font-size:10px;font-weight:700">Regular</span><svg viewBox="0 0 24 24" width="16" height="16" style="color:var(--color-neutral-600);fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"></path><path d="M21 3v5h-5"></path><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"></path><path d="M8 16H3v5"></path></svg></div>
<div style="display:flex;align-items:center;gap:12px;padding:9px 12px;border-radius:18px;background:var(--color-accent-100);box-shadow:inset 0 0 0 1.5px var(--color-accent-300)"><div style="width:36px;text-align:center;font-size:11px;font-weight:700;color:var(--color-accent-700);line-height:1.1">WED<br><span style="font-size:16px;color:var(--color-text)">7</span></div><div style="flex:1;font-weight:700;font-size:14px">Crock pot tikka masala</div><span style="padding:3px 9px;border-radius:999px;background:var(--color-accent-200);color:var(--color-accent-800);font-size:10px;font-weight:700">Side quest</span><svg viewBox="0 0 24 24" width="16" height="16" style="color:var(--color-neutral-600);fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"></path><path d="M21 3v5h-5"></path><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"></path><path d="M8 16H3v5"></path></svg></div>
<div style="display:flex;align-items:center;gap:12px;padding:9px 12px;border-radius:18px;background:var(--color-surface)"><div style="width:36px;text-align:center;font-size:11px;font-weight:700;color:var(--color-neutral-700);line-height:1.1">THU<br><span style="font-size:16px;color:var(--color-text)">8</span></div><div style="flex:1;font-weight:700;font-size:14px">Chicken stir-fry</div><span style="padding:3px 9px;border-radius:999px;background:var(--color-neutral-100);color:var(--color-neutral-800);font-size:10px;font-weight:700">Regular</span><svg viewBox="0 0 24 24" width="16" height="16" style="color:var(--color-neutral-600);fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"></path><path d="M21 3v5h-5"></path><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"></path><path d="M8 16H3v5"></path></svg></div>
<div style="display:flex;align-items:center;gap:12px;padding:9px 12px;border-radius:18px;background:var(--color-surface)"><div style="width:36px;text-align:center;font-size:11px;font-weight:700;color:var(--color-neutral-700);line-height:1.1">FRI<br><span style="font-size:16px;color:var(--color-text)">9</span></div><div style="flex:1;font-weight:700;font-size:14px">Pizza night</div><span style="padding:3px 9px;border-radius:999px;background:var(--color-neutral-100);color:var(--color-neutral-800);font-size:10px;font-weight:700">Regular</span><svg viewBox="0 0 24 24" width="16" height="16" style="color:var(--color-neutral-600);fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"></path><path d="M21 3v5h-5"></path><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"></path><path d="M8 16H3v5"></path></svg></div>
<div style="display:flex;align-items:center;gap:12px;padding:9px 12px;border-radius:18px;background:var(--color-accent-100);box-shadow:inset 0 0 0 1.5px var(--color-accent-300)"><div style="width:36px;text-align:center;font-size:11px;font-weight:700;color:var(--color-accent-700);line-height:1.1">SAT<br><span style="font-size:16px;color:var(--color-text)">10</span></div><div style="flex:1;font-weight:700;font-size:14px">Korean beef bowls</div><span style="padding:3px 9px;border-radius:999px;background:var(--color-accent-200);color:var(--color-accent-800);font-size:10px;font-weight:700">Side quest</span><svg viewBox="0 0 24 24" width="16" height="16" style="color:var(--color-neutral-600);fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"></path><path d="M21 3v5h-5"></path><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"></path><path d="M8 16H3v5"></path></svg></div>
<div style="display:flex;align-items:center;gap:12px;padding:9px 12px;border-radius:18px;background:var(--color-surface)"><div style="width:36px;text-align:center;font-size:11px;font-weight:700;color:var(--color-neutral-700);line-height:1.1">SUN<br><span style="font-size:16px;color:var(--color-text)">11</span></div><div style="flex:1;font-weight:700;font-size:14px">Sheet-pan salmon</div><span style="padding:3px 9px;border-radius:999px;background:var(--color-neutral-100);color:var(--color-neutral-800);font-size:10px;font-weight:700">Regular</span><svg viewBox="0 0 24 24" width="16" height="16" style="color:var(--color-neutral-600);fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"></path><path d="M21 3v5h-5"></path><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"></path><path d="M8 16H3v5"></path></svg></div>
</div>
<div style="margin:14px 20px 0;height:50px;border-radius:999px;background:var(--color-accent);display:flex;align-items:center;justify-content:center;gap:8px;font-family:var(--font-heading);font-size:15px;color:var(--on-accent)"><svg viewBox="0 0 24 24" width="18" height="18" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><circle cx="8" cy="21" r="1"></circle><circle cx="19" cy="21" r="1"></circle><path d="M2.05 2.05h2l2.66 12.42a2 2 0 0 0 2 1.58h9.78a2 2 0 0 0 1.95-1.57l1.65-7.43H5.12"></path></svg>Shopping list · 23 items</div>
<div style="position:absolute;left:14px;right:14px;bottom:16px;height:62px;border-radius:999px;background:var(--color-text);display:flex;align-items:center;justify-content:space-around;color:var(--color-neutral-400)">
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"></path><path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path></svg>
<div style="display:flex;align-items:center;gap:6px;padding:10px 14px;border-radius:999px;background:var(--color-accent);color:var(--on-accent);font-size:13px;font-weight:700"><svg viewBox="0 0 24 24" width="20" height="20" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M8 2v4"></path><path d="M16 2v4"></path><rect width="18" height="18" x="3" y="4" rx="2"></rect><path d="M3 10h18"></path></svg>Plan</div>
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><circle cx="12" cy="12" r="10"></circle><path d="m16.24 7.76-1.804 5.411a2 2 0 0 1-1.265 1.265L7.76 16.24l1.804-5.411a2 2 0 0 1 1.265-1.265z"></path></svg>
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M10 5H3"></path><path d="M12 19H3"></path><path d="M14 3v4"></path><path d="M16 17v4"></path><path d="M21 12h-9"></path><path d="M21 19h-5"></path><path d="M21 5h-7"></path><path d="M8 10v4"></path><path d="M8 12H3"></path></svg>
</div>
</div></div><div style="display:flex;flex-direction:column;gap:8px"><div style="font-size:12px;font-weight:700;color:var(--color-neutral-700)">Shopping list</div><div style="width:360px;height:760px;position:relative;border-radius:36px;background:var(--color-bg);overflow:hidden;box-shadow:var(--shadow-md)">
<div style="height:40px;display:flex;align-items:center;justify-content:space-between;padding:0 26px;font-size:13px;font-weight:700"><span>9:41</span><span style="width:22px;height:10px;border-radius:3px;background:var(--color-text)"></span></div>
<div style="padding:6px 20px 0;display:flex;justify-content:space-between;align-items:flex-end"><div><div style="font-size:11px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:var(--color-accent-700)">Supply run · Oct 5–11</div><div style="font-family:var(--font-heading);font-size:28px;line-height:1.1">Shopping list</div></div><div style="font-size:13px;font-weight:700;color:var(--color-neutral-700)">19 / 23</div></div>
<div style="margin:10px 20px 0;font-size:12px;line-height:1.45;color:var(--color-neutral-800)">Uncheck anything you already have — it won't be counted.</div>
<div style="padding:12px 20px 0;display:flex;flex-direction:column;gap:12px">
<div style="padding:14px;border-radius:24px;background:var(--color-surface);display:flex;flex-direction:column;gap:9px">
<div style="display:flex;justify-content:space-between;align-items:baseline"><span style="font-family:var(--font-heading);font-size:16px">Beef tacos</span><span style="font-size:11px;font-weight:700;color:var(--color-neutral-700)">MON</span></div>
<div style="display:flex;align-items:center;gap:10px;font-size:14px"><span style="width:20px;height:20px;border-radius:7px;background:var(--color-accent);color:var(--on-accent);display:flex;align-items:center;justify-content:center"><svg viewBox="0 0 24 24" width="13" height="13" style="fill:none;stroke:currentColor;stroke-width:3.5;stroke-linecap:round;stroke-linejoin:round"><path d="M20 6 9 17l-5-5"></path></svg></span><span style="flex:1">Ground beef</span><span style="color:var(--color-neutral-700);font-size:12px">1 lb</span></div>
<div style="display:flex;align-items:center;gap:10px;font-size:14px"><span style="width:20px;height:20px;border-radius:7px;background:var(--color-accent);color:var(--on-accent);display:flex;align-items:center;justify-content:center"><svg viewBox="0 0 24 24" width="13" height="13" style="fill:none;stroke:currentColor;stroke-width:3.5;stroke-linecap:round;stroke-linejoin:round"><path d="M20 6 9 17l-5-5"></path></svg></span><span style="flex:1">Corn tortillas</span><span style="color:var(--color-neutral-700);font-size:12px">12</span></div>
<div style="display:flex;align-items:center;gap:10px;font-size:14px;color:var(--color-neutral-600)"><span style="width:20px;height:20px;border-radius:7px;border:2px solid var(--color-neutral-400)"></span><span style="flex:1;text-decoration:line-through">Taco seasoning</span><span style="font-size:11px;font-weight:700">have it</span></div>
</div>
<div style="padding:14px;border-radius:24px;background:var(--color-surface);display:flex;flex-direction:column;gap:9px">
<div style="display:flex;justify-content:space-between;align-items:baseline"><span style="font-family:var(--font-heading);font-size:16px">Crock pot tikka masala</span><span style="font-size:11px;font-weight:700;color:var(--color-neutral-700)">WED</span></div>
<div style="display:flex;align-items:center;gap:10px;font-size:14px"><span style="width:20px;height:20px;border-radius:7px;background:var(--color-accent);color:var(--on-accent);display:flex;align-items:center;justify-content:center"><svg viewBox="0 0 24 24" width="13" height="13" style="fill:none;stroke:currentColor;stroke-width:3.5;stroke-linecap:round;stroke-linejoin:round"><path d="M20 6 9 17l-5-5"></path></svg></span><span style="flex:1">Chicken thighs</span><span style="color:var(--color-neutral-700);font-size:12px">2 lb</span></div>
<div style="display:flex;align-items:center;gap:10px;font-size:14px"><span style="width:20px;height:20px;border-radius:7px;background:var(--color-accent);color:var(--on-accent);display:flex;align-items:center;justify-content:center"><svg viewBox="0 0 24 24" width="13" height="13" style="fill:none;stroke:currentColor;stroke-width:3.5;stroke-linecap:round;stroke-linejoin:round"><path d="M20 6 9 17l-5-5"></path></svg></span><span style="flex:1">Tikka masala paste</span><span style="color:var(--color-neutral-700);font-size:12px">1 jar</span></div>
<div style="display:flex;align-items:center;gap:10px;font-size:14px;color:var(--color-neutral-600)"><span style="width:20px;height:20px;border-radius:7px;border:2px solid var(--color-neutral-400)"></span><span style="flex:1;text-decoration:line-through">Basmati rice</span><span style="font-size:11px;font-weight:700">have it</span></div>
</div>
<div style="padding:14px;border-radius:24px;background:var(--color-surface);display:flex;justify-content:space-between;align-items:baseline"><span style="font-family:var(--font-heading);font-size:16px">Chicken stir-fry</span><span style="font-size:11px;font-weight:700;color:var(--color-neutral-700)">THU · 5 items</span></div>
</div>
<div style="position:absolute;left:20px;right:20px;bottom:96px;display:flex;align-items:center;gap:8px;font-size:12px;font-weight:700;color:var(--color-accent-2-800)"><svg viewBox="0 0 24 24" width="15" height="15" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path></svg>Sent to Google Chat · Sat 8:00 am</div>
<div style="position:absolute;left:14px;right:14px;bottom:16px;height:62px;border-radius:999px;background:var(--color-text);display:flex;align-items:center;justify-content:space-around;color:var(--color-neutral-400)">
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"></path><path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path></svg>
<div style="display:flex;align-items:center;gap:6px;padding:10px 14px;border-radius:999px;background:var(--color-accent);color:var(--on-accent);font-size:13px;font-weight:700"><svg viewBox="0 0 24 24" width="20" height="20" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M8 2v4"></path><path d="M16 2v4"></path><rect width="18" height="18" x="3" y="4" rx="2"></rect><path d="M3 10h18"></path></svg>Plan</div>
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><circle cx="12" cy="12" r="10"></circle><path d="m16.24 7.76-1.804 5.411a2 2 0 0 1-1.265 1.265L7.76 16.24l1.804-5.411a2 2 0 0 1 1.265-1.265z"></path></svg>
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M10 5H3"></path><path d="M12 19H3"></path><path d="M14 3v4"></path><path d="M16 17v4"></path><path d="M21 12h-9"></path><path d="M21 19h-5"></path><path d="M21 5h-7"></path><path d="M8 10v4"></path><path d="M8 12H3"></path></svg>
</div>
</div></div></div>
</div>
<div id="b4" style="--color-bg:oklch(0.975 0.008 85);--color-surface:oklch(0.935 0.016 85);--color-accent:oklch(0.42 0.14 300);--color-accent-100:oklch(0.97 0.032 300);--color-accent-200:oklch(0.93 0.063 300);--color-accent-300:oklch(0.86 0.095 300);--color-accent-400:oklch(0.77 0.126 300);--color-accent-500:oklch(0.67 0.147 300);--color-accent-600:oklch(0.57 0.147 300);--color-accent-700:oklch(0.47 0.137 300);--color-accent-800:oklch(0.37 0.116 300);--color-accent-900:oklch(0.27 0.084 300);--on-accent:var(--color-bg);--color-accent-2:oklch(0.58 0.09 215);--color-accent-2-100:oklch(0.97 0.030 215);--color-accent-2-200:oklch(0.93 0.060 215);--color-accent-2-300:oklch(0.86 0.090 215);--color-accent-2-400:oklch(0.77 0.120 215);--color-accent-2-500:oklch(0.67 0.140 215);--color-accent-2-600:oklch(0.57 0.140 215);--color-accent-2-700:oklch(0.47 0.130 215);--color-accent-2-800:oklch(0.37 0.110 215);--color-accent-2-900:oklch(0.27 0.080 215);padding:32px 40px;border-radius:40px;background:var(--color-bg);display:flex;flex-direction:column;gap:20px;color:var(--color-text);font-family:var(--font-body)">
<div style="display:flex;align-items:center;gap:20px;flex-wrap:wrap"><div style="display:flex;flex-direction:column;gap:4px;min-width:280px"><div style="font-family:var(--font-heading);font-size:28px;line-height:1.15;white-space:nowrap">B4 · Grape + teal blue · off-white</div><div style="font-size:14px;color:var(--color-neutral-800)">Green-leaning blue gives the strongest contrast with grape.</div></div><div style="display:flex;gap:8px"><span style="width:44px;height:44px;border-radius:50%;background:var(--color-bg);box-shadow:inset 0 0 0 1px var(--color-divider)"></span><span style="width:44px;height:44px;border-radius:50%;background:var(--color-surface);box-shadow:inset 0 0 0 1px var(--color-divider)"></span><span style="width:44px;height:44px;border-radius:50%;background:var(--color-accent);box-shadow:inset 0 0 0 1px var(--color-divider)"></span><span style="width:44px;height:44px;border-radius:50%;background:var(--color-accent-2);box-shadow:inset 0 0 0 1px var(--color-divider)"></span><span style="width:44px;height:44px;border-radius:50%;background:var(--color-text);box-shadow:inset 0 0 0 1px var(--color-divider)"></span></div><div style="display:flex;gap:3px"><span style="width:18px;height:44px;border-radius:999px;background:var(--color-accent-100)"></span><span style="width:18px;height:44px;border-radius:999px;background:var(--color-accent-200)"></span><span style="width:18px;height:44px;border-radius:999px;background:var(--color-accent-300)"></span><span style="width:18px;height:44px;border-radius:999px;background:var(--color-accent-400)"></span><span style="width:18px;height:44px;border-radius:999px;background:var(--color-accent-500)"></span><span style="width:18px;height:44px;border-radius:999px;background:var(--color-accent-600)"></span><span style="width:18px;height:44px;border-radius:999px;background:var(--color-accent-700)"></span><span style="width:18px;height:44px;border-radius:999px;background:var(--color-accent-800)"></span><span style="width:18px;height:44px;border-radius:999px;background:var(--color-accent-900)"></span></div><div style="display:flex;gap:3px"><span style="width:18px;height:44px;border-radius:999px;background:var(--color-accent-2-100)"></span><span style="width:18px;height:44px;border-radius:999px;background:var(--color-accent-2-200)"></span><span style="width:18px;height:44px;border-radius:999px;background:var(--color-accent-2-300)"></span><span style="width:18px;height:44px;border-radius:999px;background:var(--color-accent-2-400)"></span><span style="width:18px;height:44px;border-radius:999px;background:var(--color-accent-2-500)"></span><span style="width:18px;height:44px;border-radius:999px;background:var(--color-accent-2-600)"></span><span style="width:18px;height:44px;border-radius:999px;background:var(--color-accent-2-700)"></span><span style="width:18px;height:44px;border-radius:999px;background:var(--color-accent-2-800)"></span><span style="width:18px;height:44px;border-radius:999px;background:var(--color-accent-2-900)"></span></div></div>
<div style="display:flex;gap:28px;flex-wrap:wrap"><div style="display:flex;flex-direction:column;gap:8px"><div style="font-size:12px;font-weight:700;color:var(--color-neutral-700)">Home</div><div style="width:360px;height:760px;position:relative;border-radius:36px;background:var(--color-bg);overflow:hidden;box-shadow:var(--shadow-md)">
<div style="height:40px;display:flex;align-items:center;justify-content:space-between;padding:0 26px;font-size:13px;font-weight:700"><span>9:41</span><span style="width:22px;height:10px;border-radius:3px;background:var(--color-text)"></span></div>
<div style="padding:6px 20px 0;display:flex;justify-content:space-between;align-items:flex-start">
<div><div style="font-size:12px;font-weight:700;color:var(--color-neutral-700)">Friday, Oct 2</div><div style="font-family:var(--font-heading);font-size:28px;line-height:1.1">Tonight's quest</div></div>
<div style="position:relative;width:40px;height:40px;border-radius:50%;background:var(--color-surface);display:flex;align-items:center;justify-content:center"><svg viewBox="0 0 24 24" width="19" height="19" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M10.268 21a2 2 0 0 0 3.464 0"></path><path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326"></path></svg><span style="position:absolute;top:8px;right:9px;width:8px;height:8px;border-radius:50%;background:var(--color-accent);color:var(--on-accent)"></span></div>
</div>
<div style="margin:14px 20px 0;border-radius:32px;background:var(--color-surface);overflow:hidden;display:flex;flex-direction:column">
<div style="height:150px;margin:10px;border-radius:24px;background:repeating-linear-gradient(135deg,var(--color-neutral-300) 0 8px,var(--color-neutral-200) 8px 16px);display:flex;align-items:center;justify-content:center;font:11px ui-monospace,Menlo,monospace;color:var(--color-neutral-700)">dish photo</div>
<div style="padding:2px 18px 16px;display:flex;align-items:flex-end;justify-content:space-between;gap:10px"><div><div style="font-family:var(--font-heading);font-size:22px;line-height:1.1">Pizza night</div><div style="font-size:12px;color:var(--color-neutral-700);margin-top:4px">Regular · 40 min</div></div><div style="height:38px;padding:0 16px;border-radius:999px;background:var(--color-text);color:var(--color-bg);display:flex;align-items:center;font-family:var(--font-heading);font-size:13px">Recipe</div></div>
</div>
<div style="margin:12px 20px 0;display:grid;grid-template-columns:1fr 1fr;gap:10px">
<div style="height:104px;padding:14px;border-radius:26px;background:var(--color-accent);color:var(--on-accent);display:flex;flex-direction:column;justify-content:space-between"><svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z"></path></svg><span style="font-family:var(--font-heading);font-size:16px;line-height:1.1">Quick dinner</span></div>
<div style="height:104px;padding:14px;border-radius:26px;background:var(--color-accent-2-300);color:var(--color-accent-2-900);display:flex;flex-direction:column;justify-content:space-between"><svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><circle cx="11" cy="11" r="8"></circle><path d="m21 21-4.3-4.3"></path></svg><span style="font-family:var(--font-heading);font-size:16px;line-height:1.1">Cook from what I have</span></div>
</div>
<div style="margin:12px 20px 0;display:flex;flex-direction:column;gap:8px">
<div style="display:flex;align-items:center;gap:12px;padding:12px 14px;border-radius:20px;background:var(--color-neutral-100)"><svg viewBox="0 0 24 24" width="18" height="18" style="color:var(--color-accent-700);fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><circle cx="12" cy="12" r="10"></circle><path d="m16.24 7.76-1.804 5.411a2 2 0 0 1-1.265 1.265L7.76 16.24l1.804-5.411a2 2 0 0 1 1.265-1.265z"></path></svg><span style="flex:1;font-size:13px;font-weight:700">10 new side quests to swipe</span><svg viewBox="0 0 24 24" width="16" height="16" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="m9 18 6-6-6-6"></path></svg></div>
<div style="display:flex;align-items:center;gap:12px;padding:12px 14px;border-radius:20px;background:var(--color-neutral-100)"><svg viewBox="0 0 24 24" width="18" height="18" style="color:var(--color-accent-700);fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><circle cx="8" cy="21" r="1"></circle><circle cx="19" cy="21" r="1"></circle><path d="M2.05 2.05h2l2.66 12.42a2 2 0 0 0 2 1.58h9.78a2 2 0 0 0 1.95-1.57l1.65-7.43H5.12"></path></svg><span style="flex:1;font-size:13px;font-weight:700">Shopping list ready · Oct 5–11</span><svg viewBox="0 0 24 24" width="16" height="16" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="m9 18 6-6-6-6"></path></svg></div>
</div>
<div style="position:absolute;left:14px;right:14px;bottom:16px;height:62px;border-radius:999px;background:var(--color-text);display:flex;align-items:center;justify-content:space-around;color:var(--color-neutral-400)">
<div style="display:flex;align-items:center;gap:6px;padding:10px 14px;border-radius:999px;background:var(--color-accent);color:var(--on-accent);font-size:13px;font-weight:700"><svg viewBox="0 0 24 24" width="20" height="20" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"></path><path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path></svg>Home</div>
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M8 2v4"></path><path d="M16 2v4"></path><rect width="18" height="18" x="3" y="4" rx="2"></rect><path d="M3 10h18"></path></svg>
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><circle cx="12" cy="12" r="10"></circle><path d="m16.24 7.76-1.804 5.411a2 2 0 0 1-1.265 1.265L7.76 16.24l1.804-5.411a2 2 0 0 1 1.265-1.265z"></path></svg>
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M10 5H3"></path><path d="M12 19H3"></path><path d="M14 3v4"></path><path d="M16 17v4"></path><path d="M21 12h-9"></path><path d="M21 19h-5"></path><path d="M21 5h-7"></path><path d="M8 10v4"></path><path d="M8 12H3"></path></svg>
</div>
</div></div><div style="display:flex;flex-direction:column;gap:8px"><div style="font-size:12px;font-weight:700;color:var(--color-neutral-700)">Discover</div><div style="width:360px;height:760px;position:relative;border-radius:36px;background:var(--color-bg);overflow:hidden;box-shadow:var(--shadow-md)">
<div style="height:40px;display:flex;align-items:center;justify-content:space-between;padding:0 26px;font-size:13px;font-weight:700"><span>9:41</span><span style="width:22px;height:10px;border-radius:3px;background:var(--color-text)"></span></div>
<div style="padding:8px 20px 0;display:flex;align-items:flex-end;justify-content:space-between">
<div><div style="font-size:11px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:var(--color-accent-700)">Discover</div><div style="font-family:var(--font-heading);font-size:30px;line-height:1.1">Side quests</div></div>
<div style="display:flex;align-items:center;gap:6px;padding:7px 12px;border-radius:999px;background:var(--color-accent-2-200);color:var(--color-accent-2-800);font-size:12px;font-weight:700">Quest log · 6</div>
</div>
<div style="position:relative;margin:18px 20px 0;height:440px">
<div style="position:absolute;inset:0;transform:translateY(16px) scale(.9);border-radius:32px;background:var(--color-neutral-300)"></div>
<div style="position:absolute;inset:0;transform:translateY(8px) scale(.95);border-radius:32px;background:var(--color-surface)"></div>
<div style="position:absolute;inset:0;border-radius:32px;background:var(--color-neutral-100);box-shadow:var(--shadow-lg);overflow:hidden;display:flex;flex-direction:column">
<div style="height:220px;margin:10px;border-radius:24px;background:repeating-linear-gradient(135deg,var(--color-neutral-300) 0 8px,var(--color-neutral-200) 8px 16px);display:flex;align-items:center;justify-content:center;font:11px ui-monospace,Menlo,monospace;color:var(--color-neutral-700)">dish photo</div>
<div style="padding:4px 20px 0;display:flex;flex-direction:column;gap:10px">
<div style="display:flex;align-items:center;justify-content:space-between"><div style="font-family:var(--font-heading);font-size:24px;line-height:1.1">Korean beef bowls</div></div>
<div style="display:flex;align-items:center;gap:8px;font-size:12px;font-weight:700;color:var(--color-neutral-700)"><span style="display:flex;gap:3px"><span style="width:8px;height:8px;border-radius:50%;background:var(--color-accent);color:var(--on-accent)"></span><span style="width:8px;height:8px;border-radius:50%;background:var(--color-neutral-300)"></span><span style="width:8px;height:8px;border-radius:50%;background:var(--color-neutral-300)"></span></span>Easy</div>
<div style="padding:12px 14px;border-radius:18px;background:var(--color-accent-2-100);color:var(--color-accent-2-900);font-size:13px;line-height:1.45"><b>Why this?</b> You make Chicken stir-fry and Beef tacos most weeks. Same flavors, sweet-savory twist.</div>
</div>
</div>
</div>
<div style="display:flex;justify-content:center;gap:28px;margin-top:22px">
<div style="display:flex;flex-direction:column;align-items:center;gap:6px;font-size:11px;font-weight:700;color:var(--color-neutral-700)"><div style="width:62px;height:62px;border-radius:50%;background:var(--color-neutral-100);box-shadow:var(--shadow-md);display:flex;align-items:center;justify-content:center;color:var(--color-text)"><svg viewBox="0 0 24 24" width="26" height="26" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M18 6 6 18"></path><path d="m6 6 12 12"></path></svg></div>Pass</div>
<div style="display:flex;flex-direction:column;align-items:center;gap:6px;font-size:11px;font-weight:700;color:var(--color-neutral-700)"><div style="width:62px;height:62px;border-radius:50%;background:var(--color-accent-2-600);box-shadow:var(--shadow-md);display:flex;align-items:center;justify-content:center;color:var(--color-bg)"><svg viewBox="0 0 24 24" width="26" height="26" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z"></path></svg></div>Try it</div>
</div>
<div style="position:absolute;left:14px;right:14px;bottom:16px;height:62px;border-radius:999px;background:var(--color-text);display:flex;align-items:center;justify-content:space-around;color:var(--color-neutral-400)">
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"></path><path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path></svg>
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M8 2v4"></path><path d="M16 2v4"></path><rect width="18" height="18" x="3" y="4" rx="2"></rect><path d="M3 10h18"></path></svg>
<div style="display:flex;align-items:center;gap:6px;padding:10px 14px;border-radius:999px;background:var(--color-accent);color:var(--on-accent);font-size:13px;font-weight:700"><svg viewBox="0 0 24 24" width="20" height="20" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><circle cx="12" cy="12" r="10"></circle><path d="m16.24 7.76-1.804 5.411a2 2 0 0 1-1.265 1.265L7.76 16.24l1.804-5.411a2 2 0 0 1 1.265-1.265z"></path></svg>Discover</div>
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M10 5H3"></path><path d="M12 19H3"></path><path d="M14 3v4"></path><path d="M16 17v4"></path><path d="M21 12h-9"></path><path d="M21 19h-5"></path><path d="M21 5h-7"></path><path d="M8 10v4"></path><path d="M8 12H3"></path></svg>
</div>
</div></div><div style="display:flex;flex-direction:column;gap:8px"><div style="font-size:12px;font-weight:700;color:var(--color-neutral-700)">Weekly plan</div><div style="width:360px;height:760px;position:relative;border-radius:36px;background:var(--color-bg);overflow:hidden;box-shadow:var(--shadow-md)">
<div style="height:40px;display:flex;align-items:center;justify-content:space-between;padding:0 26px;font-size:13px;font-weight:700"><span>9:41</span><span style="width:22px;height:10px;border-radius:3px;background:var(--color-text)"></span></div>
<div style="padding:6px 20px 0;display:flex;align-items:center;justify-content:space-between">
<div><div style="font-size:11px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:var(--color-accent-700)">Week 41</div><div style="font-family:var(--font-heading);font-size:26px;line-height:1.1">Oct 5 – 11</div></div>
<div style="display:flex;gap:6px"><div style="width:36px;height:36px;border-radius:50%;background:var(--color-surface);display:flex;align-items:center;justify-content:center"><svg viewBox="0 0 24 24" width="18" height="18" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="m15 18-6-6 6-6"></path></svg></div><div style="width:36px;height:36px;border-radius:50%;background:var(--color-surface);display:flex;align-items:center;justify-content:center"><svg viewBox="0 0 24 24" width="18" height="18" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="m9 18 6-6-6-6"></path></svg></div></div>
</div>
<div style="margin:14px 20px 0;padding:10px 14px;border-radius:18px;background:var(--color-accent-2-100);color:var(--color-accent-2-900);font-size:12px;line-height:1.4;display:flex;gap:8px;align-items:center"><svg viewBox="0 0 24 24" width="16" height="16" style="flex:none;fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M20 6 9 17l-5-5"></path></svg><span>5 regulars + 2 side quests · synced to Google Calendar</span></div>
<div style="margin:12px 20px 0;display:flex;flex-direction:column;gap:6px">
<div style="display:flex;align-items:center;gap:12px;padding:9px 12px;border-radius:18px;background:var(--color-surface)"><div style="width:36px;text-align:center;font-size:11px;font-weight:700;color:var(--color-neutral-700);line-height:1.1">MON<br><span style="font-size:16px;color:var(--color-text)">5</span></div><div style="flex:1;font-weight:700;font-size:14px">Beef tacos</div><span style="padding:3px 9px;border-radius:999px;background:var(--color-neutral-100);color:var(--color-neutral-800);font-size:10px;font-weight:700">Regular</span><svg viewBox="0 0 24 24" width="16" height="16" style="color:var(--color-neutral-600);fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"></path><path d="M21 3v5h-5"></path><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"></path><path d="M8 16H3v5"></path></svg></div>
<div style="display:flex;align-items:center;gap:12px;padding:9px 12px;border-radius:18px;background:var(--color-surface)"><div style="width:36px;text-align:center;font-size:11px;font-weight:700;color:var(--color-neutral-700);line-height:1.1">TUE<br><span style="font-size:16px;color:var(--color-text)">6</span></div><div style="flex:1;font-weight:700;font-size:14px">Spaghetti bolognese</div><span style="padding:3px 9px;border-radius:999px;background:var(--color-neutral-100);color:var(--color-neutral-800);font-size:10px;font-weight:700">Regular</span><svg viewBox="0 0 24 24" width="16" height="16" style="color:var(--color-neutral-600);fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"></path><path d="M21 3v5h-5"></path><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"></path><path d="M8 16H3v5"></path></svg></div>
<div style="display:flex;align-items:center;gap:12px;padding:9px 12px;border-radius:18px;background:var(--color-accent-100);box-shadow:inset 0 0 0 1.5px var(--color-accent-300)"><div style="width:36px;text-align:center;font-size:11px;font-weight:700;color:var(--color-accent-700);line-height:1.1">WED<br><span style="font-size:16px;color:var(--color-text)">7</span></div><div style="flex:1;font-weight:700;font-size:14px">Crock pot tikka masala</div><span style="padding:3px 9px;border-radius:999px;background:var(--color-accent-200);color:var(--color-accent-800);font-size:10px;font-weight:700">Side quest</span><svg viewBox="0 0 24 24" width="16" height="16" style="color:var(--color-neutral-600);fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"></path><path d="M21 3v5h-5"></path><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"></path><path d="M8 16H3v5"></path></svg></div>
<div style="display:flex;align-items:center;gap:12px;padding:9px 12px;border-radius:18px;background:var(--color-surface)"><div style="width:36px;text-align:center;font-size:11px;font-weight:700;color:var(--color-neutral-700);line-height:1.1">THU<br><span style="font-size:16px;color:var(--color-text)">8</span></div><div style="flex:1;font-weight:700;font-size:14px">Chicken stir-fry</div><span style="padding:3px 9px;border-radius:999px;background:var(--color-neutral-100);color:var(--color-neutral-800);font-size:10px;font-weight:700">Regular</span><svg viewBox="0 0 24 24" width="16" height="16" style="color:var(--color-neutral-600);fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"></path><path d="M21 3v5h-5"></path><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"></path><path d="M8 16H3v5"></path></svg></div>
<div style="display:flex;align-items:center;gap:12px;padding:9px 12px;border-radius:18px;background:var(--color-surface)"><div style="width:36px;text-align:center;font-size:11px;font-weight:700;color:var(--color-neutral-700);line-height:1.1">FRI<br><span style="font-size:16px;color:var(--color-text)">9</span></div><div style="flex:1;font-weight:700;font-size:14px">Pizza night</div><span style="padding:3px 9px;border-radius:999px;background:var(--color-neutral-100);color:var(--color-neutral-800);font-size:10px;font-weight:700">Regular</span><svg viewBox="0 0 24 24" width="16" height="16" style="color:var(--color-neutral-600);fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"></path><path d="M21 3v5h-5"></path><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"></path><path d="M8 16H3v5"></path></svg></div>
<div style="display:flex;align-items:center;gap:12px;padding:9px 12px;border-radius:18px;background:var(--color-accent-100);box-shadow:inset 0 0 0 1.5px var(--color-accent-300)"><div style="width:36px;text-align:center;font-size:11px;font-weight:700;color:var(--color-accent-700);line-height:1.1">SAT<br><span style="font-size:16px;color:var(--color-text)">10</span></div><div style="flex:1;font-weight:700;font-size:14px">Korean beef bowls</div><span style="padding:3px 9px;border-radius:999px;background:var(--color-accent-200);color:var(--color-accent-800);font-size:10px;font-weight:700">Side quest</span><svg viewBox="0 0 24 24" width="16" height="16" style="color:var(--color-neutral-600);fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"></path><path d="M21 3v5h-5"></path><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"></path><path d="M8 16H3v5"></path></svg></div>
<div style="display:flex;align-items:center;gap:12px;padding:9px 12px;border-radius:18px;background:var(--color-surface)"><div style="width:36px;text-align:center;font-size:11px;font-weight:700;color:var(--color-neutral-700);line-height:1.1">SUN<br><span style="font-size:16px;color:var(--color-text)">11</span></div><div style="flex:1;font-weight:700;font-size:14px">Sheet-pan salmon</div><span style="padding:3px 9px;border-radius:999px;background:var(--color-neutral-100);color:var(--color-neutral-800);font-size:10px;font-weight:700">Regular</span><svg viewBox="0 0 24 24" width="16" height="16" style="color:var(--color-neutral-600);fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"></path><path d="M21 3v5h-5"></path><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"></path><path d="M8 16H3v5"></path></svg></div>
</div>
<div style="margin:14px 20px 0;height:50px;border-radius:999px;background:var(--color-accent);display:flex;align-items:center;justify-content:center;gap:8px;font-family:var(--font-heading);font-size:15px;color:var(--on-accent)"><svg viewBox="0 0 24 24" width="18" height="18" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><circle cx="8" cy="21" r="1"></circle><circle cx="19" cy="21" r="1"></circle><path d="M2.05 2.05h2l2.66 12.42a2 2 0 0 0 2 1.58h9.78a2 2 0 0 0 1.95-1.57l1.65-7.43H5.12"></path></svg>Shopping list · 23 items</div>
<div style="position:absolute;left:14px;right:14px;bottom:16px;height:62px;border-radius:999px;background:var(--color-text);display:flex;align-items:center;justify-content:space-around;color:var(--color-neutral-400)">
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"></path><path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path></svg>
<div style="display:flex;align-items:center;gap:6px;padding:10px 14px;border-radius:999px;background:var(--color-accent);color:var(--on-accent);font-size:13px;font-weight:700"><svg viewBox="0 0 24 24" width="20" height="20" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M8 2v4"></path><path d="M16 2v4"></path><rect width="18" height="18" x="3" y="4" rx="2"></rect><path d="M3 10h18"></path></svg>Plan</div>
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><circle cx="12" cy="12" r="10"></circle><path d="m16.24 7.76-1.804 5.411a2 2 0 0 1-1.265 1.265L7.76 16.24l1.804-5.411a2 2 0 0 1 1.265-1.265z"></path></svg>
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M10 5H3"></path><path d="M12 19H3"></path><path d="M14 3v4"></path><path d="M16 17v4"></path><path d="M21 12h-9"></path><path d="M21 19h-5"></path><path d="M21 5h-7"></path><path d="M8 10v4"></path><path d="M8 12H3"></path></svg>
</div>
</div></div><div style="display:flex;flex-direction:column;gap:8px"><div style="font-size:12px;font-weight:700;color:var(--color-neutral-700)">Shopping list</div><div style="width:360px;height:760px;position:relative;border-radius:36px;background:var(--color-bg);overflow:hidden;box-shadow:var(--shadow-md)">
<div style="height:40px;display:flex;align-items:center;justify-content:space-between;padding:0 26px;font-size:13px;font-weight:700"><span>9:41</span><span style="width:22px;height:10px;border-radius:3px;background:var(--color-text)"></span></div>
<div style="padding:6px 20px 0;display:flex;justify-content:space-between;align-items:flex-end"><div><div style="font-size:11px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:var(--color-accent-700)">Supply run · Oct 5–11</div><div style="font-family:var(--font-heading);font-size:28px;line-height:1.1">Shopping list</div></div><div style="font-size:13px;font-weight:700;color:var(--color-neutral-700)">19 / 23</div></div>
<div style="margin:10px 20px 0;font-size:12px;line-height:1.45;color:var(--color-neutral-800)">Uncheck anything you already have — it won't be counted.</div>
<div style="padding:12px 20px 0;display:flex;flex-direction:column;gap:12px">
<div style="padding:14px;border-radius:24px;background:var(--color-surface);display:flex;flex-direction:column;gap:9px">
<div style="display:flex;justify-content:space-between;align-items:baseline"><span style="font-family:var(--font-heading);font-size:16px">Beef tacos</span><span style="font-size:11px;font-weight:700;color:var(--color-neutral-700)">MON</span></div>
<div style="display:flex;align-items:center;gap:10px;font-size:14px"><span style="width:20px;height:20px;border-radius:7px;background:var(--color-accent);color:var(--on-accent);display:flex;align-items:center;justify-content:center"><svg viewBox="0 0 24 24" width="13" height="13" style="fill:none;stroke:currentColor;stroke-width:3.5;stroke-linecap:round;stroke-linejoin:round"><path d="M20 6 9 17l-5-5"></path></svg></span><span style="flex:1">Ground beef</span><span style="color:var(--color-neutral-700);font-size:12px">1 lb</span></div>
<div style="display:flex;align-items:center;gap:10px;font-size:14px"><span style="width:20px;height:20px;border-radius:7px;background:var(--color-accent);color:var(--on-accent);display:flex;align-items:center;justify-content:center"><svg viewBox="0 0 24 24" width="13" height="13" style="fill:none;stroke:currentColor;stroke-width:3.5;stroke-linecap:round;stroke-linejoin:round"><path d="M20 6 9 17l-5-5"></path></svg></span><span style="flex:1">Corn tortillas</span><span style="color:var(--color-neutral-700);font-size:12px">12</span></div>
<div style="display:flex;align-items:center;gap:10px;font-size:14px;color:var(--color-neutral-600)"><span style="width:20px;height:20px;border-radius:7px;border:2px solid var(--color-neutral-400)"></span><span style="flex:1;text-decoration:line-through">Taco seasoning</span><span style="font-size:11px;font-weight:700">have it</span></div>
</div>
<div style="padding:14px;border-radius:24px;background:var(--color-surface);display:flex;flex-direction:column;gap:9px">
<div style="display:flex;justify-content:space-between;align-items:baseline"><span style="font-family:var(--font-heading);font-size:16px">Crock pot tikka masala</span><span style="font-size:11px;font-weight:700;color:var(--color-neutral-700)">WED</span></div>
<div style="display:flex;align-items:center;gap:10px;font-size:14px"><span style="width:20px;height:20px;border-radius:7px;background:var(--color-accent);color:var(--on-accent);display:flex;align-items:center;justify-content:center"><svg viewBox="0 0 24 24" width="13" height="13" style="fill:none;stroke:currentColor;stroke-width:3.5;stroke-linecap:round;stroke-linejoin:round"><path d="M20 6 9 17l-5-5"></path></svg></span><span style="flex:1">Chicken thighs</span><span style="color:var(--color-neutral-700);font-size:12px">2 lb</span></div>
<div style="display:flex;align-items:center;gap:10px;font-size:14px"><span style="width:20px;height:20px;border-radius:7px;background:var(--color-accent);color:var(--on-accent);display:flex;align-items:center;justify-content:center"><svg viewBox="0 0 24 24" width="13" height="13" style="fill:none;stroke:currentColor;stroke-width:3.5;stroke-linecap:round;stroke-linejoin:round"><path d="M20 6 9 17l-5-5"></path></svg></span><span style="flex:1">Tikka masala paste</span><span style="color:var(--color-neutral-700);font-size:12px">1 jar</span></div>
<div style="display:flex;align-items:center;gap:10px;font-size:14px;color:var(--color-neutral-600)"><span style="width:20px;height:20px;border-radius:7px;border:2px solid var(--color-neutral-400)"></span><span style="flex:1;text-decoration:line-through">Basmati rice</span><span style="font-size:11px;font-weight:700">have it</span></div>
</div>
<div style="padding:14px;border-radius:24px;background:var(--color-surface);display:flex;justify-content:space-between;align-items:baseline"><span style="font-family:var(--font-heading);font-size:16px">Chicken stir-fry</span><span style="font-size:11px;font-weight:700;color:var(--color-neutral-700)">THU · 5 items</span></div>
</div>
<div style="position:absolute;left:20px;right:20px;bottom:96px;display:flex;align-items:center;gap:8px;font-size:12px;font-weight:700;color:var(--color-accent-2-800)"><svg viewBox="0 0 24 24" width="15" height="15" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path></svg>Sent to Google Chat · Sat 8:00 am</div>
<div style="position:absolute;left:14px;right:14px;bottom:16px;height:62px;border-radius:999px;background:var(--color-text);display:flex;align-items:center;justify-content:space-around;color:var(--color-neutral-400)">
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"></path><path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path></svg>
<div style="display:flex;align-items:center;gap:6px;padding:10px 14px;border-radius:999px;background:var(--color-accent);color:var(--on-accent);font-size:13px;font-weight:700"><svg viewBox="0 0 24 24" width="20" height="20" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M8 2v4"></path><path d="M16 2v4"></path><rect width="18" height="18" x="3" y="4" rx="2"></rect><path d="M3 10h18"></path></svg>Plan</div>
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><circle cx="12" cy="12" r="10"></circle><path d="m16.24 7.76-1.804 5.411a2 2 0 0 1-1.265 1.265L7.76 16.24l1.804-5.411a2 2 0 0 1 1.265-1.265z"></path></svg>
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M10 5H3"></path><path d="M12 19H3"></path><path d="M14 3v4"></path><path d="M16 17v4"></path><path d="M21 12h-9"></path><path d="M21 19h-5"></path><path d="M21 5h-7"></path><path d="M8 10v4"></path><path d="M8 12H3"></path></svg>
</div>
</div></div></div>
</div>
<div style="font-family:var(--font-heading);font-size:26px;color:var(--color-text);margin-top:24px">Round 1 · First purple options</div>
<div id="c0" style="--on-accent:var(--color-text);padding:32px 40px;border-radius:40px;background:var(--color-bg);display:flex;flex-direction:column;gap:20px;color:var(--color-text);font-family:var(--font-body)">
<div style="display:flex;align-items:center;gap:20px;flex-wrap:wrap"><div style="display:flex;flex-direction:column;gap:4px;min-width:280px"><div style="font-family:var(--font-heading);font-size:28px;line-height:1.15;white-space:nowrap">Current (terracotta)</div><div style="font-size:14px;color:var(--color-neutral-800)">For reference — today's Organic palette.</div></div><div style="display:flex;gap:8px"><span style="width:44px;height:44px;border-radius:50%;background:var(--color-bg);box-shadow:inset 0 0 0 1px var(--color-divider)"></span><span style="width:44px;height:44px;border-radius:50%;background:var(--color-surface);box-shadow:inset 0 0 0 1px var(--color-divider)"></span><span style="width:44px;height:44px;border-radius:50%;background:var(--color-accent);box-shadow:inset 0 0 0 1px var(--color-divider)"></span><span style="width:44px;height:44px;border-radius:50%;background:var(--color-accent-2);box-shadow:inset 0 0 0 1px var(--color-divider)"></span><span style="width:44px;height:44px;border-radius:50%;background:var(--color-text);box-shadow:inset 0 0 0 1px var(--color-divider)"></span></div><div style="display:flex;gap:3px"><span style="width:22px;height:44px;border-radius:999px;background:var(--color-accent-100)"></span><span style="width:22px;height:44px;border-radius:999px;background:var(--color-accent-200)"></span><span style="width:22px;height:44px;border-radius:999px;background:var(--color-accent-300)"></span><span style="width:22px;height:44px;border-radius:999px;background:var(--color-accent-400)"></span><span style="width:22px;height:44px;border-radius:999px;background:var(--color-accent-500)"></span><span style="width:22px;height:44px;border-radius:999px;background:var(--color-accent-600)"></span><span style="width:22px;height:44px;border-radius:999px;background:var(--color-accent-700)"></span><span style="width:22px;height:44px;border-radius:999px;background:var(--color-accent-800)"></span><span style="width:22px;height:44px;border-radius:999px;background:var(--color-accent-900)"></span></div></div>
<div style="display:flex;gap:28px;flex-wrap:wrap"><div style="display:flex;flex-direction:column;gap:8px"><div style="font-size:12px;font-weight:700;color:var(--color-neutral-700)">Home</div><div style="width:360px;height:760px;position:relative;border-radius:36px;background:var(--color-bg);overflow:hidden;box-shadow:var(--shadow-md)">
<div style="height:40px;display:flex;align-items:center;justify-content:space-between;padding:0 26px;font-size:13px;font-weight:700"><span>9:41</span><span style="width:22px;height:10px;border-radius:3px;background:var(--color-text)"></span></div>
<div style="padding:6px 20px 0;display:flex;justify-content:space-between;align-items:flex-start">
<div><div style="font-size:12px;font-weight:700;color:var(--color-neutral-700)">Friday, Oct 2</div><div style="font-family:var(--font-heading);font-size:28px;line-height:1.1">Tonight's quest</div></div>
<div style="position:relative;width:40px;height:40px;border-radius:50%;background:var(--color-surface);display:flex;align-items:center;justify-content:center"><svg viewBox="0 0 24 24" width="19" height="19" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M10.268 21a2 2 0 0 0 3.464 0"></path><path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326"></path></svg><span style="position:absolute;top:8px;right:9px;width:8px;height:8px;border-radius:50%;background:var(--color-accent);color:var(--on-accent)"></span></div>
</div>
<div style="margin:14px 20px 0;border-radius:32px;background:var(--color-surface);overflow:hidden;display:flex;flex-direction:column">
<div style="height:150px;margin:10px;border-radius:24px;background:repeating-linear-gradient(135deg,var(--color-neutral-300) 0 8px,var(--color-neutral-200) 8px 16px);display:flex;align-items:center;justify-content:center;font:11px ui-monospace,Menlo,monospace;color:var(--color-neutral-700)">dish photo</div>
<div style="padding:2px 18px 16px;display:flex;align-items:flex-end;justify-content:space-between;gap:10px"><div><div style="font-family:var(--font-heading);font-size:22px;line-height:1.1">Pizza night</div><div style="font-size:12px;color:var(--color-neutral-700);margin-top:4px">Regular · 40 min</div></div><div style="height:38px;padding:0 16px;border-radius:999px;background:var(--color-text);color:var(--color-bg);display:flex;align-items:center;font-family:var(--font-heading);font-size:13px">Recipe</div></div>
</div>
<div style="margin:12px 20px 0;display:grid;grid-template-columns:1fr 1fr;gap:10px">
<div style="height:104px;padding:14px;border-radius:26px;background:var(--color-accent);color:var(--on-accent);display:flex;flex-direction:column;justify-content:space-between"><svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z"></path></svg><span style="font-family:var(--font-heading);font-size:16px;line-height:1.1">Quick dinner</span></div>
<div style="height:104px;padding:14px;border-radius:26px;background:var(--color-accent-2-300);color:var(--color-accent-2-900);display:flex;flex-direction:column;justify-content:space-between"><svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><circle cx="11" cy="11" r="8"></circle><path d="m21 21-4.3-4.3"></path></svg><span style="font-family:var(--font-heading);font-size:16px;line-height:1.1">Cook from what I have</span></div>
</div>
<div style="margin:12px 20px 0;display:flex;flex-direction:column;gap:8px">
<div style="display:flex;align-items:center;gap:12px;padding:12px 14px;border-radius:20px;background:var(--color-neutral-100)"><svg viewBox="0 0 24 24" width="18" height="18" style="color:var(--color-accent-700);fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><circle cx="12" cy="12" r="10"></circle><path d="m16.24 7.76-1.804 5.411a2 2 0 0 1-1.265 1.265L7.76 16.24l1.804-5.411a2 2 0 0 1 1.265-1.265z"></path></svg><span style="flex:1;font-size:13px;font-weight:700">10 new side quests to swipe</span><svg viewBox="0 0 24 24" width="16" height="16" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="m9 18 6-6-6-6"></path></svg></div>
<div style="display:flex;align-items:center;gap:12px;padding:12px 14px;border-radius:20px;background:var(--color-neutral-100)"><svg viewBox="0 0 24 24" width="18" height="18" style="color:var(--color-accent-700);fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><circle cx="8" cy="21" r="1"></circle><circle cx="19" cy="21" r="1"></circle><path d="M2.05 2.05h2l2.66 12.42a2 2 0 0 0 2 1.58h9.78a2 2 0 0 0 1.95-1.57l1.65-7.43H5.12"></path></svg><span style="flex:1;font-size:13px;font-weight:700">Shopping list ready · Oct 5–11</span><svg viewBox="0 0 24 24" width="16" height="16" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="m9 18 6-6-6-6"></path></svg></div>
</div>
<div style="position:absolute;left:14px;right:14px;bottom:16px;height:62px;border-radius:999px;background:var(--color-text);display:flex;align-items:center;justify-content:space-around;color:var(--color-neutral-400)">
<div style="display:flex;align-items:center;gap:6px;padding:10px 14px;border-radius:999px;background:var(--color-accent);color:var(--on-accent);font-size:13px;font-weight:700"><svg viewBox="0 0 24 24" width="20" height="20" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"></path><path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path></svg>Home</div>
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M8 2v4"></path><path d="M16 2v4"></path><rect width="18" height="18" x="3" y="4" rx="2"></rect><path d="M3 10h18"></path></svg>
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><circle cx="12" cy="12" r="10"></circle><path d="m16.24 7.76-1.804 5.411a2 2 0 0 1-1.265 1.265L7.76 16.24l1.804-5.411a2 2 0 0 1 1.265-1.265z"></path></svg>
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M10 5H3"></path><path d="M12 19H3"></path><path d="M14 3v4"></path><path d="M16 17v4"></path><path d="M21 12h-9"></path><path d="M21 19h-5"></path><path d="M21 5h-7"></path><path d="M8 10v4"></path><path d="M8 12H3"></path></svg>
</div>
</div></div><div style="display:flex;flex-direction:column;gap:8px"><div style="font-size:12px;font-weight:700;color:var(--color-neutral-700)">Discover</div><div style="width:360px;height:760px;position:relative;border-radius:36px;background:var(--color-bg);overflow:hidden;box-shadow:var(--shadow-md)">
<div style="height:40px;display:flex;align-items:center;justify-content:space-between;padding:0 26px;font-size:13px;font-weight:700"><span>9:41</span><span style="width:22px;height:10px;border-radius:3px;background:var(--color-text)"></span></div>
<div style="padding:8px 20px 0;display:flex;align-items:flex-end;justify-content:space-between">
<div><div style="font-size:11px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:var(--color-accent-700)">Discover</div><div style="font-family:var(--font-heading);font-size:30px;line-height:1.1">Side quests</div></div>
<div style="display:flex;align-items:center;gap:6px;padding:7px 12px;border-radius:999px;background:var(--color-accent-2-200);color:var(--color-accent-2-800);font-size:12px;font-weight:700">Quest log · 6</div>
</div>
<div style="position:relative;margin:18px 20px 0;height:440px">
<div style="position:absolute;inset:0;transform:translateY(16px) scale(.9);border-radius:32px;background:var(--color-neutral-300)"></div>
<div style="position:absolute;inset:0;transform:translateY(8px) scale(.95);border-radius:32px;background:var(--color-surface)"></div>
<div style="position:absolute;inset:0;border-radius:32px;background:var(--color-neutral-100);box-shadow:var(--shadow-lg);overflow:hidden;display:flex;flex-direction:column">
<div style="height:220px;margin:10px;border-radius:24px;background:repeating-linear-gradient(135deg,var(--color-neutral-300) 0 8px,var(--color-neutral-200) 8px 16px);display:flex;align-items:center;justify-content:center;font:11px ui-monospace,Menlo,monospace;color:var(--color-neutral-700)">dish photo</div>
<div style="padding:4px 20px 0;display:flex;flex-direction:column;gap:10px">
<div style="display:flex;align-items:center;justify-content:space-between"><div style="font-family:var(--font-heading);font-size:24px;line-height:1.1">Korean beef bowls</div></div>
<div style="display:flex;align-items:center;gap:8px;font-size:12px;font-weight:700;color:var(--color-neutral-700)"><span style="display:flex;gap:3px"><span style="width:8px;height:8px;border-radius:50%;background:var(--color-accent);color:var(--on-accent)"></span><span style="width:8px;height:8px;border-radius:50%;background:var(--color-neutral-300)"></span><span style="width:8px;height:8px;border-radius:50%;background:var(--color-neutral-300)"></span></span>Easy</div>
<div style="padding:12px 14px;border-radius:18px;background:var(--color-accent-2-100);color:var(--color-accent-2-900);font-size:13px;line-height:1.45"><b>Why this?</b> You make Chicken stir-fry and Beef tacos most weeks. Same flavors, sweet-savory twist.</div>
</div>
</div>
</div>
<div style="display:flex;justify-content:center;gap:28px;margin-top:22px">
<div style="display:flex;flex-direction:column;align-items:center;gap:6px;font-size:11px;font-weight:700;color:var(--color-neutral-700)"><div style="width:62px;height:62px;border-radius:50%;background:var(--color-neutral-100);box-shadow:var(--shadow-md);display:flex;align-items:center;justify-content:center;color:var(--color-text)"><svg viewBox="0 0 24 24" width="26" height="26" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M18 6 6 18"></path><path d="m6 6 12 12"></path></svg></div>Pass</div>
<div style="display:flex;flex-direction:column;align-items:center;gap:6px;font-size:11px;font-weight:700;color:var(--color-neutral-700)"><div style="width:62px;height:62px;border-radius:50%;background:var(--color-accent-2-600);box-shadow:var(--shadow-md);display:flex;align-items:center;justify-content:center;color:var(--color-bg)"><svg viewBox="0 0 24 24" width="26" height="26" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z"></path></svg></div>Try it</div>
</div>
<div style="position:absolute;left:14px;right:14px;bottom:16px;height:62px;border-radius:999px;background:var(--color-text);display:flex;align-items:center;justify-content:space-around;color:var(--color-neutral-400)">
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"></path><path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path></svg>
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M8 2v4"></path><path d="M16 2v4"></path><rect width="18" height="18" x="3" y="4" rx="2"></rect><path d="M3 10h18"></path></svg>
<div style="display:flex;align-items:center;gap:6px;padding:10px 14px;border-radius:999px;background:var(--color-accent);color:var(--on-accent);font-size:13px;font-weight:700"><svg viewBox="0 0 24 24" width="20" height="20" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><circle cx="12" cy="12" r="10"></circle><path d="m16.24 7.76-1.804 5.411a2 2 0 0 1-1.265 1.265L7.76 16.24l1.804-5.411a2 2 0 0 1 1.265-1.265z"></path></svg>Discover</div>
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M10 5H3"></path><path d="M12 19H3"></path><path d="M14 3v4"></path><path d="M16 17v4"></path><path d="M21 12h-9"></path><path d="M21 19h-5"></path><path d="M21 5h-7"></path><path d="M8 10v4"></path><path d="M8 12H3"></path></svg>
</div>
</div></div><div style="display:flex;flex-direction:column;gap:8px"><div style="font-size:12px;font-weight:700;color:var(--color-neutral-700)">Weekly plan</div><div style="width:360px;height:760px;position:relative;border-radius:36px;background:var(--color-bg);overflow:hidden;box-shadow:var(--shadow-md)">
<div style="height:40px;display:flex;align-items:center;justify-content:space-between;padding:0 26px;font-size:13px;font-weight:700"><span>9:41</span><span style="width:22px;height:10px;border-radius:3px;background:var(--color-text)"></span></div>
<div style="padding:6px 20px 0;display:flex;align-items:center;justify-content:space-between">
<div><div style="font-size:11px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:var(--color-accent-700)">Week 41</div><div style="font-family:var(--font-heading);font-size:26px;line-height:1.1">Oct 5 – 11</div></div>
<div style="display:flex;gap:6px"><div style="width:36px;height:36px;border-radius:50%;background:var(--color-surface);display:flex;align-items:center;justify-content:center"><svg viewBox="0 0 24 24" width="18" height="18" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="m15 18-6-6 6-6"></path></svg></div><div style="width:36px;height:36px;border-radius:50%;background:var(--color-surface);display:flex;align-items:center;justify-content:center"><svg viewBox="0 0 24 24" width="18" height="18" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="m9 18 6-6-6-6"></path></svg></div></div>
</div>
<div style="margin:14px 20px 0;padding:10px 14px;border-radius:18px;background:var(--color-accent-2-100);color:var(--color-accent-2-900);font-size:12px;line-height:1.4;display:flex;gap:8px;align-items:center"><svg viewBox="0 0 24 24" width="16" height="16" style="flex:none;fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M20 6 9 17l-5-5"></path></svg><span>5 regulars + 2 side quests · synced to Google Calendar</span></div>
<div style="margin:12px 20px 0;display:flex;flex-direction:column;gap:6px">
<div style="display:flex;align-items:center;gap:12px;padding:9px 12px;border-radius:18px;background:var(--color-surface)"><div style="width:36px;text-align:center;font-size:11px;font-weight:700;color:var(--color-neutral-700);line-height:1.1">MON<br><span style="font-size:16px;color:var(--color-text)">5</span></div><div style="flex:1;font-weight:700;font-size:14px">Beef tacos</div><span style="padding:3px 9px;border-radius:999px;background:var(--color-neutral-100);color:var(--color-neutral-800);font-size:10px;font-weight:700">Regular</span><svg viewBox="0 0 24 24" width="16" height="16" style="color:var(--color-neutral-600);fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"></path><path d="M21 3v5h-5"></path><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"></path><path d="M8 16H3v5"></path></svg></div>
<div style="display:flex;align-items:center;gap:12px;padding:9px 12px;border-radius:18px;background:var(--color-surface)"><div style="width:36px;text-align:center;font-size:11px;font-weight:700;color:var(--color-neutral-700);line-height:1.1">TUE<br><span style="font-size:16px;color:var(--color-text)">6</span></div><div style="flex:1;font-weight:700;font-size:14px">Spaghetti bolognese</div><span style="padding:3px 9px;border-radius:999px;background:var(--color-neutral-100);color:var(--color-neutral-800);font-size:10px;font-weight:700">Regular</span><svg viewBox="0 0 24 24" width="16" height="16" style="color:var(--color-neutral-600);fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"></path><path d="M21 3v5h-5"></path><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"></path><path d="M8 16H3v5"></path></svg></div>
<div style="display:flex;align-items:center;gap:12px;padding:9px 12px;border-radius:18px;background:var(--color-accent-100);box-shadow:inset 0 0 0 1.5px var(--color-accent-300)"><div style="width:36px;text-align:center;font-size:11px;font-weight:700;color:var(--color-accent-700);line-height:1.1">WED<br><span style="font-size:16px;color:var(--color-text)">7</span></div><div style="flex:1;font-weight:700;font-size:14px">Crock pot tikka masala</div><span style="padding:3px 9px;border-radius:999px;background:var(--color-accent-200);color:var(--color-accent-800);font-size:10px;font-weight:700">Side quest</span><svg viewBox="0 0 24 24" width="16" height="16" style="color:var(--color-neutral-600);fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"></path><path d="M21 3v5h-5"></path><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"></path><path d="M8 16H3v5"></path></svg></div>
<div style="display:flex;align-items:center;gap:12px;padding:9px 12px;border-radius:18px;background:var(--color-surface)"><div style="width:36px;text-align:center;font-size:11px;font-weight:700;color:var(--color-neutral-700);line-height:1.1">THU<br><span style="font-size:16px;color:var(--color-text)">8</span></div><div style="flex:1;font-weight:700;font-size:14px">Chicken stir-fry</div><span style="padding:3px 9px;border-radius:999px;background:var(--color-neutral-100);color:var(--color-neutral-800);font-size:10px;font-weight:700">Regular</span><svg viewBox="0 0 24 24" width="16" height="16" style="color:var(--color-neutral-600);fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"></path><path d="M21 3v5h-5"></path><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"></path><path d="M8 16H3v5"></path></svg></div>
<div style="display:flex;align-items:center;gap:12px;padding:9px 12px;border-radius:18px;background:var(--color-surface)"><div style="width:36px;text-align:center;font-size:11px;font-weight:700;color:var(--color-neutral-700);line-height:1.1">FRI<br><span style="font-size:16px;color:var(--color-text)">9</span></div><div style="flex:1;font-weight:700;font-size:14px">Pizza night</div><span style="padding:3px 9px;border-radius:999px;background:var(--color-neutral-100);color:var(--color-neutral-800);font-size:10px;font-weight:700">Regular</span><svg viewBox="0 0 24 24" width="16" height="16" style="color:var(--color-neutral-600);fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"></path><path d="M21 3v5h-5"></path><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"></path><path d="M8 16H3v5"></path></svg></div>
<div style="display:flex;align-items:center;gap:12px;padding:9px 12px;border-radius:18px;background:var(--color-accent-100);box-shadow:inset 0 0 0 1.5px var(--color-accent-300)"><div style="width:36px;text-align:center;font-size:11px;font-weight:700;color:var(--color-accent-700);line-height:1.1">SAT<br><span style="font-size:16px;color:var(--color-text)">10</span></div><div style="flex:1;font-weight:700;font-size:14px">Korean beef bowls</div><span style="padding:3px 9px;border-radius:999px;background:var(--color-accent-200);color:var(--color-accent-800);font-size:10px;font-weight:700">Side quest</span><svg viewBox="0 0 24 24" width="16" height="16" style="color:var(--color-neutral-600);fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"></path><path d="M21 3v5h-5"></path><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"></path><path d="M8 16H3v5"></path></svg></div>
<div style="display:flex;align-items:center;gap:12px;padding:9px 12px;border-radius:18px;background:var(--color-surface)"><div style="width:36px;text-align:center;font-size:11px;font-weight:700;color:var(--color-neutral-700);line-height:1.1">SUN<br><span style="font-size:16px;color:var(--color-text)">11</span></div><div style="flex:1;font-weight:700;font-size:14px">Sheet-pan salmon</div><span style="padding:3px 9px;border-radius:999px;background:var(--color-neutral-100);color:var(--color-neutral-800);font-size:10px;font-weight:700">Regular</span><svg viewBox="0 0 24 24" width="16" height="16" style="color:var(--color-neutral-600);fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"></path><path d="M21 3v5h-5"></path><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"></path><path d="M8 16H3v5"></path></svg></div>
</div>
<div style="margin:14px 20px 0;height:50px;border-radius:999px;background:var(--color-accent);display:flex;align-items:center;justify-content:center;gap:8px;font-family:var(--font-heading);font-size:15px;color:var(--on-accent)"><svg viewBox="0 0 24 24" width="18" height="18" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><circle cx="8" cy="21" r="1"></circle><circle cx="19" cy="21" r="1"></circle><path d="M2.05 2.05h2l2.66 12.42a2 2 0 0 0 2 1.58h9.78a2 2 0 0 0 1.95-1.57l1.65-7.43H5.12"></path></svg>Shopping list · 23 items</div>
<div style="position:absolute;left:14px;right:14px;bottom:16px;height:62px;border-radius:999px;background:var(--color-text);display:flex;align-items:center;justify-content:space-around;color:var(--color-neutral-400)">
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"></path><path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path></svg>
<div style="display:flex;align-items:center;gap:6px;padding:10px 14px;border-radius:999px;background:var(--color-accent);color:var(--on-accent);font-size:13px;font-weight:700"><svg viewBox="0 0 24 24" width="20" height="20" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M8 2v4"></path><path d="M16 2v4"></path><rect width="18" height="18" x="3" y="4" rx="2"></rect><path d="M3 10h18"></path></svg>Plan</div>
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><circle cx="12" cy="12" r="10"></circle><path d="m16.24 7.76-1.804 5.411a2 2 0 0 1-1.265 1.265L7.76 16.24l1.804-5.411a2 2 0 0 1 1.265-1.265z"></path></svg>
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M10 5H3"></path><path d="M12 19H3"></path><path d="M14 3v4"></path><path d="M16 17v4"></path><path d="M21 12h-9"></path><path d="M21 19h-5"></path><path d="M21 5h-7"></path><path d="M8 10v4"></path><path d="M8 12H3"></path></svg>
</div>
</div></div><div style="display:flex;flex-direction:column;gap:8px"><div style="font-size:12px;font-weight:700;color:var(--color-neutral-700)">Shopping list</div><div style="width:360px;height:760px;position:relative;border-radius:36px;background:var(--color-bg);overflow:hidden;box-shadow:var(--shadow-md)">
<div style="height:40px;display:flex;align-items:center;justify-content:space-between;padding:0 26px;font-size:13px;font-weight:700"><span>9:41</span><span style="width:22px;height:10px;border-radius:3px;background:var(--color-text)"></span></div>
<div style="padding:6px 20px 0;display:flex;justify-content:space-between;align-items:flex-end"><div><div style="font-size:11px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:var(--color-accent-700)">Supply run · Oct 5–11</div><div style="font-family:var(--font-heading);font-size:28px;line-height:1.1">Shopping list</div></div><div style="font-size:13px;font-weight:700;color:var(--color-neutral-700)">19 / 23</div></div>
<div style="margin:10px 20px 0;font-size:12px;line-height:1.45;color:var(--color-neutral-800)">Uncheck anything you already have — it won't be counted.</div>
<div style="padding:12px 20px 0;display:flex;flex-direction:column;gap:12px">
<div style="padding:14px;border-radius:24px;background:var(--color-surface);display:flex;flex-direction:column;gap:9px">
<div style="display:flex;justify-content:space-between;align-items:baseline"><span style="font-family:var(--font-heading);font-size:16px">Beef tacos</span><span style="font-size:11px;font-weight:700;color:var(--color-neutral-700)">MON</span></div>
<div style="display:flex;align-items:center;gap:10px;font-size:14px"><span style="width:20px;height:20px;border-radius:7px;background:var(--color-accent);color:var(--on-accent);display:flex;align-items:center;justify-content:center"><svg viewBox="0 0 24 24" width="13" height="13" style="fill:none;stroke:currentColor;stroke-width:3.5;stroke-linecap:round;stroke-linejoin:round"><path d="M20 6 9 17l-5-5"></path></svg></span><span style="flex:1">Ground beef</span><span style="color:var(--color-neutral-700);font-size:12px">1 lb</span></div>
<div style="display:flex;align-items:center;gap:10px;font-size:14px"><span style="width:20px;height:20px;border-radius:7px;background:var(--color-accent);color:var(--on-accent);display:flex;align-items:center;justify-content:center"><svg viewBox="0 0 24 24" width="13" height="13" style="fill:none;stroke:currentColor;stroke-width:3.5;stroke-linecap:round;stroke-linejoin:round"><path d="M20 6 9 17l-5-5"></path></svg></span><span style="flex:1">Corn tortillas</span><span style="color:var(--color-neutral-700);font-size:12px">12</span></div>
<div style="display:flex;align-items:center;gap:10px;font-size:14px;color:var(--color-neutral-600)"><span style="width:20px;height:20px;border-radius:7px;border:2px solid var(--color-neutral-400)"></span><span style="flex:1;text-decoration:line-through">Taco seasoning</span><span style="font-size:11px;font-weight:700">have it</span></div>
</div>
<div style="padding:14px;border-radius:24px;background:var(--color-surface);display:flex;flex-direction:column;gap:9px">
<div style="display:flex;justify-content:space-between;align-items:baseline"><span style="font-family:var(--font-heading);font-size:16px">Crock pot tikka masala</span><span style="font-size:11px;font-weight:700;color:var(--color-neutral-700)">WED</span></div>
<div style="display:flex;align-items:center;gap:10px;font-size:14px"><span style="width:20px;height:20px;border-radius:7px;background:var(--color-accent);color:var(--on-accent);display:flex;align-items:center;justify-content:center"><svg viewBox="0 0 24 24" width="13" height="13" style="fill:none;stroke:currentColor;stroke-width:3.5;stroke-linecap:round;stroke-linejoin:round"><path d="M20 6 9 17l-5-5"></path></svg></span><span style="flex:1">Chicken thighs</span><span style="color:var(--color-neutral-700);font-size:12px">2 lb</span></div>
<div style="display:flex;align-items:center;gap:10px;font-size:14px"><span style="width:20px;height:20px;border-radius:7px;background:var(--color-accent);color:var(--on-accent);display:flex;align-items:center;justify-content:center"><svg viewBox="0 0 24 24" width="13" height="13" style="fill:none;stroke:currentColor;stroke-width:3.5;stroke-linecap:round;stroke-linejoin:round"><path d="M20 6 9 17l-5-5"></path></svg></span><span style="flex:1">Tikka masala paste</span><span style="color:var(--color-neutral-700);font-size:12px">1 jar</span></div>
<div style="display:flex;align-items:center;gap:10px;font-size:14px;color:var(--color-neutral-600)"><span style="width:20px;height:20px;border-radius:7px;border:2px solid var(--color-neutral-400)"></span><span style="flex:1;text-decoration:line-through">Basmati rice</span><span style="font-size:11px;font-weight:700">have it</span></div>
</div>
<div style="padding:14px;border-radius:24px;background:var(--color-surface);display:flex;justify-content:space-between;align-items:baseline"><span style="font-family:var(--font-heading);font-size:16px">Chicken stir-fry</span><span style="font-size:11px;font-weight:700;color:var(--color-neutral-700)">THU · 5 items</span></div>
</div>
<div style="position:absolute;left:20px;right:20px;bottom:96px;display:flex;align-items:center;gap:8px;font-size:12px;font-weight:700;color:var(--color-accent-2-800)"><svg viewBox="0 0 24 24" width="15" height="15" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path></svg>Sent to Google Chat · Sat 8:00 am</div>
<div style="position:absolute;left:14px;right:14px;bottom:16px;height:62px;border-radius:999px;background:var(--color-text);display:flex;align-items:center;justify-content:space-around;color:var(--color-neutral-400)">
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"></path><path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path></svg>
<div style="display:flex;align-items:center;gap:6px;padding:10px 14px;border-radius:999px;background:var(--color-accent);color:var(--on-accent);font-size:13px;font-weight:700"><svg viewBox="0 0 24 24" width="20" height="20" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M8 2v4"></path><path d="M16 2v4"></path><rect width="18" height="18" x="3" y="4" rx="2"></rect><path d="M3 10h18"></path></svg>Plan</div>
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><circle cx="12" cy="12" r="10"></circle><path d="m16.24 7.76-1.804 5.411a2 2 0 0 1-1.265 1.265L7.76 16.24l1.804-5.411a2 2 0 0 1 1.265-1.265z"></path></svg>
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M10 5H3"></path><path d="M12 19H3"></path><path d="M14 3v4"></path><path d="M16 17v4"></path><path d="M21 12h-9"></path><path d="M21 19h-5"></path><path d="M21 5h-7"></path><path d="M8 10v4"></path><path d="M8 12H3"></path></svg>
</div>
</div></div></div>
</div>
<div id="c1" style="--color-accent:oklch(0.5 0.13 310);--color-accent-100:oklch(0.97 0.030 310);--color-accent-200:oklch(0.93 0.060 310);--color-accent-300:oklch(0.86 0.090 310);--color-accent-400:oklch(0.77 0.120 310);--color-accent-500:oklch(0.67 0.140 310);--color-accent-600:oklch(0.57 0.140 310);--color-accent-700:oklch(0.47 0.130 310);--color-accent-800:oklch(0.37 0.110 310);--color-accent-900:oklch(0.27 0.080 310);--on-accent:var(--color-bg);padding:32px 40px;border-radius:40px;background:var(--color-bg);display:flex;flex-direction:column;gap:20px;color:var(--color-text);font-family:var(--font-body)">
<div style="display:flex;align-items:center;gap:20px;flex-wrap:wrap"><div style="display:flex;flex-direction:column;gap:4px;min-width:280px"><div style="font-family:var(--font-heading);font-size:28px;line-height:1.15;white-space:nowrap">1 · Plum + sage</div><div style="font-size:14px;color:var(--color-neutral-800)">Recommended. Smallest change; plum and sage pair naturally on the cream ground.</div></div><div style="display:flex;gap:8px"><span style="width:44px;height:44px;border-radius:50%;background:var(--color-bg);box-shadow:inset 0 0 0 1px var(--color-divider)"></span><span style="width:44px;height:44px;border-radius:50%;background:var(--color-surface);box-shadow:inset 0 0 0 1px var(--color-divider)"></span><span style="width:44px;height:44px;border-radius:50%;background:var(--color-accent);box-shadow:inset 0 0 0 1px var(--color-divider)"></span><span style="width:44px;height:44px;border-radius:50%;background:var(--color-accent-2);box-shadow:inset 0 0 0 1px var(--color-divider)"></span><span style="width:44px;height:44px;border-radius:50%;background:var(--color-text);box-shadow:inset 0 0 0 1px var(--color-divider)"></span></div><div style="display:flex;gap:3px"><span style="width:22px;height:44px;border-radius:999px;background:var(--color-accent-100)"></span><span style="width:22px;height:44px;border-radius:999px;background:var(--color-accent-200)"></span><span style="width:22px;height:44px;border-radius:999px;background:var(--color-accent-300)"></span><span style="width:22px;height:44px;border-radius:999px;background:var(--color-accent-400)"></span><span style="width:22px;height:44px;border-radius:999px;background:var(--color-accent-500)"></span><span style="width:22px;height:44px;border-radius:999px;background:var(--color-accent-600)"></span><span style="width:22px;height:44px;border-radius:999px;background:var(--color-accent-700)"></span><span style="width:22px;height:44px;border-radius:999px;background:var(--color-accent-800)"></span><span style="width:22px;height:44px;border-radius:999px;background:var(--color-accent-900)"></span></div></div>
<div style="display:flex;gap:28px;flex-wrap:wrap"><div style="display:flex;flex-direction:column;gap:8px"><div style="font-size:12px;font-weight:700;color:var(--color-neutral-700)">Home</div><div style="width:360px;height:760px;position:relative;border-radius:36px;background:var(--color-bg);overflow:hidden;box-shadow:var(--shadow-md)">
<div style="height:40px;display:flex;align-items:center;justify-content:space-between;padding:0 26px;font-size:13px;font-weight:700"><span>9:41</span><span style="width:22px;height:10px;border-radius:3px;background:var(--color-text)"></span></div>
<div style="padding:6px 20px 0;display:flex;justify-content:space-between;align-items:flex-start">
<div><div style="font-size:12px;font-weight:700;color:var(--color-neutral-700)">Friday, Oct 2</div><div style="font-family:var(--font-heading);font-size:28px;line-height:1.1">Tonight's quest</div></div>
<div style="position:relative;width:40px;height:40px;border-radius:50%;background:var(--color-surface);display:flex;align-items:center;justify-content:center"><svg viewBox="0 0 24 24" width="19" height="19" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M10.268 21a2 2 0 0 0 3.464 0"></path><path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326"></path></svg><span style="position:absolute;top:8px;right:9px;width:8px;height:8px;border-radius:50%;background:var(--color-accent);color:var(--on-accent)"></span></div>
</div>
<div style="margin:14px 20px 0;border-radius:32px;background:var(--color-surface);overflow:hidden;display:flex;flex-direction:column">
<div style="height:150px;margin:10px;border-radius:24px;background:repeating-linear-gradient(135deg,var(--color-neutral-300) 0 8px,var(--color-neutral-200) 8px 16px);display:flex;align-items:center;justify-content:center;font:11px ui-monospace,Menlo,monospace;color:var(--color-neutral-700)">dish photo</div>
<div style="padding:2px 18px 16px;display:flex;align-items:flex-end;justify-content:space-between;gap:10px"><div><div style="font-family:var(--font-heading);font-size:22px;line-height:1.1">Pizza night</div><div style="font-size:12px;color:var(--color-neutral-700);margin-top:4px">Regular · 40 min</div></div><div style="height:38px;padding:0 16px;border-radius:999px;background:var(--color-text);color:var(--color-bg);display:flex;align-items:center;font-family:var(--font-heading);font-size:13px">Recipe</div></div>
</div>
<div style="margin:12px 20px 0;display:grid;grid-template-columns:1fr 1fr;gap:10px">
<div style="height:104px;padding:14px;border-radius:26px;background:var(--color-accent);color:var(--on-accent);display:flex;flex-direction:column;justify-content:space-between"><svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z"></path></svg><span style="font-family:var(--font-heading);font-size:16px;line-height:1.1">Quick dinner</span></div>
<div style="height:104px;padding:14px;border-radius:26px;background:var(--color-accent-2-300);color:var(--color-accent-2-900);display:flex;flex-direction:column;justify-content:space-between"><svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><circle cx="11" cy="11" r="8"></circle><path d="m21 21-4.3-4.3"></path></svg><span style="font-family:var(--font-heading);font-size:16px;line-height:1.1">Cook from what I have</span></div>
</div>
<div style="margin:12px 20px 0;display:flex;flex-direction:column;gap:8px">
<div style="display:flex;align-items:center;gap:12px;padding:12px 14px;border-radius:20px;background:var(--color-neutral-100)"><svg viewBox="0 0 24 24" width="18" height="18" style="color:var(--color-accent-700);fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><circle cx="12" cy="12" r="10"></circle><path d="m16.24 7.76-1.804 5.411a2 2 0 0 1-1.265 1.265L7.76 16.24l1.804-5.411a2 2 0 0 1 1.265-1.265z"></path></svg><span style="flex:1;font-size:13px;font-weight:700">10 new side quests to swipe</span><svg viewBox="0 0 24 24" width="16" height="16" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="m9 18 6-6-6-6"></path></svg></div>
<div style="display:flex;align-items:center;gap:12px;padding:12px 14px;border-radius:20px;background:var(--color-neutral-100)"><svg viewBox="0 0 24 24" width="18" height="18" style="color:var(--color-accent-700);fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><circle cx="8" cy="21" r="1"></circle><circle cx="19" cy="21" r="1"></circle><path d="M2.05 2.05h2l2.66 12.42a2 2 0 0 0 2 1.58h9.78a2 2 0 0 0 1.95-1.57l1.65-7.43H5.12"></path></svg><span style="flex:1;font-size:13px;font-weight:700">Shopping list ready · Oct 5–11</span><svg viewBox="0 0 24 24" width="16" height="16" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="m9 18 6-6-6-6"></path></svg></div>
</div>
<div style="position:absolute;left:14px;right:14px;bottom:16px;height:62px;border-radius:999px;background:var(--color-text);display:flex;align-items:center;justify-content:space-around;color:var(--color-neutral-400)">
<div style="display:flex;align-items:center;gap:6px;padding:10px 14px;border-radius:999px;background:var(--color-accent);color:var(--on-accent);font-size:13px;font-weight:700"><svg viewBox="0 0 24 24" width="20" height="20" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"></path><path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path></svg>Home</div>
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M8 2v4"></path><path d="M16 2v4"></path><rect width="18" height="18" x="3" y="4" rx="2"></rect><path d="M3 10h18"></path></svg>
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><circle cx="12" cy="12" r="10"></circle><path d="m16.24 7.76-1.804 5.411a2 2 0 0 1-1.265 1.265L7.76 16.24l1.804-5.411a2 2 0 0 1 1.265-1.265z"></path></svg>
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M10 5H3"></path><path d="M12 19H3"></path><path d="M14 3v4"></path><path d="M16 17v4"></path><path d="M21 12h-9"></path><path d="M21 19h-5"></path><path d="M21 5h-7"></path><path d="M8 10v4"></path><path d="M8 12H3"></path></svg>
</div>
</div></div><div style="display:flex;flex-direction:column;gap:8px"><div style="font-size:12px;font-weight:700;color:var(--color-neutral-700)">Discover</div><div style="width:360px;height:760px;position:relative;border-radius:36px;background:var(--color-bg);overflow:hidden;box-shadow:var(--shadow-md)">
<div style="height:40px;display:flex;align-items:center;justify-content:space-between;padding:0 26px;font-size:13px;font-weight:700"><span>9:41</span><span style="width:22px;height:10px;border-radius:3px;background:var(--color-text)"></span></div>
<div style="padding:8px 20px 0;display:flex;align-items:flex-end;justify-content:space-between">
<div><div style="font-size:11px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:var(--color-accent-700)">Discover</div><div style="font-family:var(--font-heading);font-size:30px;line-height:1.1">Side quests</div></div>
<div style="display:flex;align-items:center;gap:6px;padding:7px 12px;border-radius:999px;background:var(--color-accent-2-200);color:var(--color-accent-2-800);font-size:12px;font-weight:700">Quest log · 6</div>
</div>
<div style="position:relative;margin:18px 20px 0;height:440px">
<div style="position:absolute;inset:0;transform:translateY(16px) scale(.9);border-radius:32px;background:var(--color-neutral-300)"></div>
<div style="position:absolute;inset:0;transform:translateY(8px) scale(.95);border-radius:32px;background:var(--color-surface)"></div>
<div style="position:absolute;inset:0;border-radius:32px;background:var(--color-neutral-100);box-shadow:var(--shadow-lg);overflow:hidden;display:flex;flex-direction:column">
<div style="height:220px;margin:10px;border-radius:24px;background:repeating-linear-gradient(135deg,var(--color-neutral-300) 0 8px,var(--color-neutral-200) 8px 16px);display:flex;align-items:center;justify-content:center;font:11px ui-monospace,Menlo,monospace;color:var(--color-neutral-700)">dish photo</div>
<div style="padding:4px 20px 0;display:flex;flex-direction:column;gap:10px">
<div style="display:flex;align-items:center;justify-content:space-between"><div style="font-family:var(--font-heading);font-size:24px;line-height:1.1">Korean beef bowls</div></div>
<div style="display:flex;align-items:center;gap:8px;font-size:12px;font-weight:700;color:var(--color-neutral-700)"><span style="display:flex;gap:3px"><span style="width:8px;height:8px;border-radius:50%;background:var(--color-accent);color:var(--on-accent)"></span><span style="width:8px;height:8px;border-radius:50%;background:var(--color-neutral-300)"></span><span style="width:8px;height:8px;border-radius:50%;background:var(--color-neutral-300)"></span></span>Easy</div>
<div style="padding:12px 14px;border-radius:18px;background:var(--color-accent-2-100);color:var(--color-accent-2-900);font-size:13px;line-height:1.45"><b>Why this?</b> You make Chicken stir-fry and Beef tacos most weeks. Same flavors, sweet-savory twist.</div>
</div>
</div>
</div>
<div style="display:flex;justify-content:center;gap:28px;margin-top:22px">
<div style="display:flex;flex-direction:column;align-items:center;gap:6px;font-size:11px;font-weight:700;color:var(--color-neutral-700)"><div style="width:62px;height:62px;border-radius:50%;background:var(--color-neutral-100);box-shadow:var(--shadow-md);display:flex;align-items:center;justify-content:center;color:var(--color-text)"><svg viewBox="0 0 24 24" width="26" height="26" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M18 6 6 18"></path><path d="m6 6 12 12"></path></svg></div>Pass</div>
<div style="display:flex;flex-direction:column;align-items:center;gap:6px;font-size:11px;font-weight:700;color:var(--color-neutral-700)"><div style="width:62px;height:62px;border-radius:50%;background:var(--color-accent-2-600);box-shadow:var(--shadow-md);display:flex;align-items:center;justify-content:center;color:var(--color-bg)"><svg viewBox="0 0 24 24" width="26" height="26" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z"></path></svg></div>Try it</div>
</div>
<div style="position:absolute;left:14px;right:14px;bottom:16px;height:62px;border-radius:999px;background:var(--color-text);display:flex;align-items:center;justify-content:space-around;color:var(--color-neutral-400)">
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"></path><path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path></svg>
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M8 2v4"></path><path d="M16 2v4"></path><rect width="18" height="18" x="3" y="4" rx="2"></rect><path d="M3 10h18"></path></svg>
<div style="display:flex;align-items:center;gap:6px;padding:10px 14px;border-radius:999px;background:var(--color-accent);color:var(--on-accent);font-size:13px;font-weight:700"><svg viewBox="0 0 24 24" width="20" height="20" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><circle cx="12" cy="12" r="10"></circle><path d="m16.24 7.76-1.804 5.411a2 2 0 0 1-1.265 1.265L7.76 16.24l1.804-5.411a2 2 0 0 1 1.265-1.265z"></path></svg>Discover</div>
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M10 5H3"></path><path d="M12 19H3"></path><path d="M14 3v4"></path><path d="M16 17v4"></path><path d="M21 12h-9"></path><path d="M21 19h-5"></path><path d="M21 5h-7"></path><path d="M8 10v4"></path><path d="M8 12H3"></path></svg>
</div>
</div></div><div style="display:flex;flex-direction:column;gap:8px"><div style="font-size:12px;font-weight:700;color:var(--color-neutral-700)">Weekly plan</div><div style="width:360px;height:760px;position:relative;border-radius:36px;background:var(--color-bg);overflow:hidden;box-shadow:var(--shadow-md)">
<div style="height:40px;display:flex;align-items:center;justify-content:space-between;padding:0 26px;font-size:13px;font-weight:700"><span>9:41</span><span style="width:22px;height:10px;border-radius:3px;background:var(--color-text)"></span></div>
<div style="padding:6px 20px 0;display:flex;align-items:center;justify-content:space-between">
<div><div style="font-size:11px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:var(--color-accent-700)">Week 41</div><div style="font-family:var(--font-heading);font-size:26px;line-height:1.1">Oct 5 – 11</div></div>
<div style="display:flex;gap:6px"><div style="width:36px;height:36px;border-radius:50%;background:var(--color-surface);display:flex;align-items:center;justify-content:center"><svg viewBox="0 0 24 24" width="18" height="18" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="m15 18-6-6 6-6"></path></svg></div><div style="width:36px;height:36px;border-radius:50%;background:var(--color-surface);display:flex;align-items:center;justify-content:center"><svg viewBox="0 0 24 24" width="18" height="18" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="m9 18 6-6-6-6"></path></svg></div></div>
</div>
<div style="margin:14px 20px 0;padding:10px 14px;border-radius:18px;background:var(--color-accent-2-100);color:var(--color-accent-2-900);font-size:12px;line-height:1.4;display:flex;gap:8px;align-items:center"><svg viewBox="0 0 24 24" width="16" height="16" style="flex:none;fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M20 6 9 17l-5-5"></path></svg><span>5 regulars + 2 side quests · synced to Google Calendar</span></div>
<div style="margin:12px 20px 0;display:flex;flex-direction:column;gap:6px">
<div style="display:flex;align-items:center;gap:12px;padding:9px 12px;border-radius:18px;background:var(--color-surface)"><div style="width:36px;text-align:center;font-size:11px;font-weight:700;color:var(--color-neutral-700);line-height:1.1">MON<br><span style="font-size:16px;color:var(--color-text)">5</span></div><div style="flex:1;font-weight:700;font-size:14px">Beef tacos</div><span style="padding:3px 9px;border-radius:999px;background:var(--color-neutral-100);color:var(--color-neutral-800);font-size:10px;font-weight:700">Regular</span><svg viewBox="0 0 24 24" width="16" height="16" style="color:var(--color-neutral-600);fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"></path><path d="M21 3v5h-5"></path><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"></path><path d="M8 16H3v5"></path></svg></div>
<div style="display:flex;align-items:center;gap:12px;padding:9px 12px;border-radius:18px;background:var(--color-surface)"><div style="width:36px;text-align:center;font-size:11px;font-weight:700;color:var(--color-neutral-700);line-height:1.1">TUE<br><span style="font-size:16px;color:var(--color-text)">6</span></div><div style="flex:1;font-weight:700;font-size:14px">Spaghetti bolognese</div><span style="padding:3px 9px;border-radius:999px;background:var(--color-neutral-100);color:var(--color-neutral-800);font-size:10px;font-weight:700">Regular</span><svg viewBox="0 0 24 24" width="16" height="16" style="color:var(--color-neutral-600);fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"></path><path d="M21 3v5h-5"></path><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"></path><path d="M8 16H3v5"></path></svg></div>
<div style="display:flex;align-items:center;gap:12px;padding:9px 12px;border-radius:18px;background:var(--color-accent-100);box-shadow:inset 0 0 0 1.5px var(--color-accent-300)"><div style="width:36px;text-align:center;font-size:11px;font-weight:700;color:var(--color-accent-700);line-height:1.1">WED<br><span style="font-size:16px;color:var(--color-text)">7</span></div><div style="flex:1;font-weight:700;font-size:14px">Crock pot tikka masala</div><span style="padding:3px 9px;border-radius:999px;background:var(--color-accent-200);color:var(--color-accent-800);font-size:10px;font-weight:700">Side quest</span><svg viewBox="0 0 24 24" width="16" height="16" style="color:var(--color-neutral-600);fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"></path><path d="M21 3v5h-5"></path><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"></path><path d="M8 16H3v5"></path></svg></div>
<div style="display:flex;align-items:center;gap:12px;padding:9px 12px;border-radius:18px;background:var(--color-surface)"><div style="width:36px;text-align:center;font-size:11px;font-weight:700;color:var(--color-neutral-700);line-height:1.1">THU<br><span style="font-size:16px;color:var(--color-text)">8</span></div><div style="flex:1;font-weight:700;font-size:14px">Chicken stir-fry</div><span style="padding:3px 9px;border-radius:999px;background:var(--color-neutral-100);color:var(--color-neutral-800);font-size:10px;font-weight:700">Regular</span><svg viewBox="0 0 24 24" width="16" height="16" style="color:var(--color-neutral-600);fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"></path><path d="M21 3v5h-5"></path><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"></path><path d="M8 16H3v5"></path></svg></div>
<div style="display:flex;align-items:center;gap:12px;padding:9px 12px;border-radius:18px;background:var(--color-surface)"><div style="width:36px;text-align:center;font-size:11px;font-weight:700;color:var(--color-neutral-700);line-height:1.1">FRI<br><span style="font-size:16px;color:var(--color-text)">9</span></div><div style="flex:1;font-weight:700;font-size:14px">Pizza night</div><span style="padding:3px 9px;border-radius:999px;background:var(--color-neutral-100);color:var(--color-neutral-800);font-size:10px;font-weight:700">Regular</span><svg viewBox="0 0 24 24" width="16" height="16" style="color:var(--color-neutral-600);fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"></path><path d="M21 3v5h-5"></path><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"></path><path d="M8 16H3v5"></path></svg></div>
<div style="display:flex;align-items:center;gap:12px;padding:9px 12px;border-radius:18px;background:var(--color-accent-100);box-shadow:inset 0 0 0 1.5px var(--color-accent-300)"><div style="width:36px;text-align:center;font-size:11px;font-weight:700;color:var(--color-accent-700);line-height:1.1">SAT<br><span style="font-size:16px;color:var(--color-text)">10</span></div><div style="flex:1;font-weight:700;font-size:14px">Korean beef bowls</div><span style="padding:3px 9px;border-radius:999px;background:var(--color-accent-200);color:var(--color-accent-800);font-size:10px;font-weight:700">Side quest</span><svg viewBox="0 0 24 24" width="16" height="16" style="color:var(--color-neutral-600);fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"></path><path d="M21 3v5h-5"></path><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"></path><path d="M8 16H3v5"></path></svg></div>
<div style="display:flex;align-items:center;gap:12px;padding:9px 12px;border-radius:18px;background:var(--color-surface)"><div style="width:36px;text-align:center;font-size:11px;font-weight:700;color:var(--color-neutral-700);line-height:1.1">SUN<br><span style="font-size:16px;color:var(--color-text)">11</span></div><div style="flex:1;font-weight:700;font-size:14px">Sheet-pan salmon</div><span style="padding:3px 9px;border-radius:999px;background:var(--color-neutral-100);color:var(--color-neutral-800);font-size:10px;font-weight:700">Regular</span><svg viewBox="0 0 24 24" width="16" height="16" style="color:var(--color-neutral-600);fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"></path><path d="M21 3v5h-5"></path><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"></path><path d="M8 16H3v5"></path></svg></div>
</div>
<div style="margin:14px 20px 0;height:50px;border-radius:999px;background:var(--color-accent);display:flex;align-items:center;justify-content:center;gap:8px;font-family:var(--font-heading);font-size:15px;color:var(--on-accent)"><svg viewBox="0 0 24 24" width="18" height="18" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><circle cx="8" cy="21" r="1"></circle><circle cx="19" cy="21" r="1"></circle><path d="M2.05 2.05h2l2.66 12.42a2 2 0 0 0 2 1.58h9.78a2 2 0 0 0 1.95-1.57l1.65-7.43H5.12"></path></svg>Shopping list · 23 items</div>
<div style="position:absolute;left:14px;right:14px;bottom:16px;height:62px;border-radius:999px;background:var(--color-text);display:flex;align-items:center;justify-content:space-around;color:var(--color-neutral-400)">
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"></path><path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path></svg>
<div style="display:flex;align-items:center;gap:6px;padding:10px 14px;border-radius:999px;background:var(--color-accent);color:var(--on-accent);font-size:13px;font-weight:700"><svg viewBox="0 0 24 24" width="20" height="20" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M8 2v4"></path><path d="M16 2v4"></path><rect width="18" height="18" x="3" y="4" rx="2"></rect><path d="M3 10h18"></path></svg>Plan</div>
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><circle cx="12" cy="12" r="10"></circle><path d="m16.24 7.76-1.804 5.411a2 2 0 0 1-1.265 1.265L7.76 16.24l1.804-5.411a2 2 0 0 1 1.265-1.265z"></path></svg>
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M10 5H3"></path><path d="M12 19H3"></path><path d="M14 3v4"></path><path d="M16 17v4"></path><path d="M21 12h-9"></path><path d="M21 19h-5"></path><path d="M21 5h-7"></path><path d="M8 10v4"></path><path d="M8 12H3"></path></svg>
</div>
</div></div><div style="display:flex;flex-direction:column;gap:8px"><div style="font-size:12px;font-weight:700;color:var(--color-neutral-700)">Shopping list</div><div style="width:360px;height:760px;position:relative;border-radius:36px;background:var(--color-bg);overflow:hidden;box-shadow:var(--shadow-md)">
<div style="height:40px;display:flex;align-items:center;justify-content:space-between;padding:0 26px;font-size:13px;font-weight:700"><span>9:41</span><span style="width:22px;height:10px;border-radius:3px;background:var(--color-text)"></span></div>
<div style="padding:6px 20px 0;display:flex;justify-content:space-between;align-items:flex-end"><div><div style="font-size:11px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:var(--color-accent-700)">Supply run · Oct 5–11</div><div style="font-family:var(--font-heading);font-size:28px;line-height:1.1">Shopping list</div></div><div style="font-size:13px;font-weight:700;color:var(--color-neutral-700)">19 / 23</div></div>
<div style="margin:10px 20px 0;font-size:12px;line-height:1.45;color:var(--color-neutral-800)">Uncheck anything you already have — it won't be counted.</div>
<div style="padding:12px 20px 0;display:flex;flex-direction:column;gap:12px">
<div style="padding:14px;border-radius:24px;background:var(--color-surface);display:flex;flex-direction:column;gap:9px">
<div style="display:flex;justify-content:space-between;align-items:baseline"><span style="font-family:var(--font-heading);font-size:16px">Beef tacos</span><span style="font-size:11px;font-weight:700;color:var(--color-neutral-700)">MON</span></div>
<div style="display:flex;align-items:center;gap:10px;font-size:14px"><span style="width:20px;height:20px;border-radius:7px;background:var(--color-accent);color:var(--on-accent);display:flex;align-items:center;justify-content:center"><svg viewBox="0 0 24 24" width="13" height="13" style="fill:none;stroke:currentColor;stroke-width:3.5;stroke-linecap:round;stroke-linejoin:round"><path d="M20 6 9 17l-5-5"></path></svg></span><span style="flex:1">Ground beef</span><span style="color:var(--color-neutral-700);font-size:12px">1 lb</span></div>
<div style="display:flex;align-items:center;gap:10px;font-size:14px"><span style="width:20px;height:20px;border-radius:7px;background:var(--color-accent);color:var(--on-accent);display:flex;align-items:center;justify-content:center"><svg viewBox="0 0 24 24" width="13" height="13" style="fill:none;stroke:currentColor;stroke-width:3.5;stroke-linecap:round;stroke-linejoin:round"><path d="M20 6 9 17l-5-5"></path></svg></span><span style="flex:1">Corn tortillas</span><span style="color:var(--color-neutral-700);font-size:12px">12</span></div>
<div style="display:flex;align-items:center;gap:10px;font-size:14px;color:var(--color-neutral-600)"><span style="width:20px;height:20px;border-radius:7px;border:2px solid var(--color-neutral-400)"></span><span style="flex:1;text-decoration:line-through">Taco seasoning</span><span style="font-size:11px;font-weight:700">have it</span></div>
</div>
<div style="padding:14px;border-radius:24px;background:var(--color-surface);display:flex;flex-direction:column;gap:9px">
<div style="display:flex;justify-content:space-between;align-items:baseline"><span style="font-family:var(--font-heading);font-size:16px">Crock pot tikka masala</span><span style="font-size:11px;font-weight:700;color:var(--color-neutral-700)">WED</span></div>
<div style="display:flex;align-items:center;gap:10px;font-size:14px"><span style="width:20px;height:20px;border-radius:7px;background:var(--color-accent);color:var(--on-accent);display:flex;align-items:center;justify-content:center"><svg viewBox="0 0 24 24" width="13" height="13" style="fill:none;stroke:currentColor;stroke-width:3.5;stroke-linecap:round;stroke-linejoin:round"><path d="M20 6 9 17l-5-5"></path></svg></span><span style="flex:1">Chicken thighs</span><span style="color:var(--color-neutral-700);font-size:12px">2 lb</span></div>
<div style="display:flex;align-items:center;gap:10px;font-size:14px"><span style="width:20px;height:20px;border-radius:7px;background:var(--color-accent);color:var(--on-accent);display:flex;align-items:center;justify-content:center"><svg viewBox="0 0 24 24" width="13" height="13" style="fill:none;stroke:currentColor;stroke-width:3.5;stroke-linecap:round;stroke-linejoin:round"><path d="M20 6 9 17l-5-5"></path></svg></span><span style="flex:1">Tikka masala paste</span><span style="color:var(--color-neutral-700);font-size:12px">1 jar</span></div>
<div style="display:flex;align-items:center;gap:10px;font-size:14px;color:var(--color-neutral-600)"><span style="width:20px;height:20px;border-radius:7px;border:2px solid var(--color-neutral-400)"></span><span style="flex:1;text-decoration:line-through">Basmati rice</span><span style="font-size:11px;font-weight:700">have it</span></div>
</div>
<div style="padding:14px;border-radius:24px;background:var(--color-surface);display:flex;justify-content:space-between;align-items:baseline"><span style="font-family:var(--font-heading);font-size:16px">Chicken stir-fry</span><span style="font-size:11px;font-weight:700;color:var(--color-neutral-700)">THU · 5 items</span></div>
</div>
<div style="position:absolute;left:20px;right:20px;bottom:96px;display:flex;align-items:center;gap:8px;font-size:12px;font-weight:700;color:var(--color-accent-2-800)"><svg viewBox="0 0 24 24" width="15" height="15" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path></svg>Sent to Google Chat · Sat 8:00 am</div>
<div style="position:absolute;left:14px;right:14px;bottom:16px;height:62px;border-radius:999px;background:var(--color-text);display:flex;align-items:center;justify-content:space-around;color:var(--color-neutral-400)">
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"></path><path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path></svg>
<div style="display:flex;align-items:center;gap:6px;padding:10px 14px;border-radius:999px;background:var(--color-accent);color:var(--on-accent);font-size:13px;font-weight:700"><svg viewBox="0 0 24 24" width="20" height="20" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M8 2v4"></path><path d="M16 2v4"></path><rect width="18" height="18" x="3" y="4" rx="2"></rect><path d="M3 10h18"></path></svg>Plan</div>
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><circle cx="12" cy="12" r="10"></circle><path d="m16.24 7.76-1.804 5.411a2 2 0 0 1-1.265 1.265L7.76 16.24l1.804-5.411a2 2 0 0 1 1.265-1.265z"></path></svg>
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M10 5H3"></path><path d="M12 19H3"></path><path d="M14 3v4"></path><path d="M16 17v4"></path><path d="M21 12h-9"></path><path d="M21 19h-5"></path><path d="M21 5h-7"></path><path d="M8 10v4"></path><path d="M8 12H3"></path></svg>
</div>
</div></div></div>
</div>
<div id="c2" style="--color-bg:oklch(0.975 0.008 85);--color-surface:oklch(0.935 0.016 85);--color-accent:oklch(0.5 0.13 310);--color-accent-100:oklch(0.97 0.030 310);--color-accent-200:oklch(0.93 0.060 310);--color-accent-300:oklch(0.86 0.090 310);--color-accent-400:oklch(0.77 0.120 310);--color-accent-500:oklch(0.67 0.140 310);--color-accent-600:oklch(0.57 0.140 310);--color-accent-700:oklch(0.47 0.130 310);--color-accent-800:oklch(0.37 0.110 310);--color-accent-900:oklch(0.27 0.080 310);--color-accent-2:oklch(0.76 0.14 78);--color-accent-2-100:oklch(0.97 0.030 78);--color-accent-2-200:oklch(0.93 0.060 78);--color-accent-2-300:oklch(0.86 0.090 78);--color-accent-2-400:oklch(0.77 0.120 78);--color-accent-2-500:oklch(0.67 0.140 78);--color-accent-2-600:oklch(0.57 0.140 78);--color-accent-2-700:oklch(0.47 0.130 78);--color-accent-2-800:oklch(0.37 0.110 78);--color-accent-2-900:oklch(0.27 0.080 78);--on-accent:var(--color-bg);padding:32px 40px;border-radius:40px;background:var(--color-bg);display:flex;flex-direction:column;gap:20px;color:var(--color-text);font-family:var(--font-body)">
<div style="display:flex;align-items:center;gap:20px;flex-wrap:wrap"><div style="display:flex;flex-direction:column;gap:4px;min-width:280px"><div style="font-family:var(--font-heading);font-size:28px;line-height:1.15;white-space:nowrap">2 · Plum + marigold</div><div style="font-size:14px;color:var(--color-neutral-800)">Brighter and more playful. Ground moves to a softer off-white.</div></div><div style="display:flex;gap:8px"><span style="width:44px;height:44px;border-radius:50%;background:var(--color-bg);box-shadow:inset 0 0 0 1px var(--color-divider)"></span><span style="width:44px;height:44px;border-radius:50%;background:var(--color-surface);box-shadow:inset 0 0 0 1px var(--color-divider)"></span><span style="width:44px;height:44px;border-radius:50%;background:var(--color-accent);box-shadow:inset 0 0 0 1px var(--color-divider)"></span><span style="width:44px;height:44px;border-radius:50%;background:var(--color-accent-2);box-shadow:inset 0 0 0 1px var(--color-divider)"></span><span style="width:44px;height:44px;border-radius:50%;background:var(--color-text);box-shadow:inset 0 0 0 1px var(--color-divider)"></span></div><div style="display:flex;gap:3px"><span style="width:22px;height:44px;border-radius:999px;background:var(--color-accent-100)"></span><span style="width:22px;height:44px;border-radius:999px;background:var(--color-accent-200)"></span><span style="width:22px;height:44px;border-radius:999px;background:var(--color-accent-300)"></span><span style="width:22px;height:44px;border-radius:999px;background:var(--color-accent-400)"></span><span style="width:22px;height:44px;border-radius:999px;background:var(--color-accent-500)"></span><span style="width:22px;height:44px;border-radius:999px;background:var(--color-accent-600)"></span><span style="width:22px;height:44px;border-radius:999px;background:var(--color-accent-700)"></span><span style="width:22px;height:44px;border-radius:999px;background:var(--color-accent-800)"></span><span style="width:22px;height:44px;border-radius:999px;background:var(--color-accent-900)"></span></div></div>
<div style="display:flex;gap:28px;flex-wrap:wrap"><div style="display:flex;flex-direction:column;gap:8px"><div style="font-size:12px;font-weight:700;color:var(--color-neutral-700)">Home</div><div style="width:360px;height:760px;position:relative;border-radius:36px;background:var(--color-bg);overflow:hidden;box-shadow:var(--shadow-md)">
<div style="height:40px;display:flex;align-items:center;justify-content:space-between;padding:0 26px;font-size:13px;font-weight:700"><span>9:41</span><span style="width:22px;height:10px;border-radius:3px;background:var(--color-text)"></span></div>
<div style="padding:6px 20px 0;display:flex;justify-content:space-between;align-items:flex-start">
<div><div style="font-size:12px;font-weight:700;color:var(--color-neutral-700)">Friday, Oct 2</div><div style="font-family:var(--font-heading);font-size:28px;line-height:1.1">Tonight's quest</div></div>
<div style="position:relative;width:40px;height:40px;border-radius:50%;background:var(--color-surface);display:flex;align-items:center;justify-content:center"><svg viewBox="0 0 24 24" width="19" height="19" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M10.268 21a2 2 0 0 0 3.464 0"></path><path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326"></path></svg><span style="position:absolute;top:8px;right:9px;width:8px;height:8px;border-radius:50%;background:var(--color-accent);color:var(--on-accent)"></span></div>
</div>
<div style="margin:14px 20px 0;border-radius:32px;background:var(--color-surface);overflow:hidden;display:flex;flex-direction:column">
<div style="height:150px;margin:10px;border-radius:24px;background:repeating-linear-gradient(135deg,var(--color-neutral-300) 0 8px,var(--color-neutral-200) 8px 16px);display:flex;align-items:center;justify-content:center;font:11px ui-monospace,Menlo,monospace;color:var(--color-neutral-700)">dish photo</div>
<div style="padding:2px 18px 16px;display:flex;align-items:flex-end;justify-content:space-between;gap:10px"><div><div style="font-family:var(--font-heading);font-size:22px;line-height:1.1">Pizza night</div><div style="font-size:12px;color:var(--color-neutral-700);margin-top:4px">Regular · 40 min</div></div><div style="height:38px;padding:0 16px;border-radius:999px;background:var(--color-text);color:var(--color-bg);display:flex;align-items:center;font-family:var(--font-heading);font-size:13px">Recipe</div></div>
</div>
<div style="margin:12px 20px 0;display:grid;grid-template-columns:1fr 1fr;gap:10px">
<div style="height:104px;padding:14px;border-radius:26px;background:var(--color-accent);color:var(--on-accent);display:flex;flex-direction:column;justify-content:space-between"><svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z"></path></svg><span style="font-family:var(--font-heading);font-size:16px;line-height:1.1">Quick dinner</span></div>
<div style="height:104px;padding:14px;border-radius:26px;background:var(--color-accent-2-300);color:var(--color-accent-2-900);display:flex;flex-direction:column;justify-content:space-between"><svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><circle cx="11" cy="11" r="8"></circle><path d="m21 21-4.3-4.3"></path></svg><span style="font-family:var(--font-heading);font-size:16px;line-height:1.1">Cook from what I have</span></div>
</div>
<div style="margin:12px 20px 0;display:flex;flex-direction:column;gap:8px">
<div style="display:flex;align-items:center;gap:12px;padding:12px 14px;border-radius:20px;background:var(--color-neutral-100)"><svg viewBox="0 0 24 24" width="18" height="18" style="color:var(--color-accent-700);fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><circle cx="12" cy="12" r="10"></circle><path d="m16.24 7.76-1.804 5.411a2 2 0 0 1-1.265 1.265L7.76 16.24l1.804-5.411a2 2 0 0 1 1.265-1.265z"></path></svg><span style="flex:1;font-size:13px;font-weight:700">10 new side quests to swipe</span><svg viewBox="0 0 24 24" width="16" height="16" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="m9 18 6-6-6-6"></path></svg></div>
<div style="display:flex;align-items:center;gap:12px;padding:12px 14px;border-radius:20px;background:var(--color-neutral-100)"><svg viewBox="0 0 24 24" width="18" height="18" style="color:var(--color-accent-700);fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><circle cx="8" cy="21" r="1"></circle><circle cx="19" cy="21" r="1"></circle><path d="M2.05 2.05h2l2.66 12.42a2 2 0 0 0 2 1.58h9.78a2 2 0 0 0 1.95-1.57l1.65-7.43H5.12"></path></svg><span style="flex:1;font-size:13px;font-weight:700">Shopping list ready · Oct 5–11</span><svg viewBox="0 0 24 24" width="16" height="16" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="m9 18 6-6-6-6"></path></svg></div>
</div>
<div style="position:absolute;left:14px;right:14px;bottom:16px;height:62px;border-radius:999px;background:var(--color-text);display:flex;align-items:center;justify-content:space-around;color:var(--color-neutral-400)">
<div style="display:flex;align-items:center;gap:6px;padding:10px 14px;border-radius:999px;background:var(--color-accent);color:var(--on-accent);font-size:13px;font-weight:700"><svg viewBox="0 0 24 24" width="20" height="20" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"></path><path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path></svg>Home</div>
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M8 2v4"></path><path d="M16 2v4"></path><rect width="18" height="18" x="3" y="4" rx="2"></rect><path d="M3 10h18"></path></svg>
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><circle cx="12" cy="12" r="10"></circle><path d="m16.24 7.76-1.804 5.411a2 2 0 0 1-1.265 1.265L7.76 16.24l1.804-5.411a2 2 0 0 1 1.265-1.265z"></path></svg>
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M10 5H3"></path><path d="M12 19H3"></path><path d="M14 3v4"></path><path d="M16 17v4"></path><path d="M21 12h-9"></path><path d="M21 19h-5"></path><path d="M21 5h-7"></path><path d="M8 10v4"></path><path d="M8 12H3"></path></svg>
</div>
</div></div><div style="display:flex;flex-direction:column;gap:8px"><div style="font-size:12px;font-weight:700;color:var(--color-neutral-700)">Discover</div><div style="width:360px;height:760px;position:relative;border-radius:36px;background:var(--color-bg);overflow:hidden;box-shadow:var(--shadow-md)">
<div style="height:40px;display:flex;align-items:center;justify-content:space-between;padding:0 26px;font-size:13px;font-weight:700"><span>9:41</span><span style="width:22px;height:10px;border-radius:3px;background:var(--color-text)"></span></div>
<div style="padding:8px 20px 0;display:flex;align-items:flex-end;justify-content:space-between">
<div><div style="font-size:11px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:var(--color-accent-700)">Discover</div><div style="font-family:var(--font-heading);font-size:30px;line-height:1.1">Side quests</div></div>
<div style="display:flex;align-items:center;gap:6px;padding:7px 12px;border-radius:999px;background:var(--color-accent-2-200);color:var(--color-accent-2-800);font-size:12px;font-weight:700">Quest log · 6</div>
</div>
<div style="position:relative;margin:18px 20px 0;height:440px">
<div style="position:absolute;inset:0;transform:translateY(16px) scale(.9);border-radius:32px;background:var(--color-neutral-300)"></div>
<div style="position:absolute;inset:0;transform:translateY(8px) scale(.95);border-radius:32px;background:var(--color-surface)"></div>
<div style="position:absolute;inset:0;border-radius:32px;background:var(--color-neutral-100);box-shadow:var(--shadow-lg);overflow:hidden;display:flex;flex-direction:column">
<div style="height:220px;margin:10px;border-radius:24px;background:repeating-linear-gradient(135deg,var(--color-neutral-300) 0 8px,var(--color-neutral-200) 8px 16px);display:flex;align-items:center;justify-content:center;font:11px ui-monospace,Menlo,monospace;color:var(--color-neutral-700)">dish photo</div>
<div style="padding:4px 20px 0;display:flex;flex-direction:column;gap:10px">
<div style="display:flex;align-items:center;justify-content:space-between"><div style="font-family:var(--font-heading);font-size:24px;line-height:1.1">Korean beef bowls</div></div>
<div style="display:flex;align-items:center;gap:8px;font-size:12px;font-weight:700;color:var(--color-neutral-700)"><span style="display:flex;gap:3px"><span style="width:8px;height:8px;border-radius:50%;background:var(--color-accent);color:var(--on-accent)"></span><span style="width:8px;height:8px;border-radius:50%;background:var(--color-neutral-300)"></span><span style="width:8px;height:8px;border-radius:50%;background:var(--color-neutral-300)"></span></span>Easy</div>
<div style="padding:12px 14px;border-radius:18px;background:var(--color-accent-2-100);color:var(--color-accent-2-900);font-size:13px;line-height:1.45"><b>Why this?</b> You make Chicken stir-fry and Beef tacos most weeks. Same flavors, sweet-savory twist.</div>
</div>
</div>
</div>
<div style="display:flex;justify-content:center;gap:28px;margin-top:22px">
<div style="display:flex;flex-direction:column;align-items:center;gap:6px;font-size:11px;font-weight:700;color:var(--color-neutral-700)"><div style="width:62px;height:62px;border-radius:50%;background:var(--color-neutral-100);box-shadow:var(--shadow-md);display:flex;align-items:center;justify-content:center;color:var(--color-text)"><svg viewBox="0 0 24 24" width="26" height="26" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M18 6 6 18"></path><path d="m6 6 12 12"></path></svg></div>Pass</div>
<div style="display:flex;flex-direction:column;align-items:center;gap:6px;font-size:11px;font-weight:700;color:var(--color-neutral-700)"><div style="width:62px;height:62px;border-radius:50%;background:var(--color-accent-2-600);box-shadow:var(--shadow-md);display:flex;align-items:center;justify-content:center;color:var(--color-bg)"><svg viewBox="0 0 24 24" width="26" height="26" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z"></path></svg></div>Try it</div>
</div>
<div style="position:absolute;left:14px;right:14px;bottom:16px;height:62px;border-radius:999px;background:var(--color-text);display:flex;align-items:center;justify-content:space-around;color:var(--color-neutral-400)">
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"></path><path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path></svg>
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M8 2v4"></path><path d="M16 2v4"></path><rect width="18" height="18" x="3" y="4" rx="2"></rect><path d="M3 10h18"></path></svg>
<div style="display:flex;align-items:center;gap:6px;padding:10px 14px;border-radius:999px;background:var(--color-accent);color:var(--on-accent);font-size:13px;font-weight:700"><svg viewBox="0 0 24 24" width="20" height="20" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><circle cx="12" cy="12" r="10"></circle><path d="m16.24 7.76-1.804 5.411a2 2 0 0 1-1.265 1.265L7.76 16.24l1.804-5.411a2 2 0 0 1 1.265-1.265z"></path></svg>Discover</div>
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M10 5H3"></path><path d="M12 19H3"></path><path d="M14 3v4"></path><path d="M16 17v4"></path><path d="M21 12h-9"></path><path d="M21 19h-5"></path><path d="M21 5h-7"></path><path d="M8 10v4"></path><path d="M8 12H3"></path></svg>
</div>
</div></div><div style="display:flex;flex-direction:column;gap:8px"><div style="font-size:12px;font-weight:700;color:var(--color-neutral-700)">Weekly plan</div><div style="width:360px;height:760px;position:relative;border-radius:36px;background:var(--color-bg);overflow:hidden;box-shadow:var(--shadow-md)">
<div style="height:40px;display:flex;align-items:center;justify-content:space-between;padding:0 26px;font-size:13px;font-weight:700"><span>9:41</span><span style="width:22px;height:10px;border-radius:3px;background:var(--color-text)"></span></div>
<div style="padding:6px 20px 0;display:flex;align-items:center;justify-content:space-between">
<div><div style="font-size:11px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:var(--color-accent-700)">Week 41</div><div style="font-family:var(--font-heading);font-size:26px;line-height:1.1">Oct 5 – 11</div></div>
<div style="display:flex;gap:6px"><div style="width:36px;height:36px;border-radius:50%;background:var(--color-surface);display:flex;align-items:center;justify-content:center"><svg viewBox="0 0 24 24" width="18" height="18" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="m15 18-6-6 6-6"></path></svg></div><div style="width:36px;height:36px;border-radius:50%;background:var(--color-surface);display:flex;align-items:center;justify-content:center"><svg viewBox="0 0 24 24" width="18" height="18" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="m9 18 6-6-6-6"></path></svg></div></div>
</div>
<div style="margin:14px 20px 0;padding:10px 14px;border-radius:18px;background:var(--color-accent-2-100);color:var(--color-accent-2-900);font-size:12px;line-height:1.4;display:flex;gap:8px;align-items:center"><svg viewBox="0 0 24 24" width="16" height="16" style="flex:none;fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M20 6 9 17l-5-5"></path></svg><span>5 regulars + 2 side quests · synced to Google Calendar</span></div>
<div style="margin:12px 20px 0;display:flex;flex-direction:column;gap:6px">
<div style="display:flex;align-items:center;gap:12px;padding:9px 12px;border-radius:18px;background:var(--color-surface)"><div style="width:36px;text-align:center;font-size:11px;font-weight:700;color:var(--color-neutral-700);line-height:1.1">MON<br><span style="font-size:16px;color:var(--color-text)">5</span></div><div style="flex:1;font-weight:700;font-size:14px">Beef tacos</div><span style="padding:3px 9px;border-radius:999px;background:var(--color-neutral-100);color:var(--color-neutral-800);font-size:10px;font-weight:700">Regular</span><svg viewBox="0 0 24 24" width="16" height="16" style="color:var(--color-neutral-600);fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"></path><path d="M21 3v5h-5"></path><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"></path><path d="M8 16H3v5"></path></svg></div>
<div style="display:flex;align-items:center;gap:12px;padding:9px 12px;border-radius:18px;background:var(--color-surface)"><div style="width:36px;text-align:center;font-size:11px;font-weight:700;color:var(--color-neutral-700);line-height:1.1">TUE<br><span style="font-size:16px;color:var(--color-text)">6</span></div><div style="flex:1;font-weight:700;font-size:14px">Spaghetti bolognese</div><span style="padding:3px 9px;border-radius:999px;background:var(--color-neutral-100);color:var(--color-neutral-800);font-size:10px;font-weight:700">Regular</span><svg viewBox="0 0 24 24" width="16" height="16" style="color:var(--color-neutral-600);fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"></path><path d="M21 3v5h-5"></path><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"></path><path d="M8 16H3v5"></path></svg></div>
<div style="display:flex;align-items:center;gap:12px;padding:9px 12px;border-radius:18px;background:var(--color-accent-100);box-shadow:inset 0 0 0 1.5px var(--color-accent-300)"><div style="width:36px;text-align:center;font-size:11px;font-weight:700;color:var(--color-accent-700);line-height:1.1">WED<br><span style="font-size:16px;color:var(--color-text)">7</span></div><div style="flex:1;font-weight:700;font-size:14px">Crock pot tikka masala</div><span style="padding:3px 9px;border-radius:999px;background:var(--color-accent-200);color:var(--color-accent-800);font-size:10px;font-weight:700">Side quest</span><svg viewBox="0 0 24 24" width="16" height="16" style="color:var(--color-neutral-600);fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"></path><path d="M21 3v5h-5"></path><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"></path><path d="M8 16H3v5"></path></svg></div>
<div style="display:flex;align-items:center;gap:12px;padding:9px 12px;border-radius:18px;background:var(--color-surface)"><div style="width:36px;text-align:center;font-size:11px;font-weight:700;color:var(--color-neutral-700);line-height:1.1">THU<br><span style="font-size:16px;color:var(--color-text)">8</span></div><div style="flex:1;font-weight:700;font-size:14px">Chicken stir-fry</div><span style="padding:3px 9px;border-radius:999px;background:var(--color-neutral-100);color:var(--color-neutral-800);font-size:10px;font-weight:700">Regular</span><svg viewBox="0 0 24 24" width="16" height="16" style="color:var(--color-neutral-600);fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"></path><path d="M21 3v5h-5"></path><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"></path><path d="M8 16H3v5"></path></svg></div>
<div style="display:flex;align-items:center;gap:12px;padding:9px 12px;border-radius:18px;background:var(--color-surface)"><div style="width:36px;text-align:center;font-size:11px;font-weight:700;color:var(--color-neutral-700);line-height:1.1">FRI<br><span style="font-size:16px;color:var(--color-text)">9</span></div><div style="flex:1;font-weight:700;font-size:14px">Pizza night</div><span style="padding:3px 9px;border-radius:999px;background:var(--color-neutral-100);color:var(--color-neutral-800);font-size:10px;font-weight:700">Regular</span><svg viewBox="0 0 24 24" width="16" height="16" style="color:var(--color-neutral-600);fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"></path><path d="M21 3v5h-5"></path><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"></path><path d="M8 16H3v5"></path></svg></div>
<div style="display:flex;align-items:center;gap:12px;padding:9px 12px;border-radius:18px;background:var(--color-accent-100);box-shadow:inset 0 0 0 1.5px var(--color-accent-300)"><div style="width:36px;text-align:center;font-size:11px;font-weight:700;color:var(--color-accent-700);line-height:1.1">SAT<br><span style="font-size:16px;color:var(--color-text)">10</span></div><div style="flex:1;font-weight:700;font-size:14px">Korean beef bowls</div><span style="padding:3px 9px;border-radius:999px;background:var(--color-accent-200);color:var(--color-accent-800);font-size:10px;font-weight:700">Side quest</span><svg viewBox="0 0 24 24" width="16" height="16" style="color:var(--color-neutral-600);fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"></path><path d="M21 3v5h-5"></path><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"></path><path d="M8 16H3v5"></path></svg></div>
<div style="display:flex;align-items:center;gap:12px;padding:9px 12px;border-radius:18px;background:var(--color-surface)"><div style="width:36px;text-align:center;font-size:11px;font-weight:700;color:var(--color-neutral-700);line-height:1.1">SUN<br><span style="font-size:16px;color:var(--color-text)">11</span></div><div style="flex:1;font-weight:700;font-size:14px">Sheet-pan salmon</div><span style="padding:3px 9px;border-radius:999px;background:var(--color-neutral-100);color:var(--color-neutral-800);font-size:10px;font-weight:700">Regular</span><svg viewBox="0 0 24 24" width="16" height="16" style="color:var(--color-neutral-600);fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"></path><path d="M21 3v5h-5"></path><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"></path><path d="M8 16H3v5"></path></svg></div>
</div>
<div style="margin:14px 20px 0;height:50px;border-radius:999px;background:var(--color-accent);display:flex;align-items:center;justify-content:center;gap:8px;font-family:var(--font-heading);font-size:15px;color:var(--on-accent)"><svg viewBox="0 0 24 24" width="18" height="18" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><circle cx="8" cy="21" r="1"></circle><circle cx="19" cy="21" r="1"></circle><path d="M2.05 2.05h2l2.66 12.42a2 2 0 0 0 2 1.58h9.78a2 2 0 0 0 1.95-1.57l1.65-7.43H5.12"></path></svg>Shopping list · 23 items</div>
<div style="position:absolute;left:14px;right:14px;bottom:16px;height:62px;border-radius:999px;background:var(--color-text);display:flex;align-items:center;justify-content:space-around;color:var(--color-neutral-400)">
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"></path><path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path></svg>
<div style="display:flex;align-items:center;gap:6px;padding:10px 14px;border-radius:999px;background:var(--color-accent);color:var(--on-accent);font-size:13px;font-weight:700"><svg viewBox="0 0 24 24" width="20" height="20" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M8 2v4"></path><path d="M16 2v4"></path><rect width="18" height="18" x="3" y="4" rx="2"></rect><path d="M3 10h18"></path></svg>Plan</div>
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><circle cx="12" cy="12" r="10"></circle><path d="m16.24 7.76-1.804 5.411a2 2 0 0 1-1.265 1.265L7.76 16.24l1.804-5.411a2 2 0 0 1 1.265-1.265z"></path></svg>
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M10 5H3"></path><path d="M12 19H3"></path><path d="M14 3v4"></path><path d="M16 17v4"></path><path d="M21 12h-9"></path><path d="M21 19h-5"></path><path d="M21 5h-7"></path><path d="M8 10v4"></path><path d="M8 12H3"></path></svg>
</div>
</div></div><div style="display:flex;flex-direction:column;gap:8px"><div style="font-size:12px;font-weight:700;color:var(--color-neutral-700)">Shopping list</div><div style="width:360px;height:760px;position:relative;border-radius:36px;background:var(--color-bg);overflow:hidden;box-shadow:var(--shadow-md)">
<div style="height:40px;display:flex;align-items:center;justify-content:space-between;padding:0 26px;font-size:13px;font-weight:700"><span>9:41</span><span style="width:22px;height:10px;border-radius:3px;background:var(--color-text)"></span></div>
<div style="padding:6px 20px 0;display:flex;justify-content:space-between;align-items:flex-end"><div><div style="font-size:11px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:var(--color-accent-700)">Supply run · Oct 5–11</div><div style="font-family:var(--font-heading);font-size:28px;line-height:1.1">Shopping list</div></div><div style="font-size:13px;font-weight:700;color:var(--color-neutral-700)">19 / 23</div></div>
<div style="margin:10px 20px 0;font-size:12px;line-height:1.45;color:var(--color-neutral-800)">Uncheck anything you already have — it won't be counted.</div>
<div style="padding:12px 20px 0;display:flex;flex-direction:column;gap:12px">
<div style="padding:14px;border-radius:24px;background:var(--color-surface);display:flex;flex-direction:column;gap:9px">
<div style="display:flex;justify-content:space-between;align-items:baseline"><span style="font-family:var(--font-heading);font-size:16px">Beef tacos</span><span style="font-size:11px;font-weight:700;color:var(--color-neutral-700)">MON</span></div>
<div style="display:flex;align-items:center;gap:10px;font-size:14px"><span style="width:20px;height:20px;border-radius:7px;background:var(--color-accent);color:var(--on-accent);display:flex;align-items:center;justify-content:center"><svg viewBox="0 0 24 24" width="13" height="13" style="fill:none;stroke:currentColor;stroke-width:3.5;stroke-linecap:round;stroke-linejoin:round"><path d="M20 6 9 17l-5-5"></path></svg></span><span style="flex:1">Ground beef</span><span style="color:var(--color-neutral-700);font-size:12px">1 lb</span></div>
<div style="display:flex;align-items:center;gap:10px;font-size:14px"><span style="width:20px;height:20px;border-radius:7px;background:var(--color-accent);color:var(--on-accent);display:flex;align-items:center;justify-content:center"><svg viewBox="0 0 24 24" width="13" height="13" style="fill:none;stroke:currentColor;stroke-width:3.5;stroke-linecap:round;stroke-linejoin:round"><path d="M20 6 9 17l-5-5"></path></svg></span><span style="flex:1">Corn tortillas</span><span style="color:var(--color-neutral-700);font-size:12px">12</span></div>
<div style="display:flex;align-items:center;gap:10px;font-size:14px;color:var(--color-neutral-600)"><span style="width:20px;height:20px;border-radius:7px;border:2px solid var(--color-neutral-400)"></span><span style="flex:1;text-decoration:line-through">Taco seasoning</span><span style="font-size:11px;font-weight:700">have it</span></div>
</div>
<div style="padding:14px;border-radius:24px;background:var(--color-surface);display:flex;flex-direction:column;gap:9px">
<div style="display:flex;justify-content:space-between;align-items:baseline"><span style="font-family:var(--font-heading);font-size:16px">Crock pot tikka masala</span><span style="font-size:11px;font-weight:700;color:var(--color-neutral-700)">WED</span></div>
<div style="display:flex;align-items:center;gap:10px;font-size:14px"><span style="width:20px;height:20px;border-radius:7px;background:var(--color-accent);color:var(--on-accent);display:flex;align-items:center;justify-content:center"><svg viewBox="0 0 24 24" width="13" height="13" style="fill:none;stroke:currentColor;stroke-width:3.5;stroke-linecap:round;stroke-linejoin:round"><path d="M20 6 9 17l-5-5"></path></svg></span><span style="flex:1">Chicken thighs</span><span style="color:var(--color-neutral-700);font-size:12px">2 lb</span></div>
<div style="display:flex;align-items:center;gap:10px;font-size:14px"><span style="width:20px;height:20px;border-radius:7px;background:var(--color-accent);color:var(--on-accent);display:flex;align-items:center;justify-content:center"><svg viewBox="0 0 24 24" width="13" height="13" style="fill:none;stroke:currentColor;stroke-width:3.5;stroke-linecap:round;stroke-linejoin:round"><path d="M20 6 9 17l-5-5"></path></svg></span><span style="flex:1">Tikka masala paste</span><span style="color:var(--color-neutral-700);font-size:12px">1 jar</span></div>
<div style="display:flex;align-items:center;gap:10px;font-size:14px;color:var(--color-neutral-600)"><span style="width:20px;height:20px;border-radius:7px;border:2px solid var(--color-neutral-400)"></span><span style="flex:1;text-decoration:line-through">Basmati rice</span><span style="font-size:11px;font-weight:700">have it</span></div>
</div>
<div style="padding:14px;border-radius:24px;background:var(--color-surface);display:flex;justify-content:space-between;align-items:baseline"><span style="font-family:var(--font-heading);font-size:16px">Chicken stir-fry</span><span style="font-size:11px;font-weight:700;color:var(--color-neutral-700)">THU · 5 items</span></div>
</div>
<div style="position:absolute;left:20px;right:20px;bottom:96px;display:flex;align-items:center;gap:8px;font-size:12px;font-weight:700;color:var(--color-accent-2-800)"><svg viewBox="0 0 24 24" width="15" height="15" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path></svg>Sent to Google Chat · Sat 8:00 am</div>
<div style="position:absolute;left:14px;right:14px;bottom:16px;height:62px;border-radius:999px;background:var(--color-text);display:flex;align-items:center;justify-content:space-around;color:var(--color-neutral-400)">
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"></path><path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path></svg>
<div style="display:flex;align-items:center;gap:6px;padding:10px 14px;border-radius:999px;background:var(--color-accent);color:var(--on-accent);font-size:13px;font-weight:700"><svg viewBox="0 0 24 24" width="20" height="20" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M8 2v4"></path><path d="M16 2v4"></path><rect width="18" height="18" x="3" y="4" rx="2"></rect><path d="M3 10h18"></path></svg>Plan</div>
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><circle cx="12" cy="12" r="10"></circle><path d="m16.24 7.76-1.804 5.411a2 2 0 0 1-1.265 1.265L7.76 16.24l1.804-5.411a2 2 0 0 1 1.265-1.265z"></path></svg>
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M10 5H3"></path><path d="M12 19H3"></path><path d="M14 3v4"></path><path d="M16 17v4"></path><path d="M21 12h-9"></path><path d="M21 19h-5"></path><path d="M21 5h-7"></path><path d="M8 10v4"></path><path d="M8 12H3"></path></svg>
</div>
</div></div></div>
</div>
<div id="c3" style="--color-accent:oklch(0.42 0.14 300);--color-accent-100:oklch(0.97 0.032 300);--color-accent-200:oklch(0.93 0.063 300);--color-accent-300:oklch(0.86 0.095 300);--color-accent-400:oklch(0.77 0.126 300);--color-accent-500:oklch(0.67 0.147 300);--color-accent-600:oklch(0.57 0.147 300);--color-accent-700:oklch(0.47 0.137 300);--color-accent-800:oklch(0.37 0.116 300);--color-accent-900:oklch(0.27 0.084 300);--color-accent-2:#c67139;--color-accent-2-100:#fff2eb;--color-accent-2-200:#ffe1d0;--color-accent-2-300:#ffc6a5;--color-accent-2-400:#f6a06b;--color-accent-2-500:#d67f48;--color-accent-2-600:#b2622d;--color-accent-2-700:#8c491a;--color-accent-2-800:#643312;--color-accent-2-900:#402310;--on-accent:var(--color-bg);padding:32px 40px;border-radius:40px;background:var(--color-bg);display:flex;flex-direction:column;gap:20px;color:var(--color-text);font-family:var(--font-body)">
<div style="display:flex;align-items:center;gap:20px;flex-wrap:wrap"><div style="display:flex;flex-direction:column;gap:4px;min-width:280px"><div style="font-family:var(--font-heading);font-size:28px;line-height:1.15;white-space:nowrap">3 · Grape + terracotta</div><div style="font-size:14px;color:var(--color-neutral-800)">Deepest and richest; terracotta becomes the second voice.</div></div><div style="display:flex;gap:8px"><span style="width:44px;height:44px;border-radius:50%;background:var(--color-bg);box-shadow:inset 0 0 0 1px var(--color-divider)"></span><span style="width:44px;height:44px;border-radius:50%;background:var(--color-surface);box-shadow:inset 0 0 0 1px var(--color-divider)"></span><span style="width:44px;height:44px;border-radius:50%;background:var(--color-accent);box-shadow:inset 0 0 0 1px var(--color-divider)"></span><span style="width:44px;height:44px;border-radius:50%;background:var(--color-accent-2);box-shadow:inset 0 0 0 1px var(--color-divider)"></span><span style="width:44px;height:44px;border-radius:50%;background:var(--color-text);box-shadow:inset 0 0 0 1px var(--color-divider)"></span></div><div style="display:flex;gap:3px"><span style="width:22px;height:44px;border-radius:999px;background:var(--color-accent-100)"></span><span style="width:22px;height:44px;border-radius:999px;background:var(--color-accent-200)"></span><span style="width:22px;height:44px;border-radius:999px;background:var(--color-accent-300)"></span><span style="width:22px;height:44px;border-radius:999px;background:var(--color-accent-400)"></span><span style="width:22px;height:44px;border-radius:999px;background:var(--color-accent-500)"></span><span style="width:22px;height:44px;border-radius:999px;background:var(--color-accent-600)"></span><span style="width:22px;height:44px;border-radius:999px;background:var(--color-accent-700)"></span><span style="width:22px;height:44px;border-radius:999px;background:var(--color-accent-800)"></span><span style="width:22px;height:44px;border-radius:999px;background:var(--color-accent-900)"></span></div></div>
<div style="display:flex;gap:28px;flex-wrap:wrap"><div style="display:flex;flex-direction:column;gap:8px"><div style="font-size:12px;font-weight:700;color:var(--color-neutral-700)">Home</div><div style="width:360px;height:760px;position:relative;border-radius:36px;background:var(--color-bg);overflow:hidden;box-shadow:var(--shadow-md)">
<div style="height:40px;display:flex;align-items:center;justify-content:space-between;padding:0 26px;font-size:13px;font-weight:700"><span>9:41</span><span style="width:22px;height:10px;border-radius:3px;background:var(--color-text)"></span></div>
<div style="padding:6px 20px 0;display:flex;justify-content:space-between;align-items:flex-start">
<div><div style="font-size:12px;font-weight:700;color:var(--color-neutral-700)">Friday, Oct 2</div><div style="font-family:var(--font-heading);font-size:28px;line-height:1.1">Tonight's quest</div></div>
<div style="position:relative;width:40px;height:40px;border-radius:50%;background:var(--color-surface);display:flex;align-items:center;justify-content:center"><svg viewBox="0 0 24 24" width="19" height="19" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M10.268 21a2 2 0 0 0 3.464 0"></path><path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326"></path></svg><span style="position:absolute;top:8px;right:9px;width:8px;height:8px;border-radius:50%;background:var(--color-accent);color:var(--on-accent)"></span></div>
</div>
<div style="margin:14px 20px 0;border-radius:32px;background:var(--color-surface);overflow:hidden;display:flex;flex-direction:column">
<div style="height:150px;margin:10px;border-radius:24px;background:repeating-linear-gradient(135deg,var(--color-neutral-300) 0 8px,var(--color-neutral-200) 8px 16px);display:flex;align-items:center;justify-content:center;font:11px ui-monospace,Menlo,monospace;color:var(--color-neutral-700)">dish photo</div>
<div style="padding:2px 18px 16px;display:flex;align-items:flex-end;justify-content:space-between;gap:10px"><div><div style="font-family:var(--font-heading);font-size:22px;line-height:1.1">Pizza night</div><div style="font-size:12px;color:var(--color-neutral-700);margin-top:4px">Regular · 40 min</div></div><div style="height:38px;padding:0 16px;border-radius:999px;background:var(--color-text);color:var(--color-bg);display:flex;align-items:center;font-family:var(--font-heading);font-size:13px">Recipe</div></div>
</div>
<div style="margin:12px 20px 0;display:grid;grid-template-columns:1fr 1fr;gap:10px">
<div style="height:104px;padding:14px;border-radius:26px;background:var(--color-accent);color:var(--on-accent);display:flex;flex-direction:column;justify-content:space-between"><svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z"></path></svg><span style="font-family:var(--font-heading);font-size:16px;line-height:1.1">Quick dinner</span></div>
<div style="height:104px;padding:14px;border-radius:26px;background:var(--color-accent-2-300);color:var(--color-accent-2-900);display:flex;flex-direction:column;justify-content:space-between"><svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><circle cx="11" cy="11" r="8"></circle><path d="m21 21-4.3-4.3"></path></svg><span style="font-family:var(--font-heading);font-size:16px;line-height:1.1">Cook from what I have</span></div>
</div>
<div style="margin:12px 20px 0;display:flex;flex-direction:column;gap:8px">
<div style="display:flex;align-items:center;gap:12px;padding:12px 14px;border-radius:20px;background:var(--color-neutral-100)"><svg viewBox="0 0 24 24" width="18" height="18" style="color:var(--color-accent-700);fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><circle cx="12" cy="12" r="10"></circle><path d="m16.24 7.76-1.804 5.411a2 2 0 0 1-1.265 1.265L7.76 16.24l1.804-5.411a2 2 0 0 1 1.265-1.265z"></path></svg><span style="flex:1;font-size:13px;font-weight:700">10 new side quests to swipe</span><svg viewBox="0 0 24 24" width="16" height="16" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="m9 18 6-6-6-6"></path></svg></div>
<div style="display:flex;align-items:center;gap:12px;padding:12px 14px;border-radius:20px;background:var(--color-neutral-100)"><svg viewBox="0 0 24 24" width="18" height="18" style="color:var(--color-accent-700);fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><circle cx="8" cy="21" r="1"></circle><circle cx="19" cy="21" r="1"></circle><path d="M2.05 2.05h2l2.66 12.42a2 2 0 0 0 2 1.58h9.78a2 2 0 0 0 1.95-1.57l1.65-7.43H5.12"></path></svg><span style="flex:1;font-size:13px;font-weight:700">Shopping list ready · Oct 5–11</span><svg viewBox="0 0 24 24" width="16" height="16" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="m9 18 6-6-6-6"></path></svg></div>
</div>
<div style="position:absolute;left:14px;right:14px;bottom:16px;height:62px;border-radius:999px;background:var(--color-text);display:flex;align-items:center;justify-content:space-around;color:var(--color-neutral-400)">
<div style="display:flex;align-items:center;gap:6px;padding:10px 14px;border-radius:999px;background:var(--color-accent);color:var(--on-accent);font-size:13px;font-weight:700"><svg viewBox="0 0 24 24" width="20" height="20" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"></path><path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path></svg>Home</div>
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M8 2v4"></path><path d="M16 2v4"></path><rect width="18" height="18" x="3" y="4" rx="2"></rect><path d="M3 10h18"></path></svg>
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><circle cx="12" cy="12" r="10"></circle><path d="m16.24 7.76-1.804 5.411a2 2 0 0 1-1.265 1.265L7.76 16.24l1.804-5.411a2 2 0 0 1 1.265-1.265z"></path></svg>
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M10 5H3"></path><path d="M12 19H3"></path><path d="M14 3v4"></path><path d="M16 17v4"></path><path d="M21 12h-9"></path><path d="M21 19h-5"></path><path d="M21 5h-7"></path><path d="M8 10v4"></path><path d="M8 12H3"></path></svg>
</div>
</div></div><div style="display:flex;flex-direction:column;gap:8px"><div style="font-size:12px;font-weight:700;color:var(--color-neutral-700)">Discover</div><div style="width:360px;height:760px;position:relative;border-radius:36px;background:var(--color-bg);overflow:hidden;box-shadow:var(--shadow-md)">
<div style="height:40px;display:flex;align-items:center;justify-content:space-between;padding:0 26px;font-size:13px;font-weight:700"><span>9:41</span><span style="width:22px;height:10px;border-radius:3px;background:var(--color-text)"></span></div>
<div style="padding:8px 20px 0;display:flex;align-items:flex-end;justify-content:space-between">
<div><div style="font-size:11px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:var(--color-accent-700)">Discover</div><div style="font-family:var(--font-heading);font-size:30px;line-height:1.1">Side quests</div></div>
<div style="display:flex;align-items:center;gap:6px;padding:7px 12px;border-radius:999px;background:var(--color-accent-2-200);color:var(--color-accent-2-800);font-size:12px;font-weight:700">Quest log · 6</div>
</div>
<div style="position:relative;margin:18px 20px 0;height:440px">
<div style="position:absolute;inset:0;transform:translateY(16px) scale(.9);border-radius:32px;background:var(--color-neutral-300)"></div>
<div style="position:absolute;inset:0;transform:translateY(8px) scale(.95);border-radius:32px;background:var(--color-surface)"></div>
<div style="position:absolute;inset:0;border-radius:32px;background:var(--color-neutral-100);box-shadow:var(--shadow-lg);overflow:hidden;display:flex;flex-direction:column">
<div style="height:220px;margin:10px;border-radius:24px;background:repeating-linear-gradient(135deg,var(--color-neutral-300) 0 8px,var(--color-neutral-200) 8px 16px);display:flex;align-items:center;justify-content:center;font:11px ui-monospace,Menlo,monospace;color:var(--color-neutral-700)">dish photo</div>
<div style="padding:4px 20px 0;display:flex;flex-direction:column;gap:10px">
<div style="display:flex;align-items:center;justify-content:space-between"><div style="font-family:var(--font-heading);font-size:24px;line-height:1.1">Korean beef bowls</div></div>
<div style="display:flex;align-items:center;gap:8px;font-size:12px;font-weight:700;color:var(--color-neutral-700)"><span style="display:flex;gap:3px"><span style="width:8px;height:8px;border-radius:50%;background:var(--color-accent);color:var(--on-accent)"></span><span style="width:8px;height:8px;border-radius:50%;background:var(--color-neutral-300)"></span><span style="width:8px;height:8px;border-radius:50%;background:var(--color-neutral-300)"></span></span>Easy</div>
<div style="padding:12px 14px;border-radius:18px;background:var(--color-accent-2-100);color:var(--color-accent-2-900);font-size:13px;line-height:1.45"><b>Why this?</b> You make Chicken stir-fry and Beef tacos most weeks. Same flavors, sweet-savory twist.</div>
</div>
</div>
</div>
<div style="display:flex;justify-content:center;gap:28px;margin-top:22px">
<div style="display:flex;flex-direction:column;align-items:center;gap:6px;font-size:11px;font-weight:700;color:var(--color-neutral-700)"><div style="width:62px;height:62px;border-radius:50%;background:var(--color-neutral-100);box-shadow:var(--shadow-md);display:flex;align-items:center;justify-content:center;color:var(--color-text)"><svg viewBox="0 0 24 24" width="26" height="26" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M18 6 6 18"></path><path d="m6 6 12 12"></path></svg></div>Pass</div>
<div style="display:flex;flex-direction:column;align-items:center;gap:6px;font-size:11px;font-weight:700;color:var(--color-neutral-700)"><div style="width:62px;height:62px;border-radius:50%;background:var(--color-accent-2-600);box-shadow:var(--shadow-md);display:flex;align-items:center;justify-content:center;color:var(--color-bg)"><svg viewBox="0 0 24 24" width="26" height="26" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z"></path></svg></div>Try it</div>
</div>
<div style="position:absolute;left:14px;right:14px;bottom:16px;height:62px;border-radius:999px;background:var(--color-text);display:flex;align-items:center;justify-content:space-around;color:var(--color-neutral-400)">
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"></path><path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path></svg>
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M8 2v4"></path><path d="M16 2v4"></path><rect width="18" height="18" x="3" y="4" rx="2"></rect><path d="M3 10h18"></path></svg>
<div style="display:flex;align-items:center;gap:6px;padding:10px 14px;border-radius:999px;background:var(--color-accent);color:var(--on-accent);font-size:13px;font-weight:700"><svg viewBox="0 0 24 24" width="20" height="20" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><circle cx="12" cy="12" r="10"></circle><path d="m16.24 7.76-1.804 5.411a2 2 0 0 1-1.265 1.265L7.76 16.24l1.804-5.411a2 2 0 0 1 1.265-1.265z"></path></svg>Discover</div>
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M10 5H3"></path><path d="M12 19H3"></path><path d="M14 3v4"></path><path d="M16 17v4"></path><path d="M21 12h-9"></path><path d="M21 19h-5"></path><path d="M21 5h-7"></path><path d="M8 10v4"></path><path d="M8 12H3"></path></svg>
</div>
</div></div><div style="display:flex;flex-direction:column;gap:8px"><div style="font-size:12px;font-weight:700;color:var(--color-neutral-700)">Weekly plan</div><div style="width:360px;height:760px;position:relative;border-radius:36px;background:var(--color-bg);overflow:hidden;box-shadow:var(--shadow-md)">
<div style="height:40px;display:flex;align-items:center;justify-content:space-between;padding:0 26px;font-size:13px;font-weight:700"><span>9:41</span><span style="width:22px;height:10px;border-radius:3px;background:var(--color-text)"></span></div>
<div style="padding:6px 20px 0;display:flex;align-items:center;justify-content:space-between">
<div><div style="font-size:11px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:var(--color-accent-700)">Week 41</div><div style="font-family:var(--font-heading);font-size:26px;line-height:1.1">Oct 5 – 11</div></div>
<div style="display:flex;gap:6px"><div style="width:36px;height:36px;border-radius:50%;background:var(--color-surface);display:flex;align-items:center;justify-content:center"><svg viewBox="0 0 24 24" width="18" height="18" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="m15 18-6-6 6-6"></path></svg></div><div style="width:36px;height:36px;border-radius:50%;background:var(--color-surface);display:flex;align-items:center;justify-content:center"><svg viewBox="0 0 24 24" width="18" height="18" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="m9 18 6-6-6-6"></path></svg></div></div>
</div>
<div style="margin:14px 20px 0;padding:10px 14px;border-radius:18px;background:var(--color-accent-2-100);color:var(--color-accent-2-900);font-size:12px;line-height:1.4;display:flex;gap:8px;align-items:center"><svg viewBox="0 0 24 24" width="16" height="16" style="flex:none;fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M20 6 9 17l-5-5"></path></svg><span>5 regulars + 2 side quests · synced to Google Calendar</span></div>
<div style="margin:12px 20px 0;display:flex;flex-direction:column;gap:6px">
<div style="display:flex;align-items:center;gap:12px;padding:9px 12px;border-radius:18px;background:var(--color-surface)"><div style="width:36px;text-align:center;font-size:11px;font-weight:700;color:var(--color-neutral-700);line-height:1.1">MON<br><span style="font-size:16px;color:var(--color-text)">5</span></div><div style="flex:1;font-weight:700;font-size:14px">Beef tacos</div><span style="padding:3px 9px;border-radius:999px;background:var(--color-neutral-100);color:var(--color-neutral-800);font-size:10px;font-weight:700">Regular</span><svg viewBox="0 0 24 24" width="16" height="16" style="color:var(--color-neutral-600);fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"></path><path d="M21 3v5h-5"></path><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"></path><path d="M8 16H3v5"></path></svg></div>
<div style="display:flex;align-items:center;gap:12px;padding:9px 12px;border-radius:18px;background:var(--color-surface)"><div style="width:36px;text-align:center;font-size:11px;font-weight:700;color:var(--color-neutral-700);line-height:1.1">TUE<br><span style="font-size:16px;color:var(--color-text)">6</span></div><div style="flex:1;font-weight:700;font-size:14px">Spaghetti bolognese</div><span style="padding:3px 9px;border-radius:999px;background:var(--color-neutral-100);color:var(--color-neutral-800);font-size:10px;font-weight:700">Regular</span><svg viewBox="0 0 24 24" width="16" height="16" style="color:var(--color-neutral-600);fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"></path><path d="M21 3v5h-5"></path><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"></path><path d="M8 16H3v5"></path></svg></div>
<div style="display:flex;align-items:center;gap:12px;padding:9px 12px;border-radius:18px;background:var(--color-accent-100);box-shadow:inset 0 0 0 1.5px var(--color-accent-300)"><div style="width:36px;text-align:center;font-size:11px;font-weight:700;color:var(--color-accent-700);line-height:1.1">WED<br><span style="font-size:16px;color:var(--color-text)">7</span></div><div style="flex:1;font-weight:700;font-size:14px">Crock pot tikka masala</div><span style="padding:3px 9px;border-radius:999px;background:var(--color-accent-200);color:var(--color-accent-800);font-size:10px;font-weight:700">Side quest</span><svg viewBox="0 0 24 24" width="16" height="16" style="color:var(--color-neutral-600);fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"></path><path d="M21 3v5h-5"></path><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"></path><path d="M8 16H3v5"></path></svg></div>
<div style="display:flex;align-items:center;gap:12px;padding:9px 12px;border-radius:18px;background:var(--color-surface)"><div style="width:36px;text-align:center;font-size:11px;font-weight:700;color:var(--color-neutral-700);line-height:1.1">THU<br><span style="font-size:16px;color:var(--color-text)">8</span></div><div style="flex:1;font-weight:700;font-size:14px">Chicken stir-fry</div><span style="padding:3px 9px;border-radius:999px;background:var(--color-neutral-100);color:var(--color-neutral-800);font-size:10px;font-weight:700">Regular</span><svg viewBox="0 0 24 24" width="16" height="16" style="color:var(--color-neutral-600);fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"></path><path d="M21 3v5h-5"></path><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"></path><path d="M8 16H3v5"></path></svg></div>
<div style="display:flex;align-items:center;gap:12px;padding:9px 12px;border-radius:18px;background:var(--color-surface)"><div style="width:36px;text-align:center;font-size:11px;font-weight:700;color:var(--color-neutral-700);line-height:1.1">FRI<br><span style="font-size:16px;color:var(--color-text)">9</span></div><div style="flex:1;font-weight:700;font-size:14px">Pizza night</div><span style="padding:3px 9px;border-radius:999px;background:var(--color-neutral-100);color:var(--color-neutral-800);font-size:10px;font-weight:700">Regular</span><svg viewBox="0 0 24 24" width="16" height="16" style="color:var(--color-neutral-600);fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"></path><path d="M21 3v5h-5"></path><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"></path><path d="M8 16H3v5"></path></svg></div>
<div style="display:flex;align-items:center;gap:12px;padding:9px 12px;border-radius:18px;background:var(--color-accent-100);box-shadow:inset 0 0 0 1.5px var(--color-accent-300)"><div style="width:36px;text-align:center;font-size:11px;font-weight:700;color:var(--color-accent-700);line-height:1.1">SAT<br><span style="font-size:16px;color:var(--color-text)">10</span></div><div style="flex:1;font-weight:700;font-size:14px">Korean beef bowls</div><span style="padding:3px 9px;border-radius:999px;background:var(--color-accent-200);color:var(--color-accent-800);font-size:10px;font-weight:700">Side quest</span><svg viewBox="0 0 24 24" width="16" height="16" style="color:var(--color-neutral-600);fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"></path><path d="M21 3v5h-5"></path><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"></path><path d="M8 16H3v5"></path></svg></div>
<div style="display:flex;align-items:center;gap:12px;padding:9px 12px;border-radius:18px;background:var(--color-surface)"><div style="width:36px;text-align:center;font-size:11px;font-weight:700;color:var(--color-neutral-700);line-height:1.1">SUN<br><span style="font-size:16px;color:var(--color-text)">11</span></div><div style="flex:1;font-weight:700;font-size:14px">Sheet-pan salmon</div><span style="padding:3px 9px;border-radius:999px;background:var(--color-neutral-100);color:var(--color-neutral-800);font-size:10px;font-weight:700">Regular</span><svg viewBox="0 0 24 24" width="16" height="16" style="color:var(--color-neutral-600);fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"></path><path d="M21 3v5h-5"></path><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"></path><path d="M8 16H3v5"></path></svg></div>
</div>
<div style="margin:14px 20px 0;height:50px;border-radius:999px;background:var(--color-accent);display:flex;align-items:center;justify-content:center;gap:8px;font-family:var(--font-heading);font-size:15px;color:var(--on-accent)"><svg viewBox="0 0 24 24" width="18" height="18" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><circle cx="8" cy="21" r="1"></circle><circle cx="19" cy="21" r="1"></circle><path d="M2.05 2.05h2l2.66 12.42a2 2 0 0 0 2 1.58h9.78a2 2 0 0 0 1.95-1.57l1.65-7.43H5.12"></path></svg>Shopping list · 23 items</div>
<div style="position:absolute;left:14px;right:14px;bottom:16px;height:62px;border-radius:999px;background:var(--color-text);display:flex;align-items:center;justify-content:space-around;color:var(--color-neutral-400)">
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"></path><path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path></svg>
<div style="display:flex;align-items:center;gap:6px;padding:10px 14px;border-radius:999px;background:var(--color-accent);color:var(--on-accent);font-size:13px;font-weight:700"><svg viewBox="0 0 24 24" width="20" height="20" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M8 2v4"></path><path d="M16 2v4"></path><rect width="18" height="18" x="3" y="4" rx="2"></rect><path d="M3 10h18"></path></svg>Plan</div>
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><circle cx="12" cy="12" r="10"></circle><path d="m16.24 7.76-1.804 5.411a2 2 0 0 1-1.265 1.265L7.76 16.24l1.804-5.411a2 2 0 0 1 1.265-1.265z"></path></svg>
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M10 5H3"></path><path d="M12 19H3"></path><path d="M14 3v4"></path><path d="M16 17v4"></path><path d="M21 12h-9"></path><path d="M21 19h-5"></path><path d="M21 5h-7"></path><path d="M8 10v4"></path><path d="M8 12H3"></path></svg>
</div>
</div></div><div style="display:flex;flex-direction:column;gap:8px"><div style="font-size:12px;font-weight:700;color:var(--color-neutral-700)">Shopping list</div><div style="width:360px;height:760px;position:relative;border-radius:36px;background:var(--color-bg);overflow:hidden;box-shadow:var(--shadow-md)">
<div style="height:40px;display:flex;align-items:center;justify-content:space-between;padding:0 26px;font-size:13px;font-weight:700"><span>9:41</span><span style="width:22px;height:10px;border-radius:3px;background:var(--color-text)"></span></div>
<div style="padding:6px 20px 0;display:flex;justify-content:space-between;align-items:flex-end"><div><div style="font-size:11px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:var(--color-accent-700)">Supply run · Oct 5–11</div><div style="font-family:var(--font-heading);font-size:28px;line-height:1.1">Shopping list</div></div><div style="font-size:13px;font-weight:700;color:var(--color-neutral-700)">19 / 23</div></div>
<div style="margin:10px 20px 0;font-size:12px;line-height:1.45;color:var(--color-neutral-800)">Uncheck anything you already have — it won't be counted.</div>
<div style="padding:12px 20px 0;display:flex;flex-direction:column;gap:12px">
<div style="padding:14px;border-radius:24px;background:var(--color-surface);display:flex;flex-direction:column;gap:9px">
<div style="display:flex;justify-content:space-between;align-items:baseline"><span style="font-family:var(--font-heading);font-size:16px">Beef tacos</span><span style="font-size:11px;font-weight:700;color:var(--color-neutral-700)">MON</span></div>
<div style="display:flex;align-items:center;gap:10px;font-size:14px"><span style="width:20px;height:20px;border-radius:7px;background:var(--color-accent);color:var(--on-accent);display:flex;align-items:center;justify-content:center"><svg viewBox="0 0 24 24" width="13" height="13" style="fill:none;stroke:currentColor;stroke-width:3.5;stroke-linecap:round;stroke-linejoin:round"><path d="M20 6 9 17l-5-5"></path></svg></span><span style="flex:1">Ground beef</span><span style="color:var(--color-neutral-700);font-size:12px">1 lb</span></div>
<div style="display:flex;align-items:center;gap:10px;font-size:14px"><span style="width:20px;height:20px;border-radius:7px;background:var(--color-accent);color:var(--on-accent);display:flex;align-items:center;justify-content:center"><svg viewBox="0 0 24 24" width="13" height="13" style="fill:none;stroke:currentColor;stroke-width:3.5;stroke-linecap:round;stroke-linejoin:round"><path d="M20 6 9 17l-5-5"></path></svg></span><span style="flex:1">Corn tortillas</span><span style="color:var(--color-neutral-700);font-size:12px">12</span></div>
<div style="display:flex;align-items:center;gap:10px;font-size:14px;color:var(--color-neutral-600)"><span style="width:20px;height:20px;border-radius:7px;border:2px solid var(--color-neutral-400)"></span><span style="flex:1;text-decoration:line-through">Taco seasoning</span><span style="font-size:11px;font-weight:700">have it</span></div>
</div>
<div style="padding:14px;border-radius:24px;background:var(--color-surface);display:flex;flex-direction:column;gap:9px">
<div style="display:flex;justify-content:space-between;align-items:baseline"><span style="font-family:var(--font-heading);font-size:16px">Crock pot tikka masala</span><span style="font-size:11px;font-weight:700;color:var(--color-neutral-700)">WED</span></div>
<div style="display:flex;align-items:center;gap:10px;font-size:14px"><span style="width:20px;height:20px;border-radius:7px;background:var(--color-accent);color:var(--on-accent);display:flex;align-items:center;justify-content:center"><svg viewBox="0 0 24 24" width="13" height="13" style="fill:none;stroke:currentColor;stroke-width:3.5;stroke-linecap:round;stroke-linejoin:round"><path d="M20 6 9 17l-5-5"></path></svg></span><span style="flex:1">Chicken thighs</span><span style="color:var(--color-neutral-700);font-size:12px">2 lb</span></div>
<div style="display:flex;align-items:center;gap:10px;font-size:14px"><span style="width:20px;height:20px;border-radius:7px;background:var(--color-accent);color:var(--on-accent);display:flex;align-items:center;justify-content:center"><svg viewBox="0 0 24 24" width="13" height="13" style="fill:none;stroke:currentColor;stroke-width:3.5;stroke-linecap:round;stroke-linejoin:round"><path d="M20 6 9 17l-5-5"></path></svg></span><span style="flex:1">Tikka masala paste</span><span style="color:var(--color-neutral-700);font-size:12px">1 jar</span></div>
<div style="display:flex;align-items:center;gap:10px;font-size:14px;color:var(--color-neutral-600)"><span style="width:20px;height:20px;border-radius:7px;border:2px solid var(--color-neutral-400)"></span><span style="flex:1;text-decoration:line-through">Basmati rice</span><span style="font-size:11px;font-weight:700">have it</span></div>
</div>
<div style="padding:14px;border-radius:24px;background:var(--color-surface);display:flex;justify-content:space-between;align-items:baseline"><span style="font-family:var(--font-heading);font-size:16px">Chicken stir-fry</span><span style="font-size:11px;font-weight:700;color:var(--color-neutral-700)">THU · 5 items</span></div>
</div>
<div style="position:absolute;left:20px;right:20px;bottom:96px;display:flex;align-items:center;gap:8px;font-size:12px;font-weight:700;color:var(--color-accent-2-800)"><svg viewBox="0 0 24 24" width="15" height="15" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path></svg>Sent to Google Chat · Sat 8:00 am</div>
<div style="position:absolute;left:14px;right:14px;bottom:16px;height:62px;border-radius:999px;background:var(--color-text);display:flex;align-items:center;justify-content:space-around;color:var(--color-neutral-400)">
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"></path><path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path></svg>
<div style="display:flex;align-items:center;gap:6px;padding:10px 14px;border-radius:999px;background:var(--color-accent);color:var(--on-accent);font-size:13px;font-weight:700"><svg viewBox="0 0 24 24" width="20" height="20" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M8 2v4"></path><path d="M16 2v4"></path><rect width="18" height="18" x="3" y="4" rx="2"></rect><path d="M3 10h18"></path></svg>Plan</div>
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><circle cx="12" cy="12" r="10"></circle><path d="m16.24 7.76-1.804 5.411a2 2 0 0 1-1.265 1.265L7.76 16.24l1.804-5.411a2 2 0 0 1 1.265-1.265z"></path></svg>
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M10 5H3"></path><path d="M12 19H3"></path><path d="M14 3v4"></path><path d="M16 17v4"></path><path d="M21 12h-9"></path><path d="M21 19h-5"></path><path d="M21 5h-7"></path><path d="M8 10v4"></path><path d="M8 12H3"></path></svg>
</div>
</div></div></div>
</div>
</div>
</x-dc>
</body>
</html>
File diff suppressed because one or more lines are too long
@@ -0,0 +1,416 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<helmet data-dc-atomics>
<meta name="design_doc_mode" content="canvas">
<link rel="stylesheet" href="_ds/organic-cd54d318-425e-4a60-adfb-d590f70d40bd/styles.css">
<style>body{margin:0;background:var(--color-neutral-200);font-family:var(--font-body);color:var(--color-text)}a{color:var(--color-accent-700)}a:hover{color:var(--color-accent-800)}.dv-turn{padding:44px 48px 36px;scroll-margin-top:16px}.dv-thd{display:flex;align-items:baseline;gap:10px;margin:0 0 12px}.dv-tid{font:600 10px ui-monospace,Menlo,monospace;padding:3px 7px;background:var(--color-text);color:var(--color-bg);border-radius:4px;text-decoration:none}.dv-tname{font:400 28px/1.2 var(--font-heading);color:var(--color-text)}.dv-opts{display:flex;flex-wrap:wrap;gap:32px;align-items:flex-start}.dv-opt{flex:none;display:flex;flex-direction:column;gap:10px;scroll-margin-top:16px}.dv-oid{font:600 10.5px ui-monospace,Menlo,monospace;padding:3px 7px;background:var(--color-neutral-300);color:var(--color-text);border-radius:5px;text-decoration:none}.dv-olabel{display:flex;align-items:baseline;gap:8px;font:600 12px/1.3 var(--font-body);color:var(--color-neutral-800)}.dv-opt:target .dv-oid{background:var(--color-accent);color:var(--color-bg)}.dv-next{margin:28px 0 0;font:13px/1.5 var(--font-body);color:var(--color-neutral-700)}.dv-group{font:700 11px/1 var(--font-body);letter-spacing:.1em;text-transform:uppercase;color:var(--color-accent-700);margin:40px 0 16px}</style>
</helmet>
<section class="dv-turn" id="t1">
<div class="dv-thd"><a class="dv-tid" href="#t1">1</a><span class="dv-tname">MealQuest — first pass</span></div>
<div style="max-width:760px;font-size:14px;line-height:1.55;color:var(--color-neutral-800);display:flex;flex-direction:column;gap:6px">
<div><b>Working from your answers:</b> one shared household account · Organic design system · playful, quest-flavored copy · bottom nav = Home, Plan, Discover, Settings.</div>
<div>Accepted suggestions go to a <b>Quest log</b> (to-try pool). The AI builds each week from routine dinners + Quest log picks; you swap days, then it pushes to Google Calendar (app is source of truth). Shopping lists are grouped by dinner; uncheck what you already have. Each week's list becomes a post in the archive.</div>
<div><b>Assumptions to confirm:</b> Shopping list and Quick dinners are reached from Home/Plan (not tabs) · Admin lives inside Settings · Photos are placeholders.</div>
</div>
<div class="dv-group">Discover · swipe — 3 directions</div>
<div class="dv-opts">
<div class="dv-opt" id="1a"><div class="dv-olabel"><a class="dv-oid" href="#1a">1a</a>Card stack · photo-led</div>
<div style="width:360px;height:760px;position:relative;border-radius:36px;background:var(--color-bg);overflow:hidden;box-shadow:var(--shadow-md)">
<div style="height:40px;display:flex;align-items:center;justify-content:space-between;padding:0 26px;font-size:13px;font-weight:700"><span>9:41</span><span style="width:22px;height:10px;border-radius:3px;background:var(--color-text)"></span></div>
<div style="padding:8px 20px 0;display:flex;align-items:flex-end;justify-content:space-between">
<div><div style="font-size:11px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:var(--color-accent-700)">Discover</div><div style="font-family:var(--font-heading);font-size:30px;line-height:1.1">Side quests</div></div>
<div style="display:flex;align-items:center;gap:6px;padding:7px 12px;border-radius:999px;background:var(--color-accent-2-200);color:var(--color-accent-2-800);font-size:12px;font-weight:700">Quest log · 6</div>
</div>
<div style="position:relative;margin:18px 20px 0;height:440px">
<div style="position:absolute;inset:0;transform:translateY(16px) scale(.9);border-radius:32px;background:var(--color-neutral-300)"></div>
<div style="position:absolute;inset:0;transform:translateY(8px) scale(.95);border-radius:32px;background:var(--color-surface)"></div>
<div style="position:absolute;inset:0;border-radius:32px;background:var(--color-neutral-100);box-shadow:var(--shadow-lg);overflow:hidden;display:flex;flex-direction:column">
<div style="height:220px;margin:10px;border-radius:24px;background:repeating-linear-gradient(135deg,var(--color-neutral-300) 0 8px,var(--color-neutral-200) 8px 16px);display:flex;align-items:center;justify-content:center;font:11px ui-monospace,Menlo,monospace;color:var(--color-neutral-700)">dish photo</div>
<div style="padding:4px 20px 0;display:flex;flex-direction:column;gap:10px">
<div style="display:flex;align-items:center;justify-content:space-between"><div style="font-family:var(--font-heading);font-size:24px;line-height:1.1">Korean beef bowls</div></div>
<div style="display:flex;align-items:center;gap:8px;font-size:12px;font-weight:700;color:var(--color-neutral-700)"><span style="display:flex;gap:3px"><span style="width:8px;height:8px;border-radius:50%;background:var(--color-accent)"></span><span style="width:8px;height:8px;border-radius:50%;background:var(--color-neutral-300)"></span><span style="width:8px;height:8px;border-radius:50%;background:var(--color-neutral-300)"></span></span>Easy</div>
<div style="padding:12px 14px;border-radius:18px;background:var(--color-accent-2-100);color:var(--color-accent-2-900);font-size:13px;line-height:1.45"><b>Why this?</b> You make Chicken stir-fry and Beef tacos most weeks. Same flavors, sweet-savory twist.</div>
</div>
</div>
</div>
<div style="display:flex;justify-content:center;gap:28px;margin-top:22px">
<div style="display:flex;flex-direction:column;align-items:center;gap:6px;font-size:11px;font-weight:700;color:var(--color-neutral-700)"><div style="width:62px;height:62px;border-radius:50%;background:var(--color-neutral-100);box-shadow:var(--shadow-md);display:flex;align-items:center;justify-content:center;color:var(--color-text)"><svg viewBox="0 0 24 24" width="26" height="26" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M18 6 6 18"></path><path d="m6 6 12 12"></path></svg></div>Pass</div>
<div style="display:flex;flex-direction:column;align-items:center;gap:6px;font-size:11px;font-weight:700;color:var(--color-neutral-700)"><div style="width:62px;height:62px;border-radius:50%;background:var(--color-accent-2-600);box-shadow:var(--shadow-md);display:flex;align-items:center;justify-content:center;color:var(--color-bg)"><svg viewBox="0 0 24 24" width="26" height="26" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z"></path></svg></div>Try it</div>
</div>
<div style="position:absolute;left:14px;right:14px;bottom:16px;height:62px;border-radius:999px;background:var(--color-text);display:flex;align-items:center;justify-content:space-around;color:var(--color-neutral-400)">
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"></path><path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path></svg>
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M8 2v4"></path><path d="M16 2v4"></path><rect width="18" height="18" x="3" y="4" rx="2"></rect><path d="M3 10h18"></path></svg>
<div style="display:flex;align-items:center;gap:6px;padding:10px 14px;border-radius:999px;background:var(--color-accent);color:var(--color-text);font-size:13px;font-weight:700"><svg viewBox="0 0 24 24" width="20" height="20" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><circle cx="12" cy="12" r="10"></circle><path d="m16.24 7.76-1.804 5.411a2 2 0 0 1-1.265 1.265L7.76 16.24l1.804-5.411a2 2 0 0 1 1.265-1.265z"></path></svg>Discover</div>
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M10 5H3"></path><path d="M12 19H3"></path><path d="M14 3v4"></path><path d="M16 17v4"></path><path d="M21 12h-9"></path><path d="M21 19h-5"></path><path d="M21 5h-7"></path><path d="M8 10v4"></path><path d="M8 12H3"></path></svg>
</div>
</div></div>
<div class="dv-opt" id="1b"><div class="dv-olabel"><a class="dv-oid" href="#1b">1b</a>Full-bleed · mid-swipe state</div>
<div style="width:360px;height:760px;position:relative;border-radius:36px;background:var(--color-bg);overflow:hidden;box-shadow:var(--shadow-md)">
<div style="height:40px;display:flex;align-items:center;justify-content:space-between;padding:0 26px;font-size:13px;font-weight:700"><span>9:41</span><span style="width:22px;height:10px;border-radius:3px;background:var(--color-text)"></span></div>
<div style="padding:4px 20px 0;display:flex;align-items:center;justify-content:space-between"><div style="font-family:var(--font-heading);font-size:22px">Discover</div><div style="font-size:12px;font-weight:700;color:var(--color-neutral-700)">3 of 10 today</div></div>
<div style="position:relative;margin:14px 16px 0;height:540px">
<div style="position:absolute;inset:0;border-radius:34px;background:repeating-linear-gradient(135deg,var(--color-neutral-300) 0 8px,var(--color-neutral-200) 8px 16px)"></div>
<div style="position:absolute;inset:0;transform:translateX(46px) rotate(7deg);transform-origin:50% 100%;border-radius:34px;overflow:hidden;box-shadow:var(--shadow-lg);background:repeating-linear-gradient(135deg,var(--color-accent-2-300) 0 8px,var(--color-accent-2-200) 8px 16px)">
<div style="position:absolute;top:120px;left:0;right:0;text-align:center;font:11px ui-monospace,Menlo,monospace;color:var(--color-accent-2-800)">full-bleed dish photo</div>
<div style="position:absolute;top:28px;left:24px;transform:rotate(-12deg);padding:6px 14px;border:3px solid var(--color-accent-2-700);border-radius:14px;font-family:var(--font-heading);font-size:24px;color:var(--color-accent-2-800);background:var(--color-accent-2-100)">Try it!</div>
<div style="position:absolute;left:12px;right:12px;bottom:12px;padding:18px;border-radius:26px;background:var(--color-bg);display:flex;flex-direction:column;gap:8px">
<div style="display:flex;gap:6px"><span style="padding:3px 10px;border-radius:999px;background:var(--color-accent-100);color:var(--color-accent-800);font-size:11px;font-weight:700">Medium</span></div>
<div style="font-family:var(--font-heading);font-size:26px;line-height:1.05">Crock pot chicken tikka masala</div>
<div style="font-size:13px;line-height:1.45;color:var(--color-neutral-800)">Because Butter chicken night is a household favorite — this one cooks itself while you work.</div>
</div>
</div>
</div>
<div style="display:flex;justify-content:space-between;padding:16px 34px 0;font-size:12px;font-weight:700;color:var(--color-neutral-700)"><span>← Pass</span><span style="color:var(--color-accent-2-700)">Add to Quest log →</span></div>
<div style="position:absolute;left:14px;right:14px;bottom:16px;height:62px;border-radius:999px;background:var(--color-text);display:flex;align-items:center;justify-content:space-around;color:var(--color-neutral-400)">
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"></path><path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path></svg>
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M8 2v4"></path><path d="M16 2v4"></path><rect width="18" height="18" x="3" y="4" rx="2"></rect><path d="M3 10h18"></path></svg>
<div style="display:flex;align-items:center;gap:6px;padding:10px 14px;border-radius:999px;background:var(--color-accent);color:var(--color-text);font-size:13px;font-weight:700"><svg viewBox="0 0 24 24" width="20" height="20" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><circle cx="12" cy="12" r="10"></circle><path d="m16.24 7.76-1.804 5.411a2 2 0 0 1-1.265 1.265L7.76 16.24l1.804-5.411a2 2 0 0 1 1.265-1.265z"></path></svg>Discover</div>
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M10 5H3"></path><path d="M12 19H3"></path><path d="M14 3v4"></path><path d="M16 17v4"></path><path d="M21 12h-9"></path><path d="M21 19h-5"></path><path d="M21 5h-7"></path><path d="M8 10v4"></path><path d="M8 12H3"></path></svg>
</div>
</div></div>
<div class="dv-opt" id="1c"><div class="dv-olabel"><a class="dv-oid" href="#1c">1c</a>Quest board · assistant voice</div>
<div style="width:360px;height:760px;position:relative;border-radius:36px;background:var(--color-bg);overflow:hidden;box-shadow:var(--shadow-md)">
<div style="height:40px;display:flex;align-items:center;justify-content:space-between;padding:0 26px;font-size:13px;font-weight:700"><span>9:41</span><span style="width:22px;height:10px;border-radius:3px;background:var(--color-text)"></span></div>
<div style="padding:6px 20px 0;display:flex;flex-direction:column;gap:8px">
<div style="display:flex;justify-content:space-between;font-size:12px;font-weight:700;color:var(--color-neutral-700)"><span>Today's batch</span><span>4 / 10</span></div>
<div style="height:8px;border-radius:999px;background:var(--color-neutral-300)"><div style="width:40%;height:100%;border-radius:999px;background:var(--color-accent)"></div></div>
</div>
<div style="margin:22px 20px 0;padding:22px;border-radius:34px;background:var(--color-surface);box-shadow:var(--shadow-md);display:flex;flex-direction:column;gap:16px">
<div style="width:150px;height:150px;border-radius:50%;background:repeating-linear-gradient(135deg,var(--color-neutral-300) 0 8px,var(--color-neutral-200) 8px 16px);display:flex;align-items:center;justify-content:center;font:11px ui-monospace,Menlo,monospace;color:var(--color-neutral-700)">dish photo</div>
<div style="font-family:var(--font-heading);font-size:30px;line-height:1.05">Lemon garlic shrimp orzo</div>
<div style="display:flex;align-items:center;gap:10px"><span style="display:flex;gap:4px"><span style="width:22px;height:8px;border-radius:999px;background:var(--color-accent-2-600)"></span><span style="width:22px;height:8px;border-radius:999px;background:var(--color-accent-2-600)"></span><span style="width:22px;height:8px;border-radius:999px;background:var(--color-neutral-400)"></span></span><span style="font-size:12px;font-weight:700;color:var(--color-accent-2-800)">Level 2 · Medium</span></div>
<div style="display:flex;gap:10px;align-items:flex-start">
<div style="flex:none;width:32px;height:32px;border-radius:50%;background:var(--color-accent);color:var(--color-text);display:flex;align-items:center;justify-content:center;font-family:var(--font-heading);font-size:15px">Q</div>
<div style="padding:12px 14px;border-radius:4px 18px 18px 18px;background:var(--color-neutral-100);font-size:13px;line-height:1.45">Your Shrimp scampi gets 5 stars every time. Orzo makes it a one-pot night.</div>
</div>
</div>
<div style="display:flex;gap:10px;margin:20px 20px 0">
<div style="flex:1;height:52px;border-radius:999px;border:1.5px solid var(--color-divider);display:flex;align-items:center;justify-content:center;font-family:var(--font-heading);font-size:15px">Not for us</div>
<div style="flex:1.3;height:52px;border-radius:999px;background:var(--color-accent);display:flex;align-items:center;justify-content:center;font-family:var(--font-heading);font-size:15px;color:var(--color-text)">Accept quest</div>
</div>
<div style="text-align:center;margin-top:10px;font-size:11px;color:var(--color-neutral-700)">or swipe the card</div>
<div style="position:absolute;left:14px;right:14px;bottom:16px;height:62px;border-radius:999px;background:var(--color-text);display:flex;align-items:center;justify-content:space-around;color:var(--color-neutral-400)">
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"></path><path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path></svg>
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M8 2v4"></path><path d="M16 2v4"></path><rect width="18" height="18" x="3" y="4" rx="2"></rect><path d="M3 10h18"></path></svg>
<div style="display:flex;align-items:center;gap:6px;padding:10px 14px;border-radius:999px;background:var(--color-accent);color:var(--color-text);font-size:13px;font-weight:700"><svg viewBox="0 0 24 24" width="20" height="20" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><circle cx="12" cy="12" r="10"></circle><path d="m16.24 7.76-1.804 5.411a2 2 0 0 1-1.265 1.265L7.76 16.24l1.804-5.411a2 2 0 0 1 1.265-1.265z"></path></svg>Discover</div>
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M10 5H3"></path><path d="M12 19H3"></path><path d="M14 3v4"></path><path d="M16 17v4"></path><path d="M21 12h-9"></path><path d="M21 19h-5"></path><path d="M21 5h-7"></path><path d="M8 10v4"></path><path d="M8 12H3"></path></svg>
</div>
</div></div>
</div>
<div class="dv-group">Weekly plan — 3 directions</div>
<div class="dv-opts">
<div class="dv-opt" id="1d"><div class="dv-olabel"><a class="dv-oid" href="#1d">1d</a>Day list · swap per row</div>
<div style="width:360px;height:760px;position:relative;border-radius:36px;background:var(--color-bg);overflow:hidden;box-shadow:var(--shadow-md)">
<div style="height:40px;display:flex;align-items:center;justify-content:space-between;padding:0 26px;font-size:13px;font-weight:700"><span>9:41</span><span style="width:22px;height:10px;border-radius:3px;background:var(--color-text)"></span></div>
<div style="padding:6px 20px 0;display:flex;align-items:center;justify-content:space-between">
<div><div style="font-size:11px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:var(--color-accent-700)">Week 41</div><div style="font-family:var(--font-heading);font-size:26px;line-height:1.1">Oct 5 – 11</div></div>
<div style="display:flex;gap:6px"><div style="width:36px;height:36px;border-radius:50%;background:var(--color-surface);display:flex;align-items:center;justify-content:center"><svg viewBox="0 0 24 24" width="18" height="18" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="m15 18-6-6 6-6"></path></svg></div><div style="width:36px;height:36px;border-radius:50%;background:var(--color-surface);display:flex;align-items:center;justify-content:center"><svg viewBox="0 0 24 24" width="18" height="18" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="m9 18 6-6-6-6"></path></svg></div></div>
</div>
<div style="margin:14px 20px 0;padding:10px 14px;border-radius:18px;background:var(--color-accent-2-100);color:var(--color-accent-2-900);font-size:12px;line-height:1.4;display:flex;gap:8px;align-items:center"><svg viewBox="0 0 24 24" width="16" height="16" style="flex:none;fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M20 6 9 17l-5-5"></path></svg><span>5 regulars + 2 side quests · synced to Google Calendar</span></div>
<div style="margin:12px 20px 0;display:flex;flex-direction:column;gap:6px">
<div style="display:flex;align-items:center;gap:12px;padding:9px 12px;border-radius:18px;background:var(--color-surface)"><div style="width:36px;text-align:center;font-size:11px;font-weight:700;color:var(--color-neutral-700);line-height:1.1">MON<br><span style="font-size:16px;color:var(--color-text)">5</span></div><div style="flex:1;font-weight:700;font-size:14px">Beef tacos</div><span style="padding:3px 9px;border-radius:999px;background:var(--color-neutral-100);color:var(--color-neutral-800);font-size:10px;font-weight:700">Regular</span><svg viewBox="0 0 24 24" width="16" height="16" style="color:var(--color-neutral-600);fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"></path><path d="M21 3v5h-5"></path><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"></path><path d="M8 16H3v5"></path></svg></div>
<div style="display:flex;align-items:center;gap:12px;padding:9px 12px;border-radius:18px;background:var(--color-surface)"><div style="width:36px;text-align:center;font-size:11px;font-weight:700;color:var(--color-neutral-700);line-height:1.1">TUE<br><span style="font-size:16px;color:var(--color-text)">6</span></div><div style="flex:1;font-weight:700;font-size:14px">Spaghetti bolognese</div><span style="padding:3px 9px;border-radius:999px;background:var(--color-neutral-100);color:var(--color-neutral-800);font-size:10px;font-weight:700">Regular</span><svg viewBox="0 0 24 24" width="16" height="16" style="color:var(--color-neutral-600);fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"></path><path d="M21 3v5h-5"></path><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"></path><path d="M8 16H3v5"></path></svg></div>
<div style="display:flex;align-items:center;gap:12px;padding:9px 12px;border-radius:18px;background:var(--color-accent-100);box-shadow:inset 0 0 0 1.5px var(--color-accent-300)"><div style="width:36px;text-align:center;font-size:11px;font-weight:700;color:var(--color-accent-700);line-height:1.1">WED<br><span style="font-size:16px;color:var(--color-text)">7</span></div><div style="flex:1;font-weight:700;font-size:14px">Crock pot tikka masala</div><span style="padding:3px 9px;border-radius:999px;background:var(--color-accent-200);color:var(--color-accent-800);font-size:10px;font-weight:700">Side quest</span><svg viewBox="0 0 24 24" width="16" height="16" style="color:var(--color-neutral-600);fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"></path><path d="M21 3v5h-5"></path><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"></path><path d="M8 16H3v5"></path></svg></div>
<div style="display:flex;align-items:center;gap:12px;padding:9px 12px;border-radius:18px;background:var(--color-surface)"><div style="width:36px;text-align:center;font-size:11px;font-weight:700;color:var(--color-neutral-700);line-height:1.1">THU<br><span style="font-size:16px;color:var(--color-text)">8</span></div><div style="flex:1;font-weight:700;font-size:14px">Chicken stir-fry</div><span style="padding:3px 9px;border-radius:999px;background:var(--color-neutral-100);color:var(--color-neutral-800);font-size:10px;font-weight:700">Regular</span><svg viewBox="0 0 24 24" width="16" height="16" style="color:var(--color-neutral-600);fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"></path><path d="M21 3v5h-5"></path><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"></path><path d="M8 16H3v5"></path></svg></div>
<div style="display:flex;align-items:center;gap:12px;padding:9px 12px;border-radius:18px;background:var(--color-surface)"><div style="width:36px;text-align:center;font-size:11px;font-weight:700;color:var(--color-neutral-700);line-height:1.1">FRI<br><span style="font-size:16px;color:var(--color-text)">9</span></div><div style="flex:1;font-weight:700;font-size:14px">Pizza night</div><span style="padding:3px 9px;border-radius:999px;background:var(--color-neutral-100);color:var(--color-neutral-800);font-size:10px;font-weight:700">Regular</span><svg viewBox="0 0 24 24" width="16" height="16" style="color:var(--color-neutral-600);fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"></path><path d="M21 3v5h-5"></path><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"></path><path d="M8 16H3v5"></path></svg></div>
<div style="display:flex;align-items:center;gap:12px;padding:9px 12px;border-radius:18px;background:var(--color-accent-100);box-shadow:inset 0 0 0 1.5px var(--color-accent-300)"><div style="width:36px;text-align:center;font-size:11px;font-weight:700;color:var(--color-accent-700);line-height:1.1">SAT<br><span style="font-size:16px;color:var(--color-text)">10</span></div><div style="flex:1;font-weight:700;font-size:14px">Korean beef bowls</div><span style="padding:3px 9px;border-radius:999px;background:var(--color-accent-200);color:var(--color-accent-800);font-size:10px;font-weight:700">Side quest</span><svg viewBox="0 0 24 24" width="16" height="16" style="color:var(--color-neutral-600);fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"></path><path d="M21 3v5h-5"></path><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"></path><path d="M8 16H3v5"></path></svg></div>
<div style="display:flex;align-items:center;gap:12px;padding:9px 12px;border-radius:18px;background:var(--color-surface)"><div style="width:36px;text-align:center;font-size:11px;font-weight:700;color:var(--color-neutral-700);line-height:1.1">SUN<br><span style="font-size:16px;color:var(--color-text)">11</span></div><div style="flex:1;font-weight:700;font-size:14px">Sheet-pan salmon</div><span style="padding:3px 9px;border-radius:999px;background:var(--color-neutral-100);color:var(--color-neutral-800);font-size:10px;font-weight:700">Regular</span><svg viewBox="0 0 24 24" width="16" height="16" style="color:var(--color-neutral-600);fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"></path><path d="M21 3v5h-5"></path><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"></path><path d="M8 16H3v5"></path></svg></div>
</div>
<div style="margin:14px 20px 0;height:50px;border-radius:999px;background:var(--color-accent);display:flex;align-items:center;justify-content:center;gap:8px;font-family:var(--font-heading);font-size:15px;color:var(--color-text)"><svg viewBox="0 0 24 24" width="18" height="18" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><circle cx="8" cy="21" r="1"></circle><circle cx="19" cy="21" r="1"></circle><path d="M2.05 2.05h2l2.66 12.42a2 2 0 0 0 2 1.58h9.78a2 2 0 0 0 1.95-1.57l1.65-7.43H5.12"></path></svg>Shopping list · 23 items</div>
<div style="position:absolute;left:14px;right:14px;bottom:16px;height:62px;border-radius:999px;background:var(--color-text);display:flex;align-items:center;justify-content:space-around;color:var(--color-neutral-400)">
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"></path><path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path></svg>
<div style="display:flex;align-items:center;gap:6px;padding:10px 14px;border-radius:999px;background:var(--color-accent);color:var(--color-text);font-size:13px;font-weight:700"><svg viewBox="0 0 24 24" width="20" height="20" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M8 2v4"></path><path d="M16 2v4"></path><rect width="18" height="18" x="3" y="4" rx="2"></rect><path d="M3 10h18"></path></svg>Plan</div>
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><circle cx="12" cy="12" r="10"></circle><path d="m16.24 7.76-1.804 5.411a2 2 0 0 1-1.265 1.265L7.76 16.24l1.804-5.411a2 2 0 0 1 1.265-1.265z"></path></svg>
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M10 5H3"></path><path d="M12 19H3"></path><path d="M14 3v4"></path><path d="M16 17v4"></path><path d="M21 12h-9"></path><path d="M21 19h-5"></path><path d="M21 5h-7"></path><path d="M8 10v4"></path><path d="M8 12H3"></path></svg>
</div>
</div></div>
<div class="dv-opt" id="1e"><div class="dv-olabel"><a class="dv-oid" href="#1e">1e</a>Day strip · focus on one night</div>
<div style="width:360px;height:760px;position:relative;border-radius:36px;background:var(--color-bg);overflow:hidden;box-shadow:var(--shadow-md)">
<div style="height:40px;display:flex;align-items:center;justify-content:space-between;padding:0 26px;font-size:13px;font-weight:700"><span>9:41</span><span style="width:22px;height:10px;border-radius:3px;background:var(--color-text)"></span></div>
<div style="padding:6px 20px 0;font-family:var(--font-heading);font-size:26px">This week's quest</div>
<div style="display:flex;justify-content:space-between;padding:14px 16px 0">
<div style="width:40px;padding:8px 0;border-radius:999px;text-align:center;font-size:11px;font-weight:700;color:var(--color-neutral-700);line-height:1.3">M<br><span style="font-size:15px;color:var(--color-text)">5</span></div>
<div style="width:40px;padding:8px 0;border-radius:999px;text-align:center;font-size:11px;font-weight:700;color:var(--color-neutral-700);line-height:1.3">T<br><span style="font-size:15px;color:var(--color-text)">6</span></div>
<div style="width:40px;padding:8px 0;border-radius:999px;text-align:center;font-size:11px;font-weight:700;background:var(--color-text);color:var(--color-neutral-300);line-height:1.3">W<br><span style="font-size:15px;color:var(--color-bg)">7</span></div>
<div style="width:40px;padding:8px 0;border-radius:999px;text-align:center;font-size:11px;font-weight:700;color:var(--color-neutral-700);line-height:1.3">T<br><span style="font-size:15px;color:var(--color-text)">8</span></div>
<div style="width:40px;padding:8px 0;border-radius:999px;text-align:center;font-size:11px;font-weight:700;color:var(--color-neutral-700);line-height:1.3">F<br><span style="font-size:15px;color:var(--color-text)">9</span></div>
<div style="width:40px;padding:8px 0;border-radius:999px;text-align:center;font-size:11px;font-weight:700;color:var(--color-neutral-700);line-height:1.3">S<br><span style="font-size:15px;color:var(--color-text)">10</span></div>
<div style="width:40px;padding:8px 0;border-radius:999px;text-align:center;font-size:11px;font-weight:700;color:var(--color-neutral-700);line-height:1.3">S<br><span style="font-size:15px;color:var(--color-text)">11</span></div>
</div>
<div style="margin:14px 20px 0;border-radius:32px;background:var(--color-surface);overflow:hidden;box-shadow:var(--shadow-md)">
<div style="height:150px;margin:10px;border-radius:24px;background:repeating-linear-gradient(135deg,var(--color-neutral-300) 0 8px,var(--color-neutral-200) 8px 16px);display:flex;align-items:center;justify-content:center;font:11px ui-monospace,Menlo,monospace;color:var(--color-neutral-700)">dish photo</div>
<div style="padding:4px 18px 18px;display:flex;flex-direction:column;gap:8px">
<div style="display:flex;gap:6px"><span style="padding:3px 10px;border-radius:999px;background:var(--color-accent-200);color:var(--color-accent-800);font-size:11px;font-weight:700">Side quest</span><span style="padding:3px 10px;border-radius:999px;background:var(--color-accent-2-200);color:var(--color-accent-2-800);font-size:11px;font-weight:700">Crock pot · 6 hr</span></div>
<div style="font-family:var(--font-heading);font-size:22px;line-height:1.1">Crock pot chicken tikka masala</div>
<div style="font-size:13px;color:var(--color-neutral-800)">Start by 12:30 pm for a 6:30 dinner.</div>
<div style="display:flex;gap:8px;margin-top:4px"><div style="flex:1;height:42px;border-radius:999px;border:1.5px solid var(--color-divider);display:flex;align-items:center;justify-content:center;gap:6px;font-family:var(--font-heading);font-size:14px"><svg viewBox="0 0 24 24" width="15" height="15" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"></path><path d="M21 3v5h-5"></path><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"></path><path d="M8 16H3v5"></path></svg>Swap</div><div style="flex:1;height:42px;border-radius:999px;background:var(--color-text);color:var(--color-bg);display:flex;align-items:center;justify-content:center;font-family:var(--font-heading);font-size:14px">Recipe</div></div>
</div>
</div>
<div style="padding:18px 20px 0;font-size:11px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:var(--color-neutral-700)">Coming up</div>
<div style="padding:8px 20px 0;display:flex;flex-direction:column;gap:10px">
<div style="display:flex;align-items:center;gap:12px"><div style="width:40px;height:40px;border-radius:50%;background:repeating-linear-gradient(135deg,var(--color-neutral-300) 0 5px,var(--color-neutral-200) 5px 10px)"></div><div style="flex:1"><div style="font-weight:700;font-size:14px">Chicken stir-fry</div><div style="font-size:12px;color:var(--color-neutral-700)">Thu · Regular</div></div></div>
<div style="display:flex;align-items:center;gap:12px"><div style="width:40px;height:40px;border-radius:50%;background:repeating-linear-gradient(135deg,var(--color-neutral-300) 0 5px,var(--color-neutral-200) 5px 10px)"></div><div style="flex:1"><div style="font-weight:700;font-size:14px">Pizza night</div><div style="font-size:12px;color:var(--color-neutral-700)">Fri · Regular</div></div></div>
</div>
<div style="position:absolute;left:14px;right:14px;bottom:16px;height:62px;border-radius:999px;background:var(--color-text);display:flex;align-items:center;justify-content:space-around;color:var(--color-neutral-400)">
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"></path><path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path></svg>
<div style="display:flex;align-items:center;gap:6px;padding:10px 14px;border-radius:999px;background:var(--color-accent);color:var(--color-text);font-size:13px;font-weight:700"><svg viewBox="0 0 24 24" width="20" height="20" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M8 2v4"></path><path d="M16 2v4"></path><rect width="18" height="18" x="3" y="4" rx="2"></rect><path d="M3 10h18"></path></svg>Plan</div>
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><circle cx="12" cy="12" r="10"></circle><path d="m16.24 7.76-1.804 5.411a2 2 0 0 1-1.265 1.265L7.76 16.24l1.804-5.411a2 2 0 0 1 1.265-1.265z"></path></svg>
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M10 5H3"></path><path d="M12 19H3"></path><path d="M14 3v4"></path><path d="M16 17v4"></path><path d="M21 12h-9"></path><path d="M21 19h-5"></path><path d="M21 5h-7"></path><path d="M8 10v4"></path><path d="M8 12H3"></path></svg>
</div>
</div></div>
<div class="dv-opt" id="1f"><div class="dv-olabel"><a class="dv-oid" href="#1f">1f</a>Draft board · approve then push</div>
<div style="width:360px;height:760px;position:relative;border-radius:36px;background:var(--color-bg);overflow:hidden;box-shadow:var(--shadow-md)">
<div style="height:40px;display:flex;align-items:center;justify-content:space-between;padding:0 26px;font-size:13px;font-weight:700"><span>9:41</span><span style="width:22px;height:10px;border-radius:3px;background:var(--color-text)"></span></div>
<div style="padding:6px 20px 0;display:flex;align-items:center;gap:10px"><div style="font-family:var(--font-heading);font-size:26px">Oct 5 – 11</div><span style="padding:3px 10px;border-radius:999px;background:var(--color-accent-200);color:var(--color-accent-800);font-size:11px;font-weight:700">Draft</span></div>
<div style="margin:12px 20px 0;display:flex;gap:10px;align-items:flex-start">
<div style="flex:none;width:32px;height:32px;border-radius:50%;background:var(--color-accent);color:var(--color-text);display:flex;align-items:center;justify-content:center;font-family:var(--font-heading);font-size:15px">Q</div>
<div style="padding:10px 14px;border-radius:4px 18px 18px 18px;background:var(--color-surface);font-size:13px;line-height:1.45">I mixed 5 of your regulars with 2 from your Quest log. Tap any day to swap it.</div>
</div>
<div style="margin:14px 20px 0;display:grid;grid-template-columns:1fr 1fr;gap:8px">
<div style="height:86px;padding:12px;border-radius:22px;background:var(--color-surface);display:flex;flex-direction:column;justify-content:space-between"><span style="font-size:10px;font-weight:700;letter-spacing:.1em;color:var(--color-neutral-700)">MON</span><span style="font-family:var(--font-heading);font-size:15px;line-height:1.1">Beef tacos</span></div>
<div style="height:86px;padding:12px;border-radius:22px;background:var(--color-surface);display:flex;flex-direction:column;justify-content:space-between"><span style="font-size:10px;font-weight:700;letter-spacing:.1em;color:var(--color-neutral-700)">TUE</span><span style="font-family:var(--font-heading);font-size:15px;line-height:1.1">Spaghetti bolognese</span></div>
<div style="height:86px;padding:12px;border-radius:22px;background:var(--color-accent-2-300);display:flex;flex-direction:column;justify-content:space-between"><span style="font-size:10px;font-weight:700;letter-spacing:.1em;color:var(--color-accent-2-900)">WED · NEW</span><span style="font-family:var(--font-heading);font-size:15px;line-height:1.1">Crock pot tikka masala</span></div>
<div style="height:86px;padding:12px;border-radius:22px;background:var(--color-surface);display:flex;flex-direction:column;justify-content:space-between"><span style="font-size:10px;font-weight:700;letter-spacing:.1em;color:var(--color-neutral-700)">THU</span><span style="font-family:var(--font-heading);font-size:15px;line-height:1.1">Chicken stir-fry</span></div>
<div style="height:86px;padding:12px;border-radius:22px;background:var(--color-bg);box-shadow:inset 0 0 0 2px var(--color-accent);display:flex;flex-direction:column;justify-content:space-between"><span style="font-size:10px;font-weight:700;letter-spacing:.1em;color:var(--color-accent-700)">FRI · SWAPPING…</span><span style="font-family:var(--font-heading);font-size:15px;line-height:1.1;color:var(--color-neutral-600)">Pizza night</span></div>
<div style="height:86px;padding:12px;border-radius:22px;background:var(--color-accent-2-300);display:flex;flex-direction:column;justify-content:space-between"><span style="font-size:10px;font-weight:700;letter-spacing:.1em;color:var(--color-accent-2-900)">SAT · NEW</span><span style="font-family:var(--font-heading);font-size:15px;line-height:1.1">Korean beef bowls</span></div>
<div style="height:86px;padding:12px;border-radius:22px;background:var(--color-surface);display:flex;flex-direction:column;justify-content:space-between"><span style="font-size:10px;font-weight:700;letter-spacing:.1em;color:var(--color-neutral-700)">SUN</span><span style="font-family:var(--font-heading);font-size:15px;line-height:1.1">Sheet-pan salmon</span></div>
<div style="height:86px;padding:12px;border-radius:22px;border:2px dashed var(--color-neutral-400);display:flex;align-items:center;justify-content:center;gap:6px;font-size:12px;font-weight:700;color:var(--color-neutral-700)"><svg viewBox="0 0 24 24" width="16" height="16" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M5 12h14"></path><path d="M12 5v14"></path></svg>Rebuild week</div>
</div>
<div style="margin:14px 20px 0;height:50px;border-radius:999px;background:var(--color-accent);display:flex;align-items:center;justify-content:center;gap:8px;font-family:var(--font-heading);font-size:15px;color:var(--color-text)">Looks good — push to Calendar</div>
<div style="position:absolute;left:14px;right:14px;bottom:16px;height:62px;border-radius:999px;background:var(--color-text);display:flex;align-items:center;justify-content:space-around;color:var(--color-neutral-400)">
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"></path><path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path></svg>
<div style="display:flex;align-items:center;gap:6px;padding:10px 14px;border-radius:999px;background:var(--color-accent);color:var(--color-text);font-size:13px;font-weight:700"><svg viewBox="0 0 24 24" width="20" height="20" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M8 2v4"></path><path d="M16 2v4"></path><rect width="18" height="18" x="3" y="4" rx="2"></rect><path d="M3 10h18"></path></svg>Plan</div>
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><circle cx="12" cy="12" r="10"></circle><path d="m16.24 7.76-1.804 5.411a2 2 0 0 1-1.265 1.265L7.76 16.24l1.804-5.411a2 2 0 0 1 1.265-1.265z"></path></svg>
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M10 5H3"></path><path d="M12 19H3"></path><path d="M14 3v4"></path><path d="M16 17v4"></path><path d="M21 12h-9"></path><path d="M21 19h-5"></path><path d="M21 5h-7"></path><path d="M8 10v4"></path><path d="M8 12H3"></path></svg>
</div>
</div></div>
</div>
<div class="dv-group">Home, onboarding &amp; cooking</div>
<div class="dv-opts">
<div class="dv-opt" id="1g"><div class="dv-olabel"><a class="dv-oid" href="#1g">1g</a>Home / dashboard</div>
<div style="width:360px;height:760px;position:relative;border-radius:36px;background:var(--color-bg);overflow:hidden;box-shadow:var(--shadow-md)">
<div style="height:40px;display:flex;align-items:center;justify-content:space-between;padding:0 26px;font-size:13px;font-weight:700"><span>9:41</span><span style="width:22px;height:10px;border-radius:3px;background:var(--color-text)"></span></div>
<div style="padding:6px 20px 0;display:flex;justify-content:space-between;align-items:flex-start">
<div><div style="font-size:12px;font-weight:700;color:var(--color-neutral-700)">Friday, Oct 2</div><div style="font-family:var(--font-heading);font-size:28px;line-height:1.1">Tonight's quest</div></div>
<div style="position:relative;width:40px;height:40px;border-radius:50%;background:var(--color-surface);display:flex;align-items:center;justify-content:center"><svg viewBox="0 0 24 24" width="19" height="19" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M10.268 21a2 2 0 0 0 3.464 0"></path><path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326"></path></svg><span style="position:absolute;top:8px;right:9px;width:8px;height:8px;border-radius:50%;background:var(--color-accent)"></span></div>
</div>
<div style="margin:14px 20px 0;border-radius:32px;background:var(--color-surface);overflow:hidden;display:flex;flex-direction:column">
<div style="height:150px;margin:10px;border-radius:24px;background:repeating-linear-gradient(135deg,var(--color-neutral-300) 0 8px,var(--color-neutral-200) 8px 16px);display:flex;align-items:center;justify-content:center;font:11px ui-monospace,Menlo,monospace;color:var(--color-neutral-700)">dish photo</div>
<div style="padding:2px 18px 16px;display:flex;align-items:flex-end;justify-content:space-between;gap:10px"><div><div style="font-family:var(--font-heading);font-size:22px;line-height:1.1">Pizza night</div><div style="font-size:12px;color:var(--color-neutral-700);margin-top:4px">Regular · 40 min</div></div><div style="height:38px;padding:0 16px;border-radius:999px;background:var(--color-text);color:var(--color-bg);display:flex;align-items:center;font-family:var(--font-heading);font-size:13px">Recipe</div></div>
</div>
<div style="margin:12px 20px 0;display:grid;grid-template-columns:1fr 1fr;gap:10px">
<div style="height:104px;padding:14px;border-radius:26px;background:var(--color-accent);color:var(--color-text);display:flex;flex-direction:column;justify-content:space-between"><svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z"></path></svg><span style="font-family:var(--font-heading);font-size:16px;line-height:1.1">Quick dinner</span></div>
<div style="height:104px;padding:14px;border-radius:26px;background:var(--color-accent-2-300);color:var(--color-accent-2-900);display:flex;flex-direction:column;justify-content:space-between"><svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><circle cx="11" cy="11" r="8"></circle><path d="m21 21-4.3-4.3"></path></svg><span style="font-family:var(--font-heading);font-size:16px;line-height:1.1">Cook from what I have</span></div>
</div>
<div style="margin:12px 20px 0;display:flex;flex-direction:column;gap:8px">
<div style="display:flex;align-items:center;gap:12px;padding:12px 14px;border-radius:20px;background:var(--color-neutral-100)"><svg viewBox="0 0 24 24" width="18" height="18" style="color:var(--color-accent-700);fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><circle cx="12" cy="12" r="10"></circle><path d="m16.24 7.76-1.804 5.411a2 2 0 0 1-1.265 1.265L7.76 16.24l1.804-5.411a2 2 0 0 1 1.265-1.265z"></path></svg><span style="flex:1;font-size:13px;font-weight:700">10 new side quests to swipe</span><svg viewBox="0 0 24 24" width="16" height="16" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="m9 18 6-6-6-6"></path></svg></div>
<div style="display:flex;align-items:center;gap:12px;padding:12px 14px;border-radius:20px;background:var(--color-neutral-100)"><svg viewBox="0 0 24 24" width="18" height="18" style="color:var(--color-accent-700);fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><circle cx="8" cy="21" r="1"></circle><circle cx="19" cy="21" r="1"></circle><path d="M2.05 2.05h2l2.66 12.42a2 2 0 0 0 2 1.58h9.78a2 2 0 0 0 1.95-1.57l1.65-7.43H5.12"></path></svg><span style="flex:1;font-size:13px;font-weight:700">Shopping list ready · Oct 5–11</span><svg viewBox="0 0 24 24" width="16" height="16" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="m9 18 6-6-6-6"></path></svg></div>
</div>
<div style="position:absolute;left:14px;right:14px;bottom:16px;height:62px;border-radius:999px;background:var(--color-text);display:flex;align-items:center;justify-content:space-around;color:var(--color-neutral-400)">
<div style="display:flex;align-items:center;gap:6px;padding:10px 14px;border-radius:999px;background:var(--color-accent);color:var(--color-text);font-size:13px;font-weight:700"><svg viewBox="0 0 24 24" width="20" height="20" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"></path><path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path></svg>Home</div>
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M8 2v4"></path><path d="M16 2v4"></path><rect width="18" height="18" x="3" y="4" rx="2"></rect><path d="M3 10h18"></path></svg>
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><circle cx="12" cy="12" r="10"></circle><path d="m16.24 7.76-1.804 5.411a2 2 0 0 1-1.265 1.265L7.76 16.24l1.804-5.411a2 2 0 0 1 1.265-1.265z"></path></svg>
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M10 5H3"></path><path d="M12 19H3"></path><path d="M14 3v4"></path><path d="M16 17v4"></path><path d="M21 12h-9"></path><path d="M21 19h-5"></path><path d="M21 5h-7"></path><path d="M8 10v4"></path><path d="M8 12H3"></path></svg>
</div>
</div></div>
<div class="dv-opt" id="1h"><div class="dv-olabel"><a class="dv-oid" href="#1h">1h</a>Onboarding · add routine dinners</div>
<div style="width:360px;height:760px;position:relative;border-radius:36px;background:var(--color-bg);overflow:hidden;box-shadow:var(--shadow-md)">
<div style="height:40px;display:flex;align-items:center;justify-content:space-between;padding:0 26px;font-size:13px;font-weight:700"><span>9:41</span><span style="width:22px;height:10px;border-radius:3px;background:var(--color-text)"></span></div>
<div style="padding:10px 20px 0;display:flex;gap:6px"><span style="flex:1;height:6px;border-radius:999px;background:var(--color-accent)"></span><span style="flex:1;height:6px;border-radius:999px;background:var(--color-accent)"></span><span style="flex:1;height:6px;border-radius:999px;background:var(--color-neutral-300)"></span></div>
<div style="padding:26px 20px 0"><div style="font-size:12px;font-weight:700;color:var(--color-accent-700)">Step 2 of 3</div><div style="font-family:var(--font-heading);font-size:30px;line-height:1.08;margin-top:6px">What's on your usual rotation?</div><div style="font-size:14px;line-height:1.5;color:var(--color-neutral-800);margin-top:10px">Add the dinners your household makes on repeat. MealQuest builds suggestions from these.</div></div>
<div style="margin:18px 20px 0;height:48px;border-radius:999px;background:var(--color-surface);border:1px solid var(--color-divider);display:flex;align-items:center;padding:0 6px 0 18px;gap:8px"><span style="flex:1;font-size:14px;color:var(--color-neutral-600)">e.g. Chicken enchiladas</span><div style="width:36px;height:36px;border-radius:50%;background:var(--color-accent);color:var(--color-text);display:flex;align-items:center;justify-content:center"><svg viewBox="0 0 24 24" width="18" height="18" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M5 12h14"></path><path d="M12 5v14"></path></svg></div></div>
<div style="padding:16px 20px 0;font-size:12px;font-weight:700;color:var(--color-neutral-700)">Your regulars · 7</div>
<div style="padding:8px 20px 0;display:flex;flex-wrap:wrap;gap:8px">
<span style="display:flex;align-items:center;gap:6px;padding:8px 10px 8px 14px;border-radius:999px;background:var(--color-accent-2-200);color:var(--color-accent-2-900);font-size:13px;font-weight:700">Beef tacos ×</span>
<span style="display:flex;align-items:center;gap:6px;padding:8px 10px 8px 14px;border-radius:999px;background:var(--color-accent-2-200);color:var(--color-accent-2-900);font-size:13px;font-weight:700">Spaghetti bolognese ×</span>
<span style="display:flex;align-items:center;gap:6px;padding:8px 10px 8px 14px;border-radius:999px;background:var(--color-accent-2-200);color:var(--color-accent-2-900);font-size:13px;font-weight:700">Chicken stir-fry ×</span>
<span style="display:flex;align-items:center;gap:6px;padding:8px 10px 8px 14px;border-radius:999px;background:var(--color-accent-2-200);color:var(--color-accent-2-900);font-size:13px;font-weight:700">Pizza night ×</span>
<span style="display:flex;align-items:center;gap:6px;padding:8px 10px 8px 14px;border-radius:999px;background:var(--color-accent-2-200);color:var(--color-accent-2-900);font-size:13px;font-weight:700">Sheet-pan salmon ×</span>
<span style="display:flex;align-items:center;gap:6px;padding:8px 10px 8px 14px;border-radius:999px;background:var(--color-accent-2-200);color:var(--color-accent-2-900);font-size:13px;font-weight:700">Butter chicken ×</span>
<span style="display:flex;align-items:center;gap:6px;padding:8px 10px 8px 14px;border-radius:999px;background:var(--color-accent-2-200);color:var(--color-accent-2-900);font-size:13px;font-weight:700">Shrimp scampi ×</span>
</div>
<div style="padding:18px 20px 0;font-size:12px;font-weight:700;color:var(--color-neutral-700)">Common picks</div>
<div style="padding:8px 20px 0;display:flex;flex-wrap:wrap;gap:8px">
<span style="padding:7px 14px;border-radius:999px;border:1.5px dashed var(--color-neutral-400);font-size:13px">+ Chili</span>
<span style="padding:7px 14px;border-radius:999px;border:1.5px dashed var(--color-neutral-400);font-size:13px">+ Pot roast</span>
<span style="padding:7px 14px;border-radius:999px;border:1.5px dashed var(--color-neutral-400);font-size:13px">+ Burgers</span>
<span style="padding:7px 14px;border-radius:999px;border:1.5px dashed var(--color-neutral-400);font-size:13px">+ Fried rice</span>
</div>
<div style="position:absolute;left:20px;right:20px;bottom:24px;height:52px;border-radius:999px;background:var(--color-accent);display:flex;align-items:center;justify-content:center;font-family:var(--font-heading);font-size:16px;color:var(--color-text)">Next: notifications</div>
</div></div>
<div class="dv-opt" id="1i"><div class="dv-olabel"><a class="dv-oid" href="#1i">1i</a>Cook from what I have</div>
<div style="width:360px;height:760px;position:relative;border-radius:36px;background:var(--color-bg);overflow:hidden;box-shadow:var(--shadow-md)">
<div style="height:40px;display:flex;align-items:center;justify-content:space-between;padding:0 26px;font-size:13px;font-weight:700"><span>9:41</span><span style="width:22px;height:10px;border-radius:3px;background:var(--color-text)"></span></div>
<div style="padding:6px 20px 0;display:flex;align-items:center;gap:10px"><div style="width:36px;height:36px;border-radius:50%;background:var(--color-surface);display:flex;align-items:center;justify-content:center"><svg viewBox="0 0 24 24" width="18" height="18" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="m15 18-6-6 6-6"></path></svg></div><div style="font-family:var(--font-heading);font-size:22px">Cook from what I have</div></div>
<div style="margin:16px 20px 0;padding:12px;border-radius:26px;background:var(--color-surface);display:flex;flex-wrap:wrap;gap:6px">
<span style="padding:6px 12px;border-radius:999px;background:var(--color-bg);font-size:13px;font-weight:700">Chicken thighs ×</span>
<span style="padding:6px 12px;border-radius:999px;background:var(--color-bg);font-size:13px;font-weight:700">Rice ×</span>
<span style="padding:6px 12px;border-radius:999px;background:var(--color-bg);font-size:13px;font-weight:700">Spinach ×</span>
<span style="padding:6px 12px;border-radius:999px;background:var(--color-bg);font-size:13px;font-weight:700">Coconut milk ×</span>
<span style="padding:6px 12px;border-radius:999px;background:var(--color-bg);font-size:13px;font-weight:700">Garlic ×</span>
<span style="padding:6px 8px;font-size:13px;color:var(--color-neutral-600)">Add ingredient…</span>
</div>
<div style="margin:12px 20px 0;display:flex;align-items:center;justify-content:space-between"><div style="display:flex;border:1px solid var(--color-divider);border-radius:999px;overflow:hidden;font-size:13px;font-weight:700"><span style="padding:8px 14px">Any time</span><span style="padding:8px 14px;background:var(--color-accent);color:var(--color-text)">Quick only</span></div><div style="height:38px;padding:0 16px;border-radius:999px;background:var(--color-text);color:var(--color-bg);display:flex;align-items:center;font-family:var(--font-heading);font-size:14px">Find ideas</div></div>
<div style="padding:20px 20px 0;font-size:12px;font-weight:700;color:var(--color-neutral-700)">3 ideas</div>
<div style="padding:8px 20px 0;display:flex;flex-direction:column;gap:10px">
<div style="padding:14px;border-radius:24px;background:var(--color-neutral-100);display:flex;flex-direction:column;gap:6px"><div style="display:flex;justify-content:space-between;align-items:baseline"><span style="font-family:var(--font-heading);font-size:17px">Coconut chicken curry</span><span style="font-size:12px;font-weight:700;color:var(--color-neutral-700)">30 min</span></div><div style="font-size:12px;color:var(--color-accent-2-800);font-weight:700">Uses all 5 · missing: 1 lime</div><div style="display:flex;gap:8px;margin-top:4px"><span style="padding:6px 12px;border-radius:999px;background:var(--color-accent);color:var(--color-text);font-size:12px;font-weight:700">Cook tonight</span><span style="padding:6px 12px;border-radius:999px;border:1px solid var(--color-divider);font-size:12px;font-weight:700">Add to Quest log</span></div></div>
<div style="padding:14px;border-radius:24px;background:var(--color-neutral-100);display:flex;flex-direction:column;gap:6px"><div style="display:flex;justify-content:space-between;align-items:baseline"><span style="font-family:var(--font-heading);font-size:17px">Garlic spinach rice bowls</span><span style="font-size:12px;font-weight:700;color:var(--color-neutral-700)">25 min</span></div><div style="font-size:12px;color:var(--color-accent-2-800);font-weight:700">Uses 4 of 5 · nothing missing</div><div style="display:flex;gap:8px;margin-top:4px"><span style="padding:6px 12px;border-radius:999px;background:var(--color-accent);color:var(--color-text);font-size:12px;font-weight:700">Cook tonight</span><span style="padding:6px 12px;border-radius:999px;border:1px solid var(--color-divider);font-size:12px;font-weight:700">Add to Quest log</span></div></div>
<div style="padding:14px;border-radius:24px;background:var(--color-neutral-100);display:flex;flex-direction:column;gap:6px"><div style="display:flex;justify-content:space-between;align-items:baseline"><span style="font-family:var(--font-heading);font-size:17px">One-pan chicken &amp; rice</span><span style="font-size:12px;font-weight:700;color:var(--color-neutral-700)">30 min</span></div><div style="font-size:12px;color:var(--color-accent-2-800);font-weight:700">Uses 3 of 5 · missing: onion</div></div>
</div>
</div></div>
<div class="dv-opt" id="1j"><div class="dv-olabel"><a class="dv-oid" href="#1j">1j</a>Quick dinners · two tabs</div>
<div style="width:360px;height:760px;position:relative;border-radius:36px;background:var(--color-bg);overflow:hidden;box-shadow:var(--shadow-md)">
<div style="height:40px;display:flex;align-items:center;justify-content:space-between;padding:0 26px;font-size:13px;font-weight:700"><span>9:41</span><span style="width:22px;height:10px;border-radius:3px;background:var(--color-text)"></span></div>
<div style="padding:6px 20px 0;display:flex;align-items:center;gap:10px"><div style="width:36px;height:36px;border-radius:50%;background:var(--color-surface);display:flex;align-items:center;justify-content:center"><svg viewBox="0 0 24 24" width="18" height="18" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="m15 18-6-6 6-6"></path></svg></div><div style="font-family:var(--font-heading);font-size:24px">Quick dinners</div></div>
<div style="margin:16px 20px 0;padding:4px;border-radius:999px;background:var(--color-surface);display:grid;grid-template-columns:1fr 1fr;font-size:14px;font-weight:700;text-align:center"><span style="padding:10px 0;color:var(--color-neutral-700)">Crock pot</span><span style="padding:10px 0;border-radius:999px;background:var(--color-bg);box-shadow:var(--shadow-sm)">30 min or less</span></div>
<div style="padding:12px 20px 0;font-size:13px;color:var(--color-neutral-800)">Prep to table in 30 minutes or less, picked from your tastes.</div>
<div style="padding:14px 20px 0;display:flex;flex-direction:column;gap:10px">
<div style="display:flex;gap:12px;align-items:center;padding:10px;border-radius:24px;background:var(--color-neutral-100)"><div style="flex:none;width:64px;height:64px;border-radius:50%;background:repeating-linear-gradient(135deg,var(--color-neutral-300) 0 5px,var(--color-neutral-200) 5px 10px)"></div><div style="flex:1;display:flex;flex-direction:column;gap:3px"><span style="font-family:var(--font-heading);font-size:16px;line-height:1.15">Lemon garlic shrimp orzo</span><span style="font-size:12px;color:var(--color-neutral-700)">25 min · Medium</span></div><div style="width:36px;height:36px;border-radius:50%;background:var(--color-accent-2-600);color:var(--color-bg);display:flex;align-items:center;justify-content:center"><svg viewBox="0 0 24 24" width="17" height="17" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M5 12h14"></path><path d="M12 5v14"></path></svg></div></div>
<div style="display:flex;gap:12px;align-items:center;padding:10px;border-radius:24px;background:var(--color-neutral-100)"><div style="flex:none;width:64px;height:64px;border-radius:50%;background:repeating-linear-gradient(135deg,var(--color-neutral-300) 0 5px,var(--color-neutral-200) 5px 10px)"></div><div style="flex:1;display:flex;flex-direction:column;gap:3px"><span style="font-family:var(--font-heading);font-size:16px;line-height:1.15">Chicken quesadillas</span><span style="font-size:12px;color:var(--color-neutral-700)">20 min · Easy</span></div><div style="width:36px;height:36px;border-radius:50%;background:var(--color-accent-2-600);color:var(--color-bg);display:flex;align-items:center;justify-content:center"><svg viewBox="0 0 24 24" width="17" height="17" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M5 12h14"></path><path d="M12 5v14"></path></svg></div></div>
<div style="display:flex;gap:12px;align-items:center;padding:10px;border-radius:24px;background:var(--color-neutral-100)"><div style="flex:none;width:64px;height:64px;border-radius:50%;background:repeating-linear-gradient(135deg,var(--color-neutral-300) 0 5px,var(--color-neutral-200) 5px 10px)"></div><div style="flex:1;display:flex;flex-direction:column;gap:3px"><span style="font-family:var(--font-heading);font-size:16px;line-height:1.15">Beef &amp; broccoli</span><span style="font-size:12px;color:var(--color-neutral-700)">30 min · Easy</span></div><div style="width:36px;height:36px;border-radius:50%;background:var(--color-accent-2-600);color:var(--color-bg);display:flex;align-items:center;justify-content:center"><svg viewBox="0 0 24 24" width="17" height="17" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M5 12h14"></path><path d="M12 5v14"></path></svg></div></div>
<div style="display:flex;gap:12px;align-items:center;padding:10px;border-radius:24px;background:var(--color-neutral-100)"><div style="flex:none;width:64px;height:64px;border-radius:50%;background:repeating-linear-gradient(135deg,var(--color-neutral-300) 0 5px,var(--color-neutral-200) 5px 10px)"></div><div style="flex:1;display:flex;flex-direction:column;gap:3px"><span style="font-family:var(--font-heading);font-size:16px;line-height:1.15">Pesto gnocchi &amp; peas</span><span style="font-size:12px;color:var(--color-neutral-700)">15 min · Easy</span></div><div style="width:36px;height:36px;border-radius:50%;background:var(--color-accent-2-600);color:var(--color-bg);display:flex;align-items:center;justify-content:center"><svg viewBox="0 0 24 24" width="17" height="17" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M5 12h14"></path><path d="M12 5v14"></path></svg></div></div>
</div>
<div style="margin:16px 20px 0;display:flex;align-items:center;justify-content:center;gap:6px;font-size:13px;font-weight:700;color:var(--color-accent-700)"><svg viewBox="0 0 24 24" width="15" height="15" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"></path><path d="M21 3v5h-5"></path><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"></path><path d="M8 16H3v5"></path></svg>Show me more</div>
</div></div>
<div class="dv-opt" id="1k"><div class="dv-olabel"><a class="dv-oid" href="#1k">1k</a>Recipe / dinner detail</div>
<div style="width:360px;height:760px;position:relative;border-radius:36px;background:var(--color-bg);overflow:hidden;box-shadow:var(--shadow-md)">
<div style="position:relative;height:250px;background:repeating-linear-gradient(135deg,var(--color-neutral-300) 0 8px,var(--color-neutral-200) 8px 16px);display:flex;align-items:center;justify-content:center;font:11px ui-monospace,Menlo,monospace;color:var(--color-neutral-700)">hero dish photo
<div style="position:absolute;top:48px;left:20px;width:40px;height:40px;border-radius:50%;background:var(--color-bg);display:flex;align-items:center;justify-content:center;color:var(--color-text)"><svg viewBox="0 0 24 24" width="18" height="18" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="m15 18-6-6 6-6"></path></svg></div></div>
<div style="margin-top:-28px;position:relative;border-radius:32px 32px 0 0;background:var(--color-bg);padding:22px 20px 0;display:flex;flex-direction:column;gap:10px">
<div style="display:flex;gap:6px;flex-wrap:wrap"><span style="padding:3px 10px;border-radius:999px;background:var(--color-accent-200);color:var(--color-accent-800);font-size:11px;font-weight:700">Side quest</span><span style="padding:3px 10px;border-radius:999px;background:var(--color-neutral-100);color:var(--color-neutral-800);font-size:11px;font-weight:700">Easy</span><span style="padding:3px 10px;border-radius:999px;background:var(--color-neutral-100);color:var(--color-neutral-800);font-size:11px;font-weight:700">30 min</span></div>
<div style="font-family:var(--font-heading);font-size:28px;line-height:1.05">Korean beef bowls</div>
<div style="padding:12px 14px;border-radius:18px;background:var(--color-accent-2-100);color:var(--color-accent-2-900);font-size:13px;line-height:1.45"><b>Why it was picked:</b> close cousin of your Beef tacos and Chicken stir-fry.</div>
<div style="display:flex;justify-content:space-between;align-items:baseline;margin-top:6px"><span style="font-family:var(--font-heading);font-size:18px">Ingredients</span><span style="font-size:12px;font-weight:700;color:var(--color-neutral-700)">Serves 4</span></div>
<div style="display:flex;flex-direction:column;font-size:14px">
<div style="display:flex;justify-content:space-between;padding:7px 0;border-bottom:1px solid var(--color-divider)"><span>Ground beef</span><span style="color:var(--color-neutral-700)">1 lb</span></div>
<div style="display:flex;justify-content:space-between;padding:7px 0;border-bottom:1px solid var(--color-divider)"><span>Jasmine rice</span><span style="color:var(--color-neutral-700)">2 cups</span></div>
<div style="display:flex;justify-content:space-between;padding:7px 0;border-bottom:1px solid var(--color-divider)"><span>Soy sauce</span><span style="color:var(--color-neutral-700)">¼ cup</span></div>
<div style="display:flex;justify-content:space-between;padding:7px 0;border-bottom:1px solid var(--color-divider)"><span>Brown sugar</span><span style="color:var(--color-neutral-700)">3 tbsp</span></div>
<div style="display:flex;justify-content:space-between;padding:7px 0"><span>Green onions</span><span style="color:var(--color-neutral-700)">1 bunch</span></div>
</div>
</div>
<div style="position:absolute;left:20px;right:20px;bottom:24px;display:flex;gap:10px"><div style="flex:1;height:52px;border-radius:999px;background:var(--color-accent);display:flex;align-items:center;justify-content:center;font-family:var(--font-heading);font-size:16px;color:var(--color-text)">Add to this week</div><div style="width:52px;height:52px;border-radius:50%;background:var(--color-surface);display:flex;align-items:center;justify-content:center"><svg viewBox="0 0 24 24" width="20" height="20" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z"></path></svg></div></div>
</div></div>
</div>
<div class="dv-group">Shopping</div>
<div class="dv-opts">
<div class="dv-opt" id="1l"><div class="dv-olabel"><a class="dv-oid" href="#1l">1l</a>Shopping list · grouped by dinner</div>
<div style="width:360px;height:760px;position:relative;border-radius:36px;background:var(--color-bg);overflow:hidden;box-shadow:var(--shadow-md)">
<div style="height:40px;display:flex;align-items:center;justify-content:space-between;padding:0 26px;font-size:13px;font-weight:700"><span>9:41</span><span style="width:22px;height:10px;border-radius:3px;background:var(--color-text)"></span></div>
<div style="padding:6px 20px 0;display:flex;justify-content:space-between;align-items:flex-end"><div><div style="font-size:11px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:var(--color-accent-700)">Supply run · Oct 5–11</div><div style="font-family:var(--font-heading);font-size:28px;line-height:1.1">Shopping list</div></div><div style="font-size:13px;font-weight:700;color:var(--color-neutral-700)">19 / 23</div></div>
<div style="margin:10px 20px 0;font-size:12px;line-height:1.45;color:var(--color-neutral-800)">Uncheck anything you already have — it won't be counted.</div>
<div style="padding:12px 20px 0;display:flex;flex-direction:column;gap:12px">
<div style="padding:14px;border-radius:24px;background:var(--color-surface);display:flex;flex-direction:column;gap:9px">
<div style="display:flex;justify-content:space-between;align-items:baseline"><span style="font-family:var(--font-heading);font-size:16px">Beef tacos</span><span style="font-size:11px;font-weight:700;color:var(--color-neutral-700)">MON</span></div>
<div style="display:flex;align-items:center;gap:10px;font-size:14px"><span style="width:20px;height:20px;border-radius:7px;background:var(--color-accent);color:var(--color-text);display:flex;align-items:center;justify-content:center"><svg viewBox="0 0 24 24" width="13" height="13" style="fill:none;stroke:currentColor;stroke-width:3.5;stroke-linecap:round;stroke-linejoin:round"><path d="M20 6 9 17l-5-5"></path></svg></span><span style="flex:1">Ground beef</span><span style="color:var(--color-neutral-700);font-size:12px">1 lb</span></div>
<div style="display:flex;align-items:center;gap:10px;font-size:14px"><span style="width:20px;height:20px;border-radius:7px;background:var(--color-accent);color:var(--color-text);display:flex;align-items:center;justify-content:center"><svg viewBox="0 0 24 24" width="13" height="13" style="fill:none;stroke:currentColor;stroke-width:3.5;stroke-linecap:round;stroke-linejoin:round"><path d="M20 6 9 17l-5-5"></path></svg></span><span style="flex:1">Corn tortillas</span><span style="color:var(--color-neutral-700);font-size:12px">12</span></div>
<div style="display:flex;align-items:center;gap:10px;font-size:14px;color:var(--color-neutral-600)"><span style="width:20px;height:20px;border-radius:7px;border:2px solid var(--color-neutral-400)"></span><span style="flex:1;text-decoration:line-through">Taco seasoning</span><span style="font-size:11px;font-weight:700">have it</span></div>
</div>
<div style="padding:14px;border-radius:24px;background:var(--color-surface);display:flex;flex-direction:column;gap:9px">
<div style="display:flex;justify-content:space-between;align-items:baseline"><span style="font-family:var(--font-heading);font-size:16px">Crock pot tikka masala</span><span style="font-size:11px;font-weight:700;color:var(--color-neutral-700)">WED</span></div>
<div style="display:flex;align-items:center;gap:10px;font-size:14px"><span style="width:20px;height:20px;border-radius:7px;background:var(--color-accent);color:var(--color-text);display:flex;align-items:center;justify-content:center"><svg viewBox="0 0 24 24" width="13" height="13" style="fill:none;stroke:currentColor;stroke-width:3.5;stroke-linecap:round;stroke-linejoin:round"><path d="M20 6 9 17l-5-5"></path></svg></span><span style="flex:1">Chicken thighs</span><span style="color:var(--color-neutral-700);font-size:12px">2 lb</span></div>
<div style="display:flex;align-items:center;gap:10px;font-size:14px"><span style="width:20px;height:20px;border-radius:7px;background:var(--color-accent);color:var(--color-text);display:flex;align-items:center;justify-content:center"><svg viewBox="0 0 24 24" width="13" height="13" style="fill:none;stroke:currentColor;stroke-width:3.5;stroke-linecap:round;stroke-linejoin:round"><path d="M20 6 9 17l-5-5"></path></svg></span><span style="flex:1">Tikka masala paste</span><span style="color:var(--color-neutral-700);font-size:12px">1 jar</span></div>
<div style="display:flex;align-items:center;gap:10px;font-size:14px;color:var(--color-neutral-600)"><span style="width:20px;height:20px;border-radius:7px;border:2px solid var(--color-neutral-400)"></span><span style="flex:1;text-decoration:line-through">Basmati rice</span><span style="font-size:11px;font-weight:700">have it</span></div>
</div>
<div style="padding:14px;border-radius:24px;background:var(--color-surface);display:flex;justify-content:space-between;align-items:baseline"><span style="font-family:var(--font-heading);font-size:16px">Chicken stir-fry</span><span style="font-size:11px;font-weight:700;color:var(--color-neutral-700)">THU · 5 items</span></div>
</div>
<div style="position:absolute;left:20px;right:20px;bottom:96px;display:flex;align-items:center;gap:8px;font-size:12px;font-weight:700;color:var(--color-accent-2-800)"><svg viewBox="0 0 24 24" width="15" height="15" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path></svg>Sent to Google Chat · Sat 8:00 am</div>
<div style="position:absolute;left:14px;right:14px;bottom:16px;height:62px;border-radius:999px;background:var(--color-text);display:flex;align-items:center;justify-content:space-around;color:var(--color-neutral-400)">
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"></path><path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path></svg>
<div style="display:flex;align-items:center;gap:6px;padding:10px 14px;border-radius:999px;background:var(--color-accent);color:var(--color-text);font-size:13px;font-weight:700"><svg viewBox="0 0 24 24" width="20" height="20" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M8 2v4"></path><path d="M16 2v4"></path><rect width="18" height="18" x="3" y="4" rx="2"></rect><path d="M3 10h18"></path></svg>Plan</div>
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><circle cx="12" cy="12" r="10"></circle><path d="m16.24 7.76-1.804 5.411a2 2 0 0 1-1.265 1.265L7.76 16.24l1.804-5.411a2 2 0 0 1 1.265-1.265z"></path></svg>
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M10 5H3"></path><path d="M12 19H3"></path><path d="M14 3v4"></path><path d="M16 17v4"></path><path d="M21 12h-9"></path><path d="M21 19h-5"></path><path d="M21 5h-7"></path><path d="M8 10v4"></path><path d="M8 12H3"></path></svg>
</div>
</div></div>
<div class="dv-opt" id="1m"><div class="dv-olabel"><a class="dv-oid" href="#1m">1m</a>Shopping history · weekly posts</div>
<div style="width:360px;height:760px;position:relative;border-radius:36px;background:var(--color-bg);overflow:hidden;box-shadow:var(--shadow-md)">
<div style="height:40px;display:flex;align-items:center;justify-content:space-between;padding:0 26px;font-size:13px;font-weight:700"><span>9:41</span><span style="width:22px;height:10px;border-radius:3px;background:var(--color-text)"></span></div>
<div style="padding:6px 20px 0;display:flex;align-items:center;gap:10px"><div style="width:36px;height:36px;border-radius:50%;background:var(--color-surface);display:flex;align-items:center;justify-content:center"><svg viewBox="0 0 24 24" width="18" height="18" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="m15 18-6-6 6-6"></path></svg></div><div style="font-family:var(--font-heading);font-size:24px">Supply archive</div></div>
<div style="margin:14px 20px 0;display:inline-flex;border:1px solid var(--color-divider);border-radius:999px;overflow:hidden;font-size:13px;font-weight:700"><span style="padding:8px 16px;background:var(--color-accent);color:var(--color-text)">Week</span><span style="padding:8px 16px">Month</span><span style="padding:8px 16px">Year</span></div>
<div style="padding:18px 20px 0;font-family:var(--font-heading);font-size:18px">October 2026</div>
<div style="padding:10px 20px 0;display:flex;flex-direction:column;gap:12px">
<div style="padding:16px;border-radius:26px;background:var(--color-surface);display:flex;flex-direction:column;gap:8px">
<div style="font-size:11px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:var(--color-accent-700)">Week 41 · Oct 5 – 11</div>
<div style="font-family:var(--font-heading);font-size:19px;line-height:1.15">Tacos, tikka &amp; a salmon finale</div>
<div style="font-size:13px;color:var(--color-neutral-800)">7 dinners · 23 items · 4 already on hand</div>
<div style="display:flex;flex-wrap:wrap;gap:6px"><span style="padding:3px 10px;border-radius:999px;background:var(--color-bg);font-size:11px;font-weight:700">Beef tacos</span><span style="padding:3px 10px;border-radius:999px;background:var(--color-accent-200);color:var(--color-accent-800);font-size:11px;font-weight:700">Tikka masala</span><span style="padding:3px 10px;border-radius:999px;background:var(--color-bg);font-size:11px;font-weight:700">+5</span></div>
</div>
</div>
<div style="padding:20px 20px 0;font-family:var(--font-heading);font-size:18px">September 2026</div>
<div style="padding:10px 20px 0;display:flex;flex-direction:column;gap:10px">
<div style="padding:14px 16px;border-radius:22px;background:var(--color-neutral-100);display:flex;flex-direction:column;gap:4px"><div style="font-size:11px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:var(--color-neutral-700)">Week 40 · Sep 28 – Oct 4</div><div style="font-family:var(--font-heading);font-size:16px">Pizza, pot roast &amp; the great orzo trial</div><div style="font-size:12px;color:var(--color-neutral-700)">7 dinners · 26 items</div></div>
<div style="padding:14px 16px;border-radius:22px;background:var(--color-neutral-100);display:flex;flex-direction:column;gap:4px"><div style="font-size:11px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:var(--color-neutral-700)">Week 39 · Sep 21 – 27</div><div style="font-family:var(--font-heading);font-size:16px">Chili season opens</div><div style="font-size:12px;color:var(--color-neutral-700)">6 dinners · 21 items</div></div>
</div>
</div></div>
</div>
<div class="dv-group">Settings &amp; admin</div>
<div class="dv-opts">
<div class="dv-opt" id="1n"><div class="dv-olabel"><a class="dv-oid" href="#1n">1n</a>Notifications &amp; Google Chat</div>
<div style="width:360px;height:760px;position:relative;border-radius:36px;background:var(--color-bg);overflow:hidden;box-shadow:var(--shadow-md)">
<div style="height:40px;display:flex;align-items:center;justify-content:space-between;padding:0 26px;font-size:13px;font-weight:700"><span>9:41</span><span style="width:22px;height:10px;border-radius:3px;background:var(--color-text)"></span></div>
<div style="padding:6px 20px 0;font-family:var(--font-heading);font-size:26px">Notifications</div>
<div style="padding:14px 20px 0;display:grid;grid-template-columns:1fr 46px 46px;align-items:center;row-gap:12px;font-size:14px">
<span style="font-size:11px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:var(--color-neutral-700)">Event</span><span style="font-size:10px;font-weight:700;color:var(--color-neutral-700);text-align:center">PUSH</span><span style="font-size:10px;font-weight:700;color:var(--color-neutral-700);text-align:center">CHAT</span>
<span style="font-weight:700">New weekly plan ready</span><span style="justify-self:center;width:38px;height:22px;border-radius:999px;background:var(--color-accent-2-600);position:relative"><span style="position:absolute;right:3px;top:3px;width:16px;height:16px;border-radius:50%;background:var(--color-bg)"></span></span><span style="justify-self:center;width:38px;height:22px;border-radius:999px;background:var(--color-accent-2-600);position:relative"><span style="position:absolute;right:3px;top:3px;width:16px;height:16px;border-radius:50%;background:var(--color-bg)"></span></span>
<span style="font-weight:700">Shopping list ready</span><span style="justify-self:center;width:38px;height:22px;border-radius:999px;background:var(--color-accent-2-600);position:relative"><span style="position:absolute;right:3px;top:3px;width:16px;height:16px;border-radius:50%;background:var(--color-bg)"></span></span><span style="justify-self:center;width:38px;height:22px;border-radius:999px;background:var(--color-accent-2-600);position:relative"><span style="position:absolute;right:3px;top:3px;width:16px;height:16px;border-radius:50%;background:var(--color-bg)"></span></span>
<span style="font-weight:700">Day-of dinner reminder<br><span style="font-weight:400;font-size:12px;color:var(--color-neutral-700)">at 4:30 pm</span></span><span style="justify-self:center;width:38px;height:22px;border-radius:999px;background:var(--color-accent-2-600);position:relative"><span style="position:absolute;right:3px;top:3px;width:16px;height:16px;border-radius:50%;background:var(--color-bg)"></span></span><span style="justify-self:center;width:38px;height:22px;border-radius:999px;background:var(--color-neutral-300);position:relative"><span style="position:absolute;left:3px;top:3px;width:16px;height:16px;border-radius:50%;background:var(--color-bg)"></span></span>
<span style="font-weight:700">New suggestions to swipe</span><span style="justify-self:center;width:38px;height:22px;border-radius:999px;background:var(--color-accent-2-600);position:relative"><span style="position:absolute;right:3px;top:3px;width:16px;height:16px;border-radius:50%;background:var(--color-bg)"></span></span><span style="justify-self:center;width:38px;height:22px;border-radius:999px;background:var(--color-neutral-300);position:relative"><span style="position:absolute;left:3px;top:3px;width:16px;height:16px;border-radius:50%;background:var(--color-bg)"></span></span>
</div>
<div style="margin:22px 20px 0;padding:16px;border-radius:26px;background:var(--color-surface);display:flex;flex-direction:column;gap:10px">
<div style="font-family:var(--font-heading);font-size:17px">Google Chat webhook</div>
<div style="height:42px;padding:0 14px;border-radius:999px;background:var(--color-bg);border:1px solid var(--color-divider);display:flex;align-items:center;font:12px ui-monospace,Menlo,monospace;color:var(--color-neutral-800);overflow:hidden;white-space:nowrap">https://chat.googleapis.com/v1/spaces/…</div>
<div style="display:flex;align-items:center;justify-content:space-between"><span style="font-size:12px;font-weight:700;color:var(--color-accent-2-800)">Connected · Dinner Crew space</span><span style="padding:7px 14px;border-radius:999px;border:1.5px solid var(--color-divider);font-family:var(--font-heading);font-size:13px">Send test</span></div>
</div>
<div style="margin:12px 20px 0;padding:12px 14px;border-radius:18px;background:var(--color-neutral-100);font-size:12px;line-height:1.45;display:flex;gap:10px"><div style="flex:none;width:26px;height:26px;border-radius:50%;background:var(--color-accent);display:flex;align-items:center;justify-content:center;font-family:var(--font-heading);font-size:12px">Q</div><div><b>MealQuest</b> · preview<br>This week's plan is ready: Tacos, Bolognese, Tikka masala + 4 more. Open the plan →</div></div>
<div style="position:absolute;left:14px;right:14px;bottom:16px;height:62px;border-radius:999px;background:var(--color-text);display:flex;align-items:center;justify-content:space-around;color:var(--color-neutral-400)">
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"></path><path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path></svg>
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M8 2v4"></path><path d="M16 2v4"></path><rect width="18" height="18" x="3" y="4" rx="2"></rect><path d="M3 10h18"></path></svg>
<svg viewBox="0 0 24 24" width="22" height="22" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><circle cx="12" cy="12" r="10"></circle><path d="m16.24 7.76-1.804 5.411a2 2 0 0 1-1.265 1.265L7.76 16.24l1.804-5.411a2 2 0 0 1 1.265-1.265z"></path></svg>
<div style="display:flex;align-items:center;gap:6px;padding:10px 14px;border-radius:999px;background:var(--color-accent);color:var(--color-text);font-size:13px;font-weight:700"><svg viewBox="0 0 24 24" width="20" height="20" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M10 5H3"></path><path d="M12 19H3"></path><path d="M14 3v4"></path><path d="M16 17v4"></path><path d="M21 12h-9"></path><path d="M21 19h-5"></path><path d="M21 5h-7"></path><path d="M8 10v4"></path><path d="M8 12H3"></path></svg>Settings</div>
</div>
</div></div>
<div class="dv-opt" id="1o"><div class="dv-olabel"><a class="dv-oid" href="#1o">1o</a>Admin · LLM configuration</div>
<div style="width:360px;height:760px;position:relative;border-radius:36px;background:var(--color-bg);overflow:hidden;box-shadow:var(--shadow-md)">
<div style="height:40px;display:flex;align-items:center;justify-content:space-between;padding:0 26px;font-size:13px;font-weight:700"><span>9:41</span><span style="width:22px;height:10px;border-radius:3px;background:var(--color-text)"></span></div>
<div style="padding:6px 20px 0"><div style="font-size:11px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:var(--color-accent-700)">Admin</div><div style="font-family:var(--font-heading);font-size:26px;line-height:1.1">AI assistant</div></div>
<div style="padding:16px 20px 0;display:flex;flex-direction:column;gap:14px">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px">
<div><div style="font-size:12px;font-weight:700;color:var(--color-neutral-700);margin-bottom:5px">Provider</div><div style="height:42px;padding:0 14px;border-radius:999px;background:var(--color-surface);border:1px solid var(--color-divider);display:flex;align-items:center;justify-content:space-between;font-size:14px;font-weight:700">Anthropic<svg viewBox="0 0 24 24" width="15" height="15" style="transform:rotate(90deg);fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="m9 18 6-6-6-6"></path></svg></div></div>
<div><div style="font-size:12px;font-weight:700;color:var(--color-neutral-700);margin-bottom:5px">Model</div><div style="height:42px;padding:0 14px;border-radius:999px;background:var(--color-surface);border:1px solid var(--color-divider);display:flex;align-items:center;justify-content:space-between;font-size:14px;font-weight:700">Claude Sonnet<svg viewBox="0 0 24 24" width="15" height="15" style="transform:rotate(90deg);fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="m9 18 6-6-6-6"></path></svg></div></div>
</div>
<div><div style="font-size:12px;font-weight:700;color:var(--color-neutral-700);margin-bottom:5px">API key</div><div style="height:42px;padding:0 6px 0 14px;border-radius:999px;background:var(--color-surface);border:1px solid var(--color-divider);display:flex;align-items:center;justify-content:space-between;font:13px ui-monospace,Menlo,monospace">sk-••••••••••••3f9a<span style="padding:6px 12px;border-radius:999px;background:var(--color-bg);font:700 12px var(--font-body)">Replace</span></div><div style="font-size:12px;font-weight:700;color:var(--color-accent-2-800);margin-top:6px">Verified · Oct 1</div></div>
<div><div style="display:flex;justify-content:space-between;font-size:12px;font-weight:700;color:var(--color-neutral-700);margin-bottom:10px"><span>Creativity (temperature)</span><span style="color:var(--color-text)">0.7</span></div>
<div style="position:relative;height:8px;border-radius:999px;background:var(--color-neutral-300)"><div style="width:70%;height:100%;border-radius:999px;background:var(--color-accent)"></div><div style="position:absolute;left:70%;top:50%;transform:translate(-50%,-50%);width:24px;height:24px;border-radius:50%;background:var(--color-bg);box-shadow:var(--shadow-md);border:3px solid var(--color-accent)"></div></div>
<div style="display:flex;justify-content:space-between;font-size:11px;color:var(--color-neutral-700);margin-top:8px"><span>Familiar</span><span>Adventurous</span></div></div>
<div><div style="font-size:12px;font-weight:700;color:var(--color-neutral-700);margin-bottom:5px">System prompt</div><div style="height:150px;padding:12px 14px;border-radius:22px;background:var(--color-surface);border:1px solid var(--color-divider);font:12px/1.55 ui-monospace,Menlo,monospace;color:var(--color-neutral-900);overflow:hidden">You are MealQuest, a dinner planning assistant for one household. Suggest dinners similar to their regular rotation. Prefer weeknight-friendly recipes. Flag crock pot meals and anything ≤30 min as "quick"…</div></div>
</div>
<div style="position:absolute;left:20px;right:20px;bottom:24px;display:flex;gap:10px"><div style="flex:1;height:50px;border-radius:999px;border:1.5px solid var(--color-divider);display:flex;align-items:center;justify-content:center;font-family:var(--font-heading);font-size:15px">Reset</div><div style="flex:2;height:50px;border-radius:999px;background:var(--color-accent);display:flex;align-items:center;justify-content:center;font-family:var(--font-heading);font-size:15px;color:var(--color-text)">Save changes</div></div>
</div></div>
</div>
<p class="dv-next">Try next: "go with <a class="dv-oid" href="#1a">1a</a> for swipe and <a class="dv-oid" href="#1f">1f</a> for the plan" · "show the Quest log (to-try pool) screen" · "add a Settings home that links to admin"</p>
</section>
</x-dc>
</body>
</html>
@@ -0,0 +1,206 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<helmet>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Bagel+Fat+One&display=swap">
<link rel="stylesheet" href="_ds/organic-cd54d318-425e-4a60-adfb-d590f70d40bd/styles.css">
<style>body{margin:0;background:#e9e4dc}a{color:#68449b}a:hover{color:#4a2d73}button{font:inherit}button:focus-visible{outline:2px solid #5a358c;outline-offset:2px}</style>
</helmet>
<div style="--color-bg:#f9f6f1;--color-surface:#eee9de;--color-accent:#5a358c;--color-accent-100:#f8f1ff;--color-accent-200:#eedfff;--color-accent-300:#dac3ff;--color-accent-700:#68449b;--color-accent-800:#4a2d73;--color-accent-2:#29889c;--color-accent-2-100:#e0fbff;--color-accent-2-200:#bbf3ff;--color-accent-2-600:#008aaa;--color-accent-2-700:#006b88;--color-accent-2-800:#004c63;--color-accent-2-900:#002f3e;--on-accent:#f9f6f1;--font-heading:'Bagel Fat One',system-ui,sans-serif;min-height:100vh;padding:48px;display:flex;gap:48px;flex-wrap:wrap;align-items:flex-start;font-family:var(--font-body);color:var(--color-text)">
<div style="width:360px;height:760px;position:relative;border-radius:36px;background:var(--color-bg);overflow:hidden;box-shadow:var(--shadow-lg);flex:none">
<div style="height:40px;display:flex;align-items:center;justify-content:space-between;padding:0 26px;font-size:13px;font-weight:700"><span>9:41</span><span style="width:22px;height:10px;border-radius:3px;background:var(--color-text)"></span></div>
<sc-if value="{{ onDiscover }}" hint-placeholder-val="{{ true }}">
<div style="padding:8px 20px 0;display:flex;align-items:flex-end;justify-content:space-between">
<div><div style="font-size:11px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:var(--color-accent-700)">Discover</div><div style="font-family:var(--font-heading);font-size:30px;line-height:1.1">Side quests</div></div>
<div ref="{{ refCount }}" style="padding:7px 12px;border-radius:999px;background:var(--color-accent-2-200);color:var(--color-accent-2-800);font-size:12px;font-weight:700">Quest log · {{ questCount }}</div>
</div>
<div style="position:relative;margin:18px 20px 0;height:440px;perspective:1000px">
<sc-for list="{{ cards }}" as="card" hint-placeholder-count="3">
<div ref="{{ card.ref }}" onPointerDown="{{ card.onDown }}" onPointerMove="{{ card.onMove }}" onPointerUp="{{ card.onUp }}" onPointerCancel="{{ card.onUp }}" style="{{ card.style }}">
<div style="height:220px;margin:10px;border-radius:24px;background:repeating-linear-gradient(135deg,#dcd3c4 0 8px,#eee7db 8px 16px);display:flex;align-items:center;justify-content:center;font:11px ui-monospace,Menlo,monospace;color:#645c50">dish photo</div>
<div style="padding:4px 20px 0;display:flex;flex-direction:column;gap:10px">
<div style="font-family:var(--font-heading);font-size:24px;line-height:1.1">{{ card.name }}</div>
<div style="display:flex;align-items:center;gap:8px;font-size:12px;font-weight:700;color:#645c50"><span style="display:flex;gap:3px"><span style="{{ card.d1 }}"></span><span style="{{ card.d2 }}"></span><span style="{{ card.d3 }}"></span></span>{{ card.diff }}</div>
<div style="padding:12px 14px;border-radius:18px;background:var(--color-accent-2-100);color:var(--color-accent-2-900);font-size:13px;line-height:1.45"><b>Why this?</b> {{ card.why }}</div>
</div>
<div data-stamp="1" style="position:absolute;top:26px;left:22px;transform:rotate(-12deg);padding:6px 14px;border:3px solid var(--color-accent-2-700);border-radius:14px;font-family:var(--font-heading);font-size:24px;color:var(--color-accent-2-800);background:var(--color-accent-2-100);opacity:0;pointer-events:none">Try it!</div>
<div data-pass="1" style="position:absolute;top:26px;right:22px;transform:rotate(12deg);padding:6px 14px;border:3px solid #474238;border-radius:14px;font-family:var(--font-heading);font-size:24px;color:#2e2b25;background:#f9f4ed;opacity:0;pointer-events:none">Pass</div>
<div data-shade="1" style="position:absolute;inset:0;border-radius:32px;background:#201e1d;opacity:0;pointer-events:none"></div>
</div>
</sc-for>
</div>
<div style="display:flex;justify-content:center;gap:28px;margin-top:22px">
<button onClick="{{ pass }}" style="border:0;background:none;padding:0;cursor:pointer;display:flex;flex-direction:column;align-items:center;gap:6px;font-size:11px;font-weight:700;color:#645c50"><span ref="{{ refPassBtn }}" style="width:62px;height:62px;border-radius:50%;background:#f9f4ed;box-shadow:var(--shadow-md);display:flex;align-items:center;justify-content:center;color:var(--color-text)"><svg viewBox="0 0 24 24" width="26" height="26" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M18 6 6 18"></path><path d="m6 6 12 12"></path></svg></span>Pass</button>
<button onClick="{{ tryIt }}" style="border:0;background:none;padding:0;cursor:pointer;display:flex;flex-direction:column;align-items:center;gap:6px;font-size:11px;font-weight:700;color:#645c50"><span ref="{{ refTryBtn }}" style="width:62px;height:62px;border-radius:50%;background:var(--color-accent-2-600);box-shadow:var(--shadow-md);display:flex;align-items:center;justify-content:center;color:var(--color-bg)"><svg viewBox="0 0 24 24" width="26" height="26" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z"></path></svg></span>Try it</button>
</div>
</sc-if>
<sc-if value="{{ offDiscover }}" hint-placeholder-val="{{ false }}">
<div style="padding:24px 20px;display:flex;flex-direction:column;gap:8px"><div style="font-family:var(--font-heading);font-size:30px">{{ tab }}</div><div style="font-size:14px;color:#645c50">Placeholder: see the static mockups for this tab. Tap Discover to return to the card demo.</div></div>
</sc-if>
<div style="position:absolute;left:12px;right:12px;bottom:16px;height:62px;border-radius:999px;background:var(--color-text);display:flex;align-items:center;justify-content:space-around;padding:0 6px">
<div ref="{{ refPill }}" style="position:absolute;top:10px;height:42px;left:0;width:0;border-radius:999px;background:var(--color-accent);pointer-events:none"></div>
<button ref="{{ refHome }}" onClick="{{ goHome }}" aria-label="Home" style="{{ navHome }}"><svg viewBox="0 0 24 24" width="21" height="21" style="flex:none;fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"></path><path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path></svg><sc-if value="{{ isHome }}" hint-placeholder-val="{{ false }}"><span data-label="1">Home</span></sc-if></button>
<button ref="{{ refPlan }}" onClick="{{ goPlan }}" aria-label="Plan" style="{{ navPlan }}"><svg viewBox="0 0 24 24" width="21" height="21" style="flex:none;fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M8 2v4"></path><path d="M16 2v4"></path><rect width="18" height="18" x="3" y="4" rx="2"></rect><path d="M3 10h18"></path></svg><sc-if value="{{ isPlan }}" hint-placeholder-val="{{ false }}"><span data-label="1">Plan</span></sc-if></button>
<button ref="{{ refDiscover }}" onClick="{{ goDiscover }}" aria-label="Discover" style="{{ navDiscover }}"><svg viewBox="0 0 24 24" width="21" height="21" style="flex:none;fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><circle cx="12" cy="12" r="10"></circle><path d="m16.24 7.76-1.804 5.411a2 2 0 0 1-1.265 1.265L7.76 16.24l1.804-5.411a2 2 0 0 1 1.265-1.265z"></path></svg><sc-if value="{{ isDiscover }}" hint-placeholder-val="{{ true }}"><span data-label="1">Discover</span></sc-if></button>
<button ref="{{ refQuick }}" onClick="{{ goQuick }}" aria-label="Quick" style="{{ navQuick }}"><svg viewBox="0 0 24 24" width="21" height="21" style="flex:none;fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.020A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z"></path></svg><sc-if value="{{ isQuick }}" hint-placeholder-val="{{ false }}"><span data-label="1">Quick</span></sc-if></button>
<button ref="{{ refSettings }}" onClick="{{ goSettings }}" aria-label="Settings" style="{{ navSettings }}"><svg viewBox="0 0 24 24" width="21" height="21" style="flex:none;fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M10 5H3"></path><path d="M12 19H3"></path><path d="M14 3v4"></path><path d="M16 17v4"></path><path d="M21 12h-9"></path><path d="M21 19h-5"></path><path d="M21 5h-7"></path><path d="M8 10v4"></path><path d="M8 12H3"></path></svg><sc-if value="{{ isSettings }}" hint-placeholder-val="{{ false }}"><span data-label="1">Settings</span></sc-if></button>
</div>
</div>
<div style="max-width:420px;display:flex;flex-direction:column;gap:18px">
<div style="font-family:var(--font-heading);font-size:34px;line-height:1.1">MealQuest motion reference</div>
<div style="font-size:14px;line-height:1.55;color:#474238">Live reference for the three signature animations. Tap the buttons, drag the card, or switch tabs. Exact keyframes are in the handoff README.</div>
<div style="padding:18px 20px;border-radius:26px;background:#f9f6f1;display:flex;flex-direction:column;gap:6px"><div style="font-family:var(--font-heading);font-size:18px">Nav pill slide</div><div style="font-size:13px;line-height:1.5;color:#474238">Pill animates left + width to the tapped item, 420 ms, cubic-bezier(.34,1.4,.64,1). Other icons FLIP to their new spots on the same curve; label fades in after 120 ms.</div></div>
<div style="padding:18px 20px;border-radius:26px;background:#f9f6f1;display:flex;flex-direction:column;gap:6px"><div style="font-family:var(--font-heading);font-size:18px">Try it</div><div style="font-size:13px;line-height:1.5;color:#474238">Stamp pops in. Card wiggles (±4°, 420 ms), pulls back slightly, then flies off right with a 28° tilt (480 ms, ease-in). Quest log pill bumps.</div></div>
<div style="padding:18px 20px;border-radius:26px;background:#f9f6f1;display:flex;flex-direction:column;gap:6px"><div style="font-family:var(--font-heading);font-size:18px">Pass</div><div style="font-size:13px;line-height:1.5;color:#474238">Card folds over on its left edge (rotateY to −80°, shading darkens), then drops and tumbles off left (720 ms total).</div></div>
<div style="display:flex;align-items:center;gap:10px;font-size:13px;font-weight:700;color:#474238"><button onClick="{{ toggleReduced }}" style="border:1.5px solid rgba(32,30,29,.16);background:none;border-radius:999px;padding:8px 14px;cursor:pointer;font-weight:700">Reduced motion: {{ reducedLabel }}</button><span>{{ lastAction }}</span></div>
</div>
</div>
</x-dc>
<script type="text/x-dc" data-dc-script>
const SPRING = 'cubic-bezier(.34,1.4,.64,1)';
const DEPTH_TF = ['translateY(0px) scale(1)', 'translateY(8px) scale(.95)', 'translateY(16px) scale(.9)'];
const DECK = [
{ name: 'Korean beef bowls', level: 1, diff: 'Easy', why: 'You make Chicken stir-fry and Beef tacos most weeks. Same flavors, sweet-savory twist.' },
{ name: 'Crock pot chicken tikka masala', level: 2, diff: 'Medium', why: 'Butter chicken night is a household favorite. This one cooks itself while you work.' },
{ name: 'Lemon garlic shrimp orzo', level: 2, diff: 'Medium', why: 'Your Shrimp scampi is on repeat. Orzo makes it a one-pot night.' },
{ name: 'Turkey chili', level: 1, diff: 'Easy', why: 'A lighter take on the chili you make every fall.' },
{ name: 'Thai basil chicken', level: 2, diff: 'Medium', why: 'Close to your stir-fry, with a fresh herby kick.' }
];
class Component extends DCLogic {
state = { idx: 0, tab: 'Discover', quest: 6, reduced: false, last: '' };
busy = false; cardEls = []; nav = {}; drag = null;
constructor(p) {
super(p);
this.cardRefs = [0, 1, 2].map(d => el => { this.cardEls[d] = el; });
['Home', 'Plan', 'Discover', 'Quick', 'Settings'].forEach(t => {
this['ref' + t] = el => { this.nav[t] = el; };
this['go' + t] = () => this.selectTab(t);
});
this.refPill = el => { this.pill = el; };
this.refCount = el => { this.countEl = el; };
this.refTryBtn = el => { this.tryBtn = el; };
this.refPassBtn = el => { this.passBtn = el; };
}
componentDidMount() {
this.state.reduced = window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches;
this.forceUpdate();
const place = () => { const el = this.nav[this.state.tab]; if (el && this.pill) { this.pill.style.left = el.offsetLeft + 'px'; this.pill.style.width = el.offsetWidth + 'px'; } };
requestAnimationFrame(place);
document.fonts && document.fonts.ready.then(place);
}
selectTab(t) {
if (t === this.state.tab) return;
const before = {}; Object.keys(this.nav).forEach(k => before[k] = this.nav[k].getBoundingClientRect().left);
const from = { left: this.pill.offsetLeft, width: this.pill.offsetWidth };
this.setState({ tab: t }, () => {
const el = this.nav[t]; const to = { left: el.offsetLeft, width: el.offsetWidth };
this.pill.style.left = to.left + 'px'; this.pill.style.width = to.width + 'px';
if (this.state.reduced) return;
this.pill.animate([{ left: from.left + 'px', width: from.width + 'px' }, { left: to.left + 'px', width: to.width + 'px' }], { duration: 420, easing: SPRING });
Object.keys(this.nav).forEach(k => {
const dx = before[k] - this.nav[k].getBoundingClientRect().left;
if (Math.abs(dx) > 0.5) this.nav[k].animate([{ transform: `translateX(${dx}px)` }, { transform: 'translateX(0)' }], { duration: 420, easing: SPRING });
});
const lab = el.querySelector('[data-label]');
if (lab) lab.animate([{ opacity: 0, transform: 'translateX(-4px)' }, { opacity: 1, transform: 'none' }], { duration: 220, delay: 120, easing: 'ease-out', fill: 'backwards' });
});
}
top() { return this.cardEls[0]; }
bump(el) { el && el.animate([{ transform: 'scale(1)' }, { transform: 'scale(.88)' }, { transform: 'scale(1.06)' }, { transform: 'scale(1)' }], { duration: 300, easing: 'ease-out' }); }
tryIt = (fromDrag) => {
if (this.busy && fromDrag !== true) return; this.busy = true;
const el = this.top(); const stamp = el.querySelector('[data-stamp]');
if (fromDrag !== true) this.bump(this.tryBtn);
if (this.state.reduced) return el.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 200, fill: 'forwards' }).finished.then(() => this.advance(true));
const cur = el.style.transform || 'translate(0px,0px) rotate(0deg)';
stamp.animate([{ opacity: 0, transform: 'rotate(-12deg) scale(1.5)' }, { opacity: 1, transform: 'rotate(-12deg) scale(1)' }], { duration: 220, easing: SPRING, fill: 'forwards' });
const wiggle = fromDrag === true ? Promise.resolve() : el.animate([
{ transform: 'rotate(0deg)' }, { transform: 'rotate(-4deg)' }, { transform: 'rotate(4deg)' }, { transform: 'rotate(-3deg)' }, { transform: 'rotate(2deg)' }, { transform: 'rotate(0deg)' }
], { duration: 420, easing: 'ease-in-out' }).finished;
wiggle.then(() => el.animate(
fromDrag === true
? [{ transform: cur }, { transform: 'translate(150%,-60px) rotate(28deg)', opacity: 0 }]
: [{ transform: 'translate(0px,0px) rotate(0deg)' }, { transform: 'translate(-14px,4px) rotate(-3deg)', offset: 0.18 }, { transform: 'translate(150%,-60px) rotate(28deg)', opacity: 0 }],
{ duration: fromDrag === true ? 340 : 480, easing: 'cubic-bezier(.55,0,.8,.4)', fill: 'forwards' }
).finished).then(() => this.advance(true));
};
pass = (fromDrag) => {
if (this.busy && fromDrag !== true) return; this.busy = true;
const el = this.top(); const shade = el.querySelector('[data-shade]');
if (fromDrag !== true) this.bump(this.passBtn);
if (this.state.reduced) return el.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 200, fill: 'forwards' }).finished.then(() => this.advance(false));
const cur = el.style.transform || 'translate(0px,0px) rotate(0deg)';
el.style.transformOrigin = '0% 50%';
shade.animate([{ opacity: 0 }, { opacity: 0.4, offset: 0.45 }, { opacity: 0.55 }], { duration: 720, fill: 'forwards' });
el.animate([
{ transform: `${cur} rotateY(0deg)`, easing: 'cubic-bezier(.45,0,.85,.55)' },
{ transform: `${cur} rotateY(-80deg)`, offset: 0.45, easing: 'cubic-bezier(.4,0,.9,.6)' },
{ transform: 'translate(-45%,560px) rotate(-35deg) rotateY(-90deg)', opacity: 0 }
], { duration: 720, fill: 'forwards' }).finished.then(() => this.advance(false));
};
advance(accepted) {
this.cardEls.forEach(el => { if (!el) return; el.getAnimations({ subtree: true }).forEach(a => a.cancel()); el.style.transform = ''; el.style.transformOrigin = ''; });
this.setState(s => ({ idx: s.idx + 1, quest: s.quest + (accepted ? 1 : 0), last: accepted ? 'Added to Quest log' : 'Passed' }), () => {
if (accepted) this.bump(this.countEl);
if (!this.state.reduced) [0, 1].forEach(d => { const el = this.cardEls[d]; el && el.animate([{ transform: DEPTH_TF[d + 1] }, { transform: DEPTH_TF[d] }], { duration: 300, easing: 'cubic-bezier(.2,.8,.2,1)' }); });
this.busy = false;
});
}
onDown = e => {
if (this.busy) return;
const el = this.top(); el.setPointerCapture(e.pointerId);
this.drag = { x: e.clientX, y: e.clientY, dx: 0 };
};
onMove = e => {
if (!this.drag) return;
const el = this.top(); const dx = e.clientX - this.drag.x, dy = e.clientY - this.drag.y; this.drag.dx = dx;
el.style.transform = `translate(${dx}px,${dy * 0.3}px) rotate(${dx / 18}deg)`;
el.querySelector('[data-stamp]').style.opacity = Math.max(0, Math.min(1, dx / 100));
el.querySelector('[data-pass]').style.opacity = Math.max(0, Math.min(1, -dx / 100));
};
onUp = () => {
if (!this.drag) return;
const el = this.top(); const dx = this.drag.dx; this.drag = null;
const st = el.querySelector('[data-stamp]'), ps = el.querySelector('[data-pass]');
if (dx > 110) { this.busy = true; ps.style.opacity = 0; return this.tryIt(true); }
if (dx < -110) { this.busy = true; st.style.opacity = 0; return this.pass(true); }
const cur = el.style.transform; el.style.transform = '';
st.style.opacity = 0; ps.style.opacity = 0;
if (cur) el.animate([{ transform: cur }, { transform: 'translate(0px,0px) rotate(0deg)' }], { duration: 420, easing: SPRING });
};
navStyle(t) {
const on = this.state.tab === t;
return { position: 'relative', zIndex: 1, display: 'flex', alignItems: 'center', gap: 6, height: 42, padding: '0 12px', border: 0, background: 'none', borderRadius: 999, cursor: 'pointer', fontSize: 13, fontWeight: 700, whiteSpace: 'nowrap', color: on ? 'var(--on-accent)' : '#c0b6a5', transition: 'color .2s' };
}
renderVals() {
const s = this.state;
const dot = on => ({ width: 8, height: 8, borderRadius: '50%', background: on ? 'var(--color-accent)' : '#dcd3c4' });
const cards = [2, 1, 0].map(d => {
const c = DECK[(s.idx + d) % DECK.length];
return {
...c, ref: this.cardRefs[d],
d1: dot(c.level >= 1), d2: dot(c.level >= 2), d3: dot(c.level >= 3),
onDown: d === 0 ? this.onDown : undefined, onMove: d === 0 ? this.onMove : undefined, onUp: d === 0 ? this.onUp : undefined,
style: { position: 'absolute', inset: 0, borderRadius: 32, background: d === 0 ? '#f9f4ed' : d === 1 ? 'var(--color-surface)' : '#dcd3c4', boxShadow: d === 0 ? 'var(--shadow-lg)' : 'none', overflow: 'hidden', transform: DEPTH_TF[d], zIndex: 3 - d, touchAction: 'none', userSelect: 'none', cursor: d === 0 ? 'grab' : 'default', willChange: 'transform' }
};
});
const v = {
cards, questCount: s.quest, tab: s.tab, onDiscover: s.tab === 'Discover', offDiscover: s.tab !== 'Discover',
pass: this.pass, tryIt: this.tryIt, refPill: this.refPill, refCount: this.refCount, refTryBtn: this.refTryBtn, refPassBtn: this.refPassBtn,
toggleReduced: () => this.setState(x => ({ reduced: !x.reduced })), reducedLabel: s.reduced ? 'on' : 'off', lastAction: s.last
};
['Home', 'Plan', 'Discover', 'Quick', 'Settings'].forEach(t => {
v['ref' + t] = this['ref' + t]; v['go' + t] = this['go' + t]; v['nav' + t] = this.navStyle(t); v['is' + t] = s.tab === t;
});
return v;
}
}
</script>
</body>
</html>
@@ -0,0 +1,152 @@
{
"plugins": [
"react",
"import"
],
"rules": {
"react/forbid-elements": [
"warn",
{
"forbid": []
}
],
"no-restricted-imports": [
"warn",
{
"patterns": []
}
],
"no-restricted-syntax": [
"warn",
{
"selector": "Literal[value=/#[0-9a-fA-F]{3,8}\\b/]",
"message": "Raw hex color — use a design-system color token via var()."
},
{
"selector": "Literal[value=/\\b\\d+px\\b/]",
"message": "Raw px value — use a design-system spacing token via var()."
},
{
"selector": "Literal[value=/font-family\\s*:\\s*(?!['\\\"]?(?:Caprasimo|Figtree))/i]",
"message": "Font not provided by the design system. Available: Caprasimo, Figtree."
}
]
},
"overrides": [
{
"files": [
"**/index.js"
],
"rules": {
"no-restricted-imports": "off"
}
}
],
"x-omelette": {
"components": {},
"tokens": [
"--color-accent",
"--color-accent-100",
"--color-accent-2",
"--color-accent-2-100",
"--color-accent-2-200",
"--color-accent-2-300",
"--color-accent-2-400",
"--color-accent-2-500",
"--color-accent-2-600",
"--color-accent-2-700",
"--color-accent-2-800",
"--color-accent-2-900",
"--color-accent-200",
"--color-accent-300",
"--color-accent-400",
"--color-accent-500",
"--color-accent-600",
"--color-accent-700",
"--color-accent-800",
"--color-accent-900",
"--color-bg",
"--color-divider",
"--color-neutral-100",
"--color-neutral-200",
"--color-neutral-300",
"--color-neutral-400",
"--color-neutral-500",
"--color-neutral-600",
"--color-neutral-700",
"--color-neutral-800",
"--color-neutral-900",
"--color-surface",
"--color-text",
"--font-body",
"--font-heading",
"--font-heading-weight",
"--radius-lg",
"--radius-md",
"--radius-sm",
"--shadow-lg",
"--shadow-md",
"--shadow-sm",
"--space-1",
"--space-2",
"--space-3",
"--space-4",
"--space-6",
"--space-8"
],
"tokenKinds": {
"--color-bg": "color",
"--color-surface": "color",
"--color-text": "font",
"--color-accent": "color",
"--color-accent-2": "color",
"--color-divider": "color",
"--color-neutral-100": "color",
"--color-neutral-200": "color",
"--color-neutral-300": "color",
"--color-neutral-400": "color",
"--color-neutral-500": "color",
"--color-neutral-600": "color",
"--color-neutral-700": "color",
"--color-neutral-800": "color",
"--color-neutral-900": "color",
"--color-accent-100": "color",
"--color-accent-200": "color",
"--color-accent-300": "color",
"--color-accent-400": "color",
"--color-accent-500": "color",
"--color-accent-600": "color",
"--color-accent-700": "color",
"--color-accent-800": "color",
"--color-accent-900": "color",
"--color-accent-2-100": "color",
"--color-accent-2-200": "color",
"--color-accent-2-300": "color",
"--color-accent-2-400": "color",
"--color-accent-2-500": "color",
"--color-accent-2-600": "color",
"--color-accent-2-700": "color",
"--color-accent-2-800": "color",
"--color-accent-2-900": "color",
"--font-heading": "font",
"--font-heading-weight": "font",
"--font-body": "font",
"--space-1": "spacing",
"--space-2": "spacing",
"--space-3": "spacing",
"--space-4": "spacing",
"--space-6": "spacing",
"--space-8": "spacing",
"--radius-sm": "radius",
"--radius-md": "radius",
"--radius-lg": "radius",
"--shadow-sm": "shadow",
"--shadow-md": "shadow",
"--shadow-lg": "shadow"
},
"fontFamilies": [
"Caprasimo",
"Figtree"
]
}
}
@@ -0,0 +1,11 @@
/* @ds-bundle: {"format":4,"namespace":"Organic_organi","components":[],"sourceHashes":{},"inlinedExternals":[],"unexposedExports":[]} */
(() => {
const __ds_ns = (window.Organic_organi = window.Organic_organi || {});
const __ds_scope = {};
(__ds_ns.__errors = __ds_ns.__errors || []);
})();
File diff suppressed because one or more lines are too long
@@ -0,0 +1,81 @@
# Organic design system
Organic is warm, rounded and a little playful: a cream-and-sand ground with a terracotta accent and a sage second accent, Caprasimo display headings over Figtree, 16px radii that grow into pills and soft circular shapes. Photographs are washed so they sit back into the warm page instead of on top of it.
## How to use this
- Link the one stylesheet from every page — `<link rel="stylesheet" href="styles.css">` (adjust the relative path) — and take every color, font, spacing, radius and shadow from its variables (`var(--color-*)`, `var(--font-*)`, `var(--space-*)`, `var(--radius-*)`, `var(--shadow-*)`). Never hard-code a hex, a font name or a px value the tokens already carry.
- Build with the classes below rather than inventing parallel ones; the component pages are plain HTML, so view source and copy the markup.
- `templates/` holds starting points a consuming project can copy whole.
- The whole system was derived from `theme.json`. To change the look, edit the tokens at the top of `styles.css` — every page, the thumbnail and this guide read from them — and keep `theme.json` and the written guidance in step so they don't drift from what the CSS actually does.
## Direction
Left-aligned, asymmetric layouts. Flush-left headings; content hugs the left edge with whitespace on the right. Lean into round shapes — over-rounded containers, pill buttons (`border-radius: 999px`), soft circular accents. Wrap hero and inline images in the `.washed` class — desaturated, lower-contrast and lifted, so they sit back into the page rather than on top of it.
## Color
A light ground (`--color-bg` #f5ead8) with `--color-text` #201e1d and two accents — `--color-accent` #c67139 and `--color-accent-2` #7a8a5e. Each role carries a 100–900 tonal ramp (`--color-neutral-100` … `--color-accent-2-900`) generated in OKLCH on a shared perceptual lightness scale, so the same step of any ramp has the same visual weight. Use the light steps (100–300) for tinted fills, hovers and subtle borders, 500 as the role's base, and the dark steps (700–900) for text on tinted fills and for pressed states; prefer ramp steps over ad-hoc `color-mix()`. For elevation use `--shadow-sm/md/lg` (already tuned to the ground) rather than ad-hoc box-shadows.
## Type
Caprasimo for headings over Figtree for body text, loaded as `--font-heading` / `--font-body`. Density 1.10× and radius 16px are already baked into the `--space-*` / `--radius-*` scales — use the variables, not raw numbers.
## Icons
Use Lucide icons (https://lucide.dev), at stroke-width 2.75 for a rounder, heavier look throughout.
## Interaction states
Interactive states are themed, never browser defaults: give every interactive element a `:hover` tint and a pressed state from the accent ramp (one step past the base — `--color-accent-600` on a light ground, `--color-accent-400` on a dark one, or a `color-mix()` tint for outlined/ghost variants), and style keyboard focus with `:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }` — never leave the default blue focus ring.
## Components
| Class | What it is | Shown in |
| --- | --- | --- |
| `.btn` with `.btn-primary`, `.btn-secondary`, `.btn-ghost`, `.btn-icon`, `.btn-block` | Actions — the primary is a solid accent fill | components/buttons.html |
| `.tag` with `.tag-accent`, `.tag-accent-2`, `.tag-neutral`, `.tag-outline` | Small labels tinted from the ramps | components/buttons.html |
| `.field` + `label`, `.input`, `.radio` + `.dot`, `.seg` + `.seg-opt` | Form fields and choices on native elements — no script | components/forms.html |
| `.card` with `.card-kicker`, `.card-title`, `.card-body`, `.card-meta`; `.elev-sm/md/lg` | Surface-filled content cards; elevation utilities | components/cards.html |
| `.nav` + `.nav-brand` | The header bar | components/navigation.html |
| `.table` | Data tables with themed header and row rules | components/table.html |
| `.dialog-backdrop` + `.dialog` (+ `.dialog-title/-body/-actions`) | A modal at the top elevation | components/dialog.html |
| `.hr` | A horizontal rule — present, but this system prefers whitespace; avoid it | — |
| `.washed` | The image wrapper — every content photograph goes through it | foundations/image.html |
States are built in: hovers and pressed states come from the accent ramp, keyboard focus is the 2px accent `:focus-visible` ring, `::selection` is an accent tint, and disabled controls drop to 45% opacity. Don't restyle them per page. The accent-to-ground pair is tuned to at least 3:1 — enough for icons, large text and interface chrome, not for body copy — so for paragraph-size text in the accent use a deep ramp step (`--color-accent-700` on this ground) rather than the accent itself.
## Do
- Over-round: `--radius-lg` for containers, `border-radius: 999px` for buttons and inputs.
- Use soft shapes — circles and blobs — as decoration and imagery masks.
- Reach for the sage `--color-accent-2-*` ramp as a genuine second voice, not just a highlight.
- Wash photography with the `.washed` wrapper and keep its edges rounded.
## Don't
- Do not draw sharp corners or hairline-only geometry.
- Do not desaturate the palette into greys — warmth is the point.
- Do not use condensed or geometric display faces; Caprasimo is the only display voice.
- Do not crowd elements; the rounded shapes need air to read as soft.
## Files
- `styles.css` — the only stylesheet: the token sheet (`:root` variables, ramps, base type) plus the component layer. Link it from every page.
- `readme.md` — this guide.
- `theme.json` — the parameters these files were derived from (a machine-readable record of the theme).
- `thumbnail.html` — the project cover (brand mark + swatches).
- `foundations/type.html` — the type scale and the heading/body pairing at real sizes.
- `foundations/color.html` — color roles and the 100-900 tonal ramps, with usage notes.
- `foundations/layout.html` — the spacing scale, the grid and how edges are drawn.
- `foundations/icons.html` — the icon set at interface sizes, inline and in buttons.
- `foundations/image.html` — how photographs and figures are treated.
- `components/buttons.html` — buttons, icon buttons and tags in every variant and state.
- `components/forms.html` — text fields, radios and the segmented control on native elements.
- `components/cards.html` — content cards and the elevation steps.
- `components/navigation.html` — the header bar pattern.
- `components/table.html` — a data table with the themed header and row rules.
- `components/dialog.html` — a modal over its backdrop at the top elevation.
- `theme.html` — the theme's parameters rendered as a reference sheet.
- `templates/landing/` — a starter page consuming the system the intended way (`index.html`, its `ds-base.js` loader, and the vendored `image-slot.js` its photograph mounts).
- `assets/photo.jpg` — the reference photograph the imagery page treats.
@@ -0,0 +1,257 @@
/* Organic — design-system tokens and component classes. This file is the source of truth for the system's look; retune it here and see readme.md. */
@import url('https://fonts.googleapis.com/css2?family=Caprasimo:wght@400&family=Figtree:wght@400;600;700&display=swap');
:root {
--color-bg: #f5ead8;
--color-surface: #ebddc5;
--color-text: #201e1d;
--color-accent: #c67139;
--color-accent-2: #7a8a5e;
--color-divider: color-mix(in srgb, #201e1d 16%, transparent);
/* Tonal ramps — generated in OKLCH on one shared lightness scale, so the
same step of any role matches the others in visual value. */
--color-neutral-100: #f9f4ed;
--color-neutral-200: #eee7db;
--color-neutral-300: #dcd3c4;
--color-neutral-400: #c0b6a5;
--color-neutral-500: #a19786;
--color-neutral-600: #82796a;
--color-neutral-700: #645c50;
--color-neutral-800: #474238;
--color-neutral-900: #2e2b25;
--color-accent-100: #fff2eb;
--color-accent-200: #ffe1d0;
--color-accent-300: #ffc6a5;
--color-accent-400: #f6a06b;
--color-accent-500: #d67f48;
--color-accent-600: #b2622d;
--color-accent-700: #8c491a;
--color-accent-800: #643312;
--color-accent-900: #402310;
--color-accent-2-100: #f0fae1;
--color-accent-2-200: #e1eecc;
--color-accent-2-300: #ccdbb2;
--color-accent-2-400: #aebf92;
--color-accent-2-500: #8fa073;
--color-accent-2-600: #728157;
--color-accent-2-700: #56633f;
--color-accent-2-800: #3d472b;
--color-accent-2-900: #272e1b;
--font-heading: "Caprasimo", system-ui, sans-serif;
--font-heading-weight: 400;
--font-body: "Figtree", system-ui, sans-serif;
--space-1: 4.4px;
--space-2: 8.8px;
--space-3: 13.2px;
--space-4: 17.6px;
--space-6: 26.4px;
--space-8: 35.2px;
--radius-sm: 8px;
--radius-md: 16px;
--radius-lg: 28px;
/* Elevation — derived from the ground: soft ink-tinted shadows on a
light theme, a hairline edge + ambient darkness on a dark one. */
--shadow-sm: 0 1px 2px color-mix(in srgb, #2e2b25 14%, transparent);
--shadow-md: 0 3px 10px color-mix(in srgb, #2e2b25 16%, transparent);
--shadow-lg: 0 12px 32px color-mix(in srgb, #2e2b25 22%, transparent);
}
body {
background: var(--color-bg);
color: var(--color-text);
font-family: var(--font-body);
}
h1, h2, h3, h4 { font-family: var(--font-heading); font-weight: var(--font-heading-weight); }
.washed{filter:saturate(0.6) contrast(0.85) brightness(1.1) opacity(0.94)}
/* ══════════════════════════════════════════════════════════════════════════
Components — built with the tokens above. Plain CSS
on plain HTML: no JavaScript, no build step. Each class is documented in
readme.md and demonstrated in foundations/ and components/.
══════════════════════════════════════════════════════════════════════ */
*, *::before, *::after { box-sizing: border-box; }
body { margin: 0; font-size: 15px; line-height: 1.55; font-weight: 400; }
h1, h2, h3, h4, h5, h6 {
font-family: var(--font-heading); font-weight: var(--font-heading-weight);
line-height: 1.12; letter-spacing: -0.015em; margin: 0 0 var(--space-2);
}
h1 { font-size: 42px; }
h2 { font-size: 32px; }
h3 { font-size: 25px; }
h4 { font-size: 20px; }
h5 { font-size: 16px; }
h6 { font-size: 13px; }
h6 { letter-spacing: 0.08em; text-transform: uppercase; }
p { margin: 0 0 var(--space-3); }
a { color: var(--color-accent); text-underline-offset: 3px; }
img { display: block; max-width: 100%; }
figure { margin: 0; }
figcaption {
font-size: 11px; margin-top: var(--space-1);
color: color-mix(in srgb, var(--color-text) 55%, transparent);
}
.text-muted { color: color-mix(in srgb, var(--color-text) 55%, transparent); }
:focus { outline: none; }
:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }
::selection { background: color-mix(in srgb, var(--color-accent) 30%, transparent); }
/* — rules — */
.hr {
height: 1px; border: 0; margin: var(--space-4) 0;
background: var(--color-divider);
}
/* — buttons — */
.btn {
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
cursor: pointer; text-decoration: none;
font-family: var(--font-heading); font-weight: var(--font-heading-weight);
font-size: 14px; line-height: 1.2; color: var(--color-text); /* matches the .input's 14px —
the pair sits side by side in sign-up rows */
background: transparent; border: 1px solid transparent;
padding: var(--space-2) calc(var(--space-3) * 1.2);
border-radius: var(--radius-md);
}
.btn svg { display: block; }
.btn:disabled { opacity: 0.45; cursor: not-allowed; }
.btn-primary { background: var(--color-accent); color: var(--color-bg); }
.btn-primary:hover { background: var(--color-accent-600); }
.btn-primary:active { background: var(--color-accent-700); }
.btn-secondary { border-color: var(--color-divider); }
.btn-secondary:hover { background: color-mix(in srgb, var(--color-text) 7%, transparent); }
.btn-secondary:active { background: color-mix(in srgb, var(--color-text) 14%, transparent); }
.btn-ghost { color: var(--color-accent); padding-inline: var(--space-1); }
.btn-ghost:hover { background: color-mix(in srgb, var(--color-accent) 10%, transparent); }
.btn-ghost:active { background: color-mix(in srgb, var(--color-accent) 18%, transparent); }
.btn-icon { width: 36px; height: 36px; padding: 0; }
.btn-block { width: 100%; margin-top: var(--space-2); }
/* — forms — */
.field > label {
display: block; font-size: 12px; margin-bottom: 5px;
color: color-mix(in srgb, var(--color-text) 70%, transparent);
}
.input {
width: 100%; min-height: 36px; padding: 6px 10px; font: inherit;
font-size: 14px; color: var(--color-text); caret-color: var(--color-accent);
background: var(--color-surface);
border: 1px solid var(--color-divider); border-radius: var(--radius-md);
}
.input:hover { border-color: color-mix(in srgb, var(--color-text) 45%, transparent); }
.input:focus-visible { border-color: var(--color-accent); outline-offset: 0; }
textarea.input { min-height: 90px; resize: vertical; }
.radio { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; font-size: 14px; }
.radio input, .seg-opt input {
position: absolute; opacity: 0; width: 0; height: 0; pointer-events: none;
}
.radio .dot {
width: 16px; height: 16px; flex: none; border-radius: 50%;
border: 1.5px solid var(--color-divider);
}
.radio:hover .dot { border-color: var(--color-accent); }
.radio input:checked + .dot {
border-color: var(--color-accent); background: var(--color-accent);
box-shadow: inset 0 0 0 4px var(--color-bg);
}
.radio input:focus-visible + .dot { outline: 2px solid var(--color-accent); outline-offset: 2px; }
.seg {
display: inline-flex; overflow: hidden;
border: 1px solid var(--color-divider); border-radius: var(--radius-md);
}
.seg-opt {
display: inline-flex; align-items: center; gap: 6px;
padding: 7px 12px; font-size: 13px; cursor: pointer;
}
.seg-opt + .seg-opt { border-left: 1px solid var(--color-divider); }
.seg-opt:has(input:checked) { background: var(--color-accent); color: var(--color-bg); }
.seg-opt:not(:has(input:checked)):hover { background: color-mix(in srgb, var(--color-text) 7%, transparent); }
.seg-opt:has(input:focus-visible) { outline: 2px solid var(--color-accent); outline-offset: -2px; }
/* — cards — */
.card {
display: flex; flex-direction: column; gap: var(--space-2);
padding: var(--space-3); border-radius: var(--radius-md); background: var(--color-surface);
}
.card-kicker { font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--color-accent); }
.card-title {
font-family: var(--font-heading); font-weight: var(--font-heading-weight);
font-size: 17px; line-height: 1.2;
}
.card-body { margin: 0; font-size: 13px; opacity: 0.8; flex: 1; }
.card-meta {
display: flex; align-items: center; gap: 6px; font-size: 11px;
color: color-mix(in srgb, var(--color-text) 50%, transparent);
}
.elev-sm { box-shadow: var(--shadow-sm); }
.elev-md { box-shadow: var(--shadow-md); }
.elev-lg { box-shadow: var(--shadow-lg); }
/* — tags — */
.tag {
display: inline-flex; align-items: center; font-size: 11px;
letter-spacing: 0.02em; padding: 3px 10px;
border-radius: calc(var(--radius-md) * 0.75);
}
.tag-accent { background: var(--color-accent-100); color: var(--color-accent-800); }
.tag-accent-2 { background: var(--color-accent-2-100); color: var(--color-accent-2-800); }
.tag-neutral { background: var(--color-neutral-100); color: var(--color-neutral-800); }
.tag-outline { border: 1px solid var(--color-accent); color: var(--color-accent); }
/* — navigation — */
.nav {
display: flex; align-items: center; gap: var(--space-4);
padding: var(--space-3) var(--space-4);
border-bottom: none;
}
.nav-brand {
font-family: var(--font-heading); font-weight: var(--font-heading-weight);
font-size: 18px; margin-right: auto;
}
.nav a { color: inherit; text-decoration: none; font-size: 14px; }
.nav a:hover, .nav a[aria-current='page'] { color: var(--color-accent); }
/* — tables — */
.table { width: 100%; border-collapse: collapse; font-size: 14px; }
.table th {
text-align: left; font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase;
color: color-mix(in srgb, var(--color-text) 60%, transparent);
padding: var(--space-2); border-bottom: 1px solid var(--color-divider);
}
.table td {
padding: var(--space-2);
border-bottom: 1px solid color-mix(in srgb, var(--color-text) 8%, transparent);
}
.table tbody tr:hover { background: color-mix(in srgb, var(--color-text) 4%, transparent); }
/* — dialog — */
.dialog-backdrop {
position: fixed; inset: 0; display: grid; place-items: center;
padding: var(--space-4);
background: color-mix(in srgb, var(--color-neutral-900) 50%, transparent);
}
.dialog {
width: min(440px, 100%); display: flex; flex-direction: column; gap: var(--space-3);
padding: var(--space-4); border-radius: var(--radius-lg);
background: var(--color-surface); box-shadow: var(--shadow-lg);
}
.dialog-title {
font-family: var(--font-heading); font-weight: var(--font-heading-weight);
font-size: 20px;
}
.dialog-body { font-size: 14px; opacity: 0.85; }
.dialog-actions { display: flex; justify-content: flex-end; gap: var(--space-2); margin-top: var(--space-2); }
/* — rounded frame: everything softens, small controls go pill — */
.card, .dialog { border-radius: calc(var(--radius-lg) * 1.15); }
.btn, .tag, .seg, .input { border-radius: 999px; }
.input { padding-inline: 14px; }
File diff suppressed because one or more lines are too long
@@ -0,0 +1,206 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<helmet>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Bagel+Fat+One&display=swap">
<link rel="stylesheet" href="_ds/organic-cd54d318-425e-4a60-adfb-d590f70d40bd/styles.css">
<style>body{margin:0;background:#e9e4dc}a{color:#68449b}a:hover{color:#4a2d73}button{font:inherit}button:focus-visible{outline:2px solid #5a358c;outline-offset:2px}</style>
</helmet>
<div style="--color-bg:#f9f6f1;--color-surface:#eee9de;--color-accent:#5a358c;--color-accent-100:#f8f1ff;--color-accent-200:#eedfff;--color-accent-300:#dac3ff;--color-accent-700:#68449b;--color-accent-800:#4a2d73;--color-accent-2:#29889c;--color-accent-2-100:#e0fbff;--color-accent-2-200:#bbf3ff;--color-accent-2-600:#008aaa;--color-accent-2-700:#006b88;--color-accent-2-800:#004c63;--color-accent-2-900:#002f3e;--on-accent:#f9f6f1;--font-heading:'Bagel Fat One',system-ui,sans-serif;min-height:100vh;padding:48px;display:flex;gap:48px;flex-wrap:wrap;align-items:flex-start;font-family:var(--font-body);color:var(--color-text)">
<div style="width:360px;height:760px;position:relative;border-radius:36px;background:var(--color-bg);overflow:hidden;box-shadow:var(--shadow-lg);flex:none">
<div style="height:40px;display:flex;align-items:center;justify-content:space-between;padding:0 26px;font-size:13px;font-weight:700"><span>9:41</span><span style="width:22px;height:10px;border-radius:3px;background:var(--color-text)"></span></div>
<sc-if value="{{ onDiscover }}" hint-placeholder-val="{{ true }}">
<div style="padding:8px 20px 0;display:flex;align-items:flex-end;justify-content:space-between">
<div><div style="font-size:11px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:var(--color-accent-700)">Discover</div><div style="font-family:var(--font-heading);font-size:30px;line-height:1.1">Side quests</div></div>
<div ref="{{ refCount }}" style="padding:7px 12px;border-radius:999px;background:var(--color-accent-2-200);color:var(--color-accent-2-800);font-size:12px;font-weight:700">Quest log · {{ questCount }}</div>
</div>
<div style="position:relative;margin:18px 20px 0;height:440px;perspective:1000px">
<sc-for list="{{ cards }}" as="card" hint-placeholder-count="3">
<div ref="{{ card.ref }}" onPointerDown="{{ card.onDown }}" onPointerMove="{{ card.onMove }}" onPointerUp="{{ card.onUp }}" onPointerCancel="{{ card.onUp }}" style="{{ card.style }}">
<div style="height:220px;margin:10px;border-radius:24px;background:repeating-linear-gradient(135deg,#dcd3c4 0 8px,#eee7db 8px 16px);display:flex;align-items:center;justify-content:center;font:11px ui-monospace,Menlo,monospace;color:#645c50">dish photo</div>
<div style="padding:4px 20px 0;display:flex;flex-direction:column;gap:10px">
<div style="font-family:var(--font-heading);font-size:24px;line-height:1.1">{{ card.name }}</div>
<div style="display:flex;align-items:center;gap:8px;font-size:12px;font-weight:700;color:#645c50"><span style="display:flex;gap:3px"><span style="{{ card.d1 }}"></span><span style="{{ card.d2 }}"></span><span style="{{ card.d3 }}"></span></span>{{ card.diff }}</div>
<div style="padding:12px 14px;border-radius:18px;background:var(--color-accent-2-100);color:var(--color-accent-2-900);font-size:13px;line-height:1.45"><b>Why this?</b> {{ card.why }}</div>
</div>
<div data-stamp="1" style="position:absolute;top:26px;left:22px;transform:rotate(-12deg);padding:6px 14px;border:3px solid var(--color-accent-2-700);border-radius:14px;font-family:var(--font-heading);font-size:24px;color:var(--color-accent-2-800);background:var(--color-accent-2-100);opacity:0;pointer-events:none">Try it!</div>
<div data-pass="1" style="position:absolute;top:26px;right:22px;transform:rotate(12deg);padding:6px 14px;border:3px solid #474238;border-radius:14px;font-family:var(--font-heading);font-size:24px;color:#2e2b25;background:#f9f4ed;opacity:0;pointer-events:none">Pass</div>
<div data-shade="1" style="position:absolute;inset:0;border-radius:32px;background:#201e1d;opacity:0;pointer-events:none"></div>
</div>
</sc-for>
</div>
<div style="display:flex;justify-content:center;gap:28px;margin-top:22px">
<button onClick="{{ pass }}" style="border:0;background:none;padding:0;cursor:pointer;display:flex;flex-direction:column;align-items:center;gap:6px;font-size:11px;font-weight:700;color:#645c50"><span ref="{{ refPassBtn }}" style="width:62px;height:62px;border-radius:50%;background:#f9f4ed;box-shadow:var(--shadow-md);display:flex;align-items:center;justify-content:center;color:var(--color-text)"><svg viewBox="0 0 24 24" width="26" height="26" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M18 6 6 18"></path><path d="m6 6 12 12"></path></svg></span>Pass</button>
<button onClick="{{ tryIt }}" style="border:0;background:none;padding:0;cursor:pointer;display:flex;flex-direction:column;align-items:center;gap:6px;font-size:11px;font-weight:700;color:#645c50"><span ref="{{ refTryBtn }}" style="width:62px;height:62px;border-radius:50%;background:var(--color-accent-2-600);box-shadow:var(--shadow-md);display:flex;align-items:center;justify-content:center;color:var(--color-bg)"><svg viewBox="0 0 24 24" width="26" height="26" style="fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z"></path></svg></span>Try it</button>
</div>
</sc-if>
<sc-if value="{{ offDiscover }}" hint-placeholder-val="{{ false }}">
<div style="padding:24px 20px;display:flex;flex-direction:column;gap:8px"><div style="font-family:var(--font-heading);font-size:30px">{{ tab }}</div><div style="font-size:14px;color:#645c50">Placeholder: see the static mockups for this tab. Tap Discover to return to the card demo.</div></div>
</sc-if>
<div style="position:absolute;left:12px;right:12px;bottom:16px;height:62px;border-radius:999px;background:var(--color-text);display:flex;align-items:center;justify-content:space-around;padding:0 6px">
<div ref="{{ refPill }}" style="position:absolute;top:10px;height:42px;left:0;width:0;border-radius:999px;background:var(--color-accent);pointer-events:none"></div>
<button ref="{{ refHome }}" onClick="{{ goHome }}" aria-label="Home" style="{{ navHome }}"><svg viewBox="0 0 24 24" width="21" height="21" style="flex:none;fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"></path><path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path></svg><sc-if value="{{ isHome }}" hint-placeholder-val="{{ false }}"><span data-label="1">Home</span></sc-if></button>
<button ref="{{ refPlan }}" onClick="{{ goPlan }}" aria-label="Plan" style="{{ navPlan }}"><svg viewBox="0 0 24 24" width="21" height="21" style="flex:none;fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M8 2v4"></path><path d="M16 2v4"></path><rect width="18" height="18" x="3" y="4" rx="2"></rect><path d="M3 10h18"></path></svg><sc-if value="{{ isPlan }}" hint-placeholder-val="{{ false }}"><span data-label="1">Plan</span></sc-if></button>
<button ref="{{ refDiscover }}" onClick="{{ goDiscover }}" aria-label="Discover" style="{{ navDiscover }}"><svg viewBox="0 0 24 24" width="21" height="21" style="flex:none;fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><circle cx="12" cy="12" r="10"></circle><path d="m16.24 7.76-1.804 5.411a2 2 0 0 1-1.265 1.265L7.76 16.24l1.804-5.411a2 2 0 0 1 1.265-1.265z"></path></svg><sc-if value="{{ isDiscover }}" hint-placeholder-val="{{ true }}"><span data-label="1">Discover</span></sc-if></button>
<button ref="{{ refQuick }}" onClick="{{ goQuick }}" aria-label="Quick" style="{{ navQuick }}"><svg viewBox="0 0 24 24" width="21" height="21" style="flex:none;fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.020A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z"></path></svg><sc-if value="{{ isQuick }}" hint-placeholder-val="{{ false }}"><span data-label="1">Quick</span></sc-if></button>
<button ref="{{ refSettings }}" onClick="{{ goSettings }}" aria-label="Settings" style="{{ navSettings }}"><svg viewBox="0 0 24 24" width="21" height="21" style="flex:none;fill:none;stroke:currentColor;stroke-width:2.75;stroke-linecap:round;stroke-linejoin:round"><path d="M10 5H3"></path><path d="M12 19H3"></path><path d="M14 3v4"></path><path d="M16 17v4"></path><path d="M21 12h-9"></path><path d="M21 19h-5"></path><path d="M21 5h-7"></path><path d="M8 10v4"></path><path d="M8 12H3"></path></svg><sc-if value="{{ isSettings }}" hint-placeholder-val="{{ false }}"><span data-label="1">Settings</span></sc-if></button>
</div>
</div>
<div style="max-width:420px;display:flex;flex-direction:column;gap:18px">
<div style="font-family:var(--font-heading);font-size:34px;line-height:1.1">MealQuest motion reference</div>
<div style="font-size:14px;line-height:1.55;color:#474238">Live reference for the three signature animations. Tap the buttons, drag the card, or switch tabs. Exact keyframes are in the handoff README.</div>
<div style="padding:18px 20px;border-radius:26px;background:#f9f6f1;display:flex;flex-direction:column;gap:6px"><div style="font-family:var(--font-heading);font-size:18px">Nav pill slide</div><div style="font-size:13px;line-height:1.5;color:#474238">Pill animates left + width to the tapped item, 420 ms, cubic-bezier(.34,1.4,.64,1). Other icons FLIP to their new spots on the same curve; label fades in after 120 ms.</div></div>
<div style="padding:18px 20px;border-radius:26px;background:#f9f6f1;display:flex;flex-direction:column;gap:6px"><div style="font-family:var(--font-heading);font-size:18px">Try it</div><div style="font-size:13px;line-height:1.5;color:#474238">Stamp pops in. Card wiggles (±4°, 420 ms), pulls back slightly, then flies off right with a 28° tilt (480 ms, ease-in). Quest log pill bumps.</div></div>
<div style="padding:18px 20px;border-radius:26px;background:#f9f6f1;display:flex;flex-direction:column;gap:6px"><div style="font-family:var(--font-heading);font-size:18px">Pass</div><div style="font-size:13px;line-height:1.5;color:#474238">Card folds over on its left edge (rotateY to −80°, shading darkens), then drops and tumbles off left (720 ms total).</div></div>
<div style="display:flex;align-items:center;gap:10px;font-size:13px;font-weight:700;color:#474238"><button onClick="{{ toggleReduced }}" style="border:1.5px solid rgba(32,30,29,.16);background:none;border-radius:999px;padding:8px 14px;cursor:pointer;font-weight:700">Reduced motion: {{ reducedLabel }}</button><span>{{ lastAction }}</span></div>
</div>
</div>
</x-dc>
<script type="text/x-dc" data-dc-script>
const SPRING = 'cubic-bezier(.34,1.4,.64,1)';
const DEPTH_TF = ['translateY(0px) scale(1)', 'translateY(8px) scale(.95)', 'translateY(16px) scale(.9)'];
const DECK = [
{ name: 'Korean beef bowls', level: 1, diff: 'Easy', why: 'You make Chicken stir-fry and Beef tacos most weeks. Same flavors, sweet-savory twist.' },
{ name: 'Crock pot chicken tikka masala', level: 2, diff: 'Medium', why: 'Butter chicken night is a household favorite. This one cooks itself while you work.' },
{ name: 'Lemon garlic shrimp orzo', level: 2, diff: 'Medium', why: 'Your Shrimp scampi is on repeat. Orzo makes it a one-pot night.' },
{ name: 'Turkey chili', level: 1, diff: 'Easy', why: 'A lighter take on the chili you make every fall.' },
{ name: 'Thai basil chicken', level: 2, diff: 'Medium', why: 'Close to your stir-fry, with a fresh herby kick.' }
];
class Component extends DCLogic {
state = { idx: 0, tab: 'Discover', quest: 6, reduced: false, last: '' };
busy = false; cardEls = []; nav = {}; drag = null;
constructor(p) {
super(p);
this.cardRefs = [0, 1, 2].map(d => el => { this.cardEls[d] = el; });
['Home', 'Plan', 'Discover', 'Quick', 'Settings'].forEach(t => {
this['ref' + t] = el => { this.nav[t] = el; };
this['go' + t] = () => this.selectTab(t);
});
this.refPill = el => { this.pill = el; };
this.refCount = el => { this.countEl = el; };
this.refTryBtn = el => { this.tryBtn = el; };
this.refPassBtn = el => { this.passBtn = el; };
}
componentDidMount() {
this.state.reduced = window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches;
this.forceUpdate();
const place = () => { const el = this.nav[this.state.tab]; if (el && this.pill) { this.pill.style.left = el.offsetLeft + 'px'; this.pill.style.width = el.offsetWidth + 'px'; } };
requestAnimationFrame(place);
document.fonts && document.fonts.ready.then(place);
}
selectTab(t) {
if (t === this.state.tab) return;
const before = {}; Object.keys(this.nav).forEach(k => before[k] = this.nav[k].getBoundingClientRect().left);
const from = { left: this.pill.offsetLeft, width: this.pill.offsetWidth };
this.setState({ tab: t }, () => {
const el = this.nav[t]; const to = { left: el.offsetLeft, width: el.offsetWidth };
this.pill.style.left = to.left + 'px'; this.pill.style.width = to.width + 'px';
if (this.state.reduced) return;
this.pill.animate([{ left: from.left + 'px', width: from.width + 'px' }, { left: to.left + 'px', width: to.width + 'px' }], { duration: 420, easing: SPRING });
Object.keys(this.nav).forEach(k => {
const dx = before[k] - this.nav[k].getBoundingClientRect().left;
if (Math.abs(dx) > 0.5) this.nav[k].animate([{ transform: `translateX(${dx}px)` }, { transform: 'translateX(0)' }], { duration: 420, easing: SPRING });
});
const lab = el.querySelector('[data-label]');
if (lab) lab.animate([{ opacity: 0, transform: 'translateX(-4px)' }, { opacity: 1, transform: 'none' }], { duration: 220, delay: 120, easing: 'ease-out', fill: 'backwards' });
});
}
top() { return this.cardEls[0]; }
bump(el) { el && el.animate([{ transform: 'scale(1)' }, { transform: 'scale(.88)' }, { transform: 'scale(1.06)' }, { transform: 'scale(1)' }], { duration: 300, easing: 'ease-out' }); }
tryIt = (fromDrag) => {
if (this.busy && fromDrag !== true) return; this.busy = true;
const el = this.top(); const stamp = el.querySelector('[data-stamp]');
if (fromDrag !== true) this.bump(this.tryBtn);
if (this.state.reduced) return el.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 200, fill: 'forwards' }).finished.then(() => this.advance(true));
const cur = el.style.transform || 'translate(0px,0px) rotate(0deg)';
stamp.animate([{ opacity: 0, transform: 'rotate(-12deg) scale(1.5)' }, { opacity: 1, transform: 'rotate(-12deg) scale(1)' }], { duration: 220, easing: SPRING, fill: 'forwards' });
const wiggle = fromDrag === true ? Promise.resolve() : el.animate([
{ transform: 'rotate(0deg)' }, { transform: 'rotate(-4deg)' }, { transform: 'rotate(4deg)' }, { transform: 'rotate(-3deg)' }, { transform: 'rotate(2deg)' }, { transform: 'rotate(0deg)' }
], { duration: 420, easing: 'ease-in-out' }).finished;
wiggle.then(() => el.animate(
fromDrag === true
? [{ transform: cur }, { transform: 'translate(150%,-60px) rotate(28deg)', opacity: 0 }]
: [{ transform: 'translate(0px,0px) rotate(0deg)' }, { transform: 'translate(-14px,4px) rotate(-3deg)', offset: 0.18 }, { transform: 'translate(150%,-60px) rotate(28deg)', opacity: 0 }],
{ duration: fromDrag === true ? 340 : 480, easing: 'cubic-bezier(.55,0,.8,.4)', fill: 'forwards' }
).finished).then(() => this.advance(true));
};
pass = (fromDrag) => {
if (this.busy && fromDrag !== true) return; this.busy = true;
const el = this.top(); const shade = el.querySelector('[data-shade]');
if (fromDrag !== true) this.bump(this.passBtn);
if (this.state.reduced) return el.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 200, fill: 'forwards' }).finished.then(() => this.advance(false));
const cur = el.style.transform || 'translate(0px,0px) rotate(0deg)';
el.style.transformOrigin = '0% 50%';
shade.animate([{ opacity: 0 }, { opacity: 0.4, offset: 0.45 }, { opacity: 0.55 }], { duration: 720, fill: 'forwards' });
el.animate([
{ transform: `${cur} rotateY(0deg)`, easing: 'cubic-bezier(.45,0,.85,.55)' },
{ transform: `${cur} rotateY(-80deg)`, offset: 0.45, easing: 'cubic-bezier(.4,0,.9,.6)' },
{ transform: 'translate(-45%,560px) rotate(-35deg) rotateY(-90deg)', opacity: 0 }
], { duration: 720, fill: 'forwards' }).finished.then(() => this.advance(false));
};
advance(accepted) {
this.cardEls.forEach(el => { if (!el) return; el.getAnimations({ subtree: true }).forEach(a => a.cancel()); el.style.transform = ''; el.style.transformOrigin = ''; });
this.setState(s => ({ idx: s.idx + 1, quest: s.quest + (accepted ? 1 : 0), last: accepted ? 'Added to Quest log' : 'Passed' }), () => {
if (accepted) this.bump(this.countEl);
if (!this.state.reduced) [0, 1].forEach(d => { const el = this.cardEls[d]; el && el.animate([{ transform: DEPTH_TF[d + 1] }, { transform: DEPTH_TF[d] }], { duration: 300, easing: 'cubic-bezier(.2,.8,.2,1)' }); });
this.busy = false;
});
}
onDown = e => {
if (this.busy) return;
const el = this.top(); el.setPointerCapture(e.pointerId);
this.drag = { x: e.clientX, y: e.clientY, dx: 0 };
};
onMove = e => {
if (!this.drag) return;
const el = this.top(); const dx = e.clientX - this.drag.x, dy = e.clientY - this.drag.y; this.drag.dx = dx;
el.style.transform = `translate(${dx}px,${dy * 0.3}px) rotate(${dx / 18}deg)`;
el.querySelector('[data-stamp]').style.opacity = Math.max(0, Math.min(1, dx / 100));
el.querySelector('[data-pass]').style.opacity = Math.max(0, Math.min(1, -dx / 100));
};
onUp = () => {
if (!this.drag) return;
const el = this.top(); const dx = this.drag.dx; this.drag = null;
const st = el.querySelector('[data-stamp]'), ps = el.querySelector('[data-pass]');
if (dx > 110) { this.busy = true; ps.style.opacity = 0; return this.tryIt(true); }
if (dx < -110) { this.busy = true; st.style.opacity = 0; return this.pass(true); }
const cur = el.style.transform; el.style.transform = '';
st.style.opacity = 0; ps.style.opacity = 0;
if (cur) el.animate([{ transform: cur }, { transform: 'translate(0px,0px) rotate(0deg)' }], { duration: 420, easing: SPRING });
};
navStyle(t) {
const on = this.state.tab === t;
return { position: 'relative', zIndex: 1, display: 'flex', alignItems: 'center', gap: 6, height: 42, padding: '0 12px', border: 0, background: 'none', borderRadius: 999, cursor: 'pointer', fontSize: 13, fontWeight: 700, whiteSpace: 'nowrap', color: on ? 'var(--on-accent)' : '#c0b6a5', transition: 'color .2s' };
}
renderVals() {
const s = this.state;
const dot = on => ({ width: 8, height: 8, borderRadius: '50%', background: on ? 'var(--color-accent)' : '#dcd3c4' });
const cards = [2, 1, 0].map(d => {
const c = DECK[(s.idx + d) % DECK.length];
return {
...c, ref: this.cardRefs[d],
d1: dot(c.level >= 1), d2: dot(c.level >= 2), d3: dot(c.level >= 3),
onDown: d === 0 ? this.onDown : undefined, onMove: d === 0 ? this.onMove : undefined, onUp: d === 0 ? this.onUp : undefined,
style: { position: 'absolute', inset: 0, borderRadius: 32, background: d === 0 ? '#f9f4ed' : d === 1 ? 'var(--color-surface)' : '#dcd3c4', boxShadow: d === 0 ? 'var(--shadow-lg)' : 'none', overflow: 'hidden', transform: DEPTH_TF[d], zIndex: 3 - d, touchAction: 'none', userSelect: 'none', cursor: d === 0 ? 'grab' : 'default', willChange: 'transform' }
};
});
const v = {
cards, questCount: s.quest, tab: s.tab, onDiscover: s.tab === 'Discover', offDiscover: s.tab !== 'Discover',
pass: this.pass, tryIt: this.tryIt, refPill: this.refPill, refCount: this.refCount, refTryBtn: this.refTryBtn, refPassBtn: this.refPassBtn,
toggleReduced: () => this.setState(x => ({ reduced: !x.reduced })), reducedLabel: s.reduced ? 'on' : 'off', lastAction: s.last
};
['Home', 'Plan', 'Discover', 'Quick', 'Settings'].forEach(t => {
v['ref' + t] = this['ref' + t]; v['go' + t] = this['go' + t]; v['nav' + t] = this.navStyle(t); v['is' + t] = s.tab === t;
});
return v;
}
}
</script>
</body>
</html>
@@ -0,0 +1,296 @@
# Handoff: MealQuest — mobile-first meal planning PWA
## Overview
MealQuest helps one household plan weekly dinners. The household enters the dinners they make on repeat ("regulars"); an AI assistant suggests new dinners to try ("side quests"), which the household accepts or declines with a swipe-card UI. Accepted dinners go into a to-try pool (the "Quest log"). Each week the AI drafts a plan that mixes regulars with Quest-log picks; the household swaps days, then the plan is pushed to Google Calendar. A shopping list is generated from the planned dinners, grouped by dinner, minus anything the household unchecks as already on hand. Each week's list is archived as a "post" in a blog-style history (by week / month / year). Push notifications and Google Chat webhook messages announce key events. An admin area inside Settings configures the LLM.
### Product decisions (confirmed with the client)
| Topic | Decision |
|---|---|
| Users | One **shared household account** (no per-member profiles). Anyone in the household can edit everything, including Admin. |
| Swipe accept | Goes into the **Quest log** (to-try pool), scheduled later. |
| Suggestion card shows | Photo, **why it was suggested** (referencing regular dinners), **difficulty**. |
| Week building | **AI builds the week** mixing regulars + Quest-log meals; the user swaps/adjusts. No fixed new-vs-regular ratio. |
| Google Calendar | **One-way: app is source of truth**, pushes dinners to a Google Calendar. |
| Shopping list | All ingredients for planned dinners, **grouped by dinner**. The user **unchecks items they already have** (this is the only pantry mechanism — no persistent pantry). |
| History | **Each weekly shopping list is a new post**; filter Week / Month / Year. |
| Quick dinners | "Quick" = **crock pot** OR **≤ 30 min prep-to-table**. Shown as **two tabs** (Crock pot / 30 min or less). Reachable from a **bottom-nav tab** AND a Home shortcut, and as a "Quick only" filter in Cook-from-what-I-have. |
| Notifications (push + Google Chat) | New weekly plan ready · Shopping list ready · Day-of dinner reminder · New suggestions to swipe. Each event is toggled separately for Push and Chat. |
| Admin (LLM) | Provider + model picker · API key management · Temperature (creativity) · System prompt editor. Lives **inside Settings**. |
| Copy tone | Playful, quest-themed, but every label stays plain enough to understand. |
### Vocabulary (use consistently in UI copy)
- **Regulars** — the household's routine dinners.
- **Side quests** — AI dinner suggestions (Discover tab).
- **Quest log** — accepted suggestions waiting to be scheduled (to-try pool).
- **Supply run** — the current week's shopping list.
- **Supply archive** — shopping-list history.
## About the design files
The files in this bundle are **design references built in HTML** — static hi-fi mockups plus one interactive motion prototype. They show the intended look and behavior; they are **not production code to copy**. Recreate them in the target codebase using its established patterns. There is no existing codebase, so the recommended stack is:
- **React 18 + TypeScript + Vite**, `vite-plugin-pwa` (Workbox) for the service worker + manifest.
- Styling: CSS variables from `tokens.css` + CSS Modules or Tailwind configured to those variables. Do not hard-code hex values in components.
- Motion: **Web Animations API** (as in the prototype) or **Framer Motion** — the keyframes below translate 1:1.
- Icons: **Lucide** (`lucide-react`), `strokeWidth={2.75}` everywhere.
- Routing: React Router. Data: TanStack Query against a small API (backend out of scope for this handoff; integration points listed under *State & data*).
## Fidelity
**High-fidelity.** Colors, type, radii, spacing, copy and layout are final. Recreate pixel-accurately. Photos are placeholders (striped boxes labelled "dish photo") — real recipe photography will be supplied and should be rendered through the Organic "washed" treatment (`filter: saturate(.6) contrast(.85) brightness(1.1) opacity(.94)`), with rounded corners.
---
## Design tokens
All values are in `tokens.css`. Summary:
**Color**
| Role | Token | Hex |
|---|---|---|
| Page / phone background | `--color-bg` | `#f9f6f1` |
| Surface (cards, rows, inputs) | `--color-surface` | `#eee9de` |
| Ink | `--color-text` | `#201e1d` |
| Primary accent (Grape) | `--color-accent` | `#5a358c` |
| Text on accent | `--on-accent` | `#f9f6f1` |
| Second accent (Teal blue) | `--color-accent-2` | `#29889c` |
| Divider | `--color-divider` | `#201e1d` @ 16% |
Ramps (100→900): Grape `#f8f1ff #eedfff #dac3ff #c1a1f9 #a47ee1 #865fc0 #68449b #4a2d73 #2d1a49`; Teal `#e0fbff #bbf3ff #88e0f6 #3fc7e4 #00a9ca #008aaa #006b88 #004c63 #002f3e`; Neutral `#f9f4ed #eee7db #dcd3c4 #c0b6a5 #a19786 #82796a #645c50 #474238 #2e2b25`.
Usage rules:
- Grape fill (`--color-accent`) = primary buttons, active nav pill, selected segmented option, checked checkboxes, slider fill. **Always `--on-accent` text on it.**
- Grape small text (kickers, links) uses `--color-accent-700`.
- "Side quest"/new-dinner highlights: grape-100 fill + grape-300 inset ring (rows) or teal-200/300 fills (tiles). Tags: grape-100 bg / grape-800 text.
- Teal = positive/confirmation voice: "Why this?" boxes (teal-100 bg, teal-900 text), "Synced"/"Connected" status text (teal-800), toggles ON and "Try it" button (teal-600), Quest-log count pill (teal-200 bg, teal-800 text).
- Neutral-100 = secondary card fill; neutral-700 = muted meta text.
**Typography**
| Use | Font | Size / line-height |
|---|---|---|
| Mobile screen title | Bagel Fat One 400 | 26–30px / 1.1 |
| Card title (suggestion) | Bagel Fat One | 24px / 1.1 |
| Section/card heading | Bagel Fat One | 16–22px / 1.15 |
| Tablet/desktop page title | Bagel Fat One | 36–40px / 1.1 |
| Buttons | Bagel Fat One | 14–16px |
| Body | Figtree 400 | 13–15px / 1.45–1.55 |
| Row label | Figtree 700 | 14–15px |
| Kicker | Figtree 700, uppercase, letter-spacing .1em | 11–12px |
| Meta / tag | Figtree 700 | 10–12px |
| Mono (URLs, keys, system prompt, photo placeholder) | ui-monospace | 11–13px |
**Shape:** buttons, inputs, tags, nav, segmented controls are pills (999px). Mobile cards 24–32px radius; list rows 18–24px; inner photo 24px inset 10px from card edge. Phone frame in mocks is 36px (device chrome only).
**Elevation:** `--shadow-sm` (segmented selected), `--shadow-md` (round action buttons, tiles), `--shadow-lg` (top swipe card, dragged items).
**Icons (Lucide, stroke 2.75):** house, calendar, compass, zap, sliders-horizontal, x, heart, check, chevron-left/right/down, refresh-cw (swap), plus, shopping-cart, message-square, bell, search, archive, users. Nav icons 21–22px; inline 15–18px.
---
## Layout & navigation
### Breakpoints
- **Mobile < 768px** — design width 360–390px. Bottom nav bar.
- **Tablet 768–1199px** — design 1180×820 landscape. 96px left icon rail.
- **Desktop ≥ 1200px** — design 1440×900. 208px left sidebar.
### Primary nav (5 items, same order everywhere)
`Home · Plan · Discover · Quick · Settings`
- **Mobile bottom bar:** absolute, `left/right 12px`, `bottom 16px` (+ safe-area inset), height 62px, pill, background `--color-text`, `justify-content: space-around`, padding 0 6px. Inactive items: icon only, color `#c0b6a5` (neutral-400). Active item: icon + label (Figtree 700 13px) on a grape pill (height 42px, padding 0 12–14px, `--on-accent` text). **The grape pill is one shared element that slides between items — see Motion.**
- **Tablet rail:** 96px, `--color-surface` bg. Top: 48px grape circle with "M". Items stacked, gap 18px: 56×36 icon pill (active = grape fill) over an 11px 700 label.
- **Desktop sidebar:** 208px, `--color-surface`, padding 28px 16px. "MealQuest" wordmark (Bagel Fat One 28px, grape). Items: pill rows 12px 16px, gap 12px, Figtree 700 15px; active = grape fill + on-accent text. Footer card "Our kitchen / Shared household" with 36px teal avatar.
### Routes
| Route | Screen | Mock id |
|---|---|---|
| `/` | Home | 1g |
| `/onboarding` | Add regular dinners (step 2 of 3 shown) | 1h |
| `/plan` | Weekly plan (draft board) | **1f** (mobile), 2c (tablet), 2d (desktop) |
| `/plan/shopping` | Shopping list | 1l, 2b, 2f |
| `/plan/shopping/archive` | Supply archive | 1m, 2f (right panel) |
| `/discover` | Swipe suggestions | **1a** |
| `/quick` | Quick dinners (tabs) | 1j |
| `/cook-from-pantry` | Cook from what I have | 1i |
| `/dinner/:id` | Recipe / dinner detail | 1k, 2a (tablet reading mode) |
| `/settings` | Settings home | 1p |
| `/settings/notifications` | Notifications & Google Chat | 1n |
| `/settings/ai` | Admin · AI assistant | 1o |
Desktop settings (2e) shows Notifications, Google Chat webhook and AI assistant on one page (two columns).
**Chosen mobile directions:** Discover = **1a** (card stack), Plan = **1f** (draft board). Mocks 1b, 1c, 1d, 1e are rejected alternatives — ignore them.
---
## Screens
All mobile screens: background `--color-bg`; status-bar area 40px; content side padding 20px; bottom nav overlays the last 94px, so scrollable content needs ≥ 100px bottom padding.
### 1g · Home
- Header: "Friday, Oct 2" (12px 700 neutral-700) over title "Tonight's quest". Right: 40px round bell button (surface) with 8px grape unread dot.
- Tonight card (surface, radius 32): 150px photo inset 10px, then dinner name (Bagel 22px), meta "Regular · 40 min", right-aligned dark pill button "Recipe" (`--color-text` bg, `--color-bg` text).
- Two 104px tiles, 2-col grid gap 10: **Quick dinner** (grape fill, zap icon, on-accent text) → `/quick`; **Cook from what I have** (teal-300 fill, teal-900 text, search icon) → `/cook-from-pantry`.
- Status rows (neutral-100, radius 20, 12px 14px): "10 new side quests to swipe" → Discover; "Shopping list ready · Oct 5–11" → Shopping. Icon in grape-700, chevron right.
### 1h · Onboarding — regular dinners
3 progress bars (6px pills, filled = grape). "Step 2 of 3" kicker, title "What's on your usual rotation?" (30px), helper "Add the dinners your household makes on repeat. MealQuest builds suggestions from these." Input pill (48px, surface) with 36px grape "+" button. "Your regulars · N": chips teal-200/teal-900 with "×". "Common picks": dashed-outline chips "+ Chili" etc. Fixed bottom primary button "Next: notifications" (52px).
### 1a · Discover — swipe (CHOSEN)
- Header: kicker "Discover", title "Side quests"; right pill "Quest log · 6" (teal-200/teal-800).
- Deck area: margin 18px 20px, height 440px, `perspective: 1000px`. Three stacked cards: depth 0 `none`, depth 1 `translateY(8px) scale(.95)`, depth 2 `translateY(16px) scale(.9)`. Top card bg `#f9f4ed`, shadow-lg; depth 1 surface; depth 2 neutral-300. Radius 32.
- Card content: photo 220px inset 10px (radius 24); title (Bagel 24px); difficulty = 3 dots 8px (filled grape, empty neutral-300) + label "Easy/Medium/Hard"; "Why this?" box (teal-100, radius 18, 12px 14px, 13px/1.45) — e.g. "You make Chicken stir-fry and Beef tacos most weeks. Same flavors, sweet-savory twist."
- Stamps (hidden until gesture): "Try it!" top-left, rotate −12°, teal; "Pass" top-right, rotate 12°, neutral.
- Actions (centered, gap 28, 22px below deck): 62px round **Pass** (neutral-100, X) and **Try it** (teal-600, heart, on-accent icon), 11px labels below.
### 1f · Plan — draft board (CHOSEN)
- Header: "Oct 5 – 11" (26px) + "Draft" tag (grape-100/800). After pushing, tag becomes "Synced".
- Assistant bubble: 32px grape "Q" avatar + bubble (surface, radius 4 18 18 18): "I mixed 5 of your regulars with 2 from your Quest log. Tap any day to swap it."
- 2-col grid, gap 8, tiles 86px, radius 22, padding 12: day kicker (10px 700 .1em) + dinner (Bagel 15px). Regular = surface; Quest-log pick = teal-300 with "WED · NEW" kicker (teal-900). Swapping state = bg + 2px grape inset ring, kicker "FRI · SWAPPING…", muted name. 8th tile = dashed "+ Rebuild week".
- Primary button "Looks good — push to Calendar" (50px).
- Tapping a tile opens a swap sheet (not mocked; use Quest log + Regulars lists from 2d's right panel as a bottom sheet).
### 1j · Quick dinners
Header with 36px grape zap circle + "Quick dinners". Segmented tabs (surface track, 4px padding; selected = bg + shadow-sm): **Crock pot | 30 min or less**. Helper line per tab ("Prep to table in 30 minutes or less, picked from your tastes." / crock-pot equivalent). Rows (neutral-100, radius 24): 64px round photo, name (Bagel 16px), "25 min · Medium", 36px teal "+" (adds to Quest log). Footer link "↻ Show me more" (grape-700). Nav: Quick active.
### 1i · Cook from what I have
Back button + title. Ingredient chip field (surface, radius 26; chips bg-colored "Chicken thighs ×"; trailing "Add ingredient…"). Segmented "Any time | Quick only" + dark "Find ideas" button. Result cards (neutral-100, radius 24): name + time, teal-800 line "Uses all 5 · missing: 1 lime", buttons "Cook tonight" (grape) and "Add to Quest log" (outline).
### 1k · Recipe detail (mobile) / 2a · Kitchen reading mode (tablet)
Mobile: 250px full-bleed photo with round back button; sheet overlaps by 28px (radius 32 top). Tags (Side quest / Easy / 30 min), title 28px, "Why it was picked" teal box, Ingredients list with qty right-aligned and dividers, sticky bottom "Add to this week" + heart button.
Tablet 2a: header (back, "Saturday, Oct 10 · Side quest", title 36px) with **Keep screen on** toggle (Screen Wake Lock API) and **text size** segmented A/A/A. Two columns `400px | 1fr`: Ingredients card with 22px checkboxes (checked = struck-through, "3 of 9 set out"); Steps list — completed step muted with check, **current step** highlighted (grape-100 bg, 2px grape-300 ring, number circle grape, text 22px), others 18px. "Previous / Next step" buttons bottom-right.
### 1l · Shopping list / 2b tablet / 2f desktop
Kicker "Supply run · Oct 5–11", title "Shopping list", count "19 / 23". Helper "Uncheck anything you already have — it won't be counted." One card per dinner (surface, radius 24): dinner name (Bagel 16px) + day (11px). Items: 20–22px checkbox (checked = grape fill w/ white check = **to buy**; unchecked = 2px neutral-400 outline, struck-through name, "have it" label). Status line: "Sent to Google Chat · Sat 8:00 am" (teal-800, message icon). Tablet/desktop: CSS `column-count: 3` masonry, actions "Archive" (outline) + "Send to Google Chat" (primary).
### 1m · Supply archive (and 2f right panel)
Segmented "Week | Month | Year". Month headings (Bagel 18px). Post cards: kicker "WEEK 41 · OCT 5 – 11", AI-written title e.g. "Tacos, tikka & a salmon finale", meta "7 dinners · 23 items · 4 already on hand", dinner chips. Current week post = grape-100 + grape-300 ring, "· Current". Tapping a post opens the frozen list (read-only).
### 1p · Settings home
Title "Settings". Household banner (teal-100, 44px avatar "K", "Our kitchen / Shared household · changes apply to everyone"). Grouped list cards (surface, radius 26; rows 14px 16px; 38px round icon; chevron):
- HOUSEHOLD: Regular dinners ("7 on rotation"), Google Calendar ("Connected · Dinners calendar").
- ALERTS: Notifications & Google Chat.
- ADMIN: AI assistant ("Anthropic · Claude Sonnet") + "Admin" tag; card has grape-300 inset ring.
### 1n · Notifications & Google Chat
Back + title. Grid `1fr 46px 46px`: Event | PUSH | CHAT with toggles (40×24 pill; ON teal-600, knob right; OFF neutral-300). Events: New weekly plan ready · Shopping list ready · Day-of dinner reminder ("at 4:30 pm", tappable time) · New suggestions to swipe. Google Chat webhook card: mono URL input (ellipsis), "Connected · Dinner Crew space", "Send test" button. Message preview bubble: "MealQuest · preview — This week's plan is ready: Tacos, Bolognese, Tikka masala + 4 more. Open the plan →".
### 1o · Admin · AI assistant
Back + kicker "Settings · Admin" + title "AI assistant". Provider + Model dropdown pills (2-col). API key: masked mono "sk-••••••••3f9a" + "Replace" chip; "Verified · Oct 1" (teal-800). Temperature slider 0–1 step 0.1 (8px track, grape fill, 24px knob with 3px grape ring), labels "Familiar ↔ Adventurous", value right. System prompt textarea (mono 12px, radius 22). Bottom: "Reset" (outline) + "Save changes" (primary). Store API keys server-side only; the client only ever sees the masked tail.
### 2c tablet / 2d desktop · Plan manager
Header: kicker "Week 41", title "Oct 5 – 11", prev/next circles (+ "This week" pill on desktop), actions "Shopping list · 28", "Rebuild week", "Push to Calendar" (primary). Status "Synced to Google Calendar 2 min ago" (teal-800). 7-column grid (`repeat(7, minmax(0,1fr))`, gap 12): day label + card (photo 96–110px, name Bagel 16–17px, time, crock-pot "Start by 12:30 pm" hint, tag Regular/Side quest, swap icon). Side-quest cards teal-200.
Desktop right panel (290px, left divider): segmented "Quest log · 6 | Regulars · 9", helper "Drag a dinner onto a day to swap it in.", draggable rows (48px photo, name, difficulty, 6-dot grip). **Drag state:** source row becomes a dashed ghost at 55% opacity; target day shows dashed 2.5px grape border on grape-100 with "Drop to swap / Replaces Pizza night"; dragged card floats with shadow-lg, rotate −3°. Tablet 2c shows the Quest log as a chip strip under the grid with the same drag behavior.
### 2e · Desktop settings
Title + "Changes apply to everyone in the household." Sub-tabs: Household · Regular dinners · **Notifications & AI**. Left column: Notifications matrix card, Google Chat webhook card. Right column: AI assistant card (same fields as 1o, prompt textarea flexes to fill).
---
## Motion
A working reference is in **`MealQuest Motion.dc.html`** (open in a browser; tap buttons, drag the card, switch tabs, toggle reduced motion). Implement with WAAPI or Framer Motion using these exact values.
### 1. Bottom-nav pill slide
One absolutely positioned grape pill (`top:10px; height:42px`) lives behind the nav items (items are `position:relative; z-index:1`).
1. On tap, record each item's `getBoundingClientRect().left` and the pill's current `left/width`.
2. Commit the new active tab (active item now renders its label, so items reflow).
3. Measure the active item's `offsetLeft/offsetWidth`; set the pill to it and animate **from old → new `left` + `width`, 420ms, `cubic-bezier(.34,1.4,.64,1)`** (slight overshoot).
4. FLIP every nav item: animate `translateX(oldLeft − newLeft) → 0` with the same duration/easing so icons glide instead of jumping.
5. Label fades in: `opacity 0, translateX(-4px) → 1, none`, 220ms ease-out, **120ms delay**.
6. Icon color crossfades (inactive `#c0b6a5` → `--on-accent`) via `transition: color .2s`.
Recompute pill position on resize and after fonts load.
### 2. "Try it" (accept)
Triggered by the Try it button (gesture variant below).
1. Button bump: `scale 1 → .88 → 1.06 → 1`, 300ms ease-out.
2. "Try it!" stamp pops: `opacity 0, rotate(-12deg) scale(1.5) → opacity 1, rotate(-12deg) scale(1)`, 220ms spring, fill forwards.
3. **Wiggle:** card `rotate 0 → -4° → 4° → -3° → 2° → 0`, 420ms ease-in-out (transform-origin center).
4. **Fly off right:** keyframes `translate(0,0) rotate(0)` → at 18% `translate(-14px,4px) rotate(-3deg)` (anticipation) → `translate(150%,-60px) rotate(28deg); opacity 0`. 480ms, `cubic-bezier(.55,0,.8,.4)`, fill forwards.
5. On finish: add dinner to Quest log (optimistic), bump the "Quest log · N" pill (same bump as step 1), advance the deck (see 5).
### 3. "Pass" (decline)
1. Button bump (as above).
2. Set `transform-origin: 0% 50%` (left edge — the card folds over like a page).
3. Card keyframes (720ms total, fill forwards):
- 0%: `rotateY(0deg)` — easing to next frame `cubic-bezier(.45,0,.85,.55)`
- 45%: `rotateY(-80deg)` — easing to next frame `cubic-bezier(.4,0,.9,.6)`
- 100%: `translate(-45%,560px) rotate(-35deg) rotateY(-90deg); opacity 0` (drops and tumbles off left)
4. Shade overlay inside the card (`#201e1d`, inset 0, radius 32): opacity `0 → .4 (45%) → .55`, 720ms — sells the fold.
5. Parent deck container has `perspective: 1000px`.
6. On finish: record the decline (so the AI doesn't re-suggest), advance the deck.
### 4. Swipe gesture (top card only)
- Pointer events with `setPointerCapture`; card has `touch-action: none`.
- While dragging: `transform: translate(dx, dy*0.3) rotate(dx/18 deg)`. "Try it!" stamp opacity = `clamp(dx/100, 0, 1)`; "Pass" stamp opacity = `clamp(-dx/100, 0, 1)`.
- Release with **dx > 110px** → accept: skip the wiggle, fly from the current transform to `translate(150%,-60px) rotate(28deg); opacity 0`, 340ms, same throw easing.
- Release with **dx < −110px** → pass: run the Pass fold, prefixing the first two keyframes with the current drag transform.
- Otherwise snap back to `translate(0,0) rotate(0)`, 420ms `cubic-bezier(.34,1.4,.64,1)`; reset stamps.
- Lock input (`busy`) while any exit animation runs.
### 5. Deck advance
After an exit, cancel the exited card's animations, remove it, and promote the others: depth 1→0 and 2→1 animate from their previous depth transform to the new one, **300ms `cubic-bezier(.2,.8,.2,1)`**. Prefetch suggestions so the deck always has ≥ 3 cards.
### 6. Reduced motion
Respect `prefers-reduced-motion: reduce`: nav pill jumps instantly (no FLIP/overshoot); Try it / Pass become a 200ms opacity fade; no wiggle, fold or deck promotion.
### Other transitions (not prototyped; keep consistent)
- Screen push/pop: 260ms `cubic-bezier(.2,.8,.2,1)`, slide 24px + fade.
- Toggles: knob slides 180ms spring; track color 150ms.
- Checkbox check: scale `.8 → 1` 160ms spring; strike-through fades in 150ms.
- Bottom sheets: rise from 100% → 0, 320ms `cubic-bezier(.2,.8,.2,1)`, scrim fade to 50% neutral-900.
- Buttons: hover = one ramp step darker (`--color-accent-600`… on grape use `#4a2d73` for pressed), pressed `scale(.97)` 100ms. Focus: `outline 2px solid var(--color-accent); outline-offset 2px`.
---
## State & data
### Core entities
```ts
type Difficulty = 'easy' | 'medium' | 'hard';
interface Dinner { id: string; name: string; photoUrl?: string; minutes: number; method: 'stovetop'|'oven'|'crockpot'|'other';
difficulty: Difficulty; ingredients: Ingredient[]; steps: string[]; source: 'regular'|'suggestion'; }
interface Ingredient { name: string; qty?: string; }
interface Suggestion { dinner: Dinner; reason: string; /* "why this?" copy from the LLM, cites regulars */
status: 'pending'|'accepted'|'declined'; }
interface WeekPlan { weekStart: string /* ISO Monday */; days: { date: string; dinnerId: string; isNew: boolean }[];
status: 'draft'|'synced'; calendarSyncedAt?: string; }
interface ShoppingList { weekStart: string; groups: { dinnerId: string; day: string;
items: { name: string; qty?: string; needed: boolean /* false = "have it" */ }[] }[];
sentToChatAt?: string; title: string /* blog post title, AI-generated */; }
interface Settings { notifications: Record<'planReady'|'listReady'|'dayOfReminder'|'newSuggestions', { push: boolean; chat: boolean }>;
reminderTime: string; chatWebhookUrl?: string; calendarId?: string;
ai: { provider: string; model: string; temperature: number; systemPrompt: string; apiKeyLast4?: string; keyVerifiedAt?: string }; }
```
`isQuick(d) = d.method === 'crockpot' || d.minutes <= 30` (prep-to-table).
### Client state
- `activeTab` (nav), deck `{ queue: Suggestion[], busy: boolean, drag?: {x,y,dx} }`, `questLogCount`.
- Plan: `selectedDay` for swap sheet, drag-and-drop source/target on tablet/desktop.
- Shopping list item toggles are optimistic and persisted per week; once the week ends, the list is frozen into the archive.
### Integration points (backend — out of scope, but the UI depends on them)
- **LLM** — suggestion generation, week drafting, ingredient-based ideas, quick ideas, archive post titles. Uses Admin settings. Never expose the API key to the client.
- **Google Calendar** — OAuth once (Settings → Google Calendar); "Push to Calendar" creates/updates one event per dinner (one-way).
- **Google Chat** — server POSTs to the stored incoming-webhook URL for enabled events; "Send test" posts a sample.
- **Web Push** — VAPID; request permission during onboarding step 3 ("notifications"), not on first load. iOS requires the PWA to be installed to the home screen — show an install hint.
- **PWA** — manifest (`name: MealQuest`, `theme_color: #5a358c`, `background_color: #f9f6f1`, `display: standalone`), offline cache for the current plan, shopping list and recipes (kitchen use).
### Empty / loading / error states (not mocked — follow the system)
- Deck empty: Bagel title "All caught up", body "New side quests arrive Sunday." + "Get more now" primary.
- Loading: pill-shaped skeletons in `--color-surface` with a slow 1.2s opacity pulse.
- Errors: inline teal/grape-toned banner (neutral-100 card, grape-700 text) with a retry button; webhook test failure shows the HTTP status under the URL field.
## Accessibility
- Swipe is never the only path: Pass / Try it buttons always present; also support ← / → keys on Discover.
- All text ≥ 4.5:1 (on-accent on grape ≈ 9:1). Hit targets ≥ 44px (nav items are 42px tall inside a 62px bar — keep the full bar height tappable).
- Toggles are `role="switch"`; checkboxes native inputs styled; nav items `aria-current="page"`.
## Assets
- Fonts: Bagel Fat One, Figtree (Google Fonts).
- Icons: Lucide.
- Photos: placeholders only — supply real dinner photos and apply the washed treatment.
- Base system: Organic design system (`_ds/.../styles.css`), overridden by `tokens.css`.
## Files
- `README.md` — this document.
- `tokens.css` — final design tokens (use these).
- `MealQuest Mockups v2.dc.html` — all screens. Section 2 = tablet & desktop (2a–2f); section 1 = mobile (1a–1p). Chosen mobile directions are badged "Chosen".
- `MealQuest Motion.dc.html` — interactive motion reference (nav pill, Try it, Pass, swipe).
- `screenshots/` — PNG of every chosen screen: `mobile-1a…1p-*.png` (720×1520, 2× of a 360×760 phone), `tablet-2a…2c-*.png` (1180×820), `desktop-2d…2f-*.png` (1440×900). Rejected mobile alternatives (1b–1e) are not included. Note: the static screenshot of 1a doesn't show the two stacked cards behind the top card; see Motion → deck depth transforms.
- `support.js`, `_ds/organic-…/styles.css` — needed only to open the HTML references locally (serve the folder over a local web server, e.g. `npx serve`).
@@ -0,0 +1,257 @@
/* Organic — design-system tokens and component classes. This file is the source of truth for the system's look; retune it here and see readme.md. */
@import url('https://fonts.googleapis.com/css2?family=Caprasimo:wght@400&family=Figtree:wght@400;600;700&display=swap');
:root {
--color-bg: #f5ead8;
--color-surface: #ebddc5;
--color-text: #201e1d;
--color-accent: #c67139;
--color-accent-2: #7a8a5e;
--color-divider: color-mix(in srgb, #201e1d 16%, transparent);
/* Tonal ramps — generated in OKLCH on one shared lightness scale, so the
same step of any role matches the others in visual value. */
--color-neutral-100: #f9f4ed;
--color-neutral-200: #eee7db;
--color-neutral-300: #dcd3c4;
--color-neutral-400: #c0b6a5;
--color-neutral-500: #a19786;
--color-neutral-600: #82796a;
--color-neutral-700: #645c50;
--color-neutral-800: #474238;
--color-neutral-900: #2e2b25;
--color-accent-100: #fff2eb;
--color-accent-200: #ffe1d0;
--color-accent-300: #ffc6a5;
--color-accent-400: #f6a06b;
--color-accent-500: #d67f48;
--color-accent-600: #b2622d;
--color-accent-700: #8c491a;
--color-accent-800: #643312;
--color-accent-900: #402310;
--color-accent-2-100: #f0fae1;
--color-accent-2-200: #e1eecc;
--color-accent-2-300: #ccdbb2;
--color-accent-2-400: #aebf92;
--color-accent-2-500: #8fa073;
--color-accent-2-600: #728157;
--color-accent-2-700: #56633f;
--color-accent-2-800: #3d472b;
--color-accent-2-900: #272e1b;
--font-heading: "Caprasimo", system-ui, sans-serif;
--font-heading-weight: 400;
--font-body: "Figtree", system-ui, sans-serif;
--space-1: 4.4px;
--space-2: 8.8px;
--space-3: 13.2px;
--space-4: 17.6px;
--space-6: 26.4px;
--space-8: 35.2px;
--radius-sm: 8px;
--radius-md: 16px;
--radius-lg: 28px;
/* Elevation — derived from the ground: soft ink-tinted shadows on a
light theme, a hairline edge + ambient darkness on a dark one. */
--shadow-sm: 0 1px 2px color-mix(in srgb, #2e2b25 14%, transparent);
--shadow-md: 0 3px 10px color-mix(in srgb, #2e2b25 16%, transparent);
--shadow-lg: 0 12px 32px color-mix(in srgb, #2e2b25 22%, transparent);
}
body {
background: var(--color-bg);
color: var(--color-text);
font-family: var(--font-body);
}
h1, h2, h3, h4 { font-family: var(--font-heading); font-weight: var(--font-heading-weight); }
.washed{filter:saturate(0.6) contrast(0.85) brightness(1.1) opacity(0.94)}
/* ══════════════════════════════════════════════════════════════════════════
Components — built with the tokens above. Plain CSS
on plain HTML: no JavaScript, no build step. Each class is documented in
readme.md and demonstrated in foundations/ and components/.
══════════════════════════════════════════════════════════════════════ */
*, *::before, *::after { box-sizing: border-box; }
body { margin: 0; font-size: 15px; line-height: 1.55; font-weight: 400; }
h1, h2, h3, h4, h5, h6 {
font-family: var(--font-heading); font-weight: var(--font-heading-weight);
line-height: 1.12; letter-spacing: -0.015em; margin: 0 0 var(--space-2);
}
h1 { font-size: 42px; }
h2 { font-size: 32px; }
h3 { font-size: 25px; }
h4 { font-size: 20px; }
h5 { font-size: 16px; }
h6 { font-size: 13px; }
h6 { letter-spacing: 0.08em; text-transform: uppercase; }
p { margin: 0 0 var(--space-3); }
a { color: var(--color-accent); text-underline-offset: 3px; }
img { display: block; max-width: 100%; }
figure { margin: 0; }
figcaption {
font-size: 11px; margin-top: var(--space-1);
color: color-mix(in srgb, var(--color-text) 55%, transparent);
}
.text-muted { color: color-mix(in srgb, var(--color-text) 55%, transparent); }
:focus { outline: none; }
:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }
::selection { background: color-mix(in srgb, var(--color-accent) 30%, transparent); }
/* — rules — */
.hr {
height: 1px; border: 0; margin: var(--space-4) 0;
background: var(--color-divider);
}
/* — buttons — */
.btn {
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
cursor: pointer; text-decoration: none;
font-family: var(--font-heading); font-weight: var(--font-heading-weight);
font-size: 14px; line-height: 1.2; color: var(--color-text); /* matches the .input's 14px —
the pair sits side by side in sign-up rows */
background: transparent; border: 1px solid transparent;
padding: var(--space-2) calc(var(--space-3) * 1.2);
border-radius: var(--radius-md);
}
.btn svg { display: block; }
.btn:disabled { opacity: 0.45; cursor: not-allowed; }
.btn-primary { background: var(--color-accent); color: var(--color-bg); }
.btn-primary:hover { background: var(--color-accent-600); }
.btn-primary:active { background: var(--color-accent-700); }
.btn-secondary { border-color: var(--color-divider); }
.btn-secondary:hover { background: color-mix(in srgb, var(--color-text) 7%, transparent); }
.btn-secondary:active { background: color-mix(in srgb, var(--color-text) 14%, transparent); }
.btn-ghost { color: var(--color-accent); padding-inline: var(--space-1); }
.btn-ghost:hover { background: color-mix(in srgb, var(--color-accent) 10%, transparent); }
.btn-ghost:active { background: color-mix(in srgb, var(--color-accent) 18%, transparent); }
.btn-icon { width: 36px; height: 36px; padding: 0; }
.btn-block { width: 100%; margin-top: var(--space-2); }
/* — forms — */
.field > label {
display: block; font-size: 12px; margin-bottom: 5px;
color: color-mix(in srgb, var(--color-text) 70%, transparent);
}
.input {
width: 100%; min-height: 36px; padding: 6px 10px; font: inherit;
font-size: 14px; color: var(--color-text); caret-color: var(--color-accent);
background: var(--color-surface);
border: 1px solid var(--color-divider); border-radius: var(--radius-md);
}
.input:hover { border-color: color-mix(in srgb, var(--color-text) 45%, transparent); }
.input:focus-visible { border-color: var(--color-accent); outline-offset: 0; }
textarea.input { min-height: 90px; resize: vertical; }
.radio { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; font-size: 14px; }
.radio input, .seg-opt input {
position: absolute; opacity: 0; width: 0; height: 0; pointer-events: none;
}
.radio .dot {
width: 16px; height: 16px; flex: none; border-radius: 50%;
border: 1.5px solid var(--color-divider);
}
.radio:hover .dot { border-color: var(--color-accent); }
.radio input:checked + .dot {
border-color: var(--color-accent); background: var(--color-accent);
box-shadow: inset 0 0 0 4px var(--color-bg);
}
.radio input:focus-visible + .dot { outline: 2px solid var(--color-accent); outline-offset: 2px; }
.seg {
display: inline-flex; overflow: hidden;
border: 1px solid var(--color-divider); border-radius: var(--radius-md);
}
.seg-opt {
display: inline-flex; align-items: center; gap: 6px;
padding: 7px 12px; font-size: 13px; cursor: pointer;
}
.seg-opt + .seg-opt { border-left: 1px solid var(--color-divider); }
.seg-opt:has(input:checked) { background: var(--color-accent); color: var(--color-bg); }
.seg-opt:not(:has(input:checked)):hover { background: color-mix(in srgb, var(--color-text) 7%, transparent); }
.seg-opt:has(input:focus-visible) { outline: 2px solid var(--color-accent); outline-offset: -2px; }
/* — cards — */
.card {
display: flex; flex-direction: column; gap: var(--space-2);
padding: var(--space-3); border-radius: var(--radius-md); background: var(--color-surface);
}
.card-kicker { font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--color-accent); }
.card-title {
font-family: var(--font-heading); font-weight: var(--font-heading-weight);
font-size: 17px; line-height: 1.2;
}
.card-body { margin: 0; font-size: 13px; opacity: 0.8; flex: 1; }
.card-meta {
display: flex; align-items: center; gap: 6px; font-size: 11px;
color: color-mix(in srgb, var(--color-text) 50%, transparent);
}
.elev-sm { box-shadow: var(--shadow-sm); }
.elev-md { box-shadow: var(--shadow-md); }
.elev-lg { box-shadow: var(--shadow-lg); }
/* — tags — */
.tag {
display: inline-flex; align-items: center; font-size: 11px;
letter-spacing: 0.02em; padding: 3px 10px;
border-radius: calc(var(--radius-md) * 0.75);
}
.tag-accent { background: var(--color-accent-100); color: var(--color-accent-800); }
.tag-accent-2 { background: var(--color-accent-2-100); color: var(--color-accent-2-800); }
.tag-neutral { background: var(--color-neutral-100); color: var(--color-neutral-800); }
.tag-outline { border: 1px solid var(--color-accent); color: var(--color-accent); }
/* — navigation — */
.nav {
display: flex; align-items: center; gap: var(--space-4);
padding: var(--space-3) var(--space-4);
border-bottom: none;
}
.nav-brand {
font-family: var(--font-heading); font-weight: var(--font-heading-weight);
font-size: 18px; margin-right: auto;
}
.nav a { color: inherit; text-decoration: none; font-size: 14px; }
.nav a:hover, .nav a[aria-current='page'] { color: var(--color-accent); }
/* — tables — */
.table { width: 100%; border-collapse: collapse; font-size: 14px; }
.table th {
text-align: left; font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase;
color: color-mix(in srgb, var(--color-text) 60%, transparent);
padding: var(--space-2); border-bottom: 1px solid var(--color-divider);
}
.table td {
padding: var(--space-2);
border-bottom: 1px solid color-mix(in srgb, var(--color-text) 8%, transparent);
}
.table tbody tr:hover { background: color-mix(in srgb, var(--color-text) 4%, transparent); }
/* — dialog — */
.dialog-backdrop {
position: fixed; inset: 0; display: grid; place-items: center;
padding: var(--space-4);
background: color-mix(in srgb, var(--color-neutral-900) 50%, transparent);
}
.dialog {
width: min(440px, 100%); display: flex; flex-direction: column; gap: var(--space-3);
padding: var(--space-4); border-radius: var(--radius-lg);
background: var(--color-surface); box-shadow: var(--shadow-lg);
}
.dialog-title {
font-family: var(--font-heading); font-weight: var(--font-heading-weight);
font-size: 20px;
}
.dialog-body { font-size: 14px; opacity: 0.85; }
.dialog-actions { display: flex; justify-content: flex-end; gap: var(--space-2); margin-top: var(--space-2); }
/* — rounded frame: everything softens, small controls go pill — */
.card, .dialog { border-radius: calc(var(--radius-lg) * 1.15); }
.btn, .tag, .seg, .input { border-radius: 999px; }
.input { padding-inline: 14px; }
Binary file not shown.

After

Width:  |  Height:  |  Size: 209 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

File diff suppressed because it is too large Load Diff
@@ -0,0 +1,76 @@
/* MealQuest design tokens — final palette "B4: Grape + teal blue on off-white".
Built on the Organic design system (styles.css in this bundle); these values OVERRIDE its defaults. */
@import url('https://fonts.googleapis.com/css2?family=Bagel+Fat+One&family=Figtree:wght@400;600;700&display=swap');
:root {
/* Ground + ink */
--color-bg: #f9f6f1; /* oklch(0.975 0.008 85) — page / phone background */
--color-surface: #eee9de; /* oklch(0.935 0.016 85) — cards, rows, inputs */
--color-text: #201e1d;
--color-divider: color-mix(in srgb, #201e1d 16%, transparent);
--on-accent: #f9f6f1; /* text/icons on --color-accent fills (never dark text on grape) */
/* Primary accent — Grape (hue 300) */
--color-accent: #5a358c; /* oklch(0.42 0.14 300) */
--color-accent-100: #f8f1ff;
--color-accent-200: #eedfff;
--color-accent-300: #dac3ff;
--color-accent-400: #c1a1f9;
--color-accent-500: #a47ee1;
--color-accent-600: #865fc0;
--color-accent-700: #68449b; /* accent-colored small text on light grounds */
--color-accent-800: #4a2d73;
--color-accent-900: #2d1a49;
/* Second accent — Teal blue (hue 215) */
--color-accent-2: #29889c; /* oklch(0.58 0.09 215) */
--color-accent-2-100: #e0fbff;
--color-accent-2-200: #bbf3ff;
--color-accent-2-300: #88e0f6;
--color-accent-2-400: #3fc7e4;
--color-accent-2-500: #00a9ca;
--color-accent-2-600: #008aaa; /* toggles ON, "Try it" button, + buttons */
--color-accent-2-700: #006b88;
--color-accent-2-800: #004c63; /* teal text on teal-100/200 fills */
--color-accent-2-900: #002f3e;
/* Neutrals (unchanged from Organic) */
--color-neutral-100: #f9f4ed;
--color-neutral-200: #eee7db;
--color-neutral-300: #dcd3c4;
--color-neutral-400: #c0b6a5;
--color-neutral-500: #a19786;
--color-neutral-600: #82796a;
--color-neutral-700: #645c50;
--color-neutral-800: #474238;
--color-neutral-900: #2e2b25;
/* Type */
--font-heading: "Bagel Fat One", system-ui, sans-serif; /* weight 400 only */
--font-body: "Figtree", system-ui, sans-serif; /* 400 / 600 / 700 */
--font-mono: ui-monospace, Menlo, monospace;
/* Radii */
--radius-sm: 8px;
--radius-md: 16px;
--radius-lg: 28px;
--radius-card: 32px; /* mobile hero cards */
--radius-pill: 999px; /* buttons, inputs, tags, nav */
/* Spacing (Organic, density 1.1) */
--space-1: 4.4px; --space-2: 8.8px; --space-3: 13.2px; --space-4: 17.6px; --space-6: 26.4px; --space-8: 35.2px;
/* Elevation */
--shadow-sm: 0 1px 2px color-mix(in srgb, #2e2b25 14%, transparent);
--shadow-md: 0 3px 10px color-mix(in srgb, #2e2b25 16%, transparent);
--shadow-lg: 0 12px 32px color-mix(in srgb, #2e2b25 22%, transparent);
/* Motion */
--ease-spring: cubic-bezier(.34, 1.4, .64, 1); /* nav pill, snap-back, bumps */
--ease-out-soft: cubic-bezier(.2, .8, .2, 1); /* card promotion */
--ease-throw: cubic-bezier(.55, 0, .8, .4); /* fly-off right */
}
@media (prefers-reduced-motion: reduce) {
:root { --motion-scale: 0; }
}
File diff suppressed because it is too large Load Diff
+244
View File
@@ -0,0 +1,244 @@
# MealQuest architecture
This mirrors the architecture section of `CONTEXT.MD` in the CTXD project
`mealquest`, which is the source of record; if the two disagree, CTXD wins and
this file should be updated. It describes the **planned** application. The
repository today is the Feature 01 scaffold: `cmd/server`, `internal/config`,
`internal/server` (`/health`), `internal/frontend` (embedded PWA with SPA
fallback), the `web/` skeleton, `Makefile`, `Dockerfile` and CI.
Decision numbers (D-NNN) refer to `DECISIONS.MD` in CTXD.
## Topology
```
PWA (phone / tablet / desktop)
│ HTTPS
▼
Traefik v3.6 — 10.60.1.33
file route: mealquest-ccl.yml
│
▼
docker compose project "mealquest" — 10.60.1.14
├── mealquest Go service + embedded PWA, host 8090 → container 8080
├── searxng sidecar for recipe search; internal compose network
│ only, no published port
└── Litestream SQLite replication → Synology NAS 10.60.1.50
```
One Go service serves the API, the auth flow, media and the PWA. Its SQLite
database is replicated by Litestream to the NAS.
Traefik terminates TLS. The service honours `X-Forwarded-For` and
`X-Forwarded-Proto` only from peers listed in `MEALQUEST_TRUSTED_PROXY`
(comma-separated IPs/CIDRs; production lists `10.60.1.33`); from any other
peer they are ignored. The client is the rightmost `X-Forwarded-For` address
that is not a trusted proxy. The resolved client IP is added as `client_ip`
to every log line written while handling a request. The OIDC redirect URI
and the Secure cookies are fixed by `MEALQUEST_PUBLIC_URL`, never by request
headers, so sign-in works whichever peer the request arrives from.
## Repository layout
| Path | Purpose |
| --- | --- |
| `cmd/server` | Entry point; wiring only. |
| `internal/config` | Env-only `MEALQUEST_*` config; secrets from `*_FILE`. |
| `internal/server` | HTTP server, router, middleware. |
| `internal/frontend` | Embedded PWA, SPA fallback. |
| `internal/auth` | Authentik OIDC, sessions, CSRF. |
| `internal/db`, `internal/store` | SQLite connections, sqlc queries. |
| `internal/secretbox` | AES-256-GCM encryption for `_ct` columns. |
| `internal/household`, `internal/dinners`, `internal/recipes` | Domain. |
| `internal/ai/llm`, `internal/ai/harness`, `internal/ai/skills` | AI provider adapters, skill harness, skills. |
| `internal/suggest`, `internal/plan`, `internal/shopping`, `internal/ideas` | Feature services. |
| `internal/gcal`, `internal/notify`, `internal/push`, `internal/chat` | Integrations. |
| `internal/jobs`, `internal/scheduler` | Background jobs and schedules. |
| `migrations/` | SQL migrations (no goose or golang-migrate). |
| `web/` | React PWA. |
| `deploy/` | Deployment files. |
| `docs/` | Documentation. |
| `design/` | Requirements and UI handoff (read-only reference). |
## Backend stack and conventions
- Go 1.25, `CGO_ENABLED=0`, a single static binary.
- Router: `go-chi/chi/v5`. Logging: `log/slog` JSON.
- SQLite via `modernc.org/sqlite`, with `journal_mode=WAL`,
`foreign_keys=ON` and `busy_timeout`. **One writer connection plus a read
pool.** Queries generated by `sqlc`.
- IDs are ULIDs (`oklog/ulid/v2`).
- Timestamps are stored in UTC; local calendar dates are `YYYY-MM-DD` strings
in the household timezone.
- Configuration is environment-only (`MEALQUEST_*`). Secrets come only from
files named by `*_FILE` variables. Missing or invalid config stops startup.
- Error responses always have the body
`{"error":{"code":"…","message":"…","retryable":false}}`.
## Authentication
- Authentik OIDC; the backend is a **confidential client**
(`coreos/go-oidc/v3`, `golang.org/x/oauth2`).
- Session cookie `__Host-mealquest_session` (HttpOnly, Secure,
SameSite=Lax). Sessions expire after 30 days idle or 180 days absolute.
- Non-GET requests require the CSRF header `X-MealQuest-CSRF`.
- Access requires membership of the Authentik group `mealquest-admin`.
There is a **single role**: one shared household, no per-person profiles.
## Data model
Columns ending in `_ct` hold AES-256-GCM ciphertext (via `secretbox`).
| Table | Holds |
| --- | --- |
| `household_settings` | One row: timezone, dinner_time (17:30), reminder_time (16:30), review_time (09:00), plan_draft day/time (Fri 09:00), suggestions day/time (Sun 08:00), chat_autosend day/time (Sat 08:00), onboarding_completed_at, updates_seen_at. |
| `recipes` | A real recipe page: source_url (unique), site_name, title, total/prep/cook minutes, yield, raw_jsonld, fetched_at. |
| `recipe_ingredients` | recipe_id, position, raw_text, name, qty, unit, note. |
| `recipe_steps` | recipe_id, position, text. |
| `photos` | source_url, local_path, width, height, sha256, fetched_at. |
| `dinners` | The meal record and its lifecycle: name, normalized_name, status (`suggested` / `quest_log` / `tried` / `regular` / `archived`), origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by. |
| `suggestion_batches` | trigger, status, ai_run_id. |
| `suggestions` | dinner_id, batch_id, reason, cited_regular_ids (JSON), status, decided_at. |
| `week_plans` | week_start (unique), status (`draft` / `synced` / `changed`), ai_note, ai_run_id, calendar_synced_at. |
| `plan_days` | plan_id, date (unique per plan), dinner_id, is_new, calendar_event_id. |
| `rotation_reviews` | dinner_id + plan_date (unique), status, decision (`keep` / `drop` / `not_made`), decided_at, updated_by. |
| `shopping_lists` | week_start (unique), plan_id, title, status (`current` / `archived`), sent_to_chat_at, frozen_at. |
| `shopping_groups` / `shopping_items` | One group per dinner/day; item name, qty, needed (bool), position. |
| `ai_settings` | One row: provider, base_url, model, temperature, system_prompt, api_key_ct, api_key_last4, key_verified_at. |
| `recipe_search_settings` | One row: provider (`searxng` / `brave`), base_url, api_key_ct. |
| `integrations_google` | One row: refresh_token_ct, calendar_id, calendar_name, connected_at, last_sync_error. |
| `chat_settings` | One row: webhook_url_ct, space_label, last_test_at, last_test_status. |
| `notification_prefs` | event (`plan_ready` / `list_ready` / `day_of_reminder` / `new_suggestions` / `rotation_review`), push, chat. |
| `push_subscriptions` | endpoint (unique), p256dh, auth, user_agent, failure_count, last_success_at. |
| `notification_outbox` | event_key + channel (unique), payload, status, attempts, next_attempt_at, last_error. |
| `jobs` | kind, status, payload, result (progressive), error, timestamps. |
| `schedule_runs` | schedule, period_key (unique). |
| `ai_runs` | skill, skill_version, provider, model, temperature, status, latency_ms, tokens_in/out, error, redacted request/response (30-day retention). |
| `sessions` | id_hash, subject, email, name, groups, csrf_token, created/last_seen/expires. |
`recipes` holds a fetched recipe page; `dinners` holds the household's meal
record and its lifecycle, optionally linked to a recipe and a photo.
## Dinner lifecycle
```
suggested (deck) ──Try it──▶ quest_log ──planned date passes──▶ tried
│ ▲ │
│ └──────── explicit decision ─────┤
│ ├──▶ regular
│ └──▶ archived
└──Pass──▶ archived (name kept for dedupe)
```
From `tried`, an explicit decision moves the dinner to `regular`, to
`archived`, or back to `quest_log`. Promotion to `regular` is **never
automatic** (D-021).
## API surface
- JSON API under `/api/v1`, using the error body above for every failure.
It requires the session cookie and, for non-GET requests, the
`X-MealQuest-CSRF` header.
- `/auth/*` — the OIDC flow.
- `/media/*` — cached photos.
- `/health` — liveness.
- Everything else serves the embedded PWA with SPA fallback.
## AI layer
- **Provider interface** (`ai/llm`) with adapters for OpenAI-compatible,
Anthropic and Ollama endpoints, called over plain HTTP — no vendor SDKs.
- **Skill harness** (`ai/harness`): prompt layering, validation of the
structured output, and **one repair retry**; if the repair also fails the
run fails — no silent fallback. Every call is recorded in `ai_runs`.
## Recipe grounding
- Dinners are grounded in real recipe pages found through recipe search
(`searxng` or `brave`, per `recipe_search_settings`).
- Fetching is **SSRF-safe**.
- Recipe data is parsed from schema.org `Recipe` JSON-LD.
- Photos are cached locally (`photos`) and processed with
`golang.org/x/image`.
## Scheduler
Times are in the household timezone. The times of `day_of_reminder`
(`reminder_time`) and `rotation_check` (`review_time`), and the day and time
of `suggestions_weekly`, `plan_draft` and `shopping_chat_autosend`, are
household settings in `household_settings` (`dinner_time` is also stored
there); the table shows their defaults. `week_rollover` and the `ai_runs`
purge are fixed and not configurable.
| Schedule | When | Does |
| --- | --- | --- |
| `suggestions_weekly` | Sunday 08:00 | New batch of Side quests. |
| `plan_draft` | Friday 09:00 | AI drafts next week's plan. |
| `shopping_chat_autosend` | Saturday 08:00 | Sends the Supply run to Google Chat. |
| `day_of_reminder` | Daily 16:30 | Tonight's dinner reminder. |
| `rotation_check` | Daily 09:00 | Asks "Keep it in rotation?" for tried dinners. |
| `week_rollover` | Monday 00:05 (fixed) | Freezes last week's Supply run into the Supply archive. |
| `ai_runs` purge | Daily (fixed) | Deletes `ai_runs` rows past 30-day retention. |
Runs are recorded in `schedule_runs` and executed through `jobs`.
## Notifications
- Events: `plan_ready`, `list_ready`, `day_of_reminder`, `new_suggestions`,
`rotation_review`; each can go to Web Push and/or Google Chat per
`notification_prefs`.
- Delivery goes through `notification_outbox`.
- Web Push uses VAPID (`webpush-go`). Google Chat uses the stored incoming
webhook.
## Google Calendar
- One-way push: the app is the source of truth; edits in Google Calendar do
not come back.
- Connected once with Google OAuth in Settings; one event per planned
dinner.
- **Deterministic event ids**; each plan day records its
`calendar_event_id`.
## Frontend
- React 19, TypeScript, Vite, React Router 7, TanStack Query 5,
`vite-plugin-pwa`, `lucide-react` (stroke 2.75).
- CSS Modules over `tokens.css`; no Tailwind, no Framer Motion, no
hard-coded hex. Self-hosted Bagel Fat One and Figtree.
- Tests: Vitest, Testing Library, jsdom.
- Offline cache for the current plan, shopping list and recipes.
| Route | Screen |
| --- | --- |
| `/` | Home |
| `/onboarding` | Add regular dinners |
| `/plan` | Weekly plan (Draft board) |
| `/plan/shopping` | Shopping list (Supply run) |
| `/plan/shopping/archive` | Supply archive |
| `/discover` | Swipe suggestions (Side quests) |
| `/quick` | Quick dinners |
| `/cook-from-pantry` | Cook from what I have |
| `/dinner/:id` | Recipe / dinner detail (Kitchen reading mode on tablet) |
| `/settings` | Settings home |
| `/settings/household` | Household settings |
| `/settings/regulars` | Regular dinners |
| `/settings/calendar` | Google Calendar connection |
| `/settings/notifications` | Notifications and Google Chat |
| `/settings/ai` | Admin · AI assistant |
`/settings/household`, `/settings/regulars` and `/settings/calendar` come from
CTXD; the other routes are defined in the UI handoff.
## Boundaries and gotchas
- **Secrets never reach the client** — API keys, OAuth tokens and the Chat
webhook stay server-side, encrypted at rest.
- **The app is the source of truth for the plan**; Google Calendar is a
one-way mirror.
- **Never auto-promote to Regular** (D-021).
- **Quick** means cooked in a crock pot **or** ≤ 30 minutes prep to table.
- **Weeks run Monday–Sunday** in the household timezone.
- **No silent fallbacks** — fail fast and report.
- **iOS Web Push requires the PWA to be installed to the Home Screen**; show
an install hint.
+24
View File
@@ -0,0 +1,24 @@
module code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest
go 1.25.0
require (
github.com/coreos/go-oidc/v3 v3.21.0
github.com/go-chi/chi/v5 v5.3.1
github.com/oklog/ulid/v2 v2.1.2
golang.org/x/oauth2 v0.36.0
modernc.org/sqlite v1.59.0
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/sys v0.47.0 // indirect
modernc.org/libc v1.75.7 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.12.1 // indirect
)
+61
View File
@@ -0,0 +1,61 @@
github.com/coreos/go-oidc/v3 v3.21.0 h1:wZo4Q9Pum8dYEj0eMUPrqR+kvuGkeUplbLpNCkBqoWM=
github.com/coreos/go-oidc/v3 v3.21.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8=
github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/oklog/ulid/v2 v2.1.2 h1:IEclFb9JNvzYA6MW2SCxbLzcHTVsfqm3PrqGQJH5zec=
github.com/oklog/ulid/v2 v2.1.2/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
modernc.org/cc/v4 v4.29.2 h1:h6+9ciCnPKutf4I03CvheAvDLX7+IHlqR6Iy6J+cgd8=
modernc.org/cc/v4 v4.29.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.35.0 h1:F+TUsmw09QxLzmi3aeYYGxjAXarmZaKgj3mKQHNaA8w=
modernc.org/ccgo/v4 v4.35.0/go.mod h1:qrVGs9S3Sr2Ztcg9ve+kTAYMp5a3YvWjo+SoN06kJ5I=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc=
modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.75.7 h1:o3DTP9/0p9pKmY2WCKQaySW6wIiZhNM7wc2lUoyhfew=
modernc.org/libc v1.75.7/go.mod h1:bO5o2ztHxBb2rjz0PgdHN0sSMw57CgxGFLZ3Qd/QpVQ=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.59.0 h1:X1es1GpqBlS/5T+vbM4HLUdaa8OtQx468DF2vrx+38A=
modernc.org/sqlite v1.59.0/go.mod h1:+paeT2A3iPRHkQDwG7oA6Tk0zQd5woMEI8q7orfry8k=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+45
View File
@@ -0,0 +1,45 @@
package auth
import (
"context"
"slices"
)
// Actor is the signed-in household member a request acts for. Handlers
// record Actor.Subject as updated_by on edits.
type Actor struct {
Subject string
Email string
Name string
Groups []string
}
// Actor returns the household member s belongs to.
func (s Session) Actor() Actor {
return Actor{Subject: s.Subject, Email: s.Email, Name: s.Name, Groups: slices.Clone(s.Groups)}
}
type sessionKey struct{}
// WithSession returns a copy of ctx carrying s. The session middleware calls
// it for every authenticated request.
func WithSession(ctx context.Context, s Session) context.Context {
return context.WithValue(ctx, sessionKey{}, s)
}
// SessionFrom returns the session WithSession stored in ctx. ok is false when
// ctx has none, which behind the session middleware means a wiring bug.
func SessionFrom(ctx context.Context) (s Session, ok bool) {
s, ok = ctx.Value(sessionKey{}).(Session)
return s, ok
}
// ActorFrom returns the actor of the session WithSession stored in ctx. ok is
// false when ctx has none.
func ActorFrom(ctx context.Context) (actor Actor, ok bool) {
s, ok := SessionFrom(ctx)
if !ok {
return Actor{}, false
}
return s.Actor(), true
}
+40
View File
@@ -0,0 +1,40 @@
package auth
import (
"context"
"slices"
"testing"
)
func TestActorContext(t *testing.T) {
t.Parallel()
if _, ok := ActorFrom(context.Background()); ok {
t.Error("ActorFrom found an actor in an empty context")
}
if _, ok := SessionFrom(context.Background()); ok {
t.Error("SessionFrom found a session in an empty context")
}
s := Session{Subject: "user-123", Email: "cook@example.com", Name: "Home Cook", Groups: []string{"mealquest-admin"}, CSRFToken: "t"}
ctx := WithSession(context.Background(), s)
gotSession, ok := SessionFrom(ctx)
if !ok || gotSession.Subject != s.Subject || gotSession.CSRFToken != s.CSRFToken {
t.Errorf("SessionFrom = %+v (ok=%v), want %+v", gotSession, ok, s)
}
got, ok := ActorFrom(ctx)
if !ok || got.Subject != s.Subject || got.Email != s.Email || got.Name != s.Name || !slices.Equal(got.Groups, s.Groups) {
t.Errorf("ActorFrom = %+v (ok=%v), want the session's actor", got, ok)
}
}
func TestSessionActor(t *testing.T) {
t.Parallel()
s := Session{Subject: "user-123", Email: "cook@example.com", Name: "Home Cook", Groups: []string{"mealquest-admin"}, CSRFToken: "t"}
a := s.Actor()
if a.Subject != s.Subject || a.Email != s.Email || a.Name != s.Name || !slices.Equal(a.Groups, s.Groups) {
t.Errorf("Actor = %+v, want the session's identity", a)
}
a.Groups[0] = "changed"
if s.Groups[0] != "mealquest-admin" {
t.Error("Actor shares the session's groups slice")
}
}
+439
View File
@@ -0,0 +1,439 @@
// Package auth signs the household in with Authentik over OpenID Connect.
// The service is a confidential client: the authorization code, tokens and
// client secret stay on the server and never reach JavaScript.
package auth
import (
"context"
"crypto/rand"
"crypto/subtle"
"errors"
"fmt"
"log/slog"
"net/http"
"net/url"
"slices"
"sync/atomic"
"time"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/go-chi/chi/v5"
"golang.org/x/oauth2"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secureurl"
)
// Routes owned by this package.
const (
LoginPath = "/auth/login"
CallbackPath = "/auth/callback"
LogoutPath = "/auth/logout"
)
// providerTimeout bounds every request to the identity provider.
const providerTimeout = 10 * time.Second
// scopes requested at login.
var scopes = []string{oidc.ScopeOpenID, "profile", "email", "groups", oidc.ScopeOfflineAccess}
// Claims are the verified ID token claims handed to the session layer.
type Claims struct {
Subject string `json:"sub"`
Email string `json:"email"`
EmailVerified bool `json:"email_verified"`
Name string `json:"name"`
PreferredUsername string `json:"preferred_username"`
Groups []string `json:"groups"`
}
// Sessions is the session layer. StartSession is called once per successful
// login with claims from a verified ID token; it must set the session cookie
// on w. EndSession is called on logout; it must end the session r carries,
// if any, and clear the cookie on w. A returned error fails the request with
// 500.
type Sessions interface {
StartSession(w http.ResponseWriter, r *http.Request, claims Claims) error
EndSession(w http.ResponseWriter, r *http.Request) error
}
// Config is what New needs to reach the identity provider.
type Config struct {
// Issuer is the exact issuer URL; discovery runs against it.
Issuer string
ClientID string
ClientSecret string
// PublicURL is the service's public URL; its origin prefixes CallbackPath
// to form the redirect URI.
PublicURL *url.URL
// SecretKey is the server key the login cookie's signing key derives from.
SecretKey []byte
// AllowedGroups are the household's groups; a sign-in is refused unless
// the account is in at least one of them.
AllowedGroups []string
}
// Auth serves the login and callback routes.
type Auth struct {
oauth oauth2.Config
verifier *oidc.IDTokenVerifier
provider *oidc.Provider
client *http.Client
cookie loginCookie
sessions Sessions
allowed []string
// endSessionURL is the provider's end_session_endpoint; logout ends
// there so the provider's own session ends too.
endSessionURL string
logger *slog.Logger
}
// New discovers the provider at cfg.Issuer and returns the login flow. It
// fails if discovery fails or the issuer the provider reports differs.
func New(ctx context.Context, cfg Config, sessions Sessions, logger *slog.Logger) (*Auth, error) {
switch {
case cfg.Issuer == "", cfg.ClientID == "", cfg.ClientSecret == "":
return nil, errors.New("auth: issuer, client ID and client secret are required")
case cfg.PublicURL == nil:
return nil, errors.New("auth: public URL is required")
case len(cfg.SecretKey) == 0:
return nil, errors.New("auth: secret key is required")
case sessions == nil:
return nil, errors.New("auth: session layer is required")
case len(cfg.AllowedGroups) == 0:
return nil, errors.New("auth: at least one allowed group is required")
}
if err := checkEndpoint("issuer", cfg.Issuer); err != nil {
return nil, err
}
client := &http.Client{Timeout: providerTimeout, CheckRedirect: checkRedirect}
discovery := *client
recorder := &statusRecorder{next: http.DefaultTransport}
discovery.Transport = recorder
provider, err := oidc.NewProvider(oidc.ClientContext(ctx, &discovery), cfg.Issuer)
if err != nil {
return nil, discoveryError(err, int(recorder.status.Load()))
}
endpoint := provider.Endpoint()
var discovered struct {
JWKSURI string `json:"jwks_uri"`
EndSessionURI string `json:"end_session_endpoint"`
}
if err := provider.Claims(&discovered); err != nil {
return nil, fmt.Errorf("auth: OIDC discovery: %w", err)
}
// The token endpoint receives the client secret and the key set decides
// which tokens verify, so every endpoint used must pass the same rule.
for _, e := range []struct{ name, raw string }{
{"authorization endpoint", endpoint.AuthURL},
{"token endpoint", endpoint.TokenURL},
{"JWKS endpoint", discovered.JWKSURI},
{"end session endpoint", discovered.EndSessionURI},
// Groups come from userinfo when the ID token carries none, and the
// access token is sent there.
{"userinfo endpoint", provider.UserInfoEndpoint()},
} {
if err := checkEndpoint(e.name, e.raw); err != nil {
return nil, err
}
}
// client_secret_basic, the OIDC default; no auto-detection retries.
endpoint.AuthStyle = oauth2.AuthStyleInHeader
return &Auth{
oauth: oauth2.Config{
ClientID: cfg.ClientID,
ClientSecret: cfg.ClientSecret,
Endpoint: endpoint,
RedirectURL: cfg.PublicURL.Scheme + "://" + cfg.PublicURL.Host + CallbackPath,
Scopes: scopes,
},
verifier: provider.Verifier(&oidc.Config{
ClientID: cfg.ClientID,
SupportedSigningAlgs: []string{oidc.RS256},
}),
provider: provider,
client: client,
allowed: slices.Clone(cfg.AllowedGroups),
cookie: newLoginCookie(cfg.SecretKey),
sessions: sessions,
endSessionURL: discovered.EndSessionURI,
logger: logger,
}, nil
}
// statusRecorder records the status of the last response it carries.
type statusRecorder struct {
next http.RoundTripper
status atomic.Int32
}
func (s *statusRecorder) RoundTrip(req *http.Request) (*http.Response, error) {
resp, err := s.next.RoundTrip(req)
if err == nil {
s.status.Store(int32(resp.StatusCode))
}
return resp, err
}
// discoveryError reports a failed discovery. go-oidc puts the provider's
// response body in its error for a non-200 discovery response, so that
// error is replaced by the status alone. Transport errors carry no response
// content and keep their cause.
func discoveryError(err error, status int) error {
var urlErr *url.Error
if !errors.As(err, &urlErr) && status != 0 && status != http.StatusOK {
return fmt.Errorf("auth: OIDC discovery: provider answered HTTP %d", status)
}
return fmt.Errorf("auth: OIDC discovery: %w", err)
}
// checkEndpoint applies secureurl.Check to the provider URL raw, naming the
// endpoint in the error.
func checkEndpoint(name, raw string) error {
u, err := url.Parse(raw)
if err != nil {
return fmt.Errorf("auth: OIDC %s %q is not a valid URL", name, raw)
}
if err := secureurl.Check(u); err != nil {
return fmt.Errorf("auth: OIDC %s %q: %w", name, raw, err)
}
return nil
}
// maxRedirects matches net/http's default redirect limit.
const maxRedirects = 10
// checkRedirect is the provider client's redirect policy. On a 307 or 308 to
// the same host Go resends the body and Authorization header, which carry the
// code and client secret, so every target must pass secureurl.Check and an
// https:// request never follows a redirect to plain http://, even loopback.
func checkRedirect(req *http.Request, via []*http.Request) error {
if len(via) >= maxRedirects {
return fmt.Errorf("stopped after %d redirects", maxRedirects)
}
if err := secureurl.Check(req.URL); err != nil {
return fmt.Errorf("redirect refused: %w", err)
}
if via[len(via)-1].URL.Scheme == "https" && req.URL.Scheme != "https" {
return errors.New("redirect refused: https:// to plain http:// downgrade")
}
return nil
}
// Mount registers GET LoginPath, GET CallbackPath and POST LogoutPath on r.
func (a *Auth) Mount(r chi.Router) {
r.Get(LoginPath, a.login)
r.Get(CallbackPath, a.callback)
r.Post(LogoutPath, a.logout)
}
// EndSessionURL is the provider's end_session_endpoint.
func (a *Auth) EndSessionURL() string { return a.endSessionURL }
// login starts the authorization code flow with PKCE (S256). The optional
// "return" query parameter must be a same-origin path; anything else fails
// the login with 400.
func (a *Auth) login(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
returnTo, err := returnPath(r.URL.Query().Get("return"))
if err != nil {
http.Error(w, "invalid return path", http.StatusBadRequest)
return
}
state := loginState{
State: rand.Text(),
Nonce: rand.Text(),
Verifier: oauth2.GenerateVerifier(),
Return: returnTo,
}
if err := a.cookie.set(w, state, time.Now()); err != nil {
a.logger.ErrorContext(r.Context(), "set login cookie", "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, a.oauth.AuthCodeURL(state.State,
oidc.Nonce(state.Nonce),
oauth2.S256ChallengeOption(state.Verifier),
), http.StatusFound)
}
// callback completes the flow: it checks state against the login cookie,
// exchanges the code, verifies the ID token and nonce, then starts a session
// if the account is in an allowed group. Membership is checked on every
// login, so an account removed from the group is refused at its next one.
func (a *Auth) callback(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
ctx := r.Context()
// The login cookie is single-use.
a.cookie.clear(w)
state, err := a.cookie.read(r, time.Now())
if err != nil {
a.logger.WarnContext(ctx, "login callback without a valid login cookie", "error", err)
http.Error(w, "sign-in expired; start again", http.StatusBadRequest)
return
}
q := r.URL.Query()
if subtle.ConstantTimeCompare([]byte(q.Get("state")), []byte(state.State)) != 1 {
a.logger.WarnContext(ctx, "login callback state mismatch")
http.Error(w, "sign-in state mismatch; start again", http.StatusBadRequest)
return
}
if q.Get("error") != "" {
// error and error_description are provider-supplied text; only the
// fixed category is logged.
a.logger.WarnContext(ctx, "identity provider returned an authorization error")
http.Error(w, "sign-in was refused", http.StatusUnauthorized)
return
}
code := q.Get("code")
if code == "" {
http.Error(w, "missing authorization code", http.StatusBadRequest)
return
}
claims, err := a.verify(ctx, code, state)
if err != nil {
a.logger.WarnContext(ctx, "sign-in failed", "error", err)
http.Error(w, "sign-in failed", http.StatusUnauthorized)
return
}
if !a.member(claims.Groups) {
if claims.Groups == nil {
a.logger.WarnContext(ctx, "sign-in refused: no groups claim in the ID token or userinfo", "subject", claims.Subject)
} else {
a.logger.WarnContext(ctx, "sign-in refused: not in an allowed group", "subject", claims.Subject)
}
// A session this browser already holds ends too, so a refused
// sign-in never leaves it signed in.
if err := a.sessions.EndSession(w, r); err != nil {
a.logger.ErrorContext(ctx, "end session", "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
a.refuse(w, r)
return
}
if err := a.sessions.StartSession(w, r, claims); err != nil {
a.logger.ErrorContext(ctx, "start session", "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, state.Return, http.StatusSeeOther)
}
// logout ends the session, clears the cookie and sends the browser to the
// provider's end_session_endpoint to end the provider session as well.
func (a *Auth) logout(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
if err := a.sessions.EndSession(w, r); err != nil {
a.logger.ErrorContext(r.Context(), "end session", "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, a.endSessionURL, http.StatusSeeOther)
}
// verify exchanges code for tokens and returns the verified ID token claims.
// The verifier enforces RS256, issuer, audience and expiry; nonce is checked
// here. When the ID token has no groups claim, Groups comes from userinfo;
// it stays nil when neither has one.
func (a *Auth) verify(ctx context.Context, code string, state loginState) (Claims, error) {
token, err := a.oauth.Exchange(context.WithValue(ctx, oauth2.HTTPClient, a.client), code,
oauth2.VerifierOption(state.Verifier))
if err != nil {
return Claims{}, exchangeError(err)
}
raw, ok := token.Extra("id_token").(string)
if !ok || raw == "" {
return Claims{}, errors.New("token response has no id_token")
}
idToken, err := a.verifier.Verify(ctx, raw)
if err != nil {
return Claims{}, verifyError(err)
}
if subtle.ConstantTimeCompare([]byte(idToken.Nonce), []byte(state.Nonce)) != 1 {
return Claims{}, errors.New("ID token nonce mismatch")
}
var claims Claims
if err := idToken.Claims(&claims); err != nil {
return Claims{}, fmt.Errorf("decode ID token claims: %w", err)
}
if claims.Subject == "" {
return Claims{}, errors.New("ID token has no subject")
}
if claims.Groups == nil {
groups, err := a.userinfoGroups(ctx, token, claims.Subject)
if err != nil {
return Claims{}, err
}
claims.Groups = groups
}
return claims, nil
}
// userinfoGroups returns the groups claim from the userinfo endpoint, nil if
// it has none. The userinfo subject must match the ID token's subject.
func (a *Auth) userinfoGroups(ctx context.Context, token *oauth2.Token, subject string) ([]string, error) {
info, err := a.provider.UserInfo(oidc.ClientContext(ctx, a.client), oauth2.StaticTokenSource(token))
if err != nil {
return nil, userinfoError(err)
}
if info.Subject != subject {
return nil, errors.New("userinfo subject does not match the ID token")
}
var claims struct {
Groups []string `json:"groups"`
}
if err := info.Claims(&claims); err != nil {
return nil, fmt.Errorf("decode userinfo claims: %w", err)
}
return claims.Groups, nil
}
// userinfoError reduces a failed userinfo request to a fixed category, like
// exchangeError: go-oidc's error text can carry the response body.
func userinfoError(err error) error {
var transport *url.Error
if errors.As(err, &transport) {
return fmt.Errorf("userinfo: request failed: %s: %w", transport.Op, transport.Err)
}
return errors.New("userinfo: endpoint returned an unusable response")
}
// verifyError reduces a failed ID token verification to a fixed category.
// go-oidc's error text can carry the JWKS endpoint's response body or
// token-supplied values, so only the expiry of an expired token is kept.
func verifyError(err error) error {
var expired *oidc.TokenExpiredError
if errors.As(err, &expired) {
return fmt.Errorf("verify ID token: token expired at %s", expired.Expiry.UTC().Format(time.RFC3339))
}
return errors.New("verify ID token: token or provider key set rejected")
}
// member reports whether groups contains an allowed group.
func (a *Auth) member(groups []string) bool {
return slices.ContainsFunc(groups, func(g string) bool { return slices.Contains(a.allowed, g) })
}
// exchangeError reduces a failed code exchange to a fixed category and safe
// metadata. The oauth2 error text can carry the token endpoint's response
// body or error_description, which may echo credentials, so it is dropped.
func exchangeError(err error) error {
var retrieve *oauth2.RetrieveError
var transport *url.Error
switch {
case errors.As(err, &retrieve):
return fmt.Errorf("exchange code: token endpoint rejected the request (HTTP %d)", retrieve.Response.StatusCode)
case errors.As(err, &transport):
// Request failures never carry the response. The URL is dropped: after
// a refused redirect it is the provider-supplied Location.
return fmt.Errorf("exchange code: token request failed: %s: %w", transport.Op, transport.Err)
default:
return errors.New("exchange code: token endpoint returned an unusable response")
}
}
File diff suppressed because it is too large Load Diff
+124
View File
@@ -0,0 +1,124 @@
package auth
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"time"
)
const (
loginCookieName = "__Host-mealquest_login"
loginCookieTTL = 10 * time.Minute
// loginCookieKeyLabel separates the login cookie's signing key from other
// keys derived from the same server secret.
loginCookieKeyLabel = "mealquest auth login cookie v1"
)
// loginState travels from login to callback in the signed login cookie.
type loginState struct {
State string `json:"s"`
Nonce string `json:"n"`
Verifier string `json:"v"`
Return string `json:"r"`
Expires int64 `json:"e"` // Unix seconds
}
// loginCookie signs and verifies the login cookie with HMAC-SHA256.
type loginCookie struct {
key []byte
}
func newLoginCookie(secretKey []byte) loginCookie {
mac := hmac.New(sha256.New, secretKey)
mac.Write([]byte(loginCookieKeyLabel))
return loginCookie{key: mac.Sum(nil)}
}
func (c loginCookie) sign(payload string) string {
mac := hmac.New(sha256.New, c.key)
mac.Write([]byte(payload))
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}
func (c loginCookie) set(w http.ResponseWriter, s loginState, now time.Time) error {
s.Expires = now.Add(loginCookieTTL).Unix()
data, err := json.Marshal(s)
if err != nil {
return err
}
payload := base64.RawURLEncoding.EncodeToString(data)
http.SetCookie(w, &http.Cookie{
Name: loginCookieName,
Value: payload + "." + c.sign(payload),
Path: "/",
MaxAge: int(loginCookieTTL / time.Second),
Secure: true,
HttpOnly: true,
// Lax so the cookie rides the provider's top-level redirect back.
SameSite: http.SameSiteLaxMode,
})
return nil
}
func (c loginCookie) clear(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: loginCookieName,
Path: "/",
MaxAge: -1,
Secure: true,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
}
func (c loginCookie) read(r *http.Request, now time.Time) (loginState, error) {
cookie, err := r.Cookie(loginCookieName)
if err != nil {
return loginState{}, errors.New("login cookie missing")
}
payload, sig, ok := strings.Cut(cookie.Value, ".")
if !ok || !hmac.Equal([]byte(sig), []byte(c.sign(payload))) {
return loginState{}, errors.New("login cookie signature invalid")
}
data, err := base64.RawURLEncoding.DecodeString(payload)
if err != nil {
return loginState{}, fmt.Errorf("login cookie encoding: %w", err)
}
var s loginState
if err := json.Unmarshal(data, &s); err != nil {
return loginState{}, fmt.Errorf("login cookie payload: %w", err)
}
if now.Unix() >= s.Expires {
return loginState{}, errors.New("login cookie expired")
}
return s, nil
}
// returnPath validates the optional post-login return path. Empty means "/".
// Only same-origin absolute paths are allowed: no scheme, host, protocol-
// relative "//" prefix, backslashes or control characters.
func returnPath(raw string) (string, error) {
if raw == "" {
return "/", nil
}
if !strings.HasPrefix(raw, "/") || strings.HasPrefix(raw, "//") || strings.ContainsRune(raw, '\\') {
return "", errors.New("return path must be a same-origin path")
}
for _, c := range raw {
if c < 0x20 || c == 0x7f {
return "", errors.New("return path contains a control character")
}
}
u, err := url.Parse(raw)
if err != nil || u.Scheme != "" || u.Host != "" || u.User != nil {
return "", errors.New("return path must be a same-origin path")
}
return raw, nil
}
+264
View File
@@ -0,0 +1,264 @@
package auth
import (
"context"
"errors"
"io"
"log/slog"
"net/http"
"net/url"
"slices"
"strings"
"testing"
)
// signInWith completes a login whose ID token has claims edited by edit.
func (h *harness) signInWith(t *testing.T, edit func(claims map[string]any)) *http.Response {
t.Helper()
l := h.login(t, LoginPath)
c := h.provider.claims(l.query.Get("nonce"))
edit(c)
return h.callback(l, l.query.Get("state"), h.provider.signRS256(c))
}
// assertRefused checks resp is the refused-sign-in page and starts no
// session.
func assertRefused(t *testing.T, resp *http.Response) {
t.Helper()
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("callback status = %d, want 403", resp.StatusCode)
}
if ct := resp.Header.Get("Content-Type"); ct != "text/html; charset=utf-8" {
t.Errorf("Content-Type = %q, want text/html; charset=utf-8", ct)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatal(err)
}
for _, want := range []string{
"This kitchen is for the household only",
`<form method="post" action="/auth/logout">`,
"Sign out",
} {
if !strings.Contains(string(body), want) {
t.Errorf("refused page lacks %q:\n%s", want, body)
}
}
for _, c := range resp.Cookies() {
// Clearing a session cookie (negative Max-Age) is expected.
if (c.Name == SessionCookieName || c.Name == "test_session") && c.MaxAge >= 0 {
t.Errorf("refused sign-in set session cookie %s", c.Name)
}
}
assertLoginCookieCleared(t, resp)
}
func TestGroupGateMember(t *testing.T) {
t.Parallel()
// Any one allowed group is enough, alongside groups that are not allowed.
h := newHarness(t)
resp := h.signInWith(t, func(c map[string]any) { c["groups"] = []string{"guests", "mealquest-family"} })
if resp.StatusCode != http.StatusSeeOther {
t.Fatalf("callback status = %d, want 303", resp.StatusCode)
}
if started := h.sessions.started(); len(started) != 1 || !slices.Equal(started[0].Groups, []string{"guests", "mealquest-family"}) {
t.Errorf("sessions started = %+v, want one with the ID token groups", started)
}
if n := h.provider.userinfoRequests(); n != 0 {
t.Errorf("userinfo requested %d times, want 0 when the ID token has groups", n)
}
}
func TestGroupGateRefused(t *testing.T) {
t.Parallel()
tests := []struct {
name string
groups any // ID token groups claim; nil removes it
userinfo map[string]any
}{
{name: "non-member", groups: []string{"guests", "MEALQUEST-ADMIN"}},
{name: "empty groups claim", groups: []string{}},
{name: "groups claim missing from ID token and userinfo", groups: nil},
{
name: "non-member in userinfo",
groups: nil,
userinfo: map[string]any{"sub": "user-123", "groups": []string{"guests"}},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
h := newHarness(t)
if tc.userinfo != nil {
h.provider.setUserinfo(tc.userinfo)
}
resp := h.signInWith(t, func(c map[string]any) {
if tc.groups == nil {
delete(c, "groups")
} else {
c["groups"] = tc.groups
}
})
assertRefused(t, resp)
if started := h.sessions.started(); len(started) != 0 {
t.Errorf("sessions started = %+v, want none", started)
}
if !strings.Contains(h.logs.String(), "sign-in refused") {
t.Errorf("refusal not logged:\n%s", h.logs.String())
}
})
}
}
func TestGroupGateUsesUserinfoWhenIDTokenHasNoGroups(t *testing.T) {
t.Parallel()
h := newHarness(t)
h.provider.setUserinfo(map[string]any{"sub": "user-123", "groups": []string{"mealquest-admin"}})
resp := h.signInWith(t, func(c map[string]any) { delete(c, "groups") })
if resp.StatusCode != http.StatusSeeOther {
t.Fatalf("callback status = %d, want 303", resp.StatusCode)
}
if started := h.sessions.started(); len(started) != 1 || !slices.Equal(started[0].Groups, []string{"mealquest-admin"}) {
t.Errorf("sessions started = %+v, want one with the userinfo groups", started)
}
if n := h.provider.userinfoRequests(); n != 1 {
t.Errorf("userinfo requested %d times, want 1", n)
}
}
func TestGroupGateUserinfoFailures(t *testing.T) {
t.Parallel()
tests := []struct {
name string
userinfo map[string]any
}{
{"subject mismatch", map[string]any{"sub": "someone-else", "groups": []string{"mealquest-admin"}}},
{"malformed groups", map[string]any{"sub": "user-123", "groups": "mealquest-admin"}},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
h := newHarness(t)
h.provider.setUserinfo(tc.userinfo)
resp := h.signInWith(t, func(c map[string]any) { delete(c, "groups") })
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("callback status = %d, want 401", resp.StatusCode)
}
if started := h.sessions.started(); len(started) != 0 {
t.Errorf("sessions started = %+v, want none", started)
}
})
}
}
// A failed userinfo request fails the sign-in without logging the
// endpoint's response, which may echo credentials.
func TestGroupGateUserinfoErrorNotLogged(t *testing.T) {
t.Parallel()
h := newHarness(t)
h.provider.mu.Lock()
h.provider.userinfoReply = &tokenReply{status: http.StatusInternalServerError, body: `{"error":"leaked-s3cret"}`}
h.provider.mu.Unlock()
resp := h.signInWith(t, func(c map[string]any) { delete(c, "groups") })
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("callback status = %d, want 401", resp.StatusCode)
}
if started := h.sessions.started(); len(started) != 0 {
t.Errorf("sessions started = %+v, want none", started)
}
logs := h.logs.String()
if !strings.Contains(logs, "userinfo") || strings.Contains(logs, "leaked-s3cret") {
t.Errorf("logs = %s, want the userinfo failure without the response body", logs)
}
}
// Membership is checked at every login: after removal from the group, the
// next login is refused and starts no session.
func TestGroupGateRemovedMemberRefusedAtNextLogin(t *testing.T) {
t.Parallel()
h := newSessionHarness(t)
h.signIn(t)
if n := len(h.rows(t)); n != 1 {
t.Fatalf("sessions after first login = %d, want 1", n)
}
resp := h.signInWith(t, func(c map[string]any) { c["groups"] = []string{"former-household"} })
assertRefused(t, resp)
if n := len(h.rows(t)); n != 1 {
t.Errorf("sessions after refused login = %d, want still 1", n)
}
}
// A refused sign-in from a browser that still holds a session ends that
// session and clears its cookie.
func TestGroupGateRefusalEndsExistingSession(t *testing.T) {
t.Parallel()
h := newSessionHarness(t)
_, cookie := h.signIn(t)
l := h.login(t, LoginPath)
c := h.provider.claims(l.query.Get("nonce"))
c["groups"] = []string{"former-household"}
code := h.provider.authorize(l.query.Get("code_challenge"), h.provider.signRS256(c))
q := url.Values{"code": {code}, "state": {l.query.Get("state")}}
resp := h.get(CallbackPath+"?"+q.Encode(), l.cookie, cookie)
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("callback status = %d, want 403", resp.StatusCode)
}
if n := len(h.rows(t)); n != 0 {
t.Errorf("sessions after refused login = %d, want 0", n)
}
if _, err := h.use(cookie); !errors.Is(err, ErrNoSession) {
t.Errorf("old session lookup error = %v, want ErrNoSession", err)
}
cleared := false
for _, c := range resp.Cookies() {
cleared = cleared || (c.Name == SessionCookieName && c.MaxAge < 0)
}
if !cleared {
t.Error("refused sign-in did not clear the session cookie")
}
}
func TestGroupGateRefusalEndSessionError(t *testing.T) {
t.Parallel()
h := newHarness(t)
h.sessions.err = errors.New("database down")
resp := h.signInWith(t, func(c map[string]any) { c["groups"] = []string{"guests"} })
if resp.StatusCode != http.StatusInternalServerError {
t.Errorf("callback status = %d, want 500", resp.StatusCode)
}
}
func TestNewRequiresAllowedGroups(t *testing.T) {
t.Parallel()
p := newFakeProvider(t)
_, err := New(context.Background(), Config{
Issuer: p.issuer(),
ClientID: testClientID,
ClientSecret: testClientSecret,
PublicURL: testPublicURL,
SecretKey: []byte("k"),
}, &memorySessions{}, slog.New(slog.DiscardHandler))
if err == nil || !strings.Contains(err.Error(), "allowed group") {
t.Errorf("New error = %v, want missing allowed groups named", err)
}
}
func TestNewRequiresUserinfoEndpoint(t *testing.T) {
t.Parallel()
p := newFakeProvider(t)
p.discoveryOverrides = map[string]any{"userinfo_endpoint": nil}
_, err := New(context.Background(), Config{
Issuer: p.issuer(),
ClientID: testClientID,
ClientSecret: testClientSecret,
PublicURL: testPublicURL,
SecretKey: []byte("k"),
AllowedGroups: testAllowedGroups,
}, &memorySessions{}, slog.New(slog.DiscardHandler))
if err == nil || !strings.Contains(err.Error(), "userinfo endpoint") {
t.Errorf("New error = %v, want the missing userinfo endpoint named", err)
}
}
+37
View File
@@ -0,0 +1,37 @@
package auth
import (
"io"
"net/http"
)
// refusedPage is shown when a sign-in succeeds at the provider but the
// account is not in the household's group. Signing out ends the provider
// session too, so another account can sign in.
const refusedPage = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MealQuest</title>
</head>
<body>
<main>
<h1>This kitchen is for the household only</h1>
<p>The account you signed in with is not part of our kitchen.</p>
<form method="post" action="` + LogoutPath + `">
<button type="submit">Sign out</button>
</form>
</main>
</body>
</html>
`
// refuse writes the refused-sign-in page with 403.
func (a *Auth) refuse(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusForbidden)
if _, err := io.WriteString(w, refusedPage); err != nil {
a.logger.WarnContext(r.Context(), "write refused sign-in page", "error", err)
}
}
+238
View File
@@ -0,0 +1,238 @@
package auth
import (
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/http"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
// SessionCookieName is the session cookie. The __Host- prefix makes the
// browser insist on Secure, Path=/ and no Domain.
const SessionCookieName = "__Host-mealquest_session"
// sessionIDBytes is the size of the random session ID: 256 bits.
const sessionIDBytes = 32
// ErrNoSession means the request carries no live session: the cookie is
// missing or malformed, or its session is unknown, ended or expired.
var ErrNoSession = errors.New("auth: no valid session")
// Session is a signed-in household member's server-side session.
type Session struct {
Subject string
Email string
Name string
Groups []string
CSRFToken string
CreatedAt time.Time
// LastSeenAt is the last time the session was used.
LastSeenAt time.Time
// ExpiresAt is when the session ends unless used again first.
ExpiresAt time.Time
}
// SessionManager keeps sessions in the sessions table. The cookie holds only
// the random session ID; the table holds only its SHA-256 hash. A session
// ends after idle without use and always max after it was created.
type SessionManager struct {
db *store.Store
idle time.Duration
max time.Duration
// now is the clock; tests replace it.
now func() time.Time
}
// NewSessionManager returns a SessionManager over db. idle and max must be
// positive with max >= idle.
func NewSessionManager(db *store.Store, idle, max time.Duration) (*SessionManager, error) {
switch {
case db == nil:
return nil, errors.New("auth: session store is required")
case idle <= 0 || max <= 0:
return nil, errors.New("auth: session idle and max lifetimes must be positive")
case max < idle:
return nil, errors.New("auth: session max lifetime must be at least the idle lifetime")
}
return &SessionManager{db: db, idle: idle, max: max, now: time.Now}, nil
}
// StartSession creates a session for claims and sets the session cookie.
func (m *SessionManager) StartSession(w http.ResponseWriter, r *http.Request, claims Claims) error {
if claims.Subject == "" {
return errors.New("auth: session needs a subject")
}
id, csrf := randomToken(), randomToken()
groups := claims.Groups
if groups == nil {
groups = []string{}
}
groupsJSON, err := json.Marshal(groups)
if err != nil {
return fmt.Errorf("auth: encode session groups: %w", err)
}
now := m.timestamp()
if _, err := m.db.Writer.ExecContext(r.Context(), `
INSERT INTO sessions (id_hash, subject, email, name, groups, csrf_token, created_at, last_seen_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
hashSessionID(id), claims.Subject, claims.Email, claims.Name, string(groupsJSON), csrf,
now, now, m.expiry(now.Time, now.Time),
); err != nil {
return fmt.Errorf("auth: create session: %w", err)
}
// The cookie outlives idle gaps so a returning device presents its ID;
// the server decides whether the session is still live. Max-Age is whole
// seconds, rounded up so the cookie never ends before the absolute limit.
http.SetCookie(w, sessionCookie(id, cookieMaxAge(m.max)))
return nil
}
// cookieMaxAge is lifetime in whole seconds, rounded up. It rounds the
// quotient rather than adding to lifetime, which would overflow near the
// largest time.Duration.
func cookieMaxAge(lifetime time.Duration) int {
seconds := lifetime / time.Second
if lifetime%time.Second != 0 {
seconds++
}
return int(seconds)
}
// SessionFor returns the live session r carries and slides its last use to
// now. It returns ErrNoSession when there is none; an expired session is
// deleted.
func (m *SessionManager) SessionFor(r *http.Request) (Session, error) {
idHash, err := sessionIDHash(r)
if err != nil {
return Session{}, err
}
now := m.timestamp()
var s Session
live := false
err = m.db.WithTx(r.Context(), func(tx *sql.Tx) error {
var groupsJSON string
var created, lastSeen, expires store.Timestamp
err := tx.QueryRowContext(r.Context(), `
SELECT subject, email, name, groups, csrf_token, created_at, last_seen_at, expires_at
FROM sessions WHERE id_hash = ?`, idHash,
).Scan(&s.Subject, &s.Email, &s.Name, &groupsJSON, &s.CSRFToken, &created, &lastSeen, &expires)
if errors.Is(err, sql.ErrNoRows) {
return nil
}
if err != nil {
return fmt.Errorf("auth: read session: %w", err)
}
if !now.Before(expires.Time) || now.Sub(lastSeen.Time) >= m.idle || now.Sub(created.Time) >= m.max {
if _, err := tx.ExecContext(r.Context(), `DELETE FROM sessions WHERE id_hash = ?`, idHash); err != nil {
return fmt.Errorf("auth: delete expired session: %w", err)
}
return nil
}
if err := json.Unmarshal([]byte(groupsJSON), &s.Groups); err != nil {
return fmt.Errorf("auth: decode session groups: %w", err)
}
expiresAt := m.expiry(created.Time, now.Time)
if _, err := tx.ExecContext(r.Context(), `
UPDATE sessions SET last_seen_at = ?, expires_at = ? WHERE id_hash = ?`,
now, expiresAt, idHash,
); err != nil {
return fmt.Errorf("auth: slide session: %w", err)
}
s.CreatedAt, s.LastSeenAt, s.ExpiresAt = created.Time, now.Time, expiresAt.Time
live = true
return nil
})
if err != nil {
return Session{}, err
}
if !live {
return Session{}, ErrNoSession
}
return s, nil
}
// EndSession deletes the session r carries, if any, and clears the cookie.
func (m *SessionManager) EndSession(w http.ResponseWriter, r *http.Request) error {
http.SetCookie(w, sessionCookie("", -1))
idHash, err := sessionIDHash(r)
if errors.Is(err, ErrNoSession) {
return nil
}
if err != nil {
return err
}
if _, err := m.db.Writer.ExecContext(r.Context(), `DELETE FROM sessions WHERE id_hash = ?`, idHash); err != nil {
return fmt.Errorf("auth: delete session: %w", err)
}
return nil
}
// timestamp is the clock's current instant at stored precision.
func (m *SessionManager) timestamp() store.Timestamp {
return store.Timestamp{Time: m.now().UTC().Truncate(time.Millisecond)}
}
// expiry is when a session created at created and last used at lastSeen
// ends: idle after lastSeen, but never later than max after created.
func (m *SessionManager) expiry(created, lastSeen time.Time) store.Timestamp {
idle, absolute := lastSeen.Add(m.idle), created.Add(m.max)
if absolute.Before(idle) {
return store.Timestamp{Time: absolute}
}
return store.Timestamp{Time: idle}
}
// sessionCookie is the session cookie carrying value, with maxAge seconds;
// a negative maxAge clears it.
func sessionCookie(value string, maxAge int) *http.Cookie {
return &http.Cookie{
Name: SessionCookieName,
Value: value,
Path: "/",
MaxAge: maxAge,
Secure: true,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
}
}
// sessionIDHash returns the stored hash of r's session ID, or ErrNoSession
// when the cookie is missing or is not a well-formed ID.
func sessionIDHash(r *http.Request) (string, error) {
c, err := r.Cookie(SessionCookieName)
if err != nil {
return "", ErrNoSession
}
raw, err := base64.RawURLEncoding.DecodeString(c.Value)
if err != nil || len(raw) != sessionIDBytes {
return "", ErrNoSession
}
return hashSessionID(c.Value), nil
}
// hashSessionID is the SHA-256 hex digest of the cookie value.
func hashSessionID(id string) string {
sum := sha256.Sum256([]byte(id))
return hex.EncodeToString(sum[:])
}
// randomToken returns 256 random bits, base64url-encoded without padding.
// crypto/rand.Read never returns an error; it crashes the program instead.
func randomToken() string {
b := make([]byte, sessionIDBytes)
rand.Read(b)
return base64.RawURLEncoding.EncodeToString(b)
}
+523
View File
@@ -0,0 +1,523 @@
package auth
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"log/slog"
"net/http"
"net/http/httptest"
"slices"
"strings"
"testing"
"time"
"github.com/go-chi/chi/v5"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
)
const (
testIdle = 30 * 24 * time.Hour
testMax = 180 * 24 * time.Hour
)
// testEpoch is the fake clock's starting instant.
var testEpoch = time.Date(2026, 10, 3, 18, 30, 0, 0, time.UTC)
// sessionHarness runs the real login flow against a fake provider with a
// SessionManager over a migrated database and a fake clock.
type sessionHarness struct {
*harness
db *store.Store
sessions *SessionManager
auth *Auth
clock time.Time
}
func newSessionHarness(t *testing.T) *sessionHarness {
t.Helper()
ctx := context.Background()
db, err := store.Open(ctx, t.TempDir())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := db.Close(); err != nil {
t.Error(err)
}
})
if err := store.Migrate(ctx, db.Writer, migrations.FS); err != nil {
t.Fatal(err)
}
sessions, err := NewSessionManager(db, testIdle, testMax)
if err != nil {
t.Fatal(err)
}
h := &sessionHarness{db: db, sessions: sessions, clock: testEpoch}
sessions.now = func() time.Time { return h.clock }
p := newFakeProvider(t)
logs := &syncBuffer{}
a, err := New(ctx, Config{
Issuer: p.issuer(),
ClientID: testClientID,
ClientSecret: testClientSecret,
PublicURL: testPublicURL,
SecretKey: []byte("server-secret-key"),
AllowedGroups: testAllowedGroups,
}, sessions, slog.New(slog.NewTextHandler(logs, nil)))
if err != nil {
t.Fatal(err)
}
r := chi.NewRouter()
a.Mount(r)
h.harness = &harness{provider: p, router: r, logs: logs}
h.auth = a
return h
}
func (h *sessionHarness) advance(d time.Duration) { h.clock = h.clock.Add(d) }
// signIn completes a login and returns the callback response and the
// session cookie it set.
func (h *sessionHarness) signIn(t *testing.T) (*http.Response, *http.Cookie) {
t.Helper()
l := h.login(t, LoginPath)
resp := h.callback(l, l.query.Get("state"), h.provider.signRS256(h.provider.claims(l.query.Get("nonce"))))
if resp.StatusCode != http.StatusSeeOther {
t.Fatalf("callback status = %d, want 303", resp.StatusCode)
}
for _, c := range resp.Cookies() {
if c.Name == SessionCookieName {
return resp, c
}
}
t.Fatalf("callback set no %s cookie", SessionCookieName)
return nil, nil
}
// use looks up the session cookie c names.
func (h *sessionHarness) use(c *http.Cookie) (Session, error) {
req := httptest.NewRequest(http.MethodGet, "/api/anything", nil)
if c != nil {
req.AddCookie(c)
}
return h.sessions.SessionFor(req)
}
type sessionRow struct {
idHash, subject, email, name, groups, csrf string
created, lastSeen, expires store.Timestamp
}
func (h *sessionHarness) rows(t *testing.T) []sessionRow {
t.Helper()
rs, err := h.db.Reader.Query(`SELECT id_hash, subject, email, name, groups, csrf_token,
created_at, last_seen_at, expires_at FROM sessions`)
if err != nil {
t.Fatal(err)
}
defer rs.Close()
var out []sessionRow
for rs.Next() {
var r sessionRow
if err := rs.Scan(&r.idHash, &r.subject, &r.email, &r.name, &r.groups, &r.csrf,
&r.created, &r.lastSeen, &r.expires); err != nil {
t.Fatal(err)
}
out = append(out, r)
}
if err := rs.Err(); err != nil {
t.Fatal(err)
}
return out
}
// sessionSetCookie returns the raw Set-Cookie header for the session cookie.
func sessionSetCookie(t *testing.T, resp *http.Response) string {
t.Helper()
for _, v := range resp.Header.Values("Set-Cookie") {
if strings.HasPrefix(v, SessionCookieName+"=") {
return v
}
}
t.Fatalf("no Set-Cookie for %s in %q", SessionCookieName, resp.Header.Values("Set-Cookie"))
return ""
}
// assertSessionCookieAttributes checks the attributes the __Host- prefix
// and D-004 require, on the raw header so nothing is lost in parsing.
func assertSessionCookieAttributes(t *testing.T, raw string) {
t.Helper()
attrs := map[string]bool{}
for _, a := range strings.Split(raw, ";")[1:] {
name, _, _ := strings.Cut(strings.TrimSpace(a), "=")
attrs[strings.ToLower(name)] = true
}
for _, want := range []string{"Path=/", "HttpOnly", "Secure", "SameSite=Lax"} {
if !slices.Contains(strings.Split(raw, "; "), want) {
t.Errorf("session Set-Cookie %q lacks %s", raw, want)
}
}
if attrs["domain"] {
t.Errorf("session Set-Cookie %q has a Domain", raw)
}
}
func sha256Hex(s string) string {
sum := sha256.Sum256([]byte(s))
return hex.EncodeToString(sum[:])
}
func TestCallbackStartsSession(t *testing.T) {
t.Parallel()
h := newSessionHarness(t)
resp, cookie := h.signIn(t)
assertSessionCookieAttributes(t, sessionSetCookie(t, resp))
if cookie.MaxAge != int(testMax/time.Second) {
t.Errorf("session cookie MaxAge = %d, want %d", cookie.MaxAge, int(testMax/time.Second))
}
rows := h.rows(t)
if len(rows) != 1 {
t.Fatalf("sessions has %d rows, want 1", len(rows))
}
r := rows[0]
if len(cookie.Value) != 43 {
t.Errorf("session ID %q is %d chars, want 43 (256 bits, base64url)", cookie.Value, len(cookie.Value))
}
if r.idHash != sha256Hex(cookie.Value) {
t.Errorf("id_hash = %q, want the SHA-256 of the cookie value", r.idHash)
}
if r.subject != "user-123" || r.email != "cook@example.com" || r.name != "Home Cook" ||
r.groups != `["mealquest-admin"]` {
t.Errorf("session row = %+v, want the ID token claims", r)
}
if len(r.csrf) != 43 || r.csrf == cookie.Value {
t.Errorf("csrf_token = %q, want a separate 256-bit token", r.csrf)
}
if !r.created.Equal(testEpoch) || !r.lastSeen.Equal(testEpoch) || !r.expires.Equal(testEpoch.Add(testIdle)) {
t.Errorf("created, last_seen, expires = %v, %v, %v, want %v, %v, %v",
r.created, r.lastSeen, r.expires, testEpoch, testEpoch, testEpoch.Add(testIdle))
}
s, err := h.use(cookie)
if err != nil {
t.Fatalf("SessionFor = %v, want the new session", err)
}
if s.Subject != "user-123" || s.CSRFToken != r.csrf || !slices.Equal(s.Groups, []string{"mealquest-admin"}) {
t.Errorf("SessionFor = %+v", s)
}
}
func TestStartSessionStoresOnlyTheHash(t *testing.T) {
t.Parallel()
h := newSessionHarness(t)
_, cookie := h.signIn(t)
var n int
err := h.db.Reader.QueryRow(`SELECT count(*) FROM sessions WHERE
id_hash = ?1 OR subject = ?1 OR email = ?1 OR name = ?1 OR groups = ?1 OR csrf_token = ?1`,
cookie.Value).Scan(&n)
if err != nil {
t.Fatal(err)
}
if n != 0 {
t.Error("the raw session ID is stored in the sessions table")
}
}
func TestSessionLastSeenSlides(t *testing.T) {
t.Parallel()
h := newSessionHarness(t)
_, cookie := h.signIn(t)
// Used every 20 days, the session outlives several idle periods.
for range 4 {
h.advance(20 * 24 * time.Hour)
s, err := h.use(cookie)
if err != nil {
t.Fatalf("at %v SessionFor = %v, want the session", h.clock, err)
}
if !s.LastSeenAt.Equal(h.clock) || !s.ExpiresAt.Equal(h.clock.Add(testIdle)) {
t.Errorf("at %v session last seen %v, expires %v, want %v, %v",
h.clock, s.LastSeenAt, s.ExpiresAt, h.clock, h.clock.Add(testIdle))
}
r := h.rows(t)[0]
if !r.lastSeen.Equal(h.clock) || !r.expires.Equal(h.clock.Add(testIdle)) || !r.created.Equal(testEpoch) {
t.Errorf("at %v row created %v, last seen %v, expires %v", h.clock, r.created, r.lastSeen, r.expires)
}
}
}
func TestSessionIdleExpiry(t *testing.T) {
t.Parallel()
h := newSessionHarness(t)
_, cookie := h.signIn(t)
h.advance(testIdle - time.Millisecond)
if _, err := h.use(cookie); err != nil {
t.Fatalf("just inside the idle limit SessionFor = %v, want the session", err)
}
h.advance(testIdle)
if _, err := h.use(cookie); !errors.Is(err, ErrNoSession) {
t.Fatalf("after idle SessionFor = %v, want ErrNoSession", err)
}
if n := len(h.rows(t)); n != 0 {
t.Errorf("expired session left %d rows, want 0", n)
}
// Turning the clock back cannot revive it.
h.clock = testEpoch
if _, err := h.use(cookie); !errors.Is(err, ErrNoSession) {
t.Errorf("after deletion SessionFor = %v, want ErrNoSession", err)
}
}
func TestSessionAbsoluteExpiry(t *testing.T) {
t.Parallel()
h := newSessionHarness(t)
_, cookie := h.signIn(t)
for h.clock.Before(testEpoch.Add(testMax - 20*24*time.Hour)) {
h.advance(20 * 24 * time.Hour)
if _, err := h.use(cookie); err != nil {
t.Fatalf("at %v SessionFor = %v, want the session", h.clock, err)
}
}
// Last used 20 days before the absolute limit: the idle window would run
// past it, so expiry is capped there.
if r := h.rows(t)[0]; !r.expires.Equal(testEpoch.Add(testMax)) {
t.Errorf("expires_at = %v, want the absolute limit %v", r.expires, testEpoch.Add(testMax))
}
h.clock = testEpoch.Add(testMax - time.Millisecond)
if _, err := h.use(cookie); err != nil {
t.Fatalf("just inside the absolute limit SessionFor = %v, want the session", err)
}
h.clock = testEpoch.Add(testMax)
if _, err := h.use(cookie); !errors.Is(err, ErrNoSession) {
t.Fatalf("at the absolute limit, used 1ms ago, SessionFor = %v, want ErrNoSession", err)
}
if n := len(h.rows(t)); n != 0 {
t.Errorf("expired session left %d rows, want 0", n)
}
}
// A max with fractional seconds rounds the cookie Max-Age up, so the browser
// keeps the cookie until the server-side absolute limit.
func TestSessionCookieOutlivesFractionalMax(t *testing.T) {
t.Parallel()
h := newSessionHarness(t)
h.sessions.idle, h.sessions.max = 1500*time.Millisecond, 2500*time.Millisecond
_, cookie := h.signIn(t)
if cookie.MaxAge != 3 {
t.Errorf("session cookie MaxAge = %d, want 3", cookie.MaxAge)
}
if r := h.rows(t)[0]; !r.expires.Equal(testEpoch.Add(1500 * time.Millisecond)) {
t.Errorf("expires_at = %v, want %v", r.expires, testEpoch.Add(1500*time.Millisecond))
}
h.advance(time.Second)
if _, err := h.use(cookie); err != nil {
t.Fatalf("at 1s SessionFor = %v, want the session", err)
}
h.clock = testEpoch.Add(2500*time.Millisecond - time.Millisecond)
if _, err := h.use(cookie); err != nil {
t.Fatalf("just inside the absolute limit SessionFor = %v, want the session", err)
}
h.clock = testEpoch.Add(2500 * time.Millisecond)
if _, err := h.use(cookie); !errors.Is(err, ErrNoSession) {
t.Errorf("at the absolute limit SessionFor = %v, want ErrNoSession", err)
}
}
// A lifetime near the largest accepted duration still yields a positive,
// rounded-up cookie Max-Age rather than overflowing.
func TestSessionCookieMaxAgeNearLargestMax(t *testing.T) {
t.Parallel()
largest, err := time.ParseDuration("2562047h47m16.854s")
if err != nil {
t.Fatal(err)
}
h := newSessionHarness(t)
h.sessions.max = largest
_, cookie := h.signIn(t)
if want := 9223372037; cookie.MaxAge != want {
t.Errorf("session cookie MaxAge = %d, want %d", cookie.MaxAge, want)
}
}
// A lifetime shortened by configuration applies to existing sessions even
// though their stored expires_at is later.
func TestSessionLifetimeChecksUseCurrentConfig(t *testing.T) {
t.Parallel()
h := newSessionHarness(t)
_, cookie := h.signIn(t)
h.sessions.idle = 24 * time.Hour
h.advance(24 * time.Hour)
if _, err := h.use(cookie); !errors.Is(err, ErrNoSession) {
t.Errorf("past the shortened idle SessionFor = %v, want ErrNoSession", err)
}
_, cookie = h.signIn(t)
h.sessions.idle, h.sessions.max = 24*time.Hour, 48*time.Hour
for range 3 {
h.advance(12 * time.Hour)
if _, err := h.use(cookie); err != nil {
t.Fatalf("SessionFor = %v, want the session", err)
}
}
h.advance(12 * time.Hour)
if _, err := h.use(cookie); !errors.Is(err, ErrNoSession) {
t.Errorf("past the shortened max SessionFor = %v, want ErrNoSession", err)
}
// Used recently and with expires_at still ahead, but older than a max
// shortened after that use: only the absolute check ends it.
h.sessions.idle, h.sessions.max = testIdle, testMax
_, cookie = h.signIn(t)
h.advance(10 * 24 * time.Hour)
if _, err := h.use(cookie); err != nil {
t.Fatalf("SessionFor = %v, want the session", err)
}
h.sessions.max = 10 * 24 * time.Hour
h.advance(time.Hour)
if _, err := h.use(cookie); !errors.Is(err, ErrNoSession) {
t.Errorf("past a max shortened after the last use SessionFor = %v, want ErrNoSession", err)
}
}
func TestSessionForRejectsBadCookies(t *testing.T) {
t.Parallel()
h := newSessionHarness(t)
h.signIn(t)
for name, c := range map[string]*http.Cookie{
"missing": nil,
"empty": {Name: SessionCookieName, Value: ""},
"not base64": {Name: SessionCookieName, Value: strings.Repeat("!", 43)},
"short": {Name: SessionCookieName, Value: "AAAA"},
"unknown ID": {Name: SessionCookieName, Value: randomToken()},
"other cookie": {Name: "mealquest_session", Value: randomToken()},
} {
if _, err := h.use(c); !errors.Is(err, ErrNoSession) {
t.Errorf("%s: SessionFor = %v, want ErrNoSession", name, err)
}
}
if n := len(h.rows(t)); n != 1 {
t.Errorf("sessions has %d rows, want the signed-in session only", n)
}
}
func (h *sessionHarness) logout(cookies ...*http.Cookie) *http.Response {
req := httptest.NewRequest(http.MethodPost, LogoutPath, nil)
for _, c := range cookies {
req.AddCookie(c)
}
rec := httptest.NewRecorder()
h.router.ServeHTTP(rec, req)
return rec.Result()
}
func TestLogout(t *testing.T) {
t.Parallel()
h := newSessionHarness(t)
_, cookie := h.signIn(t)
_, other := h.signIn(t)
resp := h.logout(cookie)
if resp.StatusCode != http.StatusSeeOther {
t.Fatalf("logout status = %d, want 303", resp.StatusCode)
}
want := h.provider.issuer() + "/end-session/"
if got := resp.Header.Get("Location"); got != want || h.auth.EndSessionURL() != want {
t.Errorf("logout redirect = %q, EndSessionURL = %q, want %q", got, h.auth.EndSessionURL(), want)
}
raw := sessionSetCookie(t, resp)
assertSessionCookieAttributes(t, raw)
if !strings.HasPrefix(raw, SessionCookieName+"=;") || !strings.Contains(raw, "Max-Age=0") {
t.Errorf("logout Set-Cookie = %q, want the cookie cleared", raw)
}
rows := h.rows(t)
if len(rows) != 1 || rows[0].idHash != sha256Hex(other.Value) {
t.Errorf("after logout sessions = %+v, want only the other session", rows)
}
if _, err := h.use(cookie); !errors.Is(err, ErrNoSession) {
t.Errorf("logged-out cookie: SessionFor = %v, want ErrNoSession", err)
}
if _, err := h.use(other); err != nil {
t.Errorf("other session: SessionFor = %v, want it untouched", err)
}
}
func TestLogoutWithoutSession(t *testing.T) {
t.Parallel()
h := newSessionHarness(t)
resp := h.logout()
if resp.StatusCode != http.StatusSeeOther || resp.Header.Get("Location") != h.provider.issuer()+"/end-session/" {
t.Errorf("logout = %d to %q, want 303 to the end session endpoint", resp.StatusCode, resp.Header.Get("Location"))
}
assertSessionCookieAttributes(t, sessionSetCookie(t, resp))
}
func TestLogoutIsPostOnly(t *testing.T) {
t.Parallel()
h := newSessionHarness(t)
_, cookie := h.signIn(t)
if resp := h.get(LogoutPath, cookie); resp.StatusCode != http.StatusMethodNotAllowed {
t.Errorf("GET %s status = %d, want 405", LogoutPath, resp.StatusCode)
}
if _, err := h.use(cookie); err != nil {
t.Errorf("after GET %s SessionFor = %v, want the session", LogoutPath, err)
}
}
func TestLogoutSessionError(t *testing.T) {
t.Parallel()
h := newHarness(t)
h.sessions.err = errors.New("database down")
rec := httptest.NewRecorder()
h.router.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, LogoutPath, nil))
if rec.Code != http.StatusInternalServerError {
t.Errorf("logout status = %d, want 500", rec.Code)
}
}
func TestNewRequiresEndSessionEndpoint(t *testing.T) {
t.Parallel()
p := newFakeProvider(t)
p.discoveryOverrides = map[string]any{"end_session_endpoint": nil}
_, err := New(context.Background(), Config{
Issuer: p.issuer(),
ClientID: testClientID,
ClientSecret: testClientSecret,
PublicURL: testPublicURL,
SecretKey: []byte("k"),
AllowedGroups: testAllowedGroups,
}, &memorySessions{}, slog.New(slog.DiscardHandler))
if err == nil || !strings.Contains(err.Error(), "end session endpoint") {
t.Errorf("New error = %v, want the missing end session endpoint named", err)
}
}
func TestNewSessionManagerValidates(t *testing.T) {
t.Parallel()
db := &store.Store{}
for _, tc := range []struct {
name string
db *store.Store
idle, max time.Duration
}{
{"nil store", nil, time.Hour, time.Hour},
{"zero idle", db, 0, time.Hour},
{"negative max", db, time.Hour, -time.Hour},
{"max below idle", db, 2 * time.Hour, time.Hour},
} {
if _, err := NewSessionManager(tc.db, tc.idle, tc.max); err == nil {
t.Errorf("%s: NewSessionManager succeeded", tc.name)
}
}
}
+373
View File
@@ -0,0 +1,373 @@
// Package config loads MealQuest configuration from MEALQUEST_* environment
// variables. Secrets are read only from files named by *_FILE variables.
package config
import (
"errors"
"fmt"
"net"
"net/url"
"os"
"strconv"
"strings"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secureurl"
)
// Environment variable names.
const (
EnvHTTPAddr = "MEALQUEST_HTTP_ADDR"
EnvPublicURL = "MEALQUEST_PUBLIC_URL"
EnvDataDir = "MEALQUEST_DATA_DIR"
EnvShutdownTimeout = "MEALQUEST_SHUTDOWN_TIMEOUT"
EnvSecretKeyFile = "MEALQUEST_SECRET_KEY_FILE"
EnvOIDCIssuer = "MEALQUEST_OIDC_ISSUER"
EnvOIDCClientID = "MEALQUEST_OIDC_CLIENT_ID"
EnvOIDCClientSecretFile = "MEALQUEST_OIDC_CLIENT_SECRET_FILE"
EnvOIDCAllowedGroups = "MEALQUEST_OIDC_ALLOWED_GROUPS"
EnvSessionIdle = "MEALQUEST_SESSION_IDLE"
EnvSessionMax = "MEALQUEST_SESSION_MAX"
EnvTrustedProxy = "MEALQUEST_TRUSTED_PROXY"
EnvDefaultTZ = "MEALQUEST_DEFAULT_TZ"
)
// Session lifetime defaults (D-004): long enough that the kitchen tablet
// stays signed in.
const (
DefaultSessionIdle = 720 * time.Hour // 30 days
DefaultSessionMax = 4320 * time.Hour // 180 days
)
// Config is the validated service configuration.
type Config struct {
HTTPAddr string
PublicURL *url.URL
DataDir string
ShutdownTimeout time.Duration
SecretKey Secret
// OIDCIssuer is the Authentik issuer URL used for discovery.
OIDCIssuer *url.URL
OIDCClientID string
OIDCClientSecret Secret
// OIDCAllowedGroups lists the groups whose members may sign in.
OIDCAllowedGroups []string
// SessionIdle ends a session left unused this long.
SessionIdle time.Duration
// SessionMax ends every session this long after sign-in, used or not.
SessionMax time.Duration
// TrustedProxy lists the reverse proxies whose X-Forwarded-For and
// X-Forwarded-Proto headers are honoured. Empty trusts no peer.
TrustedProxy forwarded.Trusted
// DefaultTZ is the IANA zone the household settings are created with on
// first boot. It is optional and not validated here: boot requires and
// validates it only while the settings do not exist yet, and ignores it
// once they do. Empty when unset.
DefaultTZ string
}
// PublicOrigin returns the scheme and host of PublicURL, the only parts of it
// that are safe to log.
func (c Config) PublicOrigin() string {
return c.PublicURL.Scheme + "://" + c.PublicURL.Host
}
// Load reads and validates configuration from the process environment.
// It reports every missing or invalid variable in a single *Error.
func Load() (Config, error) {
return load(os.LookupEnv)
}
func load(lookup func(string) (string, bool)) (Config, error) {
r := reader{lookup: lookup}
cfg := Config{
HTTPAddr: r.listenAddr(EnvHTTPAddr),
PublicURL: r.secureURL(EnvPublicURL),
DataDir: r.dir(EnvDataDir),
ShutdownTimeout: r.duration(EnvShutdownTimeout),
SecretKey: r.secretFile(EnvSecretKeyFile),
OIDCIssuer: r.secureURL(EnvOIDCIssuer),
OIDCClientID: r.str(EnvOIDCClientID),
OIDCClientSecret: r.secretFile(EnvOIDCClientSecretFile),
OIDCAllowedGroups: r.list(EnvOIDCAllowedGroups),
SessionIdle: r.sessionDuration(EnvSessionIdle, DefaultSessionIdle),
SessionMax: r.sessionDuration(EnvSessionMax, DefaultSessionMax),
TrustedProxy: r.trustedProxy(EnvTrustedProxy),
DefaultTZ: r.optional(EnvDefaultTZ),
}
if cfg.SessionIdle > 0 && cfg.SessionMax > 0 && cfg.SessionMax < cfg.SessionIdle {
r.fail(EnvSessionMax, "must be at least %s (%s)", EnvSessionIdle, cfg.SessionIdle)
}
if len(r.problems) > 0 {
return Config{}, &Error{Problems: r.problems}
}
return cfg, nil
}
// Problem describes one missing or invalid variable.
type Problem struct {
Var string
Reason string
}
// Error lists every configuration problem found by Load.
type Error struct {
Problems []Problem
}
func (e *Error) Error() string {
var b strings.Builder
b.WriteString("invalid configuration:")
for _, p := range e.Problems {
fmt.Fprintf(&b, "\n %s: %s", p.Var, p.Reason)
}
return b.String()
}
// Secret holds a sensitive value. It never prints its contents.
type Secret struct {
value string
}
// Reveal returns the secret value. Call it only where the value is used.
func (s Secret) Reveal() string { return s.value }
// String redacts the secret so it cannot leak through fmt or logs.
func (s Secret) String() string { return "[REDACTED]" }
// GoString redacts the secret for %#v.
func (s Secret) GoString() string { return "[REDACTED]" }
// reader reads variables and collects every problem instead of stopping at
// the first one.
type reader struct {
lookup func(string) (string, bool)
problems []Problem
}
func (r *reader) fail(name, format string, args ...any) {
r.problems = append(r.problems, Problem{Var: name, Reason: fmt.Sprintf(format, args...)})
}
// required returns the variable's value, recording a problem when it is
// unset or empty.
func (r *reader) required(name string) (string, bool) {
v, ok := r.lookup(name)
if !ok || strings.TrimSpace(v) == "" {
r.fail(name, "required but not set")
return "", false
}
return v, true
}
func (r *reader) str(name string) string {
v, _ := r.required(name)
return v
}
// list parses a comma-separated list. Every entry must be non-empty after
// trimming spaces.
func (r *reader) list(name string) []string {
v, ok := r.required(name)
if !ok {
return nil
}
items := strings.Split(v, ",")
for i, item := range items {
items[i] = strings.TrimSpace(item)
if items[i] == "" {
r.fail(name, "empty entry in comma-separated list %q", v)
return nil
}
}
return items
}
// trustedProxy parses an optional comma-separated list of IP addresses and
// CIDR prefixes with forwarded.ParseTrusted. Unset or blank trusts no peer.
func (r *reader) trustedProxy(name string) forwarded.Trusted {
v, _ := r.lookup(name)
t, err := forwarded.ParseTrusted(v)
if err != nil {
r.fail(name, "%v", err)
return nil
}
return t
}
func (r *reader) listenAddr(name string) string {
v, ok := r.required(name)
if !ok {
return ""
}
host, port, err := net.SplitHostPort(v)
if err != nil {
r.fail(name, "invalid listen address %q (want host:port or :port)", v)
return ""
}
// An empty host listens on all interfaces.
if host != "" && net.ParseIP(host) == nil && !validHostname(host) {
r.fail(name, "invalid host %q in listen address (want IP address or hostname)", host)
return ""
}
if _, err := strconv.ParseUint(port, 10, 16); err != nil {
r.fail(name, "invalid port %q in listen address (want 0-65535)", port)
return ""
}
return v
}
// validHostname reports whether h is a DNS hostname: dot-separated labels of
// 1-63 letters, digits and hyphens, not starting or ending with a hyphen.
func validHostname(h string) bool {
if len(h) > 253 {
return false
}
for _, label := range strings.Split(strings.TrimSuffix(h, "."), ".") {
if len(label) < 1 || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' {
return false
}
for _, c := range label {
if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '-') {
return false
}
}
}
return true
}
// secureURL reads an absolute URL that must pass secureurl.Check: https://,
// or http:// to a loopback host only.
func (r *reader) secureURL(name string) *url.URL {
v, ok := r.required(name)
if !ok {
return nil
}
// The value is never echoed: it may carry credentials.
u, err := url.Parse(v)
if err != nil {
r.fail(name, "invalid URL (want absolute https:// URL)")
return nil
}
if err := secureurl.Check(u); err != nil {
r.fail(name, "invalid URL: %v", err)
return nil
}
if u.User != nil {
r.fail(name, "URL must not contain user credentials")
return nil
}
return u
}
func (r *reader) dir(name string) string {
v, ok := r.required(name)
if !ok {
return ""
}
info, err := os.Stat(v)
if err != nil {
r.fail(name, "cannot access directory %q: %v", v, err)
return ""
}
if !info.IsDir() {
r.fail(name, "%q is not a directory", v)
return ""
}
return v
}
func (r *reader) duration(name string) time.Duration {
v, ok := r.required(name)
if !ok {
return 0
}
return r.parseDuration(name, v)
}
// optionalDuration returns def when name is unset. A set value, even an empty
// one, must be a valid positive duration.
func (r *reader) optionalDuration(name string, def time.Duration) time.Duration {
v, ok := r.lookup(name)
if !ok {
return def
}
return r.parseDuration(name, v)
}
// sessionDuration is optionalDuration restricted to whole milliseconds:
// session expiry is stored as a store.Timestamp, which keeps millisecond
// precision, so a finer duration could not be honoured.
func (r *reader) sessionDuration(name string, def time.Duration) time.Duration {
d := r.optionalDuration(name, def)
if d%time.Millisecond != 0 {
r.fail(name, "duration must be a whole number of milliseconds, got %s", d)
return 0
}
return d
}
func (r *reader) parseDuration(name, v string) time.Duration {
d, err := time.ParseDuration(v)
if err != nil {
r.fail(name, "invalid duration %q (want e.g. 10s, 1m)", v)
return 0
}
if d <= 0 {
r.fail(name, "duration must be positive, got %q", v)
return 0
}
return d
}
// optional returns the variable's value, or "" when it is unset.
func (r *reader) optional(name string) string {
v, _ := r.lookup(name)
return v
}
// secretFile reads a secret from the file named by name, which must end in
// _FILE. Setting the secret inline (name without _FILE) is rejected.
func (r *reader) secretFile(name string) Secret {
inline := strings.TrimSuffix(name, "_FILE")
if _, set := r.lookup(inline); set {
r.fail(inline, "secrets must not be set inline; use %s", name)
}
path, ok := r.required(name)
if !ok {
return Secret{}
}
data, err := os.ReadFile(path)
if err != nil {
// Report the cause without echoing any file contents.
r.fail(name, "cannot read secret file %q: %v", path, unwrapPathError(err))
return Secret{}
}
value := strings.TrimRight(string(data), "\r\n")
if strings.TrimSpace(value) == "" {
r.fail(name, "secret file %q is empty", path)
return Secret{}
}
return Secret{value: value}
}
func unwrapPathError(err error) error {
var pe *os.PathError
if errors.As(err, &pe) {
return pe.Err
}
return err
}
+530
View File
@@ -0,0 +1,530 @@
package config
import (
"errors"
"fmt"
"net/netip"
"net/url"
"os"
"path/filepath"
"reflect"
"slices"
"strings"
"testing"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
)
func TestLoad(t *testing.T) {
t.Parallel()
dir := t.TempDir()
secretPath := filepath.Join(dir, "secret_key")
if err := os.WriteFile(secretPath, []byte("s3cret-value\n"), 0o600); err != nil {
t.Fatal(err)
}
unreadablePath := filepath.Join(dir, "missing_secret")
clientSecretPath := filepath.Join(dir, "oidc_client_secret")
if err := os.WriteFile(clientSecretPath, []byte("client-s3cret\n"), 0o600); err != nil {
t.Fatal(err)
}
emptyPath := filepath.Join(dir, "empty_secret")
if err := os.WriteFile(emptyPath, []byte("\n"), 0o600); err != nil {
t.Fatal(err)
}
valid := func() map[string]string {
return map[string]string{
EnvHTTPAddr: ":8080",
EnvPublicURL: "https://meals.example.com",
EnvDataDir: dir,
EnvShutdownTimeout: "15s",
EnvSecretKeyFile: secretPath,
EnvOIDCIssuer: "https://auth.example.com/application/o/mealquest/",
EnvOIDCClientID: "mealquest",
EnvOIDCClientSecretFile: clientSecretPath,
EnvOIDCAllowedGroups: "mealquest-admin",
}
}
with := func(changes map[string]string, unset ...string) map[string]string {
env := valid()
for k, v := range changes {
env[k] = v
}
for _, k := range unset {
delete(env, k)
}
return env
}
tests := []struct {
name string
env map[string]string
badVars []string // variables the error must name; nil means success
}{
{
name: "all required vars present",
env: valid(),
},
{
name: "default time zone set",
env: with(map[string]string{EnvDefaultTZ: "America/New_York"}),
},
{
// Validated only when seeding the household settings, so a
// stale value cannot stop a boot that no longer uses it.
name: "default time zone not an IANA zone",
env: with(map[string]string{EnvDefaultTZ: "Mars/Olympus_Mons"}),
},
{
name: "one missing",
env: with(nil, EnvHTTPAddr),
badVars: []string{EnvHTTPAddr},
},
{
name: "empty value counts as missing",
env: with(map[string]string{EnvDataDir: ""}),
badVars: []string{EnvDataDir},
},
{
name: "all missing are collected",
env: map[string]string{},
badVars: []string{
EnvHTTPAddr, EnvPublicURL, EnvDataDir, EnvShutdownTimeout, EnvSecretKeyFile,
EnvOIDCIssuer, EnvOIDCClientID, EnvOIDCClientSecretFile, EnvOIDCAllowedGroups,
},
},
{
name: "invalid duration",
env: with(map[string]string{EnvShutdownTimeout: "ten seconds"}),
badVars: []string{EnvShutdownTimeout},
},
{
name: "non-positive duration",
env: with(map[string]string{EnvShutdownTimeout: "0s"}),
badVars: []string{EnvShutdownTimeout},
},
{
name: "invalid listen address",
env: with(map[string]string{EnvHTTPAddr: "8080"}),
badVars: []string{EnvHTTPAddr},
},
{
name: "non-numeric port",
env: with(map[string]string{EnvHTTPAddr: ":notaport"}),
badVars: []string{EnvHTTPAddr},
},
{
name: "empty port",
env: with(map[string]string{EnvHTTPAddr: "127.0.0.1:"}),
badVars: []string{EnvHTTPAddr},
},
{
name: "port out of range",
env: with(map[string]string{EnvHTTPAddr: ":65536"}),
badVars: []string{EnvHTTPAddr},
},
{
name: "listen address with IP host",
env: with(map[string]string{EnvHTTPAddr: "127.0.0.1:8080"}),
},
{
name: "listen address with IPv6 host",
env: with(map[string]string{EnvHTTPAddr: "[::1]:8080"}),
},
{
name: "listen address with hostname",
env: with(map[string]string{EnvHTTPAddr: "localhost:8080"}),
},
{
name: "listen host with space",
env: with(map[string]string{EnvHTTPAddr: "bad host:8080"}),
badVars: []string{EnvHTTPAddr},
},
{
name: "listen host with leading hyphen label",
env: with(map[string]string{EnvHTTPAddr: "-bad.example.com:8080"}),
badVars: []string{EnvHTTPAddr},
},
{
name: "listen host with empty label",
env: with(map[string]string{EnvHTTPAddr: "bad..example.com:8080"}),
badVars: []string{EnvHTTPAddr},
},
{
name: "listen host with label over 63 chars",
env: with(map[string]string{EnvHTTPAddr: strings.Repeat("a", 64) + ".example.com:8080"}),
badVars: []string{EnvHTTPAddr},
},
{
name: "public URL without hostname",
env: with(map[string]string{EnvPublicURL: "http://:8080"}),
badVars: []string{EnvPublicURL},
},
{
name: "plain HTTP public URL on a remote host",
env: with(map[string]string{EnvPublicURL: "http://meals.example.com"}),
badVars: []string{EnvPublicURL},
},
{
name: "plain HTTP public URL on a non-loopback IP",
env: with(map[string]string{EnvPublicURL: "http://192.168.1.20:8080"}),
badVars: []string{EnvPublicURL},
},
{
name: "plain HTTP public URL on 127.0.0.1",
env: with(map[string]string{EnvPublicURL: "http://127.0.0.1:8080"}),
},
{
name: "plain HTTP public URL on [::1]",
env: with(map[string]string{EnvPublicURL: "http://[::1]:8080"}),
},
{
name: "relative public URL",
env: with(map[string]string{EnvPublicURL: "meals.example.com"}),
badVars: []string{EnvPublicURL},
},
{
name: "data dir does not exist",
env: with(map[string]string{EnvDataDir: filepath.Join(dir, "nope")}),
badVars: []string{EnvDataDir},
},
{
name: "unreadable secret file",
env: with(map[string]string{EnvSecretKeyFile: unreadablePath}),
badVars: []string{EnvSecretKeyFile},
},
{
name: "secret file path is a directory",
env: with(map[string]string{EnvSecretKeyFile: dir}),
badVars: []string{EnvSecretKeyFile},
},
{
name: "empty secret file",
env: with(map[string]string{EnvSecretKeyFile: emptyPath}),
badVars: []string{EnvSecretKeyFile},
},
{
name: "inline secret rejected",
env: with(map[string]string{"MEALQUEST_SECRET_KEY": "inline"}),
badVars: []string{"MEALQUEST_SECRET_KEY"},
},
{
name: "inline OIDC client secret rejected",
env: with(map[string]string{"MEALQUEST_OIDC_CLIENT_SECRET": "inline"}),
badVars: []string{"MEALQUEST_OIDC_CLIENT_SECRET"},
},
{
name: "unreadable OIDC client secret file",
env: with(map[string]string{EnvOIDCClientSecretFile: unreadablePath}),
badVars: []string{EnvOIDCClientSecretFile},
},
{
name: "relative OIDC issuer",
env: with(map[string]string{EnvOIDCIssuer: "auth.example.com"}),
badVars: []string{EnvOIDCIssuer},
},
{
name: "non-http OIDC issuer",
env: with(map[string]string{EnvOIDCIssuer: "ftp://auth.example.com"}),
badVars: []string{EnvOIDCIssuer},
},
{
name: "plain HTTP OIDC issuer on a remote host",
env: with(map[string]string{EnvOIDCIssuer: "http://auth.example.com/application/o/mealquest/"}),
badVars: []string{EnvOIDCIssuer},
},
{
name: "plain HTTP OIDC issuer on localhost",
env: with(map[string]string{EnvOIDCIssuer: "http://localhost:9000/application/o/mealquest/"}),
},
{
name: "several allowed groups",
env: with(map[string]string{EnvOIDCAllowedGroups: "mealquest-admin, family"}),
},
{
name: "empty allowed group entry",
env: with(map[string]string{EnvOIDCAllowedGroups: "mealquest-admin,,family"}),
badVars: []string{EnvOIDCAllowedGroups},
},
{
name: "trailing comma in allowed groups",
env: with(map[string]string{EnvOIDCAllowedGroups: "mealquest-admin,"}),
badVars: []string{EnvOIDCAllowedGroups},
},
{
name: "blank OIDC client ID",
env: with(map[string]string{EnvOIDCClientID: " "}),
badVars: []string{EnvOIDCClientID},
},
{
name: "session lifetimes set",
env: with(map[string]string{EnvSessionIdle: "24h", EnvSessionMax: "48h"}),
},
{
name: "session max equal to idle",
env: with(map[string]string{EnvSessionIdle: "24h", EnvSessionMax: "24h"}),
},
{
name: "invalid session idle",
env: with(map[string]string{EnvSessionIdle: "a month"}),
badVars: []string{EnvSessionIdle},
},
{
name: "empty session idle",
env: with(map[string]string{EnvSessionIdle: ""}),
badVars: []string{EnvSessionIdle},
},
{
name: "non-positive session max",
env: with(map[string]string{EnvSessionMax: "-1h"}),
badVars: []string{EnvSessionMax},
},
{
name: "whole-millisecond session lifetimes",
env: with(map[string]string{EnvSessionIdle: "1ms", EnvSessionMax: "2ms"}),
},
{
name: "sub-millisecond session idle",
env: with(map[string]string{EnvSessionIdle: "1500us", EnvSessionMax: "2ms"}),
badVars: []string{EnvSessionIdle},
},
{
name: "sub-millisecond session max",
env: with(map[string]string{EnvSessionIdle: "1ms", EnvSessionMax: "1ms500us"}),
badVars: []string{EnvSessionMax},
},
{
name: "session max below idle",
env: with(map[string]string{EnvSessionIdle: "48h", EnvSessionMax: "24h"}),
badVars: []string{EnvSessionMax},
},
{
name: "session max below default idle",
env: with(map[string]string{EnvSessionMax: "719h"}),
badVars: []string{EnvSessionMax},
},
{
name: "trusted proxy IP",
env: with(map[string]string{EnvTrustedProxy: "10.60.1.33"}),
},
{
name: "trusted proxy IPs and CIDRs",
env: with(map[string]string{EnvTrustedProxy: "10.60.1.33, 172.16.0.0/12,2001:db8::/32,::1"}),
},
{
name: "blank trusted proxy trusts no peer",
env: with(map[string]string{EnvTrustedProxy: " "}),
},
{
name: "trusted proxy not an IP",
env: with(map[string]string{EnvTrustedProxy: "traefik"}),
badVars: []string{EnvTrustedProxy},
},
{
name: "trusted proxy invalid CIDR",
env: with(map[string]string{EnvTrustedProxy: "10.60.1.0/33"}),
badVars: []string{EnvTrustedProxy},
},
{
name: "trusted proxy CIDR with host bits",
env: with(map[string]string{EnvTrustedProxy: "10.60.1.33/24"}),
badVars: []string{EnvTrustedProxy},
},
{
name: "trusted proxy empty entry",
env: with(map[string]string{EnvTrustedProxy: "10.60.1.33,,10.60.1.34"}),
badVars: []string{EnvTrustedProxy},
},
{
name: "trusted proxy trusting everyone",
env: with(map[string]string{EnvTrustedProxy: "0.0.0.0/0"}),
badVars: []string{EnvTrustedProxy},
},
{
name: "trusted proxy with port",
env: with(map[string]string{EnvTrustedProxy: "10.60.1.33:443"}),
badVars: []string{EnvTrustedProxy},
},
{
name: "several invalid values are all named",
env: with(map[string]string{
EnvShutdownTimeout: "soon",
EnvSecretKeyFile: unreadablePath,
}, EnvPublicURL),
badVars: []string{EnvPublicURL, EnvShutdownTimeout, EnvSecretKeyFile},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
lookup := func(k string) (string, bool) {
v, ok := tc.env[k]
return v, ok
}
cfg, err := load(lookup)
if tc.badVars == nil {
if err != nil {
t.Fatalf("load() error = %v, want nil", err)
}
return
}
var cerr *Error
if !errors.As(err, &cerr) {
t.Fatalf("load() error = %v, want *Error", err)
}
var got []string
for _, p := range cerr.Problems {
got = append(got, p.Var)
}
if !slices.Equal(got, tc.badVars) {
t.Errorf("problem vars = %v, want %v", got, tc.badVars)
}
for _, v := range tc.badVars {
if !strings.Contains(err.Error(), v) {
t.Errorf("error message %q does not name %s", err.Error(), v)
}
}
if !reflect.ValueOf(cfg).IsZero() {
t.Errorf("load() returned non-zero Config alongside error: %+v", cfg)
}
})
}
}
func TestLoadValues(t *testing.T) {
t.Parallel()
dir := t.TempDir()
secretPath := filepath.Join(dir, "secret_key")
if err := os.WriteFile(secretPath, []byte("s3cret-value\r\n"), 0o600); err != nil {
t.Fatal(err)
}
clientSecretPath := filepath.Join(dir, "oidc_client_secret")
if err := os.WriteFile(clientSecretPath, []byte("client-s3cret\n"), 0o600); err != nil {
t.Fatal(err)
}
env := map[string]string{
EnvHTTPAddr: "127.0.0.1:9000",
EnvPublicURL: "http://localhost:9000",
EnvDataDir: dir,
EnvShutdownTimeout: "1m30s",
EnvSecretKeyFile: secretPath,
EnvOIDCIssuer: "https://auth.example.com/application/o/mealquest/",
EnvOIDCClientID: "mealquest",
EnvOIDCClientSecretFile: clientSecretPath,
EnvOIDCAllowedGroups: " mealquest-admin ,family",
EnvDefaultTZ: "Europe/London",
}
cfg, err := load(func(k string) (string, bool) { v, ok := env[k]; return v, ok })
if err != nil {
t.Fatalf("load() error = %v", err)
}
if cfg.HTTPAddr != "127.0.0.1:9000" {
t.Errorf("HTTPAddr = %q", cfg.HTTPAddr)
}
if cfg.PublicURL.String() != "http://localhost:9000" {
t.Errorf("PublicURL = %q", cfg.PublicURL)
}
if cfg.DataDir != dir {
t.Errorf("DataDir = %q, want %q", cfg.DataDir, dir)
}
if cfg.ShutdownTimeout != 90*time.Second {
t.Errorf("ShutdownTimeout = %v, want 1m30s", cfg.ShutdownTimeout)
}
if cfg.SecretKey.Reveal() != "s3cret-value" {
t.Errorf("SecretKey.Reveal() = %q, want trailing newline trimmed", cfg.SecretKey.Reveal())
}
if got, want := cfg.OIDCIssuer.String(), "https://auth.example.com/application/o/mealquest/"; got != want {
t.Errorf("OIDCIssuer = %q, want %q", got, want)
}
if cfg.OIDCClientID != "mealquest" {
t.Errorf("OIDCClientID = %q", cfg.OIDCClientID)
}
if cfg.OIDCClientSecret.Reveal() != "client-s3cret" {
t.Errorf("OIDCClientSecret.Reveal() = %q", cfg.OIDCClientSecret.Reveal())
}
if want := []string{"mealquest-admin", "family"}; !slices.Equal(cfg.OIDCAllowedGroups, want) {
t.Errorf("OIDCAllowedGroups = %q, want %q", cfg.OIDCAllowedGroups, want)
}
if cfg.SessionIdle != 720*time.Hour || cfg.SessionMax != 4320*time.Hour {
t.Errorf("SessionIdle, SessionMax = %v, %v, want defaults 720h, 4320h", cfg.SessionIdle, cfg.SessionMax)
}
if cfg.TrustedProxy != nil {
t.Errorf("TrustedProxy = %v, want none when unset", cfg.TrustedProxy)
}
env[EnvSessionIdle] = "12h"
env[EnvSessionMax] = "36h"
env[EnvTrustedProxy] = "10.60.1.33, 172.16.0.0/12"
cfg, err = load(func(k string) (string, bool) { v, ok := env[k]; return v, ok })
if err != nil {
t.Fatalf("load() error = %v", err)
}
if cfg.SessionIdle != 12*time.Hour || cfg.SessionMax != 36*time.Hour {
t.Errorf("SessionIdle, SessionMax = %v, %v, want 12h, 36h", cfg.SessionIdle, cfg.SessionMax)
}
want := forwarded.Trusted{netip.MustParsePrefix("10.60.1.33/32"), netip.MustParsePrefix("172.16.0.0/12")}
if !slices.Equal(cfg.TrustedProxy, want) {
t.Errorf("TrustedProxy = %v, want %v", cfg.TrustedProxy, want)
}
if cfg.DefaultTZ != "Europe/London" {
t.Errorf("DefaultTZ = %q, want Europe/London", cfg.DefaultTZ)
}
}
func TestSecretRedacted(t *testing.T) {
t.Parallel()
s := Secret{value: "s3cret-value"}
for _, format := range []string{"%v", "%s", "%+v", "%#v"} {
cfg := Config{SecretKey: s, OIDCClientSecret: s}
for _, out := range []string{fmt.Sprintf(format, s), fmt.Sprintf(format, cfg)} {
if strings.Contains(out, "s3cret-value") {
t.Errorf("format %s leaked secret: %s", format, out)
}
}
}
}
func TestPublicURLWithCredentialsRejected(t *testing.T) {
t.Parallel()
for _, raw := range []string{
"https://alice:hunter2@example.com",
"https://alice@example.com",
"https://alice:hunter2@[bad",
} {
env := map[string]string{EnvPublicURL: raw}
_, err := load(func(k string) (string, bool) { v, ok := env[k]; return v, ok })
var cerr *Error
if !errors.As(err, &cerr) || !slices.ContainsFunc(cerr.Problems, func(p Problem) bool { return p.Var == EnvPublicURL }) {
t.Errorf("load(%q) error = %v, want problem for %s", raw, err, EnvPublicURL)
}
if err != nil && (strings.Contains(err.Error(), "hunter2") || strings.Contains(err.Error(), "alice")) {
t.Errorf("load(%q) error leaks credentials: %v", raw, err)
}
}
}
func TestPublicOrigin(t *testing.T) {
t.Parallel()
u, err := url.Parse("https://alice:hunter2@meals.example.com:8443/app?token=x#frag")
if err != nil {
t.Fatal(err)
}
if got, want := (Config{PublicURL: u}).PublicOrigin(), "https://meals.example.com:8443"; got != want {
t.Errorf("PublicOrigin() = %q, want %q", got, want)
}
}
+31
View File
@@ -0,0 +1,31 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
package db
import (
"context"
"database/sql"
)
type DBTX interface {
ExecContext(context.Context, string, ...interface{}) (sql.Result, error)
PrepareContext(context.Context, string) (*sql.Stmt, error)
QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error)
QueryRowContext(context.Context, string, ...interface{}) *sql.Row
}
func New(db DBTX) *Queries {
return &Queries{db: db}
}
type Queries struct {
db DBTX
}
func (q *Queries) WithTx(tx *sql.Tx) *Queries {
return &Queries{
db: tx,
}
}
+564
View File
@@ -0,0 +1,564 @@
package db_test
import (
"context"
"database/sql"
"errors"
"reflect"
"testing"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
)
const (
created = "2026-10-03T12:00:00.000Z"
updated = "2026-10-04T12:00:00.000Z"
)
// openQueries returns queries on the writer of a freshly migrated database
// in a temp directory.
func openQueries(t *testing.T) (*store.Store, *db.Queries) {
t.Helper()
ctx := context.Background()
s, err := store.Open(ctx, t.TempDir())
if err != nil {
t.Fatalf("Open: %v", err)
}
t.Cleanup(func() {
if err := s.Close(); err != nil {
t.Errorf("Close: %v", err)
}
})
if err := store.Migrate(ctx, s.Writer, migrations.FS); err != nil {
t.Fatalf("Migrate: %v", err)
}
return s, db.New(s.Writer)
}
func str(s string) sql.NullString { return sql.NullString{String: s, Valid: true} }
func num(n int64) sql.NullInt64 { return sql.NullInt64{Int64: n, Valid: true} }
func mustNoRows(t *testing.T, what string, err error) {
t.Helper()
if !errors.Is(err, sql.ErrNoRows) {
t.Fatalf("%s: got %v, want sql.ErrNoRows", what, err)
}
}
func createPhoto(t *testing.T, q *db.Queries, sha string) db.Photo {
t.Helper()
p, err := q.CreatePhoto(context.Background(), db.CreatePhotoParams{
ID: store.NewID(),
SourceUrl: "https://example.com/" + sha + ".jpg",
LocalPath: "photos/" + sha + ".jpg",
Width: 800,
Height: 600,
Sha256: sha,
FetchedAt: created,
CreatedAt: created,
UpdatedAt: created,
})
if err != nil {
t.Fatalf("CreatePhoto: %v", err)
}
return p
}
func upsertRecipe(t *testing.T, q *db.Queries, id, url, title, at string) db.Recipe {
t.Helper()
r, err := q.UpsertRecipe(context.Background(), db.UpsertRecipeParams{
ID: id,
SourceUrl: url,
SiteName: str("Example"),
Title: title,
TotalMinutes: num(30),
PrepMinutes: num(10),
CookMinutes: num(20),
Yield: str("4 servings"),
RawJsonld: `{"@type":"Recipe"}`,
FetchedAt: at,
CreatedAt: at,
UpdatedAt: at,
})
if err != nil {
t.Fatalf("UpsertRecipe: %v", err)
}
return r
}
func TestDinners(t *testing.T) {
ctx := context.Background()
_, q := openQueries(t)
recipe := upsertRecipe(t, q, store.NewID(), "https://example.com/tacos", "Tacos", created)
photo := createPhoto(t, q, "aa11")
create := db.CreateDinnerParams{
ID: store.NewID(),
Name: "Taco Tuesday",
NormalizedName: "taco tuesday",
Status: "regular",
Origin: "regular",
Method: str("stovetop"),
MinutesTotal: num(30),
Difficulty: str("easy"),
Cuisine: str("Mexican"),
IsFavorite: 0,
RecipeID: str(recipe.ID),
PhotoID: str(photo.ID),
GroundingStatus: "grounded",
UpdatedBy: str("alex@example.com"),
CreatedAt: created,
UpdatedAt: created,
}
want := db.Dinner(create)
got, err := q.CreateDinner(ctx, create)
if err != nil {
t.Fatalf("CreateDinner: %v", err)
}
if got != want {
t.Fatalf("CreateDinner returned %+v, want %+v", got, want)
}
if got, err = q.GetDinner(ctx, create.ID); err != nil || got != want {
t.Fatalf("GetDinner = %+v, %v; want %+v", got, err, want)
}
_, err = q.GetDinner(ctx, "missing")
mustNoRows(t, "GetDinner missing", err)
// Second dinner sorts before the first by normalized name, in a
// different status so the status filter is exercised.
other, err := q.CreateDinner(ctx, db.CreateDinnerParams{
ID: store.NewID(),
Name: "Apple Pork Chops",
NormalizedName: "apple pork chops",
Status: "quest_log",
Origin: "side_quest",
GroundingStatus: "pending",
CreatedAt: created,
UpdatedAt: created,
})
if err != nil {
t.Fatalf("CreateDinner other: %v", err)
}
third, err := q.CreateDinner(ctx, db.CreateDinnerParams{
ID: store.NewID(),
Name: "Beef Stew",
NormalizedName: "beef stew",
Status: "regular",
Origin: "manual",
GroundingStatus: "manual",
CreatedAt: created,
UpdatedAt: created,
})
if err != nil {
t.Fatalf("CreateDinner third: %v", err)
}
listIDs := func(status string) []string {
t.Helper()
ds, err := q.ListDinnersByStatus(ctx, status)
if err != nil {
t.Fatalf("ListDinnersByStatus(%s): %v", status, err)
}
ids := []string{}
for _, d := range ds {
ids = append(ids, d.ID)
}
return ids
}
assertIDs(t, "regular", listIDs("regular"), third.ID, create.ID)
assertIDs(t, "quest_log", listIDs("quest_log"), other.ID)
assertIDs(t, "tried", listIDs("tried"))
update := db.UpdateDinnerParams{
ID: create.ID,
Name: "Fish Tacos",
NormalizedName: "fish tacos",
Status: "tried",
Origin: "side_quest",
Method: str("grill"),
MinutesTotal: num(25),
Difficulty: str("medium"),
Cuisine: sql.NullString{},
RecipeID: sql.NullString{},
PhotoID: str(photo.ID),
GroundingStatus: "failed",
UpdatedBy: str("sam@example.com"),
UpdatedAt: updated,
}
got, err = q.UpdateDinner(ctx, update)
if err != nil {
t.Fatalf("UpdateDinner: %v", err)
}
want = db.Dinner{
ID: create.ID, Name: update.Name, NormalizedName: update.NormalizedName,
Status: update.Status, Origin: update.Origin, Method: update.Method,
MinutesTotal: update.MinutesTotal, Difficulty: update.Difficulty,
Cuisine: update.Cuisine, IsFavorite: 0, RecipeID: update.RecipeID,
PhotoID: update.PhotoID, GroundingStatus: update.GroundingStatus,
UpdatedBy: update.UpdatedBy, CreatedAt: created, UpdatedAt: updated,
}
if got != want {
t.Fatalf("UpdateDinner returned %+v, want %+v", got, want)
}
if got, err = q.GetDinner(ctx, create.ID); err != nil || got != want {
t.Fatalf("GetDinner after update = %+v, %v; want %+v", got, err, want)
}
update.ID = "missing"
_, err = q.UpdateDinner(ctx, update)
mustNoRows(t, "UpdateDinner missing", err)
got, err = q.SetDinnerFavorite(ctx, db.SetDinnerFavoriteParams{
ID: create.ID, IsFavorite: 1, UpdatedBy: str("alex@example.com"), UpdatedAt: updated,
})
if err != nil {
t.Fatalf("SetDinnerFavorite: %v", err)
}
want.IsFavorite, want.UpdatedBy = 1, str("alex@example.com")
if got != want {
t.Fatalf("SetDinnerFavorite returned %+v, want %+v", got, want)
}
got, err = q.SetDinnerFavorite(ctx, db.SetDinnerFavoriteParams{
ID: create.ID, IsFavorite: 0, UpdatedBy: str("sam@example.com"), UpdatedAt: updated,
})
if err != nil || got.IsFavorite != 0 || got.UpdatedBy != str("sam@example.com") {
t.Fatalf("SetDinnerFavorite off = %+v, %v", got, err)
}
_, err = q.SetDinnerFavorite(ctx, db.SetDinnerFavoriteParams{ID: "missing", IsFavorite: 1, UpdatedAt: updated})
mustNoRows(t, "SetDinnerFavorite missing", err)
const archivedAt = "2026-10-05T12:00:00.000Z"
got, err = q.ArchiveDinner(ctx, db.ArchiveDinnerParams{
ID: create.ID, UpdatedBy: str("alex@example.com"), UpdatedAt: archivedAt,
})
if err != nil {
t.Fatalf("ArchiveDinner: %v", err)
}
want.IsFavorite, want.Status, want.UpdatedBy, want.UpdatedAt = 0, "archived", str("alex@example.com"), archivedAt
if got != want {
t.Fatalf("ArchiveDinner returned %+v, want %+v", got, want)
}
assertIDs(t, "archived", listIDs("archived"), create.ID)
assertIDs(t, "tried after archive", listIDs("tried"))
_, err = q.ArchiveDinner(ctx, db.ArchiveDinnerParams{ID: "missing", UpdatedAt: archivedAt})
mustNoRows(t, "ArchiveDinner missing", err)
}
func assertIDs(t *testing.T, what string, got []string, want ...string) {
t.Helper()
if len(got) != len(want) {
t.Fatalf("%s: got ids %v, want %v", what, got, want)
}
for i := range got {
if got[i] != want[i] {
t.Fatalf("%s: got ids %v, want %v", what, got, want)
}
}
}
func TestRecipeUpsertWithIngredientsAndSteps(t *testing.T) {
ctx := context.Background()
s, q := openQueries(t)
const url = "https://example.com/chili"
_, err := q.GetRecipeBySourceURL(ctx, url)
mustNoRows(t, "GetRecipeBySourceURL before insert", err)
// saveRecipe is how a caller stores a fetched recipe: upsert by
// source_url, then replace the children, all in one transaction.
saveRecipe := func(title, at string, ingredients, steps []string) db.Recipe {
t.Helper()
var r db.Recipe
err := s.WithTx(ctx, func(tx *sql.Tx) error {
qtx := q.WithTx(tx)
var err error
r, err = qtx.UpsertRecipe(ctx, db.UpsertRecipeParams{
ID: store.NewID(), SourceUrl: url, SiteName: str("Example"), Title: title,
TotalMinutes: num(60), RawJsonld: `{"name":"` + title + `"}`,
FetchedAt: at, CreatedAt: at, UpdatedAt: at,
})
if err != nil {
return err
}
if err := qtx.DeleteRecipeIngredients(ctx, r.ID); err != nil {
return err
}
if err := qtx.DeleteRecipeSteps(ctx, r.ID); err != nil {
return err
}
for i, text := range ingredients {
if err := qtx.CreateRecipeIngredient(ctx, db.CreateRecipeIngredientParams{
ID: store.NewID(), RecipeID: r.ID, Position: int64(i), RawText: text,
Name: str(text), Qty: str("1"), Unit: str("cup"), Note: sql.NullString{},
CreatedAt: at, UpdatedAt: at,
}); err != nil {
return err
}
}
for i, text := range steps {
if err := qtx.CreateRecipeStep(ctx, db.CreateRecipeStepParams{
ID: store.NewID(), RecipeID: r.ID, Position: int64(i), Text: text,
CreatedAt: at, UpdatedAt: at,
}); err != nil {
return err
}
}
return nil
})
if err != nil {
t.Fatalf("save recipe %q: %v", title, err)
}
return r
}
assertRecipe := func(id, title, createdAt, updatedAt string, ingredients, steps []string) {
t.Helper()
r, err := q.GetRecipe(ctx, id)
if err != nil {
t.Fatalf("GetRecipe: %v", err)
}
if r.Title != title || r.SourceUrl != url || r.CreatedAt != createdAt || r.UpdatedAt != updatedAt || r.FetchedAt != updatedAt {
t.Fatalf("GetRecipe = %+v; want title %q, created %s, updated %s", r, title, createdAt, updatedAt)
}
byURL, err := q.GetRecipeBySourceURL(ctx, url)
if err != nil || byURL != r {
t.Fatalf("GetRecipeBySourceURL = %+v, %v; want %+v", byURL, err, r)
}
ings, err := q.ListRecipeIngredients(ctx, id)
if err != nil {
t.Fatalf("ListRecipeIngredients: %v", err)
}
gotIngs := []string{}
for i, ing := range ings {
if ing.RecipeID != id || ing.Position != int64(i) || ing.Name != str(ing.RawText) {
t.Fatalf("ingredient %d = %+v", i, ing)
}
gotIngs = append(gotIngs, ing.RawText)
}
assertIDs(t, "ingredients", gotIngs, ingredients...)
sts, err := q.ListRecipeSteps(ctx, id)
if err != nil {
t.Fatalf("ListRecipeSteps: %v", err)
}
gotSteps := []string{}
for i, st := range sts {
if st.RecipeID != id || st.Position != int64(i) {
t.Fatalf("step %d = %+v", i, st)
}
gotSteps = append(gotSteps, st.Text)
}
assertIDs(t, "steps", gotSteps, steps...)
}
first := saveRecipe("Chili", created, []string{"beans", "beef", "tomatoes"}, []string{"Brown the beef.", "Simmer everything."})
assertRecipe(first.ID, "Chili", created, created, []string{"beans", "beef", "tomatoes"}, []string{"Brown the beef.", "Simmer everything."})
// Re-fetching the same URL keeps the id and created_at and replaces the
// fields and children.
second := saveRecipe("White Chili", updated, []string{"chicken", "white beans"}, []string{"Simmer."})
if second.ID != first.ID {
t.Fatalf("upsert changed id: %s -> %s", first.ID, second.ID)
}
assertRecipe(first.ID, "White Chili", created, updated, []string{"chicken", "white beans"}, []string{"Simmer."})
// A different recipe's children are not listed.
upsertRecipe(t, q, store.NewID(), "https://example.com/other", "Other", created)
var n int
if err := s.Reader.QueryRowContext(ctx, "SELECT count(*) FROM recipes").Scan(&n); err != nil || n != 2 {
t.Fatalf("recipes count = %d, %v; want 2", n, err)
}
_, err = q.GetRecipe(ctx, "missing")
mustNoRows(t, "GetRecipe missing", err)
}
func TestPhotos(t *testing.T) {
ctx := context.Background()
_, q := openQueries(t)
_, err := q.GetPhotoBySHA256(ctx, "bb22")
mustNoRows(t, "GetPhotoBySHA256 before insert", err)
first := createPhoto(t, q, "bb22")
createPhoto(t, q, "cc33")
if first.Sha256 != "bb22" || first.Width != 800 || first.Height != 600 || first.CreatedAt != created {
t.Fatalf("CreatePhoto returned %+v", first)
}
got, err := q.GetPhotoBySHA256(ctx, "bb22")
if err != nil || got != first {
t.Fatalf("GetPhotoBySHA256 = %+v, %v; want %+v", got, err, first)
}
// sha256 is not unique in the schema; the earliest photo is returned.
createPhoto(t, q, "bb22")
if got, err = q.GetPhotoBySHA256(ctx, "bb22"); err != nil || got != first {
t.Fatalf("GetPhotoBySHA256 with duplicate = %+v, %v; want %+v", got, err, first)
}
}
func TestHouseholdSettings(t *testing.T) {
ctx := context.Background()
_, q := openQueries(t)
_, err := q.GetHouseholdSettings(ctx)
mustNoRows(t, "GetHouseholdSettings before upsert", err)
p := db.UpsertHouseholdSettingsParams{
Timezone: "America/New_York", DinnerTime: "17:30", ReminderTime: "16:30",
ReviewTime: "09:00", PlanDraftDow: 5, PlanDraftTime: "09:00",
SuggestionsDow: 0, SuggestionsTime: "08:00", ChatAutosendDow: 6,
ChatAutosendTime: "08:00", CreatedAt: created, UpdatedAt: created,
}
want := db.HouseholdSetting{
ID: 1, Timezone: p.Timezone, DinnerTime: p.DinnerTime, ReminderTime: p.ReminderTime,
ReviewTime: p.ReviewTime, PlanDraftDow: p.PlanDraftDow, PlanDraftTime: p.PlanDraftTime,
SuggestionsDow: p.SuggestionsDow, SuggestionsTime: p.SuggestionsTime,
ChatAutosendDow: p.ChatAutosendDow, ChatAutosendTime: p.ChatAutosendTime,
CreatedAt: created, UpdatedAt: created,
}
assertSettings(t, "insert", q.UpsertHouseholdSettings, q.GetHouseholdSettings, p, want)
p = db.UpsertHouseholdSettingsParams{
Timezone: "America/Chicago", DinnerTime: "18:00", ReminderTime: "17:00",
ReviewTime: "10:00", PlanDraftDow: 4, PlanDraftTime: "20:00",
SuggestionsDow: 1, SuggestionsTime: "07:00", ChatAutosendDow: 0,
ChatAutosendTime: "09:30", OnboardingCompletedAt: str(updated),
UpdatesSeenAt: str(updated), CreatedAt: updated, UpdatedAt: updated,
}
want = db.HouseholdSetting{
ID: 1, Timezone: p.Timezone, DinnerTime: p.DinnerTime, ReminderTime: p.ReminderTime,
ReviewTime: p.ReviewTime, PlanDraftDow: p.PlanDraftDow, PlanDraftTime: p.PlanDraftTime,
SuggestionsDow: p.SuggestionsDow, SuggestionsTime: p.SuggestionsTime,
ChatAutosendDow: p.ChatAutosendDow, ChatAutosendTime: p.ChatAutosendTime,
OnboardingCompletedAt: p.OnboardingCompletedAt, UpdatesSeenAt: p.UpdatesSeenAt,
CreatedAt: created, UpdatedAt: updated,
}
assertSettings(t, "update", q.UpsertHouseholdSettings, q.GetHouseholdSettings, p, want)
}
func TestAISettings(t *testing.T) {
ctx := context.Background()
_, q := openQueries(t)
_, err := q.GetAISettings(ctx)
mustNoRows(t, "GetAISettings before upsert", err)
p := db.UpsertAISettingsParams{
Provider: "ollama", BaseUrl: "http://ollama:11434", Model: "llama3",
Temperature: 0.7, CreatedAt: created, UpdatedAt: created,
}
want := db.AiSetting{
ID: 1, Provider: p.Provider, BaseUrl: p.BaseUrl, Model: p.Model,
Temperature: p.Temperature, CreatedAt: created, UpdatedAt: created,
}
assertSettings(t, "insert", q.UpsertAISettings, q.GetAISettings, p, want)
p = db.UpsertAISettingsParams{
Provider: "anthropic", BaseUrl: "https://api.anthropic.com", Model: "claude",
Temperature: 0.2, SystemPrompt: str("Be brief."), ApiKeyCt: []byte{1, 2, 3},
ApiKeyLast4: str("wxyz"), KeyVerifiedAt: str(updated), CreatedAt: updated, UpdatedAt: updated,
}
want = db.AiSetting{
ID: 1, Provider: p.Provider, BaseUrl: p.BaseUrl, Model: p.Model,
Temperature: p.Temperature, SystemPrompt: p.SystemPrompt, ApiKeyCt: p.ApiKeyCt,
ApiKeyLast4: p.ApiKeyLast4, KeyVerifiedAt: p.KeyVerifiedAt,
CreatedAt: created, UpdatedAt: updated,
}
assertSettings(t, "update", q.UpsertAISettings, q.GetAISettings, p, want)
}
func TestRecipeSearchSettings(t *testing.T) {
ctx := context.Background()
_, q := openQueries(t)
_, err := q.GetRecipeSearchSettings(ctx)
mustNoRows(t, "GetRecipeSearchSettings before upsert", err)
p := db.UpsertRecipeSearchSettingsParams{
Provider: "searxng", BaseUrl: str("http://searxng:8080"), CreatedAt: created, UpdatedAt: created,
}
want := db.RecipeSearchSetting{
ID: 1, Provider: p.Provider, BaseUrl: p.BaseUrl, CreatedAt: created, UpdatedAt: created,
}
assertSettings(t, "insert", q.UpsertRecipeSearchSettings, q.GetRecipeSearchSettings, p, want)
p = db.UpsertRecipeSearchSettingsParams{
Provider: "brave", ApiKeyCt: []byte{9, 8, 7}, CreatedAt: updated, UpdatedAt: updated,
}
want = db.RecipeSearchSetting{
ID: 1, Provider: p.Provider, ApiKeyCt: p.ApiKeyCt, CreatedAt: created, UpdatedAt: updated,
}
assertSettings(t, "update", q.UpsertRecipeSearchSettings, q.GetRecipeSearchSettings, p, want)
}
func TestIntegrationsGoogle(t *testing.T) {
ctx := context.Background()
_, q := openQueries(t)
_, err := q.GetIntegrationsGoogle(ctx)
mustNoRows(t, "GetIntegrationsGoogle before upsert", err)
p := db.UpsertIntegrationsGoogleParams{
RefreshTokenCt: []byte{1}, ConnectedAt: created, CreatedAt: created, UpdatedAt: created,
}
want := db.IntegrationsGoogle{
ID: 1, RefreshTokenCt: p.RefreshTokenCt, ConnectedAt: created, CreatedAt: created, UpdatedAt: created,
}
assertSettings(t, "insert", q.UpsertIntegrationsGoogle, q.GetIntegrationsGoogle, p, want)
p = db.UpsertIntegrationsGoogleParams{
RefreshTokenCt: []byte{2, 3}, CalendarID: str("family@group.calendar.google.com"),
CalendarName: str("Family"), ConnectedAt: updated, LastSyncError: str("quota exceeded"),
CreatedAt: updated, UpdatedAt: updated,
}
want = db.IntegrationsGoogle{
ID: 1, RefreshTokenCt: p.RefreshTokenCt, CalendarID: p.CalendarID,
CalendarName: p.CalendarName, ConnectedAt: updated, LastSyncError: p.LastSyncError,
CreatedAt: created, UpdatedAt: updated,
}
assertSettings(t, "update", q.UpsertIntegrationsGoogle, q.GetIntegrationsGoogle, p, want)
}
func TestChatSettings(t *testing.T) {
ctx := context.Background()
_, q := openQueries(t)
_, err := q.GetChatSettings(ctx)
mustNoRows(t, "GetChatSettings before upsert", err)
p := db.UpsertChatSettingsParams{
WebhookUrlCt: []byte{4, 5}, CreatedAt: created, UpdatedAt: created,
}
want := db.ChatSetting{
ID: 1, WebhookUrlCt: p.WebhookUrlCt, CreatedAt: created, UpdatedAt: created,
}
assertSettings(t, "insert", q.UpsertChatSettings, q.GetChatSettings, p, want)
p = db.UpsertChatSettingsParams{
WebhookUrlCt: []byte{6}, SpaceLabel: str("Kitchen"), LastTestAt: str(updated),
LastTestStatus: str("ok"), CreatedAt: updated, UpdatedAt: updated,
}
want = db.ChatSetting{
ID: 1, WebhookUrlCt: p.WebhookUrlCt, SpaceLabel: p.SpaceLabel, LastTestAt: p.LastTestAt,
LastTestStatus: p.LastTestStatus, CreatedAt: created, UpdatedAt: updated,
}
assertSettings(t, "update", q.UpsertChatSettings, q.GetChatSettings, p, want)
}
// assertSettings upserts p and checks that both the returned row and a
// fresh Get equal want. On update, want keeps the original created_at.
func assertSettings[P, R any](
t *testing.T, what string,
upsert func(context.Context, P) (R, error), get func(context.Context) (R, error),
p P, want R,
) {
t.Helper()
ctx := context.Background()
got, err := upsert(ctx, p)
if err != nil {
t.Fatalf("%s upsert: %v", what, err)
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("%s upsert returned %+v, want %+v", what, got, want)
}
got, err = get(ctx)
if err != nil {
t.Fatalf("%s get: %v", what, err)
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("%s get = %+v, want %+v", what, got, want)
}
}
+321
View File
@@ -0,0 +1,321 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: dinners.sql
package db
import (
"context"
"database/sql"
)
const archiveDinner = `-- name: ArchiveDinner :one
UPDATE dinners
SET status = 'archived',
updated_by = ?,
updated_at = ?
WHERE id = ?
RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at
`
type ArchiveDinnerParams struct {
UpdatedBy sql.NullString
UpdatedAt string
ID string
}
// An unknown id returns sql.ErrNoRows.
func (q *Queries) ArchiveDinner(ctx context.Context, arg ArchiveDinnerParams) (Dinner, error) {
row := q.db.QueryRowContext(ctx, archiveDinner, arg.UpdatedBy, arg.UpdatedAt, arg.ID)
var i Dinner
err := row.Scan(
&i.ID,
&i.Name,
&i.NormalizedName,
&i.Status,
&i.Origin,
&i.Method,
&i.MinutesTotal,
&i.Difficulty,
&i.Cuisine,
&i.IsFavorite,
&i.RecipeID,
&i.PhotoID,
&i.GroundingStatus,
&i.UpdatedBy,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const createDinner = `-- name: CreateDinner :one
INSERT INTO dinners (
id, name, normalized_name, status, origin, method, minutes_total,
difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status,
updated_by, created_at, updated_at
) VALUES (
?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?, ?,
?, ?, ?
)
RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at
`
type CreateDinnerParams struct {
ID string
Name string
NormalizedName string
Status string
Origin string
Method sql.NullString
MinutesTotal sql.NullInt64
Difficulty sql.NullString
Cuisine sql.NullString
IsFavorite int64
RecipeID sql.NullString
PhotoID sql.NullString
GroundingStatus string
UpdatedBy sql.NullString
CreatedAt string
UpdatedAt string
}
func (q *Queries) CreateDinner(ctx context.Context, arg CreateDinnerParams) (Dinner, error) {
row := q.db.QueryRowContext(ctx, createDinner,
arg.ID,
arg.Name,
arg.NormalizedName,
arg.Status,
arg.Origin,
arg.Method,
arg.MinutesTotal,
arg.Difficulty,
arg.Cuisine,
arg.IsFavorite,
arg.RecipeID,
arg.PhotoID,
arg.GroundingStatus,
arg.UpdatedBy,
arg.CreatedAt,
arg.UpdatedAt,
)
var i Dinner
err := row.Scan(
&i.ID,
&i.Name,
&i.NormalizedName,
&i.Status,
&i.Origin,
&i.Method,
&i.MinutesTotal,
&i.Difficulty,
&i.Cuisine,
&i.IsFavorite,
&i.RecipeID,
&i.PhotoID,
&i.GroundingStatus,
&i.UpdatedBy,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const getDinner = `-- name: GetDinner :one
SELECT id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at FROM dinners
WHERE id = ?
`
func (q *Queries) GetDinner(ctx context.Context, id string) (Dinner, error) {
row := q.db.QueryRowContext(ctx, getDinner, id)
var i Dinner
err := row.Scan(
&i.ID,
&i.Name,
&i.NormalizedName,
&i.Status,
&i.Origin,
&i.Method,
&i.MinutesTotal,
&i.Difficulty,
&i.Cuisine,
&i.IsFavorite,
&i.RecipeID,
&i.PhotoID,
&i.GroundingStatus,
&i.UpdatedBy,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const listDinnersByStatus = `-- name: ListDinnersByStatus :many
SELECT id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at FROM dinners
WHERE status = ?
ORDER BY normalized_name, id
`
func (q *Queries) ListDinnersByStatus(ctx context.Context, status string) ([]Dinner, error) {
rows, err := q.db.QueryContext(ctx, listDinnersByStatus, status)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Dinner
for rows.Next() {
var i Dinner
if err := rows.Scan(
&i.ID,
&i.Name,
&i.NormalizedName,
&i.Status,
&i.Origin,
&i.Method,
&i.MinutesTotal,
&i.Difficulty,
&i.Cuisine,
&i.IsFavorite,
&i.RecipeID,
&i.PhotoID,
&i.GroundingStatus,
&i.UpdatedBy,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const setDinnerFavorite = `-- name: SetDinnerFavorite :one
UPDATE dinners
SET is_favorite = ?,
updated_by = ?,
updated_at = ?
WHERE id = ?
RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at
`
type SetDinnerFavoriteParams struct {
IsFavorite int64
UpdatedBy sql.NullString
UpdatedAt string
ID string
}
// An unknown id returns sql.ErrNoRows.
func (q *Queries) SetDinnerFavorite(ctx context.Context, arg SetDinnerFavoriteParams) (Dinner, error) {
row := q.db.QueryRowContext(ctx, setDinnerFavorite,
arg.IsFavorite,
arg.UpdatedBy,
arg.UpdatedAt,
arg.ID,
)
var i Dinner
err := row.Scan(
&i.ID,
&i.Name,
&i.NormalizedName,
&i.Status,
&i.Origin,
&i.Method,
&i.MinutesTotal,
&i.Difficulty,
&i.Cuisine,
&i.IsFavorite,
&i.RecipeID,
&i.PhotoID,
&i.GroundingStatus,
&i.UpdatedBy,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const updateDinner = `-- name: UpdateDinner :one
UPDATE dinners
SET name = ?,
normalized_name = ?,
status = ?,
origin = ?,
method = ?,
minutes_total = ?,
difficulty = ?,
cuisine = ?,
recipe_id = ?,
photo_id = ?,
grounding_status = ?,
updated_by = ?,
updated_at = ?
WHERE id = ?
RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at
`
type UpdateDinnerParams struct {
Name string
NormalizedName string
Status string
Origin string
Method sql.NullString
MinutesTotal sql.NullInt64
Difficulty sql.NullString
Cuisine sql.NullString
RecipeID sql.NullString
PhotoID sql.NullString
GroundingStatus string
UpdatedBy sql.NullString
UpdatedAt string
ID string
}
// An unknown id returns sql.ErrNoRows.
func (q *Queries) UpdateDinner(ctx context.Context, arg UpdateDinnerParams) (Dinner, error) {
row := q.db.QueryRowContext(ctx, updateDinner,
arg.Name,
arg.NormalizedName,
arg.Status,
arg.Origin,
arg.Method,
arg.MinutesTotal,
arg.Difficulty,
arg.Cuisine,
arg.RecipeID,
arg.PhotoID,
arg.GroundingStatus,
arg.UpdatedBy,
arg.UpdatedAt,
arg.ID,
)
var i Dinner
err := row.Scan(
&i.ID,
&i.Name,
&i.NormalizedName,
&i.Status,
&i.Origin,
&i.Method,
&i.MinutesTotal,
&i.Difficulty,
&i.Cuisine,
&i.IsFavorite,
&i.RecipeID,
&i.PhotoID,
&i.GroundingStatus,
&i.UpdatedBy,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
+166
View File
@@ -0,0 +1,166 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: jobs.sql
package db
import (
"context"
"database/sql"
)
const claimNextJob = `-- name: ClaimNextJob :one
UPDATE jobs
SET status = 'running', started_at = ?1, updated_at = ?1
WHERE id = (SELECT id FROM jobs WHERE status = 'queued' ORDER BY id LIMIT 1)
RETURNING id, kind, status, payload, result, error, started_at, finished_at, created_at, updated_at
`
// Marks the oldest queued job running and returns it. Returns sql.ErrNoRows
// when no job is queued.
func (q *Queries) ClaimNextJob(ctx context.Context, now sql.NullString) (Job, error) {
row := q.db.QueryRowContext(ctx, claimNextJob, now)
var i Job
err := row.Scan(
&i.ID,
&i.Kind,
&i.Status,
&i.Payload,
&i.Result,
&i.Error,
&i.StartedAt,
&i.FinishedAt,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const createJob = `-- name: CreateJob :exec
INSERT INTO jobs (id, kind, status, payload, created_at, updated_at)
VALUES (?, ?, 'queued', ?, ?, ?)
`
type CreateJobParams struct {
ID string
Kind string
Payload string
CreatedAt string
UpdatedAt string
}
func (q *Queries) CreateJob(ctx context.Context, arg CreateJobParams) error {
_, err := q.db.ExecContext(ctx, createJob,
arg.ID,
arg.Kind,
arg.Payload,
arg.CreatedAt,
arg.UpdatedAt,
)
return err
}
const failJob = `-- name: FailJob :execrows
UPDATE jobs
SET status = 'failed', error = ?1, finished_at = ?2, updated_at = ?2
WHERE id = ?3 AND status = 'running'
`
type FailJobParams struct {
Error sql.NullString
Now sql.NullString
ID string
}
func (q *Queries) FailJob(ctx context.Context, arg FailJobParams) (int64, error) {
result, err := q.db.ExecContext(ctx, failJob, arg.Error, arg.Now, arg.ID)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const failRunningJobs = `-- name: FailRunningJobs :execrows
UPDATE jobs
SET status = 'failed', error = ?1, finished_at = ?2, updated_at = ?2
WHERE status = 'running'
`
type FailRunningJobsParams struct {
Error sql.NullString
Now sql.NullString
}
// Fails every job left running, at boot before any worker starts.
func (q *Queries) FailRunningJobs(ctx context.Context, arg FailRunningJobsParams) (int64, error) {
result, err := q.db.ExecContext(ctx, failRunningJobs, arg.Error, arg.Now)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const getJob = `-- name: GetJob :one
SELECT id, kind, status, payload, result, error, started_at, finished_at, created_at, updated_at FROM jobs
WHERE id = ?
`
func (q *Queries) GetJob(ctx context.Context, id string) (Job, error) {
row := q.db.QueryRowContext(ctx, getJob, id)
var i Job
err := row.Scan(
&i.ID,
&i.Kind,
&i.Status,
&i.Payload,
&i.Result,
&i.Error,
&i.StartedAt,
&i.FinishedAt,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const publishJobResult = `-- name: PublishJobResult :execrows
UPDATE jobs
SET result = ?, updated_at = ?
WHERE id = ? AND status = 'running'
`
type PublishJobResultParams struct {
Result sql.NullString
UpdatedAt string
ID string
}
// Stores a partial result of a running job.
func (q *Queries) PublishJobResult(ctx context.Context, arg PublishJobResultParams) (int64, error) {
result, err := q.db.ExecContext(ctx, publishJobResult, arg.Result, arg.UpdatedAt, arg.ID)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const succeedJob = `-- name: SucceedJob :execrows
UPDATE jobs
SET status = 'succeeded', result = ?1, finished_at = ?2, updated_at = ?2
WHERE id = ?3 AND status = 'running'
`
type SucceedJobParams struct {
Result sql.NullString
Now sql.NullString
ID string
}
func (q *Queries) SucceedJob(ctx context.Context, arg SucceedJobParams) (int64, error) {
result, err := q.db.ExecContext(ctx, succeedJob, arg.Result, arg.Now, arg.ID)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
+310
View File
@@ -0,0 +1,310 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
package db
import (
"database/sql"
)
type AiRun struct {
ID string
Skill string
SkillVersion string
Provider string
Model string
Temperature float64
Status string
LatencyMs sql.NullInt64
TokensIn sql.NullInt64
TokensOut sql.NullInt64
Error sql.NullString
RequestRedacted string
ResponseRedacted sql.NullString
CreatedAt string
UpdatedAt string
}
type AiSetting struct {
ID int64
Provider string
BaseUrl string
Model string
Temperature float64
SystemPrompt sql.NullString
ApiKeyCt []byte
ApiKeyLast4 sql.NullString
KeyVerifiedAt sql.NullString
CreatedAt string
UpdatedAt string
}
type ChatSetting struct {
ID int64
WebhookUrlCt []byte
SpaceLabel sql.NullString
LastTestAt sql.NullString
LastTestStatus sql.NullString
CreatedAt string
UpdatedAt string
}
type Dinner struct {
ID string
Name string
NormalizedName string
Status string
Origin string
Method sql.NullString
MinutesTotal sql.NullInt64
Difficulty sql.NullString
Cuisine sql.NullString
IsFavorite int64
RecipeID sql.NullString
PhotoID sql.NullString
GroundingStatus string
UpdatedBy sql.NullString
CreatedAt string
UpdatedAt string
}
type HouseholdSetting struct {
ID int64
Timezone string
DinnerTime string
ReminderTime string
ReviewTime string
PlanDraftDow int64
PlanDraftTime string
SuggestionsDow int64
SuggestionsTime string
ChatAutosendDow int64
ChatAutosendTime string
OnboardingCompletedAt sql.NullString
UpdatesSeenAt sql.NullString
CreatedAt string
UpdatedAt string
UpdatedBy sql.NullString
}
type IntegrationsGoogle struct {
ID int64
RefreshTokenCt []byte
CalendarID sql.NullString
CalendarName sql.NullString
ConnectedAt string
LastSyncError sql.NullString
CreatedAt string
UpdatedAt string
}
type Job struct {
ID string
Kind string
Status string
Payload string
Result sql.NullString
Error sql.NullString
StartedAt sql.NullString
FinishedAt sql.NullString
CreatedAt string
UpdatedAt string
}
type NotificationOutbox struct {
ID string
EventKey string
Channel string
Payload string
Status string
Attempts int64
NextAttemptAt sql.NullString
LastError sql.NullString
CreatedAt string
UpdatedAt string
}
type NotificationPref struct {
Event string
Push int64
Chat int64
CreatedAt string
UpdatedAt string
}
type Photo struct {
ID string
SourceUrl string
LocalPath string
Width int64
Height int64
Sha256 string
FetchedAt string
CreatedAt string
UpdatedAt string
}
type PlanDay struct {
ID string
PlanID string
Date string
DinnerID sql.NullString
IsNew int64
CalendarEventID sql.NullString
CreatedAt string
UpdatedAt string
}
type PushSubscription struct {
ID string
Endpoint string
P256dh string
Auth string
UserAgent sql.NullString
FailureCount int64
LastSuccessAt sql.NullString
CreatedAt string
UpdatedAt string
}
type Recipe struct {
ID string
SourceUrl string
SiteName sql.NullString
Title string
TotalMinutes sql.NullInt64
PrepMinutes sql.NullInt64
CookMinutes sql.NullInt64
Yield sql.NullString
RawJsonld string
FetchedAt string
CreatedAt string
UpdatedAt string
}
type RecipeIngredient struct {
ID string
RecipeID string
Position int64
RawText string
Name sql.NullString
Qty sql.NullString
Unit sql.NullString
Note sql.NullString
CreatedAt string
UpdatedAt string
}
type RecipeSearchSetting struct {
ID int64
Provider string
BaseUrl sql.NullString
ApiKeyCt []byte
CreatedAt string
UpdatedAt string
}
type RecipeStep struct {
ID string
RecipeID string
Position int64
Text string
CreatedAt string
UpdatedAt string
}
type RotationReview struct {
ID string
DinnerID string
PlanDate string
Status string
Decision sql.NullString
DecidedAt sql.NullString
UpdatedBy sql.NullString
CreatedAt string
UpdatedAt string
}
type ScheduleRun struct {
ID string
Schedule string
PeriodKey string
CreatedAt string
}
type Session struct {
IDHash string
Subject string
Email string
Name string
Groups string
CsrfToken string
CreatedAt string
LastSeenAt string
ExpiresAt string
}
type ShoppingGroup struct {
ID string
ListID string
DinnerID string
Position int64
CreatedAt string
UpdatedAt string
}
type ShoppingItem struct {
ID string
GroupID string
Name string
Qty sql.NullString
Needed int64
Position int64
CreatedAt string
UpdatedAt string
}
type ShoppingList struct {
ID string
WeekStart string
PlanID string
Title string
Status string
SentToChatAt sql.NullString
FrozenAt sql.NullString
CreatedAt string
UpdatedAt string
}
type Suggestion struct {
ID string
DinnerID string
BatchID string
Reason string
CitedRegularIds string
Status string
DecidedAt sql.NullString
CreatedAt string
UpdatedAt string
}
type SuggestionBatch struct {
ID string
Trigger string
Status string
AiRunID sql.NullString
CreatedAt string
UpdatedAt string
}
type WeekPlan struct {
ID string
WeekStart string
Status string
AiNote sql.NullString
AiRunID sql.NullString
CalendarSyncedAt sql.NullString
CreatedAt string
UpdatedAt string
}
+86
View File
@@ -0,0 +1,86 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: photos.sql
package db
import (
"context"
)
const createPhoto = `-- name: CreatePhoto :one
INSERT INTO photos (
id, source_url, local_path, width, height, sha256, fetched_at,
created_at, updated_at
) VALUES (
?, ?, ?, ?, ?, ?, ?,
?, ?
)
RETURNING id, source_url, local_path, width, height, sha256, fetched_at, created_at, updated_at
`
type CreatePhotoParams struct {
ID string
SourceUrl string
LocalPath string
Width int64
Height int64
Sha256 string
FetchedAt string
CreatedAt string
UpdatedAt string
}
func (q *Queries) CreatePhoto(ctx context.Context, arg CreatePhotoParams) (Photo, error) {
row := q.db.QueryRowContext(ctx, createPhoto,
arg.ID,
arg.SourceUrl,
arg.LocalPath,
arg.Width,
arg.Height,
arg.Sha256,
arg.FetchedAt,
arg.CreatedAt,
arg.UpdatedAt,
)
var i Photo
err := row.Scan(
&i.ID,
&i.SourceUrl,
&i.LocalPath,
&i.Width,
&i.Height,
&i.Sha256,
&i.FetchedAt,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const getPhotoBySHA256 = `-- name: GetPhotoBySHA256 :one
SELECT id, source_url, local_path, width, height, sha256, fetched_at, created_at, updated_at FROM photos
WHERE sha256 = ?
ORDER BY id
LIMIT 1
`
// Finds stored image bytes by content hash. The schema does not make sha256
// unique, so the earliest-created (lowest id) match is returned.
func (q *Queries) GetPhotoBySHA256(ctx context.Context, sha256 string) (Photo, error) {
row := q.db.QueryRowContext(ctx, getPhotoBySHA256, sha256)
var i Photo
err := row.Scan(
&i.ID,
&i.SourceUrl,
&i.LocalPath,
&i.Width,
&i.Height,
&i.Sha256,
&i.FetchedAt,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
+77
View File
@@ -0,0 +1,77 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
package db
import (
"context"
"database/sql"
)
type Querier interface {
// An unknown id returns sql.ErrNoRows.
ArchiveDinner(ctx context.Context, arg ArchiveDinnerParams) (Dinner, error)
// Marks the oldest queued job running and returns it. Returns sql.ErrNoRows
// when no job is queued.
ClaimNextJob(ctx context.Context, now sql.NullString) (Job, error)
CreateDinner(ctx context.Context, arg CreateDinnerParams) (Dinner, error)
CreateJob(ctx context.Context, arg CreateJobParams) error
CreatePhoto(ctx context.Context, arg CreatePhotoParams) (Photo, error)
CreateRecipeIngredient(ctx context.Context, arg CreateRecipeIngredientParams) error
CreateRecipeStep(ctx context.Context, arg CreateRecipeStepParams) error
DeleteRecipeIngredients(ctx context.Context, recipeID string) error
DeleteRecipeSteps(ctx context.Context, recipeID string) error
FailJob(ctx context.Context, arg FailJobParams) (int64, error)
// Fails every job left running, at boot before any worker starts.
FailRunningJobs(ctx context.Context, arg FailRunningJobsParams) (int64, error)
// Returns sql.ErrNoRows until the row exists.
GetAISettings(ctx context.Context) (AiSetting, error)
// Returns sql.ErrNoRows until the row exists.
GetChatSettings(ctx context.Context) (ChatSetting, error)
GetDinner(ctx context.Context, id string) (Dinner, error)
// Returns sql.ErrNoRows until the row exists.
GetHouseholdSettings(ctx context.Context) (HouseholdSetting, error)
// Returns sql.ErrNoRows until the row exists.
GetIntegrationsGoogle(ctx context.Context) (IntegrationsGoogle, error)
GetJob(ctx context.Context, id string) (Job, error)
// Finds stored image bytes by content hash. The schema does not make sha256
// unique, so the earliest-created (lowest id) match is returned.
GetPhotoBySHA256(ctx context.Context, sha256 string) (Photo, error)
GetRecipe(ctx context.Context, id string) (Recipe, error)
GetRecipeBySourceURL(ctx context.Context, sourceUrl string) (Recipe, error)
// Returns sql.ErrNoRows until the row exists.
GetRecipeSearchSettings(ctx context.Context) (RecipeSearchSetting, error)
ListDinnersByStatus(ctx context.Context, status string) ([]Dinner, error)
ListRecipeIngredients(ctx context.Context, recipeID string) ([]RecipeIngredient, error)
ListRecipeSteps(ctx context.Context, recipeID string) ([]RecipeStep, error)
// Stores a partial result of a running job.
PublishJobResult(ctx context.Context, arg PublishJobResultParams) (int64, error)
// An unknown id returns sql.ErrNoRows.
SetDinnerFavorite(ctx context.Context, arg SetDinnerFavoriteParams) (Dinner, error)
SucceedJob(ctx context.Context, arg SucceedJobParams) (int64, error)
// An unknown id returns sql.ErrNoRows.
UpdateDinner(ctx context.Context, arg UpdateDinnerParams) (Dinner, error)
// Creates the single row (id 1) or replaces every column except id and
// created_at.
UpsertAISettings(ctx context.Context, arg UpsertAISettingsParams) (AiSetting, error)
// Creates the single row (id 1) or replaces every column except id and
// created_at.
UpsertChatSettings(ctx context.Context, arg UpsertChatSettingsParams) (ChatSetting, error)
// Creates the single row (id 1) or replaces every column except id and
// created_at.
UpsertHouseholdSettings(ctx context.Context, arg UpsertHouseholdSettingsParams) (HouseholdSetting, error)
// Creates the single row (id 1) or replaces every column except id and
// created_at.
UpsertIntegrationsGoogle(ctx context.Context, arg UpsertIntegrationsGoogleParams) (IntegrationsGoogle, error)
// A recipe is keyed by source_url: re-fetching the same URL updates the
// existing row and keeps its id and created_at, which the returned row
// carries. Replace its ingredients and steps in the same transaction with
// DeleteRecipeIngredients/DeleteRecipeSteps then the Create queries.
UpsertRecipe(ctx context.Context, arg UpsertRecipeParams) (Recipe, error)
// Creates the single row (id 1) or replaces every column except id and
// created_at.
UpsertRecipeSearchSettings(ctx context.Context, arg UpsertRecipeSearchSettingsParams) (RecipeSearchSetting, error)
}
var _ Querier = (*Queries)(nil)
+298
View File
@@ -0,0 +1,298 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: recipes.sql
package db
import (
"context"
"database/sql"
)
const createRecipeIngredient = `-- name: CreateRecipeIngredient :exec
INSERT INTO recipe_ingredients (
id, recipe_id, position, raw_text, name, qty, unit, note,
created_at, updated_at
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?,
?, ?
)
`
type CreateRecipeIngredientParams struct {
ID string
RecipeID string
Position int64
RawText string
Name sql.NullString
Qty sql.NullString
Unit sql.NullString
Note sql.NullString
CreatedAt string
UpdatedAt string
}
func (q *Queries) CreateRecipeIngredient(ctx context.Context, arg CreateRecipeIngredientParams) error {
_, err := q.db.ExecContext(ctx, createRecipeIngredient,
arg.ID,
arg.RecipeID,
arg.Position,
arg.RawText,
arg.Name,
arg.Qty,
arg.Unit,
arg.Note,
arg.CreatedAt,
arg.UpdatedAt,
)
return err
}
const createRecipeStep = `-- name: CreateRecipeStep :exec
INSERT INTO recipe_steps (
id, recipe_id, position, text, created_at, updated_at
) VALUES (
?, ?, ?, ?, ?, ?
)
`
type CreateRecipeStepParams struct {
ID string
RecipeID string
Position int64
Text string
CreatedAt string
UpdatedAt string
}
func (q *Queries) CreateRecipeStep(ctx context.Context, arg CreateRecipeStepParams) error {
_, err := q.db.ExecContext(ctx, createRecipeStep,
arg.ID,
arg.RecipeID,
arg.Position,
arg.Text,
arg.CreatedAt,
arg.UpdatedAt,
)
return err
}
const deleteRecipeIngredients = `-- name: DeleteRecipeIngredients :exec
DELETE FROM recipe_ingredients
WHERE recipe_id = ?
`
func (q *Queries) DeleteRecipeIngredients(ctx context.Context, recipeID string) error {
_, err := q.db.ExecContext(ctx, deleteRecipeIngredients, recipeID)
return err
}
const deleteRecipeSteps = `-- name: DeleteRecipeSteps :exec
DELETE FROM recipe_steps
WHERE recipe_id = ?
`
func (q *Queries) DeleteRecipeSteps(ctx context.Context, recipeID string) error {
_, err := q.db.ExecContext(ctx, deleteRecipeSteps, recipeID)
return err
}
const getRecipe = `-- name: GetRecipe :one
SELECT id, source_url, site_name, title, total_minutes, prep_minutes, cook_minutes, yield, raw_jsonld, fetched_at, created_at, updated_at FROM recipes
WHERE id = ?
`
func (q *Queries) GetRecipe(ctx context.Context, id string) (Recipe, error) {
row := q.db.QueryRowContext(ctx, getRecipe, id)
var i Recipe
err := row.Scan(
&i.ID,
&i.SourceUrl,
&i.SiteName,
&i.Title,
&i.TotalMinutes,
&i.PrepMinutes,
&i.CookMinutes,
&i.Yield,
&i.RawJsonld,
&i.FetchedAt,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const getRecipeBySourceURL = `-- name: GetRecipeBySourceURL :one
SELECT id, source_url, site_name, title, total_minutes, prep_minutes, cook_minutes, yield, raw_jsonld, fetched_at, created_at, updated_at FROM recipes
WHERE source_url = ?
`
func (q *Queries) GetRecipeBySourceURL(ctx context.Context, sourceUrl string) (Recipe, error) {
row := q.db.QueryRowContext(ctx, getRecipeBySourceURL, sourceUrl)
var i Recipe
err := row.Scan(
&i.ID,
&i.SourceUrl,
&i.SiteName,
&i.Title,
&i.TotalMinutes,
&i.PrepMinutes,
&i.CookMinutes,
&i.Yield,
&i.RawJsonld,
&i.FetchedAt,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const listRecipeIngredients = `-- name: ListRecipeIngredients :many
SELECT id, recipe_id, position, raw_text, name, qty, unit, note, created_at, updated_at FROM recipe_ingredients
WHERE recipe_id = ?
ORDER BY position, id
`
func (q *Queries) ListRecipeIngredients(ctx context.Context, recipeID string) ([]RecipeIngredient, error) {
rows, err := q.db.QueryContext(ctx, listRecipeIngredients, recipeID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []RecipeIngredient
for rows.Next() {
var i RecipeIngredient
if err := rows.Scan(
&i.ID,
&i.RecipeID,
&i.Position,
&i.RawText,
&i.Name,
&i.Qty,
&i.Unit,
&i.Note,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listRecipeSteps = `-- name: ListRecipeSteps :many
SELECT id, recipe_id, position, text, created_at, updated_at FROM recipe_steps
WHERE recipe_id = ?
ORDER BY position, id
`
func (q *Queries) ListRecipeSteps(ctx context.Context, recipeID string) ([]RecipeStep, error) {
rows, err := q.db.QueryContext(ctx, listRecipeSteps, recipeID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []RecipeStep
for rows.Next() {
var i RecipeStep
if err := rows.Scan(
&i.ID,
&i.RecipeID,
&i.Position,
&i.Text,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const upsertRecipe = `-- name: UpsertRecipe :one
INSERT INTO recipes (
id, source_url, site_name, title, total_minutes, prep_minutes,
cook_minutes, yield, raw_jsonld, fetched_at, created_at, updated_at
) VALUES (
?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?, ?
)
ON CONFLICT (source_url) DO UPDATE SET
site_name = excluded.site_name,
title = excluded.title,
total_minutes = excluded.total_minutes,
prep_minutes = excluded.prep_minutes,
cook_minutes = excluded.cook_minutes,
yield = excluded.yield,
raw_jsonld = excluded.raw_jsonld,
fetched_at = excluded.fetched_at,
updated_at = excluded.updated_at
RETURNING id, source_url, site_name, title, total_minutes, prep_minutes, cook_minutes, yield, raw_jsonld, fetched_at, created_at, updated_at
`
type UpsertRecipeParams struct {
ID string
SourceUrl string
SiteName sql.NullString
Title string
TotalMinutes sql.NullInt64
PrepMinutes sql.NullInt64
CookMinutes sql.NullInt64
Yield sql.NullString
RawJsonld string
FetchedAt string
CreatedAt string
UpdatedAt string
}
// A recipe is keyed by source_url: re-fetching the same URL updates the
// existing row and keeps its id and created_at, which the returned row
// carries. Replace its ingredients and steps in the same transaction with
// DeleteRecipeIngredients/DeleteRecipeSteps then the Create queries.
func (q *Queries) UpsertRecipe(ctx context.Context, arg UpsertRecipeParams) (Recipe, error) {
row := q.db.QueryRowContext(ctx, upsertRecipe,
arg.ID,
arg.SourceUrl,
arg.SiteName,
arg.Title,
arg.TotalMinutes,
arg.PrepMinutes,
arg.CookMinutes,
arg.Yield,
arg.RawJsonld,
arg.FetchedAt,
arg.CreatedAt,
arg.UpdatedAt,
)
var i Recipe
err := row.Scan(
&i.ID,
&i.SourceUrl,
&i.SiteName,
&i.Title,
&i.TotalMinutes,
&i.PrepMinutes,
&i.CookMinutes,
&i.Yield,
&i.RawJsonld,
&i.FetchedAt,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
+433
View File
@@ -0,0 +1,433 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: settings.sql
package db
import (
"context"
"database/sql"
)
const getAISettings = `-- name: GetAISettings :one
SELECT id, provider, base_url, model, temperature, system_prompt, api_key_ct, api_key_last4, key_verified_at, created_at, updated_at FROM ai_settings
WHERE id = 1
`
// Returns sql.ErrNoRows until the row exists.
func (q *Queries) GetAISettings(ctx context.Context) (AiSetting, error) {
row := q.db.QueryRowContext(ctx, getAISettings)
var i AiSetting
err := row.Scan(
&i.ID,
&i.Provider,
&i.BaseUrl,
&i.Model,
&i.Temperature,
&i.SystemPrompt,
&i.ApiKeyCt,
&i.ApiKeyLast4,
&i.KeyVerifiedAt,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const getChatSettings = `-- name: GetChatSettings :one
SELECT id, webhook_url_ct, space_label, last_test_at, last_test_status, created_at, updated_at FROM chat_settings
WHERE id = 1
`
// Returns sql.ErrNoRows until the row exists.
func (q *Queries) GetChatSettings(ctx context.Context) (ChatSetting, error) {
row := q.db.QueryRowContext(ctx, getChatSettings)
var i ChatSetting
err := row.Scan(
&i.ID,
&i.WebhookUrlCt,
&i.SpaceLabel,
&i.LastTestAt,
&i.LastTestStatus,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const getHouseholdSettings = `-- name: GetHouseholdSettings :one
SELECT id, timezone, dinner_time, reminder_time, review_time, plan_draft_dow, plan_draft_time, suggestions_dow, suggestions_time, chat_autosend_dow, chat_autosend_time, onboarding_completed_at, updates_seen_at, created_at, updated_at, updated_by FROM household_settings
WHERE id = 1
`
// Returns sql.ErrNoRows until the row exists.
func (q *Queries) GetHouseholdSettings(ctx context.Context) (HouseholdSetting, error) {
row := q.db.QueryRowContext(ctx, getHouseholdSettings)
var i HouseholdSetting
err := row.Scan(
&i.ID,
&i.Timezone,
&i.DinnerTime,
&i.ReminderTime,
&i.ReviewTime,
&i.PlanDraftDow,
&i.PlanDraftTime,
&i.SuggestionsDow,
&i.SuggestionsTime,
&i.ChatAutosendDow,
&i.ChatAutosendTime,
&i.OnboardingCompletedAt,
&i.UpdatesSeenAt,
&i.CreatedAt,
&i.UpdatedAt,
&i.UpdatedBy,
)
return i, err
}
const getIntegrationsGoogle = `-- name: GetIntegrationsGoogle :one
SELECT id, refresh_token_ct, calendar_id, calendar_name, connected_at, last_sync_error, created_at, updated_at FROM integrations_google
WHERE id = 1
`
// Returns sql.ErrNoRows until the row exists.
func (q *Queries) GetIntegrationsGoogle(ctx context.Context) (IntegrationsGoogle, error) {
row := q.db.QueryRowContext(ctx, getIntegrationsGoogle)
var i IntegrationsGoogle
err := row.Scan(
&i.ID,
&i.RefreshTokenCt,
&i.CalendarID,
&i.CalendarName,
&i.ConnectedAt,
&i.LastSyncError,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const getRecipeSearchSettings = `-- name: GetRecipeSearchSettings :one
SELECT id, provider, base_url, api_key_ct, created_at, updated_at FROM recipe_search_settings
WHERE id = 1
`
// Returns sql.ErrNoRows until the row exists.
func (q *Queries) GetRecipeSearchSettings(ctx context.Context) (RecipeSearchSetting, error) {
row := q.db.QueryRowContext(ctx, getRecipeSearchSettings)
var i RecipeSearchSetting
err := row.Scan(
&i.ID,
&i.Provider,
&i.BaseUrl,
&i.ApiKeyCt,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const upsertAISettings = `-- name: UpsertAISettings :one
INSERT INTO ai_settings (
id, provider, base_url, model, temperature, system_prompt, api_key_ct,
api_key_last4, key_verified_at, created_at, updated_at
) VALUES (
1, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?
)
ON CONFLICT (id) DO UPDATE SET
provider = excluded.provider,
base_url = excluded.base_url,
model = excluded.model,
temperature = excluded.temperature,
system_prompt = excluded.system_prompt,
api_key_ct = excluded.api_key_ct,
api_key_last4 = excluded.api_key_last4,
key_verified_at = excluded.key_verified_at,
updated_at = excluded.updated_at
RETURNING id, provider, base_url, model, temperature, system_prompt, api_key_ct, api_key_last4, key_verified_at, created_at, updated_at
`
type UpsertAISettingsParams struct {
Provider string
BaseUrl string
Model string
Temperature float64
SystemPrompt sql.NullString
ApiKeyCt []byte
ApiKeyLast4 sql.NullString
KeyVerifiedAt sql.NullString
CreatedAt string
UpdatedAt string
}
// Creates the single row (id 1) or replaces every column except id and
// created_at.
func (q *Queries) UpsertAISettings(ctx context.Context, arg UpsertAISettingsParams) (AiSetting, error) {
row := q.db.QueryRowContext(ctx, upsertAISettings,
arg.Provider,
arg.BaseUrl,
arg.Model,
arg.Temperature,
arg.SystemPrompt,
arg.ApiKeyCt,
arg.ApiKeyLast4,
arg.KeyVerifiedAt,
arg.CreatedAt,
arg.UpdatedAt,
)
var i AiSetting
err := row.Scan(
&i.ID,
&i.Provider,
&i.BaseUrl,
&i.Model,
&i.Temperature,
&i.SystemPrompt,
&i.ApiKeyCt,
&i.ApiKeyLast4,
&i.KeyVerifiedAt,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const upsertChatSettings = `-- name: UpsertChatSettings :one
INSERT INTO chat_settings (
id, webhook_url_ct, space_label, last_test_at, last_test_status,
created_at, updated_at
) VALUES (
1, ?, ?, ?, ?,
?, ?
)
ON CONFLICT (id) DO UPDATE SET
webhook_url_ct = excluded.webhook_url_ct,
space_label = excluded.space_label,
last_test_at = excluded.last_test_at,
last_test_status = excluded.last_test_status,
updated_at = excluded.updated_at
RETURNING id, webhook_url_ct, space_label, last_test_at, last_test_status, created_at, updated_at
`
type UpsertChatSettingsParams struct {
WebhookUrlCt []byte
SpaceLabel sql.NullString
LastTestAt sql.NullString
LastTestStatus sql.NullString
CreatedAt string
UpdatedAt string
}
// Creates the single row (id 1) or replaces every column except id and
// created_at.
func (q *Queries) UpsertChatSettings(ctx context.Context, arg UpsertChatSettingsParams) (ChatSetting, error) {
row := q.db.QueryRowContext(ctx, upsertChatSettings,
arg.WebhookUrlCt,
arg.SpaceLabel,
arg.LastTestAt,
arg.LastTestStatus,
arg.CreatedAt,
arg.UpdatedAt,
)
var i ChatSetting
err := row.Scan(
&i.ID,
&i.WebhookUrlCt,
&i.SpaceLabel,
&i.LastTestAt,
&i.LastTestStatus,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const upsertHouseholdSettings = `-- name: UpsertHouseholdSettings :one
INSERT INTO household_settings (
id, timezone, dinner_time, reminder_time, review_time, plan_draft_dow,
plan_draft_time, suggestions_dow, suggestions_time, chat_autosend_dow,
chat_autosend_time, onboarding_completed_at, updates_seen_at,
updated_by, created_at, updated_at
) VALUES (
1, ?, ?, ?, ?, ?,
?, ?, ?, ?,
?, ?, ?,
?, ?, ?
)
ON CONFLICT (id) DO UPDATE SET
timezone = excluded.timezone,
dinner_time = excluded.dinner_time,
reminder_time = excluded.reminder_time,
review_time = excluded.review_time,
plan_draft_dow = excluded.plan_draft_dow,
plan_draft_time = excluded.plan_draft_time,
suggestions_dow = excluded.suggestions_dow,
suggestions_time = excluded.suggestions_time,
chat_autosend_dow = excluded.chat_autosend_dow,
chat_autosend_time = excluded.chat_autosend_time,
onboarding_completed_at = excluded.onboarding_completed_at,
updates_seen_at = excluded.updates_seen_at,
updated_by = excluded.updated_by,
updated_at = excluded.updated_at
RETURNING id, timezone, dinner_time, reminder_time, review_time, plan_draft_dow, plan_draft_time, suggestions_dow, suggestions_time, chat_autosend_dow, chat_autosend_time, onboarding_completed_at, updates_seen_at, created_at, updated_at, updated_by
`
type UpsertHouseholdSettingsParams struct {
Timezone string
DinnerTime string
ReminderTime string
ReviewTime string
PlanDraftDow int64
PlanDraftTime string
SuggestionsDow int64
SuggestionsTime string
ChatAutosendDow int64
ChatAutosendTime string
OnboardingCompletedAt sql.NullString
UpdatesSeenAt sql.NullString
UpdatedBy sql.NullString
CreatedAt string
UpdatedAt string
}
// Creates the single row (id 1) or replaces every column except id and
// created_at.
func (q *Queries) UpsertHouseholdSettings(ctx context.Context, arg UpsertHouseholdSettingsParams) (HouseholdSetting, error) {
row := q.db.QueryRowContext(ctx, upsertHouseholdSettings,
arg.Timezone,
arg.DinnerTime,
arg.ReminderTime,
arg.ReviewTime,
arg.PlanDraftDow,
arg.PlanDraftTime,
arg.SuggestionsDow,
arg.SuggestionsTime,
arg.ChatAutosendDow,
arg.ChatAutosendTime,
arg.OnboardingCompletedAt,
arg.UpdatesSeenAt,
arg.UpdatedBy,
arg.CreatedAt,
arg.UpdatedAt,
)
var i HouseholdSetting
err := row.Scan(
&i.ID,
&i.Timezone,
&i.DinnerTime,
&i.ReminderTime,
&i.ReviewTime,
&i.PlanDraftDow,
&i.PlanDraftTime,
&i.SuggestionsDow,
&i.SuggestionsTime,
&i.ChatAutosendDow,
&i.ChatAutosendTime,
&i.OnboardingCompletedAt,
&i.UpdatesSeenAt,
&i.CreatedAt,
&i.UpdatedAt,
&i.UpdatedBy,
)
return i, err
}
const upsertIntegrationsGoogle = `-- name: UpsertIntegrationsGoogle :one
INSERT INTO integrations_google (
id, refresh_token_ct, calendar_id, calendar_name, connected_at,
last_sync_error, created_at, updated_at
) VALUES (
1, ?, ?, ?, ?,
?, ?, ?
)
ON CONFLICT (id) DO UPDATE SET
refresh_token_ct = excluded.refresh_token_ct,
calendar_id = excluded.calendar_id,
calendar_name = excluded.calendar_name,
connected_at = excluded.connected_at,
last_sync_error = excluded.last_sync_error,
updated_at = excluded.updated_at
RETURNING id, refresh_token_ct, calendar_id, calendar_name, connected_at, last_sync_error, created_at, updated_at
`
type UpsertIntegrationsGoogleParams struct {
RefreshTokenCt []byte
CalendarID sql.NullString
CalendarName sql.NullString
ConnectedAt string
LastSyncError sql.NullString
CreatedAt string
UpdatedAt string
}
// Creates the single row (id 1) or replaces every column except id and
// created_at.
func (q *Queries) UpsertIntegrationsGoogle(ctx context.Context, arg UpsertIntegrationsGoogleParams) (IntegrationsGoogle, error) {
row := q.db.QueryRowContext(ctx, upsertIntegrationsGoogle,
arg.RefreshTokenCt,
arg.CalendarID,
arg.CalendarName,
arg.ConnectedAt,
arg.LastSyncError,
arg.CreatedAt,
arg.UpdatedAt,
)
var i IntegrationsGoogle
err := row.Scan(
&i.ID,
&i.RefreshTokenCt,
&i.CalendarID,
&i.CalendarName,
&i.ConnectedAt,
&i.LastSyncError,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const upsertRecipeSearchSettings = `-- name: UpsertRecipeSearchSettings :one
INSERT INTO recipe_search_settings (
id, provider, base_url, api_key_ct, created_at, updated_at
) VALUES (
1, ?, ?, ?, ?, ?
)
ON CONFLICT (id) DO UPDATE SET
provider = excluded.provider,
base_url = excluded.base_url,
api_key_ct = excluded.api_key_ct,
updated_at = excluded.updated_at
RETURNING id, provider, base_url, api_key_ct, created_at, updated_at
`
type UpsertRecipeSearchSettingsParams struct {
Provider string
BaseUrl sql.NullString
ApiKeyCt []byte
CreatedAt string
UpdatedAt string
}
// Creates the single row (id 1) or replaces every column except id and
// created_at.
func (q *Queries) UpsertRecipeSearchSettings(ctx context.Context, arg UpsertRecipeSearchSettingsParams) (RecipeSearchSetting, error) {
row := q.db.QueryRowContext(ctx, upsertRecipeSearchSettings,
arg.Provider,
arg.BaseUrl,
arg.ApiKeyCt,
arg.CreatedAt,
arg.UpdatedAt,
)
var i RecipeSearchSetting
err := row.Scan(
&i.ID,
&i.Provider,
&i.BaseUrl,
&i.ApiKeyCt,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
+221
View File
@@ -0,0 +1,221 @@
// Package forwarded resolves where a request really came from. The service
// runs behind a reverse proxy (Traefik), so the TCP peer is usually the proxy;
// the X-Forwarded-For and X-Forwarded-Proto headers it adds are believed only
// when the peer is a trusted proxy. From anyone else they are ignored, so a
// client cannot spoof its address or scheme.
package forwarded
import (
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"net/netip"
"strings"
)
// Header names honoured from trusted proxies.
const (
HeaderFor = "X-Forwarded-For"
HeaderProto = "X-Forwarded-Proto"
)
// ErrInvalidHeader marks a malformed forwarded header from a trusted peer.
var ErrInvalidHeader = errors.New("invalid forwarded header")
// Trusted is the set of proxy addresses whose forwarded headers are honoured.
// The zero value trusts no peer.
type Trusted []netip.Prefix
// ParseTrusted parses a comma-separated list of IP addresses and CIDR
// prefixes. A blank list trusts no peer. Each entry must be a plain address
// or a prefix with no host bits set; zones, IPv4-mapped IPv6 forms and
// zero-length prefixes, which would trust every peer, are rejected.
func ParseTrusted(s string) (Trusted, error) {
if strings.TrimSpace(s) == "" {
return nil, nil
}
var t Trusted
for _, entry := range strings.Split(s, ",") {
entry = strings.TrimSpace(entry)
if entry == "" {
return nil, fmt.Errorf("empty entry in comma-separated list %q", s)
}
p, err := parseEntry(entry)
if err != nil {
return nil, err
}
t = append(t, p)
}
return t, nil
}
func parseEntry(entry string) (netip.Prefix, error) {
var p netip.Prefix
if strings.Contains(entry, "/") {
var err error
p, err = netip.ParsePrefix(entry)
if err != nil {
return netip.Prefix{}, fmt.Errorf("invalid CIDR %q", entry)
}
if p != p.Masked() {
return netip.Prefix{}, fmt.Errorf("CIDR %q has host bits set (did you mean %s?)", entry, p.Masked())
}
} else {
a, err := netip.ParseAddr(entry)
if err != nil {
return netip.Prefix{}, fmt.Errorf("invalid IP address %q", entry)
}
if a.Zone() != "" {
return netip.Prefix{}, fmt.Errorf("IP address %q must not have a zone", entry)
}
p = netip.PrefixFrom(a, a.BitLen())
}
switch {
case p.Addr().Is4In6():
return netip.Prefix{}, fmt.Errorf("%q is IPv4-mapped IPv6; write the IPv4 form", entry)
case p.Bits() == 0:
return netip.Prefix{}, fmt.Errorf("%q would trust every peer", entry)
}
return p, nil
}
// Contains reports whether a is a trusted proxy address. IPv4-mapped IPv6
// addresses, as a dual-stack listener reports IPv4 peers, match their IPv4
// form.
func (t Trusted) Contains(a netip.Addr) bool {
a = normalize(a)
for _, p := range t {
if p.Contains(a) {
return true
}
}
return false
}
func normalize(a netip.Addr) netip.Addr { return a.Unmap().WithZone("") }
// Origin is where a request came from.
type Origin struct {
// ClientIP is the client's address.
ClientIP netip.Addr
// Scheme is "https" or "http": the scheme the client used.
Scheme string
}
// Resolve works out r's Origin. When the TCP peer (r.RemoteAddr) is not in
// trusted, both forwarded headers are ignored: the client is the peer and the
// scheme is that of the connection.
//
// When the peer is trusted:
// - X-Forwarded-For entries (across all its header lines, in order) are
// walked right to left, skipping trusted proxy addresses; the first
// untrusted address is the client. Entries left of it are client-supplied
// and not examined. If every entry is trusted the leftmost is the client;
// with no X-Forwarded-For the peer is the client.
// - X-Forwarded-Proto, if present, must be a single "http" or "https" and
// is the scheme; without it the connection's scheme is used.
//
// A malformed header from a trusted peer is an error wrapping
// ErrInvalidHeader; any other error means RemoteAddr is not an IP and port.
func Resolve(r *http.Request, trusted Trusted) (Origin, error) {
peer, err := netip.ParseAddrPort(r.RemoteAddr)
if err != nil {
return Origin{}, fmt.Errorf("peer address %q is not an IP and port", r.RemoteAddr)
}
o := Origin{ClientIP: normalize(peer.Addr()), Scheme: "http"}
if r.TLS != nil {
o.Scheme = "https"
}
if !trusted.Contains(o.ClientIP) {
return o, nil
}
if values := r.Header.Values(HeaderFor); len(values) > 0 {
o.ClientIP, err = clientFromChain(strings.Split(strings.Join(values, ","), ","), trusted)
if err != nil {
return Origin{}, err
}
}
switch values := r.Header.Values(HeaderProto); len(values) {
case 0:
case 1:
switch scheme := strings.ToLower(strings.TrimSpace(values[0])); scheme {
case "http", "https":
o.Scheme = scheme
default:
return Origin{}, fmt.Errorf("%w: %s %q is not http or https", ErrInvalidHeader, HeaderProto, values[0])
}
default:
return Origin{}, fmt.Errorf("%w: %d %s headers, want one", ErrInvalidHeader, len(values), HeaderProto)
}
return o, nil
}
// clientFromChain returns the rightmost untrusted address in chain, or the
// leftmost address when all are trusted.
func clientFromChain(chain []string, trusted Trusted) (netip.Addr, error) {
var client netip.Addr
for i := len(chain) - 1; i >= 0; i-- {
a, err := netip.ParseAddr(strings.TrimSpace(chain[i]))
if err != nil {
return netip.Addr{}, fmt.Errorf("%w: %s entry %q is not an IP address", ErrInvalidHeader, HeaderFor, strings.TrimSpace(chain[i]))
}
client = normalize(a)
if !trusted.Contains(client) {
break
}
}
return client, nil
}
type contextKey struct{}
// NewContext returns ctx carrying o.
func NewContext(ctx context.Context, o Origin) context.Context {
return context.WithValue(ctx, contextKey{}, o)
}
// FromContext returns the Origin ctx carries; ok is false when the request
// was not resolved.
func FromContext(ctx context.Context) (o Origin, ok bool) {
o, ok = ctx.Value(contextKey{}).(Origin)
return o, ok
}
// ClientIP returns the resolved client address ctx carries.
func ClientIP(ctx context.Context) (netip.Addr, bool) {
o, ok := FromContext(ctx)
return o.ClientIP, ok
}
// Scheme returns the resolved request scheme ctx carries.
func Scheme(ctx context.Context) (string, bool) {
o, ok := FromContext(ctx)
return o.Scheme, ok
}
// LogAttr is the log attribute key for the resolved client address.
const LogAttr = "client_ip"
// LogHandler wraps h so every record logged with a resolved request's
// context carries the client address as LogAttr.
func LogHandler(h slog.Handler) slog.Handler { return logHandler{h} }
type logHandler struct{ slog.Handler }
func (h logHandler) Handle(ctx context.Context, rec slog.Record) error {
if ip, ok := ClientIP(ctx); ok {
rec = rec.Clone()
rec.AddAttrs(slog.String(LogAttr, ip.String()))
}
return h.Handler.Handle(ctx, rec)
}
func (h logHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
return logHandler{h.Handler.WithAttrs(attrs)}
}
func (h logHandler) WithGroup(name string) slog.Handler {
return logHandler{h.Handler.WithGroup(name)}
}
+260
View File
@@ -0,0 +1,260 @@
package forwarded
import (
"bytes"
"context"
"crypto/tls"
"errors"
"log/slog"
"net/http"
"net/http/httptest"
"net/netip"
"slices"
"strings"
"testing"
)
func TestParseTrusted(t *testing.T) {
t.Parallel()
tests := []struct {
in string
want []string
wantErr string
}{
{in: "", want: nil},
{in: " ", want: nil},
{in: "10.60.1.33", want: []string{"10.60.1.33/32"}},
{in: "10.60.1.33, 172.16.0.0/12", want: []string{"10.60.1.33/32", "172.16.0.0/12"}},
{in: "2001:db8::1,2001:db8::/32", want: []string{"2001:db8::1/128", "2001:db8::/32"}},
{in: "traefik", wantErr: "invalid IP address"},
{in: "10.60.1.33:443", wantErr: "invalid IP address"},
{in: "10.60.1.0/33", wantErr: "invalid CIDR"},
{in: "10.60.1.33/24", wantErr: "did you mean 10.60.1.0/24"},
{in: "10.60.1.33,,10.60.1.34", wantErr: "empty entry"},
{in: "10.60.1.33,", wantErr: "empty entry"},
{in: "fe80::1%eth0", wantErr: "zone"},
{in: "::ffff:10.60.1.33", wantErr: "IPv4-mapped"},
{in: "0.0.0.0/0", wantErr: "every peer"},
{in: "::/0", wantErr: "every peer"},
}
for _, tt := range tests {
got, err := ParseTrusted(tt.in)
if tt.wantErr != "" {
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Errorf("ParseTrusted(%q) error = %v, want one containing %q", tt.in, err, tt.wantErr)
}
continue
}
if err != nil {
t.Errorf("ParseTrusted(%q) error = %v", tt.in, err)
continue
}
var gotStr []string
for _, p := range got {
gotStr = append(gotStr, p.String())
}
if !slices.Equal(gotStr, tt.want) {
t.Errorf("ParseTrusted(%q) = %v, want %v", tt.in, gotStr, tt.want)
}
}
}
func TestTrustedContainsMappedIPv4(t *testing.T) {
t.Parallel()
trusted := mustTrusted(t, "10.60.1.33")
if !trusted.Contains(netip.MustParseAddr("::ffff:10.60.1.33")) {
t.Error("IPv4-mapped form of a trusted address is not trusted")
}
if Trusted(nil).Contains(netip.MustParseAddr("10.60.1.33")) {
t.Error("an empty set trusts a peer")
}
}
func mustTrusted(t *testing.T, s string) Trusted {
t.Helper()
tr, err := ParseTrusted(s)
if err != nil {
t.Fatal(err)
}
return tr
}
func TestResolve(t *testing.T) {
t.Parallel()
// 10.60.1.33 is Traefik; 10.60.2.0/24 is an inner proxy tier.
trusted := mustTrusted(t, "10.60.1.33,10.60.2.0/24")
tests := []struct {
name string
peer string
tls bool
xff []string
xfp []string
wantIP string
wantScheme string
}{
{
name: "untrusted peer, no headers",
peer: "198.51.100.9:5000", wantIP: "198.51.100.9", wantScheme: "http",
},
{
name: "untrusted peer over TLS",
peer: "198.51.100.9:5000", tls: true, wantIP: "198.51.100.9", wantScheme: "https",
},
{
name: "untrusted peer spoofing both headers is ignored",
peer: "198.51.100.9:5000", xff: []string{"203.0.113.7"}, xfp: []string{"https"},
wantIP: "198.51.100.9", wantScheme: "http",
},
{
name: "untrusted peer's malformed headers are ignored, not rejected",
peer: "198.51.100.9:5000", xff: []string{"not-an-ip"}, xfp: []string{"gopher"},
wantIP: "198.51.100.9", wantScheme: "http",
},
{
name: "trusted peer: client from X-Forwarded-For, scheme from X-Forwarded-Proto",
peer: "10.60.1.33:41000", xff: []string{"203.0.113.7"}, xfp: []string{"https"},
wantIP: "203.0.113.7", wantScheme: "https",
},
{
name: "trusted peer seen as IPv4-mapped IPv6",
peer: "[::ffff:10.60.1.33]:41000", xff: []string{"203.0.113.7"}, xfp: []string{"https"},
wantIP: "203.0.113.7", wantScheme: "https",
},
{
name: "trusted peer without headers is itself the client",
peer: "10.60.1.33:41000", wantIP: "10.60.1.33", wantScheme: "http",
},
{
name: "rightmost untrusted address wins over a client-supplied prefix",
peer: "10.60.1.33:41000", xff: []string{"1.2.3.4, 203.0.113.7"}, xfp: []string{"https"},
wantIP: "203.0.113.7", wantScheme: "https",
},
{
name: "trusted proxies in the chain are skipped",
peer: "10.60.1.33:41000", xff: []string{"1.2.3.4, 203.0.113.7, 10.60.2.5"}, xfp: []string{"https"},
wantIP: "203.0.113.7", wantScheme: "https",
},
{
name: "several header lines form one chain",
peer: "10.60.1.33:41000", xff: []string{"1.2.3.4", "203.0.113.7, 10.60.2.5"},
wantIP: "203.0.113.7", wantScheme: "http",
},
{
name: "garbage left of the client is not examined",
peer: "10.60.1.33:41000", xff: []string{"spoofed, 203.0.113.7"},
wantIP: "203.0.113.7", wantScheme: "http",
},
{
name: "all trusted: leftmost is the client",
peer: "10.60.1.33:41000", xff: []string{"10.60.2.5, 10.60.2.6"},
wantIP: "10.60.2.5", wantScheme: "http",
},
{
name: "IPv6 client",
peer: "10.60.1.33:41000", xff: []string{"2001:db8::7"}, xfp: []string{"HTTPS"},
wantIP: "2001:db8::7", wantScheme: "https",
},
{
name: "trusted peer may downgrade to http",
peer: "10.60.1.33:41000", tls: true, xfp: []string{"http"},
wantIP: "10.60.1.33", wantScheme: "http",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
r := request(tt.peer, tt.xff, tt.xfp)
if tt.tls {
r.TLS = &tls.ConnectionState{}
}
o, err := Resolve(r, trusted)
if err != nil {
t.Fatalf("Resolve error = %v", err)
}
if o.ClientIP.String() != tt.wantIP || o.Scheme != tt.wantScheme {
t.Errorf("Resolve = %s %s, want %s %s", o.ClientIP, o.Scheme, tt.wantIP, tt.wantScheme)
}
})
}
}
func TestResolveRejectsMalformedHeadersFromTrustedPeer(t *testing.T) {
t.Parallel()
trusted := mustTrusted(t, "10.60.1.33")
for _, tt := range []struct {
name string
xff, xfp []string
}{
{"X-Forwarded-For not an IP", []string{"not-an-ip"}, nil},
{"X-Forwarded-For with a port", []string{"203.0.113.7:5000"}, nil},
{"X-Forwarded-For empty entry", []string{"203.0.113.7, "}, nil},
{"X-Forwarded-For empty", []string{""}, nil},
{"X-Forwarded-Proto unknown scheme", nil, []string{"ftp"}},
{"X-Forwarded-Proto list", nil, []string{"https, http"}},
{"X-Forwarded-Proto repeated", nil, []string{"https", "https"}},
} {
_, err := Resolve(request("10.60.1.33:41000", tt.xff, tt.xfp), trusted)
if !errors.Is(err, ErrInvalidHeader) {
t.Errorf("%s: Resolve error = %v, want ErrInvalidHeader", tt.name, err)
}
}
}
func TestResolveBadPeerAddress(t *testing.T) {
t.Parallel()
r := request("@", nil, nil)
_, err := Resolve(r, nil)
if err == nil || errors.Is(err, ErrInvalidHeader) {
t.Errorf("Resolve error = %v, want a peer address error", err)
}
}
func request(peer string, xff, xfp []string) *http.Request {
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.RemoteAddr = peer
for _, v := range xff {
r.Header.Add(HeaderFor, v)
}
for _, v := range xfp {
r.Header.Add(HeaderProto, v)
}
return r
}
func TestContextHelpers(t *testing.T) {
t.Parallel()
if _, ok := ClientIP(context.Background()); ok {
t.Error("ClientIP ok on an unresolved context")
}
if _, ok := Scheme(context.Background()); ok {
t.Error("Scheme ok on an unresolved context")
}
ctx := NewContext(context.Background(), Origin{ClientIP: netip.MustParseAddr("203.0.113.7"), Scheme: "https"})
if ip, ok := ClientIP(ctx); !ok || ip.String() != "203.0.113.7" {
t.Errorf("ClientIP = %s %v, want 203.0.113.7 true", ip, ok)
}
if s, ok := Scheme(ctx); !ok || s != "https" {
t.Errorf("Scheme = %q %v, want https true", s, ok)
}
}
func TestLogHandler(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
logger := slog.New(LogHandler(slog.NewTextHandler(&buf, nil))).With("component", "test")
ctx := NewContext(context.Background(), Origin{ClientIP: netip.MustParseAddr("203.0.113.7"), Scheme: "https"})
logger.InfoContext(ctx, "resolved")
logger.InfoContext(context.Background(), "unresolved")
lines := strings.Split(strings.TrimSpace(buf.String()), "\n")
if len(lines) != 2 {
t.Fatalf("logged %d lines, want 2: %q", len(lines), buf.String())
}
if !strings.Contains(lines[0], "client_ip=203.0.113.7") || !strings.Contains(lines[0], "component=test") {
t.Errorf("resolved line = %q, want client_ip and the With attribute", lines[0])
}
if strings.Contains(lines[1], "client_ip") {
t.Errorf("unresolved line = %q, want no client_ip", lines[1])
}
}
+11
View File
@@ -0,0 +1,11 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>MealQuest</title>
</head>
<body>
<!-- Placeholder so the Go embed compiles. The build replaces this directory with web/dist. -->
<p>MealQuest web assets have not been built.</p>
</body>
</html>
+127
View File
@@ -0,0 +1,127 @@
// Package frontend serves the embedded PWA build with an SPA fallback.
package frontend
import (
"embed"
"errors"
"fmt"
"io"
"io/fs"
"log/slog"
"net/http"
"path"
"strings"
"time"
)
// embedded holds the web build. The build copies web/dist over the committed
// placeholder index.html.
//
//go:embed all:dist
var embedded embed.FS
const (
indexFile = "index.html"
cacheImmutable = "public, max-age=31536000, immutable"
cacheNoCache = "no-cache"
)
// noCacheFiles must be revalidated on every load: they name the hashed assets
// (or, for the service worker, control their caching), so a stale copy pins an
// old release.
var noCacheFiles = map[string]bool{
indexFile: true,
"sw.js": true,
"manifest.webmanifest": true,
}
// Assets returns the embedded web build rooted at its dist directory.
func Assets() fs.FS {
sub, err := fs.Sub(embedded, "dist")
if err != nil {
panic(err) // "dist" is a valid path, so Sub cannot fail
}
return sub
}
// NewHandler returns a handler that serves files from fsys. A GET or HEAD for a
// path that is not a file in fsys is answered with index.html so client-side
// routes load the app. Paths equal to or under a reserved prefix (for example
// "/api") are never answered with index.html, whether before or after the path
// is cleaned; they are passed to notFound.
// fsys must contain index.html.
func NewHandler(fsys fs.FS, logger *slog.Logger, notFound http.Handler, reserved ...string) (http.Handler, error) {
if _, err := fs.Stat(fsys, indexFile); err != nil {
return nil, fmt.Errorf("frontend assets: %w", err)
}
return &handler{fsys: fsys, logger: logger, notFound: notFound, reserved: reserved}, nil
}
type handler struct {
fsys fs.FS
logger *slog.Logger
notFound http.Handler
reserved []string
}
func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
urlPath := path.Clean("/" + r.URL.Path)
// Check the uncleaned path too, so dot segments cannot escape a reserved
// prefix into the fallback ("/auth/../quests/42" cleans to "/quests/42").
if h.isReserved(r.URL.Path) || h.isReserved(urlPath) {
h.notFound.ServeHTTP(w, r)
return
}
name := strings.TrimPrefix(urlPath, "/")
if name == "" || !h.isFile(name) {
name = indexFile
}
h.serveFile(w, r, name)
}
func (h *handler) isReserved(urlPath string) bool {
for _, prefix := range h.reserved {
if urlPath == prefix || strings.HasPrefix(urlPath, prefix+"/") {
return true
}
}
return false
}
func (h *handler) isFile(name string) bool {
info, err := fs.Stat(h.fsys, name)
return err == nil && info.Mode().IsRegular()
}
func (h *handler) serveFile(w http.ResponseWriter, r *http.Request, name string) {
f, err := h.fsys.Open(name)
if err != nil {
h.fail(w, r, name, err)
return
}
defer f.Close()
content, ok := f.(io.ReadSeeker)
if !ok {
h.fail(w, r, name, errors.New("file does not implement io.Seeker"))
return
}
if noCacheFiles[name] {
w.Header().Set("Cache-Control", cacheNoCache)
} else {
w.Header().Set("Cache-Control", cacheImmutable)
}
if path.Ext(name) == ".webmanifest" {
w.Header().Set("Content-Type", "application/manifest+json")
}
// Embedded files carry no modification time; ServeContent omits
// Last-Modified for the zero time.
http.ServeContent(w, r, name, time.Time{}, content)
}
func (h *handler) fail(w http.ResponseWriter, r *http.Request, name string, err error) {
h.logger.ErrorContext(r.Context(), "serve frontend asset", "file", name, "error", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
+137
View File
@@ -0,0 +1,137 @@
package frontend
import (
"io"
"log/slog"
"net/http"
"net/http/httptest"
"testing"
"testing/fstest"
)
const (
indexBody = "<!doctype html><title>fixture index</title>"
assetBody = "console.log('fixture asset')"
robotsBody = "User-agent: *"
swBody = "self.addEventListener('fetch', () => {})"
manifestBody = `{"name":"MealQuest"}`
notFoundBody = "reserved not found"
)
func fixture() fstest.MapFS {
return fstest.MapFS{
"index.html": {Data: []byte(indexBody)},
"sw.js": {Data: []byte(swBody)},
"manifest.webmanifest": {Data: []byte(manifestBody)},
"assets/index-AbC123.js": {Data: []byte(assetBody)},
"robots.txt": {Data: []byte(robotsBody)},
}
}
func newTestHandler(t *testing.T) http.Handler {
t.Helper()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
notFound := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, notFoundBody, http.StatusNotFound)
})
h, err := NewHandler(fixture(), logger, notFound, "/api", "/auth", "/media", "/health")
if err != nil {
t.Fatal(err)
}
return h
}
func TestServe(t *testing.T) {
t.Parallel()
tests := []struct {
name string
method string
path string
wantBody string
wantCache string
}{
{"hashed asset", http.MethodGet, "/assets/index-AbC123.js", assetBody, cacheImmutable},
{"unhashed asset", http.MethodGet, "/robots.txt", robotsBody, cacheImmutable},
{"root", http.MethodGet, "/", indexBody, cacheNoCache},
{"index.html", http.MethodGet, "/index.html", indexBody, cacheNoCache},
{"service worker", http.MethodGet, "/sw.js", swBody, cacheNoCache},
{"manifest", http.MethodGet, "/manifest.webmanifest", manifestBody, cacheNoCache},
{"client route", http.MethodGet, "/quests/42", indexBody, cacheNoCache},
{"missing asset", http.MethodGet, "/assets/missing.js", indexBody, cacheNoCache},
{"directory", http.MethodGet, "/assets", indexBody, cacheNoCache},
{"dot segments", http.MethodGet, "/x/../../sw.js", swBody, cacheNoCache},
{"prefix lookalike", http.MethodGet, "/apiary", indexBody, cacheNoCache},
{"head", http.MethodHead, "/assets/index-AbC123.js", "", cacheImmutable},
}
h := newTestHandler(t)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(tt.method, tt.path, nil))
if rec.Code != http.StatusOK {
t.Errorf("status = %d, want 200", rec.Code)
}
if got := rec.Body.String(); got != tt.wantBody {
t.Errorf("body = %q, want %q", got, tt.wantBody)
}
if got := rec.Header().Get("Cache-Control"); got != tt.wantCache {
t.Errorf("Cache-Control = %q, want %q", got, tt.wantCache)
}
})
}
}
func TestManifestContentType(t *testing.T) {
t.Parallel()
rec := httptest.NewRecorder()
newTestHandler(t).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/manifest.webmanifest", nil))
if got := rec.Header().Get("Content-Type"); got != "application/manifest+json" {
t.Errorf("Content-Type = %q, want application/manifest+json", got)
}
}
func TestReservedPrefixesNotServed(t *testing.T) {
t.Parallel()
h := newTestHandler(t)
for _, path := range []string{"/api", "/api/unknown", "/auth/login", "/media/x.jpg", "/health", "/health/deep", "/x/../api/unknown", "/auth/../quests/42", "/api%2F..%2Fquests/42"} {
t.Run(path, func(t *testing.T) {
t.Parallel()
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
if rec.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404", rec.Code)
}
if got := rec.Body.String(); got != notFoundBody+"\n" {
t.Errorf("body = %q, want the notFound handler's body", got)
}
})
}
}
func TestNewHandlerRequiresIndex(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
fsys := fstest.MapFS{"sw.js": {Data: []byte(swBody)}}
if _, err := NewHandler(fsys, logger, http.NotFoundHandler()); err == nil {
t.Fatal("NewHandler without index.html: want error, got nil")
}
}
func TestAssetsContainsIndex(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewHandler(Assets(), logger, http.NotFoundHandler()); err != nil {
t.Fatalf("embedded assets: %v", err)
}
}
+93
View File
@@ -0,0 +1,93 @@
package household
import (
"context"
"database/sql"
"errors"
"fmt"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
// DefaultPersonaPrompt is the default AI system prompt (CTXD PROMPTS.MD §4).
// SeedAISettings stores it on first boot and the Admin reset restores it.
const DefaultPersonaPrompt = `You are MealQuest, a dinner planning assistant for one household. Suggest dinners
similar to their regular rotation, nudging them to try something a little new each
week. Prefer weeknight-friendly recipes with common grocery-store ingredients.
Flag crock pot meals and anything 30 minutes or less as "quick". Keep explanations
short, warm and specific — mention the household's own regular dinners when you
explain why something fits. Never suggest a dinner they already make or have
declined.`
// Seed values for the AI settings (CTXD CONTEXT.MD). The base URL and model
// are empty until the household picks a provider preset; there is no key.
const (
SeedAIProvider = "anthropic"
SeedAIBaseURL = ""
SeedAIModel = ""
SeedAITemperature = 0.7
)
// seedNotificationPrefs are the notification preferences for a new
// household, one per event: push for every event, chat only for the plan
// and the shopping list.
var seedNotificationPrefs = []struct {
Event string
Push, Chat bool
}{
{Event: "plan_ready", Push: true, Chat: true},
{Event: "list_ready", Push: true, Chat: true},
{Event: "day_of_reminder", Push: true, Chat: false},
{Event: "new_suggestions", Push: true, Chat: false},
{Event: "rotation_review", Push: true, Chat: false},
}
// SeedAISettings creates the AI settings row with the seed values and
// DefaultPersonaPrompt if it does not exist. An existing row is never
// changed.
func SeedAISettings(ctx context.Context, st *store.Store) error {
return st.WithTx(ctx, func(tx *sql.Tx) error {
q := db.New(tx)
_, err := q.GetAISettings(ctx)
if err == nil {
return nil
}
if !errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("household: read AI settings: %w", err)
}
now := store.Now().String()
_, err = q.UpsertAISettings(ctx, db.UpsertAISettingsParams{
Provider: SeedAIProvider,
BaseUrl: SeedAIBaseURL,
Model: SeedAIModel,
Temperature: SeedAITemperature,
SystemPrompt: sql.NullString{String: DefaultPersonaPrompt, Valid: true},
CreatedAt: now,
UpdatedAt: now,
})
if err != nil {
return fmt.Errorf("household: seed AI settings: %w", err)
}
return nil
})
}
// SeedNotificationPrefs creates the seed notification preference row of
// every event that has none. Existing rows are never changed.
func SeedNotificationPrefs(ctx context.Context, st *store.Store) error {
return st.WithTx(ctx, func(tx *sql.Tx) error {
now := store.Now().String()
for _, p := range seedNotificationPrefs {
_, err := tx.ExecContext(ctx,
`INSERT INTO notification_prefs (event, push, chat, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT (event) DO NOTHING`,
p.Event, p.Push, p.Chat, now, now)
if err != nil {
return fmt.Errorf("household: seed notification preference %s: %w", p.Event, err)
}
}
return nil
})
}
+220
View File
@@ -0,0 +1,220 @@
package household
import (
"context"
"database/sql"
"testing"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
// notificationPrefs returns the stored notification preferences by event as
// [push, chat].
func notificationPrefs(t *testing.T, st *store.Store) map[string][2]int64 {
t.Helper()
rows, err := st.Reader.QueryContext(context.Background(), `SELECT event, push, chat FROM notification_prefs`)
if err != nil {
t.Fatal(err)
}
defer rows.Close()
got := make(map[string][2]int64)
for rows.Next() {
var event string
var push, chat int64
if err := rows.Scan(&event, &push, &chat); err != nil {
t.Fatal(err)
}
got[event] = [2]int64{push, chat}
}
if err := rows.Err(); err != nil {
t.Fatal(err)
}
return got
}
func TestSeedAISettingsCreatesRow(t *testing.T) {
t.Parallel()
ctx := context.Background()
st := migratedStore(t, t.TempDir())
if err := SeedAISettings(ctx, st); err != nil {
t.Fatal(err)
}
got, err := db.New(st.Reader).GetAISettings(ctx)
if err != nil {
t.Fatal(err)
}
want := db.AiSetting{
ID: 1,
Provider: "anthropic",
BaseUrl: "",
Model: "",
Temperature: 0.7,
SystemPrompt: sql.NullString{String: DefaultPersonaPrompt, Valid: true},
CreatedAt: got.CreatedAt,
UpdatedAt: got.UpdatedAt,
}
if got.ID != want.ID || got.Provider != want.Provider || got.BaseUrl != want.BaseUrl ||
got.Model != want.Model || got.Temperature != want.Temperature || got.SystemPrompt != want.SystemPrompt ||
got.ApiKeyCt != nil || got.ApiKeyLast4.Valid || got.KeyVerifiedAt.Valid {
t.Errorf("seeded AI settings = %+v\nwant %+v", got, want)
}
var created store.Timestamp
if err := created.Scan(got.CreatedAt); err != nil {
t.Errorf("created_at: %v", err)
}
if got.UpdatedAt != got.CreatedAt {
t.Errorf("updated_at = %q, want created_at %q", got.UpdatedAt, got.CreatedAt)
}
}
func TestDefaultPersonaPrompt(t *testing.T) {
t.Parallel()
const want = "You are MealQuest, a dinner planning assistant for one household. Suggest dinners\n" +
"similar to their regular rotation, nudging them to try something a little new each\n" +
"week. Prefer weeknight-friendly recipes with common grocery-store ingredients.\n" +
"Flag crock pot meals and anything 30 minutes or less as \"quick\". Keep explanations\n" +
"short, warm and specific — mention the household's own regular dinners when you\n" +
"explain why something fits. Never suggest a dinner they already make or have\n" +
"declined."
if DefaultPersonaPrompt != want {
t.Errorf("DefaultPersonaPrompt = %q\nwant %q", DefaultPersonaPrompt, want)
}
}
func TestSeedAISettingsNeverOverwrites(t *testing.T) {
t.Parallel()
ctx := context.Background()
dir := t.TempDir()
// First boot seeds; the household then configures a provider.
first := migratedStore(t, dir)
if err := SeedAISettings(ctx, first); err != nil {
t.Fatal(err)
}
seeded, err := db.New(first.Reader).GetAISettings(ctx)
if err != nil {
t.Fatal(err)
}
changed, err := db.New(first.Writer).UpsertAISettings(ctx, db.UpsertAISettingsParams{
Provider: "ollama",
BaseUrl: "http://ollama:11434",
Model: "llama3",
Temperature: 0.2,
SystemPrompt: sql.NullString{String: "Be brief.", Valid: true},
ApiKeyCt: []byte{1, 2, 3},
ApiKeyLast4: sql.NullString{String: "wxyz", Valid: true},
KeyVerifiedAt: sql.NullString{String: store.Now().String(), Valid: true},
CreatedAt: seeded.CreatedAt,
UpdatedAt: store.Now().String(),
})
if err != nil {
t.Fatal(err)
}
if err := first.Close(); err != nil {
t.Fatal(err)
}
// A later boot reopens the same database and seeds again.
st := migratedStore(t, dir)
if err := SeedAISettings(ctx, st); err != nil {
t.Fatalf("reboot: %v", err)
}
got, err := db.New(st.Reader).GetAISettings(ctx)
if err != nil {
t.Fatal(err)
}
if got.Provider != changed.Provider || got.BaseUrl != changed.BaseUrl || got.Model != changed.Model ||
got.Temperature != changed.Temperature || got.SystemPrompt != changed.SystemPrompt ||
string(got.ApiKeyCt) != string(changed.ApiKeyCt) || got.ApiKeyLast4 != changed.ApiKeyLast4 ||
got.KeyVerifiedAt != changed.KeyVerifiedAt || got.CreatedAt != changed.CreatedAt || got.UpdatedAt != changed.UpdatedAt {
t.Errorf("reboot changed AI settings:\n got %+v\nwant %+v", got, changed)
}
}
func TestSeedNotificationPrefsCreatesRows(t *testing.T) {
t.Parallel()
ctx := context.Background()
st := migratedStore(t, t.TempDir())
if err := SeedNotificationPrefs(ctx, st); err != nil {
t.Fatal(err)
}
want := map[string][2]int64{
"plan_ready": {1, 1},
"list_ready": {1, 1},
"day_of_reminder": {1, 0},
"new_suggestions": {1, 0},
"rotation_review": {1, 0},
}
got := notificationPrefs(t, st)
if len(got) != len(want) {
t.Errorf("seeded %d notification preferences, want %d: %v", len(got), len(want), got)
}
for event, w := range want {
if g, ok := got[event]; !ok || g != w {
t.Errorf("%s [push, chat] = %v (present %t), want %v", event, g, ok, w)
}
}
var created, updated string
err := st.Reader.QueryRowContext(ctx, `SELECT created_at, updated_at FROM notification_prefs WHERE event = 'plan_ready'`).Scan(&created, &updated)
if err != nil {
t.Fatal(err)
}
var ts store.Timestamp
if err := ts.Scan(created); err != nil {
t.Errorf("created_at: %v", err)
}
if updated != created {
t.Errorf("updated_at = %q, want created_at %q", updated, created)
}
}
func TestSeedNotificationPrefsNeverOverwrites(t *testing.T) {
t.Parallel()
ctx := context.Background()
dir := t.TempDir()
// First boot seeds; the household then changes two preferences, and one
// event's row goes missing.
first := migratedStore(t, dir)
if err := SeedNotificationPrefs(ctx, first); err != nil {
t.Fatal(err)
}
for _, stmt := range []string{
`UPDATE notification_prefs SET push = 0, chat = 0 WHERE event = 'plan_ready'`,
`UPDATE notification_prefs SET chat = 1 WHERE event = 'rotation_review'`,
`DELETE FROM notification_prefs WHERE event = 'new_suggestions'`,
} {
if _, err := first.Writer.ExecContext(ctx, stmt); err != nil {
t.Fatal(err)
}
}
if err := first.Close(); err != nil {
t.Fatal(err)
}
// A later boot keeps the changed rows and seeds only the missing one.
st := migratedStore(t, dir)
if err := SeedNotificationPrefs(ctx, st); err != nil {
t.Fatalf("reboot: %v", err)
}
want := map[string][2]int64{
"plan_ready": {0, 0},
"list_ready": {1, 1},
"day_of_reminder": {1, 0},
"new_suggestions": {1, 0},
"rotation_review": {1, 1},
}
got := notificationPrefs(t, st)
if len(got) != len(want) {
t.Errorf("%d notification preferences after reboot, want %d: %v", len(got), len(want), got)
}
for event, w := range want {
if g, ok := got[event]; !ok || g != w {
t.Errorf("after reboot %s [push, chat] = %v (present %t), want %v", event, g, ok, w)
}
}
}
+340
View File
@@ -0,0 +1,340 @@
package household
import (
"context"
"database/sql"
"errors"
"fmt"
"regexp"
"strings"
"sync"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
// Seed values for a new household (CTXD CONTEXT.MD). Days count from
// Sunday = 0, as time.Weekday does.
const (
SeedDinnerTime = "17:30"
SeedReminderTime = "16:30"
SeedReviewTime = "09:00" // rotation check
SeedPlanDraftDow = 5 // Friday
SeedPlanDraftTime = "09:00"
SeedSuggestionsDow = 0 // Sunday
SeedSuggestionsTime = "08:00"
SeedChatAutosendDow = 6 // Saturday
SeedChatAutosendTime = "08:00"
)
// ErrNoDefaultZone is returned by SeedSettings when the household settings
// do not exist yet and no default zone was configured to create them with.
var ErrNoDefaultZone = errors.New("household settings do not exist yet and no default time zone is configured")
// ErrInvalidDefaultZone is returned by SeedSettings when the household
// settings do not exist yet and the configured default zone cannot be loaded.
var ErrInvalidDefaultZone = errors.New("household settings do not exist yet and the default time zone is invalid")
// Settings are the shared household settings. Times are household-local
// 24-hour HH:MM in Timezone; days of the week count from Sunday = 0.
type Settings struct {
Timezone string `json:"timezone"`
DinnerTime string `json:"dinner_time"`
ReminderTime string `json:"reminder_time"`
ReviewTime string `json:"review_time"`
PlanDraftDow int `json:"plan_draft_dow"`
PlanDraftTime string `json:"plan_draft_time"`
SuggestionsDow int `json:"suggestions_dow"`
SuggestionsTime string `json:"suggestions_time"`
ChatAutosendDow int `json:"chat_autosend_dow"`
ChatAutosendTime string `json:"chat_autosend_time"`
OnboardingCompletedAt *string `json:"onboarding_completed_at"`
UpdatesSeenAt *string `json:"updates_seen_at"`
UpdatedBy *string `json:"updated_by"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// SettingsPatch changes the household settings. Nil fields are left as they
// are.
type SettingsPatch struct {
Timezone *string
DinnerTime *string
ReminderTime *string
ReviewTime *string
PlanDraftDow *int
PlanDraftTime *string
SuggestionsDow *int
SuggestionsTime *string
ChatAutosendDow *int
ChatAutosendTime *string
}
// Problem is one invalid field of a SettingsPatch, named by its JSON key.
type Problem struct {
Field string
Reason string
}
// ValidationError lists every invalid field of a SettingsPatch.
type ValidationError struct {
Problems []Problem
}
func (e *ValidationError) Error() string {
parts := make([]string, len(e.Problems))
for i, p := range e.Problems {
parts[i] = p.Field + ": " + p.Reason
}
return "invalid household settings: " + strings.Join(parts, "; ")
}
// SeedSettings creates the household settings row with zone defaultTZ and the
// seed times and days if it does not exist. An existing row is never changed,
// and defaultTZ is then ignored, valid or not. With no row, an empty
// defaultTZ is ErrNoDefaultZone and an unloadable one ErrInvalidDefaultZone.
func SeedSettings(ctx context.Context, st *store.Store, defaultTZ string) error {
return st.WithTx(ctx, func(tx *sql.Tx) error {
q := db.New(tx)
_, err := q.GetHouseholdSettings(ctx)
if err == nil {
return nil
}
if !errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("household: read settings: %w", err)
}
if defaultTZ == "" {
return ErrNoDefaultZone
}
if _, err := LoadZone(defaultTZ); err != nil {
return fmt.Errorf("%w: %v", ErrInvalidDefaultZone, err)
}
now := store.Now().String()
_, err = q.UpsertHouseholdSettings(ctx, db.UpsertHouseholdSettingsParams{
Timezone: defaultTZ,
DinnerTime: SeedDinnerTime,
ReminderTime: SeedReminderTime,
ReviewTime: SeedReviewTime,
PlanDraftDow: SeedPlanDraftDow,
PlanDraftTime: SeedPlanDraftTime,
SuggestionsDow: SeedSuggestionsDow,
SuggestionsTime: SeedSuggestionsTime,
ChatAutosendDow: SeedChatAutosendDow,
ChatAutosendTime: SeedChatAutosendTime,
CreatedAt: now,
UpdatedAt: now,
})
if err != nil {
return fmt.Errorf("household: seed settings: %w", err)
}
return nil
})
}
// SettingsService reads and changes the household settings, and tells
// in-process subscribers (the scheduler) about every change.
type SettingsService struct {
st *store.Store
// updateMu serialises Update with its publish, so subscribers see
// changes in commit order.
updateMu sync.Mutex
subsMu sync.Mutex
subs map[int]func(Settings)
nextID int
}
// NewSettingsService returns a service over st. Call SeedSettings first.
func NewSettingsService(st *store.Store) *SettingsService {
return &SettingsService{st: st, subs: make(map[int]func(Settings))}
}
// Get returns the household settings.
func (s *SettingsService) Get(ctx context.Context) (Settings, error) {
row, err := db.New(s.st.Reader).GetHouseholdSettings(ctx)
if err != nil {
return Settings{}, fmt.Errorf("household: read settings: %w", err)
}
return fromRow(row), nil
}
// Update validates patch, applies it, records updatedBy (the signed-in
// member's subject, which must not be empty) and publishes the new settings
// to every subscriber. An invalid patch is a *ValidationError and changes
// nothing.
func (s *SettingsService) Update(ctx context.Context, patch SettingsPatch, updatedBy string) (Settings, error) {
if updatedBy == "" {
return Settings{}, errors.New("household: update settings: no member to record as updated_by")
}
if err := patch.validate(); err != nil {
return Settings{}, err
}
s.updateMu.Lock()
defer s.updateMu.Unlock()
var row db.HouseholdSetting
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
q := db.New(tx)
cur, err := q.GetHouseholdSettings(ctx)
if err != nil {
return fmt.Errorf("household: read settings: %w", err)
}
p := db.UpsertHouseholdSettingsParams{
Timezone: cur.Timezone,
DinnerTime: cur.DinnerTime,
ReminderTime: cur.ReminderTime,
ReviewTime: cur.ReviewTime,
PlanDraftDow: cur.PlanDraftDow,
PlanDraftTime: cur.PlanDraftTime,
SuggestionsDow: cur.SuggestionsDow,
SuggestionsTime: cur.SuggestionsTime,
ChatAutosendDow: cur.ChatAutosendDow,
ChatAutosendTime: cur.ChatAutosendTime,
OnboardingCompletedAt: cur.OnboardingCompletedAt,
UpdatesSeenAt: cur.UpdatesSeenAt,
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
CreatedAt: cur.CreatedAt,
UpdatedAt: store.Now().String(),
}
setString(&p.Timezone, patch.Timezone)
setString(&p.DinnerTime, patch.DinnerTime)
setString(&p.ReminderTime, patch.ReminderTime)
setString(&p.ReviewTime, patch.ReviewTime)
setDow(&p.PlanDraftDow, patch.PlanDraftDow)
setString(&p.PlanDraftTime, patch.PlanDraftTime)
setDow(&p.SuggestionsDow, patch.SuggestionsDow)
setString(&p.SuggestionsTime, patch.SuggestionsTime)
setDow(&p.ChatAutosendDow, patch.ChatAutosendDow)
setString(&p.ChatAutosendTime, patch.ChatAutosendTime)
row, err = q.UpsertHouseholdSettings(ctx, p)
if err != nil {
return fmt.Errorf("household: update settings: %w", err)
}
return nil
})
if err != nil {
return Settings{}, err
}
settings := fromRow(row)
s.publish(settings)
return settings, nil
}
// Subscribe registers fn to receive the new settings after every successful
// Update. fn runs synchronously on the updating goroutine, so it must return
// promptly and must not call Update. The returned function unsubscribes fn.
func (s *SettingsService) Subscribe(fn func(Settings)) (unsubscribe func()) {
s.subsMu.Lock()
defer s.subsMu.Unlock()
id := s.nextID
s.nextID++
s.subs[id] = fn
return func() {
s.subsMu.Lock()
defer s.subsMu.Unlock()
delete(s.subs, id)
}
}
func (s *SettingsService) publish(settings Settings) {
s.subsMu.Lock()
fns := make([]func(Settings), 0, len(s.subs))
for _, fn := range s.subs {
fns = append(fns, fn)
}
s.subsMu.Unlock()
for _, fn := range fns {
fn(settings)
}
}
func (p SettingsPatch) validate() error {
var problems []Problem
fail := func(field, reason string) {
problems = append(problems, Problem{Field: field, Reason: reason})
}
if p == (SettingsPatch{}) {
fail("settings", "no settings to change")
}
if p.Timezone != nil {
if _, err := LoadZone(*p.Timezone); err != nil {
fail("timezone", err.Error())
}
}
for _, t := range []struct {
field string
value *string
}{
{"dinner_time", p.DinnerTime},
{"reminder_time", p.ReminderTime},
{"review_time", p.ReviewTime},
{"plan_draft_time", p.PlanDraftTime},
{"suggestions_time", p.SuggestionsTime},
{"chat_autosend_time", p.ChatAutosendTime},
} {
if t.value != nil && !clockTime.MatchString(*t.value) {
fail(t.field, fmt.Sprintf("invalid time %q (want 24-hour HH:MM, 00:00-23:59)", *t.value))
}
}
for _, d := range []struct {
field string
value *int
}{
{"plan_draft_dow", p.PlanDraftDow},
{"suggestions_dow", p.SuggestionsDow},
{"chat_autosend_dow", p.ChatAutosendDow},
} {
if d.value != nil && (*d.value < 0 || *d.value > 6) {
fail(d.field, fmt.Sprintf("invalid day of the week %d (want 0-6, Sunday = 0)", *d.value))
}
}
if len(problems) > 0 {
return &ValidationError{Problems: problems}
}
return nil
}
// clockTime matches a zero-padded 24-hour HH:MM time.
var clockTime = regexp.MustCompile(`^([01][0-9]|2[0-3]):[0-5][0-9]$`)
func setString(dst *string, v *string) {
if v != nil {
*dst = *v
}
}
func setDow(dst *int64, v *int) {
if v != nil {
*dst = int64(*v)
}
}
func fromRow(r db.HouseholdSetting) Settings {
return Settings{
Timezone: r.Timezone,
DinnerTime: r.DinnerTime,
ReminderTime: r.ReminderTime,
ReviewTime: r.ReviewTime,
PlanDraftDow: int(r.PlanDraftDow),
PlanDraftTime: r.PlanDraftTime,
SuggestionsDow: int(r.SuggestionsDow),
SuggestionsTime: r.SuggestionsTime,
ChatAutosendDow: int(r.ChatAutosendDow),
ChatAutosendTime: r.ChatAutosendTime,
OnboardingCompletedAt: nullable(r.OnboardingCompletedAt),
UpdatesSeenAt: nullable(r.UpdatesSeenAt),
UpdatedBy: nullable(r.UpdatedBy),
CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt,
}
}
func nullable(s sql.NullString) *string {
if !s.Valid {
return nil
}
return &s.String
}
+374
View File
@@ -0,0 +1,374 @@
package household
import (
"context"
"errors"
"slices"
"sync"
"testing"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
)
// migratedStore opens a temp store in dir migrated with the embedded
// migrations, closed when the test ends.
func migratedStore(t *testing.T, dir string) *store.Store {
t.Helper()
ctx := context.Background()
st, err := store.Open(ctx, dir)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := st.Close(); err != nil {
t.Error(err)
}
})
if err := store.Migrate(ctx, st.Writer, migrations.FS); err != nil {
t.Fatal(err)
}
return st
}
// seededService returns a settings service over a migrated temp database
// seeded with zone America/New_York.
func seededService(t *testing.T) *SettingsService {
t.Helper()
st := migratedStore(t, t.TempDir())
if err := SeedSettings(context.Background(), st, "America/New_York"); err != nil {
t.Fatal(err)
}
return NewSettingsService(st)
}
func ptr[T any](v T) *T { return &v }
func TestSeedSettingsCreatesRow(t *testing.T) {
t.Parallel()
ctx := context.Background()
st := migratedStore(t, t.TempDir())
if err := SeedSettings(ctx, st, "America/Chicago"); err != nil {
t.Fatal(err)
}
got, err := NewSettingsService(st).Get(ctx)
if err != nil {
t.Fatal(err)
}
want := Settings{
Timezone: "America/Chicago",
DinnerTime: "17:30",
ReminderTime: "16:30",
ReviewTime: "09:00",
PlanDraftDow: 5,
PlanDraftTime: "09:00",
SuggestionsDow: 0,
SuggestionsTime: "08:00",
ChatAutosendDow: 6,
ChatAutosendTime: "08:00",
CreatedAt: got.CreatedAt,
UpdatedAt: got.UpdatedAt,
}
if !equalSettings(got, want) {
t.Errorf("seeded settings = %+v\nwant %+v", got, want)
}
var created store.Timestamp
if err := created.Scan(got.CreatedAt); err != nil {
t.Errorf("created_at: %v", err)
}
if got.UpdatedAt != got.CreatedAt {
t.Errorf("updated_at = %q, want created_at %q", got.UpdatedAt, got.CreatedAt)
}
}
func TestSeedSettingsNeverOverwrites(t *testing.T) {
t.Parallel()
ctx := context.Background()
dir := t.TempDir()
// First boot seeds; the household then changes a setting.
first := migratedStore(t, dir)
if err := SeedSettings(ctx, first, "America/Chicago"); err != nil {
t.Fatal(err)
}
changed, err := NewSettingsService(first).Update(ctx, SettingsPatch{DinnerTime: ptr("18:15")}, "sam")
if err != nil {
t.Fatal(err)
}
if err := first.Close(); err != nil {
t.Fatal(err)
}
// Later boots reopen the same database with a different default zone,
// an invalid one and none at all.
for _, defaultTZ := range []string{"Europe/London", "Mars/Olympus_Mons", ""} {
st := migratedStore(t, dir)
if err := SeedSettings(ctx, st, defaultTZ); err != nil {
t.Fatalf("reboot with default zone %q: %v", defaultTZ, err)
}
got, err := NewSettingsService(st).Get(ctx)
if err != nil {
t.Fatal(err)
}
if !equalSettings(got, changed) {
t.Errorf("reboot with default zone %q changed settings:\n got %+v\nwant %+v", defaultTZ, got, changed)
}
if err := st.Close(); err != nil {
t.Fatal(err)
}
}
}
func TestSeedSettingsRequiresDefaultZone(t *testing.T) {
t.Parallel()
ctx := context.Background()
tests := []struct {
name string
defaultTZ string
is error
}{
{"unset", "", ErrNoDefaultZone},
{"not an IANA zone", "Mars/Olympus_Mons", ErrInvalidDefaultZone},
{"Local", "Local", ErrInvalidDefaultZone},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
st := migratedStore(t, t.TempDir())
err := SeedSettings(ctx, st, tt.defaultTZ)
if err == nil {
t.Fatal("SeedSettings succeeded")
}
if !errors.Is(err, tt.is) {
t.Errorf("error = %v, want %v", err, tt.is)
}
if _, err := NewSettingsService(st).Get(ctx); err == nil {
t.Error("a settings row was created despite the error")
}
})
}
}
func TestUpdateAppliesPatchAndRecordsUpdatedBy(t *testing.T) {
t.Parallel()
ctx := context.Background()
svc := seededService(t)
before, err := svc.Get(ctx)
if err != nil {
t.Fatal(err)
}
got, err := svc.Update(ctx, SettingsPatch{
Timezone: ptr("Europe/Paris"),
DinnerTime: ptr("19:00"),
ReminderTime: ptr("00:00"),
ReviewTime: ptr("23:59"),
PlanDraftDow: ptr(4),
PlanDraftTime: ptr("10:30"),
SuggestionsDow: ptr(1),
SuggestionsTime: ptr("07:45"),
ChatAutosendDow: ptr(0),
ChatAutosendTime: ptr("12:00"),
}, "sam")
if err != nil {
t.Fatal(err)
}
want := Settings{
Timezone: "Europe/Paris",
DinnerTime: "19:00",
ReminderTime: "00:00",
ReviewTime: "23:59",
PlanDraftDow: 4,
PlanDraftTime: "10:30",
SuggestionsDow: 1,
SuggestionsTime: "07:45",
ChatAutosendDow: 0,
ChatAutosendTime: "12:00",
UpdatedBy: ptr("sam"),
CreatedAt: before.CreatedAt,
UpdatedAt: got.UpdatedAt,
}
if !equalSettings(got, want) {
t.Errorf("Update = %+v\nwant %+v", got, want)
}
if got.UpdatedAt < before.UpdatedAt {
t.Errorf("updated_at went back: %s < %s", got.UpdatedAt, before.UpdatedAt)
}
stored, err := svc.Get(ctx)
if err != nil {
t.Fatal(err)
}
if !equalSettings(stored, got) {
t.Errorf("stored = %+v\nwant %+v", stored, got)
}
// A partial patch changes only its fields and records its own member.
got, err = svc.Update(ctx, SettingsPatch{DinnerTime: ptr("18:00")}, "alex")
if err != nil {
t.Fatal(err)
}
want.DinnerTime = "18:00"
want.UpdatedBy = ptr("alex")
want.UpdatedAt = got.UpdatedAt
if !equalSettings(got, want) {
t.Errorf("partial Update = %+v\nwant %+v", got, want)
}
}
func TestUpdateRequiresMember(t *testing.T) {
t.Parallel()
ctx := context.Background()
svc := seededService(t)
notified := 0
svc.Subscribe(func(Settings) { notified++ })
if _, err := svc.Update(ctx, SettingsPatch{DinnerTime: ptr("18:00")}, ""); err == nil {
t.Fatal("Update with no member succeeded")
}
got, err := svc.Get(ctx)
if err != nil {
t.Fatal(err)
}
if got.DinnerTime != SeedDinnerTime || got.UpdatedBy != nil || notified != 0 {
t.Errorf("Update with no member changed settings or notified: %+v, notified %d", got, notified)
}
}
func TestUpdateValidation(t *testing.T) {
t.Parallel()
ctx := context.Background()
tests := []struct {
name string
patch SettingsPatch
fields []string
}{
{"empty patch", SettingsPatch{}, []string{"settings"}},
{"unknown zone", SettingsPatch{Timezone: ptr("Mars/Olympus_Mons")}, []string{"timezone"}},
{"empty zone", SettingsPatch{Timezone: ptr("")}, []string{"timezone"}},
{"Local zone", SettingsPatch{Timezone: ptr("Local")}, []string{"timezone"}},
{"zone path traversal", SettingsPatch{Timezone: ptr("../../etc/passwd")}, []string{"timezone"}},
{"hour 24", SettingsPatch{DinnerTime: ptr("24:00")}, []string{"dinner_time"}},
{"minute 60", SettingsPatch{ReminderTime: ptr("16:60")}, []string{"reminder_time"}},
{"unpadded hour", SettingsPatch{ReviewTime: ptr("9:00")}, []string{"review_time"}},
{"seconds", SettingsPatch{PlanDraftTime: ptr("09:00:00")}, []string{"plan_draft_time"}},
{"12-hour", SettingsPatch{SuggestionsTime: ptr("08:00 AM")}, []string{"suggestions_time"}},
{"empty time", SettingsPatch{ChatAutosendTime: ptr("")}, []string{"chat_autosend_time"}},
{"dow 7", SettingsPatch{PlanDraftDow: ptr(7)}, []string{"plan_draft_dow"}},
{"dow -1", SettingsPatch{SuggestionsDow: ptr(-1)}, []string{"suggestions_dow"}},
{"dow 100", SettingsPatch{ChatAutosendDow: ptr(100)}, []string{"chat_autosend_dow"}},
{
"every problem is reported",
SettingsPatch{Timezone: ptr("Nowhere"), DinnerTime: ptr("5pm"), PlanDraftDow: ptr(9)},
[]string{"timezone", "dinner_time", "plan_draft_dow"},
},
{
"valid fields do not mask an invalid one",
SettingsPatch{DinnerTime: ptr("18:00"), ChatAutosendDow: ptr(8)},
[]string{"chat_autosend_dow"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
svc := seededService(t)
before, err := svc.Get(ctx)
if err != nil {
t.Fatal(err)
}
published := 0
svc.Subscribe(func(Settings) { published++ })
_, err = svc.Update(ctx, tt.patch, "sam")
var verr *ValidationError
if !errors.As(err, &verr) {
t.Fatalf("Update error = %v, want *ValidationError", err)
}
var fields []string
for _, p := range verr.Problems {
fields = append(fields, p.Field)
}
if !slices.Equal(fields, tt.fields) {
t.Errorf("problem fields = %v, want %v", fields, tt.fields)
}
after, err := svc.Get(ctx)
if err != nil {
t.Fatal(err)
}
if !equalSettings(after, before) {
t.Errorf("invalid patch changed settings:\n got %+v\nwant %+v", after, before)
}
if published != 0 {
t.Errorf("invalid patch published %d times", published)
}
})
}
}
func TestUpdatePublishesToSubscribers(t *testing.T) {
t.Parallel()
ctx := context.Background()
svc := seededService(t)
var mu sync.Mutex
var first, second []Settings
unsubscribe := svc.Subscribe(func(s Settings) {
mu.Lock()
defer mu.Unlock()
first = append(first, s)
})
svc.Subscribe(func(s Settings) {
mu.Lock()
defer mu.Unlock()
second = append(second, s)
})
updated, err := svc.Update(ctx, SettingsPatch{Timezone: ptr("Asia/Tokyo")}, "sam")
if err != nil {
t.Fatal(err)
}
mu.Lock()
if len(first) != 1 || !equalSettings(first[0], updated) {
t.Errorf("first subscriber got %+v, want [%+v]", first, updated)
}
if len(second) != 1 || !equalSettings(second[0], updated) {
t.Errorf("second subscriber got %+v, want [%+v]", second, updated)
}
mu.Unlock()
unsubscribe()
updated, err = svc.Update(ctx, SettingsPatch{DinnerTime: ptr("18:45")}, "sam")
if err != nil {
t.Fatal(err)
}
mu.Lock()
defer mu.Unlock()
if len(first) != 1 {
t.Errorf("unsubscribed subscriber got %d notifications, want 1", len(first))
}
if len(second) != 2 || !equalSettings(second[1], updated) {
t.Errorf("second subscriber got %+v, want a second notification %+v", second, updated)
}
}
func TestUpdateWithoutSeedFails(t *testing.T) {
t.Parallel()
svc := NewSettingsService(migratedStore(t, t.TempDir()))
if _, err := svc.Update(context.Background(), SettingsPatch{DinnerTime: ptr("18:00")}, ""); err == nil {
t.Fatal("Update succeeded with no settings row")
}
}
// equalSettings compares settings by value, following the nullable pointers.
func equalSettings(a, b Settings) bool {
eq := func(x, y *string) bool { return (x == nil) == (y == nil) && (x == nil || *x == *y) }
if !eq(a.OnboardingCompletedAt, b.OnboardingCompletedAt) || !eq(a.UpdatesSeenAt, b.UpdatesSeenAt) || !eq(a.UpdatedBy, b.UpdatedBy) {
return false
}
a.OnboardingCompletedAt, a.UpdatesSeenAt, a.UpdatedBy = nil, nil, nil
b.OnboardingCompletedAt, b.UpdatesSeenAt, b.UpdatedBy = nil, nil, nil
return a == b
}
+130
View File
@@ -0,0 +1,130 @@
// Package household owns household-local calendar and clock logic. Every
// function works in the household's IANA zone, passed explicitly, and never
// in the process zone (time.Local).
//
// Weeks are ISO weeks: Monday to Sunday, numbered by ISO 8601, so the period
// key of 2026-12-28 is "2026-W53" and of 2027-01-04 is "2027-W01".
//
// Calendar arithmetic is done on UTC midnights, which have no DST, so a date
// plus seven days is always the same weekday a week later.
package household
import (
"errors"
"fmt"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
// Today returns the household-local date at instant now in zone loc.
func Today(now time.Time, loc *time.Location) store.LocalDate {
return store.LocalDateOf(now.In(loc))
}
// WeekStart returns the ISO Monday of the week containing d. It panics if d is
// the zero LocalDate.
func WeekStart(d store.LocalDate) store.LocalDate {
t := midnightUTC(d)
// Weekday counts from Sunday = 0; shift so Monday = 0 and Sunday = 6.
sinceMonday := (int(t.Weekday()) + 6) % 7
return store.LocalDateOf(t.AddDate(0, 0, -sinceMonday))
}
// WeekDays returns the seven dates Monday to Sunday of the week starting on
// weekStart. It is an error if weekStart is not a Monday.
func WeekDays(weekStart store.LocalDate) ([7]store.LocalDate, error) {
t, err := monday(weekStart)
if err != nil {
return [7]store.LocalDate{}, err
}
var days [7]store.LocalDate
for i := range days {
days[i] = store.LocalDateOf(t.AddDate(0, 0, i))
}
return days, nil
}
// WeekLabel returns the display label of the week starting on weekStart, such
// as "Week 41 · Oct 5 – 11", or "Week 40 · Sep 28 – Oct 4" when the week
// spans two months. It is an error if weekStart is not a Monday.
func WeekLabel(weekStart store.LocalDate) (string, error) {
start, err := monday(weekStart)
if err != nil {
return "", err
}
_, week := start.ISOWeek()
end := start.AddDate(0, 0, 6)
if end.Month() == start.Month() {
return fmt.Sprintf("Week %d · %s – %d", week, start.Format("Jan 2"), end.Day()), nil
}
return fmt.Sprintf("Week %d · %s – %s", week, start.Format("Jan 2"), end.Format("Jan 2")), nil
}
// PeriodKey returns the ISO week key of the week containing d, such as
// "2026-W41". The year is the ISO week-numbering year, which differs from the
// calendar year around New Year. It panics if d is the zero LocalDate.
func PeriodKey(d store.LocalDate) string {
year, week := midnightUTC(d).ISOWeek()
return fmt.Sprintf("%04d-W%02d", year, week)
}
// LocalTime returns the instant at which household clocks in zone loc read
// hhmm (24-hour "HH:MM") on date d.
//
// DST rule: a wall time that does not exist (spring forward) or occurs twice
// (fall back) is read with the UTC offset in effect before the transition. A
// skipped time therefore moves forward by the length of the gap (02:30 on a
// day clocks jump 02:00→03:00 is 03:30), and a repeated time is its first
// occurrence. The rule assumes at most one offset change in the 48 hours
// around the wall time, which holds for every real zone.
func LocalTime(loc *time.Location, d store.LocalDate, hhmm string) (time.Time, error) {
if d.IsZero() {
return time.Time{}, errors.New("household: zero date")
}
clock, err := time.Parse("15:04", hhmm)
if err != nil || clock.Format("15:04") != hhmm {
return time.Time{}, fmt.Errorf("household: invalid time %q: want HH:MM", hhmm)
}
y, m, day := d.Date()
// wall is the wall-clock reading expressed as if it were UTC; the real
// instant is wall minus the zone's UTC offset at that instant.
wall := time.Date(y, m, day, clock.Hour(), clock.Minute(), 0, 0, time.UTC)
before := offset(wall.Add(-24*time.Hour), loc)
after := offset(wall.Add(24*time.Hour), loc)
early := wall.Add(-before)
late := wall.Add(-after)
// Use the post-transition offset only when the pre-transition reading is
// not a real wall time and the post-transition one is.
if offset(early, loc) != before && offset(late, loc) == after {
return late.In(loc), nil
}
return early.In(loc), nil
}
// offset returns the UTC offset of zone loc at instant t.
func offset(t time.Time, loc *time.Location) time.Duration {
_, secs := t.In(loc).Zone()
return time.Duration(secs) * time.Second
}
// midnightUTC returns d as midnight UTC. It panics if d is the zero LocalDate.
func midnightUTC(d store.LocalDate) time.Time {
if d.IsZero() {
panic("household: zero date")
}
y, m, day := d.Date()
return time.Date(y, m, day, 0, 0, 0, 0, time.UTC)
}
// monday returns weekStart as midnight UTC, or an error if it is not a Monday.
func monday(weekStart store.LocalDate) (time.Time, error) {
if weekStart.IsZero() {
return time.Time{}, errors.New("household: zero week start")
}
t := midnightUTC(weekStart)
if t.Weekday() != time.Monday {
return time.Time{}, fmt.Errorf("household: week start %s is a %s, want Monday", weekStart, t.Weekday())
}
return t, nil
}
+311
View File
@@ -0,0 +1,311 @@
package household
import (
"strings"
"testing"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
// TestMain runs the package with the process zone set to UTC, so any use of
// time.Local in place of the household zone shows up as a failure.
func TestMain(m *testing.M) {
time.Local = time.UTC
m.Run()
}
func loadZone(t *testing.T, name string) *time.Location {
t.Helper()
loc, err := time.LoadLocation(name)
if err != nil {
t.Fatalf("LoadLocation(%q): %v", name, err)
}
return loc
}
func date(t *testing.T, s string) store.LocalDate {
t.Helper()
d, err := store.ParseLocalDate(s)
if err != nil {
t.Fatal(err)
}
return d
}
func utc(t *testing.T, s string) time.Time {
t.Helper()
ts, err := time.Parse(time.RFC3339, s)
if err != nil {
t.Fatal(err)
}
return ts
}
func TestTodayUsesHouseholdZone(t *testing.T) {
chicago := loadZone(t, "America/Chicago")
auckland := loadZone(t, "Pacific/Auckland")
for _, tc := range []struct {
now string
loc *time.Location
want string
}{
// 2026-10-05T04:30Z is Sunday 23:30 in Chicago (CDT, UTC−5) and
// Monday 17:30 in Auckland (NZDT, UTC+13).
{"2026-10-05T04:30:00Z", chicago, "2026-10-04"},
{"2026-10-05T04:30:00Z", auckland, "2026-10-05"},
// 2026-10-04T11:30Z is Sunday 06:30 in Chicago and already Monday
// 00:30 in Auckland.
{"2026-10-04T11:30:00Z", chicago, "2026-10-04"},
{"2026-10-04T11:30:00Z", auckland, "2026-10-05"},
} {
if got := Today(utc(t, tc.now), tc.loc).String(); got != tc.want {
t.Errorf("Today(%s, %s) = %s, want %s", tc.now, tc.loc, got, tc.want)
}
}
}
func TestSundayNightMondayMorningWeekAssignment(t *testing.T) {
for _, tc := range []struct {
zone, now string
wantDay string
wantWeekStart string
wantPeriodKey string
wantWeekday time.Weekday
}{
// Sunday 23:59 local belongs to the week that started the previous
// Monday; one minute later, Monday 00:00 starts a new week.
{"America/Chicago", "2026-10-12T04:59:00Z", "2026-10-11", "2026-10-05", "2026-W41", time.Sunday},
{"America/Chicago", "2026-10-12T05:00:00Z", "2026-10-12", "2026-10-12", "2026-W42", time.Monday},
{"Pacific/Auckland", "2026-10-11T10:59:00Z", "2026-10-11", "2026-10-05", "2026-W41", time.Sunday},
{"Pacific/Auckland", "2026-10-11T11:00:00Z", "2026-10-12", "2026-10-12", "2026-W42", time.Monday},
} {
loc := loadZone(t, tc.zone)
now := utc(t, tc.now)
if wd := now.In(loc).Weekday(); wd != tc.wantWeekday {
t.Fatalf("%s in %s is %s, want %s", tc.now, tc.zone, wd, tc.wantWeekday)
}
today := Today(now, loc)
if today.String() != tc.wantDay {
t.Errorf("Today(%s, %s) = %s, want %s", tc.now, tc.zone, today, tc.wantDay)
}
if got := WeekStart(today).String(); got != tc.wantWeekStart {
t.Errorf("WeekStart(%s) = %s, want %s", today, got, tc.wantWeekStart)
}
if got := PeriodKey(today); got != tc.wantPeriodKey {
t.Errorf("PeriodKey(%s) = %s, want %s", today, got, tc.wantPeriodKey)
}
}
}
func TestWeekStartEveryWeekday(t *testing.T) {
for _, d := range []string{
"2026-10-05", "2026-10-06", "2026-10-07", "2026-10-08",
"2026-10-09", "2026-10-10", "2026-10-11",
} {
if got := WeekStart(date(t, d)).String(); got != "2026-10-05" {
t.Errorf("WeekStart(%s) = %s, want 2026-10-05", d, got)
}
}
if got := WeekStart(date(t, "2026-10-12")).String(); got != "2026-10-12" {
t.Errorf("WeekStart(2026-10-12) = %s, want 2026-10-12", got)
}
}
func TestWeekDays(t *testing.T) {
days, err := WeekDays(date(t, "2026-09-28"))
if err != nil {
t.Fatal(err)
}
want := []string{
"2026-09-28", "2026-09-29", "2026-09-30", "2026-10-01",
"2026-10-02", "2026-10-03", "2026-10-04",
}
for i, d := range days {
if d.String() != want[i] {
t.Errorf("WeekDays[%d] = %s, want %s", i, d, want[i])
}
}
}
func TestWeekDaysAcrossDSTChange(t *testing.T) {
// Chicago falls back on Sunday 2026-11-01 and Auckland springs forward on
// Sunday 2026-09-27; neither shifts the dates of those weeks.
for _, tc := range []struct{ start, last string }{
{"2026-10-26", "2026-11-01"},
{"2026-09-21", "2026-09-27"},
{"2026-03-02", "2026-03-08"},
} {
days, err := WeekDays(date(t, tc.start))
if err != nil {
t.Fatal(err)
}
if days[0].String() != tc.start || days[6].String() != tc.last {
t.Errorf("WeekDays(%s) = %s … %s, want %s … %s", tc.start, days[0], days[6], tc.start, tc.last)
}
}
}
func TestISOWeekYearBoundary(t *testing.T) {
for _, tc := range []struct{ day, weekStart, key string }{
{"2026-12-27", "2026-12-21", "2026-W52"},
{"2026-12-28", "2026-12-28", "2026-W53"},
{"2026-12-31", "2026-12-28", "2026-W53"},
{"2027-01-01", "2026-12-28", "2026-W53"},
{"2027-01-03", "2026-12-28", "2026-W53"},
{"2027-01-04", "2027-01-04", "2027-W01"},
{"2027-01-10", "2027-01-04", "2027-W01"},
{"2026-01-01", "2025-12-29", "2026-W01"},
{"2026-10-07", "2026-10-05", "2026-W41"},
} {
d := date(t, tc.day)
if got := WeekStart(d).String(); got != tc.weekStart {
t.Errorf("WeekStart(%s) = %s, want %s", tc.day, got, tc.weekStart)
}
if got := PeriodKey(d); got != tc.key {
t.Errorf("PeriodKey(%s) = %s, want %s", tc.day, got, tc.key)
}
}
days, err := WeekDays(date(t, "2026-12-28"))
if err != nil {
t.Fatal(err)
}
if days[6].String() != "2027-01-03" {
t.Errorf("W53 Sunday = %s, want 2027-01-03", days[6])
}
}
func TestWeekLabel(t *testing.T) {
for _, tc := range []struct{ start, want string }{
{"2026-10-05", "Week 41 · Oct 5 – 11"},
{"2026-09-28", "Week 40 · Sep 28 – Oct 4"},
{"2026-12-28", "Week 53 · Dec 28 – Jan 3"},
{"2027-01-04", "Week 1 · Jan 4 – 10"},
} {
got, err := WeekLabel(date(t, tc.start))
if err != nil {
t.Fatal(err)
}
if got != tc.want {
t.Errorf("WeekLabel(%s) = %q, want %q", tc.start, got, tc.want)
}
}
}
func TestWeekStartRequired(t *testing.T) {
for _, d := range []store.LocalDate{date(t, "2026-10-07"), date(t, "2026-10-11"), {}} {
if _, err := WeekDays(d); err == nil {
t.Errorf("WeekDays(%s) succeeded, want error", d)
}
if _, err := WeekLabel(d); err == nil {
t.Errorf("WeekLabel(%s) succeeded, want error", d)
}
}
}
func TestZeroDatePanics(t *testing.T) {
for name, fn := range map[string]func(){
"WeekStart": func() { WeekStart(store.LocalDate{}) },
"PeriodKey": func() { PeriodKey(store.LocalDate{}) },
} {
func() {
defer func() {
if recover() == nil {
t.Errorf("%s(zero) did not panic", name)
}
}()
fn()
}()
}
}
func TestLocalTime(t *testing.T) {
for _, tc := range []struct {
zone, day, hhmm string
want string // UTC instant
wantWall string // local wall clock of the result
}{
// Ordinary days.
{"America/Chicago", "2026-10-05", "18:30", "2026-10-05T23:30:00Z", "2026-10-05 18:30 CDT"},
{"America/Chicago", "2026-12-28", "00:00", "2026-12-28T06:00:00Z", "2026-12-28 00:00 CST"},
{"Pacific/Auckland", "2026-10-05", "18:30", "2026-10-05T05:30:00Z", "2026-10-05 18:30 NZDT"},
{"Pacific/Auckland", "2026-07-01", "23:59", "2026-07-01T11:59:00Z", "2026-07-01 23:59 NZST"},
// Chicago spring forward, Sunday 2026-03-08: 02:00 CST → 03:00 CDT.
{"America/Chicago", "2026-03-08", "01:59", "2026-03-08T07:59:00Z", "2026-03-08 01:59 CST"},
{"America/Chicago", "2026-03-08", "02:00", "2026-03-08T08:00:00Z", "2026-03-08 03:00 CDT"},
{"America/Chicago", "2026-03-08", "02:30", "2026-03-08T08:30:00Z", "2026-03-08 03:30 CDT"},
{"America/Chicago", "2026-03-08", "03:00", "2026-03-08T08:00:00Z", "2026-03-08 03:00 CDT"},
{"America/Chicago", "2026-03-08", "03:30", "2026-03-08T08:30:00Z", "2026-03-08 03:30 CDT"},
// Chicago fall back, Sunday 2026-11-01: 02:00 CDT → 01:00 CST, so
// 01:00–01:59 happens twice; the first (CDT) occurrence is chosen.
{"America/Chicago", "2026-11-01", "00:59", "2026-11-01T05:59:00Z", "2026-11-01 00:59 CDT"},
{"America/Chicago", "2026-11-01", "01:00", "2026-11-01T06:00:00Z", "2026-11-01 01:00 CDT"},
{"America/Chicago", "2026-11-01", "01:30", "2026-11-01T06:30:00Z", "2026-11-01 01:30 CDT"},
{"America/Chicago", "2026-11-01", "02:00", "2026-11-01T08:00:00Z", "2026-11-01 02:00 CST"},
// Auckland spring forward, Sunday 2026-09-27: 02:00 NZST → 03:00 NZDT.
{"Pacific/Auckland", "2026-09-27", "01:59", "2026-09-26T13:59:00Z", "2026-09-27 01:59 NZST"},
{"Pacific/Auckland", "2026-09-27", "02:30", "2026-09-26T14:30:00Z", "2026-09-27 03:30 NZDT"},
{"Pacific/Auckland", "2026-09-27", "03:00", "2026-09-26T14:00:00Z", "2026-09-27 03:00 NZDT"},
// Auckland fall back, Sunday 2026-04-05: 03:00 NZDT → 02:00 NZST, so
// 02:00–02:59 happens twice; the first (NZDT) occurrence is chosen.
{"Pacific/Auckland", "2026-04-05", "01:59", "2026-04-04T12:59:00Z", "2026-04-05 01:59 NZDT"},
{"Pacific/Auckland", "2026-04-05", "02:30", "2026-04-04T13:30:00Z", "2026-04-05 02:30 NZDT"},
{"Pacific/Auckland", "2026-04-05", "03:00", "2026-04-04T15:00:00Z", "2026-04-05 03:00 NZST"},
} {
loc := loadZone(t, tc.zone)
got, err := LocalTime(loc, date(t, tc.day), tc.hhmm)
if err != nil {
t.Fatalf("LocalTime(%s, %s, %s): %v", tc.zone, tc.day, tc.hhmm, err)
}
if !got.Equal(utc(t, tc.want)) {
t.Errorf("LocalTime(%s, %s, %s) = %s, want %s", tc.zone, tc.day, tc.hhmm, got.UTC().Format(time.RFC3339), tc.want)
}
if wall := got.Format("2006-01-02 15:04 MST"); wall != tc.wantWall {
t.Errorf("LocalTime(%s, %s, %s) wall = %s, want %s", tc.zone, tc.day, tc.hhmm, wall, tc.wantWall)
}
if got.Location() != loc {
t.Errorf("LocalTime(%s, …) location = %s, want %s", tc.zone, got.Location(), loc)
}
}
}
func TestLocalTimeDeterministic(t *testing.T) {
// The same ambiguous or skipped input always resolves to the same instant.
chicago := loadZone(t, "America/Chicago")
for _, tc := range []struct{ day, hhmm string }{{"2026-03-08", "02:30"}, {"2026-11-01", "01:30"}} {
first, err := LocalTime(chicago, date(t, tc.day), tc.hhmm)
if err != nil {
t.Fatal(err)
}
for range 100 {
again, err := LocalTime(chicago, date(t, tc.day), tc.hhmm)
if err != nil {
t.Fatal(err)
}
if !again.Equal(first) {
t.Fatalf("LocalTime(%s, %s) = %s then %s", tc.day, tc.hhmm, first, again)
}
}
}
}
func TestLocalTimeRejectsInvalid(t *testing.T) {
chicago := loadZone(t, "America/Chicago")
d := date(t, "2026-10-05")
for _, hhmm := range []string{
"", "9:30", "09:3", "24:00", "23:60", "09:30:00", "0930", " 09:30", "09:30 ", "9am", "-1:00",
} {
if got, err := LocalTime(chicago, d, hhmm); err == nil {
t.Errorf("LocalTime(%q) = %s, want error", hhmm, got)
} else if !strings.Contains(err.Error(), "HH:MM") {
t.Errorf("LocalTime(%q) error %q does not name the HH:MM form", hhmm, err)
}
}
if _, err := LocalTime(chicago, store.LocalDate{}, "09:30"); err == nil {
t.Error("LocalTime(zero date) succeeded")
}
}
+28
View File
@@ -0,0 +1,28 @@
package household
import (
"errors"
"fmt"
"time"
// Embeds the IANA zone database in the binary, so zones load the same
// way in a minimal image with no /usr/share/zoneinfo.
_ "time/tzdata"
)
// LoadZone loads the IANA zone name, such as "America/New_York". The empty
// name and "Local", which time.LoadLocation maps to UTC and the process zone,
// are refused: the household zone is never implied.
func LoadZone(name string) (*time.Location, error) {
if name == "" {
return nil, errors.New("time zone is empty (want an IANA zone such as America/New_York)")
}
if name == "Local" {
return nil, errors.New(`time zone "Local" is not an IANA zone (want e.g. America/New_York)`)
}
loc, err := time.LoadLocation(name)
if err != nil {
return nil, fmt.Errorf("unknown time zone %q (want an IANA zone such as America/New_York)", name)
}
return loc, nil
}
+44
View File
@@ -0,0 +1,44 @@
package jobs
import (
"context"
"encoding/json"
)
// KindNoop is the framework's built-in job kind: it does no work and
// succeeds with {"ok":true}. It is the runner's own trivial kind, as Feature
// 09's trivial test skill is the harness's, and the startup and readiness
// job: a noop job reaching succeeded proves the runner claims, runs and
// records jobs.
const KindNoop = "noop"
// NoopPayload is the payload of a noop job.
type NoopPayload struct{}
// NoopResult is the result of a noop job.
type NoopResult struct{}
// PublicJSON returns {"ok":true}.
func (NoopResult) PublicJSON() ([]byte, error) {
return json.Marshal(struct {
OK bool `json:"ok"`
}{OK: true})
}
func runNoop(context.Context, NoopPayload, func(NoopResult) error) (NoopResult, error) {
return NoopResult{}, nil
}
// Builtins enqueues the framework's built-in job kinds.
type Builtins struct {
Noop *Kind[NoopPayload]
}
// RegisterBuiltins registers the framework's built-in job kinds on r.
func RegisterBuiltins(r *Runner) (*Builtins, error) {
noop, err := Register(r, KindNoop, runNoop)
if err != nil {
return nil, err
}
return &Builtins{Noop: noop}, nil
}
+461
View File
@@ -0,0 +1,461 @@
// Package jobs runs long work in the background. A job is a row in the jobs
// table: Enqueue inserts it queued, and a fixed pool of workers claims queued
// jobs oldest first, so the table itself is the queue and a job enqueued
// before a restart still runs after it.
//
// Each job kind has one handler, registered before Start. A handler takes a
// typed payload and returns a result implementing Public; it may publish
// partial results while it runs. The runner stores only what a result's
// PublicJSON returns and, for a failed job, only a failure code: either a
// *Failure code the kind declared at registration or one of the runner's own
// codes. Handler error text, which may quote a URL, token or response body
// from a dependency, is never stored, returned by the API or logged.
package jobs
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"log/slog"
"regexp"
"sync"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
// Status is the state of a job.
type Status string
// Job states. A job moves queued → running → succeeded or failed.
const (
StatusQueued Status = "queued"
StatusRunning Status = "running"
StatusSucceeded Status = "succeeded"
StatusFailed Status = "failed"
)
// Failure codes the runner stores itself. A kind cannot declare them.
const (
// CodeInterrupted is the error of a job a previous process left running.
// It is fixed framework text, not a snake-case code, so no kind can
// declare it.
CodeInterrupted = "interrupted by restart"
// CodeInternal is the error of a job whose handler failed with anything
// but a *Failure with a code its kind declared, or whose payload or
// result could not be encoded. The underlying error is neither stored
// nor logged.
CodeInternal = "internal_error"
// CodeUnknownKind is the error of a job whose kind has no handler.
CodeUnknownKind = "unknown_kind"
)
// Claim retry backoff. A worker whose claim fails with a database error
// retries after claimRetryMin, doubling up to claimRetryMax, so a job queued
// when the error clears is claimed without waiting for a wake token.
const (
claimRetryMin = 50 * time.Millisecond
claimRetryMax = 5 * time.Second
)
// codePattern is the form of every failure code: lower-case snake case.
var codePattern = regexp.MustCompile(`^[a-z][a-z0-9_]{0,63}$`)
// ErrNotFound is returned by Get for an unknown job id.
var ErrNotFound = errors.New("jobs: job not found")
// ErrUnknownKind is returned by Runner.Enqueue for a kind with no
// registered handler.
var ErrUnknownKind = errors.New("jobs: unknown job kind")
// ErrInvalidPayload is returned by Runner.Enqueue for a payload that does
// not decode into the kind's payload type.
var ErrInvalidPayload = errors.New("jobs: payload does not decode")
// Failure is a handler error reporting a failure code the household may
// see. Code must be one the job's kind declared at registration; the runner
// stores it as the job's error. Any other handler error, including a Failure
// with an undeclared code, is stored as CodeInternal.
type Failure struct {
Code string
}
func (f *Failure) Error() string { return "jobs: failure " + f.Code }
// Public is a job result. PublicJSON returns the JSON the runner stores and
// GET /api/v1/jobs/{id} returns, so a result type states exactly which of its
// data is public; nothing else of the value is ever encoded.
type Public interface {
PublicJSON() ([]byte, error)
}
// Handler runs one job with its payload. It may call publish any number of
// times with a partial result, which Get returns while the job runs; its
// return value is the final result. ctx is cancelled when the runner stops.
type Handler[P any, R Public] func(ctx context.Context, payload P, publish func(partial R) error) (R, error)
// Job is a job as reported by GET /api/v1/jobs/{id}. Result is the latest
// partial or final result, and is null until the handler publishes one.
// Error is set only on a failed job.
type Job struct {
ID string `json:"id"`
Kind string `json:"kind"`
Status Status `json:"status"`
Result json.RawMessage `json:"result"`
Error *string `json:"error"`
}
// runFunc is a registered Handler with its payload and results as JSON. It
// returns the failure code to store, or "" on success; the logged reason
// never quotes an error.
type runFunc func(ctx context.Context, payload string, publish func(json.RawMessage) error) (result json.RawMessage, code, reason string)
// handler is a registered kind: run runs a job, and check reports whether
// a payload decodes into the kind's payload type.
type handler struct {
run runFunc
check func(payload []byte) bool
}
// Runner owns the job handlers and the worker pool.
type Runner struct {
st *store.Store
logger *slog.Logger
workers int
// wake holds up to one token per worker; Enqueue adds one so an idle
// worker looks for the new job.
wake chan struct{}
// claim claims the oldest queued job, marking it running at now.
claim func(ctx context.Context, now sql.NullString) (db.Job, error)
mu sync.Mutex
handlers map[string]handler
started bool
wg sync.WaitGroup
}
// NewRunner returns a runner over st with workers concurrent workers.
func NewRunner(st *store.Store, logger *slog.Logger, workers int) (*Runner, error) {
if st == nil {
return nil, errors.New("jobs: nil store")
}
if logger == nil {
return nil, errors.New("jobs: nil logger")
}
if workers < 1 {
return nil, fmt.Errorf("jobs: %d workers, want at least 1", workers)
}
return &Runner{
st: st,
logger: logger,
workers: workers,
wake: make(chan struct{}, workers),
claim: db.New(st.Writer).ClaimNextJob,
handlers: make(map[string]handler),
}, nil
}
// Kind enqueues jobs of one registered kind.
type Kind[P any] struct {
r *Runner
name string
}
// Register makes h the handler for kind and returns the Kind that enqueues
// its jobs. failureCodes are the *Failure codes h may report. It must be
// called before Start, once per kind.
func Register[P any, R Public](r *Runner, kind string, h Handler[P, R], failureCodes ...string) (*Kind[P], error) {
if kind == "" {
return nil, errors.New("jobs: register: empty kind")
}
if h == nil {
return nil, fmt.Errorf("jobs: register %q: nil handler", kind)
}
declared := make(map[string]bool, len(failureCodes))
for _, code := range failureCodes {
if !codePattern.MatchString(code) {
return nil, fmt.Errorf("jobs: register %q: failure code %q is not lower-case snake case", kind, code)
}
if code == CodeInternal || code == CodeUnknownKind {
return nil, fmt.Errorf("jobs: register %q: failure code %q is reserved for the runner", kind, code)
}
declared[code] = true
}
r.mu.Lock()
defer r.mu.Unlock()
if r.started {
return nil, fmt.Errorf("jobs: register %q: runner already started", kind)
}
if _, ok := r.handlers[kind]; ok {
return nil, fmt.Errorf("jobs: register %q: kind already registered", kind)
}
run := func(ctx context.Context, payload string, publish func(json.RawMessage) error) (json.RawMessage, string, string) {
p, err := decodePayload[P]([]byte(payload))
if err != nil {
return nil, CodeInternal, "payload does not decode"
}
result, err := h(ctx, p, func(partial R) error {
data, err := publicJSON(partial)
if err != nil {
return err
}
return publish(data)
})
if err != nil {
var failure *Failure
if !errors.As(err, &failure) {
return nil, CodeInternal, "handler error"
}
if !declared[failure.Code] {
return nil, CodeInternal, "undeclared failure code"
}
return nil, failure.Code, "handler failure"
}
data, err := publicJSON(result)
if err != nil {
return nil, CodeInternal, "result does not encode"
}
return data, "", ""
}
check := func(payload []byte) bool {
_, err := decodePayload[P](payload)
return err == nil
}
r.handlers[kind] = handler{run: run, check: check}
return &Kind[P]{r: r, name: kind}, nil
}
// decodePayload decodes payload into P, refusing unknown fields.
func decodePayload[P any](payload []byte) (P, error) {
var p P
dec := json.NewDecoder(bytes.NewReader(payload))
dec.DisallowUnknownFields()
err := dec.Decode(&p)
return p, err
}
// publicJSON returns v's PublicJSON, refusing anything that is not one JSON
// value. Its error never quotes PublicJSON's error.
func publicJSON(v Public) (json.RawMessage, error) {
data, err := v.PublicJSON()
if err != nil || !json.Valid(data) {
return nil, errors.New("jobs: result has no valid public JSON")
}
return data, nil
}
// Enqueue stores a queued job with payload and returns its id. A job
// enqueued before Start waits for it.
func (k *Kind[P]) Enqueue(ctx context.Context, payload P) (string, error) {
data, err := json.Marshal(payload)
if err != nil {
return "", fmt.Errorf("jobs: enqueue %s: encode payload: %w", k.name, err)
}
return k.r.enqueue(ctx, k.name, data)
}
// Enqueue stores a queued job of kind with its JSON payload and returns its
// id, as Kind.Enqueue does for a kind known at compile time. It returns
// ErrUnknownKind for a kind with no registered handler and
// ErrInvalidPayload for a payload that does not decode into the kind's
// payload type; neither stores a job.
func (r *Runner) Enqueue(ctx context.Context, kind string, payload json.RawMessage) (string, error) {
r.mu.Lock()
h, ok := r.handlers[kind]
r.mu.Unlock()
if !ok {
return "", ErrUnknownKind
}
if !h.check(payload) {
return "", ErrInvalidPayload
}
return r.enqueue(ctx, kind, payload)
}
// enqueue stores a queued job of kind with its encoded payload and wakes a
// worker.
func (r *Runner) enqueue(ctx context.Context, kind string, payload []byte) (string, error) {
id := store.NewID()
now := store.Now().String()
if err := db.New(r.st.Writer).CreateJob(ctx, db.CreateJobParams{
ID: id,
Kind: kind,
Payload: string(payload),
CreatedAt: now,
UpdatedAt: now,
}); err != nil {
return "", fmt.Errorf("jobs: enqueue %s: %w", kind, err)
}
select {
case r.wake <- struct{}{}:
default: // every worker already has a token and will look again
}
return id, nil
}
// Start fails every job a previous process left running with
// CodeInterrupted, then starts the workers. They stop when ctx is cancelled;
// Wait waits for them. A job whose handler returns an error after ctx is
// cancelled is left running, so the next Start fails it as interrupted.
func (r *Runner) Start(ctx context.Context) error {
r.mu.Lock()
defer r.mu.Unlock()
if r.started {
return errors.New("jobs: runner already started")
}
now := store.Now().String()
n, err := db.New(r.st.Writer).FailRunningJobs(ctx, db.FailRunningJobsParams{
Error: sql.NullString{String: CodeInterrupted, Valid: true},
Now: sql.NullString{String: now, Valid: true},
})
if err != nil {
return fmt.Errorf("jobs: fail interrupted jobs: %w", err)
}
if n > 0 {
r.logger.WarnContext(ctx, "failed jobs interrupted by restart", "count", n)
}
r.started = true
for range r.workers {
r.wg.Add(1)
go r.work(ctx)
}
return nil
}
// Wait blocks until every worker has stopped.
func (r *Runner) Wait() {
r.wg.Wait()
}
// Get returns the job with id, or ErrNotFound.
func (r *Runner) Get(ctx context.Context, id string) (Job, error) {
row, err := db.New(r.st.Reader).GetJob(ctx, id)
if errors.Is(err, sql.ErrNoRows) {
return Job{}, ErrNotFound
}
if err != nil {
return Job{}, fmt.Errorf("jobs: read job: %w", err)
}
job := Job{ID: row.ID, Kind: row.Kind, Status: Status(row.Status)}
if row.Result.Valid {
job.Result = json.RawMessage(row.Result.String)
}
if row.Error.Valid {
job.Error = &row.Error.String
}
return job, nil
}
// work claims and runs queued jobs until ctx is cancelled, waiting for a
// wake token whenever none is queued. A claim that fails with a database
// error is retried with backoff rather than waiting for a token, so a job
// already queued is not stranded by a transient error.
func (r *Runner) work(ctx context.Context) {
defer r.wg.Done()
backoff := claimRetryMin
for ctx.Err() == nil {
job, err := r.claim(ctx, sql.NullString{String: store.Now().String(), Valid: true})
switch {
case err == nil:
backoff = claimRetryMin
r.run(ctx, job)
continue
case errors.Is(err, sql.ErrNoRows):
backoff = claimRetryMin
select {
case <-r.wake:
case <-ctx.Done():
}
continue
case ctx.Err() != nil:
return
}
r.logger.ErrorContext(ctx, "claim job failed; retrying", "reason", "database error", "retry_in", backoff)
timer := time.NewTimer(backoff)
select {
case <-timer.C:
case <-ctx.Done():
timer.Stop()
}
backoff = min(2*backoff, claimRetryMax)
}
}
// run runs a claimed job to completion. The final write is made even when
// ctx has been cancelled, so a handler that finishes during shutdown is
// recorded.
func (r *Runner) run(ctx context.Context, job db.Job) {
write := context.WithoutCancel(ctx)
logger := r.logger.With("job_id", job.ID, "kind", job.Kind)
q := db.New(r.st.Writer)
h, ok := r.handlers[job.Kind] // read-only once started
if !ok {
logger.ErrorContext(ctx, "job failed", "code", CodeUnknownKind, "reason", "no handler registered for the kind")
r.fail(write, logger, job.ID, CodeUnknownKind)
return
}
publish := func(partial json.RawMessage) error {
n, err := q.PublishJobResult(ctx, db.PublishJobResultParams{
Result: sql.NullString{String: string(partial), Valid: true},
UpdatedAt: store.Now().String(),
ID: job.ID,
})
if err != nil {
return fmt.Errorf("jobs: publish result: %w", err)
}
if n != 1 {
return fmt.Errorf("jobs: publish result: job %s is not running", job.ID)
}
return nil
}
result, code, reason := h.run(ctx, job.Payload, publish)
if code != "" {
if ctx.Err() != nil {
logger.WarnContext(write, "job interrupted by shutdown; it fails at the next start")
return
}
level := slog.LevelInfo
if code == CodeInternal {
level = slog.LevelError
}
logger.Log(ctx, level, "job failed", "code", code, "reason", reason)
r.fail(write, logger, job.ID, code)
return
}
n, err := q.SucceedJob(write, db.SucceedJobParams{
Result: sql.NullString{String: string(result), Valid: true},
Now: sql.NullString{String: store.Now().String(), Valid: true},
ID: job.ID,
})
if err == nil && n != 1 {
err = errors.New("job is not running")
}
if err != nil {
logger.ErrorContext(write, "record job success", "error", err)
}
}
// fail records the job as failed with code. The record errors it logs come
// from the database, never from a handler.
func (r *Runner) fail(ctx context.Context, logger *slog.Logger, id, code string) {
n, err := db.New(r.st.Writer).FailJob(ctx, db.FailJobParams{
Error: sql.NullString{String: code, Valid: true},
Now: sql.NullString{String: store.Now().String(), Valid: true},
ID: id,
})
if err == nil && n != 1 {
err = errors.New("job is not running")
}
if err != nil {
logger.ErrorContext(ctx, "record job failure", "error", err)
}
}
+606
View File
@@ -0,0 +1,606 @@
package jobs
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
)
// sentinelSecret stands in for a credential a handler holds while it runs.
const sentinelSecret = "sk-sentinel-5f2c9a"
type countPayload struct {
Steps int `json:"steps"`
}
type countResult struct {
Done int `json:"done"`
}
func (c countResult) PublicJSON() ([]byte, error) { return json.Marshal(c) }
func migratedStore(t *testing.T) *store.Store {
t.Helper()
ctx := context.Background()
st, err := store.Open(ctx, t.TempDir())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := st.Close(); err != nil {
t.Error(err)
}
})
if err := store.Migrate(ctx, st.Writer, migrations.FS); err != nil {
t.Fatal(err)
}
return st
}
func newRunner(t *testing.T, st *store.Store, workers int) *Runner {
t.Helper()
return newRunnerLogging(t, st, workers, io.Discard)
}
func newRunnerLogging(t *testing.T, st *store.Store, workers int, w io.Writer) *Runner {
t.Helper()
r, err := NewRunner(st, slog.New(slog.NewTextHandler(w, nil)), workers)
if err != nil {
t.Fatal(err)
}
return r
}
// logBuffer collects log output written by concurrent workers.
type logBuffer struct {
mu sync.Mutex
buf bytes.Buffer
}
func (b *logBuffer) Write(p []byte) (int, error) {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.Write(p)
}
func (b *logBuffer) String() string {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.String()
}
// start starts r and stops it when the test ends. The returned function
// stops it early and waits for its workers.
func start(t *testing.T, r *Runner) (stop func()) {
t.Helper()
ctx, cancel := context.WithCancel(context.Background())
if err := r.Start(ctx); err != nil {
cancel()
t.Fatal(err)
}
stop = func() {
cancel()
r.Wait()
}
t.Cleanup(stop)
return stop
}
func register[P any, R Public](t *testing.T, r *Runner, kind string, h Handler[P, R], failureCodes ...string) *Kind[P] {
t.Helper()
k, err := Register(r, kind, h, failureCodes...)
if err != nil {
t.Fatal(err)
}
return k
}
func enqueue[P any](t *testing.T, k *Kind[P], payload P) string {
t.Helper()
id, err := k.Enqueue(context.Background(), payload)
if err != nil {
t.Fatal(err)
}
return id
}
// waitFor polls Get until done accepts the job, failing after five seconds.
func waitFor(t *testing.T, r *Runner, id string, done func(Job) bool) Job {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for {
job, err := r.Get(context.Background(), id)
if err != nil {
t.Fatal(err)
}
if done(job) {
return job
}
if time.Now().After(deadline) {
t.Fatalf("job %s never reached the expected state; last %+v (result %s)", id, job, job.Result)
}
time.Sleep(5 * time.Millisecond)
}
}
func finished(j Job) bool { return j.Status == StatusSucceeded || j.Status == StatusFailed }
// rawRow returns every column of the job's row joined, for leak checks.
func rawRow(t *testing.T, st *store.Store, id string) string {
t.Helper()
var cols [10]any
ptrs := make([]any, len(cols))
for i := range cols {
ptrs[i] = &cols[i]
}
if err := st.Reader.QueryRow("SELECT * FROM jobs WHERE id = ?", id).Scan(ptrs...); err != nil {
t.Fatal(err)
}
return fmt.Sprint(cols[:]...)
}
func TestJobSucceeds(t *testing.T) {
t.Parallel()
st := migratedStore(t)
r := newRunner(t, st, 2)
count := register(t, r, "count", func(_ context.Context, p countPayload, _ func(countResult) error) (countResult, error) {
return countResult{Done: p.Steps}, nil
})
start(t, r)
id := enqueue(t, count, countPayload{Steps: 3})
job := waitFor(t, r, id, finished)
if job.ID != id || job.Kind != "count" || job.Status != StatusSucceeded || job.Error != nil {
t.Fatalf("job = %+v, want succeeded count job %s with no error", job, id)
}
if string(job.Result) != `{"done":3}` {
t.Errorf("result = %s, want {\"done\":3}", job.Result)
}
var startedAt, finishedAt, createdAt, updatedAt, payload string
if err := st.Reader.QueryRow("SELECT started_at, finished_at, created_at, updated_at, payload FROM jobs WHERE id = ?", id).
Scan(&startedAt, &finishedAt, &createdAt, &updatedAt, &payload); err != nil {
t.Fatal(err)
}
for name, v := range map[string]string{"started_at": startedAt, "finished_at": finishedAt, "created_at": createdAt, "updated_at": updatedAt} {
var ts store.Timestamp
if err := ts.Scan(v); err != nil {
t.Errorf("%s = %q: %v", name, v, err)
}
}
if payload != `{"steps":3}` {
t.Errorf("payload = %s, want {\"steps\":3}", payload)
}
}
func TestJobFailsWithFailureCode(t *testing.T) {
t.Parallel()
st := migratedStore(t)
r := newRunner(t, st, 1)
k := register(t, r, "suggest", func(context.Context, countPayload, func(countResult) error) (countResult, error) {
return countResult{}, fmt.Errorf("suggest: %w", &Failure{Code: "no_regulars"})
}, "no_regulars")
start(t, r)
job := waitFor(t, r, enqueue(t, k, countPayload{}), finished)
if job.Status != StatusFailed || job.Error == nil || *job.Error != "no_regulars" {
t.Fatalf("job = %+v, want failed with code no_regulars", job)
}
if job.Result != nil {
t.Errorf("result = %s, want none", job.Result)
}
var stored string
if err := st.Reader.QueryRow("SELECT error FROM jobs WHERE id = ?", job.ID).Scan(&stored); err != nil {
t.Fatal(err)
}
if stored != "no_regulars" {
t.Errorf("stored error = %q, want only the code", stored)
}
}
func TestUndeclaredFailureCodeIsInternal(t *testing.T) {
t.Parallel()
r := newRunner(t, migratedStore(t), 1)
k := register(t, r, "suggest", func(context.Context, countPayload, func(countResult) error) (countResult, error) {
return countResult{}, &Failure{Code: "not_declared"}
}, "no_regulars")
start(t, r)
job := waitFor(t, r, enqueue(t, k, countPayload{}), finished)
if job.Status != StatusFailed || job.Error == nil || *job.Error != CodeInternal {
t.Fatalf("job = %+v, want failed with %q", job, CodeInternal)
}
}
// credential holds a secret the way config.Secret does: unexported, so
// encoding/json cannot see it.
type credential struct{ value string }
func (credential) String() string { return "[REDACTED]" }
// leakyResult holds a secret in exported and unexported fields; only
// Dinners is public.
type leakyResult struct {
Dinners []string
Key credential
Token string
apiKey string
}
func (l leakyResult) PublicJSON() ([]byte, error) {
return json.Marshal(struct {
Dinners []string `json:"dinners"`
}{l.Dinners})
}
// badResult has no valid public JSON; its encoding error quotes a secret.
type badResult struct{}
func (badResult) PublicJSON() ([]byte, error) {
return nil, fmt.Errorf("encode with key %s", sentinelSecret)
}
// TestSecretsNeverStoredOrLogged proves a handler holding a secret cannot
// surface it in the jobs row, Get's result or the log through its result,
// partial results, *Failure, error text or result encoding error.
func TestSecretsNeverStoredOrLogged(t *testing.T) {
t.Parallel()
st := migratedStore(t)
var logs logBuffer
r := newRunnerLogging(t, st, 1, &logs)
secretResult := leakyResult{Dinners: []string{"Tacos"}, Key: credential{sentinelSecret}, Token: sentinelSecret, apiKey: sentinelSecret}
typed := register(t, r, "typed", func(_ context.Context, _ countPayload, publish func(leakyResult) error) (leakyResult, error) {
if err := publish(secretResult); err != nil {
return leakyResult{}, err
}
return secretResult, nil
})
leakyError := register(t, r, "leaky-error", func(context.Context, countPayload, func(leakyResult) error) (leakyResult, error) {
return leakyResult{}, fmt.Errorf("GET https://api.example.com/v1?key=%s: 401 %s", sentinelSecret, sentinelSecret)
})
leakyFailure := register(t, r, "leaky-failure", func(context.Context, countPayload, func(leakyResult) error) (leakyResult, error) {
return leakyResult{}, &Failure{Code: sentinelSecret}
}, "quota_exceeded")
failure := register(t, r, "failure", func(context.Context, countPayload, func(leakyResult) error) (leakyResult, error) {
return leakyResult{}, fmt.Errorf("call with key %s: %w", sentinelSecret, &Failure{Code: "quota_exceeded"})
}, "quota_exceeded")
badEncoding := register(t, r, "bad-encoding", func(context.Context, countPayload, func(badResult) error) (badResult, error) {
return badResult{}, nil
})
stop := start(t, r)
cases := []struct {
kind *Kind[countPayload]
wantStatus Status
wantResult string
wantError string
}{
{typed, StatusSucceeded, `{"dinners":["Tacos"]}`, ""},
{leakyError, StatusFailed, "", CodeInternal},
{leakyFailure, StatusFailed, "", CodeInternal},
{failure, StatusFailed, "", "quota_exceeded"},
{badEncoding, StatusFailed, "", CodeInternal},
}
var ids []string
for _, c := range cases {
job := waitFor(t, r, enqueue(t, c.kind, countPayload{}), finished)
ids = append(ids, job.ID)
gotError := ""
if job.Error != nil {
gotError = *job.Error
}
if job.Status != c.wantStatus || string(job.Result) != c.wantResult || gotError != c.wantError {
t.Errorf("%s job = %+v (result %s), want %s with result %q and error %q", job.Kind, job, job.Result, c.wantStatus, c.wantResult, c.wantError)
}
body, err := json.Marshal(job)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(body), sentinelSecret) {
t.Errorf("%s job as served contains the secret: %s", job.Kind, body)
}
}
stop()
for _, id := range ids {
if row := rawRow(t, st, id); strings.Contains(row, sentinelSecret) {
t.Errorf("job %s row contains the secret: %s", id, row)
}
}
if strings.Contains(logs.String(), sentinelSecret) {
t.Errorf("log contains the secret:\n%s", logs.String())
}
if !strings.Contains(logs.String(), "code="+CodeInternal) {
t.Errorf("log does not record the internal_error code:\n%s", logs.String())
}
}
func TestProgressiveResultsVisibleMidRun(t *testing.T) {
t.Parallel()
r := newRunner(t, migratedStore(t), 1)
release := make(chan struct{})
k := register(t, r, "draft", func(ctx context.Context, p countPayload, publish func(countResult) error) (countResult, error) {
for i := 1; i < p.Steps; i++ {
if err := publish(countResult{Done: i}); err != nil {
return countResult{}, err
}
select {
case <-release:
case <-ctx.Done():
return countResult{}, ctx.Err()
}
}
return countResult{Done: p.Steps}, nil
})
start(t, r)
id := enqueue(t, k, countPayload{Steps: 3})
for step := 1; step < 3; step++ {
want := fmt.Sprintf(`{"done":%d}`, step)
job := waitFor(t, r, id, func(j Job) bool { return string(j.Result) == want })
if job.Status != StatusRunning || job.Error != nil {
t.Fatalf("mid-run job = %+v, want running with no error", job)
}
release <- struct{}{}
}
job := waitFor(t, r, id, finished)
if job.Status != StatusSucceeded || string(job.Result) != `{"done":3}` {
t.Fatalf("job = %+v (result %s), want succeeded with {\"done\":3}", job, job.Result)
}
}
func TestRestartFailsInterruptedJobsAndRunsQueuedOnes(t *testing.T) {
t.Parallel()
st := migratedStore(t)
first := newRunner(t, st, 1)
started := make(chan struct{})
k := register(t, first, "slow", func(ctx context.Context, _ countPayload, publish func(countResult) error) (countResult, error) {
if err := publish(countResult{Done: 1}); err != nil {
return countResult{}, err
}
close(started)
<-ctx.Done()
return countResult{}, ctx.Err()
})
stop := start(t, first)
interrupted := enqueue(t, k, countPayload{})
<-started
waiting := enqueue(t, k, countPayload{}) // the only worker is busy
stop()
if job := waitFor(t, first, interrupted, func(Job) bool { return true }); job.Status != StatusRunning {
t.Fatalf("after shutdown job = %+v, want left running", job)
}
second := newRunner(t, st, 1)
register(t, second, "slow", func(context.Context, countPayload, func(countResult) error) (countResult, error) {
return countResult{Done: 9}, nil
})
start(t, second)
job := waitFor(t, second, interrupted, finished)
if job.Status != StatusFailed || job.Error == nil || *job.Error != "interrupted by restart" {
t.Errorf("interrupted job = %+v, want failed with %q", job, "interrupted by restart")
}
if string(job.Result) != `{"done":1}` {
t.Errorf("interrupted job result = %s, want the last partial result kept", job.Result)
}
if job := waitFor(t, second, waiting, finished); job.Status != StatusSucceeded {
t.Errorf("queued job = %+v, want run by the new runner", job)
}
}
func TestWorkerPoolIsBounded(t *testing.T) {
t.Parallel()
r := newRunner(t, migratedStore(t), 2)
var running, peak atomic.Int32
release := make(chan struct{})
k := register(t, r, "busy", func(context.Context, countPayload, func(countResult) error) (countResult, error) {
n := running.Add(1)
for {
p := peak.Load()
if n <= p || peak.CompareAndSwap(p, n) {
break
}
}
<-release
running.Add(-1)
return countResult{}, nil
})
start(t, r)
ids := make([]string, 5)
for i := range ids {
ids[i] = enqueue(t, k, countPayload{})
}
deadline := time.Now().Add(5 * time.Second)
for running.Load() < 2 {
if time.Now().After(deadline) {
t.Fatal("two workers never ran at once")
}
time.Sleep(5 * time.Millisecond)
}
close(release)
for _, id := range ids {
if job := waitFor(t, r, id, finished); job.Status != StatusSucceeded {
t.Errorf("job = %+v, want succeeded", job)
}
}
if got := peak.Load(); got != 2 {
t.Errorf("peak concurrent jobs = %d, want 2", got)
}
}
func TestJobWithoutHandlerFails(t *testing.T) {
t.Parallel()
st := migratedStore(t)
old := newRunner(t, st, 1)
k := register(t, old, "retired", func(context.Context, countPayload, func(countResult) error) (countResult, error) {
return countResult{}, nil
})
id := enqueue(t, k, countPayload{}) // never started
r := newRunner(t, st, 1)
start(t, r)
job := waitFor(t, r, id, finished)
if job.Status != StatusFailed || job.Error == nil || *job.Error != CodeUnknownKind {
t.Errorf("job = %+v, want failed for a missing handler", job)
}
}
func TestGetUnknownID(t *testing.T) {
t.Parallel()
r := newRunner(t, migratedStore(t), 1)
if _, err := r.Get(context.Background(), store.NewID()); !errors.Is(err, ErrNotFound) {
t.Errorf("Get unknown id error = %v, want ErrNotFound", err)
}
}
func TestRegisterRefusesBadRegistrations(t *testing.T) {
t.Parallel()
r := newRunner(t, migratedStore(t), 1)
h := func(context.Context, countPayload, func(countResult) error) (countResult, error) {
return countResult{}, nil
}
register(t, r, "count", h)
if _, err := Register(r, "count", h); err == nil {
t.Error("duplicate kind registered")
}
if _, err := Register(r, "", h); err == nil {
t.Error("empty kind registered")
}
if _, err := Register[countPayload, countResult](r, "nil", nil); err == nil {
t.Error("nil handler registered")
}
for _, code := range []string{"", "Bad Code", "sk-sentinel", CodeInternal, CodeInterrupted, CodeUnknownKind} {
if _, err := Register(r, "code", h, code); err == nil {
t.Errorf("failure code %q accepted", code)
}
}
start(t, r)
if _, err := Register(r, "late", h); err == nil {
t.Error("kind registered after Start")
}
if err := r.Start(context.Background()); err == nil {
t.Error("second Start succeeded")
}
}
func TestNewRunnerRefusesBadConfig(t *testing.T) {
t.Parallel()
st := migratedStore(t)
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRunner(st, logger, 0); err == nil {
t.Error("zero workers accepted")
}
if _, err := NewRunner(nil, logger, 1); err == nil {
t.Error("nil store accepted")
}
if _, err := NewRunner(st, nil, 1); err == nil {
t.Error("nil logger accepted")
}
}
func TestNoopSucceeds(t *testing.T) {
t.Parallel()
r := newRunner(t, migratedStore(t), 1)
builtins, err := RegisterBuiltins(r)
if err != nil {
t.Fatal(err)
}
start(t, r)
job := waitFor(t, r, enqueue(t, builtins.Noop, NoopPayload{}), finished)
if job.Kind != KindNoop || job.Status != StatusSucceeded || string(job.Result) != `{"ok":true}` || job.Error != nil {
t.Errorf("noop job = %+v (result %s), want succeeded with {\"ok\":true}", job, job.Result)
}
}
func TestClaimErrorIsRetried(t *testing.T) {
st := migratedStore(t)
logs := &logBuffer{}
r := newRunnerLogging(t, st, 1, logs)
k := register(t, r, "count", func(ctx context.Context, p countPayload, publish func(countResult) error) (countResult, error) {
return countResult{Done: p.Steps}, nil
})
id := enqueue(t, k, countPayload{Steps: 3})
// Drop the wake token, as for a job queued before a restart, so only
// the retry can find the job.
<-r.wake
var claims atomic.Int32
claim := r.claim
r.claim = func(ctx context.Context, now sql.NullString) (db.Job, error) {
if claims.Add(1) == 1 {
return db.Job{}, errors.New("database is locked " + sentinelSecret)
}
return claim(ctx, now)
}
start(t, r)
job := waitFor(t, r, id, finished)
if job.Status != StatusSucceeded || string(job.Result) != `{"done":3}` {
t.Fatalf("job = %+v (result %s), want succeeded with {\"done\":3}", job, job.Result)
}
if n := claims.Load(); n < 2 {
t.Fatalf("claims = %d, want a retry after the error", n)
}
out := logs.String()
if !strings.Contains(out, "claim job failed; retrying") {
t.Fatalf("claim error not logged: %s", out)
}
if strings.Contains(out, sentinelSecret) {
t.Fatalf("claim error text logged: %s", out)
}
}
func TestRunnerEnqueueByKindName(t *testing.T) {
t.Parallel()
st := migratedStore(t)
r := newRunner(t, st, 1)
register(t, r, "count", func(_ context.Context, p countPayload, _ func(countResult) error) (countResult, error) {
return countResult{Done: p.Steps}, nil
})
ctx := context.Background()
if _, err := r.Enqueue(ctx, "no_such_kind", json.RawMessage(`{}`)); !errors.Is(err, ErrUnknownKind) {
t.Errorf("unknown kind error = %v, want ErrUnknownKind", err)
}
for _, payload := range []string{`{"steps":"three"}`, `{"other":1}`, `[]`, ``} {
if _, err := r.Enqueue(ctx, "count", json.RawMessage(payload)); !errors.Is(err, ErrInvalidPayload) {
t.Errorf("payload %q error = %v, want ErrInvalidPayload", payload, err)
}
}
var n int
if err := st.Reader.QueryRow("SELECT COUNT(*) FROM jobs").Scan(&n); err != nil {
t.Fatal(err)
}
if n != 0 {
t.Errorf("refused enqueues stored %d jobs, want 0", n)
}
id, err := r.Enqueue(ctx, "count", json.RawMessage(`{"steps":3}`))
if err != nil {
t.Fatal(err)
}
start(t, r)
job := waitFor(t, r, id, finished)
if job.Kind != "count" || job.Status != StatusSucceeded || string(job.Result) != `{"done":3}` {
t.Errorf("job = %+v (result %s), want succeeded with {\"done\":3}", job, job.Result)
}
}
+81
View File
@@ -0,0 +1,81 @@
// Package secretbox encrypts secrets at rest with AES-256-GCM. It is the only
// owner of secret encryption: every _ct column holds a token produced by
// Box.Encrypt, laid out as nonce ‖ ciphertext.
//
// Errors from this package never contain plaintext, ciphertext or key
// material.
package secretbox
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
)
// KeySize is the decoded key length in bytes (AES-256).
const KeySize = 32
var (
// ErrKeyEncoding means the key is not valid standard base64.
ErrKeyEncoding = errors.New("secretbox: key is not valid standard base64")
// ErrKeyLength means the decoded key is not exactly KeySize bytes.
ErrKeyLength = errors.New("secretbox: key must decode to exactly 32 bytes")
// ErrMalformed means a token is too short to hold a nonce and tag.
ErrMalformed = errors.New("secretbox: token is too short")
// ErrDecrypt means a token failed authentication: it was corrupted or
// sealed under a different key.
ErrDecrypt = errors.New("secretbox: token failed authentication")
)
// Box seals and opens secrets under one key. It is safe for concurrent use.
type Box struct {
aead cipher.AEAD
}
// New returns a Box for encodedKey, the standard-base64 encoding of a
// KeySize-byte key.
func New(encodedKey string) (*Box, error) {
key, err := base64.StdEncoding.Strict().DecodeString(encodedKey)
if err != nil {
// The decoder's error is not wrapped: it describes the key's content.
return nil, ErrKeyEncoding
}
if len(key) != KeySize {
return nil, fmt.Errorf("%w, got %d", ErrKeyLength, len(key))
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, fmt.Errorf("secretbox: %w", err)
}
aead, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("secretbox: %w", err)
}
return &Box{aead: aead}, nil
}
// Encrypt seals plaintext under a fresh random nonce and returns
// nonce ‖ ciphertext.
func (b *Box) Encrypt(plaintext []byte) ([]byte, error) {
nonce := make([]byte, b.aead.NonceSize(), b.aead.NonceSize()+len(plaintext)+b.aead.Overhead())
if _, err := rand.Read(nonce); err != nil {
return nil, fmt.Errorf("secretbox: generate nonce: %w", err)
}
return b.aead.Seal(nonce, nonce, plaintext, nil), nil
}
// Decrypt authenticates and opens a token produced by Encrypt.
func (b *Box) Decrypt(token []byte) ([]byte, error) {
n := b.aead.NonceSize()
if len(token) < n+b.aead.Overhead() {
return nil, ErrMalformed
}
plaintext, err := b.aead.Open(nil, token[:n], token[n:], nil)
if err != nil {
return nil, ErrDecrypt
}
return plaintext, nil
}
+200
View File
@@ -0,0 +1,200 @@
package secretbox
import (
"bytes"
"crypto/rand"
"encoding/base64"
"errors"
"strings"
"testing"
)
// newKey returns a fresh random key and its standard-base64 encoding.
func newKey(t *testing.T) ([]byte, string) {
t.Helper()
key := make([]byte, KeySize)
if _, err := rand.Read(key); err != nil {
t.Fatal(err)
}
return key, base64.StdEncoding.EncodeToString(key)
}
func newBox(t *testing.T) (*Box, []byte, string) {
t.Helper()
key, encoded := newKey(t)
box, err := New(encoded)
if err != nil {
t.Fatal(err)
}
return box, key, encoded
}
// assertNoLeak fails if err's message contains any of the secrets, either
// raw or base64-encoded.
func assertNoLeak(t *testing.T, err error, secrets ...[]byte) {
t.Helper()
msg := err.Error()
for _, s := range secrets {
if len(s) == 0 {
continue
}
for _, form := range []string{string(s), base64.StdEncoding.EncodeToString(s), base64.RawStdEncoding.EncodeToString(s)} {
if strings.Contains(msg, form) {
t.Errorf("error %q leaks secret material %q", msg, form)
}
}
}
}
func TestRoundTrip(t *testing.T) {
box, _, _ := newBox(t)
for _, plaintext := range [][]byte{
[]byte("ya29.oauth-refresh-token"),
{},
bytes.Repeat([]byte{0x00, 0xff}, 4096),
} {
token, err := box.Encrypt(plaintext)
if err != nil {
t.Fatal(err)
}
if len(plaintext) > 0 && bytes.Contains(token, plaintext) {
t.Errorf("token contains the plaintext")
}
got, err := box.Decrypt(token)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(got, plaintext) {
t.Errorf("Decrypt = %q, want %q", got, plaintext)
}
}
}
func TestTokenLayout(t *testing.T) {
box, _, _ := newBox(t)
plaintext := []byte("secret")
token, err := box.Encrypt(plaintext)
if err != nil {
t.Fatal(err)
}
// nonce (12) ‖ ciphertext (len(plaintext)) ‖ GCM tag (16).
if want := 12 + len(plaintext) + 16; len(token) != want {
t.Errorf("token length = %d, want %d", len(token), want)
}
}
func TestEncryptUsesFreshNonce(t *testing.T) {
box, _, _ := newBox(t)
plaintext := []byte("same plaintext")
a, err := box.Encrypt(plaintext)
if err != nil {
t.Fatal(err)
}
b, err := box.Encrypt(plaintext)
if err != nil {
t.Fatal(err)
}
if bytes.Equal(a, b) {
t.Fatal("two encryptions of the same plaintext are identical")
}
if bytes.Equal(a[:12], b[:12]) {
t.Error("two encryptions reused the same nonce")
}
}
func TestDecryptRejectsFlippedByte(t *testing.T) {
box, key, encoded := newBox(t)
plaintext := []byte("do-not-leak-me")
token, err := box.Encrypt(plaintext)
if err != nil {
t.Fatal(err)
}
// Flip one bit in every position: nonce, ciphertext and tag.
for i := range token {
tampered := bytes.Clone(token)
tampered[i] ^= 0x01
got, err := box.Decrypt(tampered)
if !errors.Is(err, ErrDecrypt) {
t.Fatalf("byte %d flipped: Decrypt = %q, %v; want ErrDecrypt", i, got, err)
}
if got != nil {
t.Errorf("byte %d flipped: Decrypt returned plaintext alongside the error", i)
}
assertNoLeak(t, err, plaintext, key, []byte(encoded), tampered)
}
}
func TestDecryptRejectsWrongKey(t *testing.T) {
box, key, encoded := newBox(t)
other, otherKey, otherEncoded := newBox(t)
plaintext := []byte("do-not-leak-me")
token, err := box.Encrypt(plaintext)
if err != nil {
t.Fatal(err)
}
got, err := other.Decrypt(token)
if !errors.Is(err, ErrDecrypt) {
t.Fatalf("Decrypt with wrong key = %q, %v; want ErrDecrypt", got, err)
}
assertNoLeak(t, err, plaintext, key, []byte(encoded), otherKey, []byte(otherEncoded), token)
}
func TestDecryptRejectsShortToken(t *testing.T) {
box, _, _ := newBox(t)
for _, n := range []int{0, 1, 12, 12 + 15} {
token := bytes.Repeat([]byte{0xab}, n)
if _, err := box.Decrypt(token); !errors.Is(err, ErrMalformed) {
t.Errorf("Decrypt(%d bytes) error = %v, want ErrMalformed", n, err)
}
}
}
func TestNewRejectsBadKey(t *testing.T) {
key, _ := newKey(t)
tests := []struct {
name string
encoded string
want error
}{
{"empty", "", ErrKeyLength},
{"not base64", "not*base64!" + strings.Repeat("A", 33), ErrKeyEncoding},
{"url-safe alphabet", strings.Repeat("_-", 22), ErrKeyEncoding},
{"unpadded", base64.RawStdEncoding.EncodeToString(key), ErrKeyEncoding},
{"raw key bytes", string(key), ErrKeyEncoding},
{"too short", base64.StdEncoding.EncodeToString(key[:31]), ErrKeyLength},
{"too long", base64.StdEncoding.EncodeToString(append(bytes.Clone(key), 0x00)), ErrKeyLength},
{"AES-128 length", base64.StdEncoding.EncodeToString(key[:16]), ErrKeyLength},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
box, err := New(tt.encoded)
if !errors.Is(err, tt.want) {
t.Fatalf("New error = %v, want %v", err, tt.want)
}
if box != nil {
t.Error("New returned a Box alongside the error")
}
assertNoLeak(t, err, key, []byte(tt.encoded))
})
}
}
func TestNewAcceptsBase64Key(t *testing.T) {
key, encoded := newKey(t)
box, err := New(encoded)
if err != nil {
t.Fatal(err)
}
// A Box built from the same key opens tokens sealed by the first.
token, err := box.Encrypt([]byte("x"))
if err != nil {
t.Fatal(err)
}
again, err := New(base64.StdEncoding.EncodeToString(key))
if err != nil {
t.Fatal(err)
}
if _, err := again.Decrypt(token); err != nil {
t.Errorf("same key failed to decrypt: %v", err)
}
}
+32
View File
@@ -0,0 +1,32 @@
// Package secureurl holds the one transport rule for URLs that carry
// credentials or session cookies: HTTPS, or plain HTTP to a loopback host for
// local development and tests.
package secureurl
import (
"errors"
"fmt"
"net/url"
"strings"
)
// loopbackHosts are the only hosts plain HTTP is allowed for, compared
// case-insensitively. url.URL's Hostname strips the brackets from "[::1]".
var loopbackHosts = map[string]bool{"localhost": true, "127.0.0.1": true, "::1": true}
// Check reports an error unless u is an absolute https:// URL, or an http://
// URL whose host is localhost, 127.0.0.1 or ::1.
func Check(u *url.URL) error {
switch {
case u.Hostname() == "":
return errors.New("URL has no host")
case u.Scheme == "https":
return nil
case u.Scheme == "http" && loopbackHosts[strings.ToLower(u.Hostname())]:
return nil
case u.Scheme == "http":
return errors.New("plain http:// is allowed only for localhost, 127.0.0.1 or [::1]; use https://")
default:
return fmt.Errorf("unsupported scheme %q (want https://)", u.Scheme)
}
}
+40
View File
@@ -0,0 +1,40 @@
package secureurl
import (
"net/url"
"testing"
)
func TestCheck(t *testing.T) {
t.Parallel()
tests := []struct {
raw string
ok bool
}{
{"https://meals.example.com", true},
{"https://auth.example.com/application/o/mealquest/", true},
{"https://192.0.2.10:8443", true},
{"http://localhost:8080", true},
{"http://LOCALHOST:8080", true},
{"http://127.0.0.1:8080/path", true},
{"http://[::1]:8080", true},
{"http://[::1]", true},
{"http://meals.example.com", false},
{"http://192.0.2.10", false},
{"http://127.0.0.2", false},
{"http://localhost.example.com", false},
{"http://0.0.0.0:8080", false},
{"ftp://localhost", false},
{"https://", false},
{"meals.example.com", false},
}
for _, tc := range tests {
u, err := url.Parse(tc.raw)
if err != nil {
t.Fatal(err)
}
if err := Check(u); (err == nil) != tc.ok {
t.Errorf("Check(%q) error = %v, want ok=%v", tc.raw, err, tc.ok)
}
}
}
+42
View File
@@ -0,0 +1,42 @@
package server
import (
"crypto/subtle"
"log/slog"
"net/http"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth"
)
// CSRFHeader carries the session's CSRF token, which the client reads from
// GET /api/v1/me.
const CSRFHeader = "X-MealQuest-CSRF"
// requireCSRF passes GET requests to next; every other
// request must carry CSRFHeader equal to its session's CSRF token or get 403
// csrf. It runs behind requireSession, which puts the session in the context.
func requireCSRF(logger *slog.Logger) func(http.Handler) http.Handler {
forbidden := apiErrorHandler(logger, http.StatusForbidden, "csrf", "missing or invalid CSRF token")
internal := apiErrorHandler(logger, http.StatusInternalServerError, "internal", "internal error")
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
next.ServeHTTP(w, r)
return
}
s, ok := auth.SessionFrom(r.Context())
if !ok || s.CSRFToken == "" {
// An empty token would match a missing header.
logger.ErrorContext(r.Context(), "csrf check without a session token", "session", ok)
internal(w, r)
return
}
if subtle.ConstantTimeCompare([]byte(r.Header.Get(CSRFHeader)), []byte(s.CSRFToken)) != 1 {
forbidden(w, r)
return
}
next.ServeHTTP(w, r)
})
}
}
+167
View File
@@ -0,0 +1,167 @@
package server
import (
"context"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"testing"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth"
)
const (
csrfJSON = `{"error":{"code":"csrf","message":"missing or invalid CSRF token","retryable":false}}`
methodNotAllowedJSON = `{"error":{"code":"method_not_allowed","message":"method not allowed","retryable":false}}`
)
func TestCSRFThroughRouter(t *testing.T) {
t.Parallel()
router := newTestRouter(t, "dev")
for _, tc := range []struct {
name, method, path string
header []string // nil sends no CSRF header
wantStatus int
wantBody string
}{
{"POST without header", http.MethodPost, "/api/v1/me", nil, http.StatusForbidden, csrfJSON},
{"POST with wrong header", http.MethodPost, "/api/v1/me", []string{"wrong-token"}, http.StatusForbidden, csrfJSON},
{"POST with empty header", http.MethodPost, "/api/v1/me", []string{""}, http.StatusForbidden, csrfJSON},
{"POST with token prefix", http.MethodPost, "/api/v1/me", []string{"csrf-token"}, http.StatusForbidden, csrfJSON},
{"PATCH without header", http.MethodPatch, "/api/v1/settings/household", nil, http.StatusForbidden, csrfJSON},
{"DELETE with wrong header", http.MethodDelete, "/api/v1/me", []string{"wrong-token"}, http.StatusForbidden, csrfJSON},
// The right token passes the check and reaches the router, which has
// no POST /api/v1/me, and the settings handler, which refuses the
// empty body.
{"POST with right header", http.MethodPost, "/api/v1/me", []string{testSession.CSRFToken}, http.StatusMethodNotAllowed, methodNotAllowedJSON},
{"PATCH with right header", http.MethodPatch, "/api/v1/settings/household", []string{testSession.CSRFToken}, http.StatusBadRequest, `{"error":{"code":"invalid_request","message":"request body must be a JSON object","retryable":false}}`},
{"GET without header", http.MethodGet, "/api/v1/me", nil, http.StatusOK, ""},
// Only GET is exempt; HEAD and OPTIONS need the header too.
{"HEAD without header", http.MethodHead, "/api/v1/me", nil, http.StatusForbidden, csrfJSON},
{"OPTIONS without header", http.MethodOptions, "/api/v1/me", nil, http.StatusForbidden, csrfJSON},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
req := httptest.NewRequest(tc.method, tc.path, nil)
req.AddCookie(validSessionCookie)
if tc.header != nil {
req.Header.Set(CSRFHeader, tc.header[0])
}
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != tc.wantStatus {
t.Errorf("status = %d, want %d (body %s)", rec.Code, tc.wantStatus, rec.Body.String())
}
if tc.wantBody != "" && rec.Body.String() != tc.wantBody {
t.Errorf("body = %s, want %s", rec.Body.String(), tc.wantBody)
}
})
}
}
func TestCSRFUnauthenticatedGets401(t *testing.T) {
t.Parallel()
req := httptest.NewRequest(http.MethodPost, "/api/v1/me", nil)
rec := httptest.NewRecorder()
newTestRouter(t, "dev").ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized || rec.Body.String() != unauthenticatedJSON {
t.Errorf("response = %d %s, want 401 %s", rec.Code, rec.Body.String(), unauthenticatedJSON)
}
}
func TestRequireCSRF(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
for _, tc := range []struct {
name string
method string
session *auth.Session
header string
wantStatus int
}{
{"right header reaches handler", http.MethodPost, &testSession, testSession.CSRFToken, http.StatusNoContent},
{"wrong header", http.MethodPut, &testSession, "nope", http.StatusForbidden},
{"GET needs no header", http.MethodGet, &testSession, "", http.StatusNoContent},
{"HEAD needs the header", http.MethodHead, &testSession, "", http.StatusForbidden},
{"OPTIONS needs the header", http.MethodOptions, &testSession, "", http.StatusForbidden},
{"no session in context", http.MethodPost, nil, "", http.StatusInternalServerError},
{"session without token", http.MethodPost, &auth.Session{Subject: "user-123"}, "", http.StatusInternalServerError},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
called := false
h := requireCSRF(logger)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
called = true
w.WriteHeader(http.StatusNoContent)
}))
ctx := context.Background()
if tc.session != nil {
ctx = auth.WithSession(ctx, *tc.session)
}
req := httptest.NewRequestWithContext(ctx, tc.method, "/api/v1/anything", nil)
if tc.header != "" {
req.Header.Set(CSRFHeader, tc.header)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != tc.wantStatus {
t.Errorf("status = %d, want %d", rec.Code, tc.wantStatus)
}
if called != (tc.wantStatus == http.StatusNoContent) {
t.Errorf("handler called = %v with status %d", called, rec.Code)
}
})
}
}
func TestMe(t *testing.T) {
t.Parallel()
req := httptest.NewRequest(http.MethodGet, "/api/v1/me", nil)
req.AddCookie(validSessionCookie)
rec := httptest.NewRecorder()
newTestRouter(t, "dev").ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (body %s)", rec.Code, rec.Body.String())
}
if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
t.Errorf("Content-Type = %q, want application/json", ct)
}
if cc := rec.Header().Get("Cache-Control"); cc != "no-store" {
t.Errorf("Cache-Control = %q, want no-store", cc)
}
var got map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("decode %s: %v", rec.Body.String(), err)
}
want := map[string]any{
"name": "Home Cook",
"email": "cook@example.com",
"household": map[string]any{"label": "Our kitchen"},
"csrfToken": "csrf-token-123",
}
gotJSON, _ := json.Marshal(got)
wantJSON, _ := json.Marshal(want)
if string(gotJSON) != string(wantJSON) {
t.Errorf("body = %s, want %s", gotJSON, wantJSON)
}
}
func TestMeNeedsSession(t *testing.T) {
t.Parallel()
rec := httptest.NewRecorder()
newTestRouter(t, "dev").ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/me", nil))
if rec.Code != http.StatusUnauthorized || rec.Body.String() != unauthenticatedJSON {
t.Errorf("response = %d %s, want 401 %s", rec.Code, rec.Body.String(), unauthenticatedJSON)
}
}
+107
View File
@@ -0,0 +1,107 @@
package server
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"github.com/go-chi/chi/v5"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
)
// maxJobBody bounds a POST /jobs request body.
const maxJobBody = 64 << 10
// jobRequest is the POST /jobs body. Payload is required; a job kind that
// takes no input is sent the empty object {}.
type jobRequest struct {
Kind string `json:"kind"`
Payload json.RawMessage `json:"payload"`
}
// jobCreated is the POST /jobs response.
type jobCreated struct {
ID string `json:"id"`
}
// postJob serves POST /api/v1/jobs: it enqueues a job of a registered kind
// and returns 202 with its id, which GET /api/v1/jobs/{id} reports on.
func postJob(logger *slog.Logger, runner *jobs.Runner) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
req, err := decodeJobRequest(http.MaxBytesReader(w, r.Body, maxJobBody))
if err != nil {
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
return
}
id, err := runner.Enqueue(r.Context(), req.Kind, req.Payload)
switch {
case errors.Is(err, jobs.ErrUnknownKind):
writeError(w, r, logger, http.StatusBadRequest, "unknown_kind", "unknown job kind")
return
case errors.Is(err, jobs.ErrInvalidPayload):
writeError(w, r, logger, http.StatusBadRequest, "invalid_payload", "payload does not match the job kind")
return
case err != nil:
logger.ErrorContext(r.Context(), "enqueue job", "kind", req.Kind, "error", err)
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
return
}
writeJSON(w, r, logger, http.StatusAccepted, jobCreated{ID: id})
}
}
// decodeJobRequest reads a single JSON object with a non-empty kind and a
// payload, refusing unknown fields, an omitted or null payload and trailing
// data.
func decodeJobRequest(body io.Reader) (jobRequest, error) {
data, err := io.ReadAll(body)
if err != nil {
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
return jobRequest{}, fmt.Errorf("request body exceeds %d bytes", tooLarge.Limit)
}
return jobRequest{}, errors.New("cannot read request body")
}
var req jobRequest
dec := json.NewDecoder(bytes.NewReader(data))
dec.DisallowUnknownFields()
if err := dec.Decode(&req); err != nil {
return jobRequest{}, errors.New("request body must be a JSON object with kind and payload")
}
if _, err := dec.Token(); err != io.EOF {
return jobRequest{}, errors.New("request body must be a single JSON object")
}
if req.Kind == "" {
return jobRequest{}, errors.New("kind: required")
}
if req.Payload == nil {
return jobRequest{}, errors.New("payload: required")
}
if bytes.Equal(req.Payload, []byte("null")) {
return jobRequest{}, errors.New("payload: must not be null")
}
return req, nil
}
// getJob serves GET /api/v1/jobs/{id}: the job's id, kind, status, latest
// result and error.
func getJob(logger *slog.Logger, runner *jobs.Runner) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
job, err := runner.Get(r.Context(), chi.URLParam(r, "id"))
if errors.Is(err, jobs.ErrNotFound) {
writeError(w, r, logger, http.StatusNotFound, "not_found", "job not found")
return
}
if err != nil {
logger.ErrorContext(r.Context(), "get job", "error", err)
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
return
}
writeJSON(w, r, logger, http.StatusOK, job)
}
}
+246
View File
@@ -0,0 +1,246 @@
package server
import (
"context"
"encoding/json"
"maps"
"net/http"
"net/http/httptest"
"slices"
"strings"
"testing"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
type draftPayload struct{}
type draftResult struct {
Days int `json:"days"`
}
func (d draftResult) PublicJSON() ([]byte, error) { return json.Marshal(d) }
// startedRunner registers a "draft" kind that publishes {"days":1}, waits
// for release and returns {"days":7}, or fails with a *jobs.Failure when
// fail is closed first. It starts the runner and stops it when the test ends.
func startedRunner(t *testing.T, release, fail <-chan struct{}) (*jobs.Runner, *jobs.Kind[draftPayload]) {
t.Helper()
runner := newTestRunner(t)
kind, err := jobs.Register(runner, "draft", func(ctx context.Context, _ draftPayload, publish func(draftResult) error) (draftResult, error) {
if err := publish(draftResult{Days: 1}); err != nil {
return draftResult{}, err
}
select {
case <-release:
return draftResult{Days: 7}, nil
case <-fail:
return draftResult{}, &jobs.Failure{Code: "no_regulars"}
case <-ctx.Done():
return draftResult{}, ctx.Err()
}
}, "no_regulars")
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
if err := runner.Start(ctx); err != nil {
cancel()
t.Fatal(err)
}
t.Cleanup(func() {
cancel()
runner.Wait()
})
return runner, kind
}
// pollJob GETs the job until want accepts the body, failing after five
// seconds.
func pollJob(t *testing.T, h http.Handler, id string, want func(map[string]any) bool) map[string]any {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for {
rec, body := serve(t, h, signedIn(http.MethodGet, "/api/v1/jobs/"+id, nil))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200: %s", rec.Code, rec.Body)
}
if want(body) {
if keys := slices.Sorted(maps.Keys(body)); !slices.Equal(keys, []string{"error", "id", "kind", "result", "status"}) {
t.Errorf("body fields = %v, want id, kind, status, result, error", keys)
}
return body
}
if time.Now().After(deadline) {
t.Fatalf("job %s never reached the expected state; last %v", id, body)
}
time.Sleep(5 * time.Millisecond)
}
}
func resultDays(body map[string]any) float64 {
result, _ := body["result"].(map[string]any)
days, _ := result["days"].(float64)
return days
}
func TestGetJobShowsProgressThenResult(t *testing.T) {
t.Parallel()
release := make(chan struct{})
runner, kind := startedRunner(t, release, nil)
router := newTestRouterWith(t, "dev", newTestSettings(t), runner)
id, err := kind.Enqueue(context.Background(), draftPayload{})
if err != nil {
t.Fatal(err)
}
mid := pollJob(t, router, id, func(b map[string]any) bool { return resultDays(b) == 1 })
if mid["id"] != id || mid["kind"] != "draft" || mid["status"] != "running" || mid["error"] != nil {
t.Errorf("mid-run body = %v, want running draft job %s with no error", mid, id)
}
close(release)
done := pollJob(t, router, id, func(b map[string]any) bool { return b["status"] == "succeeded" })
if resultDays(done) != 7 || done["error"] != nil {
t.Errorf("finished body = %v, want result {\"days\":7} and no error", done)
}
}
func TestGetJobShowsFailure(t *testing.T) {
t.Parallel()
fail := make(chan struct{})
close(fail)
runner, kind := startedRunner(t, nil, fail)
router := newTestRouterWith(t, "dev", newTestSettings(t), runner)
id, err := kind.Enqueue(context.Background(), draftPayload{})
if err != nil {
t.Fatal(err)
}
body := pollJob(t, router, id, func(b map[string]any) bool { return b["status"] == "failed" })
if body["error"] != "no_regulars" {
t.Errorf("error = %v, want the Failure code", body["error"])
}
}
func TestGetJobUnknownIDIsNotFound(t *testing.T) {
t.Parallel()
router := newTestRouter(t, "dev")
rec, _ := serve(t, router, signedIn(http.MethodGet, "/api/v1/jobs/"+store.NewID(), nil))
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", rec.Code)
}
var body errorBody
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if want := (errorBody{Error: apiError{Code: "not_found", Message: "job not found"}}); body != want {
t.Errorf("body = %+v, want %+v", body, want)
}
}
func TestGetJobRequiresSession(t *testing.T) {
t.Parallel()
router := newTestRouter(t, "dev")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/jobs/"+store.NewID(), nil))
if rec.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want 401", rec.Code)
}
}
// TestPostJobThenGetProgressAndResult drives the whole flow over the
// router: POST enqueues a progressive "draft" job, GET reads its partial
// result while the handler is held mid-run, then its final result.
func TestPostJobThenGetProgressAndResult(t *testing.T) {
t.Parallel()
release := make(chan struct{})
runner, _ := startedRunner(t, release, nil)
router := newTestRouterWith(t, "dev", newTestSettings(t), runner)
rec, body := serve(t, router, signedIn(http.MethodPost, "/api/v1/jobs", strings.NewReader(`{"kind":"draft","payload":{}}`)))
if rec.Code != http.StatusAccepted {
t.Fatalf("status = %d, want 202: %s", rec.Code, rec.Body)
}
id, _ := body["id"].(string)
if keys := slices.Sorted(maps.Keys(body)); !slices.Equal(keys, []string{"id"}) || id == "" {
t.Fatalf("body = %v, want only a job id", body)
}
mid := pollJob(t, router, id, func(b map[string]any) bool { return resultDays(b) == 1 })
if mid["id"] != id || mid["kind"] != "draft" || mid["status"] != "running" || mid["error"] != nil {
t.Errorf("mid-run body = %v, want running draft job %s with no error", mid, id)
}
close(release)
done := pollJob(t, router, id, func(b map[string]any) bool { return b["status"] == "succeeded" })
if done["id"] != id || resultDays(done) != 7 || done["error"] != nil {
t.Errorf("finished body = %v, want result {\"days\":7} and no error", done)
}
}
func TestPostJobRefusesBadRequests(t *testing.T) {
t.Parallel()
runner, _ := startedRunner(t, nil, nil)
router := newTestRouterWith(t, "dev", newTestSettings(t), runner)
for _, tc := range []struct {
name, body, code, message string
}{
{"unknown kind", `{"kind":"no_such_kind","payload":{}}`, "unknown_kind", "unknown job kind"},
{"payload for another type", `{"kind":"draft","payload":{"days":3}}`, "invalid_payload", "payload does not match the job kind"},
{"payload not an object", `{"kind":"draft","payload":[1]}`, "invalid_payload", "payload does not match the job kind"},
{"missing payload", `{"kind":"draft"}`, "invalid_request", "payload: required"},
{"null payload", `{"kind":"draft","payload":null}`, "invalid_request", "payload: must not be null"},
{"missing kind", `{"payload":{}}`, "invalid_request", "kind: required"},
{"empty body", ``, "invalid_request", "request body must be a JSON object with kind and payload"},
{"not an object", `["draft"]`, "invalid_request", "request body must be a JSON object with kind and payload"},
{"unknown field", `{"kind":"draft","payload":{},"extra":1}`, "invalid_request", "request body must be a JSON object with kind and payload"},
{"trailing data", `{"kind":"draft","payload":{}} {}`, "invalid_request", "request body must be a single JSON object"},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
rec := httptest.NewRecorder()
router.ServeHTTP(rec, signedIn(http.MethodPost, "/api/v1/jobs", strings.NewReader(tc.body)))
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400: %s", rec.Code, rec.Body)
}
var body errorBody
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if want := (errorBody{Error: apiError{Code: tc.code, Message: tc.message}}); body != want {
t.Errorf("body = %+v, want %+v", body, want)
}
})
}
}
func TestPostJobNeedsSessionAndCSRF(t *testing.T) {
t.Parallel()
router := newTestRouter(t, "dev")
const body = `{"kind":"noop","payload":{}}`
rec := httptest.NewRecorder()
router.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/api/v1/jobs", strings.NewReader(body)))
if rec.Code != http.StatusUnauthorized || rec.Body.String() != unauthenticatedJSON {
t.Errorf("no session: %d %s, want 401 %s", rec.Code, rec.Body, unauthenticatedJSON)
}
for _, header := range []string{"", "wrong-token"} {
req := httptest.NewRequest(http.MethodPost, "/api/v1/jobs", strings.NewReader(body))
req.AddCookie(validSessionCookie)
if header != "" {
req.Header.Set(CSRFHeader, header)
}
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden || rec.Body.String() != csrfJSON {
t.Errorf("CSRF header %q: %d %s, want 403 %s", header, rec.Code, rec.Body, csrfJSON)
}
}
}
+140
View File
@@ -0,0 +1,140 @@
package server
import (
"context"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"testing/fstest"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
)
// newDiscoveryProvider serves the OIDC discovery document auth.New needs;
// login only redirects to its authorization endpoint.
func newDiscoveryProvider(t *testing.T) *httptest.Server {
t.Helper()
var srv *httptest.Server
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/.well-known/openid-configuration" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"issuer": srv.URL,
"authorization_endpoint": srv.URL + "/authorize",
"token_endpoint": srv.URL + "/token",
"jwks_uri": srv.URL + "/jwks",
"userinfo_endpoint": srv.URL + "/userinfo",
"end_session_endpoint": srv.URL + "/end-session/",
"response_types_supported": []string{"code"},
"subject_types_supported": []string{"public"},
"id_token_signing_alg_values_supported": []string{"RS256"},
})
}))
t.Cleanup(srv.Close)
return srv
}
// newLoginRouter wires the production router as cmd/server does: the real
// auth flow and session manager, trusting Traefik only.
func newLoginRouter(t *testing.T, issuer string) http.Handler {
t.Helper()
ctx := context.Background()
db, err := store.Open(ctx, t.TempDir())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := db.Close(); err != nil {
t.Error(err)
}
})
if err := store.Migrate(ctx, db.Writer, migrations.FS); err != nil {
t.Fatal(err)
}
sessions, err := auth.NewSessionManager(db, 720*time.Hour, 4320*time.Hour)
if err != nil {
t.Fatal(err)
}
logger := slog.New(forwarded.LogHandler(slog.NewTextHandler(io.Discard, nil)))
flow, err := auth.New(ctx, auth.Config{
Issuer: issuer,
ClientID: "mealquest",
ClientSecret: "client-s3cret",
PublicURL: &url.URL{Scheme: "https", Host: "meals.example.com"},
SecretKey: []byte("server-secret-key"),
AllowedGroups: []string{"mealquest-admin"},
}, sessions, logger)
if err != nil {
t.Fatal(err)
}
trusted, err := forwarded.ParseTrusted(traefik)
if err != nil {
t.Fatal(err)
}
r, err := NewRouter(logger, "dev", trusted, flow, sessions, newTestSettings(t),
newTestRunner(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
return r
}
// TestLoginThroughRouter starts a login through the production router. The
// redirect URI and the Secure login cookie come from MEALQUEST_PUBLIC_URL
// whatever the direct peer: forwarded headers from an untrusted peer are
// ignored but never fail sign-in.
func TestLoginThroughRouter(t *testing.T) {
t.Parallel()
provider := newDiscoveryProvider(t)
router := newLoginRouter(t, provider.URL)
// Traefik reaches the service over plain http on its internal address;
// the host headers must not leak into the redirect URI.
headers := map[string]string{
forwarded.HeaderFor: "203.0.113.7",
forwarded.HeaderProto: "https",
"X-Forwarded-Host": "evil.example.com",
}
tests := []struct {
name string
peer string
}{
{"trusted proxy", traefik + ":41000"},
{"untrusted peer", "172.17.0.1:5000"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
rec := httptest.NewRecorder()
router.ServeHTTP(rec, originRequest("http://mealquest:8080"+auth.LoginPath, tt.peer, headers))
resp := rec.Result()
if resp.StatusCode != http.StatusFound {
t.Fatalf("login status = %d, want 302", resp.StatusCode)
}
loc, err := url.Parse(resp.Header.Get("Location"))
if err != nil {
t.Fatal(err)
}
if got, want := loc.Scheme+"://"+loc.Host+loc.Path, provider.URL+"/authorize"; got != want {
t.Errorf("redirect to %s, want %s", got, want)
}
if got, want := loc.Query().Get("redirect_uri"), "https://meals.example.com/auth/callback"; got != want {
t.Errorf("redirect_uri = %q, want %q from the public URL", got, want)
}
cookies := resp.Cookies()
if len(cookies) != 1 || cookies[0].Name != "__Host-mealquest_login" || !cookies[0].Secure || !cookies[0].HttpOnly {
t.Errorf("login cookies = %v, want one Secure, HttpOnly __Host-mealquest_login", cookies)
}
})
}
}
+52
View File
@@ -0,0 +1,52 @@
package server
import (
"encoding/json"
"log/slog"
"net/http"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth"
)
// householdLabel is the single shared household identity.
const householdLabel = "Our kitchen"
type meResponse struct {
Name string `json:"name"`
Email string `json:"email"`
Household householdInfo `json:"household"`
CSRFToken string `json:"csrfToken"`
}
type householdInfo struct {
Label string `json:"label"`
}
// me answers GET /api/v1/me with the signed-in member, the household and the
// session's CSRF token for CSRFHeader.
func me(logger *slog.Logger) http.HandlerFunc {
internal := apiErrorHandler(logger, http.StatusInternalServerError, "internal", "internal error")
return func(w http.ResponseWriter, r *http.Request) {
s, ok := auth.SessionFrom(r.Context())
if !ok {
logger.ErrorContext(r.Context(), "GET /api/v1/me without a session")
internal(w, r)
return
}
body, err := json.Marshal(meResponse{
Name: s.Name,
Email: s.Email,
Household: householdInfo{Label: householdLabel},
CSRFToken: s.CSRFToken,
})
if err != nil {
panic(err) // string fields cannot fail to marshal
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusOK)
if _, err := w.Write(body); err != nil {
logger.WarnContext(r.Context(), "write me response", "error", err)
}
}
}
+34
View File
@@ -0,0 +1,34 @@
package server
import (
"errors"
"log/slog"
"net/http"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
)
// resolveOrigin resolves every request's client address and scheme with
// forwarded.Resolve and puts them in the context (read them with
// forwarded.ClientIP and forwarded.Scheme). Forwarded headers are honoured
// only from a peer in trusted. A malformed forwarded header from a trusted
// peer gets 400 bad_request.
func resolveOrigin(logger *slog.Logger, trusted forwarded.Trusted) func(http.Handler) http.Handler {
badRequest := apiErrorHandler(logger, http.StatusBadRequest, "bad_request", "invalid forwarded headers")
internal := apiErrorHandler(logger, http.StatusInternalServerError, "internal", "internal error")
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
o, err := forwarded.Resolve(r, trusted)
switch {
case errors.Is(err, forwarded.ErrInvalidHeader):
logger.WarnContext(r.Context(), "rejected forwarded headers", "peer", r.RemoteAddr, "error", err)
badRequest(w, r)
case err != nil:
logger.ErrorContext(r.Context(), "resolve request origin", "error", err)
internal(w, r)
default:
next.ServeHTTP(w, r.WithContext(forwarded.NewContext(r.Context(), o)))
}
})
}
}
+145
View File
@@ -0,0 +1,145 @@
package server
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"testing"
"testing/fstest"
"github.com/go-chi/chi/v5"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
)
// traefik is the production proxy's address.
const traefik = "10.60.1.33"
// probeAuth mounts GET /auth/probe, which reports the request's resolved
// origin, in place of the real auth flow.
type probeAuth struct{}
func (probeAuth) Mount(r chi.Router) {
r.Get("/auth/probe", func(w http.ResponseWriter, r *http.Request) {
ip, ipOK := forwarded.ClientIP(r.Context())
scheme, schemeOK := forwarded.Scheme(r.Context())
if !ipOK || !schemeOK {
http.Error(w, "origin not resolved", http.StatusInternalServerError)
return
}
fmt.Fprintf(w, "%s %s", ip, scheme)
})
}
// newOriginRouter builds the production router trusting Traefik, logging
// through forwarded.LogHandler as cmd/server does.
func newOriginRouter(t *testing.T, logs io.Writer) http.Handler {
t.Helper()
trusted, err := forwarded.ParseTrusted(traefik)
if err != nil {
t.Fatal(err)
}
logger := slog.New(forwarded.LogHandler(slog.NewJSONHandler(logs, nil)))
r, err := NewRouter(logger, "dev", trusted, probeAuth{}, stubSessions{}, newTestSettings(t),
newTestRunner(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
return r
}
func originRequest(target, peer string, headers map[string]string) *http.Request {
req := httptest.NewRequest(http.MethodGet, target, nil)
req.RemoteAddr = peer
for k, v := range headers {
req.Header.Set(k, v)
}
return req
}
func TestRouterResolvesOrigin(t *testing.T) {
t.Parallel()
spoofed := map[string]string{forwarded.HeaderFor: "203.0.113.7", forwarded.HeaderProto: "https"}
tests := []struct {
name string
peer string
want string
}{
{"trusted proxy's forwarded headers are honoured", traefik + ":41000", "203.0.113.7 https"},
{"untrusted peer spoofing them is ignored", "198.51.100.9:5000", "198.51.100.9 http"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
rec := httptest.NewRecorder()
newOriginRouter(t, io.Discard).ServeHTTP(rec, originRequest("/auth/probe", tt.peer, spoofed))
if rec.Code != http.StatusOK || rec.Body.String() != tt.want {
t.Errorf("probe = %d %q, want 200 %q", rec.Code, rec.Body.String(), tt.want)
}
})
}
}
func TestRouterLogsResolvedClientIP(t *testing.T) {
t.Parallel()
spoofed := map[string]string{forwarded.HeaderFor: "203.0.113.7", forwarded.HeaderProto: "https"}
tests := []struct {
name string
peer string
want string
}{
{"trusted proxy", traefik + ":41000", "203.0.113.7"},
{"untrusted peer spoofing", "198.51.100.9:5000", "198.51.100.9"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
var logs bytes.Buffer
// A failed session lookup is logged with the request's context.
req := originRequest("/api/v1/me", tt.peer, spoofed)
req.AddCookie(brokenSessionCookie)
rec := httptest.NewRecorder()
newOriginRouter(t, &logs).ServeHTTP(rec, req)
if rec.Code != http.StatusInternalServerError {
t.Fatalf("status = %d, want 500", rec.Code)
}
var line map[string]any
if err := json.Unmarshal(logs.Bytes(), &line); err != nil {
t.Fatalf("log %q: %v", logs.String(), err)
}
if line["msg"] != "look up session" || line[forwarded.LogAttr] != tt.want {
t.Errorf("log = %v, want the session error with %s=%s", line, forwarded.LogAttr, tt.want)
}
})
}
}
func TestRouterRejectsMalformedForwardedHeaders(t *testing.T) {
t.Parallel()
const badRequestJSON = `{"error":{"code":"bad_request","message":"invalid forwarded headers","retryable":false}}`
for _, headers := range []map[string]string{
{forwarded.HeaderFor: "not-an-ip"},
{forwarded.HeaderProto: "ftp"},
} {
var logs bytes.Buffer
rec := httptest.NewRecorder()
newOriginRouter(t, &logs).ServeHTTP(rec, originRequest("/health", traefik+":41000", headers))
if rec.Code != http.StatusBadRequest || rec.Body.String() != badRequestJSON {
t.Errorf("%v: response = %d %s, want 400 %s", headers, rec.Code, rec.Body.String(), badRequestJSON)
}
if !bytes.Contains(logs.Bytes(), []byte("rejected forwarded headers")) {
t.Errorf("%v: logs = %q, want the rejection logged", headers, logs.String())
}
// The same headers from an untrusted peer are not examined at all.
rec = httptest.NewRecorder()
newOriginRouter(t, io.Discard).ServeHTTP(rec, originRequest("/health", "198.51.100.9:5000", headers))
if rec.Code != http.StatusOK {
t.Errorf("%v from an untrusted peer: status = %d, want 200", headers, rec.Code)
}
}
}
+120
View File
@@ -0,0 +1,120 @@
// Package server builds the MealQuest HTTP router.
package server
import (
"encoding/json"
"errors"
"io/fs"
"log/slog"
"net/http"
"github.com/go-chi/chi/v5"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
)
// reservedPrefixes are owned by the API router and are never answered with the
// SPA's index.html.
var reservedPrefixes = []string{"/api", "/auth", "/media", "/health"}
// RouteMounter registers its own routes on the router.
type RouteMounter interface {
Mount(r chi.Router)
}
// NewRouter returns the HTTP handler for the service. Every request first
// has its client address and scheme resolved, honouring forwarded headers
// only from a peer in trusted (see resolveOrigin). version is reported by
// GET /health; auth mounts the /auth routes; sessions guards /api/v1 and
// /media; settings serves /api/v1/settings/household; runner serves
// POST /api/v1/jobs and GET /api/v1/jobs/{id}; assets is the web build
// served for every other GET. /health, /auth and the web build are public.
// Requests to /api/v1 other than GET also need the session's CSRF token in CSRFHeader.
func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, settings *household.SettingsService, runner *jobs.Runner, assets fs.FS) (http.Handler, error) {
if settings == nil {
return nil, errors.New("server: nil household settings service")
}
if runner == nil {
return nil, errors.New("server: nil job runner")
}
notFound := apiErrorHandler(logger, http.StatusNotFound, "not_found", "not found")
methodNotAllowed := apiErrorHandler(logger, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
web, err := frontend.NewHandler(assets, logger, notFound, reservedPrefixes...)
if err != nil {
return nil, err
}
r := chi.NewRouter()
r.Use(resolveOrigin(logger, trusted))
r.MethodNotAllowed(methodNotAllowed)
r.NotFound(notFound)
r.Get("/health", health(logger, version))
auth.Mount(r)
// The session middleware wraps the whole /api/v1 and /media subrouters
// rather than being added with Use: chi skips a subrouter's middleware
// when it has no routes, which would leave it unguarded.
signedIn := requireSession(logger, sessions)
csrf := requireCSRF(logger)
apiV1, media := chi.NewRouter(), chi.NewRouter()
for _, sub := range []*chi.Mux{apiV1, media} {
sub.MethodNotAllowed(methodNotAllowed)
sub.NotFound(notFound)
}
apiV1.Get("/me", me(logger))
apiV1.Get("/settings/household", getHouseholdSettings(logger, settings))
apiV1.Patch("/settings/household", patchHouseholdSettings(logger, settings))
apiV1.Post("/jobs", postJob(logger, runner))
apiV1.Get("/jobs/{id}", getJob(logger, runner))
r.Route("/api", func(r chi.Router) {
r.MethodNotAllowed(methodNotAllowed)
r.NotFound(notFound)
r.Mount("/v1", signedIn(csrf(apiV1)))
})
r.Mount("/media", signedIn(media))
// Catch-all for the PWA. Unknown /auth paths reach the frontend handler,
// which passes them to notFound rather than the SPA fallback.
r.Method(http.MethodGet, "/*", web)
r.Method(http.MethodHead, "/*", web)
return r, nil
}
type errorBody struct {
Error apiError `json:"error"`
}
type apiError struct {
Code string `json:"code"`
Message string `json:"message"`
Retryable bool `json:"retryable"`
}
// apiErrorHandler responds with status and a fixed, non-retryable API error.
func apiErrorHandler(logger *slog.Logger, status int, code, message string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
writeError(w, r, logger, status, code, message)
}
}
type healthResponse struct {
Status string `json:"status"`
Version string `json:"version"`
}
func health(logger *slog.Logger, version string) http.HandlerFunc {
body, err := json.Marshal(healthResponse{Status: "ok", Version: version})
if err != nil {
panic(err) // two string fields cannot fail to marshal
}
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
if _, err := w.Write(body); err != nil {
logger.WarnContext(r.Context(), "write health response", "error", err)
}
}
}
+290
View File
@@ -0,0 +1,290 @@
package server
import (
"context"
"encoding/json"
"io"
"log/slog"
"maps"
"net/http"
"net/http/httptest"
"strings"
"testing"
"testing/fstest"
"github.com/go-chi/chi/v5"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
)
const (
fixtureIndex = "<!doctype html><title>fixture index</title>"
fixtureAsset = "console.log('fixture asset')"
notFoundJSON = `{"error":{"code":"not_found","message":"not found","retryable":false}}`
)
func newTestRouter(t *testing.T, version string) http.Handler {
t.Helper()
return newTestRouterWith(t, version, newTestSettings(t), newTestRunner(t))
}
func newTestRouterWith(t *testing.T, version string, settings *household.SettingsService, runner *jobs.Runner) http.Handler {
t.Helper()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
assets := fstest.MapFS{
"index.html": {Data: []byte(fixtureIndex)},
"assets/index-AbC123.js": {Data: []byte(fixtureAsset)},
}
r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, runner, assets)
if err != nil {
t.Fatal(err)
}
return r
}
// stubAuth mounts a fixed GET /auth/login in place of the real auth flow.
type stubAuth struct{}
func (stubAuth) Mount(r chi.Router) {
r.Get("/auth/login", func(w http.ResponseWriter, _ *http.Request) {
io.WriteString(w, "stub login")
})
}
func TestAuthRoutesMounted(t *testing.T) {
t.Parallel()
router := newTestRouter(t, "dev")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/auth/login", nil))
if rec.Code != http.StatusOK || rec.Body.String() != "stub login" {
t.Errorf("GET /auth/login = %d %q, want the mounted handler", rec.Code, rec.Body.String())
}
rec = httptest.NewRecorder()
router.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/auth/login", nil))
if rec.Code != http.StatusMethodNotAllowed {
t.Errorf("POST /auth/login status = %d, want 405", rec.Code)
}
}
// newTestStore returns a migrated temp database, closed when the test ends.
func newTestStore(t *testing.T) *store.Store {
t.Helper()
ctx := context.Background()
st, err := store.Open(ctx, t.TempDir())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := st.Close(); err != nil {
t.Error(err)
}
})
if err := store.Migrate(ctx, st.Writer, migrations.FS); err != nil {
t.Fatal(err)
}
return st
}
// newTestSettings returns a settings service over a migrated temp database
// seeded with zone America/New_York.
func newTestSettings(t *testing.T) *household.SettingsService {
t.Helper()
st := newTestStore(t)
if err := household.SeedSettings(context.Background(), st, "America/New_York"); err != nil {
t.Fatal(err)
}
return household.NewSettingsService(st)
}
// newTestRunner returns an unstarted job runner over a migrated temp
// database.
func newTestRunner(t *testing.T) *jobs.Runner {
t.Helper()
runner, err := jobs.NewRunner(newTestStore(t), slog.New(slog.NewTextHandler(io.Discard, nil)), 1)
if err != nil {
t.Fatal(err)
}
return runner
}
func TestNewRouterRequiresSettings(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, nil, newTestRunner(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil settings service")
}
}
func TestNewRouterRequiresJobRunner(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), nil, fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil job runner")
}
}
func TestHealth(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(newTestRouter(t, "1.2.3"))
t.Cleanup(srv.Close)
resp, err := http.Get(srv.URL + "/health")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("status = %d, want 200", resp.StatusCode)
}
if ct := resp.Header.Get("Content-Type"); ct != "application/json" {
t.Errorf("Content-Type = %q, want application/json", ct)
}
var got map[string]string
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
t.Fatal(err)
}
want := map[string]string{"status": "ok", "version": "1.2.3"}
if len(got) != len(want) || got["status"] != want["status"] || got["version"] != want["version"] {
t.Errorf("body = %v, want %v", got, want)
}
}
func TestHealthRejectsOtherMethods(t *testing.T) {
t.Parallel()
rec := httptest.NewRecorder()
newTestRouter(t, "dev").ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/health", nil))
if rec.Code != http.StatusMethodNotAllowed {
t.Errorf("status = %d, want 405", rec.Code)
}
if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
t.Errorf("Content-Type = %q, want application/json", ct)
}
var got struct {
Error map[string]any `json:"error"`
}
if err := json.NewDecoder(rec.Body).Decode(&got); err != nil {
t.Fatal(err)
}
want := map[string]any{
"code": "method_not_allowed",
"message": "method not allowed",
"retryable": false,
}
if !maps.Equal(got.Error, want) {
t.Errorf("error body = %v, want %v", got.Error, want)
}
}
func TestReservedPathsNotFound(t *testing.T) {
t.Parallel()
router := newTestRouter(t, "dev")
tests := []struct {
method string
path string
}{
{http.MethodGet, "/api"},
{http.MethodGet, "/api/unknown"},
{http.MethodGet, "/api/v1/unknown"},
{http.MethodPost, "/api/v1/unknown"},
{http.MethodPost, "/api/unknown"},
{http.MethodGet, "/auth/unknown"},
{http.MethodGet, "/media/photo.jpg"},
{http.MethodGet, "/health/deep"},
{http.MethodGet, "/auth/../quests/42"},
{http.MethodGet, "/api%2F..%2Fquests/42"},
}
for _, tt := range tests {
t.Run(tt.method+" "+tt.path, func(t *testing.T) {
t.Parallel()
// Signed in with the CSRF token, so /api/v1 and /media reach
// their routers.
req := httptest.NewRequest(tt.method, tt.path, nil)
req.AddCookie(validSessionCookie)
req.Header.Set(CSRFHeader, testSession.CSRFToken)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404", rec.Code)
}
if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
t.Errorf("Content-Type = %q, want application/json", ct)
}
if got := rec.Body.String(); got != notFoundJSON {
t.Errorf("body = %s, want %s", got, notFoundJSON)
}
})
}
}
func TestFrontend(t *testing.T) {
t.Parallel()
router := newTestRouter(t, "dev")
tests := []struct {
path string
wantBody string
wantCache string
}{
{"/", fixtureIndex, "no-cache"},
{"/assets/index-AbC123.js", fixtureAsset, "public, max-age=31536000, immutable"},
{"/quests/42", fixtureIndex, "no-cache"},
{"/quests/42/edit", fixtureIndex, "no-cache"},
}
for _, tt := range tests {
t.Run(tt.path, func(t *testing.T) {
t.Parallel()
rec := httptest.NewRecorder()
router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, tt.path, nil))
if rec.Code != http.StatusOK {
t.Errorf("status = %d, want 200", rec.Code)
}
if got := rec.Body.String(); got != tt.wantBody {
t.Errorf("body = %q, want %q", got, tt.wantBody)
}
if got := rec.Header().Get("Cache-Control"); got != tt.wantCache {
t.Errorf("Cache-Control = %q, want %q", got, tt.wantCache)
}
})
}
}
func TestHealthNotShadowed(t *testing.T) {
t.Parallel()
rec := httptest.NewRecorder()
newTestRouter(t, "dev").ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/health", nil))
if body := rec.Body.String(); rec.Code != http.StatusOK || strings.Contains(body, fixtureIndex) {
t.Errorf("GET /health = %d %q, want 200 health JSON", rec.Code, body)
}
}
func TestFrontendRejectsOtherMethods(t *testing.T) {
t.Parallel()
rec := httptest.NewRecorder()
newTestRouter(t, "dev").ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/quests/42", nil))
if rec.Code != http.StatusMethodNotAllowed {
t.Errorf("status = %d, want 405", rec.Code)
}
if strings.Contains(rec.Body.String(), fixtureIndex) {
t.Errorf("body = %q, want no index.html", rec.Body.String())
}
}

Some files were not shown because too many files have changed in this diff Show More