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.
This commit is contained in:
2026-07-30 14:47:15 -04:00
parent 9f0a3dbc74
commit 59645e59b5
43 changed files with 7582 additions and 566 deletions
+478 -126
View File
@@ -14,71 +14,138 @@
<link rel="stylesheet" href="/app.css" />
</head>
<body>
<header class="app-header">
<div class="brand">
<span class="brand-mark" aria-hidden="true"></span>
<div class="brand-text">
<h1>Roast Planner</h1>
<p class="brand-sub" id="header-coffee-name">New plan</p>
<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>
<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 &amp; sync</span>
</button>
</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>
</div>
<nav class="section-nav" id="section-nav" aria-label="Plan sections">
<a href="#sec-coffee">Plan</a>
<a href="#sec-bean">Bean condition</a>
<a href="#sec-machine">Roast</a>
<a href="#sec-after">Learn</a>
</nav>
<div class="header-actions">
<button id="btn-plans" type="button" class="ghost-btn">Plans</button>
<button
id="btn-toggle-fids"
type="button"
class="ghost-btn"
aria-pressed="false"
title="Show the worksheet reference codes (e.g. 1.4) on every field"
>
Field IDs
</button>
<button id="btn-toggle-prefill" type="button" class="ghost-btn">
Prefill from URL
</button>
<button id="btn-toggle-alog" type="button" class="ghost-btn">
Reference curve
</button>
<span class="header-divider" aria-hidden="true"></span>
<button id="btn-load" type="button" class="ghost-btn">Load</button>
<input id="file-load" type="file" accept="application/json" />
<button id="btn-save" type="button" class="ghost-btn">Download</button>
<details class="account-menu">
<summary class="ghost-btn" id="account-summary">Account</summary>
<div class="account-menu-popover">
<p class="account-email" id="account-email"></p>
<button id="btn-settings" type="button">Settings &amp; sync</button>
<a id="menu-admin" class="hidden" href="/admin">Admin</a>
<button id="btn-logout" type="button">Log out</button>
<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>Roast Planner</h1>
<p class="brand-sub" id="header-coffee-name">New plan</p>
</div>
</div>
</div>
</details>
<button id="btn-print" type="button" class="primary-btn">Print</button>
<span class="autosave-chip" id="autosave-status"
><span class="dot"></span
><span class="autosave-text">Not saved yet</span></span
>
</div>
</header>
<div
class="connection-status hidden"
id="connection-status"
role="status"
aria-live="polite"
></div>
<div class="drawer-overlay hidden" id="drawer-overlay"></div>
<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>
</nav>
<aside
class="drawer hidden"
id="panel-prefill"
<div class="header-actions">
<span class="autosave-chip" id="autosave-status"
><span class="dot"></span
><span class="autosave-text">Not saved yet</span></span
>
<button id="btn-print" type="button" class="primary-btn">
Print
</button>
</div>
</header>
<div
class="connection-status hidden"
id="connection-status"
role="status"
aria-live="polite"
></div>
<div class="drawer-overlay hidden" id="drawer-overlay"></div>
<aside
class="drawer hidden"
id="panel-prefill"
aria-hidden="true"
role="dialog"
aria-modal="true"
@@ -184,6 +251,39 @@
</div>
</aside>
<aside
class="drawer hidden"
id="panel-import-export"
aria-hidden="true"
role="dialog"
aria-modal="true"
aria-labelledby="import-export-title"
tabindex="-1"
>
<div class="drawer-head">
<h3 id="import-export-title">Import / export</h3>
<button
class="icon-btn"
type="button"
data-close-drawer
aria-label="Close"
>
</button>
</div>
<div class="drawer-body">
<p class="stack-label">Load a plan from a JSON file</p>
<button id="btn-load" type="button" class="primary-btn">
Choose file…
</button>
<input id="file-load" type="file" accept="application/json" />
<p class="stack-label">Save the current plan</p>
<button id="btn-save" type="button" class="ghost-btn">
Download as JSON
</button>
</div>
</aside>
<aside
class="drawer hidden"
id="panel-settings"
@@ -230,6 +330,28 @@
<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>
<summary>Why cultivar sets the clock</summary>
<p>
A roast plan is three durations laid end to end: drying +
Maillard + development = the total roast. Cultivar is the
one input that sets a time you cannot negotiate — when
first crack lands. The seed's genetics fix how much sugar
and acid it carries and how heat gets into it, so each
cultivar browns and builds its aromatics on its own
schedule. A Bourbon rushed to a 7:30 first crack tastes
thin and papery no matter how well you handle the rest; a
Gesha dragged to 9:00 goes savory and loses the florals you
paid for.
</p>
<p>
That is why the FC anchor below feeds Time Ledger line 1
and everything else in the plan is a correction of seconds
around it. Treat the cultivar on the bag as your best
hypothesis, not a fact — labels are wrong often enough that
the "After the Roast" section exists to correct them.
</p>
</details>
<div class="field-grid">
<label class="field" data-fid="0.1"
><span class="field-label">Coffee / lot</span
@@ -254,6 +376,16 @@
>
</div></label
>
<label class="field" id="lot-picker-field">
<span class="field-label">From inventory lot</span>
<select class="field-input" name="inventory.lotId" id="lot-select">
<option value="">— none —</option>
</select>
<span class="field-note" id="lot-picker-note"
>Optional — link a lot and the Roast Log can draw the
green weight down when you charge.</span
>
</label>
</div>
<div class="subhead">Cultivar</div>
@@ -267,14 +399,14 @@
placeholder="e.g. Caturra"
autocomplete="off"
/></label>
<div class="field" data-fid="1.3">
<label class="field" data-fid="1.3">
<span class="field-label">Origin</span
><input
class="field-input"
name="1.3"
placeholder="e.g. Huila, Colombia"
/>
</div>
</label>
<div class="field wide" data-fid="1.2">
<span class="field-label">Group</span>
<div class="segmented" data-radio-group="1.2">
@@ -303,13 +435,14 @@
</div>
<div class="field-grid">
<label class="field" data-fid="1.4"
><span class="field-label"
>FC anchor
<span class="hint" title="Feeds Time Ledger line 1"
></span
></span
><input class="field-input" name="1.4" placeholder="8:45"
/></label>
><span class="field-label">FC anchor</span
><input class="field-input" name="1.4" placeholder="8:45" />
<span class="field-note"
>Ledger line 1 — the whole plan hangs from this. Use
your cultivar's own row (autofills from the list),
never a group average.</span
></label
>
<label class="field" data-fid="1.5"
><span class="field-label">Profile mm:ss|mm:ss|mm:ss</span
><input
@@ -318,25 +451,23 @@
placeholder="4:15|3:15|1:30"
/></label>
<label class="field" data-fid="1.6"
><span class="field-label"
>± Refine
<span
class="hint"
title="0 on the first cook of this cultivar, otherwise carried from Step 12"
></span
></span
><input class="field-input" name="1.6" placeholder="0"
/></label>
><span class="field-label">± Refine</span
><input class="field-input" name="1.6" placeholder="0" />
<span class="field-note"
>0 the first time you roast this coffee. Afterwards,
carry "One change next batch" here — this is how the
plan learns.</span
></label
>
<label class="field" data-fid="1.7"
><span class="field-label"
>± Dev modifier
<span
class="hint"
title="Step 1's profile 3rd number, minus 1:30"
></span
></span
><input class="field-input" name="1.7"
/></label>
><span class="field-label">± Dev modifier</span
><input class="field-input" name="1.7" />
<span class="field-note"
>Your cultivar profile's third number minus 1:30 —
autofilled; 0 for most cultivars. Feeds ledger line
9.</span
></label
>
</div>
</div>
</section>
@@ -344,20 +475,41 @@
<section class="panel-card" id="sec-blend">
<div class="panel-head">
<h2>Blend</h2>
<div class="segmented small" data-radio-group="2.1">
<label class="seg-opt"
><input type="radio" name="2.1" value="single" checked /><span
>Single-origin</span
></label
>
<label class="seg-opt"
><input type="radio" name="2.1" value="blend" /><span
>Blend</span
></label
>
<div class="field" data-fid="2.1">
<div class="segmented small" data-radio-group="2.1">
<label class="seg-opt"
><input type="radio" name="2.1" value="single" checked /><span
>Single-origin</span
></label
>
<label class="seg-opt"
><input type="radio" name="2.1" value="blend" /><span
>Blend</span
></label
>
</div>
</div>
</div>
<div class="panel-body" id="blend-body">
<details class="why-panel" data-why="sec-blend" open>
<summary>Why blends combine times, never temperatures</summary>
<p>
The drum has one heat setting and one clock — you cannot
give 55% of the mass a different schedule than the other
45%. Times are additive, so a weighted average of two
first-crack anchors is another valid time on the same
clock. An average of two temperatures means nothing: first
crack is a property of the chemistry, not a dial you set.
</p>
<p>
If one component is 60% or more of the batch, roast the
whole batch as that coffee and let the rest ride along. If
no component dominates, weight each anchor by its share.
And if components disagree by more than about a minute at
first crack, split-roast and blend after — one compromise
profile serves neither half.
</p>
</details>
<div class="blend-cards" id="blend-cards"></div>
<div class="blend-total" id="blend-total">
<div class="blend-total-bar">
@@ -394,8 +546,12 @@
<div class="field-grid">
<label class="field" data-fid="2.4"
><span class="field-label">Resulting FC anchor</span
><input class="field-input" name="2.4"
/></label>
><input class="field-input" name="2.4" />
<span class="field-note"
>Replaces 1.4 as ledger line 1 while "Blend" is
selected.</span
></label
>
<label class="field" data-fid="2.5"
><span class="field-label">Resulting processing modifier</span
><input class="field-input" name="2.5"
@@ -407,6 +563,30 @@
<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>
<summary>Why processing moves development, not first crack</summary>
<p>
Processing changed the chemistry that browning starts
from. Naturals and dark honeys keep far more free
fructose, and fructose browns faster than sucrose — the
same profile lands visibly darker. That is why natural and
honey lots take 1530 seconds <em>less</em> development
and want a slightly earlier drop, and why processing never
touches the first-crack anchor. Change development and
drop, hold everything else, and the experiment stays
readable.
</p>
<p>
Roast level is the one input that is purely your decision
— and development is a bell curve, not a slider. Shorter
development pushes acidity up, longer pulls it down; but
too short reads sharp and astringent with no fruit behind
it, and too long goes flat and dull. The pleasant region is
in the middle and narrower than the acidity curve
suggests, which is why the fix is always a 15-second step
with a cupping in between, never a big swing.
</p>
</details>
<div class="field wide" data-fid="3.1">
<span class="field-label">Process</span>
<div class="chip-group" data-radio-group="3.1">
@@ -442,8 +622,13 @@
<div class="field-grid">
<label class="field" data-fid="3.2"
><span class="field-label">Dev modifier</span
><input class="field-input" name="3.2"
/></label>
><input class="field-input" name="3.2" />
<span class="field-note"
>Washed 0 · natural / dark honey 0:15 to 0:30 ·
anaerobic a further 0:10 to 0:15. Feeds ledger line
8.</span
></label
>
</div>
<div class="subhead">Roast level</div>
@@ -483,26 +668,49 @@
<input class="field-input" name="4.2" /><span class="unit"
>%</span
>
</div></label
</div>
<span class="field-note"
>Light 1113% · medium 1416% · dark 1718%. Your
after-roast scale check against this is the honest
measure of roast degree.</span
></label
>
<label class="field" data-fid="4.3"
><span class="field-label">Dev base</span
><input class="field-input" name="4.3"
/></label>
><input class="field-input" name="4.3" />
<span class="field-note"
>Ledger line 7. Light 1:152:00 · medium 2:003:00 ·
dark 3:004:00. Development past 4:30 mutes any
roast.</span
></label
>
</div>
</div>
</section>
<section class="panel-card" id="sec-bean">
<div class="panel-head"><h2>Bean Condition</h2></div>
<div class="unknown-condition" role="note">
<strong>Don't have lab measurements?</strong> Moisture and density
are optional. Leave them blank and record only evidence you have
(supplier COA, lot notes, or your next roast). They never change
the ledger automatically. Use Net correction only when you can
explain the observation behind it.
</div>
<div class="panel-body">
<details class="why-panel" data-why="sec-bean" open>
<summary>Why bean condition is worth seconds, not a new plan</summary>
<p>
Moisture, density and screen size change how much energy the
plan costs, not its shape. Wet greens (over 11.5%) stall as
they near 100&nbsp;°C while water boils off — expect the flat
spot and don't panic-add heat. Dense, high-grown seed
tolerates a hard early push; soft, low-grown seed gives you
defects instead of speed. Very large beans scorch outside
while the core lags — the safe way to go faster is more
airflow at a lower inlet temperature, never more burner.
</p>
<p>
None of these auto-adjust the ledger, and that is
deliberate: they inform the ±0:15 judgment in Net correction
and your heat/fan choices, nothing more. Don't have lab
numbers? Leave moisture and density blank — record only
evidence you actually have.
</p>
</details>
<div class="field-grid">
<label class="field" data-fid="5.1"
><span class="field-label">Moisture</span>
@@ -551,15 +759,14 @@
</div>
</div>
<label class="field" data-fid="5.6"
><span class="field-label"
>± Net correction
<span
class="hint"
title="Judgment call — rarely more than ±0:15"
></span
></span
><input class="field-input" name="5.6"
/></label>
><span class="field-label">± Net correction</span
><input class="field-input" name="5.6" />
<span class="field-note"
>Rarely more than ±0:15, and only for an observation
you can name — moisture and density never create this
number automatically. Feeds ledger line 3.</span
></label
>
</div>
</div>
</section>
@@ -567,6 +774,31 @@
<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>
<summary>
Why the rate of rise must fall, and never touch zero
</summary>
<p>
A bean that is still gaining heat, but gaining it more
slowly, is moving through its reactions in order. Two
shapes break that. The crash: RoR falls off a cliff just
after first crack, when the exotherm ends and an oversized
heat cut lands at the same moment. The bake: RoR flattens
toward zero, the bean sits at temperature without
progressing, and the cup goes flat, papery and
sweetness-free — nothing after the drop rescues a bake.
</p>
<p>
So the rule is: decline steadily from the
post-turning-point peak, through small frequent heat cuts
rather than a few big ones, and stay ahead of the
machine's roughly 60-second lag — what you change now
shows up a minute from now, so cut <em>before</em> the
exotherm, gently. If a phase would need a rate outside the
proven bands to hit its milestone, the plan is not
reachable — fix the ledger, not the roaster.
</p>
</details>
<div class="subhead">Temperatures &amp; rate-of-rise</div>
<div class="milestone-list">
<div
@@ -587,6 +819,7 @@
class="field-input sm"
name="temps.charge.tempC"
placeholder="°C"
aria-label="Charge planned temperature"
/><span class="unit">°C</span>
</div>
</div>
@@ -600,6 +833,7 @@
class="field-input sm milestone-time"
name="temps.tp.time"
placeholder="m:ss"
aria-label="Turning point planned time"
/>
<div class="band-track">
<div class="band-range"></div>
@@ -610,6 +844,7 @@
class="field-input sm"
name="temps.tp.tempC"
placeholder="°C"
aria-label="Turning point planned temperature"
/><span class="unit">°C</span>
</div>
</div>
@@ -629,6 +864,7 @@
class="field-input sm"
name="temps.yellow.tempC"
placeholder="°C"
aria-label="Yellow planned temperature"
/><span class="unit">°C</span>
</div>
</div>
@@ -648,6 +884,7 @@
class="field-input sm"
name="temps.fc.tempC"
placeholder="°C"
aria-label="First crack planned temperature"
/><span class="unit">°C</span>
</div>
</div>
@@ -667,12 +904,18 @@
class="field-input sm"
name="temps.drop.tempC"
placeholder="°C"
aria-label="Drop planned temperature"
/><span class="unit">°C</span>
</div>
</div>
</div>
<div class="subhead">Actuator schedule (planned)</div>
<p class="field-note">
The timing of your first heat cut is the main first-crack
lever — later cut, earlier crack. Say why you make each
change; that's what makes this log readable next week.
</p>
<div class="actuator-timeline" id="actuator-timeline"></div>
<button
type="button"
@@ -684,9 +927,34 @@
</div>
</section>
<section class="panel-card" id="sec-roastlog">
<div class="panel-head"><h2>Roast Log — Plan vs. Actual</h2></div>
<div class="phase-divider" role="separator" aria-label="Roast day begins">
<span class="phase-divider-label">Roast day</span>
<span class="phase-divider-text"
>Everything above is decided before you charge. Fill in from
here down at the machine, then after cupping. Print takes the
plan with you.</span
>
</div>
<section class="panel-card phase-later" id="sec-roastlog">
<div class="panel-head">
<h2>Roast Log — Plan vs. Actual</h2>
<span class="phase-chip">during the roast</span>
</div>
<div class="panel-body">
<details class="why-panel" data-why="sec-roastlog" open>
<summary>Record what you observed, never what you planned</summary>
<p>
A planned milestone written into the log as though it
happened destroys the only evidence you have. If you never
actually heard first crack, write that down — "not heard,
called at 8:10 by smell" is a useful record. "8:00"
because that was the plan is worse than nothing, because
next batch you will trust it. The plan column stays fixed
beside your entries precisely so the gap is visible. The
gap is the data.
</p>
</details>
<div class="milestone-list">
<div class="milestone-row log-row">
<div class="milestone-label">Charge</div>
@@ -697,16 +965,19 @@
class="field-input sm"
name="planActual.charge.actualTime"
placeholder="0:00"
aria-label="Charge actual time"
/>
<input
class="field-input sm"
name="planActual.charge.actualBt"
placeholder="°C"
aria-label="Charge actual temperature"
/>
<input
class="field-input note"
name="planActual.charge.note"
placeholder="Note"
aria-label="Charge note"
/>
</div>
<div class="milestone-row log-row">
@@ -716,16 +987,19 @@
class="field-input sm"
name="planActual.tp.actualTime"
placeholder="0:00"
aria-label="Turning point actual time"
/>
<input
class="field-input sm"
name="planActual.tp.actualBt"
placeholder="°C"
aria-label="Turning point actual temperature"
/>
<input
class="field-input note"
name="planActual.tp.note"
placeholder="Note"
aria-label="Turning point note"
/>
</div>
<div class="milestone-row log-row">
@@ -735,16 +1009,19 @@
class="field-input sm"
name="planActual.yellow.actualTime"
placeholder="0:00"
aria-label="Yellow actual time"
/>
<input
class="field-input sm"
name="planActual.yellow.actualBt"
placeholder="°C"
aria-label="Yellow actual temperature"
/>
<input
class="field-input note"
name="planActual.yellow.note"
placeholder="Note"
aria-label="Yellow note"
/>
</div>
<div class="milestone-row log-row">
@@ -754,16 +1031,19 @@
class="field-input sm"
name="planActual.fc.actualTime"
placeholder="0:00"
aria-label="First crack actual time"
/>
<input
class="field-input sm"
name="planActual.fc.actualBt"
placeholder="°C"
aria-label="First crack actual temperature"
/>
<input
class="field-input note"
name="planActual.fc.note"
placeholder="Note"
aria-label="First crack note"
/>
</div>
<div class="milestone-row log-row">
@@ -773,25 +1053,61 @@
class="field-input sm"
name="planActual.drop.actualTime"
placeholder="0:00"
aria-label="Drop actual time"
/>
<input
class="field-input sm"
name="planActual.drop.actualBt"
placeholder="°C"
aria-label="Drop actual temperature"
/>
<input
class="field-input note"
name="planActual.drop.note"
placeholder="Note"
aria-label="Drop note"
/>
</div>
</div>
<div class="inventory-consume hidden" id="inventory-consume">
<span class="inventory-consume-label" id="inventory-consume-label"></span>
<button type="button" class="ghost-btn small" id="btn-consume">
Draw from lot
</button>
</div>
</div>
</section>
<section class="panel-card" id="sec-after">
<div class="panel-head"><h2>After the Roast</h2></div>
<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>
@@ -867,6 +1183,12 @@
><input class="field-input" name="afterRoast.disproof"
/></label>
</div>
<div class="cupping-link">
<button type="button" class="ghost-btn" id="btn-open-cupping">
Open cupping session
</button>
<span class="field-note" id="cupping-link-note"></span>
</div>
</div>
</section>
</div>
@@ -874,6 +1196,10 @@
<aside class="dock" aria-label="Live plan feedback">
<div class="dock-card ledger-card" id="plan-now">
<div class="dock-card-head"><h2>Time Ledger</h2></div>
<p class="dock-note">
Drying + Maillard + Development = the whole roast. Cultivar
sets first crack; process and level set development.
</p>
<div class="ledger-group">
<div class="ledger-row">
@@ -894,7 +1220,13 @@
<div class="ledger-row">
<span class="l-fid">6.4</span
><span class="l-label">± Batch size</span
><input class="ledger-input" name="6.4" placeholder="0" />
><input
class="ledger-input"
name="6.4"
placeholder="0"
aria-label="Batch-size correction"
title="225 g lands first crack ~1:302:00 later than 200 g"
/>
</div>
</div>
<div class="ledger-total hero">
@@ -952,21 +1284,37 @@
<span class="check-name">Drying share</span>
<output class="check-value" data-out="check-drying"></output>
<span class="check-band">target 4351%</span>
<span class="check-why"
>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 3339%</span>
<span class="check-why"
>low = too small a browning window for sweetness and
complexity</span
>
</div>
<div class="check-tile unknown" data-pass="dtr">
<span class="check-name">Development ratio</span>
<output class="check-value" data-out="check-dtr"></output>
<span class="check-band">target 1220%</span>
<span class="check-why"
>under 12% = under-developed — sharp, astringent acidity
with no fruit behind it</span
>
</div>
<div class="check-tile unknown" data-pass="ceiling">
<span class="check-name">Development ceiling</span>
<output class="check-value" data-out="check-ceiling"></output>
<span class="check-band">under 4:30</span>
<span class="check-why"
>over 4:30 mutes any roast — nothing good is past this
line</span
>
</div>
</div>
</div>
@@ -1116,8 +1464,12 @@
</aside>
</div>
</form>
</div>
<!-- /app-workspace -->
</div>
<!-- /app-shell -->
<!-- Kept OUTSIDE #plan-form on purpose: identically-named radios here must not fight for
<!-- Kept OUTSIDE #plan-form and OUTSIDE .app-shell on purpose: identically-named radios here must not fight for
mutual exclusivity with the screen form's radios (same name + same form owner would
silently uncheck one another). This whole block is display:none except when printing;
main.js re-syncs it from state.plan on beforeprint. -->