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:
@@ -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: "✗ "; }
|
||||
}
|
||||
@@ -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 & 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 #</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 >±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 & 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:30–2: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">43–51 %</td><td class="num"><output data-out="check-drying">—</output></td>
|
||||
<td class="num passcell unknown" data-pass="drying"></td><td>high = late yellow · low = rushed</td></tr>
|
||||
<tr><td>Maillard share</td><td class="num">33–39 %</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">12–20 %</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">< 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 & 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 & 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">133–195 °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">77–100 °C @ 0:47–1: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">148–166 °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">176–187 °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">183–197 °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, 176–187 °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>
|
||||
@@ -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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
||||
}
|
||||
@@ -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 = "";
|
||||
}
|
||||
@@ -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; }
|
||||
Reference in New Issue
Block a user