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:
@@ -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
@@ -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
|
||||||
@@ -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"]
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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 1–2 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.
|
||||||
@@ -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).
|
||||||
@@ -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:
|
||||||
@@ -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­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­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­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­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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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 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>
|
||||||
@@ -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 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>
|
||||||
Reference in New Issue
Block a user