Initial roast planner webapp: fillable worksheet, live ledger, curve, URL prefill, .alog reference curve

- shared/ ports the worksheet's ledger math, time parsing, and reference tables
  (cultivars/processes/roast-levels/machine bands) as browser-safe ESM, imported
  by both the server and the browser so the arithmetic can't drift between them.
- server/prefill.js runs a zero-tool Pi Coding Agent SDK turn to extract page facts
  from a bean product URL, then derives worksheet field IDs deterministically from
  reference-data.js — the model never invents a first-crack anchor or a modifier.
- server/alog.js ports the Python alog_parser.py's format handling, including the
  tokenizer-based Python-dict-literal-to-JSON conversion the real files need.
- public/ is the worksheet reproduced as a live HTML form: worksheet.css is a
  verbatim copy of the paper worksheet's print CSS, print.js bakes values into
  the print layout so the same DOM renders both on screen and on paper.
- Ledger math verified against both of the paper worksheet's worked examples;
  alog parser verified against all 14 real logs in ref/roasts/.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
2026-07-29 15:41:35 -04:00
co-authored by Claude Sonnet 5
commit fedaf29847
22 changed files with 5000 additions and 0 deletions
+85
View File
@@ -0,0 +1,85 @@
/* Screen chrome + form-control styling layered on top of worksheet.css. Everything here is
either screen-only (hidden in print) or makes an <input>/<select>/<radio> look exactly like
the paper's blank/circle it replaces. */
body { background: #ddd4c8; }
.app-toolbar {
position: sticky; top: 0; z-index: 10;
display: flex; align-items: center; gap: 3mm; flex-wrap: wrap;
background: #1a1512; color: #fff; padding: 3mm 4mm;
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; font-size: 9pt;
}
.app-toolbar h1 { font-size: 12pt; margin: 0; color: #fff; flex: 0 0 auto; }
.app-toolbar .spacer { flex: 1 1 auto; }
.app-toolbar button, .app-toolbar label.filebtn {
background: #7a3b12; color: #fff; border: none; border-radius: 1mm;
padding: 1.6mm 3mm; font-size: 8.6pt; cursor: pointer; font-family: inherit;
}
.app-toolbar button:hover, .app-toolbar label.filebtn:hover { background: #94481a; }
.app-toolbar button:disabled { opacity: .5; cursor: default; }
.app-toolbar input[type="file"] { display: none; }
.app-toolbar .status { font-size: 7.6pt; color: #cbbfae; }
.panel {
max-width: 186mm; margin: 4mm auto 0; padding: 3mm 4mm;
background: #fff8ee; border: 1px solid #d8cdb9; border-radius: 1mm;
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; font-size: 8.6pt;
}
.panel.hidden { display: none; }
.panel h3 { margin: 0 0 2mm; font-size: 9.5pt; color: #7a3b12; }
.panel .row { display: flex; gap: 2mm; align-items: center; margin-bottom: 2mm; flex-wrap: wrap; }
.panel input[type="url"], .panel input[type="text"] {
flex: 1 1 260px; padding: 1.6mm 2mm; border: 1px solid #c9bfb5; border-radius: 1mm; font-size: 8.6pt;
}
.panel .warnings { color: #8a5c10; font-size: 7.8pt; }
.panel .warnings li { margin-bottom: .6mm; }
.panel .lib-list { list-style: none; padding: 0; margin: 0; max-height: 40mm; overflow-y: auto; }
.panel .lib-list li { padding: 1mm 0; border-bottom: 1px solid #eee; cursor: pointer; font-size: 8.2pt; }
.panel .lib-list li:hover { background: #f6efe7; }
.sheet-wrap { max-width: 186mm; margin: 6mm auto; box-shadow: 0 2px 12px rgba(0,0,0,.25); background: #fff; }
/* --- form controls that must look like the paper's .f blank / .circ options --- */
input.f, select.f {
border: none; border-bottom: .9pt solid var(--ink); background: transparent;
font: inherit; height: 4.2mm; padding: 0 .5mm; color: var(--ink);
}
input.f:focus, select.f:focus { outline: 1px dashed var(--accent); outline-offset: 1px; }
select.f { -webkit-appearance: none; appearance: none; }
textarea.f {
border: none; border-bottom: .9pt solid var(--ink); background: transparent;
font: inherit; resize: none; padding: 0 .5mm; color: var(--ink); vertical-align: bottom;
}
.opt { display: inline-block; cursor: pointer; margin-right: 1.5mm; }
.opt input { position: absolute; opacity: 0; width: 0; height: 0; }
.opt span { padding: 0 .8mm; border-radius: 3mm; }
.opt input:checked + span {
background: var(--accent-bg); border: .9pt solid var(--accent); color: var(--accent); font-weight: 700;
}
.ledger td.val { position: relative; }
.ledger td.val output { display: block; }
.ledger tr.row.check-fail td.val { background: #fbe7e2 !important; }
.ledger tr.row.check-pass td.val { background: #e6f3e6 !important; }
td.passcell.pass::after { content: "✓"; color: #2a7a2a; font-weight: 700; }
td.passcell.fail::after { content: "✗"; color: #a8371a; font-weight: 700; }
td.passcell.unknown::after { content: "—"; color: var(--faint); }
input.prefilled, select.prefilled { background: #fff3c4; }
.pv { display: none; }
/* --- print: bake values in, hide screen-only chrome --- */
@media print {
.app-toolbar, .panel, .sheet-wrap { box-shadow: none; margin: 0; max-width: none; }
.app-toolbar, .panel { display: none !important; }
body { background: #fff; }
.ws-input { display: none !important; }
.pv { display: inline-block; min-width: inherit; border-bottom: .9pt solid var(--ink); }
.opt input:checked + span { border: .9pt solid var(--ink); background: none; color: var(--ink); border-radius: 50%; padding: 0 1.4mm; }
td.passcell.pass::after, td.passcell.fail::after { content: none; }
td.passcell.pass::before { content: "✓ "; }
td.passcell.fail::before { content: "✗ "; }
}
+359
View File
@@ -0,0 +1,359 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Roast Planner</title>
<link rel="stylesheet" href="/worksheet.css">
<link rel="stylesheet" href="/app.css">
</head>
<body>
<div class="app-toolbar">
<h1>Roast Planner</h1>
<button id="btn-toggle-prefill" type="button">Prefill from URL</button>
<button id="btn-toggle-alog" type="button">Reference curve (.alog)</button>
<span class="spacer"></span>
<button id="btn-load" type="button">Load plan</button>
<input id="file-load" type="file" accept="application/json">
<button id="btn-save" type="button">Download plan</button>
<button id="btn-print" type="button">Print</button>
<span class="status" id="autosave-status"></span>
</div>
<div class="panel hidden" id="panel-prefill">
<h3>Prefill from a bean product URL</h3>
<div class="row">
<input type="url" id="prefill-url" placeholder="https://roaster.example/shop/some-coffee">
<label><input type="checkbox" id="prefill-overwrite"> overwrite filled fields</label>
<button id="prefill-go" type="button">Fetch &amp; prefill</button>
<button id="prefill-undo" type="button" disabled>Undo prefill</button>
</div>
<div id="prefill-result"></div>
</div>
<div class="panel hidden" id="panel-alog">
<h3>Attach an Artisan .alog as a reference curve</h3>
<div class="row">
<label class="filebtn">Choose .alog file<input type="file" id="alog-file" accept=".alog"></label>
<button id="alog-library-refresh" type="button">Show local library</button>
</div>
<ul class="lib-list hidden" id="alog-library-list"></ul>
<div id="alog-result"></div>
</div>
<div class="sheet-wrap">
<div class="sheet">
<form id="plan-form">
<!-- ══════════════════ FRONT ══════════════════ -->
<section class="first">
<div class="wsbar">
<span class="t">Manual Roast Plan — Worksheet</span>
<span class="r">Sheet 1 of 2 · Plan | Hottop KN-8828B-2K+</span>
</div>
<div class="wsbox">
<div class="hd"><span class="id">THE COFFEE</span><span class="ref">[n.n] = field ID · cited everywhere it feeds a number</span></div>
<div class="bd wsline tightln">
<span class="fid">0.1</span><span class="lb">Coffee / lot</span> <input class="f ws-input" style="min-width:44mm" name="0.1"><span class="pv"></span>
<span class="sep">|</span><span class="fid">0.2</span><span class="lb">Date</span> <input class="f n ws-input" type="date" name="0.2"><span class="pv"></span>
<span class="sep">|</span><span class="fid">0.3</span><span class="lb">Batch&nbsp;#</span> <input class="f ws-input" style="min-width:10mm" name="0.3"><span class="pv"></span>
<span class="sep">|</span><span class="fid">0.4</span><span class="lb">Green in</span> <input class="f n ws-input" name="0.4">g<span class="pv"></span><br>
<span class="stepdot">1</span><span class="fid">1.1</span><span class="lb">Cultivar</span> <input class="f ws-input" style="min-width:30mm" name="1.1" list="cultivar-list"><span class="pv"></span>
<span class="sep">|</span><span class="fid">1.2</span><span class="lb">Group</span>
<span class="circ" data-radio-group="1.2">
<label class="opt"><input type="radio" name="1.2" value="typica"><span>Typica</span></label>
<label class="opt"><input type="radio" name="1.2" value="bourbon"><span>Bourbon</span></label>
<label class="opt"><input type="radio" name="1.2" value="ethiopian"><span>Ethiopian</span></label>
<label class="opt"><input type="radio" name="1.2" value="hybrid"><span>Hybrid</span></label>
</span>
<span class="sep">|</span><span class="fid">1.3</span><span class="lb">Origin</span> <input class="f ws-input" style="min-width:20mm" name="1.3"><span class="pv"></span><br>
<span class="fid" style="margin-left:4.6mm">1.4</span><span class="lb">FC anchor</span> <input class="f n ws-input" name="1.4"><span class="pv"></span>
<span class="sep">|</span><span class="fid">1.5</span><span class="lb">Profile mm:ss|mm:ss|mm:ss</span> <input class="f ws-input" style="min-width:22mm" name="1.5"><span class="pv"></span>
<span class="sep">|</span><small>your cultivar's row, Step 1 — not a group number</small><br>
<span class="fid" style="margin-left:4.6mm">1.6</span><span class="lb">± Refine → ledger 2</span> <input class="f n ws-input" name="1.6"><span class="pv"></span> <small>0 first cook, else Step 12</small>
<span class="sep">|</span><span class="fid">1.7</span><span class="lb">± Dev mod → ledger 9</span> <input class="f n ws-input" name="1.7"><span class="pv"></span> <small>Step 1's profile 3rd # 1:30</small><br>
<span class="stepdot">3</span><span class="fid">3.1</span><span class="lb">Process</span>
<span class="circ" data-radio-group="3.1">
<label class="opt"><input type="radio" name="3.1" value="washed"><span>washed</span></label>
<label class="opt"><input type="radio" name="3.1" value="yellow-honey"><span>yellow/white honey</span></label>
<label class="opt"><input type="radio" name="3.1" value="red-black-honey"><span>red/black honey</span></label>
<label class="opt"><input type="radio" name="3.1" value="natural"><span>natural</span></label>
<label class="opt"><input type="radio" name="3.1" value="anaerobic"><span>anaerobic/CM</span></label>
</span>
<span class="sep">|</span><span class="fid">3.2</span><span class="lb">Dev mod → ledger 8</span> <input class="f n ws-input" name="3.2"><span class="pv"></span> <small>Step 3 table</small><br>
<span class="stepdot">4</span><span class="fid">4.1</span><span class="lb">Roast level</span>
<span class="circ" data-radio-group="4.1">
<label class="opt"><input type="radio" name="4.1" value="light"><span>light</span></label>
<label class="opt"><input type="radio" name="4.1" value="light-medium"><span>light-med</span></label>
<label class="opt"><input type="radio" name="4.1" value="medium"><span>medium</span></label>
<label class="opt"><input type="radio" name="4.1" value="dark"><span>dark</span></label>
<label class="opt"><input type="radio" name="4.1" value="very-dark"><span>very dark</span></label>
</span>
<span class="sep">|</span><span class="fid">4.2</span><span class="lb">Target WL</span> <input class="f n ws-input" name="4.2">%<span class="pv"></span>
<span class="sep">|</span><span class="fid">4.3</span><span class="lb">Dev base → ledger 7</span> <input class="f n ws-input" style="min-width:12mm" name="4.3"><span class="pv"></span><br>
<span class="stepdot">5</span><span class="fid">5.1</span><span class="lb">Moisture</span> <input class="f n ws-input" name="5.1">%<span class="pv"></span>
<span class="sep">|</span><span class="fid">5.2</span><span class="lb">Density</span> <input class="f n ws-input" name="5.2">g/L<span class="pv"></span>
<span class="sep">|</span><span class="fid">5.3</span><span class="lb">Altitude</span> <input class="f n ws-input" name="5.3"><span class="pv"></span>
<span class="sep">|</span><span class="fid">5.4</span><span class="lb">Screen</span> <input class="f n ws-input" name="5.4"><span class="pv"></span>
<span class="sep">|</span><span class="fid">5.5</span><span class="lb">Sweet</span>
<span class="circ" data-radio-group="5.5">
<label class="opt"><input type="radio" name="5.5" value="narrow"><span>narrow</span></label>
<label class="opt"><input type="radio" name="5.5" value="med"><span>med</span></label>
<label class="opt"><input type="radio" name="5.5" value="wide"><span>wide</span></label>
</span><br>
<span class="fid" style="margin-left:4.6mm">5.6</span><span class="lb">± Net correction → ledger 3</span> <input class="f n ws-input" name="5.6"><span class="pv"></span> <small>judgment, rarely &gt;±0:15</small>
</div>
</div>
<div class="wsbox">
<div class="hd"><span class="n">2 · BLEND</span><span class="ref">skip if single-origin → Step 2</span></div>
<div class="bd">
<div class="wsline tightln">
<span class="fid">2.1</span>This lot is:
<span class="circ" data-radio-group="2.1">
<label class="opt"><input type="radio" name="2.1" value="single" checked><span>single-origin — skip this box</span></label>
<label class="opt"><input type="radio" name="2.1" value="blend"><span>blend or community lot</span></label>
</span>
</div>
<table class="tight">
<thead><tr><th><span class="fid">2.2</span>Component</th><th style="width:22mm">Group</th><th style="width:24mm">Process</th>
<th class="num" style="width:14mm">Share %</th><th class="num" style="width:20mm">FC anchor</th></tr></thead>
<tbody id="blend-rows"></tbody>
</table>
<div class="wsline tightln">
<span class="fid">2.3</span>Method:
<span class="circ" data-radio-group="2.3">
<label class="opt"><input type="radio" name="2.3" value="dominant"><span>dominant (≥60%)</span></label>
<label class="opt"><input type="radio" name="2.3" value="weighted"><span>weighted average</span></label>
<label class="opt"><input type="radio" name="2.3" value="split"><span>split-roast, blend after</span></label>
</span>
</div>
<div class="wsline tightln">
<span class="fid">2.4</span><span class="lb">Resulting FC anchor → ledger line 1</span> <input class="f n ws-input" name="2.4"><span class="pv"></span>
<span class="sep">|</span><span class="fid">2.5</span><span class="lb">Resulting processing modifier → ledger line 8</span> <input class="f n ws-input" name="2.5"><span class="pv"></span>
</div>
</div>
</div>
<div class="wsbox">
<div class="hd"><span class="n">6 · TIME LEDGER</span><span class="ref">method &amp; why → Step 6</span></div>
<div class="bd">
<table class="ledger">
<tr class="row"><td class="ln">1</td><td class="desc">Cultivar/blend anchor
<span class="sub">from 1.4, or 2.4 if blend</span></td><td class="val"><output data-out="l1"></output></td></tr>
<tr class="row"><td class="ln">2</td><td class="desc"><span class="op">±</span> Refinement — from 1.6
<span class="sub">0 first cook, else Step 12</span></td><td class="val"><output data-out="l2"></output></td></tr>
<tr class="row"><td class="ln">3</td><td class="desc"><span class="op">±</span> Bean condition — from 5.6</td><td class="val"><output data-out="l3"></output></td></tr>
<tr class="row"><td class="ln">4</td><td class="desc"><span class="op">±</span> Batch size vs. Step 9 reference weights
<span class="sub">225 g ≈ 1:302:00 later than 200 g</span></td><td class="val"><input class="f n ws-input" name="6.4" placeholder="0"><span class="pv"></span></td></tr>
<tr class="total"><td class="ln">A</td><td class="desc"><strong>FIRST CRACK</strong></td><td class="val"><output data-out="A"></output></td></tr>
<tr class="grp"><td class="ln"></td><td class="desc" colspan="2">B · YELLOW / MAILLARD — informational only. NOT summed into C.</td></tr>
<tr class="row"><td class="ln">5</td><td class="desc"><strong>YELLOW</strong> = A × 0.56</td><td class="val"><output data-out="yellow"></output></td></tr>
<tr class="row"><td class="ln">6</td><td class="desc"><strong>MAILLARD</strong> = A line 5</td><td class="val"><output data-out="maillard"></output></td></tr>
<tr class="grp"><td class="ln"></td><td class="desc" colspan="2">C · DEVELOPMENT — sum ONLY rows 7 + 8 + 9</td></tr>
<tr class="row"><td class="ln">7</td><td class="desc">Development base — from 4.3</td><td class="val"><output data-out="l7"></output></td></tr>
<tr class="row"><td class="ln">8</td><td class="desc"><span class="op">±</span> Processing modifier — from 3.2, or 2.5 if blend</td><td class="val"><output data-out="l8"></output></td></tr>
<tr class="row"><td class="ln">9</td><td class="desc"><span class="op">±</span> Cultivar dev modifier — from 1.7</td><td class="val"><output data-out="l9"></output></td></tr>
<tr class="total"><td class="ln">C</td><td class="desc"><strong>DEVELOPMENT</strong> = 7 + 8 + 9 only</td><td class="val"><output data-out="C"></output></td></tr>
<tr class="total"><td class="ln">D</td><td class="desc"><strong>DROP (end of roast)</strong> = A + C</td><td class="val"><output data-out="D"></output></td></tr>
</table>
</div>
</div>
<div class="wsbox">
<div class="hd"><span class="n">7 · SANITY CHECKS</span><span class="ref">a fail sends you back to box 6 → Step 7</span></div>
<div class="bd">
<table class="tight">
<thead><tr><th style="width:44mm">Check</th><th class="num" style="width:24mm">Should be</th>
<th class="num" style="width:24mm">Yours</th><th class="num" style="width:16mm">Pass?</th><th>Failure means</th></tr></thead>
<tbody>
<tr><td>Drying share</td><td class="num">4351 %</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></tr>
<tr><td>Maillard share</td><td class="num">3339 %</td><td class="num"><output data-out="check-maillard"></output></td>
<td class="num passcell unknown" data-pass="maillard"></td><td>low = too little browning</td></tr>
<tr><td>Development ratio</td><td class="num">1220 %</td><td class="num"><output data-out="check-dtr"></output></td>
<td class="num passcell unknown" data-pass="dtr"></td><td>under 12 % = under-developed</td></tr>
<tr><td>Development ceiling</td><td class="num">&lt; 4:30</td><td class="num"><output data-out="check-ceiling"></output></td>
<td class="num passcell unknown" data-pass="ceiling"></td><td>over 4:30 mutes any roast</td></tr>
</tbody>
</table>
</div>
</div>
</section>
<!-- ══════════════════ BACK ══════════════════ -->
<section>
<div class="wsbar">
<span class="t">Roast &amp; Record</span>
<span class="r">Sheet 2 of 2 | coffee: <span id="back-coffee-name"></span></span>
</div>
<div class="wsbox">
<div class="hd"><span class="n">8 · TEMPERATURES &amp; RATE-OF-RISE</span><span class="ref">→ Step 8</span></div>
<div class="bd">
<table class="tight">
<thead><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:36mm">Your logged band</th></tr></thead>
<tbody>
<tr><td>Charge</td><td class="num"><output data-out="t-charge">0:00</output></td>
<td class="num"><input class="f n ws-input" name="temps.charge.tempC"><span class="pv"></span></td><td class="num">133195 °C</td></tr>
<tr><td>Turning point</td><td class="num"><input class="f n ws-input" name="temps.tp.time" placeholder="m:ss"><span class="pv"></span></td>
<td class="num"><input class="f n ws-input" name="temps.tp.tempC"><span class="pv"></span></td><td class="num">77100 °C @ 0:471:15</td></tr>
<tr><td>Yellow</td><td class="num"><output data-out="t-yellow"></output></td>
<td class="num"><input class="f n ws-input" name="temps.yellow.tempC"><span class="pv"></span></td><td class="num">148166 °C</td></tr>
<tr><td>First crack</td><td class="num"><output data-out="t-fc"></output></td>
<td class="num"><input class="f n ws-input" name="temps.fc.tempC"><span class="pv"></span></td><td class="num">176187 °C</td></tr>
<tr><td>Drop</td><td class="num"><output data-out="t-drop"></output></td>
<td class="num"><input class="f n ws-input" name="temps.drop.tempC"><span class="pv"></span></td><td class="num">183197 °C light</td></tr>
</tbody>
</table>
</div>
</div>
<div class="wsbox">
<div class="hd"><span class="n">9 · ACTUATOR SCHEDULE (planned)</span><span class="ref">→ Step 9</span></div>
<div class="bd">
<table class="tight">
<thead><tr><th class="num" style="width:20mm">Time</th><th class="num" style="width:18mm">Heat %</th>
<th class="num" style="width:18mm">Fan %</th><th class="num" style="width:26mm">Expected BT</th><th>Why this change</th></tr></thead>
<tbody id="actuator-rows"></tbody>
</table>
</div>
</div>
<div class="wsbox wsgrid">
<div class="hd"><span class="n">10 · THE CURVE</span><span class="ref">plan drawn live · reference from an .alog if attached → Step 10</span></div>
<div class="bd">
<div class="grid-wrap">
<svg id="curve-svg" viewBox="0 0 714 334" width="100%" role="img" aria-label="Roast curve plotting grid">
<defs><style>
.maj{stroke:#8b8078;stroke-width:.7} .min{stroke:#e2dad2;stroke-width:.45}
.ax{stroke:#1a1512;stroke-width:1.3} .al{font-size:8px;fill:#5a5048}
.alr{font-size:8px;fill:#7a3b12} .band{fill:#7a3b12;opacity:.05}
.ttl{font-size:8px;font-weight:700;fill:#1a1512;letter-spacing:.5px}
</style></defs>
<rect x="52" y="24" width="600" height="270" fill="#fff"/>
<rect class="band" x="52" y="65.4" width="600" height="19.8"/>
<text x="58" y="62.4" class="alr" style="font-size:7px">your first-crack band, 176187 °C</text>
<g class="min">
<line x1="72" y1="24" x2="72" y2="294"/><line x1="112" y1="24" x2="112" y2="294"/>
<line x1="152" y1="24" x2="152" y2="294"/><line x1="192" y1="24" x2="192" y2="294"/>
<line x1="232" y1="24" x2="232" y2="294"/><line x1="272" y1="24" x2="272" y2="294"/>
<line x1="312" y1="24" x2="312" y2="294"/><line x1="352" y1="24" x2="352" y2="294"/>
<line x1="392" y1="24" x2="392" y2="294"/><line x1="432" y1="24" x2="432" y2="294"/>
<line x1="472" y1="24" x2="472" y2="294"/><line x1="512" y1="24" x2="512" y2="294"/>
<line x1="552" y1="24" x2="552" y2="294"/><line x1="592" y1="24" x2="592" y2="294"/>
<line x1="632" y1="24" x2="632" y2="294"/>
</g>
<g class="maj">
<line x1="92" y1="24" x2="92" y2="294"/><line x1="132" y1="24" x2="132" y2="294"/>
<line x1="172" y1="24" x2="172" y2="294"/><line x1="212" y1="24" x2="212" y2="294"/>
<line x1="252" y1="24" x2="252" y2="294"/><line x1="292" y1="24" x2="292" y2="294"/>
<line x1="332" y1="24" x2="332" y2="294"/><line x1="372" y1="24" x2="372" y2="294"/>
<line x1="412" y1="24" x2="412" y2="294"/><line x1="452" y1="24" x2="452" y2="294"/>
<line x1="492" y1="24" x2="492" y2="294"/><line x1="532" y1="24" x2="532" y2="294"/>
<line x1="572" y1="24" x2="572" y2="294"/><line x1="612" y1="24" x2="612" y2="294"/>
</g>
<g class="min">
<line x1="52" y1="276" x2="652" y2="276"/><line x1="52" y1="240" x2="652" y2="240"/>
<line x1="52" y1="204" x2="652" y2="204"/><line x1="52" y1="168" x2="652" y2="168"/>
<line x1="52" y1="132" x2="652" y2="132"/><line x1="52" y1="96" x2="652" y2="96"/>
<line x1="52" y1="60" x2="652" y2="60"/>
</g>
<g class="maj">
<line x1="52" y1="258" x2="652" y2="258"/><line x1="52" y1="222" x2="652" y2="222"/>
<line x1="52" y1="186" x2="652" y2="186"/><line x1="52" y1="150" x2="652" y2="150"/>
<line x1="52" y1="114" x2="652" y2="114"/><line x1="52" y1="78" x2="652" y2="78"/>
<line x1="52" y1="42" x2="652" y2="42"/>
</g>
<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>
<text x="14" y="159" class="ttl" transform="rotate(-90 14 159)" text-anchor="middle">BEAN TEMP °C</text>
<g class="al" text-anchor="middle">
<text x="52" y="304">0:00</text><text x="92" y="304">1:00</text><text x="132" y="304">2:00</text>
<text x="172" y="304">3:00</text><text x="212" y="304">4:00</text><text x="252" y="304">5:00</text>
<text x="292" y="304">6:00</text><text x="332" y="304">7:00</text><text x="372" y="304">8:00</text>
<text x="412" y="304">9:00</text><text x="452" y="304">10:00</text><text x="492" y="304">11:00</text>
<text x="532" y="304">12:00</text><text x="572" y="304">13:00</text><text x="612" y="304">14:00</text>
<text x="652" y="304">15:00</text>
</g>
<text x="368" y="320" style="font-size:8px;fill:#5a5048">TIME FROM CHARGE · solid = plan · dashed = reference (.alog)</text>
<g id="plan-curve"></g>
<g id="ref-curve"></g>
</svg>
</div>
</div>
</div>
<div class="wsbox">
<div class="hd"><span class="n">11 · AT THE MACHINE — PLAN vs ACTUAL</span><span class="ref">→ Step 11</span></div>
<div class="bd">
<table class="tight">
<thead><tr><th style="width:28mm">Milestone</th><th class="num" style="width:20mm">Plan time</th>
<th class="num" style="width:22mm">Actual time</th><th class="num" style="width:18mm">Actual BT</th><th>Note</th></tr></thead>
<tbody>
<tr><td>Charge</td><td class="num"><output data-out="pa-charge">0:00</output></td>
<td class="num"><input class="f n ws-input" name="planActual.charge.actualTime" placeholder="0:00"><span class="pv"></span></td>
<td class="num"><input class="f n ws-input" name="planActual.charge.actualBt"><span class="pv"></span></td>
<td><input class="f ws-input" style="min-width:52mm" name="planActual.charge.note"><span class="pv"></span></td></tr>
<tr><td>Turning point</td><td class="num"><output data-out="pa-tp"></output></td>
<td class="num"><input class="f n ws-input" name="planActual.tp.actualTime"><span class="pv"></span></td>
<td class="num"><input class="f n ws-input" name="planActual.tp.actualBt"><span class="pv"></span></td>
<td><input class="f ws-input" style="min-width:52mm" name="planActual.tp.note"><span class="pv"></span></td></tr>
<tr><td>Yellow</td><td class="num"><output data-out="pa-yellow"></output></td>
<td class="num"><input class="f n ws-input" name="planActual.yellow.actualTime"><span class="pv"></span></td>
<td class="num"><input class="f n ws-input" name="planActual.yellow.actualBt"><span class="pv"></span></td>
<td><input class="f ws-input" style="min-width:52mm" name="planActual.yellow.note"><span class="pv"></span></td></tr>
<tr><td>First crack</td><td class="num"><output data-out="pa-fc"></output></td>
<td class="num"><input class="f n ws-input" name="planActual.fc.actualTime"><span class="pv"></span></td>
<td class="num"><input class="f n ws-input" name="planActual.fc.actualBt"><span class="pv"></span></td>
<td><input class="f ws-input" style="min-width:52mm" name="planActual.fc.note"><span class="pv"></span></td></tr>
<tr><td>Drop</td><td class="num"><output data-out="pa-drop"></output></td>
<td class="num"><input class="f n ws-input" name="planActual.drop.actualTime"><span class="pv"></span></td>
<td class="num"><input class="f n ws-input" name="planActual.drop.actualBt"><span class="pv"></span></td>
<td><input class="f ws-input" style="min-width:52mm" name="planActual.drop.note"><span class="pv"></span></td></tr>
</tbody>
</table>
</div>
</div>
<div class="wsbox">
<div class="hd"><span class="n">12 · AFTER THE ROAST</span><span class="ref">→ Step 12</span></div>
<div class="bd wsline tightln">
<span class="lb">Green in</span> <input class="f n ws-input" name="afterRoast.greenIn"><span class="pv"></span>g
<span class="sep">|</span><span class="lb">Out</span> <input class="f n ws-input" name="afterRoast.out"><span class="pv"></span>g
<span class="sep">|</span><span class="lb">Weight loss</span> <input class="f n ws-input" name="afterRoast.weightLossPct"><span class="pv"></span>%
<span class="sep">|</span><span class="lb">Actual DTR</span> <input class="f n ws-input" name="afterRoast.actualDtrPct"><span class="pv"></span>%<br>
<span class="lb">Colour WB/GR</span> <input class="f ws-input" name="afterRoast.colour"><span class="pv"></span>
<span class="sep">|</span><span class="lb">Rested</span> <input class="f n ws-input" name="afterRoast.restedDays"><span class="pv"></span>days
<span class="sep">|</span><span class="lb">Brew ratio</span> <input class="f ws-input" name="afterRoast.brewRatio"><span class="pv"></span>
<span class="sep">|</span><span class="lb">Method</span> <input class="f w ws-input" name="afterRoast.method"><span class="pv"></span><br>
<span class="lb">Cup notes</span> <input class="f xw ws-input" style="min-width:152mm" name="afterRoast.cupNotes"><span class="pv"></span><br>
<span class="lb" style="color:var(--accent)">One change next batch</span> <input class="f ws-input" style="min-width:58mm" name="afterRoast.oneChange"><span class="pv"></span>
<span class="sep">|</span><span class="lb">What would prove it wrong</span> <input class="f ws-input" style="min-width:34mm" name="afterRoast.disproof"><span class="pv"></span>
</div>
</div>
</section>
</form>
</div>
</div>
<datalist id="cultivar-list"></datalist>
<script type="module" src="/js/main.js"></script>
</body>
</html>
+79
View File
@@ -0,0 +1,79 @@
// Wires the ".alog reference curve" panel: local file upload, and browsing a server-side
// library directory (e.g. wherever the roastetta skill already downloaded logs).
export function initAlogPanel({ state, recompute }) {
const fileInput = document.getElementById("alog-file");
const libraryBtn = document.getElementById("alog-library-refresh");
const libraryList = document.getElementById("alog-library-list");
const resultEl = document.getElementById("alog-result");
fileInput.addEventListener("change", async (e) => {
const file = e.target.files?.[0];
if (!file) return;
resultEl.innerHTML = "<p>Parsing…</p>";
try {
const content = await file.text();
const res = await fetch("/api/alog", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ filename: file.name, content }),
});
const body = await res.json();
applyResult(body);
} catch (err) {
resultEl.innerHTML = `<p style="color:#a8371a">${escapeHtml(err.message)}</p>`;
}
});
libraryBtn.addEventListener("click", async () => {
libraryList.classList.remove("hidden");
libraryList.innerHTML = "<li>Loading…</li>";
try {
const res = await fetch("/api/alog/library");
const body = await res.json();
if (!body.ok || body.files.length === 0) {
libraryList.innerHTML = "<li>No .alog files found. Set ALOG_DIR or drop files in ~/Roastetta.</li>";
return;
}
libraryList.innerHTML = "";
for (const f of body.files) {
const li = document.createElement("li");
li.textContent = `${f.filename} (${Math.round(f.sizeBytes / 1024)} KB)`;
li.addEventListener("click", async () => {
resultEl.innerHTML = "<p>Loading…</p>";
const r = await fetch(`/api/alog/library/${encodeURIComponent(f.filename)}`);
applyResult(await r.json());
});
libraryList.appendChild(li);
}
} catch (err) {
libraryList.innerHTML = `<li style="color:#a8371a">${escapeHtml(err.message)}</li>`;
}
});
function applyResult(body) {
if (!body.ok) {
resultEl.innerHTML = `<p style="color:#a8371a">${escapeHtml(body.error ?? body.code)}</p>`;
return;
}
state.plan.reference = body;
recompute();
const warnings = (body.warnings ?? []).map((w) => `<li>${escapeHtml(w)}</li>`).join("");
resultEl.innerHTML = `
<p><strong>${escapeHtml(body.roast.title)}</strong> — ${body.roast.roastDate || "no date"} ·
first crack ${fmt(body.derived?.firstCrackS)} · development ${fmt(body.derived?.developmentS)} ·
drop ${fmt(body.derived?.dropS)} · DTR ${body.derived?.dtrPct ?? "—"}%</p>
${warnings ? `<ul class="warnings">${warnings}</ul>` : ""}
`;
}
}
function fmt(seconds) {
if (seconds === null || seconds === undefined) return "—";
const s = Math.round(seconds);
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
}
function escapeHtml(s) {
return String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
}
+293
View File
@@ -0,0 +1,293 @@
import { FIELD_IDS, blankPlan } from "/shared/fields.js";
import { computeLedger } from "/shared/ledger.js";
import { formatDuration, formatSigned, parseRangeMidpoint } from "/shared/time.js";
import { CULTIVARS, findCultivar } from "/shared/reference-data.js";
import { buildPlanCurve, pointsToPathD, tToX, tempToY } from "/shared/curve.js";
import { initPrefillPanel } from "./prefill-ui.js";
import { initAlogPanel } from "./alog-ui.js";
import { initPrint } from "./print.js";
const FIELD_ID_SET = new Set(FIELD_IDS);
const STORAGE_KEY = "roastPlannerPlan.v1";
export const state = { plan: loadFromStorage() ?? blankPlan() };
const form = document.getElementById("plan-form");
// ---- nested path get/set for names like "temps.charge.tempC" or "blendComponents.0.cultivar"
function getPath(obj, path) {
return path.split(".").reduce((o, k) => (o == null ? undefined : o[k]), obj);
}
function setPath(obj, path, value) {
const parts = path.split(".");
let node = obj;
for (let i = 0; i < parts.length - 1; i++) {
if (node[parts[i]] === undefined || node[parts[i]] === null) node[parts[i]] = {};
node = node[parts[i]];
}
node[parts[parts.length - 1]] = value;
}
function valueForName(name) {
if (FIELD_ID_SET.has(name)) return state.plan.fields[name] ?? "";
const v = getPath(state.plan, name);
return v ?? "";
}
function setValueForName(name, value) {
if (FIELD_ID_SET.has(name)) {
state.plan.fields[name] = value;
} else {
setPath(state.plan, name, value);
}
}
// ---- dynamic rows: blend components + actuator schedule
function renderBlendRows() {
const tbody = document.getElementById("blend-rows");
tbody.innerHTML = "";
state.plan.blendComponents.forEach((_, i) => {
const tr = document.createElement("tr");
tr.innerHTML = `
<td><input class="f w ws-input" name="blendComponents.${i}.cultivar"><span class="pv"></span></td>
<td><input class="f ws-input" style="min-width:18mm" name="blendComponents.${i}.group"><span class="pv"></span></td>
<td><input class="f ws-input" style="min-width:20mm" name="blendComponents.${i}.process"><span class="pv"></span></td>
<td class="num"><input class="f n ws-input" name="blendComponents.${i}.sharePct"><span class="pv"></span></td>
<td class="num"><input class="f n ws-input" name="blendComponents.${i}.fcAnchor"><span class="pv"></span></td>
`;
tbody.appendChild(tr);
});
}
function renderActuatorRows() {
const tbody = document.getElementById("actuator-rows");
tbody.innerHTML = "";
state.plan.actuators.forEach((_, i) => {
const tr = document.createElement("tr");
tr.innerHTML = `
<td class="num"><input class="f n ws-input" name="actuators.${i}.time"><span class="pv"></span></td>
<td class="num"><input class="f n ws-input" name="actuators.${i}.heatPct"><span class="pv"></span></td>
<td class="num"><input class="f n ws-input" name="actuators.${i}.fanPct"><span class="pv"></span></td>
<td class="num"><input class="f n ws-input" name="actuators.${i}.expectedBt"><span class="pv"></span></td>
<td><input class="f ws-input" style="min-width:78mm" name="actuators.${i}.why"><span class="pv"></span></td>
`;
tbody.appendChild(tr);
});
}
// ---- populate every control in the form from state.plan
export function renderFormFromPlan() {
for (const el of form.querySelectorAll("input, select, textarea")) {
const name = el.name;
if (!name) continue;
const value = valueForName(name);
if (el.type === "radio") {
el.checked = el.value === value;
} else if (el.type === "checkbox") {
el.checked = Boolean(value);
} else {
el.value = value ?? "";
}
}
}
function fmtOut(id, text) {
const el = document.querySelector(`[data-out="${id}"]`);
if (el) el.textContent = text ?? "—";
}
function renderLedger() {
const ledger = computeLedger(state.plan);
const d = (s) => (s === null || s === undefined ? "—" : formatDuration(s));
const ds = (s) => (s === null || s === undefined ? "—" : formatSigned(s));
fmtOut("l1", d(ledger.lines.l1));
fmtOut("l2", ds(ledger.lines.l2));
fmtOut("l3", ds(ledger.lines.l3));
fmtOut("A", d(ledger.A));
fmtOut("yellow", d(ledger.yellow));
fmtOut("maillard", d(ledger.maillard));
fmtOut("l7", d(ledger.lines.l7));
fmtOut("l8", ds(ledger.lines.l8));
fmtOut("l9", ds(ledger.lines.l9));
fmtOut("C", d(ledger.C));
fmtOut("D", d(ledger.D));
const pct = (v) => (v === null || v === undefined ? "—" : `${v.toFixed(1)}%`);
fmtOut("check-drying", pct(ledger.checks.drying.pct));
fmtOut("check-maillard", pct(ledger.checks.maillard.pct));
fmtOut("check-dtr", pct(ledger.checks.dtr.pct));
fmtOut("check-ceiling", d(ledger.checks.ceiling.valueS));
for (const [key, check] of Object.entries(ledger.checks)) {
const cell = document.querySelector(`[data-pass="${key}"]`);
if (!cell) continue;
cell.classList.remove("pass", "fail", "unknown");
cell.classList.add(check.pass === null ? "unknown" : check.pass ? "pass" : "fail");
}
// Box 8 / box 11 derived time columns
fmtOut("t-charge", "0:00");
fmtOut("t-yellow", d(ledger.yellow));
fmtOut("t-fc", d(ledger.A));
fmtOut("t-drop", d(ledger.D));
fmtOut("pa-charge", "0:00");
fmtOut("pa-tp", state.plan.temps.tp.time || "—");
fmtOut("pa-yellow", d(ledger.yellow));
fmtOut("pa-fc", d(ledger.A));
fmtOut("pa-drop", d(ledger.D));
document.getElementById("back-coffee-name").textContent = state.plan.fields["0.1"] || "";
return ledger;
}
function renderCurve(ledger) {
const planPoints = buildPlanCurve(state.plan, ledger);
const planGroup = document.getElementById("plan-curve");
planGroup.innerHTML = "";
if (planPoints.length > 0) {
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
path.setAttribute("d", pointsToPathD(planPoints));
path.setAttribute("fill", "none");
path.setAttribute("stroke", "#1a1512");
path.setAttribute("stroke-width", "1.6");
planGroup.appendChild(path);
for (const p of planPoints) {
const c = document.createElementNS("http://www.w3.org/2000/svg", "circle");
c.setAttribute("cx", tToX(p.timeS).toFixed(1));
c.setAttribute("cy", tempToY(p.tempC).toFixed(1));
c.setAttribute("r", "3.2");
c.setAttribute("fill", "#1a1512");
planGroup.appendChild(c);
}
}
const ref = state.plan.reference;
const refGroup = document.getElementById("ref-curve");
refGroup.innerHTML = "";
if (ref?.curve?.length) {
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
const d = ref.curve
.map((p, i) => `${i === 0 ? "M" : "L"}${tToX(p.t).toFixed(1)},${tempToY(p.bt).toFixed(1)}`)
.join(" ");
path.setAttribute("d", d);
path.setAttribute("fill", "none");
path.setAttribute("stroke", "#5a5048");
path.setAttribute("stroke-width", "1.3");
path.setAttribute("stroke-dasharray", "4 3");
path.setAttribute("opacity", ".65");
refGroup.appendChild(path);
for (const m of ref.milestones ?? []) {
if (m.tempC === null) continue;
const c = document.createElementNS("http://www.w3.org/2000/svg", "circle");
c.setAttribute("cx", tToX(m.timeS).toFixed(1));
c.setAttribute("cy", tempToY(m.tempC).toFixed(1));
c.setAttribute("r", "2.6");
c.setAttribute("fill", "none");
c.setAttribute("stroke", "#5a5048");
c.setAttribute("stroke-width", "1.2");
refGroup.appendChild(c);
}
}
}
export function recompute() {
const ledger = renderLedger();
renderCurve(ledger);
autosave();
}
function autosave() {
clearTimeout(autosave._t);
autosave._t = setTimeout(() => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state.plan));
document.getElementById("autosave-status").textContent = `saved ${new Date().toLocaleTimeString()}`;
} catch {
/* storage unavailable — non-fatal */
}
}, 400);
}
function loadFromStorage() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? JSON.parse(raw) : null;
} catch {
return null;
}
}
function cultivarAutofill(name) {
const row = findCultivar(name);
if (!row) return;
setValueForName("1.2", row.group);
setValueForName("1.4", formatDuration(parseRangeMidpoint(row.fcAnchor)));
setValueForName("1.5", row.profile.join("|"));
setValueForName("1.7", formatSigned(row.devModS));
renderFormFromPlan();
}
function wireCultivarDatalist() {
const list = document.getElementById("cultivar-list");
list.innerHTML = CULTIVARS.map((c) => `<option value="${c.name}">`).join("");
}
function wireForm() {
form.addEventListener("input", (e) => {
const el = e.target;
if (!el.name) return;
if (el.type === "radio" && !el.checked) return;
setValueForName(el.name, el.value);
if (el.name === "1.1") cultivarAutofill(el.value);
recompute();
});
}
function wireToolbar() {
document.getElementById("btn-toggle-prefill").addEventListener("click", () => {
document.getElementById("panel-prefill").classList.toggle("hidden");
});
document.getElementById("btn-toggle-alog").addEventListener("click", () => {
document.getElementById("panel-alog").classList.toggle("hidden");
});
document.getElementById("btn-save").addEventListener("click", () => {
const blob = new Blob([JSON.stringify(state.plan, null, 2)], { type: "application/json" });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = `${(state.plan.fields["0.1"] || "roast-plan").replace(/[^\w-]+/g, "_")}.json`;
a.click();
URL.revokeObjectURL(a.href);
});
document.getElementById("btn-load").addEventListener("click", () => document.getElementById("file-load").click());
document.getElementById("file-load").addEventListener("change", async (e) => {
const file = e.target.files?.[0];
if (!file) return;
try {
const loaded = JSON.parse(await file.text());
state.plan = { ...blankPlan(), ...loaded };
renderBlendRows();
renderActuatorRows();
renderFormFromPlan();
recompute();
} catch (err) {
alert(`Could not load plan: ${err.message}`);
}
});
}
function init() {
renderBlendRows();
renderActuatorRows();
wireCultivarDatalist();
renderFormFromPlan();
wireForm();
wireToolbar();
initPrefillPanel({ state, renderBlendRows, renderActuatorRows, renderFormFromPlan, recompute });
initAlogPanel({ state, recompute });
initPrint({ form });
recompute();
}
init();
+89
View File
@@ -0,0 +1,89 @@
// Wires the "Prefill from URL" panel. Applies the returned field patch only into empty
// fields by default (checkbox to overwrite), tracks a snapshot for undo, and never touches
// the form on any error.
export function initPrefillPanel({ state, renderFormFromPlan, recompute }) {
const urlInput = document.getElementById("prefill-url");
const overwriteBox = document.getElementById("prefill-overwrite");
const goBtn = document.getElementById("prefill-go");
const undoBtn = document.getElementById("prefill-undo");
const resultEl = document.getElementById("prefill-result");
let snapshot = null;
goBtn.addEventListener("click", async () => {
const url = urlInput.value.trim();
if (!url) return;
goBtn.disabled = true;
resultEl.innerHTML = `<p>Fetching…</p>`;
try {
const res = await fetch("/api/prefill", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ url }),
});
const body = await res.json();
if (!body.ok) {
resultEl.innerHTML = `<p style="color:#a8371a">${escapeHtml(body.error ?? body.code)}</p>`;
return;
}
snapshot = JSON.parse(JSON.stringify(state.plan));
const overwrite = overwriteBox.checked;
let applied = 0;
for (const [id, value] of Object.entries(body.fields ?? {})) {
const current = state.plan.fields[id] ?? "";
if (!overwrite && current !== "") continue;
state.plan.fields[id] = value;
applied++;
}
renderFormFromPlan();
markPrefilled(Object.keys(body.fields ?? {}), body.provenance ?? {});
recompute();
undoBtn.disabled = false;
const warnings = (body.warnings ?? []).map((w) => `<li>${escapeHtml(w)}</li>`).join("");
resultEl.innerHTML = `
<p>Applied ${applied} field${applied === 1 ? "" : "s"} from <a href="${escapeHtml(body.source.finalUrl)}" target="_blank" rel="noopener">${escapeHtml(body.source.finalUrl)}</a>.</p>
${warnings ? `<ul class="warnings">${warnings}</ul>` : ""}
`;
} catch (err) {
resultEl.innerHTML = `<p style="color:#a8371a">${escapeHtml(err.message)}</p>`;
} finally {
goBtn.disabled = false;
}
});
undoBtn.addEventListener("click", () => {
if (!snapshot) return;
state.plan = snapshot;
snapshot = null;
undoBtn.disabled = true;
renderFormFromPlan();
clearPrefilledMarks();
recompute();
resultEl.innerHTML = "<p>Prefill undone.</p>";
});
function markPrefilled(ids, provenance) {
for (const id of ids) {
const el = document.querySelector(`[name="${CSS.escape(id)}"]`);
if (!el) continue;
el.classList.add("prefilled");
const prov = provenance[id];
if (prov) el.title = `from: ${prov}`;
}
}
function clearPrefilledMarks() {
for (const el of document.querySelectorAll(".prefilled")) {
el.classList.remove("prefilled");
el.removeAttribute("title");
}
}
}
function escapeHtml(s) {
return String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
}
+22
View File
@@ -0,0 +1,22 @@
// Bakes live input values into their sibling .pv span before printing (so text doesn't clip
// at a fixed .f width the way a raw <input> would), then clears them after. One DOM, one
// rendering — see app.css's @media print block for the corresponding display swap.
export function initPrint({ form }) {
document.getElementById("btn-print").addEventListener("click", () => window.print());
window.addEventListener("beforeprint", () => bakeValues(form));
window.addEventListener("afterprint", () => clearBaked(form));
}
function bakeValues(form) {
for (const el of form.querySelectorAll("input.ws-input, textarea.ws-input")) {
if (el.type === "radio" || el.type === "checkbox") continue;
const pv = el.nextElementSibling;
if (pv && pv.classList.contains("pv")) pv.textContent = el.value || "";
}
}
function clearBaked(form) {
for (const pv of form.querySelectorAll(".pv")) pv.textContent = "";
}
+209
View File
@@ -0,0 +1,209 @@
/* Verbatim copy of the <style> block from
/Users/shane/dev/hope_roaster/roast-planner/manual-roast-planner.html
Do not hand-edit the rules below this line — port changes from the source instead.
Screen-only additions (form inputs, radios, toolbar) live in app.css. */
/* ─── Print setup ─────────────────────────────────────────────────────── */
@page { size: letter portrait; margin: 10mm 13mm 8mm 13mm; }
@page :first { margin-top: 10mm; }
:root{
--ink:#1a1512;
--mid:#5a5048;
--faint:#8b8078;
--rule:#c9bfb5;
--hair:#e2dad2;
--accent:#7a3b12;
--accent-bg:#f6efe7;
--warn-bg:#fbf3e4;
--box:#fdfcfa;
}
*{ box-sizing:border-box; }
html{ -webkit-print-color-adjust:exact; print-color-adjust:exact; }
body{
margin:0;
font-family:"Iowan Old Style","Palatino Linotype",Palatino,Georgia,"Times New Roman",serif;
font-size:9pt; line-height:1.32; color:var(--ink); background:#fff;
}
.sheet{ max-width:186mm; margin:0 auto; }
/* ─── Typography ──────────────────────────────────────────────────────── */
h1,h2,h3,h4,.lbl,th,.step-n,.tag{
font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;
}
h1{ font-size:20pt; line-height:1.08; margin:0 0 1.6mm; letter-spacing:-.4pt; }
h2{
font-size:11.6pt; margin:0 0 2.4mm; padding:0 0 1.3mm;
border-bottom:1.6pt solid var(--ink); letter-spacing:-.2pt;
display:flex; align-items:baseline; gap:3mm;
}
h2 .step-n{
font-size:8pt; font-weight:700; letter-spacing:.9pt; color:#fff;
background:var(--accent); padding:1mm 2.2mm; border-radius:1mm;
white-space:nowrap;
}
h3{ font-size:9.8pt; margin:2.9mm 0 1.2mm; color:var(--accent); letter-spacing:-.1pt; }
h4{ font-size:8.4pt; margin:2.2mm 0 .8mm; letter-spacing:.2pt; text-transform:uppercase; color:var(--mid); }
p{ margin:0 0 1.55mm; }
em{ font-style:italic; }
strong{ font-weight:700; }
small{ font-size:7.7pt; color:var(--mid); }
a{ color:var(--accent); }
/* ─── Page structure ──────────────────────────────────────────────────── */
section{ page-break-before:always; break-before:page; padding-top:0; }
section.first{ page-break-before:auto; break-before:auto; }
.nobreak{ page-break-inside:avoid; break-inside:avoid; }
tr{ page-break-inside:avoid; break-inside:avoid; }
thead{ display:table-header-group; }
tr.grp{ page-break-after:avoid; break-after:avoid; }
h2,h3,h4{ page-break-after:avoid; break-after:avoid; }
.why,.note{ page-break-inside:avoid; break-inside:avoid; }
/* ─── Tables ──────────────────────────────────────────────────────────── */
table{ width:100%; border-collapse:collapse; margin:1.4mm 0 2.1mm; font-size:7.9pt; }
th{
text-align:left; font-size:7pt; font-weight:700; letter-spacing:.5pt;
text-transform:uppercase; color:var(--mid);
border-bottom:1pt solid var(--ink); padding:1.1mm 1.3mm .8mm; vertical-align:bottom;
}
td{ padding:1mm 1.25mm; border-bottom:.5pt solid var(--hair); vertical-align:top; }
tr:last-child td{ border-bottom:.8pt solid var(--rule); }
tbody tr:nth-child(even) td{ background:#faf7f4; }
.grp td{ background:var(--accent-bg)!important; font-weight:700; font-size:7.6pt;
font-family:"Helvetica Neue",Helvetica,Arial,sans-serif; letter-spacing:.3pt;
border-bottom:.8pt solid var(--rule); color:var(--accent); }
td.num,th.num{ text-align:center; font-variant-numeric:tabular-nums; white-space:nowrap; }
.mono{ font-family:"SF Mono",Menlo,Consolas,monospace; font-size:7.6pt; }
.tight td{ padding:.85mm 1.15mm; }
/* ─── Callouts ────────────────────────────────────────────────────────── */
.why{
background:var(--accent-bg); border-left:2.4pt solid var(--accent);
padding:1.6mm 2.4mm 1.6mm 2.6mm; margin:1.6mm 0 2.1mm; font-size:8.3pt;
}
.why .tag{
display:block; font-size:7pt; font-weight:700; letter-spacing:1.1pt;
text-transform:uppercase; color:var(--accent); margin-bottom:.9mm;
}
.why p:last-child{ margin-bottom:0; }
.warn{ background:var(--warn-bg); border-left:2.4pt solid #a8741a; }
.warn .tag{ color:#8a5c10; }
.note{
border:.6pt solid var(--rule); background:var(--box);
padding:1.6mm 2.4mm; margin:1.6mm 0 2.1mm; font-size:8.1pt;
}
/* ─── Fill-in fields ──────────────────────────────────────────────────── */
.f{
display:inline-block; min-width:17mm; border-bottom:.9pt solid var(--ink);
height:4.2mm; margin:0 .8mm; vertical-align:baseline;
}
.f.w{ min-width:34mm; } .f.xw{ min-width:58mm; } .f.n{ min-width:11mm; }
/* Worksheet ledger */
.ledger{ width:100%; border-collapse:collapse; font-size:8.1pt; margin:1.6mm 0 2.4mm; }
.ledger th{ font-size:7.2pt; }
.ledger td{ padding:0; border:none; }
.ledger tr.row td{
border-bottom:.5pt solid var(--hair); padding:1.05mm 1.4mm; vertical-align:middle;
}
.ledger tr.row:nth-child(even) td{ background:#faf7f4; }
.ledger td.ln{
width:7mm; text-align:center; font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;
font-size:7.4pt; font-weight:700; color:var(--faint);
}
.ledger td.desc{ }
.ledger td.desc .sub{ display:block; font-size:7.2pt; color:var(--mid); line-height:1.3; }
.ledger td.val{
width:26mm; border-left:.6pt solid var(--rule); background:var(--box)!important;
text-align:center; font-family:"SF Mono",Menlo,Consolas,monospace; font-size:8.4pt;
height:6.2mm;
}
.ledger tr.total td{
border-top:1.4pt solid var(--ink); border-bottom:1.4pt solid var(--ink);
padding:1.4mm 1.4mm; font-weight:700;
}
.ledger tr.total td.val{ background:#f0e7dc!important; }
.op{ font-family:"SF Mono",Menlo,Consolas,monospace; font-weight:700; color:var(--accent); }
/* ─── Layout helpers ──────────────────────────────────────────────────── */
.cols2{ display:grid; grid-template-columns:1fr 1fr; gap:0 7mm; }
.cols3{ display:grid; grid-template-columns:1fr 1fr 1fr; gap:0 5mm; }
.hdr-meta{
display:flex; justify-content:space-between; align-items:flex-end;
border-bottom:2.4pt solid var(--ink); padding-bottom:2.5mm; margin-bottom:4mm;
}
.hdr-meta .sub{ font-size:8.8pt; color:var(--mid); max-width:118mm; }
.hdr-meta .stamp{
font-family:"Helvetica Neue",Helvetica,Arial,sans-serif; font-size:7.4pt;
color:var(--faint); text-align:right; line-height:1.5; white-space:nowrap;
}
ul,ol{ margin:0 0 2mm; padding-left:4.6mm; }
li{ margin-bottom:.8mm; }
.chk{ list-style:none; padding-left:0; }
.chk li{ padding-left:5.6mm; position:relative; margin-bottom:1.2mm; }
.chk li::before{
content:""; position:absolute; left:0; top:.6mm;
width:3.2mm; height:3.2mm; border:.9pt solid var(--ink); border-radius:.5mm;
}
.footer-note{
margin-top:5mm; padding-top:2mm; border-top:.6pt solid var(--rule);
font-size:7.4pt; color:var(--faint);
font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;
}
.flow{ width:100%; height:auto; margin:2mm 0 2.6mm; }
/* ─── Worksheet pages ─────────────────────────────────────────────────── */
.wsbar{
display:flex; justify-content:space-between; align-items:baseline;
border-bottom:2.4pt solid var(--ink); padding-bottom:1mm; margin-bottom:2mm;
font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;
}
.wsbar .t{ font-size:12pt; font-weight:700; letter-spacing:-.2pt; }
.wsbar .r{ font-size:7.6pt; color:var(--faint); letter-spacing:.6pt; text-transform:uppercase; }
.wsbox{ border:.8pt solid var(--rule); margin:0 0 2.4mm; break-inside:avoid; page-break-inside:avoid; }
.wsbox > .hd{
display:flex; justify-content:space-between; align-items:baseline;
background:var(--accent-bg); border-bottom:.8pt solid var(--rule);
padding:1.1mm 2mm; font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;
}
.wsbox > .hd .n{ font-size:8pt; font-weight:700; letter-spacing:.5pt; color:var(--accent); }
.wsbox > .hd .id{ font-size:8pt; font-weight:700; letter-spacing:.9pt; color:var(--mid); }
.wsbox > .hd .ref{ font-size:6.9pt; color:var(--mid); letter-spacing:.3pt; }
.wsbox > .bd{ padding:1.8mm 2mm; }
.wsbox table{ margin:0; font-size:7.4pt; }
.wsbox .ledger{ margin:0; }
.wsbox > .bd > table:not(:last-child){ margin-bottom:2mm; }
.wsline{ font-size:8.4pt; line-height:2.05; }
.wsline .lb{ font-family:"Helvetica Neue",Helvetica,Arial,sans-serif; font-size:7.4pt;
letter-spacing:.3pt; text-transform:uppercase; color:var(--mid); }
.wsline .sep{ color:var(--rule); padding:0 1.6mm; }
.circ{ font-size:8pt; }
.circ b{ font-weight:400; padding:0 .6mm; }
.wsgrid .grid-wrap{ border:none; margin:0; }
.wsbox{ margin-bottom:1.2mm; }
.wsbox > .hd{ padding:.9mm 2mm; }
.wsbox > .bd{ padding:1.1mm 1.8mm; }
.wsbox td{ padding:.62mm 1.1mm; }
.wsbox th{ padding:.9mm 1.1mm .6mm; }
.wsbox .ledger tr.row td{ padding:.5mm 1.2mm; }
.wsbox .ledger tr.total td{ padding:.8mm 1.2mm; }
.wsbox .ledger td.val{ height:5mm; }
.wsbox .ledger{ font-size:7.8pt; }
.wsbox .ledger td.desc .sub{ font-size:6.7pt; line-height:1.15; }
.wsline{ line-height:1.66; }
.wsline.tightln{ line-height:1.32; }
.fid{
font-family:"SF Mono",Menlo,Consolas,monospace; font-size:6.3pt; font-weight:700;
color:var(--accent); margin-right:.5mm;
}
.stepdot{
display:inline-block; width:3.6mm; height:3.6mm; border-radius:50%;
background:var(--accent); color:#fff; font-size:6.4pt; font-weight:700;
text-align:center; line-height:3.6mm; margin-right:1mm; vertical-align:middle;
font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;
}
.grid-wrap{ border:1pt solid var(--ink); padding:0; margin:2mm 0 3mm; }
svg text{ font-family:"Helvetica Neue",Helvetica,Arial,sans-serif; }