Compare commits
39
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9f2393c96 | ||
|
|
cc735aba82 | ||
|
|
385474ecef | ||
|
|
8b80c3fa6f | ||
|
|
ec078116b4 | ||
|
|
2e1b8d24f9 | ||
|
|
9448712a8a | ||
|
|
64c1b3605b | ||
|
|
0b4857c225 | ||
|
|
c6c392730f | ||
|
|
4a3d192c2c | ||
|
|
9b6ea9880e | ||
|
|
a341d67467 | ||
|
|
e62b9601a2 | ||
|
|
3bceb44ae3 | ||
|
|
c130efb180 | ||
|
|
f781930832 | ||
|
|
16d5af83f7 | ||
|
|
38c7d01e03 | ||
|
|
5efaeb63c9 | ||
|
|
eb82263ead | ||
|
|
84ce75dc14 | ||
|
|
2237c199c1 | ||
|
|
0a0356b86e | ||
|
|
84fb396c7d | ||
|
|
df5c4aef48 | ||
|
|
e51bfd3dca | ||
|
|
19fc1e4dcb | ||
|
|
0a160f0800 | ||
|
|
1cc3f72a62 | ||
|
|
7c9e5b0d0d | ||
|
|
45e67c918f | ||
|
|
8d6c471b10 | ||
|
|
1a63da77f3 | ||
|
|
eec422661b | ||
|
|
792714c6d5 | ||
|
|
5a780cefa8 | ||
|
|
306d256832 | ||
|
|
5ef82748ea |
@@ -0,0 +1,60 @@
|
||||
name: Test and deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: roast-command-center-production
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
test-and-deploy:
|
||||
runs-on: unraid
|
||||
steps:
|
||||
- name: Check out the pushed revision
|
||||
uses: https://github.com/actions/checkout@v4
|
||||
|
||||
# Host-mode action workspaces are ephemeral runner internals. Copy the checked-out SHA
|
||||
# to a normal writable path so Node tests and Docker Compose see the same source tree.
|
||||
- name: Prepare isolated build directory
|
||||
run: |
|
||||
export CI_WORKDIR="/tmp/roast-command-center-${GITHUB_RUN_ID}"
|
||||
rm -rf "$CI_WORKDIR"
|
||||
mkdir -p "$CI_WORKDIR"
|
||||
cp -a ./. "$CI_WORKDIR/"
|
||||
echo "CI_WORKDIR=$CI_WORKDIR" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Install dependencies
|
||||
run: cd "$CI_WORKDIR" && npm ci
|
||||
|
||||
- name: Test
|
||||
run: cd "$CI_WORKDIR" && npm test
|
||||
|
||||
- name: Deploy to Unraid Docker
|
||||
run: |
|
||||
cd "$CI_WORKDIR"
|
||||
docker compose \
|
||||
--project-name roast_command_center \
|
||||
--env-file /run/roast-production.env \
|
||||
-f docker-compose.yml \
|
||||
up -d --build --remove-orphans
|
||||
|
||||
- name: Verify the running container
|
||||
run: |
|
||||
cd "$CI_WORKDIR"
|
||||
for attempt in {1..15}; do
|
||||
if docker compose \
|
||||
--project-name roast_command_center \
|
||||
--env-file /run/roast-production.env \
|
||||
-f docker-compose.yml \
|
||||
exec -T app node -e "fetch('http://127.0.0.1:8090/').then((response) => process.exit(response.ok ? 0 : 1))"; then
|
||||
break
|
||||
fi
|
||||
if [ "$attempt" -eq 15 ]; then
|
||||
exit 1
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
curl --fail --retry 10 --retry-delay 2 https://roast.srmr.xyz/
|
||||
@@ -4,3 +4,5 @@ data/
|
||||
*.log
|
||||
.DS_Store
|
||||
dev-harness.mjs
|
||||
.scratch/
|
||||
.env
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
Run `npm test` before every commit and whenever changing authentication, API authorization, service-worker behavior, or persistence. Add a regression test under `test/` for every new route and for every security/ownership bug. Tests use an in-memory PostgreSQL-compatible database; production migration is run automatically at startup against `DATABASE_URL`.
|
||||
|
||||
Every change pushed to the remote must be deployed to the Unraid Docker service immediately afterward. Verify the deployed container is healthy and the production endpoint responds before reporting completion.
|
||||
Pushes to `master` automatically run `.gitea/workflows/deploy.yml`: it installs dependencies, runs `npm test`, builds/recreates the Unraid Docker project, and verifies both the container and `https://roast.srmr.xyz/`. Watch each run in **Repository → Actions**; do not report a pushed change complete until its Actions run succeeds. Production runs from `/mnt/user/dev/roast_command_center` on xNAS. The self-hosted Gitea runner is the single `gitea-act-runner` container (registered as `unraid-roast-deployer`); its custom image includes Node, Git, Docker Compose, and curl, and it reads the protected production environment file as `/run/roast-production.env`. Do not create duplicate runners, expose a runner registration token in a command/log/commit, or manually redeploy after a successful pipeline.
|
||||
|
||||
The workflow copies its checked-out revision to an isolated `/tmp` build directory before testing and deploying because the runner's host-executor workspace is ephemeral. `docker-compose.yml` accepts `APP_PORT` (production defaults to `8090`); the container-startup test uses `18090` so CI can run alongside production without binding its port.
|
||||
|
||||
Docker builds run `scripts/stamp-asset-version.mjs`, which replaces `__ASSET_VERSION__` in browser assets and the service-worker cache. Keep those markers intact: each build must produce fresh CSS/JS URLs so Cloudflare cannot serve a mixed stale frontend. The Docker entrypoint copies the read-only `PI_AGENT_CONFIG_DIR` seed mounted at `/run/pi-agent-config` into writable `/home/node/.pi/agent`; Pi locks `auth.json` at runtime, so do not mount that directory directly over the runtime path or prefill will lose configured model credentials.
|
||||
|
||||
For local development, copy `.env.example`, start `docker compose up --build`, then visit `http://localhost:8090`. Production must set `APP_ORIGIN=https://roast.srmr.xyz`, `COOKIE_SECURE=true`, a strong `POSTGRES_PASSWORD`, and a random `BOOTSTRAP_SETUP_TOKEN`. Bootstrap `[email protected]` exactly once at `POST /api/auth/bootstrap`, then remove `BOOTSTRAP_SETUP_TOKEN` from deployment configuration. Never commit credentials or a bootstrap password/token.
|
||||
|
||||
+7
-1
@@ -10,10 +10,16 @@ COPY public ./public
|
||||
COPY server ./server
|
||||
COPY shared ./shared
|
||||
COPY db/migrations ./db/migrations
|
||||
COPY scripts ./scripts
|
||||
RUN node scripts/stamp-asset-version.mjs
|
||||
COPY docker-entrypoint.sh /usr/local/bin/roast-command-center-entrypoint
|
||||
RUN chmod 755 /usr/local/bin/roast-command-center-entrypoint
|
||||
|
||||
# The optional Pi agent configuration is mounted read-only here at runtime.
|
||||
# The optional Pi agent configuration is mounted read-only at /run and copied to this writable
|
||||
# runtime directory by the entrypoint; Pi's auth storage needs a sibling lock file.
|
||||
ENV HOME=/home/node
|
||||
RUN mkdir -p /home/node/.pi/agent && chown -R node:node /home/node/.pi
|
||||
USER node
|
||||
EXPOSE 8090
|
||||
ENTRYPOINT ["roast-command-center-entrypoint"]
|
||||
CMD ["node", "server/index.js"]
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
PORT ?= 8090
|
||||
|
||||
.PHONY: all install start dev clean restart
|
||||
|
||||
all: install
|
||||
|
||||
## Install dependencies
|
||||
install:
|
||||
npm install
|
||||
|
||||
## Start the server in production mode
|
||||
start:
|
||||
PORT=$(PORT) npm start
|
||||
|
||||
## Start the server in development mode (auto-restart on file changes)
|
||||
dev:
|
||||
PORT=$(PORT) npm run dev
|
||||
|
||||
## Remove node_modules and lockfile
|
||||
clean:
|
||||
rm -rf node_modules package-lock.json
|
||||
|
||||
## Restart the server
|
||||
restart: clean install start
|
||||
@@ -12,6 +12,37 @@ A fillable, live-computing web version of the manual coffee roast plan worksheet
|
||||
- **Reference curve from an Artisan `.alog`** — upload a real roast log (or browse a local
|
||||
library directory) to overlay its actual milestones/curve on the same SVG grid as your
|
||||
plan, dashed and in a different color.
|
||||
- **Finished-roast log with Pi agent review** — upload one or more finished Artisan `.alog`
|
||||
files against a plan (or standalone from `/roasts`); each is stored verbatim (downloadable
|
||||
as a backup), parsed, and deep-reviewed asynchronously by the same zero-tool Pi agent
|
||||
pattern as prefill (grade, highlights, concerns, next-batch suggestions, plan-vs-actual).
|
||||
The `/roasts` page lists every actual roast and renders its curve with the plan overlaid.
|
||||
- **Brewing section** — the app is split into Roasting and Brewing workflows in the nav.
|
||||
`/beans` manages roasted/purchased coffee (with LLM URL prefill from a roaster's product
|
||||
page); `/brews` logs every cup against a bean with a silhouette brewer picker (immersion /
|
||||
percolation / espresso & pressure — 13 methods), recipe fields (dose, water/yield, auto
|
||||
ratio, grind, temp, times), a 0–10 rating, and tasting notes. Bean remaining weight is
|
||||
derived from logged brew doses. Green inventory's lot form also prefills from a URL.
|
||||
- **Plan chat + learned roaster behavior** — an "Ask the LLM" drawer on the planner chats
|
||||
about the open plan, grounded in the computed ledger, the learned pace profile, and a
|
||||
roaster-behavior profile (`/api/roaster-profile`) aggregated from every uploaded .alog
|
||||
(turning-point lag, phase RoR, median milestone temps). The same profile feeds roast
|
||||
reviews and supplies the planner curve's fallback milestone temps.
|
||||
- **API + tokens + Swagger** — every capability (including admin) is a JSON endpoint,
|
||||
documented by a hand-authored OpenAPI 3 spec at `/api/openapi.json` and a self-hosted
|
||||
Swagger UI at `/api-docs`. Users generate bearer tokens (`rpt_…`) on the Account page;
|
||||
token requests skip CSRF (header-borne credentials can't be forged cross-site).
|
||||
- **Backup** — Admin → Backup exports the entire database as one JSON file and can import
|
||||
it back transactionally (delete-and-restore, refuses backups with no active admin, keeps
|
||||
the importing admin's session when possible). Users can download their own data from the
|
||||
Account page.
|
||||
- **Academy** — `/academy` teaches roasting and brewing as narrated, animated slide lessons:
|
||||
green-bean physics through every roast stage (drying, Maillard, first crack, development,
|
||||
curve reading, defects) and extraction plus technique for every brewer family. Narration is
|
||||
Azure TTS (`en-US-Andrew` HD voice), pre-generated to committed MP3s by
|
||||
`scripts/generate-academy-audio.mjs` (reads `AZURE_VOICE_KEY`/`AZURE_VOICE` from `.env`,
|
||||
region eastus — run it locally after editing lesson scripts; production never needs the
|
||||
key). Slides carry display transcripts as captions and phonetic respellings for the voice.
|
||||
- **Live ledger** — the worksheet's time-ledger math (first crack, yellow, Maillard,
|
||||
development, drop, and the four sanity-check ratios) recomputes as you type, using the
|
||||
exact same arithmetic as the paper worksheet (verified against both its worked examples).
|
||||
@@ -36,7 +67,7 @@ Generate `BOOTSTRAP_SETUP_TOKEN` with `openssl rand -base64 48`, keep it only in
|
||||
|
||||
### Pi agent configuration in Docker
|
||||
|
||||
The `app` service mounts `PI_AGENT_CONFIG_DIR` (default `./appdata/pi-agent`) read-only at `/home/node/.pi/agent`, the non-root Node user's Pi configuration directory. This lets `/api/prefill` use the same configured model at runtime without baking credentials into the image. The directory is ignored by Git and Docker build context; do not commit its contents.
|
||||
The `app` service mounts `PI_AGENT_CONFIG_DIR` (default `./appdata/pi-agent`) read-only at `/run/pi-agent-config`. Its entrypoint copies that seed into the non-root Node user's writable runtime configuration directory before startup: Pi's credential storage needs to create a lock beside `auth.json`. This lets `/api/prefill` use the configured model without baking credentials into the image or mutating the host configuration. The directory is ignored by Git and Docker build context; do not commit its contents.
|
||||
|
||||
Before bringing up the stack, sync only the local Pi agent configuration you intend to make available to the container:
|
||||
|
||||
@@ -54,7 +85,10 @@ The planner is responsive and caches its app shell for offline use after the fir
|
||||
|
||||
Prefill requires a model configured via the `pi` CLI (`~/.pi/agent/{models,auth}.json`) —
|
||||
without one, `/api/prefill` returns `503 no_model` rather than crashing. Everything else
|
||||
(the form, ledger, curve, `.alog` upload) works with no model configured.
|
||||
(the form, ledger, curve, `.alog` upload) works with no model configured. Which configured
|
||||
model runs prefill and roast reviews is chosen in the admin page's **LLM** section
|
||||
(`app_settings.llm_model`, "Auto" = first available); the UI refers to the model backend
|
||||
simply as "the LLM".
|
||||
|
||||
## Layout
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
-- Additive only: existing tables, rows, and columns are untouched.
|
||||
-- Finished-roast .alog uploads: the raw file is retained verbatim (original_content) so the
|
||||
-- user can always download their own backup; parsed is the parseAlog() output the UI renders;
|
||||
-- evaluation is the Pi agent's review, written asynchronously after upload.
|
||||
CREATE TABLE actual_roasts (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
roast_plan_id uuid REFERENCES roast_plans(id) ON DELETE SET NULL,
|
||||
filename text NOT NULL,
|
||||
original_content text NOT NULL,
|
||||
parsed jsonb NOT NULL,
|
||||
evaluation jsonb,
|
||||
evaluation_status text NOT NULL DEFAULT 'pending',
|
||||
evaluation_error text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX actual_roasts_user ON actual_roasts(user_id, created_at DESC);
|
||||
CREATE INDEX actual_roasts_plan ON actual_roasts(roast_plan_id);
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Additive only. Admin-selectable LLM model ("provider:id") for prefill and roast reviews;
|
||||
-- empty means auto (first available configured model).
|
||||
INSERT INTO app_settings(key,value) VALUES ('llm_model','');
|
||||
@@ -0,0 +1,53 @@
|
||||
-- Additive only. Brewing side of the app: roasted/purchased bean management, brew logs, and
|
||||
-- user-generated API tokens. Bean remaining weight is computed (initial - sum of brew doses),
|
||||
-- never stored, so it can't drift.
|
||||
CREATE TABLE roasted_beans (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name text NOT NULL,
|
||||
roaster text NOT NULL DEFAULT '',
|
||||
origin text NOT NULL DEFAULT '',
|
||||
process text NOT NULL DEFAULT '',
|
||||
variety text NOT NULL DEFAULT '',
|
||||
roast_level text NOT NULL DEFAULT '',
|
||||
roast_date date,
|
||||
initial_weight_g numeric,
|
||||
url text NOT NULL DEFAULT '',
|
||||
tasting_notes text NOT NULL DEFAULT '',
|
||||
notes text NOT NULL DEFAULT '',
|
||||
roast_plan_id uuid REFERENCES roast_plans(id) ON DELETE SET NULL,
|
||||
archived boolean NOT NULL DEFAULT false,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE TABLE brews (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
bean_id uuid REFERENCES roasted_beans(id) ON DELETE SET NULL,
|
||||
method text NOT NULL,
|
||||
dose_g numeric,
|
||||
water_g numeric,
|
||||
yield_g numeric,
|
||||
grinder text NOT NULL DEFAULT '',
|
||||
grind_setting text NOT NULL DEFAULT '',
|
||||
water_temp_c numeric,
|
||||
brew_time_s integer,
|
||||
bloom_time_s integer,
|
||||
rating numeric,
|
||||
tasting_notes text NOT NULL DEFAULT '',
|
||||
notes text NOT NULL DEFAULT '',
|
||||
brewed_at timestamptz NOT NULL DEFAULT now(),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE TABLE api_tokens (
|
||||
token_hash text PRIMARY KEY,
|
||||
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name text NOT NULL DEFAULT '',
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
last_used_at timestamptz
|
||||
);
|
||||
CREATE INDEX roasted_beans_user ON roasted_beans(user_id, archived, created_at DESC);
|
||||
CREATE INDEX brews_user ON brews(user_id, brewed_at DESC);
|
||||
CREATE INDEX brews_bean ON brews(bean_id);
|
||||
CREATE INDEX api_tokens_user ON api_tokens(user_id);
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Additive only. Free-text recipe/steps for a brew (bloom, pours, plunge timing...) —
|
||||
-- distinct from tasting_notes (how it tasted) and notes (what to change next time).
|
||||
ALTER TABLE brews ADD COLUMN recipe text NOT NULL DEFAULT '';
|
||||
@@ -0,0 +1,8 @@
|
||||
-- Additive only. Per-user owned equipment: which brewers the Brews page should offer
|
||||
-- (subset of the shared brew-method keys) and the user's grinders (free-text names).
|
||||
CREATE TABLE user_gear (
|
||||
user_id uuid PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
brewers jsonb NOT NULL DEFAULT '[]',
|
||||
grinders jsonb NOT NULL DEFAULT '[]',
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
@@ -0,0 +1,17 @@
|
||||
-- Additive only. Per-user roasting machines: uploaded .alogs attach to one, learning is
|
||||
-- computed per roaster, and `overrides` stores the user's manual tweaks to what the learned
|
||||
-- profile would otherwise apply to their plans (milestone temps, TP time, pace factor).
|
||||
CREATE TABLE roasters (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name text NOT NULL,
|
||||
model text NOT NULL DEFAULT '',
|
||||
notes text NOT NULL DEFAULT '',
|
||||
is_default boolean NOT NULL DEFAULT false,
|
||||
overrides jsonb NOT NULL DEFAULT '{}',
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX roasters_user ON roasters(user_id, created_at);
|
||||
ALTER TABLE actual_roasts ADD COLUMN roaster_id uuid REFERENCES roasters(id) ON DELETE SET NULL;
|
||||
CREATE INDEX actual_roasts_roaster ON actual_roasts(roaster_id);
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Additive only. Account profile picture, stored as a small client-side-resized data URL.
|
||||
ALTER TABLE users ADD COLUMN avatar text;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Additive only. "After the roast" moves off the plan worksheet onto the actual roast row —
|
||||
-- one system: the uploaded .alog, its review, and the post-roast observations live together.
|
||||
ALTER TABLE actual_roasts ADD COLUMN after jsonb NOT NULL DEFAULT '{}';
|
||||
@@ -0,0 +1,5 @@
|
||||
-- Additive only. Custom display names for roast plans and green bean lots, layered on top of
|
||||
-- (not replacing) worksheet field 0.1 "Coffee / lot" — COALESCE(NULLIF(name,''), field 0.1) is
|
||||
-- the fallback everywhere a plan title is shown.
|
||||
ALTER TABLE roast_plans ADD COLUMN name text NOT NULL DEFAULT '';
|
||||
ALTER TABLE green_bean_lots ADD COLUMN name text NOT NULL DEFAULT '';
|
||||
+3
-3
@@ -15,13 +15,13 @@ services:
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
volumes:
|
||||
# Mount only non-secret Pi agent model/auth configuration; keep it read-only.
|
||||
- ${PI_AGENT_CONFIG_DIR:-./appdata/pi-agent}:/home/node/.pi/agent:ro
|
||||
# Seed Pi's writable runtime config from this read-only credential mount.
|
||||
- ${PI_AGENT_CONFIG_DIR:-./appdata/pi-agent}:/run/pi-agent-config:ro
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
# Nginx Proxy Manager routes https://roast.srmr.xyz to this host port.
|
||||
ports: ["8090:8090"]
|
||||
ports: ["${APP_PORT:-8090}:8090"]
|
||||
restart: unless-stopped
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
# The mounted Pi configuration contains credentials and must stay read-only. Pi's auth storage
|
||||
# takes a lock beside auth.json, so seed a writable per-container copy before the app starts.
|
||||
# That lets ModelRuntime read and refresh credentials without ever mutating the host mount.
|
||||
if [ -d /run/pi-agent-config ]; then
|
||||
mkdir -p "$HOME/.pi/agent"
|
||||
cp -a /run/pi-agent-config/. "$HOME/.pi/agent/"
|
||||
chmod -R u+rwX "$HOME/.pi/agent"
|
||||
fi
|
||||
|
||||
exec "$@"
|
||||
Generated
+18
-1
@@ -12,7 +12,8 @@
|
||||
"bcryptjs": "^3.0.3",
|
||||
"express": "^5.0.1",
|
||||
"nodemailer": "^9.0.3",
|
||||
"pg": "^8.22.0"
|
||||
"pg": "^8.22.0",
|
||||
"swagger-ui-dist": "^5.32.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"pg-mem": "^3.0.14",
|
||||
@@ -1861,6 +1862,13 @@
|
||||
"@noble/hashes": "^1.1.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@scarf/scarf": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz",
|
||||
"integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/accepts": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
|
||||
@@ -3330,6 +3338,15 @@
|
||||
"node": ">=14.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/swagger-ui-dist": {
|
||||
"version": "5.32.12",
|
||||
"resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.32.12.tgz",
|
||||
"integrity": "sha512-ceXE+KNc5LXafhh36trB6nxK+U2EIKUWzHT7sBiMosf2eJLAefalYnTwMouQBmSyED6m7Yz+GYZEcL+Glt3sww==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@scarf/scarf": "=1.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/toidentifier": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
||||
|
||||
+2
-1
@@ -14,7 +14,8 @@
|
||||
"bcryptjs": "^3.0.3",
|
||||
"express": "^5.0.1",
|
||||
"nodemailer": "^9.0.3",
|
||||
"pg": "^8.22.0"
|
||||
"pg": "^8.22.0",
|
||||
"swagger-ui-dist": "^5.32.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"pg-mem": "^3.0.14",
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,112 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>Academy — Roast Planner</title>
|
||||
<link rel="stylesheet" href="/app.css?v=__ASSET_VERSION__" />
|
||||
<link rel="icon" href="/icon.svg?v=__ASSET_VERSION__" type="image/svg+xml" />
|
||||
<meta name="theme-color" content="#A8481A" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-shell">
|
||||
<nav class="side-nav" id="side-nav" aria-label="Main navigation">
|
||||
<div class="side-nav-brand">
|
||||
<img class="brand-mark" src="/brand-mark.svg?v=__ASSET_VERSION__" alt="" aria-hidden="true" />
|
||||
<span class="brand-name">Roast Planner</span>
|
||||
</div>
|
||||
<div id="nav-links"></div>
|
||||
<div class="nav-spacer"></div>
|
||||
<button
|
||||
class="icon-btn nav-collapse-toggle"
|
||||
type="button"
|
||||
id="nav-collapse"
|
||||
aria-label="Collapse navigation"
|
||||
title="Collapse navigation"
|
||||
>
|
||||
«
|
||||
</button>
|
||||
<div class="nav-user">
|
||||
<div class="nav-user-avatar" id="nav-user-avatar" aria-hidden="true"></div>
|
||||
<div class="nav-user-detail">
|
||||
<span class="nav-user-email" id="account-email"></span>
|
||||
<button class="nav-user-logout" type="button" id="btn-logout">
|
||||
Log out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="app-workspace" id="app-workspace">
|
||||
<header class="app-header">
|
||||
<div class="header-row-top">
|
||||
<button
|
||||
class="icon-btn nav-hamburger"
|
||||
type="button"
|
||||
id="nav-hamburger"
|
||||
aria-label="Open navigation"
|
||||
aria-expanded="false"
|
||||
>
|
||||
☰
|
||||
</button>
|
||||
<div class="brand">
|
||||
<div class="brand-text">
|
||||
<h1>Academy</h1>
|
||||
<p class="brand-sub">
|
||||
Roasting and brewing, taught from the bean's point of view
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div class="drawer-overlay hidden" id="drawer-overlay"></div>
|
||||
|
||||
<main class="page-content">
|
||||
<div id="academy-menu"></div>
|
||||
|
||||
<section class="panel-card hidden" id="academy-player">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h2 id="player-slide-title" style="margin:0"></h2>
|
||||
<span class="muted" id="player-lesson" style="font-size:12.5px"></span>
|
||||
</div>
|
||||
<button class="ghost-btn small" type="button" id="player-close">
|
||||
✕ Close
|
||||
</button>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="academy-stage">
|
||||
<svg
|
||||
id="player-scene"
|
||||
viewBox="0 0 560 300"
|
||||
role="img"
|
||||
aria-label="Lesson animation"
|
||||
></svg>
|
||||
</div>
|
||||
<p class="academy-caption" id="player-caption" aria-live="polite"></p>
|
||||
<div class="academy-controls">
|
||||
<button class="ghost-btn small" type="button" id="player-prev">
|
||||
← Back
|
||||
</button>
|
||||
<button
|
||||
class="ghost-btn small"
|
||||
type="button"
|
||||
id="player-play"
|
||||
aria-label="Pause narration"
|
||||
>
|
||||
❚❚
|
||||
</button>
|
||||
<div class="academy-dots" id="player-dots"></div>
|
||||
<span class="muted" id="player-count"></span>
|
||||
<button class="primary-btn" type="button" id="player-next">
|
||||
Next →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="/js/academy.js?v=__ASSET_VERSION__"></script>
|
||||
</body>
|
||||
</html>
|
||||
+89
-30
@@ -4,43 +4,18 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>Account — Roast Planner</title>
|
||||
<link rel="stylesheet" href="/app.css" />
|
||||
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
|
||||
<link rel="stylesheet" href="/app.css?v=__ASSET_VERSION__" />
|
||||
<link rel="icon" href="/icon.svg?v=__ASSET_VERSION__" type="image/svg+xml" />
|
||||
<meta name="theme-color" content="#A8481A" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-shell">
|
||||
<nav class="side-nav" id="side-nav" aria-label="Main navigation">
|
||||
<div class="side-nav-brand">
|
||||
<span class="brand-mark" aria-hidden="true">◐</span>
|
||||
<img class="brand-mark" src="/brand-mark.svg?v=__ASSET_VERSION__" alt="" aria-hidden="true" />
|
||||
<span class="brand-name">Roast Planner</span>
|
||||
</div>
|
||||
<div class="nav-group">
|
||||
<a class="nav-item" href="/app"
|
||||
><span class="nav-icon" aria-hidden="true">◐</span
|
||||
><span class="nav-label">Planner</span></a
|
||||
>
|
||||
<a class="nav-item" href="#your-plans"
|
||||
><span class="nav-icon" aria-hidden="true">▤</span
|
||||
><span class="nav-label">Plans</span></a
|
||||
>
|
||||
<a class="nav-item" href="/inventory"
|
||||
><span class="nav-icon" aria-hidden="true">▥</span
|
||||
><span class="nav-label">Inventory</span></a
|
||||
>
|
||||
<a class="nav-item" href="/cupping"
|
||||
><span class="nav-icon" aria-hidden="true">◒</span
|
||||
><span class="nav-label">Cupping</span></a
|
||||
>
|
||||
<a class="nav-item" href="/account" aria-current="page"
|
||||
><span class="nav-icon" aria-hidden="true">◔</span
|
||||
><span class="nav-label">Account</span></a
|
||||
>
|
||||
<a class="nav-item hidden" id="nav-admin" href="/admin"
|
||||
><span class="nav-icon" aria-hidden="true">⚙</span
|
||||
><span class="nav-label">Admin</span></a
|
||||
>
|
||||
</div>
|
||||
<div id="nav-links"></div>
|
||||
<div class="nav-spacer"></div>
|
||||
<button
|
||||
class="icon-btn nav-collapse-toggle"
|
||||
@@ -90,6 +65,29 @@
|
||||
<h2>Profile</h2>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div style="display:flex;align-items:center;gap:14px;margin-bottom:8px">
|
||||
<div
|
||||
class="nav-user-avatar"
|
||||
id="profile-avatar"
|
||||
style="width:64px;height:64px;font-size:24px;flex:none"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||
<label class="filebtn ghost-btn small"
|
||||
>Upload picture…<input
|
||||
type="file"
|
||||
id="avatar-file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
/></label>
|
||||
<button
|
||||
class="ghost-btn small hidden"
|
||||
type="button"
|
||||
id="avatar-remove"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p>
|
||||
<strong id="profile-email"></strong>
|
||||
<span class="badge badge-admin hidden" id="profile-role-admin"
|
||||
@@ -221,6 +219,67 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel-card" id="api-tokens">
|
||||
<div class="panel-head">
|
||||
<h2>API tokens</h2>
|
||||
<a class="ghost-btn small" href="/api-docs">API docs</a>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<p class="muted">
|
||||
Tokens authenticate API requests with an
|
||||
<code>Authorization: Bearer rpt_…</code> header — full access
|
||||
to your account, so treat them like passwords.
|
||||
</p>
|
||||
<form
|
||||
id="token-form"
|
||||
style="display:flex;gap:8px;flex-wrap:wrap;align-items:center"
|
||||
>
|
||||
<input
|
||||
class="text-input"
|
||||
name="name"
|
||||
placeholder="Token name (e.g. shortcuts, cli)"
|
||||
style="max-width:260px"
|
||||
/>
|
||||
<button class="ghost-btn" type="submit" id="token-create">
|
||||
Generate token
|
||||
</button>
|
||||
</form>
|
||||
<div class="hidden" id="token-reveal-wrap" style="margin-top:10px">
|
||||
<p class="field-note" style="margin:0 0 4px">
|
||||
Copy it now — it will never be shown again.
|
||||
</p>
|
||||
<div class="token-reveal" id="token-reveal"></div>
|
||||
</div>
|
||||
<div class="table-wrap" style="margin-top:12px">
|
||||
<table class="data-table" id="tokens-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Created</th>
|
||||
<th>Last used</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tokens-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel-card" id="my-data">
|
||||
<div class="panel-head"><h2>Your data</h2></div>
|
||||
<div class="panel-body">
|
||||
<p class="muted">
|
||||
Download everything you've put into Roast Planner — plans,
|
||||
roasts, inventory, cupping, beans, and brews — as one JSON
|
||||
file.
|
||||
</p>
|
||||
<a class="ghost-btn" href="/api/account/export" download
|
||||
>Download my data</a
|
||||
>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel-card danger-zone" id="danger">
|
||||
<div class="panel-head">
|
||||
<h2>Danger zone</h2>
|
||||
@@ -254,6 +313,6 @@
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="/js/account.js"></script>
|
||||
<script type="module" src="/js/account.js?v=__ASSET_VERSION__"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+50
-30
@@ -4,43 +4,18 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>Admin — Roast Planner</title>
|
||||
<link rel="stylesheet" href="/app.css" />
|
||||
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
|
||||
<link rel="stylesheet" href="/app.css?v=__ASSET_VERSION__" />
|
||||
<link rel="icon" href="/icon.svg?v=__ASSET_VERSION__" type="image/svg+xml" />
|
||||
<meta name="theme-color" content="#A8481A" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-shell">
|
||||
<nav class="side-nav" id="side-nav" aria-label="Main navigation">
|
||||
<div class="side-nav-brand">
|
||||
<span class="brand-mark" aria-hidden="true">◐</span>
|
||||
<img class="brand-mark" src="/brand-mark.svg?v=__ASSET_VERSION__" alt="" aria-hidden="true" />
|
||||
<span class="brand-name">Roast Planner</span>
|
||||
</div>
|
||||
<div class="nav-group">
|
||||
<a class="nav-item" href="/app"
|
||||
><span class="nav-icon" aria-hidden="true">◐</span
|
||||
><span class="nav-label">Planner</span></a
|
||||
>
|
||||
<a class="nav-item" href="#plans"
|
||||
><span class="nav-icon" aria-hidden="true">▤</span
|
||||
><span class="nav-label">Plans</span></a
|
||||
>
|
||||
<a class="nav-item" href="/inventory"
|
||||
><span class="nav-icon" aria-hidden="true">▥</span
|
||||
><span class="nav-label">Inventory</span></a
|
||||
>
|
||||
<a class="nav-item" href="/cupping"
|
||||
><span class="nav-icon" aria-hidden="true">◒</span
|
||||
><span class="nav-label">Cupping</span></a
|
||||
>
|
||||
<a class="nav-item" href="/account"
|
||||
><span class="nav-icon" aria-hidden="true">◔</span
|
||||
><span class="nav-label">Account</span></a
|
||||
>
|
||||
<a class="nav-item" href="/admin" aria-current="page"
|
||||
><span class="nav-icon" aria-hidden="true">⚙</span
|
||||
><span class="nav-label">Admin</span></a
|
||||
>
|
||||
</div>
|
||||
<div id="nav-links"></div>
|
||||
<div class="nav-spacer"></div>
|
||||
<button
|
||||
class="icon-btn nav-collapse-toggle"
|
||||
@@ -124,6 +99,51 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel-card" id="llm-card">
|
||||
<div class="panel-head"><h2>LLM</h2></div>
|
||||
<div class="panel-body">
|
||||
<p class="muted" style="margin-top:0">
|
||||
Model used for URL prefill and finished-roast reviews.
|
||||
</p>
|
||||
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
|
||||
<select
|
||||
class="text-input"
|
||||
id="llm-model-select"
|
||||
style="max-width:360px"
|
||||
aria-label="LLM model"
|
||||
></select>
|
||||
<button class="ghost-btn small" type="button" id="llm-model-save">
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
<p class="field-note" id="llm-model-note"></p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel-card" id="backup-card">
|
||||
<div class="panel-head"><h2>Backup</h2></div>
|
||||
<div class="panel-body">
|
||||
<p class="muted">
|
||||
Export downloads the <strong>entire database</strong> (all
|
||||
users and their data) as one JSON file. Import
|
||||
<strong>replaces everything</strong> with a previously
|
||||
exported file.
|
||||
</p>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">
|
||||
<a class="ghost-btn" href="/api/admin/backup" download
|
||||
>Export full backup</a
|
||||
>
|
||||
<label class="filebtn ghost-btn"
|
||||
>Import backup…<input
|
||||
type="file"
|
||||
id="backup-import-file"
|
||||
accept="application/json,.json"
|
||||
/></label>
|
||||
</div>
|
||||
<p class="field-note" id="backup-note"></p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel-card" id="users">
|
||||
<div class="panel-head">
|
||||
<h2>Users</h2>
|
||||
@@ -197,6 +217,6 @@
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="/js/admin.js"></script>
|
||||
<script type="module" src="/js/admin.js?v=__ASSET_VERSION__"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>API docs — Roast Planner</title>
|
||||
<link rel="stylesheet" href="/swagger/swagger-ui.css" />
|
||||
<link rel="icon" href="/icon.svg?v=__ASSET_VERSION__" type="image/svg+xml" />
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
.rp-docs-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 16px;
|
||||
background: #1b1614;
|
||||
color: #fbf8f4;
|
||||
font:
|
||||
14px -apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
sans-serif;
|
||||
}
|
||||
.rp-docs-bar a {
|
||||
color: #f3b28c;
|
||||
text-decoration: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="rp-docs-bar">
|
||||
<strong>Roast Planner API</strong>
|
||||
<span
|
||||
>Authenticate with your session (already active in this browser) or an
|
||||
API token from your <a href="/account#api-tokens">account page</a
|
||||
>.</span
|
||||
>
|
||||
<a href="/app" style="margin-left:auto">← Back to the app</a>
|
||||
</div>
|
||||
<div id="swagger-ui"></div>
|
||||
<script src="/swagger/swagger-ui-bundle.js"></script>
|
||||
<script src="/js/swagger-init.js?v=__ASSET_VERSION__"></script>
|
||||
</body>
|
||||
</html>
|
||||
+799
-24
@@ -76,13 +76,20 @@ output {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 30;
|
||||
height: var(--header-h);
|
||||
min-height: var(--header-h);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
gap: 8px 24px;
|
||||
background: var(--surface-raised);
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding: 0 20px;
|
||||
padding: 8px 20px;
|
||||
}
|
||||
/* The planner header is two rows tall (brand + tools, then the section pills) — its sticky
|
||||
offsets (dock, anchor scroll margins) must account for that. :has() scopes it to the one
|
||||
page that has the plan form. */
|
||||
body:has(#plan-form) {
|
||||
--header-h: 112px;
|
||||
}
|
||||
.header-row-top {
|
||||
display: contents;
|
||||
@@ -94,8 +101,9 @@ output {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.brand-mark {
|
||||
font-size: 22px;
|
||||
color: var(--ember);
|
||||
width: 20px;
|
||||
height: auto;
|
||||
display: block;
|
||||
line-height: 1;
|
||||
}
|
||||
.brand-text h1 {
|
||||
@@ -109,16 +117,16 @@ output {
|
||||
color: var(--ink-3);
|
||||
}
|
||||
|
||||
/* Always its own full-width row under the brand/actions — never squeezed beside them, never
|
||||
an overflow scroller (a hidden horizontal scroll region in a header is undiscoverable,
|
||||
especially on touch). At ≤720px it becomes a fixed four-column grid. */
|
||||
.section-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
flex: 1 1 auto;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.section-nav::-webkit-scrollbar {
|
||||
display: none;
|
||||
order: 3;
|
||||
flex-basis: 100%;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.section-nav a {
|
||||
flex: 0 0 auto;
|
||||
@@ -146,8 +154,11 @@ output {
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 0 0 auto;
|
||||
gap: 6px 8px;
|
||||
flex: 1 1 auto;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
min-width: 0;
|
||||
}
|
||||
.header-divider {
|
||||
width: 1px;
|
||||
@@ -430,6 +441,198 @@ label.filebtn input[type="file"] {
|
||||
background: var(--surface-sunken);
|
||||
}
|
||||
|
||||
/* ─── Field-help dialog ───────────────────────────────────────────────── */
|
||||
.help-dialog {
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--surface-raised);
|
||||
box-shadow: var(--shadow-pop);
|
||||
width: min(680px, 94vw);
|
||||
max-height: min(82vh, 780px);
|
||||
}
|
||||
.help-dialog::backdrop {
|
||||
background: rgba(27, 22, 20, 0.32);
|
||||
}
|
||||
.help-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: inherit;
|
||||
}
|
||||
.help-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 16px 20px 12px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.help-kicker {
|
||||
display: block;
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ember);
|
||||
}
|
||||
.help-head h3 {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.help-body {
|
||||
padding: 14px 20px;
|
||||
overflow-y: auto;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.55;
|
||||
color: var(--ink-2);
|
||||
}
|
||||
.help-body h4 {
|
||||
margin: 14px 0 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-2);
|
||||
}
|
||||
.help-body h4:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
.help-body p {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
.help-body dl {
|
||||
margin: 0;
|
||||
}
|
||||
.help-checklist {
|
||||
list-style: none;
|
||||
margin: 0 0 10px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
.help-checklist li {
|
||||
position: relative;
|
||||
padding-left: 20px;
|
||||
}
|
||||
.help-checklist li::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 5px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border: 1.5px solid var(--line-strong);
|
||||
border-radius: 2px;
|
||||
}
|
||||
.help-table-wrap {
|
||||
overflow-x: auto;
|
||||
margin: 6px 0 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.help-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
}
|
||||
.help-table th {
|
||||
text-align: left;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-3);
|
||||
padding: 7px 10px 5px;
|
||||
border-bottom: 1px solid var(--line-strong);
|
||||
background: var(--surface);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.help-table td {
|
||||
padding: 6px 10px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
vertical-align: top;
|
||||
}
|
||||
.help-table tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
.help-table td.num {
|
||||
font-family: var(--font-mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
font-size: 11.5px;
|
||||
}
|
||||
.help-table tr.grp td {
|
||||
background: var(--ember-soft);
|
||||
color: var(--ember-strong);
|
||||
font-weight: 700;
|
||||
font-size: 11px;
|
||||
}
|
||||
.help-table tr.current td {
|
||||
background: var(--ember-soft);
|
||||
box-shadow: inset 3px 0 0 var(--ember);
|
||||
}
|
||||
.help-aside {
|
||||
border-left: 3px solid var(--ember);
|
||||
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
|
||||
background: var(--ember-soft);
|
||||
padding: 10px 12px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
.help-aside .tag {
|
||||
display: block;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ember-strong);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.help-aside p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.help-aside.warn {
|
||||
border-left-color: var(--warn);
|
||||
background: var(--warn-bg);
|
||||
}
|
||||
.help-aside.warn .tag {
|
||||
color: var(--warn);
|
||||
}
|
||||
.help-formula {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
background: var(--surface-sunken);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 8px 10px;
|
||||
margin: 8px 0;
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.help-attrib {
|
||||
margin: 0;
|
||||
padding: 10px 20px 14px;
|
||||
border-top: 1px solid var(--line);
|
||||
font-size: 11px;
|
||||
color: var(--ink-3);
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.help-dialog {
|
||||
width: 100vw;
|
||||
max-width: none;
|
||||
margin: auto 0 0;
|
||||
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
|
||||
max-height: 88vh;
|
||||
}
|
||||
}
|
||||
@media print {
|
||||
.help-btn,
|
||||
.help-dialog {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Workspace layout ────────────────────────────────────────────────── */
|
||||
.workspace-grid {
|
||||
display: grid;
|
||||
@@ -592,6 +795,46 @@ label.filebtn input[type="file"] {
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.help-btn {
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: 50%;
|
||||
background: none;
|
||||
color: var(--ink-3);
|
||||
font: 600 9.5px/1 var(--font-ui);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
/* Keeps the 15px visual dot but gives touch/mouse a ~25px target (WCAG's 24px minimum) —
|
||||
matters on a mobile PWA. Kept modest rather than the fuller 44px so it doesn't swallow
|
||||
clicks meant for the label text 4px away or the input just below. */
|
||||
.help-btn::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -5px;
|
||||
}
|
||||
.help-btn:hover,
|
||||
.help-btn:focus-visible {
|
||||
border-color: var(--ember);
|
||||
color: var(--ember);
|
||||
background: var(--ember-soft);
|
||||
}
|
||||
.help-btn:focus-visible {
|
||||
outline: 2px solid var(--ember);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.subhead .help-btn,
|
||||
.dock-card-head .help-btn,
|
||||
.panel-head h2 .help-btn {
|
||||
margin-left: 6px;
|
||||
vertical-align: 1px;
|
||||
}
|
||||
/* ─── Teaching layer ─────────────────────────────────────────────────────
|
||||
Replaces the old per-field "?" tooltip system: a collapsible "why" panel
|
||||
per section, a permanent one-line note under only the causally-loaded
|
||||
@@ -1393,6 +1636,28 @@ body.show-fids .field[data-fid]::after {
|
||||
.check-tile.fail .check-why {
|
||||
display: block;
|
||||
}
|
||||
.check-tile.informational .check-why {
|
||||
display: block;
|
||||
}
|
||||
.check-tile.informational .check-band {
|
||||
font-style: italic;
|
||||
}
|
||||
.refine-suggestion {
|
||||
margin-top: 6px;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--ember-soft);
|
||||
background: var(--ember-soft);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
}
|
||||
.refine-suggestion span {
|
||||
font-size: 11.5px;
|
||||
color: var(--ink-2);
|
||||
line-height: 1.4;
|
||||
}
|
||||
.dock-note {
|
||||
font-size: 10.5px;
|
||||
color: var(--ink-3);
|
||||
@@ -1452,6 +1717,11 @@ body.show-fids .field[data-fid]::after {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
.plan-list-row {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 6px;
|
||||
}
|
||||
.plan-list-item {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
@@ -1465,6 +1735,10 @@ body.show-fids .field[data-fid]::after {
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.plan-list-delete {
|
||||
flex-shrink: 0;
|
||||
align-self: center;
|
||||
}
|
||||
.plan-list-item:hover,
|
||||
.plan-list-item.active {
|
||||
border-color: var(--ember);
|
||||
@@ -1508,8 +1782,9 @@ body.show-fids .field[data-fid]::after {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.side-nav-brand .brand-mark {
|
||||
color: var(--ember-soft, #fbefe6);
|
||||
font-size: 22px;
|
||||
width: 20px;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
.side-nav-brand span.brand-name {
|
||||
font-weight: 700;
|
||||
@@ -1992,8 +2267,9 @@ body.show-fids .field[data-fid]::after {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.auth-card .auth-brand .brand-mark {
|
||||
color: var(--ember);
|
||||
font-size: 22px;
|
||||
width: 20px;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
.auth-card h1 {
|
||||
font-size: 19px;
|
||||
@@ -2074,8 +2350,9 @@ body.show-fids .field[data-fid]::after {
|
||||
font-size: 15px;
|
||||
}
|
||||
.marketing-header .brand .brand-mark {
|
||||
color: var(--ember);
|
||||
font-size: 20px;
|
||||
width: 18px;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
.marketing-header nav {
|
||||
display: flex;
|
||||
@@ -2319,7 +2596,9 @@ body.show-fids .field[data-fid]::after {
|
||||
order: 0;
|
||||
}
|
||||
.brand-mark {
|
||||
font-size: 24px;
|
||||
width: 22px;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
.brand-text h1 {
|
||||
font-size: 16px;
|
||||
@@ -2330,13 +2609,19 @@ body.show-fids .field[data-fid]::after {
|
||||
.section-nav {
|
||||
width: 100%;
|
||||
order: 2;
|
||||
margin: 0 -2px;
|
||||
margin: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 4px;
|
||||
}
|
||||
.section-nav a {
|
||||
min-height: 40px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 7px 12px;
|
||||
justify-content: center;
|
||||
padding: 7px 2px;
|
||||
font-size: 11.5px;
|
||||
text-align: center;
|
||||
}
|
||||
.header-actions {
|
||||
order: 3;
|
||||
@@ -2344,12 +2629,22 @@ body.show-fids .field[data-fid]::after {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
/* The chip yields space to the buttons rather than forcing the row wide. */
|
||||
.header-actions .autosave-chip {
|
||||
flex: 1 1 auto;
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
.header-actions #btn-print {
|
||||
.header-actions .autosave-text {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.header-actions #btn-print,
|
||||
.header-actions #btn-expand-why {
|
||||
flex: 0 0 auto;
|
||||
min-height: 44px;
|
||||
padding: 8px 16px;
|
||||
@@ -2564,6 +2859,10 @@ body.show-fids .field[data-fid]::after {
|
||||
td.passcell.fail::before {
|
||||
content: "✗ ";
|
||||
}
|
||||
td.passcell.informational::before {
|
||||
content: "— ";
|
||||
color: var(--mid);
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Legacy .opt styling reused by the (hidden-on-screen) print worksheet's own radios ── */
|
||||
@@ -2598,3 +2897,479 @@ select.f {
|
||||
padding: 0 0.5mm;
|
||||
color: #1a1512;
|
||||
}
|
||||
|
||||
/* ── Brewer picker + brew log (brews/beans pages) ─────────────────────── */
|
||||
.brewer-cat-title {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-3);
|
||||
margin: 14px 0 6px;
|
||||
}
|
||||
.brewer-cat-title:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
.brewer-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(94px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
.brewer-tile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 10px 6px 8px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface-raised);
|
||||
cursor: pointer;
|
||||
color: var(--ink-2);
|
||||
font: inherit;
|
||||
transition: border-color 0.12s, background 0.12s, color 0.12s;
|
||||
}
|
||||
.brewer-tile:hover {
|
||||
border-color: var(--line-strong);
|
||||
color: var(--ink);
|
||||
}
|
||||
.brewer-tile.selected {
|
||||
border-color: var(--ember);
|
||||
color: var(--ember-strong);
|
||||
background: var(--ember-soft);
|
||||
box-shadow: inset 0 0 0 1px var(--ember);
|
||||
}
|
||||
.brewer-tile svg {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
display: block;
|
||||
}
|
||||
.brewer-tile .brewer-name {
|
||||
font-size: 11.5px;
|
||||
line-height: 1.2;
|
||||
text-align: center;
|
||||
}
|
||||
.method-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.method-chip svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex: none;
|
||||
color: var(--ink-2);
|
||||
}
|
||||
.rating-pill {
|
||||
display: inline-block;
|
||||
min-width: 34px;
|
||||
text-align: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--surface-sunken);
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 600;
|
||||
}
|
||||
.rating-pill.good {
|
||||
background: var(--pass-bg);
|
||||
color: var(--pass);
|
||||
}
|
||||
.rating-pill.poor {
|
||||
background: var(--fail-bg);
|
||||
color: var(--fail);
|
||||
}
|
||||
.token-reveal {
|
||||
background: var(--surface-sunken);
|
||||
border: 1px dashed var(--line-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 10px 12px;
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 12.5px;
|
||||
word-break: break-all;
|
||||
user-select: all;
|
||||
}
|
||||
|
||||
/* ── Plan chat drawer ─────────────────────────────────────────────────── */
|
||||
.chat-thread {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-height: 50vh;
|
||||
overflow-y: auto;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.chat-msg {
|
||||
padding: 8px 11px;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
white-space: pre-wrap;
|
||||
max-width: 92%;
|
||||
}
|
||||
.chat-msg.user {
|
||||
align-self: flex-end;
|
||||
background: var(--ember-soft);
|
||||
color: var(--ember-strong);
|
||||
}
|
||||
.chat-msg.assistant {
|
||||
align-self: flex-start;
|
||||
background: var(--surface-sunken);
|
||||
color: var(--ink);
|
||||
}
|
||||
.chat-msg.pending {
|
||||
color: var(--ink-3);
|
||||
font-style: italic;
|
||||
}
|
||||
.chat-input-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
.chat-input-row textarea {
|
||||
flex: 1;
|
||||
resize: vertical;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
/* ── Planner header tools (page-scoped actions moved out of the left nav) ──
|
||||
Desktop: the tools render inline in the header actions row (display: contents).
|
||||
≤720px: they collapse behind a single "Tools ▾" disclosure that opens a dropdown card, so
|
||||
the mobile header keeps its fixed three-row height (--header-h) with no wrapped or
|
||||
horizontally scrolling button rows. */
|
||||
.header-tools {
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
row-gap: 6px;
|
||||
max-width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.header-tools-toggle {
|
||||
display: none;
|
||||
}
|
||||
.header-tools-group {
|
||||
display: contents;
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.header-tools {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
.header-tools-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.header-tools-toggle[aria-expanded="true"] {
|
||||
background: var(--ember-soft);
|
||||
border-color: var(--ember);
|
||||
color: var(--ember-strong);
|
||||
}
|
||||
.header-tools-group {
|
||||
display: none;
|
||||
}
|
||||
.header-tools-group.open {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 6px;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: calc(100% + 6px);
|
||||
z-index: 70;
|
||||
min-width: 220px;
|
||||
padding: 10px;
|
||||
background: var(--surface-raised);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-pop);
|
||||
}
|
||||
.header-tools-group .ghost-btn {
|
||||
justify-content: flex-start;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
}
|
||||
.header-tools-group .header-divider {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
margin: 2px 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Academy ──────────────────────────────────────────────────────────── */
|
||||
.academy-lessons {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.academy-lesson {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface-raised);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
transition: border-color 0.12s, background 0.12s;
|
||||
}
|
||||
.academy-lesson:hover {
|
||||
border-color: var(--ember);
|
||||
background: var(--ember-soft);
|
||||
}
|
||||
.academy-lesson-num {
|
||||
flex: none;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
background: var(--surface-sunken);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
color: var(--ember);
|
||||
}
|
||||
.academy-lesson-label {
|
||||
font-weight: 600;
|
||||
flex: 1;
|
||||
}
|
||||
.academy-lesson-meta {
|
||||
font-size: 11.5px;
|
||||
color: var(--ink-3);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.academy-stage {
|
||||
/* Warm-lit paper: a top light and a faint ember glow rising from the floor, so the
|
||||
* scenes sit in an environment instead of on a flat panel. */
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 253, 248, 0.65), rgba(255, 253, 248, 0) 45%),
|
||||
radial-gradient(90% 60% at 50% 115%, rgba(168, 72, 26, 0.07), transparent 60%),
|
||||
var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 8px;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6), 0 1px 3px rgba(27, 22, 20, 0.05);
|
||||
}
|
||||
.academy-stage svg {
|
||||
width: 100%;
|
||||
max-width: 760px;
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.academy-caption {
|
||||
font-size: 15px;
|
||||
line-height: 1.55;
|
||||
max-width: 720px;
|
||||
margin: 14px auto 10px;
|
||||
min-height: 3.2em;
|
||||
}
|
||||
.academy-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
.academy-dots {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
.academy-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--line-strong);
|
||||
background: var(--surface-raised);
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.academy-dot.active {
|
||||
background: var(--ember);
|
||||
border-color: var(--ember);
|
||||
}
|
||||
|
||||
/* Scene animations — restarted per slide because the SVG is rebuilt. */
|
||||
@keyframes ac-draw-kf { from { stroke-dashoffset: 900; } to { stroke-dashoffset: 0; } }
|
||||
.ac-draw { stroke-dasharray: 900; animation: ac-draw-kf 2.6s ease-out forwards; }
|
||||
@keyframes ac-pulse-kf { 0%, 100% { opacity: 1; } 50% { opacity: 0.25; } }
|
||||
.ac-pulse { animation: ac-pulse-kf 1.6s ease-in-out infinite; }
|
||||
@keyframes ac-breathe-kf { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.045); } }
|
||||
.ac-breathe { animation: ac-breathe-kf 3s ease-in-out infinite; transform-box: fill-box; transform-origin: center; }
|
||||
@keyframes ac-steam-kf { 0% { transform: translateY(6px); opacity: 0; } 30% { opacity: 0.55; } 100% { transform: translateY(-16px); opacity: 0; } }
|
||||
.ac-steam { animation: ac-steam-kf 2.4s ease-out infinite; }
|
||||
@keyframes ac-grow-w-kf { from { width: 0; } to { width: var(--w); } }
|
||||
.ac-grow-w { animation: ac-grow-w-kf 1.4s ease-out forwards; }
|
||||
@keyframes ac-grow-h-kf { from { height: 0; } to { height: 100px; } }
|
||||
.ac-grow-h { animation: ac-grow-h-kf 1.6s ease-out forwards; }
|
||||
@keyframes ac-grow-h-down-kf { from { height: 0; } to { height: 20px; } }
|
||||
.ac-grow-h-down { animation: ac-grow-h-down-kf 2.4s ease-out forwards; }
|
||||
@keyframes ac-spin-kf { to { transform: rotate(360deg); } }
|
||||
.ac-spin { animation: ac-spin-kf 9s linear infinite; transform-box: fill-box; transform-origin: center; }
|
||||
.ac-spin-slow { animation: ac-spin-kf 8s linear infinite; }
|
||||
@keyframes ac-sparkle-kf { 0%, 100% { opacity: 0.2; transform: scale(0.7); } 50% { opacity: 1; transform: scale(1.25); } }
|
||||
.ac-sparkle { animation: ac-sparkle-kf 1.8s ease-in-out infinite; transform-box: fill-box; transform-origin: center; }
|
||||
@keyframes ac-drift-kf { 0% { transform: translate(0, 0); opacity: 0.9; } 100% { transform: translate(26px, -20px); opacity: 0; } }
|
||||
.ac-drift { animation: ac-drift-kf 2.6s ease-out infinite; }
|
||||
@keyframes ac-shake-kf { 0%, 100% { transform: translate(0); } 25% { transform: translate(-1.5px, 1px); } 75% { transform: translate(1.5px, -1px); } }
|
||||
.ac-shake { animation: ac-shake-kf 0.28s linear infinite; }
|
||||
@keyframes ac-ring-kf { from { transform: scale(0.6); opacity: 0.9; } to { transform: scale(1.5); opacity: 0; } }
|
||||
.ac-ring { animation: ac-ring-kf 1.7s ease-out infinite; transform-box: fill-box; transform-origin: center; }
|
||||
@keyframes ac-crack-kf { from { stroke-dashoffset: 120; } to { stroke-dashoffset: 0; } }
|
||||
.ac-crackline { stroke-dasharray: 120; animation: ac-crack-kf 1.4s ease-in forwards; }
|
||||
@keyframes ac-dissolve-kf { 0% { transform: translate(0, 0); opacity: 1; } 100% { transform: translate(56px, -34px); opacity: 0; } }
|
||||
.ac-dissolve { animation: ac-dissolve-kf 2.4s ease-out infinite; }
|
||||
@keyframes ac-float-kf { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-7px); } }
|
||||
.ac-float { animation: ac-float-kf 2.6s ease-in-out infinite; }
|
||||
@keyframes ac-rise-kf { 0% { transform: translateY(8px); opacity: 0; } 40% { opacity: 1; } 100% { transform: translateY(-22px); opacity: 0; } }
|
||||
.ac-rise { animation: ac-rise-kf 2s ease-out infinite; }
|
||||
@keyframes ac-drip-kf { 0% { transform: translateY(0); opacity: 1; } 100% { transform: translateY(14px); opacity: 0; } }
|
||||
.ac-drip { animation: ac-drip-kf 1.1s ease-in infinite; }
|
||||
@keyframes ac-tamp-kf { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(9px); } }
|
||||
.ac-tamp { animation: ac-tamp-kf 2s ease-in-out infinite; }
|
||||
@keyframes ac-slide-x-kf { 0%, 100% { transform: translateX(-46px); } 50% { transform: translateX(46px); } }
|
||||
.ac-slide-x { animation: ac-slide-x-kf 5s ease-in-out infinite; }
|
||||
@keyframes ac-dial-kf { 0%, 100% { transform: rotate(-38deg); } 50% { transform: rotate(38deg); } }
|
||||
.ac-dial { animation: ac-dial-kf 6s ease-in-out infinite; }
|
||||
@keyframes ac-pour-kf { 0%, 100% { opacity: 0.4; } 50% { opacity: 1; } }
|
||||
.ac-pourline { animation: ac-pour-kf 1.6s ease-in-out infinite; }
|
||||
@keyframes ac-maillard-kf { 0% { fill: #d9ba5a; } 100% { fill: #8a5a30; } }
|
||||
/* Fill-shift animations ride a flat .ac-tint overlay at partial opacity so the
|
||||
* gradient shading underneath survives the color change. */
|
||||
.ac-tint, .ac-jb-color { opacity: 0.82; }
|
||||
.ac-maillard-shift .ac-tint { animation: ac-maillard-kf 5s ease-in-out infinite alternate; }
|
||||
@keyframes ac-tumble-kf { 0%, 100% { transform: translateY(0) rotate(0deg); } 50% { transform: translateY(-8px) rotate(6deg); } }
|
||||
.ac-tumble { animation: ac-tumble-kf 1.6s ease-in-out infinite; transform-box: fill-box; transform-origin: center; }
|
||||
@keyframes ac-flame-kf { 0%, 100% { transform: scaleY(1); } 50% { transform: scaleY(1.12); } }
|
||||
.ac-flame { animation: ac-flame-kf 0.9s ease-in-out infinite; transform-box: fill-box; transform-origin: bottom center; }
|
||||
.ac-delay-0 { animation-delay: 0s; }
|
||||
.ac-delay-1 { animation-delay: 0.55s; }
|
||||
.ac-delay-2 { animation-delay: 1.1s; }
|
||||
.ac-delay-3 { animation-delay: 1.65s; }
|
||||
.ac-delay-4 { animation-delay: 2.2s; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.academy-stage svg * { animation: none !important; }
|
||||
}
|
||||
|
||||
/* Academy v2: progress + refined scene animations */
|
||||
.academy-lesson-mid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.academy-lesson-num.done {
|
||||
background: var(--pass-bg);
|
||||
color: var(--pass);
|
||||
}
|
||||
.academy-progress {
|
||||
display: block;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: var(--surface-sunken);
|
||||
overflow: hidden;
|
||||
}
|
||||
.academy-progress-fill {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--ember);
|
||||
border-radius: 2px;
|
||||
}
|
||||
@keyframes ac-fill-up-kf { from { transform: scaleY(0.12); } to { transform: scaleY(0.86); } }
|
||||
.ac-fill-up { transform-box: fill-box; transform-origin: bottom; animation: ac-fill-up-kf 5s ease-out forwards; }
|
||||
@keyframes ac-dry-shift-kf { 0% { fill: #7a8c5e; } 100% { fill: #d9ba5a; } }
|
||||
.ac-dry-shift .ac-tint { animation: ac-dry-shift-kf 6s ease-in-out infinite alternate; }
|
||||
@keyframes ac-needle-kf { 0%, 100% { transform: rotate(-52deg); } 50% { transform: rotate(52deg); } }
|
||||
.ac-needle { animation: ac-needle-kf 7s ease-in-out infinite; }
|
||||
@keyframes ac-pulse-soft-kf { 0%, 100% { opacity: 1; } 50% { opacity: 0.75; } }
|
||||
.ac-pulse-soft { animation: ac-pulse-soft-kf 2.4s ease-in-out infinite; }
|
||||
|
||||
/* The 14-second "bean journey" clock — every piece of the hero scene rides it together. */
|
||||
@keyframes ac-jb-color-kf {
|
||||
0%, 30% { fill: #7a8c5e; }
|
||||
47% { fill: #d9ba5a; }
|
||||
66% { fill: #b98d54; }
|
||||
84%, 100% { fill: #8a5a30; }
|
||||
}
|
||||
.ac-jb-color { animation: ac-jb-color-kf 14s linear infinite; }
|
||||
@keyframes ac-jb-grow-kf {
|
||||
0%, 40% { transform: scale(1); }
|
||||
84%, 100% { transform: scale(1.12); }
|
||||
}
|
||||
.ac-jb-grow { transform-box: fill-box; transform-origin: center; animation: ac-jb-grow-kf 14s linear infinite; }
|
||||
@keyframes ac-jb-crack-kf {
|
||||
0%, 82% { opacity: 0; }
|
||||
86%, 100% { opacity: 1; }
|
||||
}
|
||||
.ac-jb-crack { animation: ac-jb-crack-kf 14s linear infinite; }
|
||||
@keyframes ac-jb-steam-kf {
|
||||
0%, 8% { opacity: 0; }
|
||||
18%, 42% { opacity: 1; }
|
||||
52%, 100% { opacity: 0; }
|
||||
}
|
||||
.ac-jb-steam { animation: ac-jb-steam-kf 14s linear infinite; }
|
||||
@keyframes ac-jb-marker-kf {
|
||||
from { transform: translateX(0); }
|
||||
to { transform: translateX(400px); }
|
||||
}
|
||||
.ac-jb-marker { animation: ac-jb-marker-kf 14s linear infinite; }
|
||||
|
||||
/* Academy v4: inside-the-bean / senses / gone-wrong scene animations */
|
||||
@keyframes ac-plug-kf {
|
||||
0% { transform: translate(0, 0); opacity: 0.9; }
|
||||
60%, 100% { transform: translate(var(--dx), var(--dy)); opacity: 1; }
|
||||
}
|
||||
.ac-plug { animation: ac-plug-kf 3s ease-out infinite; }
|
||||
@keyframes ac-wobble-kf {
|
||||
0%, 100% { transform: scale(1, 1) rotate(0deg); }
|
||||
25% { transform: scale(1.05, 0.95) rotate(-2deg); }
|
||||
75% { transform: scale(0.95, 1.05) rotate(2deg); }
|
||||
}
|
||||
.ac-wobble { animation: ac-wobble-kf 2.4s ease-in-out infinite; transform-box: fill-box; transform-origin: center; }
|
||||
@keyframes ac-shrink-kf { 0% { transform: scale(1); } 100% { transform: scale(0.35); } }
|
||||
.ac-shrink { animation: ac-shrink-kf 6s ease-in-out infinite alternate; transform-box: fill-box; transform-origin: center; }
|
||||
@keyframes ac-gauge-kf {
|
||||
0% { transform: rotate(-58deg); }
|
||||
55%, 100% { transform: rotate(50deg); }
|
||||
}
|
||||
.ac-gauge-needle { animation: ac-gauge-kf 3.2s ease-out infinite; }
|
||||
@keyframes ac-grow-foam-kf {
|
||||
0% { transform: scale(0.8); }
|
||||
60%, 100% { transform: scale(1); }
|
||||
}
|
||||
.ac-grow-foam { animation: ac-grow-foam-kf 4s ease-out infinite; transform-box: fill-box; transform-origin: center; }
|
||||
@keyframes ac-mortar-fade-kf { 0%, 20% { opacity: 1; } 60% { opacity: 0.25; } 100% { opacity: 1; } }
|
||||
.ac-mortar-fade { animation: ac-mortar-fade-kf 4.5s ease-in-out infinite; }
|
||||
@keyframes ac-rebar-snap-kf { 0%, 70% { opacity: 0; } 78%, 100% { opacity: 1; } }
|
||||
.ac-rebar-snap { animation: ac-rebar-snap-kf 6s linear infinite; }
|
||||
@keyframes ac-burst-kf {
|
||||
0% { transform: translateY(6px) scale(0.6); opacity: 0; }
|
||||
30% { opacity: 1; }
|
||||
100% { transform: translateY(-30px) scale(1.1); opacity: 0; }
|
||||
}
|
||||
.ac-burst { animation: ac-burst-kf 1.1s ease-out infinite; }
|
||||
@keyframes ac-grass-fade-kf { 0% { fill: #8a9575; } 40% { fill: #7a8c5e; } 100% { fill: #cdbd8f; } }
|
||||
.ac-grass-fade .ac-tint { animation: ac-grass-fade-kf 6s ease-in-out infinite alternate; }
|
||||
@keyframes ac-cross-a-kf { 0%, 45% { opacity: 1; } 55%, 100% { opacity: 0; } }
|
||||
.ac-cross-a { animation: ac-cross-a-kf 4s ease-in-out infinite; }
|
||||
@keyframes ac-cross-b-kf { 0%, 45% { opacity: 0; } 55%, 100% { opacity: 1; } }
|
||||
.ac-cross-b { animation: ac-cross-b-kf 4s ease-in-out infinite; }
|
||||
@keyframes ac-marker-cross-kf {
|
||||
0%, 100% { transform: translateX(-30px); }
|
||||
50% { transform: translateX(30px); }
|
||||
}
|
||||
.ac-marker-cross { animation: ac-marker-cross-kf 4s ease-in-out infinite; }
|
||||
@keyframes ac-row-glow-kf { 0%, 100% { opacity: 0.15; } 15%, 30% { opacity: 0.85; } }
|
||||
.ac-row-glow { animation: ac-row-glow-kf 6s ease-in-out infinite; }
|
||||
|
||||
/* ── Flavor wheel (cupping) ───────────────────────────────────────────── */
|
||||
.flavor-wheel-wrap {
|
||||
max-width: 480px;
|
||||
margin: 0 auto 14px;
|
||||
}
|
||||
.flavor-wheel-wrap svg {
|
||||
width: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>Beans — Roast Planner</title>
|
||||
<link rel="stylesheet" href="/app.css?v=__ASSET_VERSION__" />
|
||||
<link rel="icon" href="/icon.svg?v=__ASSET_VERSION__" type="image/svg+xml" />
|
||||
<meta name="theme-color" content="#A8481A" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-shell">
|
||||
<nav class="side-nav" id="side-nav" aria-label="Main navigation">
|
||||
<div class="side-nav-brand">
|
||||
<img class="brand-mark" src="/brand-mark.svg?v=__ASSET_VERSION__" alt="" aria-hidden="true" />
|
||||
<span class="brand-name">Roast Planner</span>
|
||||
</div>
|
||||
<div id="nav-links"></div>
|
||||
<div class="nav-spacer"></div>
|
||||
<button
|
||||
class="icon-btn nav-collapse-toggle"
|
||||
type="button"
|
||||
id="nav-collapse"
|
||||
aria-label="Collapse navigation"
|
||||
title="Collapse navigation"
|
||||
>
|
||||
«
|
||||
</button>
|
||||
<div class="nav-user">
|
||||
<div class="nav-user-avatar" id="nav-user-avatar" aria-hidden="true"></div>
|
||||
<div class="nav-user-detail">
|
||||
<span class="nav-user-email" id="account-email"></span>
|
||||
<button class="nav-user-logout" type="button" id="btn-logout">
|
||||
Log out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="app-workspace" id="app-workspace">
|
||||
<header class="app-header">
|
||||
<div class="header-row-top">
|
||||
<button
|
||||
class="icon-btn nav-hamburger"
|
||||
type="button"
|
||||
id="nav-hamburger"
|
||||
aria-label="Open navigation"
|
||||
aria-expanded="false"
|
||||
>
|
||||
☰
|
||||
</button>
|
||||
<div class="brand">
|
||||
<div class="brand-text">
|
||||
<h1>Beans</h1>
|
||||
<p class="brand-sub">Roasted or purchased coffee ready to brew</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div class="drawer-overlay hidden" id="drawer-overlay"></div>
|
||||
|
||||
<main class="page-content">
|
||||
<section class="panel-card" id="beans">
|
||||
<div class="panel-head">
|
||||
<h2>Your beans</h2>
|
||||
<label
|
||||
style="display:flex;align-items:center;gap:10px;font-size:13px;color:var(--ink-2)"
|
||||
><span class="switch"
|
||||
><input type="checkbox" id="show-archived" /><span
|
||||
class="switch-track"
|
||||
></span></span
|
||||
>Show archived</label
|
||||
>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="table-wrap">
|
||||
<table class="data-table" id="beans-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Bean</th>
|
||||
<th>Origin / process</th>
|
||||
<th>Roasted</th>
|
||||
<th>Remaining</th>
|
||||
<th class="num">Brews</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="beans-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel-card" id="bean-form-card">
|
||||
<div class="panel-head"><h2 id="bean-form-title">Add a bean</h2></div>
|
||||
<div class="panel-body">
|
||||
<div
|
||||
style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:12px"
|
||||
>
|
||||
<input
|
||||
class="text-input"
|
||||
id="bean-prefill-url"
|
||||
type="url"
|
||||
placeholder="Paste the roaster's product URL to prefill…"
|
||||
style="flex:1;min-width:220px"
|
||||
/>
|
||||
<button class="ghost-btn" type="button" id="bean-prefill-btn">
|
||||
Prefill from URL
|
||||
</button>
|
||||
</div>
|
||||
<p class="field-note hidden" id="bean-prefill-note"></p>
|
||||
<form id="bean-form">
|
||||
<div class="field-grid">
|
||||
<label class="field"
|
||||
><span class="field-label">Name *</span
|
||||
><input
|
||||
class="field-input"
|
||||
name="name"
|
||||
required
|
||||
placeholder="e.g. Kiamabara AB"
|
||||
/></label>
|
||||
<label class="field"
|
||||
><span class="field-label">Roaster</span
|
||||
><input class="field-input" name="roaster" placeholder="or “home roast”"
|
||||
/></label>
|
||||
<label class="field"
|
||||
><span class="field-label">Origin</span
|
||||
><input class="field-input" name="origin"
|
||||
/></label>
|
||||
<label class="field"
|
||||
><span class="field-label">Process</span
|
||||
><input class="field-input" name="process"
|
||||
/></label>
|
||||
<label class="field"
|
||||
><span class="field-label">Variety</span
|
||||
><input class="field-input" name="variety"
|
||||
/></label>
|
||||
<label class="field"
|
||||
><span class="field-label">Roast level</span
|
||||
><input class="field-input" name="roastLevel" placeholder="light / medium / dark"
|
||||
/></label>
|
||||
<label class="field"
|
||||
><span class="field-label">Roast date</span
|
||||
><input class="field-input" type="date" name="roastDate"
|
||||
/></label>
|
||||
<label class="field"
|
||||
><span class="field-label">Bag weight</span>
|
||||
<div class="unit-input">
|
||||
<input
|
||||
class="field-input"
|
||||
type="number"
|
||||
name="initialWeightG"
|
||||
inputmode="decimal"
|
||||
min="0"
|
||||
step="any"
|
||||
/><span class="unit">g</span>
|
||||
</div></label
|
||||
>
|
||||
</div>
|
||||
<label class="field"
|
||||
><span class="field-label">Roaster's tasting notes</span
|
||||
><input class="field-input" name="tastingNotes" placeholder="e.g. blackcurrant, cocoa"
|
||||
/></label>
|
||||
<label class="field"
|
||||
><span class="field-label">Product URL</span
|
||||
><input class="field-input" name="url" type="url"
|
||||
/></label>
|
||||
<label class="field"
|
||||
><span class="field-label">Notes</span
|
||||
><input class="field-input" name="notes"
|
||||
/></label>
|
||||
<button class="primary-btn" type="submit" id="bean-form-submit">
|
||||
Add bean
|
||||
</button>
|
||||
<button class="ghost-btn hidden" type="button" id="bean-form-cancel">
|
||||
Cancel edit
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="/js/beans.js?v=__ASSET_VERSION__"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,17 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="110 78 292 356">
|
||||
<!-- Standalone Roast Planner bean mark (no tile) for nav / auth / marketing lockups. -->
|
||||
<defs>
|
||||
<clipPath id="bean-clip">
|
||||
<path d="M256 100 C 352 100 372 192 372 256 C 372 320 352 412 256 412 C 160 412 140 320 140 256 C 140 192 160 100 256 100 Z"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
<g transform="rotate(-20 256 256)">
|
||||
<g clip-path="url(#bean-clip)">
|
||||
<rect x="120" y="80" width="136" height="352" fill="#7A8C5E"/>
|
||||
<rect x="256" y="80" width="136" height="352" fill="#A8481A"/>
|
||||
</g>
|
||||
<path d="M256 100 C 352 100 372 192 372 256 C 372 320 352 412 256 412 C 160 412 140 320 140 256 C 140 192 160 100 256 100 Z"
|
||||
fill="none" stroke="#54371F" stroke-width="14"/>
|
||||
<path d="M256 122 C 296 190 216 322 256 390" fill="none" stroke="#FBF8F4" stroke-width="26" stroke-linecap="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 900 B |
@@ -0,0 +1,203 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>Brews — Roast Planner</title>
|
||||
<link rel="stylesheet" href="/app.css?v=__ASSET_VERSION__" />
|
||||
<link rel="icon" href="/icon.svg?v=__ASSET_VERSION__" type="image/svg+xml" />
|
||||
<meta name="theme-color" content="#A8481A" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-shell">
|
||||
<nav class="side-nav" id="side-nav" aria-label="Main navigation">
|
||||
<div class="side-nav-brand">
|
||||
<img class="brand-mark" src="/brand-mark.svg?v=__ASSET_VERSION__" alt="" aria-hidden="true" />
|
||||
<span class="brand-name">Roast Planner</span>
|
||||
</div>
|
||||
<div id="nav-links"></div>
|
||||
<div class="nav-spacer"></div>
|
||||
<button
|
||||
class="icon-btn nav-collapse-toggle"
|
||||
type="button"
|
||||
id="nav-collapse"
|
||||
aria-label="Collapse navigation"
|
||||
title="Collapse navigation"
|
||||
>
|
||||
«
|
||||
</button>
|
||||
<div class="nav-user">
|
||||
<div class="nav-user-avatar" id="nav-user-avatar" aria-hidden="true"></div>
|
||||
<div class="nav-user-detail">
|
||||
<span class="nav-user-email" id="account-email"></span>
|
||||
<button class="nav-user-logout" type="button" id="btn-logout">
|
||||
Log out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="app-workspace" id="app-workspace">
|
||||
<header class="app-header">
|
||||
<div class="header-row-top">
|
||||
<button
|
||||
class="icon-btn nav-hamburger"
|
||||
type="button"
|
||||
id="nav-hamburger"
|
||||
aria-label="Open navigation"
|
||||
aria-expanded="false"
|
||||
>
|
||||
☰
|
||||
</button>
|
||||
<div class="brand">
|
||||
<div class="brand-text">
|
||||
<h1>Brews</h1>
|
||||
<p class="brand-sub">Log every cup — method, recipe, and how it tasted</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div class="drawer-overlay hidden" id="drawer-overlay"></div>
|
||||
|
||||
<main class="page-content">
|
||||
<section class="panel-card" id="brew-form-card">
|
||||
<div class="panel-head">
|
||||
<h2 id="brew-form-title">Log a brew</h2>
|
||||
<button class="ghost-btn small hidden" type="button" id="brew-form-cancel">
|
||||
Cancel edit
|
||||
</button>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<form id="brew-form">
|
||||
<label class="field" style="max-width:420px"
|
||||
><span class="field-label">Bean</span>
|
||||
<select class="field-input" name="beanId" id="brew-bean-select">
|
||||
<option value="">— no bean selected —</option>
|
||||
</select></label
|
||||
>
|
||||
<div id="brewer-picker" style="margin:12px 0"></div>
|
||||
<div class="field-grid">
|
||||
<label class="field"
|
||||
><span class="field-label">Dose</span>
|
||||
<div class="unit-input">
|
||||
<input class="field-input" type="number" name="doseG" inputmode="decimal" min="0" step="any" /><span class="unit">g</span>
|
||||
</div></label
|
||||
>
|
||||
<label class="field" data-espresso-hide
|
||||
><span class="field-label">Water</span>
|
||||
<div class="unit-input">
|
||||
<input class="field-input" type="number" name="waterG" inputmode="decimal" min="0" step="any" /><span class="unit">g</span>
|
||||
</div></label
|
||||
>
|
||||
<label class="field hidden" data-espresso-show
|
||||
><span class="field-label">Yield (out)</span>
|
||||
<div class="unit-input">
|
||||
<input class="field-input" type="number" name="yieldG" inputmode="decimal" min="0" step="any" /><span class="unit">g</span>
|
||||
</div></label
|
||||
>
|
||||
<label class="field"
|
||||
><span class="field-label">Ratio</span
|
||||
><input class="field-input" id="brew-ratio" readonly placeholder="—"
|
||||
/></label>
|
||||
<label class="field"
|
||||
><span class="field-label">Water temp</span>
|
||||
<div class="unit-input">
|
||||
<input class="field-input" type="number" name="waterTempC" inputmode="decimal" min="0" max="100" step="any" /><span class="unit">°C</span>
|
||||
</div></label
|
||||
>
|
||||
<label class="field"
|
||||
><span class="field-label">Grinder</span
|
||||
><input
|
||||
class="field-input"
|
||||
name="grinder"
|
||||
list="grinder-options"
|
||||
placeholder="e.g. Comandante C40"
|
||||
/><datalist id="grinder-options"></datalist></label>
|
||||
<label class="field"
|
||||
><span class="field-label">Grind setting</span
|
||||
><input class="field-input" name="grindSetting" placeholder="e.g. 22 clicks"
|
||||
/></label>
|
||||
<label class="field"
|
||||
><span class="field-label">Total time</span
|
||||
><input class="field-input" name="brewTime" placeholder="m:ss"
|
||||
/></label>
|
||||
<label class="field" data-espresso-hide
|
||||
><span class="field-label">Bloom</span
|
||||
><input class="field-input" name="bloomTime" placeholder="m:ss"
|
||||
/></label>
|
||||
</div>
|
||||
<label class="field"
|
||||
><span class="field-label">Recipe / steps</span
|
||||
><textarea
|
||||
class="field-input"
|
||||
name="recipe"
|
||||
rows="3"
|
||||
placeholder="e.g. 45 g bloom, swirl · 0:45 pour to 150 g · 1:30 pour to 250 g · drawdown by 2:45"
|
||||
></textarea></label
|
||||
>
|
||||
<label class="field" style="margin-top:6px"
|
||||
><span class="field-label"
|
||||
>Rating <span class="rating-pill" id="rating-value">–</span></span
|
||||
>
|
||||
<input
|
||||
type="range"
|
||||
name="rating"
|
||||
id="brew-rating"
|
||||
min="0"
|
||||
max="10"
|
||||
step="0.5"
|
||||
value="5"
|
||||
style="width:min(360px,100%)"
|
||||
/>
|
||||
</label>
|
||||
<label class="field"
|
||||
><span class="field-label">Tasting notes</span
|
||||
><textarea
|
||||
class="field-input"
|
||||
name="tastingNotes"
|
||||
rows="2"
|
||||
placeholder="What you actually tasted — sweetness, acidity, body, finish…"
|
||||
></textarea></label
|
||||
>
|
||||
<label class="field"
|
||||
><span class="field-label">Notes / next time</span
|
||||
><input class="field-input" name="notes" placeholder="e.g. grind finer next time"
|
||||
/></label>
|
||||
<button class="primary-btn" type="submit" id="brew-form-submit">
|
||||
Save brew
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel-card" id="brew-history">
|
||||
<div class="panel-head">
|
||||
<h2>Brew history</h2>
|
||||
<button class="ghost-btn small hidden" id="clear-bean-filter">
|
||||
Clear bean filter
|
||||
</button>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="table-wrap">
|
||||
<table class="data-table" id="brews-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>When</th>
|
||||
<th>Method</th>
|
||||
<th>Bean</th>
|
||||
<th>Recipe</th>
|
||||
<th>Rating</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="brews-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="/js/brews.js?v=__ASSET_VERSION__"></script>
|
||||
</body>
|
||||
</html>
|
||||
+18
-33
@@ -4,43 +4,18 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>Cupping — Roast Planner</title>
|
||||
<link rel="stylesheet" href="/app.css" />
|
||||
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
|
||||
<link rel="stylesheet" href="/app.css?v=__ASSET_VERSION__" />
|
||||
<link rel="icon" href="/icon.svg?v=__ASSET_VERSION__" type="image/svg+xml" />
|
||||
<meta name="theme-color" content="#A8481A" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-shell">
|
||||
<nav class="side-nav" id="side-nav" aria-label="Main navigation">
|
||||
<div class="side-nav-brand">
|
||||
<span class="brand-mark" aria-hidden="true">◐</span>
|
||||
<img class="brand-mark" src="/brand-mark.svg?v=__ASSET_VERSION__" alt="" aria-hidden="true" />
|
||||
<span class="brand-name">Roast Planner</span>
|
||||
</div>
|
||||
<div class="nav-group">
|
||||
<a class="nav-item" href="/app"
|
||||
><span class="nav-icon" aria-hidden="true">◐</span
|
||||
><span class="nav-label">Planner</span></a
|
||||
>
|
||||
<a class="nav-item" href="/account#your-plans"
|
||||
><span class="nav-icon" aria-hidden="true">▤</span
|
||||
><span class="nav-label">Plans</span></a
|
||||
>
|
||||
<a class="nav-item" href="/inventory"
|
||||
><span class="nav-icon" aria-hidden="true">▥</span
|
||||
><span class="nav-label">Inventory</span></a
|
||||
>
|
||||
<a class="nav-item" href="/cupping" aria-current="page"
|
||||
><span class="nav-icon" aria-hidden="true">◒</span
|
||||
><span class="nav-label">Cupping</span></a
|
||||
>
|
||||
<a class="nav-item" href="/account"
|
||||
><span class="nav-icon" aria-hidden="true">◔</span
|
||||
><span class="nav-label">Account</span></a
|
||||
>
|
||||
<a class="nav-item hidden" id="nav-admin" href="/admin"
|
||||
><span class="nav-icon" aria-hidden="true">⚙</span
|
||||
><span class="nav-label">Admin</span></a
|
||||
>
|
||||
</div>
|
||||
<div id="nav-links"></div>
|
||||
<div class="nav-spacer"></div>
|
||||
<button
|
||||
class="icon-btn nav-collapse-toggle"
|
||||
@@ -88,6 +63,9 @@
|
||||
><span class="dot"></span
|
||||
><span class="autosave-text">Not saved yet</span></span
|
||||
>
|
||||
<button id="btn-expand-why" type="button" class="ghost-btn">
|
||||
Expand all "why" notes
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="drawer-overlay hidden" id="drawer-overlay"></div>
|
||||
@@ -160,7 +138,7 @@
|
||||
</label>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<details class="why-panel" data-why="cup-scores" open>
|
||||
<details class="why-panel" data-why="cup-scores">
|
||||
<summary>Why the scale starts at 6</summary>
|
||||
<p>
|
||||
The 6–10 range is a specialty-grading convention:
|
||||
@@ -186,7 +164,7 @@
|
||||
<section class="panel-card" id="cup-flavors">
|
||||
<div class="panel-head"><h2>Flavors</h2></div>
|
||||
<div class="panel-body">
|
||||
<details class="why-panel" data-why="cup-flavors" open>
|
||||
<details class="why-panel" data-why="cup-flavors">
|
||||
<summary>Tag what you can point at</summary>
|
||||
<p>
|
||||
A tag is only useful if you could defend it to
|
||||
@@ -199,6 +177,13 @@
|
||||
<p class="field-note hidden" id="flavor-limit-note">
|
||||
32-tag limit reached.
|
||||
</p>
|
||||
<div class="flavor-wheel-wrap">
|
||||
<svg
|
||||
id="flavor-wheel"
|
||||
role="group"
|
||||
aria-label="Flavor wheel — tap a wedge to add or remove a flavor"
|
||||
></svg>
|
||||
</div>
|
||||
<div id="flavor-families"></div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -206,7 +191,7 @@
|
||||
<section class="panel-card" id="cup-notes">
|
||||
<div class="panel-head"><h2>Notes</h2></div>
|
||||
<div class="panel-body">
|
||||
<details class="why-panel" data-why="cup-notes" open>
|
||||
<details class="why-panel" data-why="cup-notes">
|
||||
<summary>Notes outlive scores</summary>
|
||||
<p>
|
||||
Numbers compare roasts; sentences explain them. Write
|
||||
@@ -260,6 +245,6 @@
|
||||
document.getElementById("cupping-session-view").classList.remove("hidden");
|
||||
}
|
||||
</script>
|
||||
<script type="module" src="/js/cupping.js"></script>
|
||||
<script type="module" src="/js/cupping.js?v=__ASSET_VERSION__"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+4
-4
@@ -4,15 +4,15 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>Reset your password — Roast Planner</title>
|
||||
<link rel="stylesheet" href="/app.css" />
|
||||
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
|
||||
<link rel="stylesheet" href="/app.css?v=__ASSET_VERSION__" />
|
||||
<link rel="icon" href="/icon.svg?v=__ASSET_VERSION__" type="image/svg+xml" />
|
||||
<meta name="theme-color" content="#A8481A" />
|
||||
</head>
|
||||
<body>
|
||||
<main class="auth-page">
|
||||
<section class="panel-card auth-card">
|
||||
<div class="auth-brand">
|
||||
<span class="brand-mark" aria-hidden="true">◐</span>
|
||||
<img class="brand-mark" src="/brand-mark.svg?v=__ASSET_VERSION__" alt="" aria-hidden="true" />
|
||||
<strong>Roast Planner</strong>
|
||||
</div>
|
||||
<h1>Forgot your password?</h1>
|
||||
@@ -38,6 +38,6 @@
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<script type="module" src="/js/forgot.js"></script>
|
||||
<script type="module" src="/js/forgot.js?v=__ASSET_VERSION__"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>Equipment — Roast Planner</title>
|
||||
<link rel="stylesheet" href="/app.css?v=__ASSET_VERSION__" />
|
||||
<link rel="icon" href="/icon.svg?v=__ASSET_VERSION__" type="image/svg+xml" />
|
||||
<meta name="theme-color" content="#A8481A" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-shell">
|
||||
<nav class="side-nav" id="side-nav" aria-label="Main navigation">
|
||||
<div class="side-nav-brand">
|
||||
<img class="brand-mark" src="/brand-mark.svg?v=__ASSET_VERSION__" alt="" aria-hidden="true" />
|
||||
<span class="brand-name">Roast Planner</span>
|
||||
</div>
|
||||
<div id="nav-links"></div>
|
||||
<div class="nav-spacer"></div>
|
||||
<button
|
||||
class="icon-btn nav-collapse-toggle"
|
||||
type="button"
|
||||
id="nav-collapse"
|
||||
aria-label="Collapse navigation"
|
||||
title="Collapse navigation"
|
||||
>
|
||||
«
|
||||
</button>
|
||||
<div class="nav-user">
|
||||
<div class="nav-user-avatar" id="nav-user-avatar" aria-hidden="true"></div>
|
||||
<div class="nav-user-detail">
|
||||
<span class="nav-user-email" id="account-email"></span>
|
||||
<button class="nav-user-logout" type="button" id="btn-logout">
|
||||
Log out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="app-workspace" id="app-workspace">
|
||||
<header class="app-header">
|
||||
<div class="header-row-top">
|
||||
<button
|
||||
class="icon-btn nav-hamburger"
|
||||
type="button"
|
||||
id="nav-hamburger"
|
||||
aria-label="Open navigation"
|
||||
aria-expanded="false"
|
||||
>
|
||||
☰
|
||||
</button>
|
||||
<div class="brand">
|
||||
<div class="brand-text">
|
||||
<h1>Equipment</h1>
|
||||
<p class="brand-sub">
|
||||
The brewers and grinders you own — Brews only offers these
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div class="drawer-overlay hidden" id="drawer-overlay"></div>
|
||||
|
||||
<main class="page-content">
|
||||
<section class="panel-card" id="brewers-card">
|
||||
<div class="panel-head">
|
||||
<h2>Your brewers</h2>
|
||||
<span class="muted" id="brewers-count"></span>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<p class="field-note" style="margin-top:0">
|
||||
Tap the brewers you own. With none selected, the Brews page
|
||||
shows every method.
|
||||
</p>
|
||||
<div id="gear-brewer-picker"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel-card" id="grinders-card">
|
||||
<div class="panel-head"><h2>Your grinders</h2></div>
|
||||
<div class="panel-body">
|
||||
<form
|
||||
id="grinder-form"
|
||||
style="display:flex;gap:8px;flex-wrap:wrap;align-items:center"
|
||||
>
|
||||
<input
|
||||
class="text-input"
|
||||
id="grinder-name"
|
||||
placeholder="e.g. Comandante C40, DF64"
|
||||
style="max-width:280px"
|
||||
/>
|
||||
<button class="ghost-btn" type="submit">Add grinder</button>
|
||||
</form>
|
||||
<ul class="lib-list" id="grinder-list" style="margin-top:10px"></ul>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="/js/gear.js?v=__ASSET_VERSION__"></script>
|
||||
</body>
|
||||
</html>
|
||||
+17
-5
@@ -1,7 +1,19 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<rect width="512" height="512" rx="96" fill="#FBF8F4"/>
|
||||
<circle cx="256" cy="256" r="176" fill="#A8481A"/>
|
||||
<path fill="#FBF8F4" d="M166 166h180v45h-135v90h116v45H211v-90h-45z"/>
|
||||
<path fill="#FBEFE6" d="M256 118c43 54 71 91 71 133a71 71 0 1 1-142 0c0-42 28-79 71-133Z" opacity=".9"/>
|
||||
<path fill="#A8481A" d="M233 189c-13 22-20 42-20 60a43 43 0 1 0 86 0c0-18-7-38-20-60 3 10 4 19 4 28 0 23-12 42-27 42s-27-19-27-42c0-9 1-18 4-28Z"/>
|
||||
<!-- Roast Planner mark: one bean, raw → roasted. Green half is the lot on the shelf,
|
||||
ember half is the finished roast; the crease is the journey between them. -->
|
||||
<defs>
|
||||
<clipPath id="bean-clip">
|
||||
<path d="M256 100 C 352 100 372 192 372 256 C 372 320 352 412 256 412 C 160 412 140 320 140 256 C 140 192 160 100 256 100 Z"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
<rect width="512" height="512" rx="120" fill="#FBF8F4"/>
|
||||
<g transform="rotate(-20 256 256)">
|
||||
<g clip-path="url(#bean-clip)">
|
||||
<rect x="120" y="80" width="136" height="352" fill="#7A8C5E"/>
|
||||
<rect x="256" y="80" width="136" height="352" fill="#A8481A"/>
|
||||
</g>
|
||||
<path d="M256 100 C 352 100 372 192 372 256 C 372 320 352 412 256 412 C 160 412 140 320 140 256 C 140 192 160 100 256 100 Z"
|
||||
fill="none" stroke="#54371F" stroke-width="14"/>
|
||||
<path d="M256 122 C 296 190 216 322 256 390" fill="none" stroke="#FBF8F4" stroke-width="26" stroke-linecap="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 511 B After Width: | Height: | Size: 1.0 KiB |
+265
-218
@@ -7,74 +7,20 @@
|
||||
<meta name="theme-color" content="#A8481A" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
|
||||
<link rel="apple-touch-icon" href="/icon.svg" />
|
||||
<link rel="manifest" href="/manifest.webmanifest?v=__ASSET_VERSION__" />
|
||||
<link rel="icon" href="/icon.svg?v=__ASSET_VERSION__" type="image/svg+xml" />
|
||||
<link rel="apple-touch-icon" href="/icon.svg?v=__ASSET_VERSION__" />
|
||||
<link rel="stylesheet" href="/worksheet.css" media="print" />
|
||||
<link rel="stylesheet" href="/app.css" />
|
||||
<link rel="stylesheet" href="/app.css?v=__ASSET_VERSION__" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-shell">
|
||||
<nav class="side-nav" id="side-nav" aria-label="Main navigation">
|
||||
<div class="side-nav-brand">
|
||||
<span class="brand-mark" aria-hidden="true">◐</span>
|
||||
<img class="brand-mark" src="/brand-mark.svg?v=__ASSET_VERSION__" alt="" aria-hidden="true" />
|
||||
<span class="brand-name">Roast Planner</span>
|
||||
</div>
|
||||
<div class="nav-group">
|
||||
<a class="nav-item" href="/app" aria-current="page"
|
||||
><span class="nav-icon" aria-hidden="true">◐</span
|
||||
><span class="nav-label">Planner</span></a
|
||||
>
|
||||
<button class="nav-item" type="button" id="nav-plans">
|
||||
<span class="nav-icon" aria-hidden="true">▤</span
|
||||
><span class="nav-label">Plans</span>
|
||||
</button>
|
||||
<a class="nav-item" href="/inventory"
|
||||
><span class="nav-icon" aria-hidden="true">▥</span
|
||||
><span class="nav-label">Inventory</span></a
|
||||
>
|
||||
<a class="nav-item" href="/cupping"
|
||||
><span class="nav-icon" aria-hidden="true">◒</span
|
||||
><span class="nav-label">Cupping</span></a
|
||||
>
|
||||
<a class="nav-item" href="/account"
|
||||
><span class="nav-icon" aria-hidden="true">◔</span
|
||||
><span class="nav-label">Account</span></a
|
||||
>
|
||||
<a class="nav-item hidden" id="nav-admin" href="/admin"
|
||||
><span class="nav-icon" aria-hidden="true">⚙</span
|
||||
><span class="nav-label">Admin</span></a
|
||||
>
|
||||
</div>
|
||||
<div class="nav-group">
|
||||
<div class="nav-group-title">Tools</div>
|
||||
<button class="nav-item" type="button" id="nav-prefill">
|
||||
<span class="nav-icon" aria-hidden="true">↗</span
|
||||
><span class="nav-label">Prefill from URL</span>
|
||||
</button>
|
||||
<button class="nav-item" type="button" id="nav-alog">
|
||||
<span class="nav-icon" aria-hidden="true">∿</span
|
||||
><span class="nav-label">Reference curve</span>
|
||||
</button>
|
||||
<button class="nav-item" type="button" id="nav-import-export">
|
||||
<span class="nav-icon" aria-hidden="true">⇅</span
|
||||
><span class="nav-label">Import / export</span>
|
||||
</button>
|
||||
<button
|
||||
class="nav-item"
|
||||
type="button"
|
||||
id="btn-toggle-fids"
|
||||
aria-pressed="false"
|
||||
title="Show the worksheet reference codes (e.g. 1.4) on every field"
|
||||
>
|
||||
<span class="nav-icon" aria-hidden="true">#</span
|
||||
><span class="nav-label">Field IDs</span>
|
||||
</button>
|
||||
<button class="nav-item" type="button" id="nav-settings">
|
||||
<span class="nav-icon" aria-hidden="true">◎</span
|
||||
><span class="nav-label">Settings & sync</span>
|
||||
</button>
|
||||
</div>
|
||||
<div id="nav-links"></div>
|
||||
<div class="nav-spacer"></div>
|
||||
<button
|
||||
class="icon-btn nav-collapse-toggle"
|
||||
@@ -119,16 +65,57 @@
|
||||
<nav class="section-nav" id="section-nav" aria-label="Plan sections">
|
||||
<a href="#sec-coffee">Plan</a>
|
||||
<a href="#sec-bean">Bean</a>
|
||||
<a href="#sec-machine">Machine plan</a>
|
||||
<a href="#sec-roastlog">Roast log</a>
|
||||
<a href="#sec-after">After</a>
|
||||
<a href="#sec-machine">Machine</a>
|
||||
<a href="#sec-roastlog">Log</a>
|
||||
</nav>
|
||||
|
||||
<div class="header-actions">
|
||||
<div class="header-actions header-tools">
|
||||
<span class="autosave-chip" id="autosave-status"
|
||||
><span class="dot"></span
|
||||
><span class="autosave-text">Not saved yet</span></span
|
||||
>
|
||||
<button
|
||||
id="btn-tools-menu"
|
||||
type="button"
|
||||
class="ghost-btn small header-tools-toggle"
|
||||
aria-expanded="false"
|
||||
aria-controls="header-tools-group"
|
||||
>
|
||||
⚙ Tools ▾
|
||||
</button>
|
||||
<div class="header-tools-group" id="header-tools-group">
|
||||
<button id="nav-plans" type="button" class="ghost-btn small">
|
||||
▤ Plans
|
||||
</button>
|
||||
<button id="nav-chat" type="button" class="ghost-btn small">
|
||||
✳ Ask the LLM
|
||||
</button>
|
||||
<button id="nav-prefill" type="button" class="ghost-btn small">
|
||||
↗ Prefill
|
||||
</button>
|
||||
<button id="nav-alog" type="button" class="ghost-btn small">
|
||||
∿ Curves
|
||||
</button>
|
||||
<button id="nav-import-export" type="button" class="ghost-btn small">
|
||||
⇅ Import / export
|
||||
</button>
|
||||
<button
|
||||
id="btn-toggle-fids"
|
||||
type="button"
|
||||
class="ghost-btn small"
|
||||
aria-pressed="false"
|
||||
title="Show the worksheet reference codes (e.g. 1.4) on every field"
|
||||
>
|
||||
# IDs
|
||||
</button>
|
||||
<button id="nav-settings" type="button" class="ghost-btn small">
|
||||
◎ Settings
|
||||
</button>
|
||||
<span class="header-divider" aria-hidden="true"></span>
|
||||
<button id="btn-expand-why" type="button" class="ghost-btn small">
|
||||
Expand "why" notes
|
||||
</button>
|
||||
</div>
|
||||
<button id="btn-print" type="button" class="primary-btn">
|
||||
Print
|
||||
</button>
|
||||
@@ -216,6 +203,61 @@
|
||||
</button>
|
||||
<ul class="lib-list hidden" id="alog-library-list"></ul>
|
||||
<div id="alog-result" class="drawer-result"></div>
|
||||
<hr style="border:none;border-top:1px solid var(--line,#e5dcd2);margin:16px 0" />
|
||||
<h4 style="margin:0 0 6px">Log a finished roast</h4>
|
||||
<p class="field-note" style="margin-top:0">
|
||||
Upload the actual roast's .alog to attach it to this plan — you can
|
||||
upload several. The LLM reviews each one in the background.
|
||||
</p>
|
||||
<label class="filebtn primary-btn"
|
||||
>Upload finished roast(s)<input
|
||||
type="file"
|
||||
id="actual-alog-file"
|
||||
accept=".alog"
|
||||
multiple
|
||||
/></label>
|
||||
<div id="actual-alog-result" class="drawer-result"></div>
|
||||
<p class="field-note" style="margin-bottom:0">
|
||||
<a href="/roasts">View roast history →</a>
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<aside
|
||||
class="drawer hidden"
|
||||
id="panel-chat"
|
||||
aria-hidden="true"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="chat-title"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div class="drawer-head">
|
||||
<h3 id="chat-title">Ask the LLM about this plan</h3>
|
||||
<button
|
||||
class="icon-btn"
|
||||
type="button"
|
||||
data-close-drawer
|
||||
aria-label="Close"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div class="drawer-body">
|
||||
<div class="chat-thread" id="chat-thread"></div>
|
||||
<form id="chat-form" class="chat-input-row">
|
||||
<textarea
|
||||
class="field-input"
|
||||
id="chat-input"
|
||||
rows="2"
|
||||
placeholder="e.g. Why is first crack at 8:30? What should I change for more sweetness?"
|
||||
></textarea>
|
||||
<button class="primary-btn" type="submit" id="chat-send">Send</button>
|
||||
</form>
|
||||
<p class="field-note">
|
||||
Answers are grounded in this plan's numbers, your learned pace, and
|
||||
the roaster behavior learned from your uploaded roasts.
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -310,6 +352,30 @@
|
||||
saved locally first, then synced to your account whenever you are
|
||||
online.
|
||||
</p>
|
||||
<div class="field">
|
||||
<span class="field-label">Temperature unit</span>
|
||||
<div
|
||||
class="segmented small"
|
||||
id="temp-unit-toggle"
|
||||
role="radiogroup"
|
||||
aria-label="Temperature unit"
|
||||
>
|
||||
<label class="seg-opt"
|
||||
><input type="radio" name="tempUnit" value="C" checked /><span
|
||||
>°C</span
|
||||
></label
|
||||
>
|
||||
<label class="seg-opt"
|
||||
><input type="radio" name="tempUnit" value="F" /><span
|
||||
>°F</span
|
||||
></label
|
||||
>
|
||||
</div>
|
||||
<p class="field-note">
|
||||
Changes how Machine Plan and Roast Log temperatures are shown.
|
||||
Plans are always stored in °C, so switching back is lossless.
|
||||
</p>
|
||||
</div>
|
||||
<p class="drawer-result">
|
||||
Install Roast Planner from your browser menu for a focused workspace.
|
||||
Updates wait for your confirmation so an in-progress plan is never
|
||||
@@ -324,13 +390,31 @@
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<dialog class="help-dialog" id="field-help-dialog" aria-labelledby="help-dialog-title">
|
||||
<div class="help-card">
|
||||
<div class="help-head">
|
||||
<div>
|
||||
<span class="help-kicker">Field guide</span>
|
||||
<h3 id="help-dialog-title"></h3>
|
||||
</div>
|
||||
<button class="icon-btn" type="button" data-close-help aria-label="Close">✕</button>
|
||||
</div>
|
||||
<div class="help-body" id="help-dialog-body"></div>
|
||||
<p class="help-attrib">
|
||||
Timing framework: Rob Hoos, <em>Cultivars: A Practical Guide for Coffee Roasters</em>.
|
||||
Temperatures, bands and machine settings: your own logged roasts on the Hottop
|
||||
KN-8828B-2K+ — they transfer to no other machine.
|
||||
</p>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<form id="plan-form">
|
||||
<div class="workspace-grid">
|
||||
<div class="form-col">
|
||||
<section class="panel-card" id="sec-coffee">
|
||||
<div class="panel-head"><h2>The Coffee</h2></div>
|
||||
<div class="panel-body">
|
||||
<details class="why-panel" data-why="sec-coffee" open>
|
||||
<details class="why-panel" data-why="sec-coffee">
|
||||
<summary>Why cultivar sets the clock</summary>
|
||||
<p>
|
||||
A roast plan is three durations laid end to end: drying +
|
||||
@@ -457,7 +541,17 @@
|
||||
>0 the first time you roast this coffee. Afterwards,
|
||||
carry "One change next batch" here — this is how the
|
||||
plan learns.</span
|
||||
></label
|
||||
>
|
||||
<div class="refine-suggestion hidden" id="refine-suggestion">
|
||||
<span id="refine-suggestion-text"></span>
|
||||
<button
|
||||
type="button"
|
||||
class="ghost-btn small hidden"
|
||||
id="btn-apply-refine"
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
</div></label
|
||||
>
|
||||
<label class="field" data-fid="1.7"
|
||||
><span class="field-label">± Dev modifier</span
|
||||
@@ -491,7 +585,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-body" id="blend-body">
|
||||
<details class="why-panel" data-why="sec-blend" open>
|
||||
<details class="why-panel" data-why="sec-blend">
|
||||
<summary>Why blends combine times, never temperatures</summary>
|
||||
<p>
|
||||
The drum has one heat setting and one clock — you cannot
|
||||
@@ -563,7 +657,7 @@
|
||||
<section class="panel-card" id="sec-process">
|
||||
<div class="panel-head"><h2>Roast Target</h2></div>
|
||||
<div class="panel-body">
|
||||
<details class="why-panel" data-why="sec-process" open>
|
||||
<details class="why-panel" data-why="sec-process">
|
||||
<summary>Why processing moves development, not first crack</summary>
|
||||
<p>
|
||||
Processing changed the chemistry that browning starts
|
||||
@@ -691,7 +785,7 @@
|
||||
<section class="panel-card" id="sec-bean">
|
||||
<div class="panel-head"><h2>Bean Condition</h2></div>
|
||||
<div class="panel-body">
|
||||
<details class="why-panel" data-why="sec-bean" open>
|
||||
<details class="why-panel" data-why="sec-bean">
|
||||
<summary>Why bean condition is worth seconds, not a new plan</summary>
|
||||
<p>
|
||||
Moisture, density and screen size change how much energy the
|
||||
@@ -774,7 +868,7 @@
|
||||
<section class="panel-card" id="sec-machine">
|
||||
<div class="panel-head"><h2>Machine Plan</h2></div>
|
||||
<div class="panel-body">
|
||||
<details class="why-panel" data-why="sec-machine" open>
|
||||
<details class="why-panel" data-why="sec-machine">
|
||||
<summary>
|
||||
Why the rate of rise must fall, and never touch zero
|
||||
</summary>
|
||||
@@ -942,7 +1036,7 @@
|
||||
<span class="phase-chip">during the roast</span>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<details class="why-panel" data-why="sec-roastlog" open>
|
||||
<details class="why-panel" data-why="sec-roastlog">
|
||||
<summary>Record what you observed, never what you planned</summary>
|
||||
<p>
|
||||
A planned milestone written into the log as though it
|
||||
@@ -1075,122 +1169,22 @@
|
||||
Draw from lot
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel-card phase-later" id="sec-after">
|
||||
<div class="panel-head">
|
||||
<h2>After the Roast</h2>
|
||||
<span class="phase-chip">after cupping</span>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<details class="why-panel" data-why="sec-after" open>
|
||||
<summary>One change per batch — arithmetic, not patience</summary>
|
||||
<p>
|
||||
Move two variables and there are four possible
|
||||
explanations for what the cup does; move three and there
|
||||
are eight, and you can't separate them without more
|
||||
batches than you have coffee for. This worksheet makes
|
||||
single-variable experiments cheap: every number has one
|
||||
owner.
|
||||
</p>
|
||||
<p>
|
||||
Weight loss is the honest instrument — (green − roasted) ÷
|
||||
green × 100 rolls total time, development, and drop
|
||||
temperature into one number your scale can measure. Then
|
||||
match the cup to a symptom: papery or thin → first crack
|
||||
+0:30. Savory, florals gone → first crack −0:30. Sharp
|
||||
acidity with no fruit → development +0:15 (a DTR under 12%
|
||||
confirms it). Flat and dull → development −0:15. Flat with
|
||||
no clear defect → fix the RoR shape, not the times. Write
|
||||
the winner into "One change next batch" — and next time
|
||||
you roast this coffee, carry it into ± Refine (1.6). That
|
||||
handoff is how the plan learns.
|
||||
</p>
|
||||
</details>
|
||||
<div class="field-grid">
|
||||
<label class="field"
|
||||
><span class="field-label">Green in</span>
|
||||
<div class="unit-input">
|
||||
<input class="field-input" name="afterRoast.greenIn" /><span
|
||||
class="unit"
|
||||
>g</span
|
||||
>
|
||||
</div></label
|
||||
>
|
||||
<label class="field"
|
||||
><span class="field-label">Out</span>
|
||||
<div class="unit-input">
|
||||
<input class="field-input" name="afterRoast.out" /><span
|
||||
class="unit"
|
||||
>g</span
|
||||
>
|
||||
</div></label
|
||||
>
|
||||
<label class="field"
|
||||
><span class="field-label">Weight loss</span>
|
||||
<div class="unit-input">
|
||||
<input
|
||||
class="field-input"
|
||||
name="afterRoast.weightLossPct"
|
||||
/><span class="unit">%</span>
|
||||
</div></label
|
||||
>
|
||||
<label class="field"
|
||||
><span class="field-label">Actual DTR</span>
|
||||
<div class="unit-input">
|
||||
<input
|
||||
class="field-input"
|
||||
name="afterRoast.actualDtrPct"
|
||||
/><span class="unit">%</span>
|
||||
</div></label
|
||||
>
|
||||
</div>
|
||||
<div class="field-grid">
|
||||
<label class="field"
|
||||
><span class="field-label">Colour WB/GR</span
|
||||
><input class="field-input" name="afterRoast.colour"
|
||||
/></label>
|
||||
<label class="field"
|
||||
><span class="field-label">Rested</span>
|
||||
<div class="unit-input">
|
||||
<input
|
||||
class="field-input"
|
||||
name="afterRoast.restedDays"
|
||||
/><span class="unit">days</span>
|
||||
</div></label
|
||||
>
|
||||
<label class="field"
|
||||
><span class="field-label">Brew ratio</span
|
||||
><input class="field-input" name="afterRoast.brewRatio"
|
||||
/></label>
|
||||
<label class="field"
|
||||
><span class="field-label">Method</span
|
||||
><input class="field-input" name="afterRoast.method"
|
||||
/></label>
|
||||
</div>
|
||||
<label class="field"
|
||||
><span class="field-label">Cup notes</span
|
||||
><input class="field-input" name="afterRoast.cupNotes"
|
||||
/></label>
|
||||
<div class="field-grid">
|
||||
<label class="field accent"
|
||||
><span class="field-label">One change next batch</span
|
||||
><input class="field-input" name="afterRoast.oneChange"
|
||||
/></label>
|
||||
<label class="field"
|
||||
><span class="field-label">What would prove it wrong</span
|
||||
><input class="field-input" name="afterRoast.disproof"
|
||||
/></label>
|
||||
</div>
|
||||
<div class="cupping-link">
|
||||
<div
|
||||
style="display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-top:12px"
|
||||
>
|
||||
<button type="button" class="ghost-btn" id="btn-open-cupping">
|
||||
Open cupping session
|
||||
</button>
|
||||
<span class="field-note" id="cupping-link-note"></span>
|
||||
<span class="field-note" id="cupping-link-note" style="margin:0"></span>
|
||||
</div>
|
||||
<p class="field-note">
|
||||
After the roast, upload the .alog on the
|
||||
<a href="/roasts">Roasts page</a> — weights, colour, cup notes,
|
||||
and "one change next batch" now live there with the LLM review.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
<aside class="dock" aria-label="Live plan feedback">
|
||||
@@ -1200,6 +1194,7 @@
|
||||
Drying + Maillard + Development = the whole roast. Cultivar
|
||||
sets first crack; process and level set development.
|
||||
</p>
|
||||
<p class="dock-note machine-profile-note" id="machine-profile-note"></p>
|
||||
|
||||
<div class="ledger-group">
|
||||
<div class="ledger-row">
|
||||
@@ -1228,6 +1223,16 @@
|
||||
title="225 g lands first crack ~1:30–2:00 later than 200 g"
|
||||
/>
|
||||
</div>
|
||||
<div class="ledger-row ledger-subtotal">
|
||||
<span class="l-fid"></span
|
||||
><span class="l-label">Subtotal (before pace)</span
|
||||
><output class="l-val" data-out="subtotalA">—</output>
|
||||
</div>
|
||||
<div class="ledger-row ledger-pace">
|
||||
<span class="l-fid"></span
|
||||
><span class="l-label">× Machine pace</span
|
||||
><output class="l-val" data-out="pace-factor">×1.00</output>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ledger-total hero">
|
||||
<span>First crack</span><output data-out="A">—</output>
|
||||
@@ -1283,19 +1288,22 @@
|
||||
<div class="check-tile unknown" data-pass="drying">
|
||||
<span class="check-name">Drying share</span>
|
||||
<output class="check-value" data-out="check-drying">—</output>
|
||||
<span class="check-band">target 43–51%</span>
|
||||
<span class="check-band">informational, target 43–51%</span>
|
||||
<span class="check-why"
|
||||
>high = late yellow, browning starved · low = rushed
|
||||
before internal pressure built</span
|
||||
>Derived from the development ratio (yellow is 0.56 ×
|
||||
first crack, not entered independently) — this can't fail
|
||||
on its own. High = late yellow, browning starved · low =
|
||||
rushed before internal pressure built.</span
|
||||
>
|
||||
</div>
|
||||
<div class="check-tile unknown" data-pass="maillard">
|
||||
<span class="check-name">Maillard share</span>
|
||||
<output class="check-value" data-out="check-maillard">—</output>
|
||||
<span class="check-band">target 33–39%</span>
|
||||
<span class="check-band">informational, target 33–39%</span>
|
||||
<span class="check-why"
|
||||
>low = too small a browning window for sweetness and
|
||||
complexity</span
|
||||
>Same derivation as drying share — informational, not an
|
||||
independent check. Low = too small a browning window for
|
||||
sweetness and complexity.</span
|
||||
>
|
||||
</div>
|
||||
<div class="check-tile unknown" data-pass="dtr">
|
||||
@@ -1371,7 +1379,14 @@
|
||||
fill="var(--surface-raised)"
|
||||
/>
|
||||
<rect class="band" x="52" y="65.4" width="600" height="19.8" />
|
||||
<text x="58" y="62.4" class="bandline">
|
||||
<text
|
||||
x="58"
|
||||
y="62.4"
|
||||
class="bandline"
|
||||
data-band-lo="176"
|
||||
data-band-hi="187"
|
||||
data-band-prefix="first-crack band, "
|
||||
>
|
||||
first-crack band, 176–187 °C
|
||||
</text>
|
||||
<g class="min">
|
||||
@@ -1428,15 +1443,15 @@
|
||||
<line class="ax" x1="52" y1="24" x2="52" y2="294" />
|
||||
<line class="ax" x1="52" y1="294" x2="652" y2="294" />
|
||||
<line class="ax" x1="652" y1="24" x2="652" y2="294" />
|
||||
<g class="al" text-anchor="end">
|
||||
<text x="47" y="296.8">60</text>
|
||||
<text x="47" y="260.8">80</text>
|
||||
<text x="47" y="224.8">100</text>
|
||||
<text x="47" y="188.8">120</text>
|
||||
<text x="47" y="152.8">140</text>
|
||||
<text x="47" y="116.8">160</text>
|
||||
<text x="47" y="80.8">180</text>
|
||||
<text x="47" y="44.8">200</text>
|
||||
<g class="al curve-axis-temp" text-anchor="end">
|
||||
<text data-tempc="60" x="47" y="296.8">60</text>
|
||||
<text data-tempc="80" x="47" y="260.8">80</text>
|
||||
<text data-tempc="100" x="47" y="224.8">100</text>
|
||||
<text data-tempc="120" x="47" y="188.8">120</text>
|
||||
<text data-tempc="140" x="47" y="152.8">140</text>
|
||||
<text data-tempc="160" x="47" y="116.8">160</text>
|
||||
<text data-tempc="180" x="47" y="80.8">180</text>
|
||||
<text data-tempc="200" x="47" y="44.8">200</text>
|
||||
</g>
|
||||
<g class="al" text-anchor="middle">
|
||||
<text x="52" y="304">0:00</text>
|
||||
@@ -1829,6 +1844,22 @@
|
||||
/><span class="pv"></span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="row">
|
||||
<td class="ln"></td>
|
||||
<td class="desc">Subtotal (before pace)</td>
|
||||
<td class="val"><output data-out="subtotalA">—</output></td>
|
||||
</tr>
|
||||
<tr class="row">
|
||||
<td class="ln"></td>
|
||||
<td class="desc">
|
||||
<span class="op">×</span> Machine pace
|
||||
<span class="sub"
|
||||
>learned from your own roasts, else ×1.00
|
||||
(reference)</span
|
||||
>
|
||||
</td>
|
||||
<td class="val"><output data-out="pace-factor">×1.00</output></td>
|
||||
</tr>
|
||||
<tr class="total">
|
||||
<td class="ln">A</td>
|
||||
<td class="desc"><strong>FIRST CRACK</strong></td>
|
||||
@@ -1915,16 +1946,16 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Drying share</td>
|
||||
<td>Drying share (informational)</td>
|
||||
<td class="num">43–51 %</td>
|
||||
<td class="num">
|
||||
<output data-out="check-drying">—</output>
|
||||
</td>
|
||||
<td class="num passcell unknown" data-pass="drying"></td>
|
||||
<td>high = late yellow · low = rushed</td>
|
||||
<td>can't fail on its own — see * below</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Maillard share</td>
|
||||
<td>Maillard share (informational)</td>
|
||||
<td class="num">33–39 %</td>
|
||||
<td class="num">
|
||||
<output data-out="check-maillard">—</output>
|
||||
@@ -1933,7 +1964,7 @@
|
||||
class="num passcell unknown"
|
||||
data-pass="maillard"
|
||||
></td>
|
||||
<td>low = too little browning</td>
|
||||
<td>can't fail on its own — see * below</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Development ratio</td>
|
||||
@@ -1955,6 +1986,13 @@
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="wsfoot">
|
||||
* Drying share and Maillard share are always in-band
|
||||
whenever Development ratio passes — yellow is calculated as
|
||||
a fixed 56% of first crack, not measured independently, so
|
||||
these two rows can't fail on their own. Only Development
|
||||
ratio and Development ceiling are real pass/fail checks.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -1979,7 +2017,7 @@
|
||||
<tr>
|
||||
<th style="width: 28mm">Milestone</th>
|
||||
<th class="num" style="width: 20mm">Time</th>
|
||||
<th class="num" style="width: 18mm">°C</th>
|
||||
<th class="num" style="width: 18mm" id="print-machine-temp-th">°C</th>
|
||||
<th class="num" style="width: 36mm">Your logged band</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -1995,7 +2033,7 @@
|
||||
name="temps.charge.tempC"
|
||||
/><span class="pv"></span>
|
||||
</td>
|
||||
<td class="num">133–195 °C</td>
|
||||
<td class="num" data-band-lo="133" data-band-hi="195">133–195 °C</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Turning point</td>
|
||||
@@ -2012,7 +2050,7 @@
|
||||
name="temps.tp.tempC"
|
||||
/><span class="pv"></span>
|
||||
</td>
|
||||
<td class="num">77–100 °C @ 0:47–1:15</td>
|
||||
<td class="num" data-band-lo="77" data-band-hi="100" data-band-suffix=" @ 0:47–1:15">77–100 °C @ 0:47–1:15</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Yellow</td>
|
||||
@@ -2025,7 +2063,7 @@
|
||||
name="temps.yellow.tempC"
|
||||
/><span class="pv"></span>
|
||||
</td>
|
||||
<td class="num">148–166 °C</td>
|
||||
<td class="num" data-band-lo="148" data-band-hi="166">148–166 °C</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>First crack</td>
|
||||
@@ -2036,7 +2074,7 @@
|
||||
name="temps.fc.tempC"
|
||||
/><span class="pv"></span>
|
||||
</td>
|
||||
<td class="num">176–187 °C</td>
|
||||
<td class="num" data-band-lo="176" data-band-hi="187">176–187 °C</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Drop</td>
|
||||
@@ -2047,7 +2085,7 @@
|
||||
name="temps.drop.tempC"
|
||||
/><span class="pv"></span>
|
||||
</td>
|
||||
<td class="num">183–197 °C light</td>
|
||||
<td class="num" data-band-lo="183" data-band-hi="197" data-band-suffix=" light">183–197 °C light</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -2134,7 +2172,15 @@
|
||||
width="600"
|
||||
height="19.8"
|
||||
/>
|
||||
<text x="58" y="62.4" class="alr" style="font-size: 7px">
|
||||
<text
|
||||
x="58"
|
||||
y="62.4"
|
||||
class="alr"
|
||||
style="font-size: 7px"
|
||||
data-band-lo="176"
|
||||
data-band-hi="187"
|
||||
data-band-prefix="your first-crack band, "
|
||||
>
|
||||
your first-crack band, 176–187 °C
|
||||
</text>
|
||||
<g class="min">
|
||||
@@ -2191,15 +2237,15 @@
|
||||
<line class="ax" x1="52" y1="24" x2="52" y2="294" />
|
||||
<line class="ax" x1="52" y1="294" x2="652" y2="294" />
|
||||
<line class="ax" x1="652" y1="24" x2="652" y2="294" />
|
||||
<g class="al" text-anchor="end">
|
||||
<text x="47" y="296.8">60</text>
|
||||
<text x="47" y="260.8">80</text>
|
||||
<text x="47" y="224.8">100</text>
|
||||
<text x="47" y="188.8">120</text>
|
||||
<text x="47" y="152.8">140</text>
|
||||
<text x="47" y="116.8">160</text>
|
||||
<text x="47" y="80.8">180</text>
|
||||
<text x="47" y="44.8">200</text>
|
||||
<g class="al curve-axis-temp" text-anchor="end">
|
||||
<text data-tempc="60" x="47" y="296.8">60</text>
|
||||
<text data-tempc="80" x="47" y="260.8">80</text>
|
||||
<text data-tempc="100" x="47" y="224.8">100</text>
|
||||
<text data-tempc="120" x="47" y="188.8">120</text>
|
||||
<text data-tempc="140" x="47" y="152.8">140</text>
|
||||
<text data-tempc="160" x="47" y="116.8">160</text>
|
||||
<text data-tempc="180" x="47" y="80.8">180</text>
|
||||
<text data-tempc="200" x="47" y="44.8">200</text>
|
||||
</g>
|
||||
<text
|
||||
x="14"
|
||||
@@ -2207,6 +2253,7 @@
|
||||
class="ttl"
|
||||
transform="rotate(-90 14 159)"
|
||||
text-anchor="middle"
|
||||
id="print-curve-axis-title"
|
||||
>
|
||||
BEAN TEMP °C
|
||||
</text>
|
||||
@@ -2452,6 +2499,6 @@
|
||||
|
||||
<datalist id="cultivar-list"></datalist>
|
||||
|
||||
<script type="module" src="/js/main.js"></script>
|
||||
<script type="module" src="/js/main.js?v=__ASSET_VERSION__"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+27
-30
@@ -4,43 +4,18 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>Inventory — Roast Planner</title>
|
||||
<link rel="stylesheet" href="/app.css" />
|
||||
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
|
||||
<link rel="stylesheet" href="/app.css?v=__ASSET_VERSION__" />
|
||||
<link rel="icon" href="/icon.svg?v=__ASSET_VERSION__" type="image/svg+xml" />
|
||||
<meta name="theme-color" content="#A8481A" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-shell">
|
||||
<nav class="side-nav" id="side-nav" aria-label="Main navigation">
|
||||
<div class="side-nav-brand">
|
||||
<span class="brand-mark" aria-hidden="true">◐</span>
|
||||
<img class="brand-mark" src="/brand-mark.svg?v=__ASSET_VERSION__" alt="" aria-hidden="true" />
|
||||
<span class="brand-name">Roast Planner</span>
|
||||
</div>
|
||||
<div class="nav-group">
|
||||
<a class="nav-item" href="/app"
|
||||
><span class="nav-icon" aria-hidden="true">◐</span
|
||||
><span class="nav-label">Planner</span></a
|
||||
>
|
||||
<a class="nav-item" href="/account#your-plans"
|
||||
><span class="nav-icon" aria-hidden="true">▤</span
|
||||
><span class="nav-label">Plans</span></a
|
||||
>
|
||||
<a class="nav-item" href="/inventory" aria-current="page"
|
||||
><span class="nav-icon" aria-hidden="true">▥</span
|
||||
><span class="nav-label">Inventory</span></a
|
||||
>
|
||||
<a class="nav-item" href="/cupping"
|
||||
><span class="nav-icon" aria-hidden="true">◒</span
|
||||
><span class="nav-label">Cupping</span></a
|
||||
>
|
||||
<a class="nav-item" href="/account"
|
||||
><span class="nav-icon" aria-hidden="true">◔</span
|
||||
><span class="nav-label">Account</span></a
|
||||
>
|
||||
<a class="nav-item hidden" id="nav-admin" href="/admin"
|
||||
><span class="nav-icon" aria-hidden="true">⚙</span
|
||||
><span class="nav-label">Admin</span></a
|
||||
>
|
||||
</div>
|
||||
<div id="nav-links"></div>
|
||||
<div class="nav-spacer"></div>
|
||||
<button
|
||||
class="icon-btn nav-collapse-toggle"
|
||||
@@ -118,9 +93,31 @@
|
||||
<section class="panel-card" id="lot-form-card">
|
||||
<div class="panel-head"><h2 id="lot-form-title">Add a lot</h2></div>
|
||||
<div class="panel-body">
|
||||
<div
|
||||
style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:12px"
|
||||
>
|
||||
<input
|
||||
class="text-input"
|
||||
id="lot-prefill-url"
|
||||
type="url"
|
||||
placeholder="Paste the seller's product URL to prefill…"
|
||||
style="flex:1;min-width:220px"
|
||||
/>
|
||||
<button class="ghost-btn" type="button" id="lot-prefill-btn">
|
||||
Prefill from URL
|
||||
</button>
|
||||
</div>
|
||||
<p class="field-note hidden" id="lot-prefill-note"></p>
|
||||
<form id="lot-form">
|
||||
<input type="hidden" name="id" />
|
||||
<div class="field-grid">
|
||||
<label class="field"
|
||||
><span class="field-label">Name</span
|
||||
><input
|
||||
class="field-input"
|
||||
name="name"
|
||||
placeholder="e.g. Spring Ethiopia — defaults to origin"
|
||||
/></label>
|
||||
<label class="field"
|
||||
><span class="field-label">Origin *</span
|
||||
><input
|
||||
@@ -217,6 +214,6 @@
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="/js/inventory.js"></script>
|
||||
<script type="module" src="/js/inventory.js?v=__ASSET_VERSION__"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,542 @@
|
||||
// Academy lesson content. Each slide has:
|
||||
// scene: [builderName, props] — rendered by the scene library in academy.js
|
||||
// transcript: what the captions show (normal spelling)
|
||||
// tts: what the narrator actually says — only present when pronunciation, numbers, or
|
||||
// symbols need respelling for speech (Maillard -> "my-YARD", 8:30 -> "eight thirty",
|
||||
// °C -> "degrees Celsius"). The captions never show these respellings.
|
||||
// Audio files are pre-generated to /academy-audio/<slide id>.mp3 by
|
||||
// scripts/generate-academy-audio.mjs and committed, so the app never needs a TTS key.
|
||||
// Slide ids are stable — renaming one orphans its committed audio.
|
||||
|
||||
export const ACADEMY_TRACKS = [
|
||||
{
|
||||
key: "roasting",
|
||||
name: "Roasting",
|
||||
blurb:
|
||||
"From one continuous journey to the physics inside the bean — what you'll smell, see, and hear, and how roasts go wrong.",
|
||||
lessons: [
|
||||
{
|
||||
id: "roast-journey",
|
||||
title: "The roast, start to finish",
|
||||
minutes: 9,
|
||||
slides: [
|
||||
{
|
||||
id: "rj-journey",
|
||||
title: "The whole journey, in one bean",
|
||||
scene: ["beanJourney", {}],
|
||||
transcript:
|
||||
"Watch one bean live the whole roast. Heat pours in from below; first the bean sweats its water out, then it turns yellow and browns as aromas build, swelling under internal pressure until it cracks open — and we drop it. Everything in this lesson is just slowing this loop down.",
|
||||
},
|
||||
{
|
||||
id: "gb-seed",
|
||||
title: "A seed, not a bean",
|
||||
scene: ["beanAnatomy", {}],
|
||||
transcript:
|
||||
"Coffee isn't a bean at all — it's the seed of a cherry. Under the papery silverskin sits a dense block of cellulose, packed with everything the seed saved up to grow a tree: sugars, acids, proteins, and about eleven percent water.",
|
||||
},
|
||||
{
|
||||
id: "gb-composition",
|
||||
title: "What's inside",
|
||||
scene: ["composition", {}],
|
||||
transcript:
|
||||
"Roughly half the seed is cell-wall cellulose — the scaffolding. The rest is the flavor pantry: sucrose, chlorogenic acids, amino acids, lipids, and trapped water. Roasting is just chemistry applied to this pantry, in a particular order.",
|
||||
tts: "Roughly half the seed is cell wall cellulose — the scaffolding. The rest is the flavor pantry: sucrose, chloro-JEN-ick acids, amino acids, lipids, and trapped water. Roasting is just chemistry applied to this pantry, in a particular order.",
|
||||
},
|
||||
{
|
||||
id: "rj-charge",
|
||||
title: "Charge, and the turning point",
|
||||
scene: ["charge", {}],
|
||||
transcript:
|
||||
"Drop cold beans into a hot drum and the probe plunges — it's reading the beans, not the air. Where the falling line bottoms out and turns is the turning point. How deep and how late it lands is your machine's thermal fingerprint — this app learns it from your logs.",
|
||||
},
|
||||
{
|
||||
id: "rj-drying",
|
||||
title: "Drying — boiling water uphill",
|
||||
scene: ["steam", {}],
|
||||
transcript:
|
||||
"For the first minutes the bean mostly sweats. Evaporating water soaks up enormous energy, so temperature climbs stubbornly while the bean fades from green to yellow — grass, then hay, then bread. Drying earns forty-three to fifty-one percent of the whole roast. Rush it, and the surface races ahead of a steamy centre.",
|
||||
tts: "For the first minutes the bean mostly sweats. Evaporating water soaks up enormous energy, so temperature climbs stubbornly while the bean fades from green to yellow — grass, then hay, then bread. Drying earns forty three to fifty one percent of the whole roast. Rush it, and the surface races ahead of a steamy centre.",
|
||||
},
|
||||
{
|
||||
id: "rj-maillard",
|
||||
title: "Yellow to brown — Maillard",
|
||||
scene: ["maillard", {}],
|
||||
transcript:
|
||||
"Around 150 degrees Celsius, amino acids and sugars begin the Maillard reaction — the chemistry that browns bread and seared steak. Hundreds of aroma compounds appear, steam and carbon dioxide swell the bean, and the silverskin flakes off as chaff. Brisk keeps sparkle; slow builds body and sweetness.",
|
||||
tts: "Around one hundred fifty degrees Celsius, amino acids and sugars begin the my-YARD reaction — the chemistry that browns bread and seared steak. Hundreds of aroma compounds appear, steam and carbon dioxide swell the bean, and the silverskin flakes off as chaff. Brisk keeps sparkle; slow builds body and sweetness.",
|
||||
},
|
||||
{
|
||||
id: "rj-crack",
|
||||
title: "First crack",
|
||||
scene: ["crack", { phase: "during" }],
|
||||
transcript:
|
||||
"By now each bean is a tiny pressure cooker, several atmospheres of steam pushing outward. Around 196 degrees Celsius the cell walls fail and the bean audibly pops. The crack releases heat — so the gentle heat cut had to happen a minute before, not after. Get ahead of the exotherm; never chase it.",
|
||||
tts: "By now each bean is a tiny pressure cooker, several atmospheres of steam pushing outward. Around one hundred ninety six degrees Celsius the cell walls fail and the bean audibly pops. The crack releases heat — so the gentle heat cut had to happen a minute before, not after. Get ahead of the exo-therm; never chase it.",
|
||||
},
|
||||
{
|
||||
id: "rj-development",
|
||||
title: "Development — the strongest lever",
|
||||
scene: ["dtr", {}],
|
||||
transcript:
|
||||
"Everything after first crack is development: harsh chlorogenic acids break down, sugars caramelize, body builds. Its share of the roast — the development time ratio — is the strongest single lever on the cup. This worksheet targets twelve to twenty percent: under it reads sharp and grassy, far over it goes flat and roasty.",
|
||||
tts: "Everything after first crack is development: harsh chloro-JEN-ick acids break down, sugars caramelize, body builds. Its share of the roast — the development time ratio — is the strongest single lever on the cup. This worksheet targets twelve to twenty percent: under it reads sharp and grassy, far over it goes flat and roasty.",
|
||||
},
|
||||
{
|
||||
id: "rj-drop",
|
||||
title: "The drop, and the honest number",
|
||||
scene: ["weightLoss", {}],
|
||||
transcript:
|
||||
"Drop and cool fast — carryover heat keeps roasting for another half minute. Then weigh: weight loss rolls time, temperature, and development into one number a kitchen scale can verify. A light roast loses eleven to thirteen percent.",
|
||||
},
|
||||
{
|
||||
id: "rj-curve",
|
||||
title: "Reading the curve",
|
||||
scene: ["curve", { shape: "smooth", focus: "all" }],
|
||||
transcript:
|
||||
"The whole story fits one chart: temperature dips to the turning point, climbs through drying and Maillard, cracks, and lands at the drop. Its slope — the rate of rise — should fall steadily and never stop falling. The temperature says where you are; the slope says where you're headed.",
|
||||
tts: "The whole story fits one chart: temperature dips to the turning point, climbs through drying and my-YARD, cracks, and lands at the drop. Its slope — the rate of rise — should fall steadily and never stop falling. The temperature says where you are; the slope says where you're headed.",
|
||||
},
|
||||
{
|
||||
id: "rj-ror",
|
||||
title: "Crash, flick, and stall",
|
||||
scene: ["ror", { shape: "crash" }],
|
||||
transcript:
|
||||
"Cut heat too late and the exotherm crashes the rate of rise, then it flicks back up — a crash bakes out sweetness, a flick tastes roasty. Flatten to zero and the bean just sits there, baking. All three are fixed the same way: smaller, earlier moves, upstream of the crack.",
|
||||
tts: "Cut heat too late and the exo-therm crashes the rate of rise, then it flicks back up — a crash bakes out sweetness, a flick tastes roasty. Flatten to zero and the bean just sits there, baking. All three are fixed the same way: smaller, earlier moves, upstream of the crack.",
|
||||
},
|
||||
{
|
||||
id: "rj-onechange",
|
||||
title: "Taste, then change one thing",
|
||||
scene: ["oneChange", {}],
|
||||
transcript:
|
||||
"Papery and thin? First crack came too fast — add thirty seconds. Savory with the florals gone? Too slow — take thirty off. Sharp acidity? Development plus fifteen. Change exactly one variable per batch, write it down, and let the next roast prove it. That note is what this app carries into your next plan.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "inside-bean",
|
||||
title: "Inside the bean",
|
||||
minutes: 8,
|
||||
slides: [
|
||||
{
|
||||
id: "ib-cells",
|
||||
title: "A few million tiny boilers",
|
||||
scene: ["ibCells", {}],
|
||||
transcript:
|
||||
"A green bean isn't a sponge — it's a dense, glassy block honeycombed with a few million microscopic cells. Each one is packed with oil, sugar, protein, and water. Roasting is what happens when every one of those cells becomes a tiny boiler.",
|
||||
},
|
||||
{
|
||||
id: "ib-seal",
|
||||
title: "The bean seals itself shut",
|
||||
scene: ["ibSeal", {}],
|
||||
transcript:
|
||||
"Cell walls have natural pores that would leak steam harmlessly away. But early in the roast, heat drives the cell's own oil outward, plugging those pores shut. The bean seals itself — and that is why pressure can build at all.",
|
||||
},
|
||||
{
|
||||
id: "ib-glass",
|
||||
title: "Glass to rubber, and back",
|
||||
scene: ["ibGlass", {}],
|
||||
transcript:
|
||||
"Within the first minute the warm, moist bean crosses its glass transition — from hard candy to warm caramel. Everything that follows happens in a soft, stretchable material. When the finished bean dries out and cools, it turns glassy again, locking its puffed shape in place forever.",
|
||||
},
|
||||
{
|
||||
id: "ib-front",
|
||||
title: "The retreating shoreline",
|
||||
scene: ["ibFront", {}],
|
||||
transcript:
|
||||
"Water doesn't leave the bean evenly — it retreats from the outside in, like a shoreline. Ahead of the front the core stays wet and cool, pinned near boiling. Behind it the dry shell races ahead, browning first. That gap between outside and inside is what the rest of the roast must close.",
|
||||
},
|
||||
{
|
||||
id: "ib-pressure",
|
||||
title: "Twice espresso pressure",
|
||||
scene: ["ibPressure", {}],
|
||||
transcript:
|
||||
"As long as liquid water remains, the steam pressure inside each cell is locked to the temperature — around fifteen bar as the bean nears first crack. That's twice what an espresso machine pushes, inside something the size of a pea. No one has measured it directly; the steam tables set the ceiling.",
|
||||
},
|
||||
{
|
||||
id: "ib-crack",
|
||||
title: "Why it finally cracks",
|
||||
scene: ["ibCrack", {}],
|
||||
transcript:
|
||||
"First crack is a structural failure: the rubbery, pressurized interior strains against the stiffer shell that dried first, until the shell gives way. It isn't one explosion — it's millions of tiny ones. And listen closely: fewer than one bean in ten actually pops out loud.",
|
||||
},
|
||||
{
|
||||
id: "ib-foam",
|
||||
title: "The bean becomes a foam",
|
||||
scene: ["ibFoam", {}],
|
||||
transcript:
|
||||
"By the drop, the bean has swollen by half or more and nearly half its volume is air — it's become a foam. Surprisingly, that expansion is steady across the whole roast, not a single pop at first crack. The crack is the sound of walls failing, not the moment of inflation.",
|
||||
},
|
||||
{
|
||||
id: "ib-mortar",
|
||||
title: "Mortar and rebar",
|
||||
scene: ["ibMortar", {}],
|
||||
transcript:
|
||||
"The cell walls are mortar and rebar. Roasting dissolves the soft mortar — the gums and sugars, up to sixty percent of them — which is what lets the bean stretch without falling apart. The cellulose rebar barely reacts. Push on toward very dark, and even the rebar starts to char and snap: that's second crack, a brittle fracture, not a steam pop.",
|
||||
},
|
||||
{
|
||||
id: "ib-co2",
|
||||
title: "Coffee is carbonated",
|
||||
scene: ["ibCo2", {}],
|
||||
transcript:
|
||||
"Roasting brews carbon dioxide inside the bean — up to eight liters per kilo, trapped in pores a few nanometers wide. Whole beans need weeks to let it go; grinding rips half of it out in seconds. That gas is the bloom in your pour-over and the crema on your espresso.",
|
||||
tts: "Roasting brews carbon dioxide inside the bean — up to eight liters per kilo, trapped in pores a few nano-meters wide. Whole beans need weeks to let it go; grinding rips half of it out in seconds. That gas is the bloom in your pour-over and the crema on your espresso.",
|
||||
},
|
||||
{
|
||||
id: "ib-color",
|
||||
title: "Same color, different coffee",
|
||||
scene: ["ibColor", {}],
|
||||
transcript:
|
||||
"Two roasts can land on exactly the same color and still be different coffees — a fast, hot roast ends puffier, lighter, and chemically distinct from a slow one at the same shade. Color tells you where the roast ended. The curve tells you how it got there. Log both.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "senses",
|
||||
title: "Roasting by the senses",
|
||||
minutes: 8,
|
||||
slides: [
|
||||
{
|
||||
id: "sn-instruments",
|
||||
title: "Your best instruments",
|
||||
scene: ["snInstruments", {}],
|
||||
transcript:
|
||||
"The same first crack can read three-ninety on one machine and four-ten on another — probes measure their own placement as much as the bean. Your nose, eyes, and ears are the instruments that transfer between machines. This lesson trains them.",
|
||||
tts: "The same first crack can read three ninety on one machine and four ten on another — probes measure their own placement as much as the bean. Your nose, eyes, and ears are the instruments that transfer between machines. This lesson trains them.",
|
||||
},
|
||||
{
|
||||
id: "sn-grass",
|
||||
title: "Grass, then wet hay",
|
||||
scene: ["snGrass", {}],
|
||||
transcript:
|
||||
"For the first minutes the trier smells like a lawn, then a barn: cut grass, wet hay, damp grain. The air above the beans is steam, not smoke — humid and heavy. Nothing is being created yet; the bean is only letting go of water.",
|
||||
},
|
||||
{
|
||||
id: "sn-hinge",
|
||||
title: "The hinge: hay becomes bread",
|
||||
scene: ["snHinge", {}],
|
||||
transcript:
|
||||
"Somewhere around three-thirty on the probe, in the space of ten seconds, hay becomes baking biscuits. That scent change is the end of drying — the water is out and browning can begin. Green coffee colors vary too much to trust your eyes here; experienced roasters call this moment by nose.",
|
||||
tts: "Somewhere around three thirty on the probe, in the space of ten seconds, hay becomes baking biscuits. That scent change is the end of drying — the water is out and browning can begin. Green coffee colors vary too much to trust your eyes here; experienced roasters call this moment by nose.",
|
||||
},
|
||||
{
|
||||
id: "sn-toast",
|
||||
title: "The bakery run",
|
||||
scene: ["snToast", {}],
|
||||
transcript:
|
||||
"Browning smells arrive in order: toasted grain, then nuts, then caramel as sugars begin to melt near three-seventy. Notice the texture of the smell too — the humid, steamy edge disappears, leaving dry bakery air. Sweetness is being built right now, and your nose can follow it.",
|
||||
tts: "Browning smells arrive in order: toasted grain, then nuts, then caramel as sugars begin to melt near three seventy. Notice the texture of the smell too — the humid, steamy edge disappears, leaving dry bakery air. Sweetness is being built right now, and your nose can follow it.",
|
||||
},
|
||||
{
|
||||
id: "sn-sight",
|
||||
title: "What the trier shows",
|
||||
scene: ["snSight", {}],
|
||||
transcript:
|
||||
"Watch the trier: yellow, then a marbled tan, then brown — with the center crease still clamped shut. Uneven, mottled color at light roast is normal, not a defect. Real expansion and the big chaff release only come when first crack throws the crease open.",
|
||||
},
|
||||
{
|
||||
id: "sn-firstcrack",
|
||||
title: "First crack: popcorn",
|
||||
scene: ["snFirstcrack", {}],
|
||||
transcript:
|
||||
"First crack sounds like popcorn: loud, low, well-separated pops. Ignore the first stray tick — that's one broken bean, not the batch. Call it when three to five pops land in quick succession. And remember the strange truth: fewer than one bean in ten ever pops out loud.",
|
||||
},
|
||||
{
|
||||
id: "sn-secondcrack",
|
||||
title: "Second crack: rice krispies",
|
||||
scene: ["snSecondcrack", {}],
|
||||
transcript:
|
||||
"Second crack is a different instrument: a fast, shallow crackle like rice krispies in milk, higher-pitched and quieter per snap but five times as frequent. The water is long gone — this is a brittle, carbonizing structure fracturing under gas pressure, and oils are being pushed to the surface.",
|
||||
},
|
||||
{
|
||||
id: "sn-arcs",
|
||||
title: "The four flavor arcs",
|
||||
scene: ["snArcs", {}],
|
||||
transcript:
|
||||
"Four arcs tell the flavor story. Acidity is only ever destroyed — the longer you roast, the less survives. Sweetness is a hill that peaks shortly after first crack. Body builds, then hollows out again in dark territory — dark roasts are thinner, not bolder. And bitterness waits, then climbs fast.",
|
||||
},
|
||||
{
|
||||
id: "sn-droplevels",
|
||||
title: "What each drop tastes like",
|
||||
scene: ["snDroplevels", {}],
|
||||
transcript:
|
||||
"Drop at City and the cup is bright, juicy, and delicate. City-plus is the balance point most home roasters chase. Full City turns bittersweet and chocolatey; a touch further suits espresso. Past that, roast character eclipses the origin — and by French the cup is thinner, smoky, and mostly about the roast itself.",
|
||||
},
|
||||
{
|
||||
id: "sn-smoke",
|
||||
title: "Steam becomes smoke",
|
||||
scene: ["snSmoke", {}],
|
||||
transcript:
|
||||
"The entire roast, told by your nose: steam slowly becomes smoke. A last wave of deep, sweet 'pre-smoke' aroma means real smoke is thirty seconds away. Heavy smoke means stop — the sweetness is already gone. And if tomorrow's grounds smell sharp and acrid, your charge was too hot today.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "gone-wrong",
|
||||
title: "When roasts go wrong",
|
||||
minutes: 9,
|
||||
slides: [
|
||||
{
|
||||
id: "ww-good",
|
||||
title: "What good looks like",
|
||||
scene: ["wwGood", {}],
|
||||
transcript:
|
||||
"Before the failure gallery, the target: a rate of rise that falls smoothly from turning point to drop, beans even in color with the inside matching the outside, weight loss on target — and a cup whose sweetness survives as it cools. Every defect that follows breaks one of these.",
|
||||
},
|
||||
{
|
||||
id: "ww-underdev",
|
||||
title: "Underdeveloped",
|
||||
scene: ["wwUnderdev", {}],
|
||||
transcript:
|
||||
"Snap a bean in half: a dark surface fading to a pale center means the heat never reached the core. The cup is grassy, sharp, and papery, sour and bitter at once with no sweetness between. Don't just roast darker — that burns the outside while the inside stays raw. Deliver energy earlier, or give development fifteen to thirty more seconds.",
|
||||
},
|
||||
{
|
||||
id: "ww-baked",
|
||||
title: "Baked — the invisible defect",
|
||||
scene: ["wwBaked", {}],
|
||||
transcript:
|
||||
"Stall the roast and it bakes: flat, bready, cardboard, no sweetness. The chemistry kept running at low energy — it spent the bean's sugars without making the sweet, aromatic compounds you were roasting for. Baked coffee looks perfectly normal; the tell is a cup that tastes okay hot and empty once it cools.",
|
||||
},
|
||||
{
|
||||
id: "ww-crashflick",
|
||||
title: "Crash and flick",
|
||||
scene: ["wwCrashflick", {}],
|
||||
transcript:
|
||||
"At first crack the beans dump steam and the rate of rise wants to dive; cut heat too late and it crashes, then chase it with gas and it flicks back up. The crash tastes baked, the flick tastes roasty — even at a light color. Both are fixed upstream: smaller, earlier moves, about forty-five seconds before the crack, and never panic-feed a crash.",
|
||||
},
|
||||
{
|
||||
id: "ww-scorch",
|
||||
title: "Scorching, tipping, facing",
|
||||
scene: ["wwScorch", {}],
|
||||
transcript:
|
||||
"Burn specks before the beans even tan, blackened tips, or charred faces late in the roast — three appearances of one mistake: metal or air hotter than the bean could absorb. All of them taste burnt and ashy over a hollow cup. Drop the charge ten degrees, load the drum properly, keep the beans tumbling.",
|
||||
},
|
||||
{
|
||||
id: "ww-fast",
|
||||
title: "Too fast — the gradient roast",
|
||||
scene: ["wwFast", {}],
|
||||
transcript:
|
||||
"Coffee conducts heat poorly — the inside needs time. Rush the roast and you don't get an average, you get a gradient: char over raw. The tell is a cup that tastes burnt and grassy at the same time, and no drop temperature can fix it. Slow the early roast and stretch the path to first crack.",
|
||||
},
|
||||
{
|
||||
id: "ww-quakers",
|
||||
title: "Quakers — the beans that can't brown",
|
||||
scene: ["wwQuakers", {}],
|
||||
transcript:
|
||||
"A few beans stay pale while the batch browns — quakers, seeds picked unripe, that never stored the sugars browning feeds on. Same drum, same heat, no color: proof that roast color is chemistry, not temperature. They taste like paper and peanut shells. You can't roast them out — pick them out of the cooling tray.",
|
||||
},
|
||||
{
|
||||
id: "ww-uneven",
|
||||
title: "The uneven batch",
|
||||
scene: ["wwUneven", {}],
|
||||
transcript:
|
||||
"An uneven batch is a message — read the pattern. A couple of pale outliers are quakers: the green's fault. A continuous spread of shades means the beans lived different roasts: mixed moisture or density, an overloaded drum, or too little airflow. Size the batch to the machine and buy well-sorted green.",
|
||||
},
|
||||
{
|
||||
id: "ww-airflow",
|
||||
title: "Air is a heat control",
|
||||
scene: ["wwAirflow", {}],
|
||||
transcript:
|
||||
"Around seventy percent of a drum roaster's heat arrives by air, so the fan is a heat control, not an exhaust switch. Starve it and smoke settles back onto the beans — ashy cups and a chaff fire waiting. Overdo it and you strip aromatics and stall the roast. Run it low through drying, medium through browning, high from just before crack to drop.",
|
||||
},
|
||||
{
|
||||
id: "ww-diagnose",
|
||||
title: "Taste backwards, fix forwards",
|
||||
scene: ["wwDiagnose", {}],
|
||||
transcript:
|
||||
"Let the cup diagnose the roast. Sour and thin asks for more development. Flat cardboard means you stalled — carry momentum through first crack. Smoky but dull points at a crash and flick, or starved airflow. Then the discipline: one change per batch, written down — and before you blame the roast, make sure it isn't just the brew.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "brewing",
|
||||
name: "Brewing",
|
||||
blurb: "Extraction physics first, then the technique for every brewer you own.",
|
||||
lessons: [
|
||||
{
|
||||
id: "extraction",
|
||||
title: "Extraction — the physics of a cup",
|
||||
minutes: 5,
|
||||
slides: [
|
||||
{
|
||||
id: "ex-dissolve",
|
||||
title: "Coffee is a solution",
|
||||
scene: ["dissolve", {}],
|
||||
transcript:
|
||||
"Brewing is controlled dissolving. Hot water pulls soluble material out of ground coffee in a fixed order: bright fruit acids leave first, then sweet caramels and sugars, and finally the heavy bitter compounds. Every brew method is just a different way of deciding when to stop.",
|
||||
},
|
||||
{
|
||||
id: "ex-yield",
|
||||
title: "Strength and extraction",
|
||||
scene: ["yieldMap", {}],
|
||||
transcript:
|
||||
"Two numbers describe any cup. Strength is how much dissolved coffee is in the water. Extraction is how much of the grounds you dissolved — sweetness peaks around eighteen to twenty-two percent. Under-extracted is sour and thin; over-extracted is bitter and drying.",
|
||||
},
|
||||
{
|
||||
id: "ex-grind",
|
||||
title: "Grind is surface area",
|
||||
scene: ["grind", {}],
|
||||
transcript:
|
||||
"Grinding finer doesn't make coffee stronger by magic — it exposes more surface, so water extracts faster. Finer also slows the water down through the bed. Grind size is therefore your master dial: it moves extraction and contact time at once.",
|
||||
},
|
||||
{
|
||||
id: "ex-temp-time",
|
||||
title: "Temperature, time, ratio",
|
||||
scene: ["tempTime", {}],
|
||||
transcript:
|
||||
"Hotter water extracts faster — 90 to 96 degrees Celsius suits most light roasts. Time decides how far along the dissolving order you travel. And the brew ratio — one part coffee to fifteen to seventeen parts water for filter — sets the canvas everything lands on.",
|
||||
tts: "Hotter water extracts faster — ninety to ninety six degrees Celsius suits most light roasts. Time decides how far along the dissolving order you travel. And the brew ratio — one part coffee to fifteen to seventeen parts water for filter — sets the canvas everything lands on.",
|
||||
},
|
||||
{
|
||||
id: "ex-taste",
|
||||
title: "Taste, then move one dial",
|
||||
scene: ["tasteDial", {}],
|
||||
transcript:
|
||||
"Sour, sharp, salty? Extract more: grind finer, brew hotter, or longer. Bitter, drying, hollow? Extract less: coarser, cooler, shorter. Log every brew with its recipe and rating — the same one-change discipline as roasting.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "immersion",
|
||||
title: "Immersion — steep and separate",
|
||||
minutes: 5,
|
||||
slides: [
|
||||
{
|
||||
id: "im-principle",
|
||||
title: "The principle",
|
||||
scene: ["brewAnim", { method: "french-press", mode: "steep" }],
|
||||
transcript:
|
||||
"Immersion is the simplest physics in coffee: all the water meets all the grounds for the whole brew. Extraction is even and forgiving because nothing channels — time and temperature do all the work. The trade: less clarity, more body.",
|
||||
},
|
||||
{
|
||||
id: "im-frenchpress",
|
||||
title: "French press",
|
||||
scene: ["brewAnim", { method: "french-press", mode: "plunge" }],
|
||||
transcript:
|
||||
"Coarse grind, one to fifteen, four minutes. Then the trick most people miss: don't plunge hard — break the crust, skim the foam, and press just below the surface. Pouring gently keeps the fines in the pot instead of your cup.",
|
||||
tts: "Coarse grind, one to fifteen, four minutes. Then the trick most people miss: don't plunge hard — break the crust, skim the foam, and press just below the surface. Pouring gently keeps the fines in the pot, instead of your cup.",
|
||||
},
|
||||
{
|
||||
id: "im-aeropress",
|
||||
title: "AeroPress",
|
||||
scene: ["brewAnim", { method: "aeropress", mode: "press" }],
|
||||
transcript:
|
||||
"An immersion brew finished under gentle pressure. Medium-fine grind, one to twelve, ninety seconds of steep, thirty seconds of press. The pressure pushes water through the bed at the end, so you get immersion's evenness with a cleaner, fuller finish.",
|
||||
tts: "An air-o-press brew is an immersion finished under gentle pressure. Medium fine grind, one to twelve, ninety seconds of steep, thirty seconds of press. The pressure pushes water through the bed at the end, so you get immersion's evenness with a cleaner, fuller finish.",
|
||||
},
|
||||
{
|
||||
id: "im-switch",
|
||||
title: "Clever & Hario Switch — hybrids",
|
||||
scene: ["brewAnim", { method: "hario-switch", mode: "valve" }],
|
||||
transcript:
|
||||
"A valve under a cone gives you both worlds: steep like an immersion, then flip the switch and drain through the filter like a pour-over. Steep two minutes closed, open, and let it draw down — even extraction plus paper-filter clarity.",
|
||||
tts: "A valve under a cone gives you both worlds: steep like an immersion, then flip the switch and drain through the filter like a pour over. Steep two minutes closed, open, and let it draw down — even extraction plus paper filter clarity.",
|
||||
},
|
||||
{
|
||||
id: "im-coldcup",
|
||||
title: "Cold brew & the cupping bowl",
|
||||
scene: ["brewAnim", { method: "cold-brew", mode: "steep" }],
|
||||
transcript:
|
||||
"Cold water extracts slowly, so cold brew trades heat for time: coarse grind, one to eight, twelve to eighteen hours, then filter. And the cupping bowl is immersion stripped bare — just grounds, water, and a spoon. It's how this app's cupping scores are meant to be tasted.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "percolation",
|
||||
title: "Percolation — water passing through",
|
||||
minutes: 5,
|
||||
slides: [
|
||||
{
|
||||
id: "pc-principle",
|
||||
title: "The principle",
|
||||
scene: ["brewAnim", { method: "v60", mode: "pour" }],
|
||||
transcript:
|
||||
"In percolation, fresh water continuously passes through a bed of grounds — always hungry, always extracting. That makes it efficient and brilliantly clear, but unforgiving: wherever water finds an easy path, it channels, over-extracting one spot and ignoring the rest.",
|
||||
},
|
||||
{
|
||||
id: "pc-bloom",
|
||||
title: "The bloom",
|
||||
scene: ["bloom", {}],
|
||||
transcript:
|
||||
"Fresh coffee is full of trapped carbon dioxide, and gas pushes water away from the grounds. So we bloom: wet the bed with twice its weight in water, watch it bubble and swell, and wait thirty to forty-five seconds. Skipping the bloom is brewing on top of a gas bubble.",
|
||||
tts: "Fresh coffee is full of trapped carbon dioxide, and gas pushes water away from the grounds. So we bloom: wet the bed with twice its weight in water, watch it bubble and swell, and wait thirty to forty five seconds. Skipping the bloom is brewing on top of a gas bubble.",
|
||||
},
|
||||
{
|
||||
id: "pc-v60",
|
||||
title: "V60 — the cone",
|
||||
scene: ["brewAnim", { method: "v60", mode: "spiral" }],
|
||||
transcript:
|
||||
"Medium-fine grind, one to sixteen, finished around three minutes. Pour in slow spirals from the center out, keeping the bed level — the cone's single hole means your pour rate controls the flow. Gentle pulses beat one big flood.",
|
||||
tts: "For the V sixty: medium fine grind, one to sixteen, finished around three minutes. Pour in slow spirals from the center out, keeping the bed level — the cone's single hole means your pour rate controls the flow. Gentle pulses beat one big flood.",
|
||||
},
|
||||
{
|
||||
id: "pc-flat",
|
||||
title: "Chemex & flat bottoms",
|
||||
scene: ["brewAnim", { method: "chemex", mode: "pour" }],
|
||||
transcript:
|
||||
"The Chemex's thick paper filters for the cleanest cup on this page — grind slightly coarser to keep it flowing. Flat-bottom brewers like the Kalita Wave spread water across an even bed, more forgiving of pour technique at a small cost in brightness.",
|
||||
tts: "The KEM-ex's thick paper filters for the cleanest cup on this page — grind slightly coarser to keep it flowing. Flat bottom brewers like the ka-LEE-ta Wave spread water across an even bed, more forgiving of pour technique at a small cost in brightness.",
|
||||
},
|
||||
{
|
||||
id: "pc-batch",
|
||||
title: "Auto drip & the stovetop percolator",
|
||||
scene: ["brewAnim", { method: "batch", mode: "drip" }],
|
||||
transcript:
|
||||
"A good batch brewer is just a robot doing percolation with perfect consistency — give it the same grind and ratio you'd use on a flat bottom. The classic stovetop percolator recycles brewed coffee back over the grounds; charming, but it over-extracts by design. Keep it short.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "espresso",
|
||||
title: "Espresso & pressure",
|
||||
minutes: 5,
|
||||
slides: [
|
||||
{
|
||||
id: "es-principle",
|
||||
title: "Nine bars through a puck",
|
||||
scene: ["brewAnim", { method: "espresso", mode: "pressure" }],
|
||||
transcript:
|
||||
"Espresso forces water at nine atmospheres through a compressed puck of finely ground coffee — a thirty-times concentrated brew in thirty seconds. At that intensity every variable is magnified, which is why espresso rewards obsessive consistency.",
|
||||
},
|
||||
{
|
||||
id: "es-puck",
|
||||
title: "The puck is everything",
|
||||
scene: ["tamp", {}],
|
||||
transcript:
|
||||
"Pressurized water exploits any weakness: a crack, a loose corner, an uneven density. Dose to the tenth of a gram, distribute the grounds evenly, tamp level. Puck preparation isn't ritual — it's channel prevention.",
|
||||
},
|
||||
{
|
||||
id: "es-shot",
|
||||
title: "Reading the shot",
|
||||
scene: ["shot", {}],
|
||||
transcript:
|
||||
"Start from a one-to-two ratio: eighteen grams in, thirty-six out, in twenty-five to thirty-two seconds. The stream should start as slow dark drips and flow like warm honey. Blonde and gushing means under-extracted; choked drips mean grind coarser.",
|
||||
tts: "Start from a one to two ratio: eighteen grams in, thirty six out, in twenty five to thirty two seconds. The stream should start as slow dark drips and flow like warm honey. Blonde and gushing means under-extracted; choked drips mean grind coarser.",
|
||||
},
|
||||
{
|
||||
id: "es-dial",
|
||||
title: "Dialing in",
|
||||
scene: ["tasteDial", {}],
|
||||
transcript:
|
||||
"Keep dose and ratio fixed; move only the grind. Sour and fast — finer. Bitter and slow — coarser. One change per shot, logged. When the grind is right, then and only then adjust ratio for body, or temperature for brightness.",
|
||||
},
|
||||
{
|
||||
id: "es-moka",
|
||||
title: "The moka pot",
|
||||
scene: ["brewAnim", { method: "moka", mode: "flame" }],
|
||||
transcript:
|
||||
"The moka pot is pressure brewing at one and a half bars: boiler steam pushes hot water up through the grounds. Fill the boiler with pre-heated water, use a medium-fine grind, never tamp, and pull it off the heat when the flow gurgles — the last steam-driven pulse scalds.",
|
||||
tts: "The MOH-kah pot is pressure brewing at one and a half bars: boiler steam pushes hot water up through the grounds. Fill the boiler with pre-heated water, use a medium fine grind, never tamp, and pull it off the heat when the flow gurgles — the last steam-driven pulse scalds.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const ACADEMY_SLIDE_COUNT = ACADEMY_TRACKS.reduce(
|
||||
(sum, track) => sum + track.lessons.reduce((s, l) => s + l.slides.length, 0),
|
||||
0,
|
||||
);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,222 @@
|
||||
import { protectedFetch } from "./api.js?v=__ASSET_VERSION__";
|
||||
import { initSideNav, loadNavUser } from "./nav.js?v=__ASSET_VERSION__";
|
||||
import { ACADEMY_TRACKS } from "./academy-content.js?v=__ASSET_VERSION__";
|
||||
import { SCENES, sceneDefs, sceneFallbackText } from "./academy-scenes.js?v=__ASSET_VERSION__";
|
||||
|
||||
// ── Progress ───────────────────────────────────────────────────────────────
|
||||
|
||||
const PROGRESS_KEY = "academyProgress.v1";
|
||||
function loadProgress() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(PROGRESS_KEY)) ?? {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
function saveProgress(lessonId, patch) {
|
||||
const all = loadProgress();
|
||||
all[lessonId] = { ...all[lessonId], ...patch };
|
||||
try {
|
||||
localStorage.setItem(PROGRESS_KEY, JSON.stringify(all));
|
||||
} catch {
|
||||
/* storage unavailable */
|
||||
}
|
||||
}
|
||||
|
||||
// ── Player ─────────────────────────────────────────────────────────────────
|
||||
|
||||
let current = null; // {track, lesson, index}
|
||||
const audio = new Audio();
|
||||
let autoplay = true;
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
function renderMenu() {
|
||||
const progress = loadProgress();
|
||||
const mount = $("academy-menu");
|
||||
mount.replaceChildren(
|
||||
...ACADEMY_TRACKS.map((track) => {
|
||||
const wrap = document.createElement("section");
|
||||
wrap.className = "panel-card";
|
||||
const head = document.createElement("div");
|
||||
head.className = "panel-head";
|
||||
const h2 = document.createElement("h2");
|
||||
h2.textContent = track.name;
|
||||
head.append(h2);
|
||||
const body = document.createElement("div");
|
||||
body.className = "panel-body";
|
||||
const blurb = document.createElement("p");
|
||||
blurb.className = "muted";
|
||||
blurb.style.marginTop = "0";
|
||||
blurb.textContent = track.blurb;
|
||||
body.append(blurb);
|
||||
const list = document.createElement("div");
|
||||
list.className = "academy-lessons";
|
||||
track.lessons.forEach((lesson, li) => {
|
||||
const state = progress[lesson.id] ?? {};
|
||||
const resumeAt = state.done ? 0 : (state.slide ?? 0);
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "academy-lesson";
|
||||
const num = document.createElement("span");
|
||||
num.className = "academy-lesson-num";
|
||||
num.textContent = state.done ? "✓" : String(li + 1);
|
||||
if (state.done) num.classList.add("done");
|
||||
const mid = document.createElement("span");
|
||||
mid.className = "academy-lesson-mid";
|
||||
const label = document.createElement("span");
|
||||
label.className = "academy-lesson-label";
|
||||
label.textContent = lesson.title;
|
||||
const meta = document.createElement("span");
|
||||
meta.className = "academy-lesson-meta";
|
||||
meta.textContent = state.done
|
||||
? `completed · ${lesson.slides.length} slides · ~${lesson.minutes} min`
|
||||
: resumeAt > 0
|
||||
? `resume at slide ${resumeAt + 1} of ${lesson.slides.length}`
|
||||
: `${lesson.slides.length} slides · ~${lesson.minutes} min`;
|
||||
const bar = document.createElement("span");
|
||||
bar.className = "academy-progress";
|
||||
const fill = document.createElement("span");
|
||||
fill.className = "academy-progress-fill";
|
||||
fill.style.width = state.done
|
||||
? "100%"
|
||||
: `${Math.round((resumeAt / lesson.slides.length) * 100)}%`;
|
||||
bar.append(fill);
|
||||
mid.append(label, meta, bar);
|
||||
btn.append(num, mid);
|
||||
btn.addEventListener("click", () => openLesson(track, lesson, resumeAt));
|
||||
list.append(btn);
|
||||
});
|
||||
body.append(list);
|
||||
wrap.append(head, body);
|
||||
return wrap;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function openLesson(track, lesson, index) {
|
||||
current = { track, lesson, index };
|
||||
$("academy-menu").classList.add("hidden");
|
||||
$("academy-player").classList.remove("hidden");
|
||||
renderSlide();
|
||||
}
|
||||
|
||||
function closeLesson() {
|
||||
audio.pause();
|
||||
current = null;
|
||||
$("academy-player").classList.add("hidden");
|
||||
$("academy-menu").classList.remove("hidden");
|
||||
renderMenu(); // reflect fresh progress
|
||||
}
|
||||
|
||||
function renderSlide() {
|
||||
const { lesson, index, track } = current;
|
||||
const slide = lesson.slides[index];
|
||||
saveProgress(lesson.id, { slide: index });
|
||||
$("player-lesson").textContent = `${track.name} — ${lesson.title}`;
|
||||
$("player-slide-title").textContent = slide.title;
|
||||
$("player-count").textContent = `${index + 1} / ${lesson.slides.length}`;
|
||||
|
||||
// Rebuilt per slide so CSS animations restart with it.
|
||||
const svg = $("player-scene");
|
||||
svg.replaceChildren(sceneDefs());
|
||||
try {
|
||||
svg.append(...SCENES[slide.scene[0]](slide.scene[1] ?? {}));
|
||||
} catch {
|
||||
svg.append(sceneFallbackText(slide.title));
|
||||
}
|
||||
|
||||
$("player-caption").textContent = slide.transcript;
|
||||
|
||||
const dots = $("player-dots");
|
||||
dots.replaceChildren(
|
||||
...lesson.slides.map((s, i) => {
|
||||
const dot = document.createElement("button");
|
||||
dot.type = "button";
|
||||
dot.className = `academy-dot${i === index ? " active" : ""}`;
|
||||
dot.setAttribute("aria-label", `Slide ${i + 1}: ${s.title}`);
|
||||
dot.addEventListener("click", () => {
|
||||
current.index = i;
|
||||
renderSlide();
|
||||
});
|
||||
return dot;
|
||||
}),
|
||||
);
|
||||
$("player-prev").disabled = index === 0;
|
||||
$("player-next").textContent =
|
||||
index === lesson.slides.length - 1 ? "Finish lesson" : "Next →";
|
||||
|
||||
audio.src = `/academy-audio/${slide.id}.mp3?v=__ASSET_VERSION__`;
|
||||
if (autoplay) audio.play().catch(() => setPlayState(false));
|
||||
setPlayState(autoplay);
|
||||
}
|
||||
|
||||
function setPlayState(playing) {
|
||||
$("player-play").textContent = playing ? "❚❚" : "▶";
|
||||
$("player-play").setAttribute("aria-label", playing ? "Pause narration" : "Play narration");
|
||||
}
|
||||
|
||||
function next() {
|
||||
const { lesson, index } = current;
|
||||
if (index >= lesson.slides.length - 1) {
|
||||
saveProgress(lesson.id, { done: true, slide: 0 });
|
||||
closeLesson();
|
||||
return;
|
||||
}
|
||||
current.index++;
|
||||
renderSlide();
|
||||
}
|
||||
|
||||
function wirePlayer() {
|
||||
$("player-close").addEventListener("click", closeLesson);
|
||||
$("player-prev").addEventListener("click", () => {
|
||||
if (current.index > 0) {
|
||||
current.index--;
|
||||
renderSlide();
|
||||
}
|
||||
});
|
||||
$("player-next").addEventListener("click", next);
|
||||
$("player-play").addEventListener("click", () => {
|
||||
if (audio.paused) {
|
||||
autoplay = true;
|
||||
audio.play().catch(() => {});
|
||||
setPlayState(true);
|
||||
} else {
|
||||
audio.pause();
|
||||
setPlayState(false);
|
||||
}
|
||||
});
|
||||
audio.addEventListener("ended", () => {
|
||||
setPlayState(false);
|
||||
// Give the animation a beat, then advance — hands-free lesson flow.
|
||||
if (autoplay && current) setTimeout(() => current && next(), 1200);
|
||||
});
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (!current) return;
|
||||
if (event.key === "ArrowRight") next();
|
||||
if (event.key === "ArrowLeft" && current.index > 0) {
|
||||
current.index--;
|
||||
renderSlide();
|
||||
}
|
||||
if (event.key === "Escape") closeLesson();
|
||||
if (event.key === " " && event.target === document.body) {
|
||||
event.preventDefault();
|
||||
$("player-play").click();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById("btn-logout").addEventListener("click", async () => {
|
||||
await protectedFetch("/api/auth/logout", { method: "POST" });
|
||||
location.assign("/");
|
||||
});
|
||||
|
||||
async function init() {
|
||||
initSideNav();
|
||||
const user = await loadNavUser();
|
||||
if (!user) return;
|
||||
renderMenu();
|
||||
wirePlayer();
|
||||
}
|
||||
|
||||
init();
|
||||
+137
-7
@@ -1,6 +1,6 @@
|
||||
import { api, protectedFetch } from "./api.js";
|
||||
import { initSideNav, loadNavUser } from "./nav.js";
|
||||
import { showToast } from "./toast.js";
|
||||
import { api, protectedFetch } from "./api.js?v=__ASSET_VERSION__";
|
||||
import { applyAvatar, initSideNav, loadNavUser } from "./nav.js?v=__ASSET_VERSION__";
|
||||
import { showToast } from "./toast.js?v=__ASSET_VERSION__";
|
||||
|
||||
function fmtDate(value) {
|
||||
return value ? new Date(value).toLocaleString() : "—";
|
||||
@@ -112,7 +112,7 @@ async function loadPlans() {
|
||||
...plans.map((plan) => {
|
||||
const tr = document.createElement("tr");
|
||||
const title = document.createElement("td");
|
||||
title.textContent = plan.plan?.fields?.["0.1"] || "Untitled plan";
|
||||
title.textContent = plan.name || plan.plan?.fields?.["0.1"] || "Untitled plan";
|
||||
const updated = document.createElement("td");
|
||||
updated.textContent = fmtDate(plan.updated_at);
|
||||
const actions = document.createElement("td");
|
||||
@@ -131,7 +131,12 @@ async function loadPlans() {
|
||||
try {
|
||||
await api("/api/plans", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ plan: plan.plan }),
|
||||
body: JSON.stringify({
|
||||
plan: plan.plan,
|
||||
// Carry the custom name across — a rename-only plan would otherwise
|
||||
// duplicate as an indistinguishable "Untitled plan".
|
||||
name: plan.name ? `${plan.name} (copy)` : "",
|
||||
}),
|
||||
});
|
||||
showToast("Plan duplicated.");
|
||||
loadPlans();
|
||||
@@ -150,7 +155,7 @@ async function loadPlans() {
|
||||
});
|
||||
const a = document.createElement("a");
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = `${(plan.plan?.fields?.["0.1"] || "roast-plan").replace(/[^\w-]+/g, "_")}.json`;
|
||||
a.download = `${(plan.name || plan.plan?.fields?.["0.1"] || "roast-plan").replace(/[^\w-]+/g, "_")}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(a.href);
|
||||
});
|
||||
@@ -331,6 +336,129 @@ function wirePwaButtons() {
|
||||
});
|
||||
}
|
||||
|
||||
// Profile picture: resized client-side to a small square JPEG data URL before upload, so the
|
||||
// server only ever stores a few tens of KB per user.
|
||||
function wireAvatar(user) {
|
||||
const refresh = (hasAvatar) => {
|
||||
const shownUser = { ...user, hasAvatar };
|
||||
applyAvatar(document.getElementById("profile-avatar"), shownUser, true);
|
||||
applyAvatar(document.getElementById("nav-user-avatar"), shownUser, true);
|
||||
document.getElementById("avatar-remove").classList.toggle("hidden", !hasAvatar);
|
||||
};
|
||||
refresh(user.hasAvatar);
|
||||
document.getElementById("avatar-file").addEventListener("change", async (event) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = "";
|
||||
if (!file) return;
|
||||
try {
|
||||
const bitmap = await createImageBitmap(file);
|
||||
const size = 128;
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
const context = canvas.getContext("2d");
|
||||
// Cover-crop the centre square so faces aren't squished.
|
||||
const side = Math.min(bitmap.width, bitmap.height);
|
||||
context.drawImage(
|
||||
bitmap,
|
||||
(bitmap.width - side) / 2,
|
||||
(bitmap.height - side) / 2,
|
||||
side,
|
||||
side,
|
||||
0,
|
||||
0,
|
||||
size,
|
||||
size,
|
||||
);
|
||||
const dataUrl = canvas.toDataURL("image/jpeg", 0.85);
|
||||
await api("/api/account/avatar", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ dataUrl }),
|
||||
});
|
||||
showToast("Profile picture updated.");
|
||||
refresh(true);
|
||||
} catch (error) {
|
||||
showToast(error.message || "Could not read that image.", "fail");
|
||||
}
|
||||
});
|
||||
document.getElementById("avatar-remove").addEventListener("click", async () => {
|
||||
try {
|
||||
await api("/api/account/avatar", { method: "DELETE" });
|
||||
showToast("Profile picture removed.");
|
||||
refresh(false);
|
||||
} catch (error) {
|
||||
showToast(error.message, "fail");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function loadTokens() {
|
||||
const body = document.getElementById("tokens-body");
|
||||
try {
|
||||
const { tokens } = await api("/api/tokens");
|
||||
if (!tokens.length) {
|
||||
body.innerHTML = `<tr><td colspan="4" class="empty-state">No API tokens yet.</td></tr>`;
|
||||
return;
|
||||
}
|
||||
body.replaceChildren(
|
||||
...tokens.map((token) => {
|
||||
const tr = document.createElement("tr");
|
||||
const name = document.createElement("td");
|
||||
name.textContent = token.name || "(unnamed)";
|
||||
const created = document.createElement("td");
|
||||
created.textContent = fmtDate(token.createdAt);
|
||||
const used = document.createElement("td");
|
||||
used.textContent = token.lastUsedAt ? fmtDate(token.lastUsedAt) : "Never";
|
||||
const actions = document.createElement("td");
|
||||
actions.className = "data-table-actions";
|
||||
const revoke = document.createElement("button");
|
||||
revoke.className = "ghost-btn small";
|
||||
revoke.type = "button";
|
||||
revoke.textContent = "Revoke";
|
||||
revoke.addEventListener("click", async () => {
|
||||
if (!confirm(`Revoke "${token.name || "this token"}"? Anything using it stops working immediately.`))
|
||||
return;
|
||||
try {
|
||||
await api(`/api/tokens/${token.id}`, { method: "DELETE" });
|
||||
showToast("Token revoked.");
|
||||
await loadTokens();
|
||||
} catch (error) {
|
||||
showToast(error.message, "fail");
|
||||
}
|
||||
});
|
||||
actions.append(revoke);
|
||||
tr.append(name, created, used, actions);
|
||||
return tr;
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
body.innerHTML = `<tr><td colspan="4" class="empty-state">Could not load tokens.</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
function wireTokenForm() {
|
||||
document.getElementById("token-form").addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const button = document.getElementById("token-create");
|
||||
button.disabled = true;
|
||||
try {
|
||||
const created = await api("/api/tokens", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name: event.target.name.value.trim() }),
|
||||
});
|
||||
// Shown exactly once — the server only stores a hash.
|
||||
document.getElementById("token-reveal").textContent = created.token;
|
||||
document.getElementById("token-reveal-wrap").classList.remove("hidden");
|
||||
event.target.reset();
|
||||
await loadTokens();
|
||||
} catch (error) {
|
||||
showToast(error.message, "fail");
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById("btn-logout").addEventListener("click", async () => {
|
||||
await protectedFetch("/api/auth/logout", { method: "POST" });
|
||||
location.assign("/");
|
||||
@@ -345,7 +473,9 @@ async function init() {
|
||||
await loadProfile(user);
|
||||
wireForms(user);
|
||||
wirePwaButtons();
|
||||
await Promise.all([loadSessions(), loadPlans()]);
|
||||
wireTokenForm();
|
||||
wireAvatar(user);
|
||||
await Promise.all([loadSessions(), loadPlans(), loadTokens()]);
|
||||
}
|
||||
|
||||
init();
|
||||
|
||||
+115
-3
@@ -1,6 +1,6 @@
|
||||
import { api, protectedFetch } from "./api.js";
|
||||
import { initSideNav, loadNavUser } from "./nav.js";
|
||||
import { showToast } from "./toast.js";
|
||||
import { api, protectedFetch } from "./api.js?v=__ASSET_VERSION__";
|
||||
import { initSideNav, loadNavUser } from "./nav.js?v=__ASSET_VERSION__";
|
||||
import { showToast } from "./toast.js?v=__ASSET_VERSION__";
|
||||
|
||||
let currentUsers = [];
|
||||
let planFilterUserId = null;
|
||||
@@ -93,6 +93,113 @@ function wireSignupToggle() {
|
||||
});
|
||||
}
|
||||
|
||||
async function loadLlm() {
|
||||
const select = document.getElementById("llm-model-select");
|
||||
const note = document.getElementById("llm-model-note");
|
||||
try {
|
||||
const { current, models, modelsError } = await api("/api/admin/llm");
|
||||
const auto = document.createElement("option");
|
||||
auto.value = "";
|
||||
auto.textContent = "Auto (first available model)";
|
||||
select.replaceChildren(
|
||||
auto,
|
||||
...models.map((model) => {
|
||||
const option = document.createElement("option");
|
||||
option.value = model.key;
|
||||
option.textContent = `${model.name} (${model.provider})`;
|
||||
return option;
|
||||
}),
|
||||
);
|
||||
// A previously-saved model that is no longer configured must still show as selected
|
||||
// rather than silently displaying Auto while the server keeps trying the saved one.
|
||||
if (current && !models.some((model) => model.key === current)) {
|
||||
const missing = document.createElement("option");
|
||||
missing.value = current;
|
||||
missing.textContent = `${current} (no longer configured)`;
|
||||
select.append(missing);
|
||||
}
|
||||
select.value = current;
|
||||
note.textContent = modelsError
|
||||
? "No models are configured on the server — reviews and prefill will fail until one is set up."
|
||||
: current
|
||||
? ""
|
||||
: models.length
|
||||
? `Auto currently resolves to ${models[0].name} (${models[0].provider}).`
|
||||
: "";
|
||||
} catch {
|
||||
note.textContent = "Could not load LLM settings.";
|
||||
}
|
||||
}
|
||||
|
||||
function wireLlmSave() {
|
||||
const button = document.getElementById("llm-model-save");
|
||||
button.addEventListener("click", async () => {
|
||||
button.disabled = true;
|
||||
try {
|
||||
const model = document.getElementById("llm-model-select").value;
|
||||
await api("/api/admin/llm", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ model }),
|
||||
});
|
||||
showToast(model ? "LLM model saved." : "LLM model set to auto.");
|
||||
await loadLlm();
|
||||
} catch (error) {
|
||||
showToast(error.message, "fail");
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function wireBackupImport() {
|
||||
const input = document.getElementById("backup-import-file");
|
||||
const note = document.getElementById("backup-note");
|
||||
input.addEventListener("change", async (event) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = "";
|
||||
if (!file) return;
|
||||
let backup;
|
||||
try {
|
||||
backup = JSON.parse(await file.text());
|
||||
} catch {
|
||||
note.textContent = "That file is not valid JSON.";
|
||||
return;
|
||||
}
|
||||
if (backup.format !== "roast-planner-backup") {
|
||||
note.textContent =
|
||||
"That file is not a Roast Planner backup export (expected the file downloaded by “Export full backup”).";
|
||||
return;
|
||||
}
|
||||
const userCount = backup.tables?.users?.length ?? 0;
|
||||
if (
|
||||
!confirm(
|
||||
`Replace the ENTIRE database with "${file.name}" (${userCount} user${userCount === 1 ? "" : "s"}, exported ${backup.exportedAt ?? "unknown date"})?\n\nEverything currently stored will be deleted. This cannot be undone.`,
|
||||
)
|
||||
)
|
||||
return;
|
||||
note.textContent = "Importing…";
|
||||
try {
|
||||
const result = await api("/api/admin/backup/import", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(backup),
|
||||
});
|
||||
note.textContent = `Imported: ${Object.entries(result.counts)
|
||||
.map(([table, count]) => `${table} ${count}`)
|
||||
.join(", ")}.`;
|
||||
showToast("Backup imported.");
|
||||
if (!result.sessionKept) {
|
||||
alert("The restored data does not include your current session's account — log in again with the restored credentials.");
|
||||
location.assign("/login");
|
||||
return;
|
||||
}
|
||||
await Promise.all([loadMetrics(), loadUsers(), loadPlans(), loadAudit()]);
|
||||
} catch (error) {
|
||||
note.textContent = `Import failed: ${error.message}. Nothing was changed.`;
|
||||
showToast(error.message, "fail");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function roleBadge(user) {
|
||||
return user.role === "admin"
|
||||
? `<span class="badge badge-admin">Admin</span>`
|
||||
@@ -223,6 +330,8 @@ function renderUsers() {
|
||||
showToast("User deleted.");
|
||||
loadUsers();
|
||||
loadMetrics();
|
||||
loadPlans(); // their plans/audit rows are gone too
|
||||
loadAudit();
|
||||
} catch (error) {
|
||||
showToast(error.message, "fail");
|
||||
}
|
||||
@@ -317,12 +426,15 @@ async function init() {
|
||||
if (!user) return;
|
||||
currentUserId = user.id;
|
||||
wireSignupToggle();
|
||||
wireLlmSave();
|
||||
wireBackupImport();
|
||||
await Promise.all([
|
||||
loadMetrics(),
|
||||
loadResetLinks(),
|
||||
loadUsers(),
|
||||
loadPlans(),
|
||||
loadAudit(),
|
||||
loadLlm(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user