From 59645e59b5fcc99578c6539e1352b7f64ef657ef Mon Sep 17 00:00:00 2001 From: Shane Maynard Date: Thu, 30 Jul 2026 14:47:15 -0400 Subject: [PATCH] Add green-bean inventory and cupping features Ports inventory management and SCA-style cupping scoring from hope_roaster: lot tracking with audit-logged consumption, cupping sessions with server-authoritative scoring and a live radar chart, and links from the planner (draw-from-lot, open-cupping-session). Also fixes the Blend/Single-origin toggle layout, replaces tooltips with an in-context "why" teaching layer, and stages the planner UI into pre-roast vs. post-roast phases. --- .gitignore | 1 + db/migrations/002_account.sql | 8 + db/migrations/003_inventory_cupping.sql | 45 + package-lock.json | 10 + package.json | 1 + public/account.html | 259 +++++ public/admin.html | 204 +++- public/app.css | 1158 +++++++++++++++++++-- public/cupping.html | 265 +++++ public/forgot.html | 43 + public/index.html | 604 ++++++++--- public/inventory.html | 222 ++++ public/js/account.js | 351 +++++++ public/js/admin.js | 360 ++++++- public/js/api.js | 35 + public/js/cupping.js | 586 +++++++++++ public/js/forgot.js | 30 + public/js/inventory.js | 265 +++++ public/js/landing.js | 25 - public/js/login.js | 35 + public/js/lot-picker.js | 151 +++ public/js/main.js | 278 ++--- public/js/nav.js | 62 ++ public/js/reset.js | 51 + public/js/setup.js | 37 + public/js/signup.js | 69 ++ public/js/toast.js | 18 + public/js/why-panels.js | 25 + public/landing.html | 191 +++- public/login.html | 49 + public/reset.html | 51 + public/setup.html | 63 ++ public/signup.html | 60 ++ public/sw.js | 31 +- server/app.js | 1270 ++++++++++++++++++++++- server/db.js | 6 +- server/mailer.js | 9 + shared/cupping.js | 239 +++++ shared/fields.js | 1 + test/auth.test.js | 518 +++++++-- test/cupping.test.js | 212 ++++ test/helpers.js | 52 + test/inventory.test.js | 198 ++++ 43 files changed, 7582 insertions(+), 566 deletions(-) create mode 100644 db/migrations/002_account.sql create mode 100644 db/migrations/003_inventory_cupping.sql create mode 100644 public/account.html create mode 100644 public/cupping.html create mode 100644 public/forgot.html create mode 100644 public/inventory.html create mode 100644 public/js/account.js create mode 100644 public/js/api.js create mode 100644 public/js/cupping.js create mode 100644 public/js/forgot.js create mode 100644 public/js/inventory.js delete mode 100644 public/js/landing.js create mode 100644 public/js/login.js create mode 100644 public/js/lot-picker.js create mode 100644 public/js/nav.js create mode 100644 public/js/reset.js create mode 100644 public/js/setup.js create mode 100644 public/js/signup.js create mode 100644 public/js/toast.js create mode 100644 public/js/why-panels.js create mode 100644 public/login.html create mode 100644 public/reset.html create mode 100644 public/setup.html create mode 100644 public/signup.html create mode 100644 server/mailer.js create mode 100644 shared/cupping.js create mode 100644 test/cupping.test.js create mode 100644 test/helpers.js create mode 100644 test/inventory.test.js diff --git a/.gitignore b/.gitignore index 98066dc..6dbcd7a 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ appdata// data/ *.log .DS_Store +dev-harness.mjs diff --git a/db/migrations/002_account.sql b/db/migrations/002_account.sql new file mode 100644 index 0000000..a7b8e78 --- /dev/null +++ b/db/migrations/002_account.sql @@ -0,0 +1,8 @@ +-- Additive only: existing users/sessions/roast_plans rows and columns are untouched. +CREATE TABLE password_reset_tokens (token_hash text PRIMARY KEY, user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, expires_at timestamptz NOT NULL, created_at timestamptz NOT NULL DEFAULT now()); +ALTER TABLE users ADD COLUMN disabled_at timestamptz; +ALTER TABLE sessions ADD COLUMN user_agent text, ADD COLUMN ip text, ADD COLUMN last_seen_at timestamptz; +CREATE TABLE audit_events (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), actor_user_id uuid REFERENCES users(id) ON DELETE SET NULL, action text NOT NULL, target text, created_at timestamptz NOT NULL DEFAULT now()); +CREATE INDEX audit_events_created ON audit_events(created_at DESC); +CREATE INDEX sessions_user_id ON sessions(user_id); +CREATE INDEX password_reset_tokens_user_id ON password_reset_tokens(user_id); diff --git a/db/migrations/003_inventory_cupping.sql b/db/migrations/003_inventory_cupping.sql new file mode 100644 index 0000000..cf82204 --- /dev/null +++ b/db/migrations/003_inventory_cupping.sql @@ -0,0 +1,45 @@ +-- Additive only: existing tables, rows, and columns are untouched. +CREATE TABLE green_bean_lots ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + origin text NOT NULL, + variety text NOT NULL DEFAULT '', + process text NOT NULL DEFAULT '', + producer text NOT NULL DEFAULT '', + purchase_date date, + initial_weight_g numeric NOT NULL, + remaining_weight_g numeric NOT NULL, + cost_total numeric, + moisture_pct numeric, + density_g_l numeric, + notes text NOT NULL DEFAULT '', + archived boolean NOT NULL DEFAULT false, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); +CREATE TABLE bean_consumption ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + lot_id uuid NOT NULL REFERENCES green_bean_lots(id) ON DELETE CASCADE, + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + roast_plan_id uuid REFERENCES roast_plans(id) ON DELETE SET NULL, + weight_g numeric NOT NULL CHECK (weight_g > 0), + created_at timestamptz NOT NULL DEFAULT now() +); +-- One draw-down per roast plan, ever (NULLs are distinct in Postgres, so plan-less manual +-- entries remain unlimited). This is the server-side backstop that makes the client's +-- "Draw from lot" button idempotent even if clicked twice or from two devices. +CREATE UNIQUE INDEX bean_consumption_one_per_plan ON bean_consumption(roast_plan_id); +CREATE TABLE cupping_sessions ( + 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, + data jsonb NOT NULL, + total_score numeric NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX green_bean_lots_user ON green_bean_lots(user_id, archived, purchase_date DESC); +CREATE INDEX bean_consumption_lot ON bean_consumption(lot_id, created_at DESC); +CREATE INDEX bean_consumption_user ON bean_consumption(user_id); +CREATE INDEX cupping_sessions_user ON cupping_sessions(user_id, updated_at DESC); +CREATE INDEX cupping_sessions_plan ON cupping_sessions(roast_plan_id); diff --git a/package-lock.json b/package-lock.json index 4aaed8b..85266e6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "@earendil-works/pi-coding-agent": "^0.83.0", "bcryptjs": "^3.0.3", "express": "^5.0.1", + "nodemailer": "^9.0.3", "pg": "^8.22.0" }, "devDependencies": { @@ -2745,6 +2746,15 @@ "node": ">= 0.6" } }, + "node_modules/nodemailer": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.3.tgz", + "integrity": "sha512-n+YP+NKwR5zRWa60k3GiQ6Q3B4KXCoAw40dAKeCtYn020iNN74aWK2liXIC3ZEATeGql7we3tE3t8QwhY0eskw==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/object-hash": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz", diff --git a/package.json b/package.json index 068d3fd..0c771c0 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "@earendil-works/pi-coding-agent": "^0.83.0", "bcryptjs": "^3.0.3", "express": "^5.0.1", + "nodemailer": "^9.0.3", "pg": "^8.22.0" }, "devDependencies": { diff --git a/public/account.html b/public/account.html new file mode 100644 index 0000000..d4c32cc --- /dev/null +++ b/public/account.html @@ -0,0 +1,259 @@ + + + + + + Account — Roast Planner + + + + + +
+ + +
+
+
+ +
+
+

