Initial commit: PRD, build tooling, and design mockups

Foodster is a self-hosted dinner log and meal suggester for one household.
Stage 1 (eating history) is in development; stage 2 (the suggester) follows
once there is enough history to weight against.

Replace the PRD's original stack (Nuxt, Postgres, Drizzle, Pico CSS) with
Go 1.27, net/http, templ, Datastar and SQLite — one static binary, no
Node.js in the build. Sections 3, 4, 5, 9, 10 and 11 are rewritten to match.

Record the decisions made while reviewing mockups:

- the UI is written in Finnish; PRD, code and comments stay English
- access is one shared password over HTTP Basic rather than open on the LAN
- images are CalVer vYYYYMMDD-N, built with Podman, run under Docker Compose
- registry coordinates live in .env, so no infrastructure detail is
  committed and the MIT publication option stays open

mockups/ holds standalone HTML design studies. log-fi.html is the current
one; the others are superseded exploration kept for reference.
This commit is contained in:
Esa Kataja
2026-09-05 17:35:37 +03:00
commit 6ed047aafd
13 changed files with 2067 additions and 0 deletions
+17
View File
@@ -0,0 +1,17 @@
# Copy to .env and fill in. .env is gitignored — the real registry hostname
# must not end up in the repository.
# Image coordinates. FOODSTER_REPO carries no tag.
FOODSTER_REPO=registry.example.com/you/foodster
FOODSTER_TAG=latest
# Shared household password. The app will not start without it.
FOODSTER_PASSWORD=changeme
# Host port to publish on.
FOODSTER_PORT=8080
# Used for every calendar-day calculation. Set it in development too: under
# UTC the date rolls over three hours late, which is exactly when dinner
# gets logged.
TZ=Europe/Helsinki
+13
View File
@@ -0,0 +1,13 @@
# Local config — holds the registry hostname and the shared password.
.env
# Build output
/foodster
# Generated by `templ generate` during the container build.
*_templ.go
# Local database
*.db
*.db-shm
*.db-wal
+25
View File
@@ -0,0 +1,25 @@
# syntax=docker/dockerfile:1
FROM golang:1.27-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
ARG VERSION=dev
RUN go tool templ generate && \
CGO_ENABLED=0 GOOS=linux go build \
-trimpath \
-ldflags="-s -w -X main.version=${VERSION}" \
-o /foodster ./cmd/foodster
# Nothing but the binary. modernc.org/sqlite is pure Go, so there is no libc
# to carry along, and time/tzdata is compiled in because scratch has no
# zoneinfo.
FROM scratch
COPY --from=build /foodster /foodster
USER 65534:65534
EXPOSE 8080
ENTRYPOINT ["/foodster"]
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Kessinen
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+79
View File
@@ -0,0 +1,79 @@
# Foodster. Run `make` for the target list.
COMPOSE ?= podman compose
BIN := foodster
PKG := ./cmd/foodster
# Registry coordinates, shared password and TZ live here. Gitignored.
ifneq (,$(wildcard .env))
include .env
export
endif
# Generated templ output is excluded — it is not ours to format.
GOFILES = $(shell find . -name '*.go' -not -name '*_templ.go' 2>/dev/null)
.DEFAULT_GOAL := help
.PHONY: help generate build run test lint fix image push release up down logs clean
help: ## Show this help
@grep -hE '^[a-zA-Z_-]+:.*## ' $(MAKEFILE_LIST) \
| awk -F':.*## ' '{printf " \033[1m%-9s\033[0m %s\n", $$1, $$2}'
generate: ## Generate Go from .templ files
go tool templ generate
build: generate ## Build ./foodster
CGO_ENABLED=0 go build -trimpath \
-ldflags="-s -w -X main.version=dev" -o $(BIN) $(PKG)
run: generate ## Run locally on :8080
FOODSTER_PASSWORD=$${FOODSTER_PASSWORD:-dev} \
FOODSTER_DB=$${FOODSTER_DB:-./foodster.db} \
go run $(PKG)
test: generate ## Run tests
go test ./...
lint: ## go vet, gofmt check, golangci-lint when installed
go vet ./...
@bad=$$(gofmt -l $(GOFILES) 2>/dev/null); \
if [ -n "$$bad" ]; then echo "gofmt needed:"; echo "$$bad"; exit 1; fi
@if command -v golangci-lint >/dev/null 2>&1; then golangci-lint run; \
else echo "golangci-lint not installed - skipped"; fi
fix: ## Format Go and templ sources, tidy go.mod
@if [ -n "$(GOFILES)" ]; then gofmt -w $(GOFILES); fi
go tool templ fmt .
go mod tidy
image: ## Build and tag an image as vYYYYMMDD-N. Creates a git tag.
@test -n "$(FOODSTER_REPO)" || { echo "set FOODSTER_REPO in .env"; exit 1; }
@day=$$(date +%Y%m%d); \
tag="v$$day-$$(( $$(git tag -l "v$$day-*" | wc -l) + 1 ))"; \
echo "==> $$tag"; \
git tag "$$tag"; \
podman build --platform linux/amd64 --build-arg VERSION="$$tag" \
-t "$(FOODSTER_REPO):$$tag" -t "$(FOODSTER_REPO):latest" .
push: ## Push the newest tag and :latest
@test -n "$(FOODSTER_REPO)" || { echo "set FOODSTER_REPO in .env"; exit 1; }
@tag=$$(git tag -l 'v*' --sort=-creatordate | head -n1); \
test -n "$$tag" || { echo "no tags yet - run make image"; exit 1; }; \
podman push "$(FOODSTER_REPO):$$tag"; \
podman push "$(FOODSTER_REPO):latest"
release: image push ## Build, tag and push in one go
up: ## Start the stack
$(COMPOSE) up -d
down: ## Stop the stack
$(COMPOSE) down
logs: ## Follow app logs
$(COMPOSE) logs -f app
clean: ## Remove the binary and generated templates
rm -f $(BIN)
find . -name '*_templ.go' -delete
+366
View File
@@ -0,0 +1,366 @@
# Foodster — Product Requirements Document
## 1. Overview
Foodster is a self-hosted web app that helps a single household decide what to
eat for the next seven dinners. Given a database of meals the family already
likes, the app proposes a week's worth of dinners (main dish, optional side
dishes) using their eating history as input. The proposal is a *suggestion*,
not a schedule — the user can swap items, ignore the suggestions entirely, or
log what was actually eaten after the fact.
The project is built for one family's use. If it matures into something
polished, it may be released as FOSS under MIT.
## 2. Goals
- Reduce weekly "what's for dinner?" decision fatigue.
- Keep meal variety high while still leaning on family favorites.
- Capture a history of what was actually eaten (not what was planned) so the
suggester gets better over time.
- Stay boring to run: a single `docker compose up` on a home server.
## 3. Non-goals (MVP)
- No grocery list generation (possible future add-on).
- No per-recipe ingredient tracking — meals are just names.
- No calendar/scheduling with times, reminders, or calendar exports.
- No user accounts, per-person profiles, or permissions. A single shared
password gates the whole app (§9).
- No nutrition tracking, calorie counting, or dietary-goal optimization.
- No mobile-native apps. Web only (mobile-friendly responsive is enough).
- No external internet exposure. Runs on the home LAN.
## 4. Delivery stages
The app ships in two stages. The suggester needs real eating history to
produce useful weightings, so history capture comes first and the family
seeds the database by logging meals for a few weeks before the suggester
has anything meaningful to do.
### Stage 1 — Eating history
Everything needed to manage the meal catalog and log what was eaten.
Concretely:
- Domain model: main dishes (with `categories` and `has_sides`), side
dishes, meal log entries (§6).
- Admin view: add / edit / delete mains and sides, JSON mass import (§7.3).
- Meal log view ("Mark"): date picker, main + sides selector, recent
entries list with edit/delete (§7.2).
- Deployment scaffolding: `compose.yaml`, SQLite, schema applied on app
start (§9, §10).
At the end of stage 1 the app is useful as a "what did we eat?" journal
even without any suggestions. There is no Plan view yet.
### Stage 2 — Meal suggester
Layered on top of the stage 1 foundation:
- Current suggestion cache (§6) persisted server-side.
- Plan view with seven suggested meals, two swap modes per slot, and
regenerate-all (§7.1).
- Suggestion algorithm: cooldown, category coverage with multi-category
mains, favorites-biased weighting with a floor, side attachment rules
(§8).
- Settings page for the cooldown window (§7.3).
Stage 2 can start once stage 1 has collected enough history for the
weighting to be meaningful (a few weeks of logged meals).
## 5. Users
A single household. One shared instance, no per-person accounts. Anyone on the
home network who knows the shared password can open the app and interact with
it.
The interface is written in **Finnish** — every user of this instance is a
Finnish speaker, so there is no i18n layer and no language switcher. Strings
are hardcoded in the templates. This PRD, the source, and code comments stay
in English.
## 6. Domain model
### Main dish (stage 1)
- `id`
- `name` — stored in Title Case (server normalizes on save).
- `categories` — non-empty set drawn from `meat`, `chicken`, `fish`,
`vegetarian`. Most mains will have a single category. Dishes where each
diner builds their own plate from a shared spread (e.g., tortillas,
build-your-own pizza) list every category present at the meal; one
appearance of such a main simultaneously covers **all** its listed
categories for that week, because all the proteins are actually served at
the same meal.
- `has_sides` — boolean. If `false`, the suggester never attaches side dishes
to this main (covers casseroles, one-pot meals, etc.).
- `created_at`
- `deleted_at` — nullable. Soft-delete marker: soft-deleted mains are hidden
from the admin list and all pickers, but remain resolvable when displaying
historical log entries.
### Side dish (stage 1)
- `id`
- `name` — stored in Title Case.
- `created_at`
- `deleted_at` — nullable. Same soft-delete semantics as mains.
Side dishes live in their own table and have no category. The pool is
expected to stay small.
### Meal log entry ("what was actually eaten") (stage 1)
- `id`
- `date` — SQL `DATE`, day granularity only. There is no time-of-day field
and no timezone concern; the cooldown and display logic compare dates as
pure calendar days.
- `main_dish_id`
- `side_dish_ids` (zero or more)
- `created_at`
A unique constraint enforces **one log entry per date** — editing an
existing entry replaces it; no second meal can be logged for the same day.
The log entry is the source of truth for history. Foreign keys to mains
and sides are preserved even after the referenced item is soft-deleted, so
old entries still display their meal names.
### Current suggestion (cache) (stage 2)
To survive accidental tab/browser closes and crashes, the seven-meal
suggestion is persisted server-side until the user regenerates it. A single
row keyed by "current suggestion" is enough — there's only ever one active
list. Reopening the app shows the same seven meals until the user explicitly
regenerates or swaps.
- `id`
- `slots` — ordered list of `{ main_dish_id, side_dish_ids[] }`
- `updated_at`
## 7. Features
### 7.1 Suggestion view ("Plan") (stage 2)
- Displays seven suggested meals.
- Each meal = one main dish + zero or more side dishes (sides only if the
main's `has_sides` flag is true).
- Each meal position has two swap controls:
- **Swap (same category)** — default. Replaces the main with a different
main of the same category.
- **Swap (any category)** — replaces the main with any eligible main
regardless of category. May break category coverage; see §8.1.
- Both honor the cooldown.
- A **regenerate all** action produces a fresh list of seven and overwrites
the cached current suggestion.
- No dates, no ordering beyond "these are the next seven." Order is not
tied to calendar days.
- The suggestion list is the front page / default view, and is restored from
the server-side cache on page load so a browser crash doesn't lose it.
### 7.2 Meal log view ("Mark") (stage 1)
- Form to record what was actually eaten:
- Date picker (defaults to today).
- Main dish selector.
- Side dish selector (multi-select, optional).
- List of recent log entries with edit/delete.
- Entries are editable and deletable — history is not sacred, typos happen.
### 7.3 Admin (stage 1 — catalog; stage 2 — settings)
- Add / edit / delete main dishes (with categories and `has_sides`). Names
are normalized to Title Case on save. Delete is a soft-delete.
- Add / edit / delete side dishes. Same Title Case normalization and
soft-delete behavior.
- Duplicate detection on create/edit is **case-insensitive** against the
set of non-soft-deleted items ("chicken curry" and "Chicken Curry" are
treated as the same name).
- **Mass import** of mains and sides via JSON paste or file upload.
- JSON shape (proposed):
```json
{
"mains": [
{"name": "Spaghetti Bolognese", "categories": ["meat"], "has_sides": false},
{"name": "Chicken Curry", "categories": ["chicken"], "has_sides": true},
{"name": "Tortillas", "categories": ["meat", "chicken", "fish", "vegetarian"], "has_sides": false}
],
"sides": [
{"name": "Green salad"},
{"name": "Rice"}
]
}
```
- Import is **best-effort, not atomic**: valid rows are inserted, invalid
rows (bad categories, missing fields, duplicates) are skipped and
reported back to the user in a per-row summary. Names are Title-Cased
on import; duplicate detection is case-insensitive.
- Settings page for the cooldown window (default **14 days**, configurable).
*(Stage 2 — only relevant once the suggester exists.)*
## 8. Suggestion algorithm (stage 2)
Produce seven mains, then attach sides per meal.
### 8.1 Hard constraints (mains)
1. **Cooldown**: exclude any main dish that appears in the meal log within
the last *N* days, where *N* is configurable (default 14).
2. **Category coverage**: the union of all seven mains' `categories` sets
must include `meat`, `chicken`, `fish`, and `vegetarian`. A
multi-category main covers every category in its set at once, so a
single tortillas appearance can satisfy several requirements
simultaneously.
3. If a category cannot be covered by any eligible main (including
multi-category ones) after cooldown, the UI surfaces a clear warning and
that category is **excluded from this round's list**. The seven meals
are drawn from the remaining mains. Cooldown is never silently relaxed.
### 8.2 Weighting (mains)
Within the eligible pool, each main's probability of being picked is
proportional to how often it appears in the meal log — favorites are picked
**more often**, not less. This is a deliberate inversion of the typical
"recommend what you haven't tried" heuristic: the household wants to keep
eating what they already like.
To prevent the list from being dominated by the top few favorites, the
weight is clamped so that no eligible main has a zero or near-zero
probability. Concretely (MVP):
```
weight(main) = max(base_weight, times_eaten)
```
where `base_weight` is a small positive floor (e.g., `1`). Sampling is
without replacement within the seven-meal list.
This is tunable later; the important invariant is **no dead meals**.
### 8.3 Sides
- Only mains with `has_sides = true` get sides attached. Mains with
`has_sides = false` (casseroles, one-pots) always get zero sides.
- For eligible mains, pick 12 side dishes uniformly at random from the
side pool. No cooldown, no frequency weighting on sides.
### 8.4 Swap action
Two swap modes, exposed as separate controls on each slot:
- **Swap within same category** (default). The replacement main's
`categories` must be a superset of the slot's current `categories`, so
the week's coverage cannot be weakened by the swap.
- For a single-category slot, any main sharing that category qualifies.
- For a multi-category slot (e.g., tortillas), only mains covering the
same full set qualify — often only other wildcards. If nothing
qualifies, the button is disabled and the user falls back to "any
category".
- **Swap any category**. The replacement can be any eligible main. If this
breaks coverage (e.g., removes the only fish), the UI warns but allows
it — the user is explicitly asking for freedom.
Both modes honor the cooldown. After a swap, the cached current suggestion
is updated in place.
## 9. Tech stack
The whole app is one static Go binary. There is no Node.js anywhere in the
build and no asset bundler.
- **Language**: Go 1.27. No `toolchain` directive is pinned in `go.mod`, so
moving to a newer Go is a one-line change.
- **HTTP**: standard library `net/http` with `http.ServeMux`. Its method +
path patterns cover the ~15 routes this app needs; no third-party router.
- **Views**: [templ](https://templ.guide) components, rendered server-side.
`templ` is a `go tool` dependency and runs at build time; the generated
`*_templ.go` files are not committed.
- **Interactivity**: [Datastar](https://data-star.dev). One ~11 kB script
supplies both the reactive client state and the server-driven DOM patching,
replacing what would otherwise be htmx *and* Alpine.js. Its reference SDK
is Go (`github.com/starfederation/datastar-go`): handlers read client state
with `datastar.ReadSignals` and reply over SSE with `PatchElements`.
- **Styling**: hand-written CSS, one file, no framework and no build step.
Light and dark themes come from `color-scheme: light dark` plus
`light-dark()` custom properties, which also gets native form controls,
scrollbars, and focus rings themed for free. Explicitly not Pico, not
Tailwind, not SCSS.
- **Fonts**: self-hosted `woff2` under `static/`. No external font CDN — the
app has to work with no internet access at all.
- **Database**: SQLite through `modernc.org/sqlite`, which is pure Go and so
keeps `CGO_ENABLED=0` and a fully static binary. One file on a named
volume; there is no separate database service. A single household writing
one row per day does not need Postgres.
- **Data access**: `database/sql` and hand-written SQL. No ORM.
- **Schema**: a single `schema.sql` embedded with `embed.FS` and applied on
startup with `CREATE TABLE IF NOT EXISTS`. A migration tool arrives the
first time a live table genuinely needs altering, not before.
- **Time**: the `TZ` environment variable, defaulting to `Europe/Helsinki`,
loaded via `time.LoadLocation` and fatal on a bad value — a silent fallback
to UTC would shift logged dinners to the wrong calendar day. `time/tzdata`
is imported because the runtime image carries no zoneinfo. All date logic
uses that location explicitly and never `time.Local`.
- **Auth**: HTTP Basic with one shared household password read from
`FOODSTER_PASSWORD`; the username is ignored. Compared using
`subtle.ConstantTimeCompare` over SHA-256 digests so neither the value nor
its length leaks through timing. A failed attempt sleeps 500 ms, which is
throttle enough for a LAN-only app. Note that Basic credentials travel in
cleartext over plain HTTP — acceptable on a private LAN, and the reason to
add TLS if this is ever reachable from anywhere else. `/healthz` is the
only route outside auth.
- **Containers**: built with Podman in development, run under Docker Compose
in production. Images are OCI, so one image works with both engines.
Explicitly *not* React.
## 10. Deployment
Images are built locally, pushed to a private container registry, then pulled
on the server and run with Docker Compose.
- **Versioning**: CalVer `vYYYYMMDD-N`, where `N` is the Nth build of that
day. `make image` derives `N` by counting the day's existing git tags,
creates the new tag, and bakes the version into the binary through
`-ldflags -X main.version`. `make release` builds, tags and pushes.
- **Tooling**: a `Makefile` is the single entry point — `make` on its own
lists every target. Build, test, lint, format, image and compose commands
all live there rather than in loose scripts.
- **Image**: a two-stage `Containerfile`. `golang:1.27-alpine` compiles a
static binary; the runtime stage is `FROM scratch` holding only that
binary, running as UID 65534.
- **Compose**: a single service. No database container — SQLite lives at
`/data/foodster.db` on a named volume. `restart: unless-stopped`.
- **Configuration**, entirely through environment variables (see
`.env.example`):
- `FOODSTER_REPO` and `FOODSTER_TAG` — image coordinates.
- `FOODSTER_PASSWORD` — the shared password. Required; the app refuses to
start without it.
- `FOODSTER_DB` — database file path, default `/data/foodster.db`.
- `TZ` — default `Europe/Helsinki`.
- The registry hostname exists only in `.env`, which is gitignored, because
§11 leaves open the possibility of publishing this repository.
- **Health**: `GET /healthz` returns the build version and is exempt from
auth. There is no Docker `HEALTHCHECK` directive, because a `scratch` image
has no shell to run one and `restart: unless-stopped` already covers a dead
process. Adding one would mean giving the binary a `-healthcheck` flag that
calls its own endpoint.
- Schema is applied on app start.
- No internet exposure; the server binds to the LAN.
## 11. Licensing
MIT from the outset — see `LICENSE`, copyright Kessinen. The repository stays
private during build-out, so releasing it later is a matter of flipping
visibility rather than relicensing.
This is why no infrastructure detail (registry hostname, server name, port
mapping) may be committed: those live in `.env`, which is gitignored.
## 12. Open questions / future work
- Grocery list generation from the seven-meal suggestion (requires adding
ingredients to the meal model — out of scope for MVP).
- Tags on mains (e.g., "quick", "summer", "comfort") to filter suggestions.
- Seasonal biasing (e.g., soups in winter).
- Optional "pin" on a suggested slot so `regenerate all` doesn't replace it.
- Backup / export of the meal log (JSON dump).
- Telemetry-free analytics: a stats page showing most-eaten mains, category
distribution over time, etc.
+104
View File
@@ -0,0 +1,104 @@
# Foodster
A self-hosted dinner log and meal suggester for a single household. Record
what the family actually ate, and — later — let the app propose seven dinners
drawn from that history.
The interface is in Finnish. The code, comments and documentation are in
English.
See [PRD.md](PRD.md) for the full specification.
## Status
**Stage 1 — eating history: in development.** The meal catalog and the daily
log come first, because the suggester is worthless until there are a few
weeks of real history to weight against.
Stage 2 — the seven-meal suggester — starts once that history exists.
## Stack
One static Go binary. No Node.js, no bundler, no separate database server.
| | |
|---|---|
| Language | Go 1.27 |
| HTTP | stdlib `net/http` + `http.ServeMux` |
| Views | [templ](https://templ.guide), server-rendered |
| Interactivity | [Datastar](https://data-star.dev) — signals and DOM patching in one ~11 kB script |
| Styling | hand-written CSS, `light-dark()` for themes |
| Database | SQLite via `modernc.org/sqlite` (pure Go) |
| Auth | HTTP Basic, one shared household password |
| Runtime image | `FROM scratch` |
## Quick start
```sh
cp .env.example .env # then edit it
make run # http://localhost:8080
```
`make` on its own lists every target:
```
make fix gofmt, templ fmt, go mod tidy
make lint go vet, gofmt check, golangci-lint when installed
make test go test ./...
make build ./foodster
make image build and tag vYYYYMMDD-N (creates a git tag)
make push push the newest tag and :latest
make release image + push
make up/down/logs compose
```
## Configuration
Everything is environment variables. `.env` is gitignored; start from
`.env.example`.
| Variable | Default | Purpose |
|---|---|---|
| `FOODSTER_PASSWORD` | *required* | Shared password. The app will not start without it. |
| `FOODSTER_DB` | `/data/foodster.db` | SQLite file path. |
| `TZ` | `Europe/Helsinki` | Used for every calendar-day calculation. |
| `FOODSTER_REPO` | *required to build* | Image repository, no tag. |
| `FOODSTER_TAG` | `latest` | Tag to run under compose. |
| `FOODSTER_PORT` | `8080` | Host port to publish. |
Set `TZ` in development too. Under UTC the date rolls over three hours late,
which is exactly when dinner gets logged.
## Deployment
Images are built with Podman and run under Docker Compose on a LAN server.
They are OCI images, so either engine works.
```sh
make release # build, tag, push
# on the server:
docker compose pull && docker compose up -d
```
Versions are CalVer — `vYYYYMMDD-N`, where `N` is the Nth build that day. The
running version is served at `GET /healthz`, which is the one route outside
authentication.
There is no database container. SQLite lives on a named volume, so a backup
is a file copy.
## Security
Access is a single shared password over HTTP Basic — no accounts, no
sessions. Credentials are compared in constant time, but Basic auth sends
them in cleartext, so this belongs on a private LAN. Put TLS in front of it
before exposing it anywhere else.
## Mockups
`mockups/` holds standalone HTML design studies. Open them directly in a
browser; they are references, not part of the build.
## License
[MIT](LICENSE).
+15
View File
@@ -0,0 +1,15 @@
services:
app:
image: ${FOODSTER_REPO:?set FOODSTER_REPO in .env}:${FOODSTER_TAG:-latest}
restart: unless-stopped
ports:
- "${FOODSTER_PORT:-8080}:8080"
environment:
FOODSTER_PASSWORD: ${FOODSTER_PASSWORD:?set FOODSTER_PASSWORD in .env}
FOODSTER_DB: /data/foodster.db
TZ: ${TZ:-Europe/Helsinki}
volumes:
- foodster-data:/data
volumes:
foodster-data:
+229
View File
@@ -0,0 +1,229 @@
<!doctype html>
<html lang="en" data-theme="auto">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Foodster — A · Chit rail</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Martian+Mono:wght@300;500;700&family=Public+Sans:wght@400;600&family=Saira+Condensed:wght@600;700&display=swap" rel="stylesheet">
<style>
:root{
color-scheme: light dark;
--ground: light-dark(#E7E8E3, #131417);
--chit: light-dark(#FBFBF8, #1F2126);
--ink: light-dark(#15171B, #E7E8E2);
--muted: light-dark(#6E7269, #8C9088);
--rail: light-dark(#A8ACA2, #4A4E54);
--line: light-dark(#D3D5CD, #32353B);
--stamp: light-dark(#1F3FBF, #7D97FF);
--meat: light-dark(#B23A2E, #E2705F);
--chicken: light-dark(#A97511, #E0A93A);
--fish: light-dark(#1F6FA8, #64ACDC);
--vegetarian:light-dark(#3C7A45, #7CBB86);
}
html[data-theme="light"]{ color-scheme: only light; }
html[data-theme="dark"] { color-scheme: only dark; }
*{box-sizing:border-box;}
body{
margin:0; background:var(--ground); color:var(--ink);
font-family:"Public Sans",system-ui,sans-serif; font-size:16px; line-height:1.5;
-webkit-font-smoothing:antialiased;
}
.wrap{max-width:1180px; margin:0 auto; padding:0 20px 80px;}
/* ---- masthead ---- */
.top{display:flex; align-items:baseline; gap:20px; flex-wrap:wrap;
padding:26px 0 20px; border-bottom:1px solid var(--line);}
.logo{font-family:"Martian Mono",monospace; font-weight:700; font-size:15px;
letter-spacing:.06em; text-transform:uppercase;}
.logo b{color:var(--stamp); font-weight:700;}
nav{display:flex; gap:2px; margin-left:auto;}
nav a{font-family:"Martian Mono",monospace; font-size:11px; letter-spacing:.04em;
text-transform:uppercase; color:var(--muted); text-decoration:none;
padding:7px 12px; border:1px solid transparent; border-radius:2px;}
nav a[aria-current]{color:var(--ink); border-color:var(--line); background:var(--chit);}
nav a:hover{color:var(--ink);}
.themebtn{font-family:"Martian Mono",monospace; font-size:11px; letter-spacing:.04em;
text-transform:uppercase; background:none; border:1px solid var(--line);
color:var(--muted); padding:7px 10px; border-radius:2px; cursor:pointer;}
.themebtn:hover{color:var(--ink);}
/* ---- header ---- */
header.lede{padding:44px 0 30px; display:flex; align-items:flex-end; gap:28px; flex-wrap:wrap;}
h1{font-family:"Saira Condensed",sans-serif; font-weight:700; font-size:clamp(46px,8vw,86px);
line-height:.9; letter-spacing:-.015em; margin:0; text-transform:uppercase;}
.lede p{margin:0 0 6px; max-width:34ch; color:var(--muted); font-size:15px;}
.regen{margin-left:auto; font-family:"Martian Mono",monospace; font-size:11px;
letter-spacing:.05em; text-transform:uppercase; background:var(--stamp); color:#fff;
border:0; padding:13px 20px; border-radius:2px; cursor:pointer;}
.regen:hover{filter:brightness(1.12);}
/* ---- warning ---- */
.notice{display:flex; gap:12px; align-items:flex-start; margin:0 0 34px;
padding:13px 16px; background:var(--chit); border-left:3px solid var(--meat);
border-radius:0 2px 2px 0; font-size:14px;}
.notice b{font-family:"Martian Mono",monospace; font-size:11px; letter-spacing:.05em;
text-transform:uppercase; color:var(--meat); white-space:nowrap; padding-top:2px;}
/* ---- the rail ---- */
.rail{position:relative; padding-top:22px;}
.rail::before{content:""; position:absolute; top:14px; left:-12px; right:-12px; height:3px;
background:var(--rail); border-radius:2px;}
.chits{display:grid; gap:18px; grid-template-columns:repeat(auto-fill,minmax(248px,1fr));}
.chit{position:relative; background:var(--chit); border:1px solid var(--line);
border-top:0; border-radius:0 0 3px 3px; padding:26px 16px 14px;}
.chit::before{content:""; position:absolute; top:0; left:0; right:0; height:7px;
background:radial-gradient(circle at 5px 0, var(--ground) 4px, transparent 4.5px) repeat-x;
background-size:12px 7px;}
.chit::after{content:""; position:absolute; top:-14px; left:50%; margin-left:-5px;
width:10px; height:18px; background:var(--rail); border-radius:2px;}
.cat{display:flex; gap:5px; margin:0 0 12px;}
.cat span{font-family:"Martian Mono",monospace; font-size:9px; font-weight:500;
letter-spacing:.08em; text-transform:uppercase; color:#fff; padding:3px 6px; border-radius:2px;}
.c-meat{background:var(--meat);} .c-chicken{background:var(--chicken);}
.c-fish{background:var(--fish);} .c-vegetarian{background:var(--vegetarian);}
.dish{font-family:"Saira Condensed",sans-serif; font-weight:600; font-size:29px;
line-height:1.02; margin:0 0 10px; text-transform:uppercase; letter-spacing:-.005em;}
.sides{list-style:none; margin:0 0 14px; padding:12px 0 0; border-top:1px dashed var(--line);
font-size:14px; color:var(--ink);}
.sides li{display:flex; gap:8px;}
.sides li::before{content:"+"; color:var(--muted);}
.nosides{margin:0 0 14px; padding:12px 0 0; border-top:1px dashed var(--line);
font-family:"Martian Mono",monospace; font-size:10px; letter-spacing:.04em;
text-transform:uppercase; color:var(--muted);}
.last{font-family:"Martian Mono",monospace; font-size:10px; letter-spacing:.02em;
color:var(--muted); margin:0 0 14px;}
.swaps{display:flex; gap:6px;}
.swaps button{flex:1; font-family:"Martian Mono",monospace; font-size:10px; letter-spacing:.03em;
text-transform:uppercase; background:none; color:var(--muted);
border:1px solid var(--line); border-radius:2px; padding:8px 4px; cursor:pointer;}
.swaps button:hover:not(:disabled){color:var(--stamp); border-color:var(--stamp);}
.swaps button:disabled{opacity:.35; cursor:not-allowed;}
/* ---- log strip ---- */
.logstrip{margin-top:56px; border-top:1px solid var(--line); padding-top:20px;}
.logstrip h2{font-family:"Martian Mono",monospace; font-size:11px; letter-spacing:.06em;
text-transform:uppercase; color:var(--muted); margin:0 0 14px; font-weight:500;}
.logrow{display:flex; gap:16px; align-items:baseline; padding:9px 0;
border-bottom:1px solid var(--line); font-size:15px;}
.logrow time{font-family:"Martian Mono",monospace; font-size:11px; color:var(--muted);
min-width:74px;}
.logrow .w{width:8px; height:8px; border-radius:1px; align-self:center;}
.logrow em{color:var(--muted); font-style:normal; font-size:13px;}
:focus-visible{outline:2px solid var(--stamp); outline-offset:2px;}
@media (max-width:560px){ .rail::before,.chit::after{display:none;} }
@media (prefers-reduced-motion:reduce){ *{transition:none!important;} }
</style>
</head>
<body>
<div class="wrap">
<div class="top">
<div class="logo">Food<b>ster</b></div>
<nav>
<a href="#" aria-current="page">Plan</a>
<a href="#">Log</a>
<a href="#">Catalog</a>
</nav>
<button class="themebtn" id="t">Theme: auto</button>
</div>
<header class="lede">
<h1>Seven<br>dinners</h1>
<p>Drawn from what this house actually eats. Swap anything that doesn't appeal — nothing here is a schedule.</p>
<button class="regen">New seven</button>
</header>
<div class="notice">
<b>No fish</b>
<span>Every fish dish was eaten in the last 14 days. Fish sits this week out — shorten the cooldown in Catalog if that's too strict.</span>
</div>
<div class="rail">
<div class="chits">
<article class="chit">
<div class="cat"><span class="c-meat">meat</span></div>
<h3 class="dish">Jauheliha&shy;kastike</h3>
<ul class="sides"><li>Perunamuusi</li><li>Vihersalaatti</li></ul>
<p class="last">last eaten 23 days ago</p>
<div class="swaps"><button>Swap similar</button><button>Swap anything</button></div>
</article>
<article class="chit">
<div class="cat"><span class="c-chicken">chicken</span></div>
<h3 class="dish">Broileri&shy;kastike</h3>
<ul class="sides"><li>Riisi</li></ul>
<p class="last">last eaten 31 days ago</p>
<div class="swaps"><button>Swap similar</button><button>Swap anything</button></div>
</article>
<article class="chit">
<div class="cat">
<span class="c-meat">meat</span><span class="c-chicken">chicken</span>
<span class="c-fish">fish</span><span class="c-vegetarian">veg</span>
</div>
<h3 class="dish">Tortillat</h3>
<p class="nosides">served on its own</p>
<p class="last">last eaten 40 days ago</p>
<div class="swaps">
<button disabled title="No other dish covers all four categories">Swap similar</button>
<button>Swap anything</button>
</div>
</article>
<article class="chit">
<div class="cat"><span class="c-vegetarian">vegetarian</span></div>
<h3 class="dish">Kasvis&shy;pyörykät</h3>
<ul class="sides"><li>Perunamuusi</li><li>Höyrytetyt Porkkanat</li></ul>
<p class="last">last eaten 52 days ago</p>
<div class="swaps"><button>Swap similar</button><button>Swap anything</button></div>
</article>
<article class="chit">
<div class="cat"><span class="c-meat">meat</span></div>
<h3 class="dish">Makaroni&shy;laatikko</h3>
<p class="nosides">served on its own</p>
<p class="last">last eaten 19 days ago</p>
<div class="swaps"><button>Swap similar</button><button>Swap anything</button></div>
</article>
<article class="chit">
<div class="cat"><span class="c-vegetarian">vegetarian</span></div>
<h3 class="dish">Hernekeitto</h3>
<p class="nosides">served on its own</p>
<p class="last">last eaten 27 days ago</p>
<div class="swaps"><button>Swap similar</button><button>Swap anything</button></div>
</article>
<article class="chit">
<div class="cat"><span class="c-meat">meat</span></div>
<h3 class="dish">Lihapullat</h3>
<ul class="sides"><li>Perunamuusi</li><li>Vihersalaatti</li></ul>
<p class="last">last eaten 16 days ago</p>
<div class="swaps"><button>Swap similar</button><button>Swap anything</button></div>
</article>
</div>
</div>
<section class="logstrip">
<h2>Recently eaten</h2>
<div class="logrow"><time>Thu 3 Sep</time><i class="w c-fish"></i><span>Lohikeitto</span><em>Ruisleipä</em></div>
<div class="logrow"><time>Wed 2 Sep</time><i class="w c-chicken"></i><span>Kanacurry</span><em>Riisi</em></div>
<div class="logrow"><time>Tue 1 Sep</time><i class="w c-meat"></i><span>Uunimakkara</span><em>Perunamuusi · Vihersalaatti</em></div>
</section>
</div>
<script>
const t=document.getElementById('t'), h=document.documentElement, m=['auto','light','dark'];
t.onclick=()=>{const n=m[(m.indexOf(h.dataset.theme)+1)%3]; h.dataset.theme=n; t.textContent='Theme: '+n;};
</script>
</body>
</html>
+214
View File
@@ -0,0 +1,214 @@
<!doctype html>
<html lang="en" data-theme="auto">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Foodster — B · Enamel tile</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,[email protected],500;12..96,700;12..96,800&family=Karla:wght@400;500;700&display=swap" rel="stylesheet">
<style>
:root{
color-scheme: light dark;
--grout: light-dark(#D9D6CE, #191A1C);
--card: light-dark(#FFFFFF, #232529);
--ink: light-dark(#1B1C18, #EDEDE8);
--muted: light-dark(#71736B, #94968D);
--line: light-dark(#C7C4BB, #32343A);
--glaze: light-dark(rgba(255,255,255,.30), rgba(255,255,255,.10));
--meat: light-dark(#A8452F, #8E3826);
--chicken: light-dark(#C68A16, #A87211);
--fish: light-dark(#2C6B92, #23566F);
--vegetarian:light-dark(#4E7A38, #3F6430);
--on-tile:#FFFCF4;
}
html[data-theme="light"]{ color-scheme: only light; }
html[data-theme="dark"] { color-scheme: only dark; }
*{box-sizing:border-box;}
body{margin:0; background:var(--grout); color:var(--ink);
font-family:Karla,system-ui,sans-serif; font-size:16px; line-height:1.5;
-webkit-font-smoothing:antialiased;}
.wrap{max-width:1120px; margin:0 auto; padding:0 20px 80px;}
.top{display:flex; align-items:center; gap:18px; flex-wrap:wrap; padding:22px 0;}
.logo{font-family:"Bricolage Grotesque",sans-serif; font-weight:800; font-size:22px;
letter-spacing:-.03em;}
nav{display:flex; gap:4px; margin-left:auto;}
nav a{font-size:14px; font-weight:500; color:var(--muted); text-decoration:none;
padding:7px 14px; border-radius:999px;}
nav a[aria-current]{background:var(--card); color:var(--ink);}
nav a:hover{color:var(--ink);}
.themebtn{font-family:Karla,sans-serif; font-size:13px; background:var(--card);
border:1px solid var(--line); color:var(--muted); padding:7px 13px;
border-radius:999px; cursor:pointer;}
.themebtn:hover{color:var(--ink);}
header.lede{padding:34px 0 22px; max-width:44ch;}
h1{font-family:"Bricolage Grotesque",sans-serif; font-weight:700; font-size:clamp(38px,6vw,60px);
line-height:1.0; letter-spacing:-.035em; margin:0 0 12px;}
.lede p{margin:0; color:var(--muted); font-size:16px;}
/* ---- signature: coverage bar ---- */
.coverage{margin:26px 0 8px;}
.covbar{display:flex; height:10px; border-radius:3px; overflow:hidden; gap:2px;}
.covbar i{display:block;}
.covkey{display:flex; gap:16px; flex-wrap:wrap; margin-top:10px;
font-size:13px; color:var(--muted);}
.covkey span{display:flex; align-items:center; gap:7px;}
.covkey b{width:9px; height:9px; border-radius:2px;}
.covkey em{font-style:normal; color:var(--ink); font-weight:500;}
.actions{display:flex; gap:10px; margin:26px 0 22px; flex-wrap:wrap;}
.regen{font-family:Karla,sans-serif; font-weight:700; font-size:15px; background:var(--ink);
color:var(--grout); border:0; padding:12px 22px; border-radius:8px; cursor:pointer;}
.regen:hover{opacity:.86;}
/* ---- tiles ---- */
.tiles{display:grid; gap:6px; grid-template-columns:repeat(auto-fill,minmax(232px,1fr));}
.tile{position:relative; border-radius:5px; padding:20px 18px 16px; color:var(--on-tile);
min-height:196px; display:flex; flex-direction:column; overflow:hidden;}
.tile::before{content:""; position:absolute; inset:0;
background:linear-gradient(158deg, var(--glaze) 0%, transparent 46%); pointer-events:none;}
.t-meat{background:var(--meat);} .t-chicken{background:var(--chicken);}
.t-fish{background:var(--fish);} .t-vegetarian{background:var(--vegetarian);}
.tile.wild{background:linear-gradient(112deg,
var(--meat) 0 25%, var(--chicken) 25% 50%, var(--fish) 50% 75%, var(--vegetarian) 75% 100%);}
.tag{font-size:11px; font-weight:700; letter-spacing:.09em; text-transform:uppercase;
opacity:.82; margin:0 0 auto;}
.dish{font-family:"Bricolage Grotesque",sans-serif; font-weight:700; font-size:27px;
line-height:1.05; letter-spacing:-.03em; margin:16px 0 8px;}
.sides{margin:0 0 12px; font-size:14px; opacity:.88;}
.last{font-size:12px; opacity:.7; margin:0 0 14px;}
.swaps{display:flex; gap:6px; position:relative;}
.swaps button{flex:1; font-family:Karla,sans-serif; font-size:12px; font-weight:700;
background:rgba(255,255,255,.16); color:var(--on-tile); border:0;
padding:9px 6px; border-radius:6px; cursor:pointer; backdrop-filter:blur(2px);}
.swaps button:hover:not(:disabled){background:rgba(255,255,255,.30);}
.swaps button:disabled{opacity:.4; cursor:not-allowed;}
/* ---- log ---- */
.logstrip{margin-top:44px;}
.logstrip h2{font-family:"Bricolage Grotesque",sans-serif; font-weight:700; font-size:19px;
letter-spacing:-.02em; margin:0 0 12px;}
.logrow{display:flex; gap:14px; align-items:center; background:var(--card);
border-radius:7px; padding:11px 15px; margin-bottom:5px; font-size:15px;}
.logrow time{color:var(--muted); font-size:13px; min-width:78px;}
.logrow i{width:10px; height:10px; border-radius:2px;}
.logrow em{color:var(--muted); font-style:normal; font-size:13px; margin-left:auto;}
.k-meat{background:var(--meat);} .k-chicken{background:var(--chicken);}
.k-fish{background:var(--fish);} .k-vegetarian{background:var(--vegetarian);}
:focus-visible{outline:2.5px solid var(--ink); outline-offset:2px;}
@media (prefers-reduced-motion:reduce){ *{transition:none!important;} }
</style>
</head>
<body>
<div class="wrap">
<div class="top">
<div class="logo">Foodster</div>
<nav><a href="#" aria-current="page">Plan</a><a href="#">Log</a><a href="#">Catalog</a></nav>
<button class="themebtn" id="t">Theme: auto</button>
</div>
<header class="lede">
<h1>Seven dinners, no schedule.</h1>
<p>Pick any of these on any night. Swap what doesn't appeal.</p>
</header>
<div class="coverage">
<div class="covbar" role="img" aria-label="This week covers meat, chicken, fish and vegetarian">
<i class="t-meat" style="flex:3"></i>
<i class="t-chicken" style="flex:1"></i>
<i class="t-fish" style="flex:1"></i>
<i class="t-vegetarian"style="flex:2"></i>
</div>
<div class="covkey">
<span><b class="t-meat"></b><em>Meat</em> ×3</span>
<span><b class="t-chicken"></b><em>Chicken</em> ×1</span>
<span><b class="t-fish"></b><em>Fish</em> ×1</span>
<span><b class="t-vegetarian"></b><em>Vegetarian</em> ×2</span>
</div>
</div>
<div class="actions"><button class="regen">New seven</button></div>
<div class="tiles">
<article class="tile t-meat">
<p class="tag">Meat</p>
<h3 class="dish">Jauhelihakastike</h3>
<p class="sides">Perunamuusi · Vihersalaatti</p>
<p class="last">Last eaten 23 days ago</p>
<div class="swaps"><button>Similar</button><button>Anything</button></div>
</article>
<article class="tile t-chicken">
<p class="tag">Chicken</p>
<h3 class="dish">Broilerikastike</h3>
<p class="sides">Riisi</p>
<p class="last">Last eaten 31 days ago</p>
<div class="swaps"><button>Similar</button><button>Anything</button></div>
</article>
<article class="tile wild">
<p class="tag">Meat · Chicken · Fish · Veg</p>
<h3 class="dish">Tortillat</h3>
<p class="sides">Served on its own</p>
<p class="last">Last eaten 40 days ago</p>
<div class="swaps">
<button disabled title="Nothing else covers all four">Similar</button>
<button>Anything</button>
</div>
</article>
<article class="tile t-fish">
<p class="tag">Fish</p>
<h3 class="dish">Uunilohi</h3>
<p class="sides">Riisi · Vihersalaatti</p>
<p class="last">Last eaten 34 days ago</p>
<div class="swaps"><button>Similar</button><button>Anything</button></div>
</article>
<article class="tile t-vegetarian">
<p class="tag">Vegetarian</p>
<h3 class="dish">Kasvispyörykät</h3>
<p class="sides">Perunamuusi · Höyrytetyt Porkkanat</p>
<p class="last">Last eaten 52 days ago</p>
<div class="swaps"><button>Similar</button><button>Anything</button></div>
</article>
<article class="tile t-meat">
<p class="tag">Meat</p>
<h3 class="dish">Makaronilaatikko</h3>
<p class="sides">Served on its own</p>
<p class="last">Last eaten 19 days ago</p>
<div class="swaps"><button>Similar</button><button>Anything</button></div>
</article>
<article class="tile t-vegetarian">
<p class="tag">Vegetarian</p>
<h3 class="dish">Hernekeitto</h3>
<p class="sides">Served on its own</p>
<p class="last">Last eaten 27 days ago</p>
<div class="swaps"><button>Similar</button><button>Anything</button></div>
</article>
</div>
<section class="logstrip">
<h2>Recently eaten</h2>
<div class="logrow"><time>Thu 3 Sep</time><i class="k-fish"></i><span>Lohikeitto</span><em>Ruisleipä</em></div>
<div class="logrow"><time>Wed 2 Sep</time><i class="k-chicken"></i><span>Kanacurry</span><em>Riisi</em></div>
<div class="logrow"><time>Tue 1 Sep</time><i class="k-meat"></i><span>Uunimakkara</span><em>Perunamuusi · Vihersalaatti</em></div>
</section>
</div>
<script>
const t=document.getElementById('t'), h=document.documentElement, m=['auto','light','dark'];
t.onclick=()=>{const n=m[(m.indexOf(h.dataset.theme)+1)%3]; h.dataset.theme=n; t.textContent='Theme: '+n;};
</script>
</body>
</html>
+199
View File
@@ -0,0 +1,199 @@
<!doctype html>
<html lang="en" data-theme="auto">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Foodster — C · Fridge board</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Kalam:wght@400;700&family=Figtree:wght@400;500;700;800&display=swap" rel="stylesheet">
<style>
:root{
color-scheme: light dark;
--board: light-dark(#E3E7E3, #1A1D1B);
--card: light-dark(#FCFCFA, #24272A);
--ink: light-dark(#1D2724, #E6E9E5);
--muted: light-dark(#6F7A75, #8E9994);
--line: light-dark(#D2D8D3, #33383A);
--marker:light-dark(#16403C, #6FD3C4);
--tape: light-dark(#E8D48A, #6E6134);
--meat: light-dark(#AF4230, #E07561);
--chicken: light-dark(#BE8410, #DFA83B);
--fish: light-dark(#25688F, #63AAD8);
--vegetarian:light-dark(#457A3C, #82BE7A);
}
html[data-theme="light"]{ color-scheme: only light; }
html[data-theme="dark"] { color-scheme: only dark; }
*{box-sizing:border-box;}
body{margin:0; background:var(--board); color:var(--ink);
font-family:Figtree,system-ui,sans-serif; font-size:16px; line-height:1.5;
-webkit-font-smoothing:antialiased;}
.wrap{max-width:1100px; margin:0 auto; padding:0 20px 80px;}
.top{display:flex; align-items:center; gap:18px; flex-wrap:wrap; padding:22px 0 0;}
.logo{font-family:Kalam,cursive; font-weight:700; font-size:26px; color:var(--marker);}
nav{display:flex; gap:6px; margin-left:auto;}
nav a{font-size:14px; font-weight:500; color:var(--muted); text-decoration:none; padding:6px 2px;
border-bottom:2px solid transparent;}
nav a[aria-current]{color:var(--ink); border-color:var(--marker);}
nav a:hover{color:var(--ink);}
.themebtn{font-family:Figtree,sans-serif; font-size:13px; background:none;
border:1px solid var(--line); color:var(--muted); padding:6px 12px;
border-radius:6px; cursor:pointer;}
.themebtn:hover{color:var(--ink);}
header.lede{padding:38px 0 8px; max-width:46ch;}
h1{font-family:Figtree,sans-serif; font-weight:800; font-size:clamp(34px,5.5vw,52px);
line-height:1.05; letter-spacing:-.03em; margin:0 0 10px;}
.lede p{margin:0; color:var(--muted);}
.handnote{font-family:Kalam,cursive; font-size:19px; color:var(--marker);
transform:rotate(-1.1deg); margin:18px 0 0;}
.actions{display:flex; gap:10px; margin:24px 0 26px;}
.regen{font-family:Figtree,sans-serif; font-weight:700; font-size:15px;
background:var(--marker); color:var(--board); border:0; padding:12px 22px;
border-radius:7px; cursor:pointer;}
.regen:hover{filter:brightness(1.12);}
/* ---- cards on the board ---- */
.cards{display:grid; gap:22px; grid-template-columns:repeat(auto-fill,minmax(238px,1fr));}
.card{position:relative; background:var(--card); border-radius:3px; padding:26px 18px 16px;
box-shadow:0 1px 2px rgba(0,0,0,.10), 0 6px 14px -8px rgba(0,0,0,.22);}
.card:nth-child(3n+1){transform:rotate(-.55deg);}
.card:nth-child(3n+2){transform:rotate(.4deg);}
.card:nth-child(3n+3){transform:rotate(-.2deg);}
.card::before{content:""; position:absolute; top:-7px; left:50%; margin-left:-7px;
width:14px; height:14px; border-radius:50%; background:var(--mag,var(--muted));
box-shadow:inset 0 -2px 3px rgba(0,0,0,.28);}
.m-meat{--mag:var(--meat);} .m-chicken{--mag:var(--chicken);}
.m-fish{--mag:var(--fish);} .m-vegetarian{--mag:var(--vegetarian);}
.card.wild::before{background:conic-gradient(var(--meat) 0 25%, var(--chicken) 25% 50%,
var(--fish) 50% 75%, var(--vegetarian) 75% 100%);}
.tag{font-size:11px; font-weight:700; letter-spacing:.08em; text-transform:uppercase;
color:var(--muted); margin:0;}
.dish{font-family:Figtree,sans-serif; font-weight:700; font-size:25px; line-height:1.08;
letter-spacing:-.025em; margin:8px 0 8px;}
.sides{margin:0 0 10px; font-size:14px; color:var(--ink);}
.sides.none{color:var(--muted); font-style:italic;}
.last{font-size:12.5px; color:var(--muted); margin:0 0 14px;}
.swaps{display:flex; gap:6px; border-top:1px solid var(--line); padding-top:12px;}
.swaps button{flex:1; font-family:Figtree,sans-serif; font-size:12.5px; font-weight:600;
background:none; color:var(--muted); border:1px solid var(--line);
border-radius:6px; padding:8px 5px; cursor:pointer;}
.swaps button:hover:not(:disabled){color:var(--marker); border-color:var(--marker);}
.swaps button:disabled{opacity:.4; cursor:not-allowed;}
/* ---- the split: handwriting = what we actually ate ---- */
.logstrip{margin-top:56px; border-top:2px dashed var(--line); padding-top:24px;}
.logstrip h2{font-family:Figtree,sans-serif; font-weight:800; font-size:18px;
letter-spacing:-.02em; margin:0 0 4px;}
.logstrip .sub{color:var(--muted); font-size:14px; margin:0 0 18px;}
.logrow{display:flex; gap:14px; align-items:baseline; padding:10px 0;
border-bottom:1px solid var(--line);}
.logrow time{font-size:13px; color:var(--muted); min-width:80px;}
.logrow .hand{font-family:Kalam,cursive; font-size:22px; color:var(--marker); line-height:1.2;}
.logrow em{font-family:Kalam,cursive; font-style:normal; font-size:17px;
color:var(--muted); margin-left:auto;}
:focus-visible{outline:2.5px solid var(--marker); outline-offset:3px;}
@media (max-width:560px){ .card{transform:none!important;} }
@media (prefers-reduced-motion:reduce){ *{transition:none!important;} }
</style>
</head>
<body>
<div class="wrap">
<div class="top">
<div class="logo">Foodster</div>
<nav><a href="#" aria-current="page">Plan</a><a href="#">Log</a><a href="#">Catalog</a></nav>
<button class="themebtn" id="t">Theme: auto</button>
</div>
<header class="lede">
<h1>Seven dinners on the door.</h1>
<p>Take them in any order. Swap what nobody's in the mood for.</p>
<p class="handnote">— last regenerated Sunday</p>
</header>
<div class="actions"><button class="regen">New seven</button></div>
<div class="cards">
<article class="card m-meat">
<p class="tag">Meat</p>
<h3 class="dish">Jauhelihakastike</h3>
<p class="sides">Perunamuusi · Vihersalaatti</p>
<p class="last">Last eaten 23 days ago</p>
<div class="swaps"><button>Swap similar</button><button>Swap anything</button></div>
</article>
<article class="card m-chicken">
<p class="tag">Chicken</p>
<h3 class="dish">Broilerikastike</h3>
<p class="sides">Riisi</p>
<p class="last">Last eaten 31 days ago</p>
<div class="swaps"><button>Swap similar</button><button>Swap anything</button></div>
</article>
<article class="card wild">
<p class="tag">Meat · Chicken · Fish · Veg</p>
<h3 class="dish">Tortillat</h3>
<p class="sides none">Served on its own</p>
<p class="last">Last eaten 40 days ago</p>
<div class="swaps">
<button disabled title="Nothing else covers all four">Swap similar</button>
<button>Swap anything</button>
</div>
</article>
<article class="card m-fish">
<p class="tag">Fish</p>
<h3 class="dish">Uunilohi</h3>
<p class="sides">Riisi · Vihersalaatti</p>
<p class="last">Last eaten 34 days ago</p>
<div class="swaps"><button>Swap similar</button><button>Swap anything</button></div>
</article>
<article class="card m-vegetarian">
<p class="tag">Vegetarian</p>
<h3 class="dish">Kasvispyörykät</h3>
<p class="sides">Perunamuusi · Höyrytetyt Porkkanat</p>
<p class="last">Last eaten 52 days ago</p>
<div class="swaps"><button>Swap similar</button><button>Swap anything</button></div>
</article>
<article class="card m-meat">
<p class="tag">Meat</p>
<h3 class="dish">Makaronilaatikko</h3>
<p class="sides none">Served on its own</p>
<p class="last">Last eaten 19 days ago</p>
<div class="swaps"><button>Swap similar</button><button>Swap anything</button></div>
</article>
<article class="card m-vegetarian">
<p class="tag">Vegetarian</p>
<h3 class="dish">Hernekeitto</h3>
<p class="sides none">Served on its own</p>
<p class="last">Last eaten 27 days ago</p>
<div class="swaps"><button>Swap similar</button><button>Swap anything</button></div>
</article>
</div>
<section class="logstrip">
<h2>What we actually ate</h2>
<p class="sub">Printed above is the app guessing. Below is the house, on the record.</p>
<div class="logrow"><time>Thu 3 Sep</time><span class="hand">Lohikeitto</span><em>ruisleipä</em></div>
<div class="logrow"><time>Wed 2 Sep</time><span class="hand">Kanacurry</span><em>riisi</em></div>
<div class="logrow"><time>Tue 1 Sep</time><span class="hand">Uunimakkara</span><em>perunamuusi, vihersalaatti</em></div>
</section>
</div>
<script>
const t=document.getElementById('t'), h=document.documentElement, m=['auto','light','dark'];
t.onclick=()=>{const n=m[(m.indexOf(h.dataset.theme)+1)%3]; h.dataset.theme=n; t.textContent='Theme: '+n;};
</script>
</body>
</html>
+345
View File
@@ -0,0 +1,345 @@
<!doctype html>
<html lang="fi" data-theme="auto">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Foodster — kirjaus ja historia</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Archivo:wght@400;500;600;700&family=Martian+Mono:wght@300;500&display=swap" rel="stylesheet">
<style>
:root{
color-scheme: light dark;
--paper: light-dark(#ECEDE8, #121316);
--card: light-dark(#FFFFFF, #1C1E22);
--sunk: light-dark(#E3E4DE, #17181B);
--ink: light-dark(#14161A, #E9EAE5);
--muted: light-dark(#6B6F6A, #8B8F89);
--line: light-dark(#D5D6D0, #2C2F34);
--accent:light-dark(#15616D, #58C6D2);
--onacc: light-dark(#FFFFFF, #0C1417);
--warn: light-dark(#AF4230, #DE7561);
--liha: light-dark(#AF4230, #DE7561);
--kana: light-dark(#B57E10, #DFA83B);
--kala: light-dark(#25688F, #63AAD8);
--kasvis:light-dark(#457A3C, #82BE7A);
--tap: 48px;
}
html[data-theme="light"]{color-scheme:only light;}
html[data-theme="dark"] {color-scheme:only dark;}
*{box-sizing:border-box; -webkit-tap-highlight-color:transparent;}
body{margin:0; background:var(--sunk); color:var(--ink);
font-family:Archivo,system-ui,sans-serif; font-size:16px; line-height:1.45;
-webkit-font-smoothing:antialiased;}
button,input{font-family:inherit;}
:focus-visible{outline:2.5px solid var(--accent); outline-offset:2px;}
@media (prefers-reduced-motion:reduce){*{transition:none!important;}}
.dot{width:9px; height:9px; border-radius:2px; flex:none; display:inline-block;}
.d-liha{background:var(--liha);} .d-kana{background:var(--kana);}
.d-kala{background:var(--kala);} .d-kasvis{background:var(--kasvis);}
.d-sek{background:conic-gradient(var(--liha) 0 25%,var(--kana) 25% 50%,
var(--kala) 50% 75%,var(--kasvis) 75% 100%);}
/* ---- comparison rig (desktop only) ---- */
.rig{padding:20px; max-width:1400px; margin:0 auto;}
.rigtop{display:flex; align-items:center; gap:14px; flex-wrap:wrap; margin-bottom:18px;}
.rigtop h1{font-size:17px; font-weight:700; letter-spacing:-.02em; margin:0;}
.rigtop p{margin:0; font-size:13px; color:var(--muted);}
.themebtn{font-size:12px; background:var(--card); border:1px solid var(--line);
color:var(--muted); padding:7px 12px; border-radius:7px; cursor:pointer; margin-left:auto;}
.switch{display:none; gap:6px; margin-bottom:14px;}
.switch button{font-size:14px; font-weight:600; background:var(--card);
border:1px solid var(--line); color:var(--muted); padding:10px 12px;
border-radius:8px; cursor:pointer; flex:1;}
.switch button[aria-pressed="true"]{background:var(--accent); border-color:var(--accent); color:var(--onacc);}
.frames{display:flex; gap:22px; align-items:flex-start; overflow-x:auto; padding-bottom:14px;}
.framewrap{flex:none;}
.framelbl{font-size:13px; font-weight:600; margin:0 0 9px; letter-spacing:-.01em;}
.framelbl span{font-weight:400; color:var(--muted);}
.phone{width:390px; height:788px; background:var(--paper); border:1px solid var(--line);
border-radius:26px; overflow:hidden; display:flex; flex-direction:column;}
/* ---- app chrome ---- */
.screen{flex:1; overflow-y:auto; overscroll-behavior:contain; position:relative;}
.appbar{position:sticky; top:0; z-index:5; background:var(--paper);
border-bottom:1px solid var(--line); padding:14px 16px 12px;}
.appbar h2{margin:0; font-size:22px; font-weight:700; letter-spacing:-.03em;}
.appbar .meta{font-size:12.5px; color:var(--muted); margin:2px 0 0;}
.pad{padding:16px;}
.tabbar{flex:none; display:flex; background:var(--card); border-top:1px solid var(--line);
padding-bottom:env(safe-area-inset-bottom);}
.tabbar a{flex:1; text-align:center; text-decoration:none; color:var(--muted);
font-size:11.5px; font-weight:600; padding:11px 0 13px;}
.tabbar a[aria-current]{color:var(--accent);}
.tabbar i{display:block; font-style:normal; font-size:18px; line-height:1.3;}
.primary{display:block; width:100%; min-height:var(--tap); background:var(--accent);
color:var(--onacc); border:0; border-radius:11px; font-size:16.5px; font-weight:700;
cursor:pointer; letter-spacing:-.01em;}
.ghost{display:block; width:100%; min-height:var(--tap); background:none; color:var(--muted);
border:0; font-size:15px; cursor:pointer;}
/* ---- tap board ---- */
.dayseg{display:flex; gap:6px; margin-top:11px;}
.dayseg button{flex:1; min-height:42px; font-size:14px; font-weight:600; background:var(--card);
border:1px solid var(--line); color:var(--ink); border-radius:9px; cursor:pointer;}
.dayseg button[aria-pressed="true"]{background:var(--ink); border-color:var(--ink); color:var(--paper);}
.dayseg input{flex:none; width:52px; font-size:16px; background:var(--card); color:var(--ink);
border:1px solid var(--line); border-radius:9px; padding:0 8px;}
.filter{width:100%; min-height:var(--tap); font-size:16px; background:var(--card);
color:var(--ink); border:1px solid var(--line); border-radius:10px;
padding:0 14px; margin-bottom:14px;}
.board{display:flex; flex-wrap:wrap; gap:8px;}
.pill{display:flex; align-items:center; gap:8px; background:var(--card);
border:1px solid var(--line); border-radius:11px; cursor:pointer; color:var(--ink);
font-weight:600; letter-spacing:-.022em; min-height:var(--tap);}
.pill .n{font-family:"Martian Mono",monospace; font-weight:300; color:var(--muted); font-size:10px;}
.pill.xl{font-size:22px; padding:14px 18px; flex:1 1 100%;}
.pill.lg{font-size:18px; padding:12px 16px;}
.pill.md{font-size:15.5px; padding:11px 14px;}
.pill.sm{font-size:14px; padding:10px 13px; color:var(--muted);}
.pill.picked{background:var(--accent); border-color:var(--accent); color:var(--onacc);}
.pill.picked .n{color:var(--onacc); opacity:.65;}
.sheet{position:absolute; left:0; right:0; bottom:0; z-index:9; background:var(--card);
border-top:1px solid var(--line); border-radius:18px 18px 0 0; padding:8px 16px 16px;
box-shadow:0 -10px 30px -14px rgba(0,0,0,.4);}
.grab{width:38px; height:4px; border-radius:3px; background:var(--line); margin:0 auto 13px;}
.sheet h3{margin:0 0 2px; font-size:19px; letter-spacing:-.025em;}
.sheet .q{margin:0 0 12px; font-size:13px; color:var(--muted);}
.chips{display:flex; gap:7px; flex-wrap:wrap; margin-bottom:14px;}
.chips button{font-size:14.5px; min-height:42px; background:var(--sunk);
border:1px solid var(--line); color:var(--ink); padding:0 14px;
border-radius:999px; cursor:pointer;}
.chips button[aria-pressed="true"]{background:var(--accent); border-color:var(--accent); color:var(--onacc);}
/* ---- already-logged state ---- */
.logged{background:var(--card); border:1px solid var(--line); border-radius:14px; padding:18px;}
.logged .k{font-family:"Martian Mono",monospace; font-size:10px; letter-spacing:.09em;
text-transform:uppercase; color:var(--accent); margin:0 0 10px;}
.logged .nm{font-size:26px; font-weight:700; letter-spacing:-.035em; margin:0 0 3px;
display:flex; align-items:center; gap:10px;}
.logged .sd{font-size:14.5px; color:var(--muted); margin:0 0 16px;}
.logged .pair{display:flex; gap:8px;}
.logged .pair button{flex:1; min-height:var(--tap); font-size:15px; font-weight:600;
background:var(--sunk); border:1px solid var(--line); color:var(--ink);
border-radius:10px; cursor:pointer;}
.logged .pair button.del{color:var(--warn);}
.orlog{margin:22px 0 0; font-size:14px; color:var(--muted); text-align:center;}
.orlog button{background:none; border:0; color:var(--accent); font:inherit;
font-weight:600; text-decoration:underline; cursor:pointer; min-height:38px;}
/* ---- journal ---- */
.entry{display:flex; gap:12px; align-items:center; padding:14px 0;
border-bottom:1px solid var(--line); min-height:var(--tap);}
.entry time{font-family:"Martian Mono",monospace; font-size:11px; color:var(--muted);
min-width:54px; flex:none;}
.entry .nm{font-size:17px; font-weight:600; letter-spacing:-.025em;
display:flex; align-items:center; gap:8px;}
.entry .sd{font-size:12.5px; color:var(--muted); margin-top:1px;}
.entry .chev{margin-left:auto; color:var(--muted); font-size:19px; flex:none;}
.gapline{display:flex; gap:12px; align-items:center; padding:12px 0;
border-bottom:1px dashed var(--line); font-size:13.5px; color:var(--muted);
min-height:var(--tap);}
.gapline time{font-family:"Martian Mono",monospace; font-size:11px; min-width:54px; flex:none;}
.gapline button{margin-left:auto; font-size:13.5px; background:none; border:0;
color:var(--accent); font-weight:600; cursor:pointer; min-height:38px; padding:0 4px;}
.monthrule{font-family:"Martian Mono",monospace; font-size:10px; letter-spacing:.12em;
text-transform:uppercase; color:var(--muted); padding:22px 0 5px;}
@media (max-width:900px){
body{background:var(--paper);}
.rig{padding:0;}
.rigtop{padding:12px 16px 0; margin-bottom:10px;}
.switch{display:flex; padding:0 16px;}
.frames{display:block; overflow:visible; padding:0;}
.framewrap{display:none;} .framewrap.on{display:block;}
.framelbl{display:none;}
.phone{width:auto; height:calc(100svh - 118px); border:0; border-radius:0;}
}
</style>
</head>
<body>
<div class="rig">
<div class="rigtop">
<h1>Foodster · vaihe 1</h1>
<p>390&nbsp;px · Kirjaa, Historia, ja jo kirjattu tila</p>
<button class="themebtn" id="t">Teema: auto</button>
</div>
<div class="switch">
<button aria-pressed="true" data-v="f1">Kirjaa</button>
<button aria-pressed="false" data-v="f2">Kirjattu</button>
<button aria-pressed="false" data-v="f3">Historia</button>
</div>
<div class="frames">
<!-- ============ 1 · KIRJAA ============ -->
<div class="framewrap on" id="f1">
<p class="framelbl">1 · Kirjaa <span>— napauta ruoka, valitse lisukkeet</span></p>
<div class="phone">
<div class="screen">
<div class="appbar">
<h2>Mitä syötiin?</h2>
<div class="dayseg">
<button aria-pressed="true">Tänään</button>
<button aria-pressed="false">Eilen</button>
<input type="date" value="2026-09-05" aria-label="Muu päivä">
</div>
</div>
<div class="pad" style="padding-bottom:280px">
<input class="filter" type="search" placeholder="Etsi" aria-label="Etsi">
<div class="board">
<button class="pill xl"><i class="dot d-liha"></i>Lihapullat <span class="n">12</span></button>
<button class="pill xl"><i class="dot d-liha"></i>Jauhelihakastike <span class="n">11</span></button>
<button class="pill lg picked"><i class="dot d-kala"></i>Lohikeitto <span class="n">9</span></button>
<button class="pill lg"><i class="dot d-kana"></i>Kanacurry <span class="n">8</span></button>
<button class="pill lg"><i class="dot d-liha"></i>Makaronilaatikko <span class="n">7</span></button>
<button class="pill lg"><i class="dot d-kana"></i>Broilerikastike <span class="n">6</span></button>
<button class="pill md"><i class="dot d-kala"></i>Uunilohi <span class="n">5</span></button>
<button class="pill md"><i class="dot d-kasvis"></i>Kasvispyörykät <span class="n">5</span></button>
<button class="pill md"><i class="dot d-sek"></i>Tortillat <span class="n">4</span></button>
<button class="pill md"><i class="dot d-kasvis"></i>Hernekeitto <span class="n">4</span></button>
<button class="pill md"><i class="dot d-liha"></i>Uunimakkara <span class="n">3</span></button>
<button class="pill sm"><i class="dot d-liha"></i>Kaalilaatikko <span class="n">2</span></button>
<button class="pill sm"><i class="dot d-sek"></i>Kotipizza <span class="n">2</span></button>
<button class="pill sm"><i class="dot d-kala"></i>Kalapuikot <span class="n">1</span></button>
</div>
</div>
<div class="sheet">
<div class="grab"></div>
<h3>Lohikeitto</h3>
<p class="q">Lisukkeita?</p>
<div class="chips">
<button aria-pressed="true">Ruisleipä</button>
<button aria-pressed="false">Perunamuusi</button>
<button aria-pressed="false">Riisi</button>
<button aria-pressed="false">Vihersalaatti</button>
</div>
<button class="primary">Tallenna</button>
<button class="ghost">Peruuta</button>
</div>
</div>
<nav class="tabbar">
<a href="#" aria-current="page"><i></i>Kirjaa</a>
<a href="#"><i></i>Historia</a>
<a href="#"><i></i>Ruoat</a>
</nav>
</div>
</div>
<!-- ============ 2 · JO KIRJATTU ============ -->
<div class="framewrap" id="f2">
<p class="framelbl">2 · Päivä jo kirjattu <span>— yksi merkintä per päivä</span></p>
<div class="phone">
<div class="screen">
<div class="appbar">
<h2>Mitä syötiin?</h2>
<div class="dayseg">
<button aria-pressed="true">Tänään</button>
<button aria-pressed="false">Eilen</button>
<input type="date" value="2026-09-05" aria-label="Muu päivä">
</div>
</div>
<div class="pad">
<div class="logged">
<p class="k">Tänään kirjattu</p>
<p class="nm"><i class="dot d-kala"></i>Lohikeitto</p>
<p class="sd">Ruisleipä</p>
<div class="pair">
<button>Muokkaa</button>
<button class="del">Poista</button>
</div>
</div>
<p class="orlog">Väärä päivä? <button>Kirjaa toinen päivä</button></p>
</div>
</div>
<nav class="tabbar">
<a href="#" aria-current="page"><i></i>Kirjaa</a>
<a href="#"><i></i>Historia</a>
<a href="#"><i></i>Ruoat</a>
</nav>
</div>
</div>
<!-- ============ 3 · HISTORIA ============ -->
<div class="framewrap" id="f3">
<p class="framelbl">3 · Historia <span>— lista, ei kalenteria</span></p>
<div class="phone">
<div class="screen">
<div class="appbar">
<h2>Historia</h2>
</div>
<div class="pad">
<div class="monthrule">Syyskuu</div>
<div class="gapline"><time>pe 4.9.</time><span>Ei merkintää</span><button>Merkitse</button></div>
<div class="entry">
<time>to 3.9.</time>
<div><div class="nm"><i class="dot d-liha"></i>Lihapullat</div><div class="sd">Vihersalaatti</div></div>
<span class="chev"></span>
</div>
<div class="entry">
<time>ke 2.9.</time>
<div><div class="nm"><i class="dot d-kala"></i>Lohikeitto</div><div class="sd">Ruisleipä</div></div>
<span class="chev"></span>
</div>
<div class="entry">
<time>ti 1.9.</time>
<div><div class="nm"><i class="dot d-kana"></i>Kanacurry</div><div class="sd">Riisi</div></div>
<span class="chev"></span>
</div>
<div class="monthrule">Elokuu</div>
<div class="entry">
<time>ma 31.8.</time>
<div><div class="nm"><i class="dot d-liha"></i>Uunimakkara</div><div class="sd">Perunamuusi</div></div>
<span class="chev"></span>
</div>
<div class="entry">
<time>su 30.8.</time>
<div><div class="nm"><i class="dot d-kasvis"></i>Kasvispyörykät</div><div class="sd">Ei lisukkeita</div></div>
<span class="chev"></span>
</div>
<div class="entry">
<time>la 29.8.</time>
<div><div class="nm"><i class="dot d-liha"></i>Lihapullat</div><div class="sd">Perunamuusi</div></div>
<span class="chev"></span>
</div>
<div class="gapline"><time>pe 28.8.</time><span>Ei merkintää</span><button>Merkitse</button></div>
<div class="entry">
<time>to 27.8.</time>
<div><div class="nm"><i class="dot d-kana"></i>Kanacurry</div><div class="sd">Riisi</div></div>
<span class="chev"></span>
</div>
<div class="entry">
<time>ke 26.8.</time>
<div><div class="nm"><i class="dot d-liha"></i>Jauhelihakastike</div><div class="sd">Perunamuusi, vihersalaatti</div></div>
<span class="chev"></span>
</div>
</div>
</div>
<nav class="tabbar">
<a href="#"><i></i>Kirjaa</a>
<a href="#" aria-current="page"><i></i>Historia</a>
<a href="#"><i></i>Ruoat</a>
</nav>
</div>
</div>
</div>
</div>
<script>
const h=document.documentElement, tb=document.getElementById('t'), m=['auto','light','dark'];
tb.onclick=()=>{const n=m[(m.indexOf(h.dataset.theme)+1)%3];h.dataset.theme=n;tb.textContent='Teema: '+n;};
document.querySelectorAll('.switch button').forEach(b=>b.onclick=()=>{
document.querySelectorAll('.switch button').forEach(x=>x.setAttribute('aria-pressed',x===b));
document.querySelectorAll('.framewrap').forEach(f=>f.classList.toggle('on',f.id===b.dataset.v));
});
document.querySelectorAll('.chips button,.dayseg button').forEach(b=>b.onclick=()=>
b.setAttribute('aria-pressed', b.getAttribute('aria-pressed')!=='true'));
</script>
</body>
</html>
+440
View File
@@ -0,0 +1,440 @@
<!doctype html>
<html lang="en" data-theme="auto">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Foodster — stage 1 log · mobile layouts</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Archivo:wght@400;500;600;700&family=Martian+Mono:wght@300;500&display=swap" rel="stylesheet">
<style>
/* ============ shared skin ============ */
:root{
color-scheme: light dark;
--paper: light-dark(#ECEDE8, #121316);
--card: light-dark(#FFFFFF, #1C1E22);
--sunk: light-dark(#E3E4DE, #17181B);
--ink: light-dark(#14161A, #E9EAE5);
--muted: light-dark(#6B6F6A, #8B8F89);
--line: light-dark(#D5D6D0, #2C2F34);
--accent:light-dark(#15616D, #58C6D2);
--onacc: light-dark(#FFFFFF, #0C1417);
--meat: light-dark(#AF4230, #DE7561);
--chicken: light-dark(#B57E10, #DFA83B);
--fish: light-dark(#25688F, #63AAD8);
--vegetarian:light-dark(#457A3C, #82BE7A);
--tap: 48px; /* minimum touch target */
}
html[data-theme="light"]{color-scheme:only light;}
html[data-theme="dark"] {color-scheme:only dark;}
*{box-sizing:border-box; -webkit-tap-highlight-color:transparent;}
body{margin:0; background:var(--sunk); color:var(--ink);
font-family:Archivo,system-ui,sans-serif; font-size:16px; line-height:1.45;
-webkit-font-smoothing:antialiased;}
button,input{font-family:inherit;}
:focus-visible{outline:2.5px solid var(--accent); outline-offset:2px;}
@media (prefers-reduced-motion:reduce){*{transition:none!important;}}
.dot{width:9px; height:9px; border-radius:2px; flex:none; display:inline-block;}
.d-meat{background:var(--meat);} .d-chicken{background:var(--chicken);}
.d-fish{background:var(--fish);} .d-vegetarian{background:var(--vegetarian);}
.d-wild{background:conic-gradient(var(--meat) 0 25%,var(--chicken) 25% 50%,
var(--fish) 50% 75%,var(--vegetarian) 75% 100%);}
/* ============ comparison rig (desktop only) ============ */
.rig{padding:20px; max-width:1400px; margin:0 auto;}
.rigtop{display:flex; align-items:center; gap:14px; flex-wrap:wrap; margin-bottom:18px;}
.rigtop h1{font-size:17px; font-weight:700; letter-spacing:-.02em; margin:0;}
.rigtop p{margin:0; font-size:13px; color:var(--muted);}
.themebtn{font-size:12px; background:var(--card); border:1px solid var(--line);
color:var(--muted); padding:7px 12px; border-radius:7px; cursor:pointer; margin-left:auto;}
.switch{display:none; gap:6px; margin-bottom:14px; flex-wrap:wrap;}
.switch button{font-size:14px; font-weight:600; background:var(--card);
border:1px solid var(--line); color:var(--muted); padding:10px 14px;
border-radius:8px; cursor:pointer; flex:1;}
.switch button[aria-pressed="true"]{background:var(--accent); border-color:var(--accent); color:var(--onacc);}
.frames{display:flex; gap:22px; align-items:flex-start; overflow-x:auto; padding-bottom:14px;}
.framewrap{flex:none;}
.framelbl{font-size:13px; font-weight:600; margin:0 0 9px; letter-spacing:-.01em;}
.framelbl span{font-weight:400; color:var(--muted);}
.phone{width:390px; height:788px; background:var(--paper); border:1px solid var(--line);
border-radius:26px; overflow:hidden; display:flex; flex-direction:column;}
/* ============ in-app chrome ============ */
.screen{flex:1; overflow-y:auto; overscroll-behavior:contain;}
.appbar{position:sticky; top:0; z-index:5; background:var(--paper);
border-bottom:1px solid var(--line); padding:14px 16px 12px;}
.appbar h2{margin:0; font-size:22px; font-weight:700; letter-spacing:-.03em;}
.appbar .meta{font-size:12.5px; color:var(--muted); margin:2px 0 0;}
.pad{padding:16px;}
.tabbar{flex:none; display:flex; background:var(--card); border-top:1px solid var(--line);
padding-bottom:env(safe-area-inset-bottom);}
.tabbar a{flex:1; text-align:center; text-decoration:none; color:var(--muted);
font-size:11.5px; font-weight:600; padding:11px 0 13px; letter-spacing:.01em;}
.tabbar a[aria-current]{color:var(--accent);}
.tabbar i{display:block; font-style:normal; font-size:18px; line-height:1.3;}
.primary{display:block; width:100%; min-height:var(--tap); background:var(--accent);
color:var(--onacc); border:0; border-radius:11px; font-size:16.5px; font-weight:700;
cursor:pointer; letter-spacing:-.01em;}
.ghost{display:block; width:100%; min-height:var(--tap); background:none; color:var(--muted);
border:0; font-size:15px; cursor:pointer;}
/* ============ 1 · CALENDAR-LED ============ */
.mini{display:grid; grid-template-columns:repeat(7,1fr); gap:4px;}
.mini .wn{font-size:9.5px; letter-spacing:.06em; text-transform:uppercase;
color:var(--muted); text-align:center; padding-bottom:3px;}
.mini button{aspect-ratio:1; background:var(--card); border:1px solid var(--line);
border-radius:8px; cursor:pointer; display:flex; flex-direction:column;
align-items:center; justify-content:center; gap:4px; padding:0; color:var(--muted);
font-size:11px;}
.mini button .dot{width:11px; height:11px; border-radius:3px;}
.mini button.empty{background:none; border-style:dashed;}
.mini button.empty::after{content:"+"; font-size:14px; opacity:.45;}
.mini button.today{border:2px solid var(--accent); color:var(--accent); font-weight:700;}
.mini button.future{opacity:.3; border-color:transparent; background:none;}
.tally{display:flex; align-items:baseline; gap:8px; margin:14px 0 0; font-size:12.5px;
color:var(--muted);}
.tally b{color:var(--ink); font-size:14px;}
.bar{flex:1; height:5px; border-radius:3px; background:var(--line); overflow:hidden;}
.bar i{display:block; height:100%; background:var(--accent);}
.tonight{background:var(--card); border:1px solid var(--line); border-radius:13px;
padding:16px; margin:18px 0 0;}
.tonight .k{font-family:"Martian Mono",monospace; font-size:10px; letter-spacing:.08em;
text-transform:uppercase; color:var(--muted); margin:0 0 9px;}
.dayrow{display:flex; gap:12px; align-items:center; padding:13px 0;
border-bottom:1px solid var(--line); min-height:var(--tap);}
.dayrow time{font-family:"Martian Mono",monospace; font-size:11px; color:var(--muted);
min-width:56px; flex:none;}
.dayrow .nm{font-size:16px; font-weight:600; letter-spacing:-.02em;}
.dayrow .sd{font-size:12.5px; color:var(--muted);}
.dayrow .chev{margin-left:auto; color:var(--muted); font-size:18px;}
.sechead{font-family:"Martian Mono",monospace; font-size:10px; letter-spacing:.09em;
text-transform:uppercase; color:var(--muted); margin:24px 0 4px;}
/* ============ 2 · TAP BOARD ============ */
.dayseg{display:flex; gap:6px; margin-top:11px;}
.dayseg button{flex:1; min-height:42px; font-size:14px; font-weight:600; background:var(--card);
border:1px solid var(--line); color:var(--ink); border-radius:9px; cursor:pointer;}
.dayseg button[aria-pressed="true"]{background:var(--ink); border-color:var(--ink); color:var(--paper);}
.dayseg input{flex:none; width:52px; font-size:16px; background:var(--card); color:var(--ink);
border:1px solid var(--line); border-radius:9px; padding:0 8px;}
.filter{width:100%; min-height:var(--tap); font-size:16px; background:var(--card);
color:var(--ink); border:1px solid var(--line); border-radius:10px;
padding:0 14px; margin-bottom:14px;}
.board{display:flex; flex-wrap:wrap; gap:8px;}
.pill{display:flex; align-items:center; gap:8px; background:var(--card);
border:1px solid var(--line); border-radius:11px; cursor:pointer; color:var(--ink);
font-weight:600; letter-spacing:-.022em; min-height:var(--tap);}
.pill .n{font-family:"Martian Mono",monospace; font-weight:300; color:var(--muted); font-size:10px;}
.pill.xl{font-size:22px; padding:14px 18px; flex:1 1 100%;}
.pill.lg{font-size:18px; padding:12px 16px;}
.pill.md{font-size:15.5px; padding:11px 14px;}
.pill.sm{font-size:14px; padding:10px 13px; color:var(--muted);}
.pill.picked{background:var(--accent); border-color:var(--accent); color:var(--onacc);}
.pill.picked .n{color:var(--onacc); opacity:.65;}
.sheetwrap{position:relative;}
.sheet{position:absolute; left:0; right:0; bottom:0; z-index:9; background:var(--card);
border-top:1px solid var(--line); border-radius:18px 18px 0 0; padding:8px 16px 16px;
box-shadow:0 -10px 30px -14px rgba(0,0,0,.4);}
.grab{width:38px; height:4px; border-radius:3px; background:var(--line); margin:0 auto 13px;}
.sheet h3{margin:0 0 2px; font-size:19px; letter-spacing:-.025em;}
.sheet .q{margin:0 0 12px; font-size:13px; color:var(--muted);}
.chips{display:flex; gap:7px; flex-wrap:wrap; margin-bottom:14px;}
.chips button{font-size:14.5px; min-height:42px; background:var(--sunk);
border:1px solid var(--line); color:var(--ink); padding:0 14px;
border-radius:999px; cursor:pointer;}
.chips button[aria-pressed="true"]{background:var(--accent); border-color:var(--accent); color:var(--onacc);}
/* ============ 3 · JOURNAL ============ */
.jtonight{background:var(--accent); color:var(--onacc); border-radius:14px;
padding:16px; margin-bottom:20px;}
.jtonight .k{font-family:"Martian Mono",monospace; font-size:10px; letter-spacing:.09em;
text-transform:uppercase; opacity:.75; margin:0 0 5px;}
.jtonight p{margin:0 0 13px; font-size:19px; font-weight:600; letter-spacing:-.025em;}
.jtonight button{width:100%; min-height:var(--tap); background:var(--onacc); color:var(--accent);
border:0; border-radius:10px; font-size:16px; font-weight:700; cursor:pointer;}
.entry{display:flex; gap:12px; align-items:center; padding:14px 0;
border-bottom:1px solid var(--line); min-height:var(--tap);}
.entry time{font-family:"Martian Mono",monospace; font-size:11px; color:var(--muted);
min-width:52px; flex:none;}
.entry .nm{font-size:17px; font-weight:600; letter-spacing:-.025em;
display:flex; align-items:center; gap:8px;}
.entry .sd{font-size:12.5px; color:var(--muted); margin-top:1px;}
.entry .chev{margin-left:auto; color:var(--muted); font-size:19px; flex:none;}
.gapline{display:flex; gap:12px; align-items:center; padding:12px 0;
border-bottom:1px dashed var(--line); font-size:13.5px; color:var(--muted);
min-height:var(--tap);}
.gapline time{font-family:"Martian Mono",monospace; font-size:11px; min-width:52px; flex:none;}
.gapline button{margin-left:auto; font-size:13.5px; background:none; border:0;
color:var(--accent); font-weight:600; cursor:pointer; min-height:38px; padding:0 4px;}
.monthrule{font-family:"Martian Mono",monospace; font-size:10px; letter-spacing:.12em;
text-transform:uppercase; color:var(--muted); padding:22px 0 5px;}
/* ============ real phone: drop the rig, go full bleed ============ */
@media (max-width:900px){
body{background:var(--paper);}
.rig{padding:0;}
.rigtop{padding:12px 16px 0; margin-bottom:10px;}
.switch{display:flex; padding:0 16px;}
.frames{display:block; overflow:visible; padding:0;}
.framewrap{display:none;}
.framewrap.on{display:block;}
.framelbl{display:none;}
.phone{width:auto; height:calc(100svh - 118px); border:0; border-radius:0;}
}
</style>
</head>
<body>
<div class="rig">
<div class="rigtop">
<h1>Foodster · stage 1 log</h1>
<p>390&nbsp;px. Three structures, one skin.</p>
<button class="themebtn" id="t">Theme: auto</button>
</div>
<div class="switch">
<button aria-pressed="true" data-v="f1">Calendar</button>
<button aria-pressed="false" data-v="f2">Tap board</button>
<button aria-pressed="false" data-v="f3">Journal</button>
</div>
<div class="frames">
<!-- ================= 1 · CALENDAR-LED ================= -->
<div class="framewrap on" id="f1">
<p class="framelbl">1 · Calendar-led <span>— history first, calendar navigates</span></p>
<div class="phone">
<div class="screen">
<div class="appbar">
<h2>What we ate</h2>
<p class="meta">Last five weeks · 3 Aug 6 Sep</p>
</div>
<div class="pad">
<div class="mini" aria-hidden="true">
<div class="wn">M</div><div class="wn">T</div><div class="wn">W</div><div class="wn">T</div>
<div class="wn">F</div><div class="wn">S</div><div class="wn">S</div>
</div>
<div class="mini">
<button>3<i class="dot d-meat"></i></button>
<button>4<i class="dot d-fish"></i></button>
<button class="empty">5</button>
<button>6<i class="dot d-meat"></i></button>
<button>7<i class="dot d-wild"></i></button>
<button>8<i class="dot d-vegetarian"></i></button>
<button>9<i class="dot d-fish"></i></button>
<button>10<i class="dot d-chicken"></i></button>
<button>11<i class="dot d-vegetarian"></i></button>
<button>12<i class="dot d-meat"></i></button>
<button class="empty">13</button>
<button>14<i class="dot d-chicken"></i></button>
<button>15<i class="dot d-meat"></i></button>
<button>16<i class="dot d-fish"></i></button>
<button>17<i class="dot d-meat"></i></button>
<button>18<i class="dot d-vegetarian"></i></button>
<button>19<i class="dot d-meat"></i></button>
<button class="empty">20</button>
<button>21<i class="dot d-wild"></i></button>
<button>22<i class="dot d-chicken"></i></button>
<button>23<i class="dot d-meat"></i></button>
<button>24<i class="dot d-fish"></i></button>
<button>25<i class="dot d-vegetarian"></i></button>
<button>26<i class="dot d-meat"></i></button>
<button>27<i class="dot d-chicken"></i></button>
<button class="empty">28</button>
<button>29<i class="dot d-meat"></i></button>
<button>30<i class="dot d-vegetarian"></i></button>
<button>31<i class="dot d-meat"></i></button>
<button>1<i class="dot d-chicken"></i></button>
<button>2<i class="dot d-fish"></i></button>
<button>3<i class="dot d-meat"></i></button>
<button class="empty">4</button>
<button class="today">5</button>
<button class="future">6</button>
</div>
<div class="tally">
<b>30</b><span>of 35 logged</span>
<span class="bar"><i style="width:86%"></i></span>
</div>
<div class="tonight">
<p class="k">Tonight · Sat 5 Sep</p>
<button class="primary">Log tonight's dinner</button>
</div>
<p class="sechead">Recent</p>
<div class="dayrow">
<time>Thu 3</time>
<div><div class="nm">Lihapullat</div><div class="sd">Vihersalaatti</div></div>
<span class="chev"></span>
</div>
<div class="dayrow">
<time>Wed 2</time>
<div><div class="nm">Lohikeitto</div><div class="sd">Ruisleipä</div></div>
<span class="chev"></span>
</div>
<div class="dayrow">
<time>Tue 1</time>
<div><div class="nm">Kanacurry</div><div class="sd">Riisi</div></div>
<span class="chev"></span>
</div>
</div>
</div>
<nav class="tabbar">
<a href="#" aria-current="page"><i></i>Log</a>
<a href="#"><i></i>History</a>
<a href="#"><i></i>Catalog</a>
</nav>
</div>
</div>
<!-- ================= 2 · TAP BOARD ================= -->
<div class="framewrap" id="f2">
<p class="framelbl">2 · Tap board <span>— fastest entry, sheet in the thumb zone</span></p>
<div class="phone">
<div class="screen sheetwrap">
<div class="appbar">
<h2>Tap what you ate</h2>
<div class="dayseg">
<button aria-pressed="true">Today</button>
<button aria-pressed="false">Yesterday</button>
<input type="date" value="2026-09-05" aria-label="Another date">
</div>
</div>
<div class="pad" style="padding-bottom:270px">
<input class="filter" type="search" placeholder="Filter dishes…" aria-label="Filter dishes">
<div class="board">
<button class="pill xl"><i class="dot d-meat"></i>Lihapullat <span class="n">12</span></button>
<button class="pill xl"><i class="dot d-meat"></i>Jauhelihakastike <span class="n">11</span></button>
<button class="pill lg picked"><i class="dot d-fish"></i>Lohikeitto <span class="n">9</span></button>
<button class="pill lg"><i class="dot d-chicken"></i>Kanacurry <span class="n">8</span></button>
<button class="pill lg"><i class="dot d-meat"></i>Makaronilaatikko <span class="n">7</span></button>
<button class="pill lg"><i class="dot d-chicken"></i>Broilerikastike <span class="n">6</span></button>
<button class="pill md"><i class="dot d-fish"></i>Uunilohi <span class="n">5</span></button>
<button class="pill md"><i class="dot d-vegetarian"></i>Kasvispyörykät <span class="n">5</span></button>
<button class="pill md"><i class="dot d-wild"></i>Tortillat <span class="n">4</span></button>
<button class="pill md"><i class="dot d-vegetarian"></i>Hernekeitto <span class="n">4</span></button>
<button class="pill md"><i class="dot d-meat"></i>Uunimakkara <span class="n">3</span></button>
<button class="pill sm"><i class="dot d-meat"></i>Kaalilaatikko <span class="n">2</span></button>
<button class="pill sm"><i class="dot d-wild"></i>Kotipizza <span class="n">2</span></button>
<button class="pill sm"><i class="dot d-fish"></i>Kalapuikot <span class="n">1</span></button>
</div>
</div>
<div class="sheet">
<div class="grab"></div>
<h3>Lohikeitto</h3>
<p class="q">Anything on the side?</p>
<div class="chips">
<button aria-pressed="true">Ruisleipä</button>
<button aria-pressed="false">Perunamuusi</button>
<button aria-pressed="false">Riisi</button>
<button aria-pressed="false">Vihersalaatti</button>
</div>
<button class="primary">Save Saturday</button>
<button class="ghost">Cancel</button>
</div>
</div>
<nav class="tabbar">
<a href="#" aria-current="page"><i></i>Log</a>
<a href="#"><i></i>History</a>
<a href="#"><i></i>Catalog</a>
</nav>
</div>
</div>
<!-- ================= 3 · JOURNAL ================= -->
<div class="framewrap" id="f3">
<p class="framelbl">3 · Journal <span>— one column, gaps called out inline</span></p>
<div class="phone">
<div class="screen">
<div class="appbar">
<h2>Dinner journal</h2>
<p class="meta">One line per night</p>
</div>
<div class="pad">
<div class="jtonight">
<p class="k">Tonight · Sat 5 Sep</p>
<p>Nothing written down yet.</p>
<button>What did we eat?</button>
</div>
<div class="monthrule">September</div>
<div class="gapline"><time>Fri 4</time><span>Not logged</span><button>Fill in</button></div>
<div class="entry">
<time>Thu 3</time>
<div><div class="nm"><i class="dot d-meat"></i>Lihapullat</div><div class="sd">Vihersalaatti</div></div>
<span class="chev"></span>
</div>
<div class="entry">
<time>Wed 2</time>
<div><div class="nm"><i class="dot d-fish"></i>Lohikeitto</div><div class="sd">Ruisleipä</div></div>
<span class="chev"></span>
</div>
<div class="entry">
<time>Tue 1</time>
<div><div class="nm"><i class="dot d-chicken"></i>Kanacurry</div><div class="sd">Riisi</div></div>
<span class="chev"></span>
</div>
<div class="monthrule">August</div>
<div class="entry">
<time>Mon 31</time>
<div><div class="nm"><i class="dot d-meat"></i>Uunimakkara</div><div class="sd">Perunamuusi</div></div>
<span class="chev"></span>
</div>
<div class="entry">
<time>Sun 30</time>
<div><div class="nm"><i class="dot d-vegetarian"></i>Kasvispyörykät</div><div class="sd">Served on its own</div></div>
<span class="chev"></span>
</div>
<div class="entry">
<time>Sat 29</time>
<div><div class="nm"><i class="dot d-meat"></i>Lihapullat</div><div class="sd">Perunamuusi</div></div>
<span class="chev"></span>
</div>
<div class="gapline"><time>Fri 28</time><span>Not logged</span><button>Fill in</button></div>
<div class="entry">
<time>Thu 27</time>
<div><div class="nm"><i class="dot d-chicken"></i>Kanacurry</div><div class="sd">Riisi</div></div>
<span class="chev"></span>
</div>
</div>
</div>
<nav class="tabbar">
<a href="#" aria-current="page"><i></i>Log</a>
<a href="#"><i></i>History</a>
<a href="#"><i></i>Catalog</a>
</nav>
</div>
</div>
</div>
</div>
<script>
const h=document.documentElement, tb=document.getElementById('t'), m=['auto','light','dark'];
tb.onclick=()=>{const n=m[(m.indexOf(h.dataset.theme)+1)%3];h.dataset.theme=n;tb.textContent='Theme: '+n;};
document.querySelectorAll('.switch button').forEach(b=>b.onclick=()=>{
document.querySelectorAll('.switch button').forEach(x=>x.setAttribute('aria-pressed',x===b));
document.querySelectorAll('.framewrap').forEach(f=>f.classList.toggle('on',f.id===b.dataset.v));
});
document.querySelectorAll('.chips button,.dayseg button').forEach(b=>b.onclick=()=>
b.setAttribute('aria-pressed', b.getAttribute('aria-pressed')!=='true'));
</script>
</body>
</html>