Account

+

Profile, security, plans, and devices

+
+
+
+
+ + +
+
+
+

Profile

+
+
+

+ + +

+

+

Change email

+
+ + + +
+
+
+ +
+
+

Security

+
+
+

Change password

+
+ + + + +
+

Active sessions

+
+ + + + + + + + + + +
DeviceLast seenExpires
+
+ +
+
+ +
+
+

Your plans

+
+
+
+ + + + + + + + + +
TitleUpdated
+
+
+
+ +
+
+

App

+
+
+

+ Install Roast Planner for a focused, offline-capable + workspace. Updates wait for your confirmation so an + in-progress plan is never discarded. +

+ + +
+
+ +
+
+

Danger zone

+
+
+

+ Deleting your account permanently removes your plans and + cannot be undone. +

+
+ + + +
+
+
+
+
+
+ + + diff --git a/public/admin.html b/public/admin.html index 6e5b29c..308b010 100644 --- a/public/admin.html +++ b/public/admin.html @@ -3,22 +3,200 @@ - Roast Planner Admin + Admin — Roast Planner + + -
-
- ← Plans -

Administration

- -

Users

-
    -

    Recent roast plans

    -
      -
      -
      +
      + + +
      +
      +
      + +
      +
      +

      Administration

      +

      Users, plans, and app settings

      +
      +
      +
      +
      + + +
      +
      + + + +
      +

      Signups

      +
      + +
      +
      + +
      +
      +

      Users

      + +
      +
      +
      + + + + + + + + + + + + +
      EmailRoleStatusPlansJoined
      +
      +
      +
      + +
      +
      +

      Roast plans

      + +
      +
      +
      + + + + + + + + + +
      OwnerTitleUpdated
      +
      +
      +
      + +
      +

      Recent activity

      +
      +
      + + + + + + + + + + +
      WhenActorActionTarget
      +
      +
      +
      +
      +
      +
      diff --git a/public/app.css b/public/app.css index 1ab6113..5811b42 100644 --- a/public/app.css +++ b/public/app.css @@ -33,6 +33,10 @@ "Times New Roman", serif; --font-mono: "SF Mono", Menlo, Consolas, monospace; --header-h: 60px; + --roast: #2a1d16; + --cream: #fbf8f4; + --nav-w: 232px; + --nav-w-collapsed: 64px; } * { @@ -80,6 +84,9 @@ output { border-bottom: 1px solid var(--line); padding: 0 20px; } +.header-row-top { + display: contents; +} .brand { display: flex; align-items: center; @@ -512,6 +519,49 @@ label.filebtn input[type="file"] { margin-top: 0; } +/* ─── Plan vs. roast-day phase staging ──────────────────────────────────── */ +.phase-divider { + display: flex; + align-items: baseline; + gap: 12px; + margin: 6px 0 20px; + padding-top: 18px; + border-top: 2px solid var(--line-strong); +} +.phase-divider-label { + font-size: 11px; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--ember-strong); + white-space: nowrap; +} +.phase-divider-text { + font-size: 12px; + color: var(--ink-3); + line-height: 1.45; +} +.phase-chip { + font-size: 10px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--ink-3); + background: var(--surface-sunken); + padding: 3px 9px; + border-radius: 999px; + white-space: nowrap; +} +.panel-card.phase-later { + opacity: 0.72; + transition: opacity 0.2s; +} +.panel-card.phase-later:hover, +.panel-card.phase-later:focus-within, +.panel-card.phase-later.has-data { + opacity: 1; +} + /* ─── Fields ──────────────────────────────────────────────────────────── */ .field-grid { display: grid; @@ -542,53 +592,191 @@ label.filebtn input[type="file"] { align-items: center; gap: 4px; } -.field-help-host { - position: relative; - display: flex; - flex-direction: column; - min-width: 0; +/* ─── 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 + fields, and fail-only reasoning on the sanity-check tiles. */ +.why-panel { + margin: 0 0 16px; + border-left: 3px solid var(--ember); + border-radius: 0 var(--radius-sm) var(--radius-sm) 0; + background: var(--ember-soft); } -.field-help-host > .field { - width: 100%; -} -.field-help { - position: absolute; - top: -2px; - right: 0; - width: 18px; - height: 18px; - border: 1px solid var(--line-strong); - border-radius: 50%; - background: var(--surface-raised); - color: var(--ember-strong); - font-weight: 800; +.why-panel summary { cursor: pointer; - z-index: 1; + padding: 9px 12px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; + color: var(--ember-strong); + list-style-position: inside; } -.field-help:focus-visible { +.why-panel summary:focus-visible { outline: 2px solid var(--ember); outline-offset: 2px; } -.field-help-text { - margin-top: 2px; - padding: 8px 10px; - border-left: 2px solid var(--ember); - border-radius: 0 var(--radius-sm) var(--radius-sm) 0; - background: var(--ember-soft); - font-size: 11.5px; +.why-panel p { + padding: 0 14px 10px; + font-size: 12.5px; + line-height: 1.55; + color: var(--ink-2); + margin: 0 0 6px; +} +.why-panel p:last-child { + padding-bottom: 12px; +} +.field-note { + display: block; + font-size: 11px; line-height: 1.45; + color: var(--ink-3); + margin: 2px 0 0; +} + +/* ─── Cupping session ────────────────────────────────────────────────────── */ +.cup-score-row, +.cup-tick-row, +.cup-stepper-row { + display: grid; + grid-template-columns: 140px 1fr auto auto; + align-items: center; + gap: 10px; + padding: 8px 0; + border-bottom: 1px solid var(--line); +} +.cup-stepper-row { + grid-template-columns: 140px auto auto auto; +} +.cup-score-row:last-of-type, +.cup-tick-row:last-of-type { + border-bottom: none; +} +.cup-score-label { + font-size: 12.5px; + font-weight: 600; color: var(--ink-2); } -.hint { - font-size: 11px; - color: var(--ink-3); - cursor: help; - border-radius: 50%; - width: 14px; - height: 14px; +.cup-score-row input[type="range"] { + width: 100%; + accent-color: var(--ember); +} +.cup-score-row.unscored input[type="range"] { + opacity: 0.45; +} +.cup-score-value { + font-family: var(--font-mono); + font-size: 13px; + font-weight: 700; + color: var(--ink); + min-width: 40px; + text-align: right; +} +.cup-score-clear { + padding: 3px 7px; +} +.cup-tick-cells { + display: flex; + gap: 4px; + flex-wrap: wrap; +} +.cup-tick { + width: 24px; + height: 24px; + border-radius: var(--radius-sm); + border: 1px solid var(--line-strong); + background: var(--surface-raised); + cursor: pointer; +} +.cup-tick[aria-pressed="true"] { + background: var(--ember); + border-color: var(--ember); +} +.flavor-chip { display: inline-flex; align-items: center; - justify-content: center; + gap: 6px; + padding: 5px 6px 5px 12px; + border-radius: 999px; + border: 1px solid var(--ember); + background: var(--ember-soft); + color: var(--ember-strong); + font-size: 12.5px; + font-weight: 500; +} +.flavor-chip-remove { + width: 24px; + height: 24px; + border: none; + border-radius: 50%; + background: transparent; + color: var(--ember-strong); + cursor: pointer; + font-size: 11px; +} +.flavor-chip-remove:hover { + background: rgba(168, 72, 26, 0.18); +} +.cup-stepper-row output { + min-width: 20px; + text-align: center; + font-family: var(--font-mono); + font-weight: 700; +} +.cupping-defects { + margin-top: 10px; +} +.cupping-defects summary { + cursor: pointer; + font-size: 12px; + font-weight: 700; + color: var(--ink-2); + padding: 6px 0; +} +.flavor-family { + margin-bottom: 8px; + border: 1px solid var(--line); + border-radius: var(--radius-md); + padding: 4px 12px; +} +.flavor-family summary { + cursor: pointer; + padding: 8px 0; + font-size: 13px; + font-weight: 600; + color: var(--ink-2); +} +.flavor-family .subhead { + margin: 10px 0 6px; +} +#cup-radar { + width: 100%; + height: auto; + aspect-ratio: 1; +} + +/* ─── Planner inventory/cupping integration ─────────────────────────────── */ +.inventory-consume { + display: flex; + align-items: center; + gap: 10px; + margin-top: 14px; + padding: 10px 12px; + background: var(--surface-sunken); + border-radius: var(--radius-md); +} +.inventory-consume-label { + font-size: 12.5px; + color: var(--ink-2); + flex: 1 1 auto; +} +.cupping-link { + display: flex; + align-items: center; + gap: 12px; + margin-top: 16px; + padding-top: 16px; + border-top: 1px solid var(--line); } .field-input { width: 100%; @@ -1196,6 +1384,21 @@ body.show-fids .field[data-fid]::after { .check-tile.fail .check-value { color: var(--fail); } +.check-why { + display: none; + font-size: 10.5px; + line-height: 1.4; + color: var(--ink-2); +} +.check-tile.fail .check-why { + display: block; +} +.dock-note { + font-size: 10.5px; + color: var(--ink-3); + line-height: 1.4; + margin: -4px 0 10px; +} /* ─── Curve card ──────────────────────────────────────────────────────── */ .curve-legend { @@ -1231,14 +1434,6 @@ body.show-fids .field[data-fid]::after { border: 1px solid var(--line); } -.unknown-condition { - margin: 0 22px 4px; - padding: 10px 12px; - border-left: 3px solid var(--ember); - background: var(--ember-soft); - font-size: 12.5px; - color: var(--ink-2); -} .ledger-warnings { padding: 10px 12px; border: 1px solid #e8d49e; @@ -1282,6 +1477,806 @@ body.show-fids .field[data-fid]::after { font-size: 11px; color: var(--ink-3); } +/* ─── Side navigation (app shell) ──────────────────────────────────────── */ +.app-shell { + display: flex; + min-height: 100vh; +} +.side-nav { + position: fixed; + top: 0; + bottom: 0; + left: 0; + width: var(--nav-w); + /* Above .drawer-overlay (40) and .drawer (41): when the mobile off-canvas nav is open, its + own backdrop must not intercept clicks on the nav's items (matches how right-side drawers + already sit above the same shared overlay). */ + z-index: 42; + display: flex; + flex-direction: column; + background: var(--roast); + color: var(--cream); + transition: width 0.18s ease; + overflow-x: hidden; +} +.side-nav-brand { + display: flex; + align-items: center; + gap: 10px; + padding: 16px; + flex: 0 0 auto; + white-space: nowrap; +} +.side-nav-brand .brand-mark { + color: var(--ember-soft, #fbefe6); + font-size: 22px; +} +.side-nav-brand span.brand-name { + font-weight: 700; + font-size: 14.5px; + letter-spacing: -0.1px; +} +.nav-group { + display: flex; + flex-direction: column; + gap: 2px; + padding: 6px 8px; +} +.nav-group-title { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.08em; + font-weight: 700; + color: rgba(251, 248, 244, 0.45); + padding: 12px 10px 6px; + white-space: nowrap; +} +.nav-item { + display: flex; + align-items: center; + gap: 12px; + padding: 9px 10px; + border-radius: var(--radius-sm); + color: rgba(251, 248, 244, 0.82); + text-decoration: none; + font-size: 13.5px; + font-weight: 500; + background: transparent; + border: none; + width: 100%; + text-align: left; + cursor: pointer; + font-family: inherit; + white-space: nowrap; + position: relative; +} +.nav-item:hover { + background: rgba(251, 248, 244, 0.08); + color: var(--cream); +} +.nav-item .nav-icon { + flex: 0 0 auto; + width: 18px; + text-align: center; + font-size: 15px; +} +.nav-item[aria-current="page"] { + background: rgba(168, 72, 26, 0.35); + color: #fff; + font-weight: 700; +} +.nav-item[aria-current="page"]::before { + content: ""; + position: absolute; + left: -8px; + top: 6px; + bottom: 6px; + width: 3px; + border-radius: 2px; + background: var(--ember); +} +.nav-spacer { + flex: 1 1 auto; +} +.nav-collapse-toggle { + margin: 8px; + align-self: flex-start; +} +.nav-user { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 16px; + border-top: 1px solid rgba(251, 248, 244, 0.12); +} +.nav-user-avatar { + flex: 0 0 auto; + width: 28px; + height: 28px; + border-radius: 50%; + background: var(--ember); + color: #fff; + display: flex; + align-items: center; + justify-content: center; + font-size: 12.5px; + font-weight: 700; +} +.nav-user-detail { + min-width: 0; + display: flex; + flex-direction: column; + white-space: nowrap; +} +.nav-user-email { + font-size: 12px; + color: var(--cream); + overflow: hidden; + text-overflow: ellipsis; +} +.nav-user-logout { + font-size: 11px; + color: rgba(251, 248, 244, 0.55); + background: none; + border: none; + padding: 0; + text-align: left; + cursor: pointer; + font-family: inherit; +} +.nav-user-logout:hover { + color: var(--cream); + text-decoration: underline; +} +.app-workspace { + flex: 1 1 auto; + min-width: 0; + margin-left: var(--nav-w); + transition: margin-left 0.18s ease; +} +.app-workspace.nav-collapsed { + margin-left: var(--nav-w-collapsed); +} +.nav-hamburger { + display: none; +} +/* The user-toggled collapse (desktop only — the toggle button itself is hidden below 901px, but + the persisted .collapsed class isn't otherwise scoped, and its higher specificity than the + <900px off-canvas rules would otherwise still shrink/relabel the mobile drawer if a user had + collapsed the rail on desktop before viewing on a phone). */ +@media (min-width: 901px) { + .side-nav.collapsed { + width: var(--nav-w-collapsed); + } + .side-nav.collapsed .brand-name, + .side-nav.collapsed .nav-label, + .side-nav.collapsed .nav-group-title, + .side-nav.collapsed .nav-user-detail { + display: none; + } + .side-nav.collapsed .nav-item { + justify-content: center; + padding: 10px; + } +} +/* Icon-only rail: only between the mobile off-canvas breakpoint and the desktop breakpoint — + scoped with min-width so it never fights the <900px off-canvas rules below for the same + element (that previously caused a CSS-specificity conflict that broke the mobile nav width). */ +@media (min-width: 901px) and (max-width: 1180px) { + .side-nav { + width: var(--nav-w-collapsed); + } + .side-nav .brand-name, + .side-nav .nav-label, + .side-nav .nav-group-title, + .side-nav .nav-user-detail { + display: none; + } + .side-nav .nav-item { + justify-content: center; + padding: 10px; + } + .app-workspace { + margin-left: var(--nav-w-collapsed); + } +} +@media (max-width: 900px) { + .side-nav { + transform: translateX(-100%); + transition: transform 0.2s; + width: min(280px, 82vw); + } + .side-nav.mobile-open { + transform: translateX(0); + } + .side-nav .brand-name, + .side-nav .nav-label, + .side-nav .nav-group-title, + .side-nav .nav-user-detail { + display: block; + } + .side-nav .nav-item { + justify-content: flex-start !important; + } + .app-workspace { + margin-left: 0 !important; + } + .nav-hamburger { + display: inline-flex; + } + .nav-collapse-toggle { + display: none; + } +} + +/* ─── Account / admin content shell ─────────────────────────────────────── */ +.page-content { + max-width: 860px; + margin: 0 auto; + padding: 28px 24px 80px; + display: flex; + flex-direction: column; + gap: 20px; +} +.page-content.wide { + max-width: 1160px; +} +.panel-head p.panel-sub { + margin: 2px 0 0; + font-size: 12px; + color: var(--ink-3); + font-weight: 400; +} +.field-row { + display: flex; + gap: 14px; + flex-wrap: wrap; +} +.field-row .field { + flex: 1 1 200px; +} +.session-device-label { + font-size: 12.5px; + color: var(--ink-2); + display: inline-block; + max-width: 200px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + vertical-align: middle; +} +.danger-zone { + border-color: var(--fail-bg); +} +.danger-zone .panel-head { + background: var(--fail-bg); +} +.danger-zone .panel-head h2 { + color: var(--fail); +} +.inline-form { + display: flex; + gap: 8px; + flex-wrap: wrap; + align-items: flex-end; +} +.inline-form .field { + flex: 1 1 160px; +} +.empty-state { + font-size: 13px; + color: var(--ink-3); + padding: 14px 0; + text-align: center; +} +@media (max-width: 720px) { + .page-content { + padding: 16px 14px 60px; + } +} + +/* ─── Data table (admin, account) ──────────────────────────────────────── */ +.data-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} +.data-table th { + text-align: left; + font-size: 10.5px; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--ink-3); + font-weight: 700; + padding: 9px 10px; + background: var(--surface-sunken); + border-bottom: 1px solid var(--line); + white-space: nowrap; +} +.data-table td { + padding: 10px; + border-bottom: 1px solid var(--line); + vertical-align: middle; +} +.data-table tbody tr:hover { + background: var(--surface-sunken); +} +.data-table td.num { + text-align: right; + font-variant-numeric: tabular-nums; +} +.data-table-actions { + display: flex; + gap: 6px; + justify-content: flex-end; + flex-wrap: wrap; +} +.table-wrap { + overflow-x: auto; + border: 1px solid var(--line); + border-radius: var(--radius-md); +} +.table-wrap .data-table { + border: none; +} +.data-table tr.archived { + opacity: 0.55; +} +.lot-remaining { + display: flex; + align-items: center; + gap: 8px; + min-width: 160px; +} +.lot-remaining .blend-total-bar { + flex: 1 1 auto; +} +.lot-remaining .blend-total-label { + white-space: nowrap; +} + +/* ─── Badges, switch, toast, stat tiles ─────────────────────────────────── */ +.badge { + display: inline-flex; + align-items: center; + padding: 2px 9px; + border-radius: 999px; + font-size: 10.5px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.03em; +} +.badge-admin { + background: var(--ember-soft); + color: var(--ember-strong); +} +.badge-user { + background: var(--surface-sunken); + color: var(--ink-2); +} +.badge-disabled { + background: var(--fail-bg); + color: var(--fail); +} +.badge-current { + background: var(--pass-bg); + color: var(--pass); +} +.switch { + position: relative; + display: inline-block; + width: 38px; + height: 22px; + flex: 0 0 auto; +} +.switch input { + opacity: 0; + width: 0; + height: 0; +} +.switch-track { + position: absolute; + inset: 0; + background: var(--line-strong); + border-radius: 999px; + cursor: pointer; + transition: background 0.15s; +} +.switch-track::before { + content: ""; + position: absolute; + top: 2px; + left: 2px; + width: 18px; + height: 18px; + border-radius: 50%; + background: #fff; + transition: transform 0.15s; + box-shadow: 0 1px 2px rgba(27, 22, 20, 0.3); +} +.switch input:checked + .switch-track { + background: var(--ember); +} +.switch input:checked + .switch-track::before { + transform: translateX(16px); +} +.switch input:focus-visible + .switch-track { + outline: 2px solid var(--ember); + outline-offset: 2px; +} +.toast-stack { + position: fixed; + right: 18px; + bottom: 18px; + z-index: 60; + display: flex; + flex-direction: column; + gap: 8px; + max-width: min(360px, calc(100vw - 36px)); +} +.toast { + background: var(--ink); + color: var(--cream); + padding: 11px 14px; + border-radius: var(--radius-sm); + font-size: 12.5px; + box-shadow: var(--shadow-pop); + display: flex; + align-items: center; + gap: 10px; + animation: toast-in 0.18s ease-out; +} +.toast.warn { + background: var(--warn); +} +.toast.fail { + background: var(--fail); +} +@keyframes toast-in { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} +.stat-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 14px; +} +.stat-tile { + background: var(--surface-raised); + border: 1px solid var(--line); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-card); + padding: 16px 18px; +} +.stat-tile-label { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--ink-3); + font-weight: 700; +} +.stat-tile-value { + font-family: var(--font-serif); + font-size: 26px; + font-weight: 600; + color: var(--ink); + margin-top: 4px; +} + +/* ─── Auth pages (login/signup/forgot/reset/setup) ─────────────────────── */ +.auth-page { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 32px 18px; +} +.auth-card { + width: 100%; + max-width: 400px; + padding: 30px 28px 28px; + display: flex; + flex-direction: column; + gap: 14px; +} +.auth-card .auth-brand { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 2px; +} +.auth-card .auth-brand .brand-mark { + color: var(--ember); + font-size: 22px; +} +.auth-card h1 { + font-size: 19px; + font-weight: 700; +} +.auth-card p.lede { + margin: -6px 0 4px; + font-size: 13px; + color: var(--ink-2); +} +.auth-card form { + display: flex; + flex-direction: column; + gap: 12px; +} +.auth-card label { + display: flex; + flex-direction: column; + gap: 5px; + font-size: 12.5px; + font-weight: 600; + color: var(--ink-2); +} +.auth-card .primary-btn { + padding: 10px 12px; + font-size: 13.5px; + margin-top: 4px; +} +.auth-links { + display: flex; + justify-content: space-between; + font-size: 12.5px; +} +.auth-links a { + color: var(--ember); + text-decoration: none; +} +.auth-links a:hover { + text-decoration: underline; +} +.auth-message { + font-size: 12.5px; + border-radius: var(--radius-sm); + padding: 9px 11px; +} +.auth-message:empty { + display: none; +} +.auth-message.error { + background: var(--fail-bg); + color: var(--fail); +} +.auth-message.info { + background: var(--pass-bg); + color: var(--pass); +} +.auth-page .muted { + font-size: 11.5px; + color: var(--ink-3); + text-align: center; + margin: 0; +} + +/* ─── Marketing landing page ────────────────────────────────────────────── */ +.marketing-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 28px; + max-width: 1160px; + margin: 0 auto; +} +.marketing-header .brand { + display: flex; + align-items: center; + gap: 8px; + font-weight: 700; + font-size: 15px; +} +.marketing-header .brand .brand-mark { + color: var(--ember); + font-size: 20px; +} +.marketing-header nav { + display: flex; + gap: 10px; +} +.hero { + background: var(--roast); + color: var(--cream); +} +.hero-inner { + max-width: 1160px; + margin: 0 auto; + padding: 48px 28px 76px; + display: grid; + grid-template-columns: 1.1fr 1fr; + gap: 40px; + align-items: center; +} +.hero h1 { + font-family: var(--font-serif); + font-size: clamp(32px, 4.6vw, 44px); + line-height: 1.12; + letter-spacing: -0.5px; + margin: 0 0 16px; +} +.hero p.lede { + font-size: 16px; + line-height: 1.55; + color: rgba(251, 248, 244, 0.82); + margin: 0 0 24px; + max-width: 46ch; +} +.hero-ctas { + display: flex; + gap: 10px; + flex-wrap: wrap; +} +.hero-ctas .ghost-btn { + color: var(--cream); + border-color: rgba(251, 248, 244, 0.35); +} +.hero-ctas .ghost-btn:hover { + background: rgba(251, 248, 244, 0.12); + color: #fff; +} +.hero-visual { + background: var(--surface-raised); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-pop); + padding: 18px 20px; + color: var(--ink); +} +.marketing-section { + max-width: 1160px; + margin: 0 auto; + padding: 64px 28px; +} +.marketing-section h2 { + font-size: 24px; + font-weight: 700; + text-align: center; + margin: 0 0 8px; +} +.marketing-section p.section-lede { + text-align: center; + color: var(--ink-2); + max-width: 52ch; + margin: 0 auto 36px; +} +.feature-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 18px; +} +.feature-card { + background: var(--surface-raised); + border: 1px solid var(--line); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-card); + padding: 20px 22px; +} +.feature-card .feature-icon { + font-size: 20px; + color: var(--ember); + margin-bottom: 10px; +} +.feature-card h3 { + font-size: 15px; + font-weight: 700; + margin: 0 0 8px; +} +.feature-card p { + font-size: 13.5px; + color: var(--ink-2); + line-height: 1.5; + margin: 0; +} +.feature-row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 14px; + margin-top: 14px; +} +.feature-row-item { + display: flex; + gap: 10px; + align-items: flex-start; + font-size: 13px; + color: var(--ink-2); +} +.feature-row-item .feature-icon { + color: var(--ember); + flex: 0 0 auto; +} +.steps-row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 22px; + counter-reset: step; +} +.step-item { + position: relative; + padding-left: 40px; +} +.step-item::before { + counter-increment: step; + content: counter(step); + position: absolute; + left: 0; + top: 0; + width: 28px; + height: 28px; + border-radius: 50%; + background: var(--ember-soft); + color: var(--ember-strong); + font-weight: 700; + font-size: 13px; + display: flex; + align-items: center; + justify-content: center; +} +.step-item h3 { + font-size: 14px; + font-weight: 700; + margin: 0 0 4px; +} +.step-item p { + font-size: 13px; + color: var(--ink-2); + margin: 0; + line-height: 1.5; +} +.cta-band { + background: var(--ember); + color: #fff; + text-align: center; + padding: 52px 28px; +} +.cta-band h2 { + font-family: var(--font-serif); + font-size: 26px; + margin: 0 0 18px; +} +.cta-band .primary-btn { + background: #fff; + color: var(--ember-strong); + border-color: #fff; + padding: 11px 20px; + font-size: 14px; +} +.cta-band .primary-btn:hover { + background: var(--surface-sunken); +} +.marketing-footer { + padding: 22px 28px 40px; + max-width: 1160px; + margin: 0 auto; + display: flex; + justify-content: space-between; + align-items: center; + font-size: 12.5px; + color: var(--ink-3); +} +.marketing-footer a { + color: var(--ink-2); + margin-left: 14px; + text-decoration: none; +} +.marketing-footer a:hover { + text-decoration: underline; +} +@media (max-width: 860px) { + .hero-inner { + grid-template-columns: 1fr; + padding-bottom: 48px; + } + .hero-visual { + order: -1; + } +} + /* ─── PWA and mobile layout ───────────────────────────────────────────── */ .connection-status { position: sticky; @@ -1301,8 +2296,9 @@ body.show-fids .field[data-fid]::after { @media (max-width: 720px) { :root { - /* The mobile header is multi-row; keep anchor offsets valid rather than using auto in calc(). */ - --header-h: 220px; + /* The mobile header is multi-row (top bar / section pills / actions); keep this in sync + with its actual stacked height so sticky/scroll-anchor offsets stay correct. */ + --header-h: 166px; } body { font-size: 16px; @@ -1311,12 +2307,16 @@ body.show-fids .field[data-fid]::after { .app-header { position: sticky; padding: 10px 12px; - gap: 10px; + gap: 8px; padding-top: max(10px, env(safe-area-inset-top)); } - .brand { - width: 100%; + .header-row-top { + display: flex; + align-items: center; justify-content: space-between; + gap: 10px; + width: 100%; + order: 0; } .brand-mark { font-size: 24px; @@ -1341,28 +2341,36 @@ body.show-fids .field[data-fid]::after { .header-actions { order: 3; width: 100%; - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 7px; + display: flex; + align-items: center; + gap: 8px; + } + .header-actions .autosave-chip { + flex: 1 1 auto; + justify-content: center; + } + .header-actions #btn-print { + flex: 0 0 auto; + min-height: 44px; + padding: 8px 16px; } .header-divider { display: none; } + .autosave-chip { + min-height: 32px; + margin: 0; + } .ghost-btn, .primary-btn, label.filebtn { min-height: 44px; - padding: 8px 6px; - font-size: 12px; - line-height: 1.15; - text-align: center; - white-space: normal; } - .autosave-chip { - grid-column: 1 / -1; - justify-content: center; - min-height: 32px; - margin: 0; + .drawer-body .ghost-btn, + .drawer-body .primary-btn, + .drawer-body label.filebtn { + width: 100%; + text-align: center; } .workspace-grid { padding: 14px 10px 48px; @@ -1383,15 +2391,8 @@ body.show-fids .field[data-fid]::after { .panel-body { padding: 14px; } - .unknown-condition { - margin: 0 14px 4px; - } - .field-help { - width: 22px; - height: 22px; - } - .field-help-text { - font-size: 12px; + .why-panel p { + font-size: 13px; } .field-grid { grid-template-columns: 1fr; @@ -1473,6 +2474,13 @@ body.show-fids .field[data-fid]::after { .check-tile { min-height: 76px; } + .phase-divider { + flex-direction: column; + gap: 4px; + } + .panel-head { + flex-wrap: wrap; + } .curve-wrap { overflow-x: auto; } @@ -1511,12 +2519,20 @@ body.show-fids .field[data-fid]::after { } @media print { + .side-nav, .app-header, .drawer, .drawer-overlay, .workspace-grid { display: none !important; } + /* .app-shell/.app-workspace are otherwise flex/margin containers sized for the on-screen + nav; with everything inside them hidden above, they'd still occupy space and push + #print-sheet (a sibling further down the body) below a blank page. */ + .app-shell, + .app-workspace { + display: contents; + } body { background: #fff; } diff --git a/public/cupping.html b/public/cupping.html new file mode 100644 index 0000000..1b6085b --- /dev/null +++ b/public/cupping.html @@ -0,0 +1,265 @@ + + + + + + Cupping — Roast Planner + + + + + +
      + + +
      +
      +
      + +
      +
      +

      Cupping

      +

      + Tasting and scoring sessions +

      +
      +
      +
      + +
      + + +
      +
      +

      Cupping sessions

      +
      +
      + + + + + + + + + + + + +
      CoffeeScoreCupsFlavorsUpdated
      +
      +
      +
      + +
      +

      New session

      +
      +
      + + + +
      +
      +
      +
      + +
      +
      +
      +
      +
      +

      Scores

      + +
      +
      +
      + Why the scale starts at 6 +

      + The 6–10 range is a specialty-grading convention: + anything below specialty grade simply isn't scored, so + 6 is the floor of the scale, not "zero". Score in + quarter-point steps and leave an attribute unscored + (—) rather than guessing — an unscored attribute + contributes nothing, which is honest; a guessed 7.5 + pollutes every comparison you make later. The tick + rows count cups, not quality: five clean cups out of + five is full marks even if the coffee is merely good. +

      +
      +
      +
      +
      + Defects +
      +
      +
      +
      + +
      +

      Flavors

      +
      +
      + Tag what you can point at +

      + A tag is only useful if you could defend it to + another taster with the cup in front of you. Two or + three confident descriptors beat a dozen hopeful ones + — the list is capped at 32, but a session that needs + 10 is already suspect. +

      +
      + +
      +
      +
      + +
      +

      Notes

      +
      +
      + Notes outlive scores +

      + Numbers compare roasts; sentences explain them. Write + what you'd want to read before roasting this coffee + again — texture, where it fell apart as it cooled, + what you'd change. +

      +
      + +

      0/4000

      +
      +
      +
      + + +
      +
      +
      +
      + + + + diff --git a/public/forgot.html b/public/forgot.html new file mode 100644 index 0000000..cc6ed95 --- /dev/null +++ b/public/forgot.html @@ -0,0 +1,43 @@ + + + + + + Reset your password — Roast Planner + + + + + +
      +
      +
      + + Roast Planner +
      +

      Forgot your password?

      +

      + Enter your account email and we'll send a link to reset your + password. +

      + +
      + + +
      + +
      +
      + + + diff --git a/public/index.html b/public/index.html index 41c37f8..3ab28f3 100644 --- a/public/index.html +++ b/public/index.html @@ -14,71 +14,138 @@ -
      -
      - -
      -

      Roast Planner

      -

      New plan

      +
      +
      - - -
      - - - - - - - - - - - Not saved yet -
      -
      - - + -