Initial commit

This commit is contained in:
2026-07-29 19:01:43 -04:00
parent fedaf29847
commit 106b1104c3
4 changed files with 1111 additions and 146 deletions
+395 -79
View File
@@ -1,85 +1,401 @@
/* 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. */
/* Roast Planner — screen UI. worksheet.css is print-only (loaded with media="print"); this file
owns the entire on-screen experience and is deliberately independent of the print layout. */
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;
:root{
--ink:#1B1614; --ink-2:#5B524B; --ink-3:#8C8179;
--surface:#FBF8F4; --surface-raised:#FFFFFF; --surface-sunken:#F2ECE4;
--line:#E4DCD2; --line-strong:#D3C8BA;
--ember:#A8481A; --ember-soft:#FBEFE6; --ember-strong:#7F350F;
--pass:#2F6F4E; --pass-bg:#E8F3ED;
--warn:#A8741A; --warn-bg:#FBF3E1;
--fail:#B03A22; --fail-bg:#FBEAE5;
--radius-sm:6px; --radius-md:10px; --radius-lg:16px;
--shadow-card:0 1px 2px rgba(27,22,20,.04), 0 8px 24px -12px rgba(27,22,20,.12);
--shadow-pop:0 12px 32px -8px rgba(27,22,20,.28);
--font-ui:-apple-system,BlinkMacSystemFont,"SF Pro Text","Segoe UI",Inter,Helvetica,Arial,sans-serif;
--font-serif:"Iowan Old Style","Palatino Linotype",Palatino,Georgia,"Times New Roman",serif;
--font-mono:"SF Mono",Menlo,Consolas,monospace;
--header-h:60px;
}
.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;
*{ box-sizing:border-box; }
html{ background:var(--surface); }
body{
margin:0; background:var(--surface); color:var(--ink);
font-family:var(--font-ui); font-size:15px; line-height:1.45;
-webkit-font-smoothing:antialiased;
}
h1,h2,h3{ margin:0; font-family:var(--font-ui); }
output{ font-variant-numeric:tabular-nums; }
/* ─── Header ──────────────────────────────────────────────────────────── */
.app-header{
position:sticky; top:0; z-index:30; height:var(--header-h);
display:flex; align-items:center; gap:24px;
background:var(--surface-raised); border-bottom:1px solid var(--line);
padding:0 20px;
}
.brand{ display:flex; align-items:center; gap:10px; flex:0 0 auto; }
.brand-mark{ font-size:22px; color:var(--ember); line-height:1; }
.brand-text h1{ font-size:15px; font-weight:700; letter-spacing:-.1px; }
.brand-sub{ margin:0; font-size:11.5px; color:var(--ink-3); }
.section-nav{
display:flex; align-items:center; gap:2px; flex:1 1 auto; overflow-x:auto;
scrollbar-width:none;
}
.section-nav::-webkit-scrollbar{ display:none; }
.section-nav a{
flex:0 0 auto; padding:6px 12px; border-radius:999px; font-size:12.5px; font-weight:500;
color:var(--ink-2); text-decoration:none; white-space:nowrap; transition:background .15s,color .15s;
}
.section-nav a:hover{ background:var(--surface-sunken); color:var(--ink); }
.section-nav a.active{ background:var(--ember-soft); color:var(--ember-strong); font-weight:600; }
.header-actions{ display:flex; align-items:center; gap:8px; flex:0 0 auto; }
.header-divider{ width:1px; height:22px; background:var(--line); margin:0 2px; }
.ghost-btn, .primary-btn, label.filebtn{
font-family:var(--font-ui); font-size:12.5px; font-weight:600; cursor:pointer;
border-radius:var(--radius-sm); padding:7px 12px; white-space:nowrap;
border:1px solid transparent; transition:background .15s,border-color .15s,color .15s;
}
.ghost-btn{ background:transparent; color:var(--ink-2); border-color:var(--line); }
.ghost-btn:hover{ background:var(--surface-sunken); color:var(--ink); }
.ghost-btn[aria-pressed="true"]{ background:var(--ember-soft); border-color:var(--ember); color:var(--ember-strong); }
.ghost-btn.small{ font-size:12px; padding:5px 10px; }
.ghost-btn:disabled{ opacity:.45; cursor:default; }
.primary-btn, label.filebtn{ background:var(--ember); color:#fff; border-color:var(--ember); }
.primary-btn:hover, label.filebtn:hover{ background:var(--ember-strong); }
.primary-btn:disabled{ opacity:.5; cursor:default; }
#file-load{ display:none; }
label.filebtn input[type=file]{ display:none; }
.autosave-chip{
display:flex; align-items:center; gap:6px; font-size:11.5px; color:var(--ink-3);
padding:4px 10px; border-radius:999px; background:var(--surface-sunken); margin-left:2px;
}
.autosave-chip .dot{ width:6px; height:6px; border-radius:50%; background:var(--ink-3); transition:background .2s; }
.autosave-chip.saving .dot{ background:var(--warn); animation:pulse 1s ease-in-out infinite; }
.autosave-chip.saved .dot{ background:var(--pass); }
@keyframes pulse{ 0%,100%{ opacity:1; } 50%{ opacity:.35; } }
/* ─── Drawers ─────────────────────────────────────────────────────────── */
.drawer-overlay{
position:fixed; inset:0; background:rgba(27,22,20,.32); z-index:40; opacity:1; transition:opacity .18s;
}
.drawer-overlay.hidden{ display:none; }
.drawer{
position:fixed; top:0; right:0; bottom:0; width:min(420px,92vw); z-index:41;
background:var(--surface-raised); box-shadow:var(--shadow-pop);
display:flex; flex-direction:column; transform:translateX(0); transition:transform .2s;
}
.drawer.hidden{ display:none; }
.drawer-head{
display:flex; align-items:center; justify-content:space-between;
padding:16px 20px; border-bottom:1px solid var(--line);
}
.drawer-head h3{ font-size:14.5px; font-weight:700; }
.icon-btn{
background:none; border:none; font-size:14px; color:var(--ink-3); cursor:pointer;
width:28px; height:28px; border-radius:var(--radius-sm);
}
.icon-btn:hover{ background:var(--surface-sunken); color:var(--ink); }
.drawer-body{ padding:18px 20px; overflow-y:auto; display:flex; flex-direction:column; gap:12px; }
.stack-label{ font-size:12px; font-weight:600; color:var(--ink-2); }
.text-input{
width:100%; padding:9px 11px; border:1px solid var(--line); border-radius:var(--radius-sm);
font-size:13.5px; font-family:var(--font-ui); background:var(--surface-raised); color:var(--ink);
}
.text-input:focus{ outline:none; border-color:var(--ember); box-shadow:0 0 0 3px var(--ember-soft); }
.checkbox-row{ display:flex; align-items:center; gap:7px; font-size:13px; color:var(--ink-2); }
.drawer-actions{ display:flex; gap:8px; }
.drawer-result{ font-size:12.5px; color:var(--ink-2); line-height:1.55; }
.drawer-result a{ color:var(--ember); }
.drawer-result .warnings{ color:var(--warn); margin:6px 0 0; padding-left:18px; }
.lib-list{ list-style:none; margin:0; padding:0; max-height:220px; overflow-y:auto; border:1px solid var(--line); border-radius:var(--radius-sm); }
.lib-list.hidden{ display:none; }
.lib-list li{ padding:8px 10px; font-size:12.5px; border-bottom:1px solid var(--line); cursor:pointer; }
.lib-list li:last-child{ border-bottom:none; }
.lib-list li:hover{ background:var(--surface-sunken); }
/* ─── Workspace layout ────────────────────────────────────────────────── */
.workspace-grid{
display:grid; grid-template-columns:1fr 380px; gap:28px; align-items:start;
max-width:1360px; margin:0 auto; padding:28px 24px 80px;
}
.form-col{ min-width:0; display:flex; flex-direction:column; }
.dock{
position:sticky; top:calc(var(--header-h) + 20px);
display:flex; flex-direction:column; gap:18px;
max-height:calc(100vh - var(--header-h) - 40px); overflow-y:auto;
padding-bottom:4px;
}
.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: "✗ "; }
@media (max-width:1180px){
.workspace-grid{ grid-template-columns:1fr; }
.dock{ position:static; max-height:none; }
}
@media (max-width:1080px){
.app-header{ flex-wrap:wrap; height:auto; padding:10px 14px; gap:10px; }
.section-nav{ order:3; flex-basis:100%; }
}
@media (max-width:720px){
.workspace-grid{ padding:18px 14px 60px; }
}
/* ─── Panel cards (form sections) ────────────────────────────────────── */
.panel-card{
background:var(--surface-raised); border:1px solid var(--line); border-radius:var(--radius-lg);
margin-bottom:20px; box-shadow:var(--shadow-card);
}
.panel-head{
display:flex; align-items:center; justify-content:space-between; gap:16px;
padding:16px 22px; border-bottom:1px solid var(--line);
}
.panel-head h2{ font-size:16px; font-weight:700; letter-spacing:-.1px; }
.panel-body{ padding:20px 22px 22px; }
.subhead{
font-size:11px; text-transform:uppercase; letter-spacing:.06em; font-weight:700;
color:var(--ink-3); margin:20px 0 12px;
}
.subhead:first-child{ margin-top:0; }
/* ─── Fields ──────────────────────────────────────────────────────────── */
.field-grid{
display:grid; grid-template-columns:repeat(auto-fit,minmax(150px,1fr)); gap:14px 18px; margin-bottom:14px;
}
.field-grid:last-child{ margin-bottom:0; }
.field{ position:relative; display:flex; flex-direction:column; gap:5px; }
.field.wide{ grid-column:1 / -1; }
.field.accent .field-label{ color:var(--ember-strong); }
.field-label{
font-size:12px; font-weight:600; color:var(--ink-2); display:flex; align-items:center; gap:4px;
}
.hint{
font-size:11px; color:var(--ink-3); cursor:help; border-radius:50%; width:14px; height:14px;
display:inline-flex; align-items:center; justify-content:center;
}
.field-input{
width:100%; padding:8px 10px; border:1px solid var(--line); border-radius:var(--radius-sm);
font-family:var(--font-ui); font-size:13.5px; color:var(--ink); background:var(--surface-raised);
transition:border-color .15s, box-shadow .15s;
}
.field-input.sm{ text-align:center; font-family:var(--font-mono); font-size:13px; }
.field-input.note{ font-family:var(--font-ui); }
.field-input:focus{ outline:none; border-color:var(--ember); box-shadow:0 0 0 3px var(--ember-soft); }
.field-input.prefilled{ background:#FFF6DE; border-color:#E4C766; }
.field-input::placeholder{ color:var(--ink-3); opacity:.7; }
.unit-input{ position:relative; }
.unit-input .field-input{ padding-right:32px; }
.unit-input.sm .field-input{ padding-right:26px; }
.unit-input .unit{
position:absolute; right:10px; top:50%; transform:translateY(-50%);
font-size:11.5px; color:var(--ink-3); pointer-events:none;
}
.unit-input.sm .unit{ right:7px; font-size:10.5px; }
/* field-id badge — hidden until hover/focus, or pinned via "Field IDs" toggle */
.field[data-fid]::after{
content:attr(data-fid); position:absolute; top:-6px; right:0;
font-family:var(--font-mono); font-size:9.5px; font-weight:700; color:var(--ember);
background:var(--ember-soft); padding:1px 5px; border-radius:5px;
opacity:0; transition:opacity .12s; pointer-events:none;
}
.field[data-fid]:hover::after, .field[data-fid]:focus-within::after{ opacity:1; }
body.show-fids .field[data-fid]::after{ opacity:1; }
/* ─── Choice controls (segmented + chips) ───────────────────────────── */
.segmented{
display:inline-flex; flex-wrap:wrap; gap:2px; background:var(--surface-sunken);
padding:2px; border-radius:var(--radius-md);
}
.segmented.small{ padding:2px; }
.seg-opt{ position:relative; cursor:pointer; }
.seg-opt input{ position:absolute; opacity:0; width:0; height:0; }
.seg-opt span{
display:block; padding:6px 12px; border-radius:8px; font-size:12.5px; font-weight:500;
color:var(--ink-2); white-space:nowrap; transition:background .15s,color .15s,box-shadow .15s;
}
.seg-opt input:checked + span{
background:var(--surface-raised); color:var(--ember-strong); font-weight:700;
box-shadow:0 1px 2px rgba(27,22,20,.12);
}
.seg-opt input:focus-visible + span{ outline:2px solid var(--ember); outline-offset:1px; }
.chip-group{ display:flex; flex-wrap:wrap; gap:8px; }
.chip-opt{ cursor:pointer; }
.chip-opt input{ position:absolute; opacity:0; width:0; height:0; }
.chip-opt span{
display:inline-flex; align-items:center; padding:7px 14px; border-radius:999px;
border:1px solid var(--line); font-size:12.5px; font-weight:500; color:var(--ink-2);
background:var(--surface-raised); transition:all .15s;
}
.chip-opt input:checked + span{
background:var(--ember-soft); border-color:var(--ember); color:var(--ember-strong); font-weight:700;
}
.chip-opt input:focus-visible + span{ outline:2px solid var(--ember); outline-offset:1px; }
/* ─── Blend cards ─────────────────────────────────────────────────────── */
#blend-body.collapsed .blend-cards, #blend-body.collapsed .blend-total,
#blend-body.collapsed #btn-add-blend, #blend-body.collapsed .subhead,
#blend-body.collapsed .field.wide, #blend-body.collapsed .field-grid{ display:none; }
.blend-cards{ display:flex; flex-direction:column; gap:10px; margin-bottom:12px; }
.blend-card{
display:grid; grid-template-columns:1.4fr 1fr 1fr .7fr .8fr auto; gap:10px; align-items:end;
background:var(--surface-sunken); border-radius:var(--radius-md); padding:12px;
}
.blend-card .field-label{ font-size:10.5px; }
.blend-remove{
background:none; border:1px solid var(--line); color:var(--ink-3); width:30px; height:30px;
border-radius:var(--radius-sm); cursor:pointer; font-size:13px;
}
.blend-remove:hover{ background:var(--fail-bg); color:var(--fail); border-color:var(--fail); }
.blend-total{ display:flex; align-items:center; gap:10px; margin-bottom:14px; }
.blend-total-bar{ flex:1 1 auto; height:6px; border-radius:999px; background:var(--surface-sunken); overflow:hidden; }
.blend-total-fill{ height:100%; background:var(--ember); width:0%; transition:width .2s,background .2s; }
.blend-total-fill.over, .blend-total-fill.under{ background:var(--fail); }
.blend-total-label{ font-size:11.5px; font-family:var(--font-mono); color:var(--ink-3); white-space:nowrap; }
@media (max-width:640px){ .blend-card{ grid-template-columns:1fr 1fr; } }
/* ─── Milestone lists (temps / roast log) ────────────────────────────── */
.milestone-list{ display:flex; flex-direction:column; gap:6px; }
.milestone-row{
display:grid; grid-template-columns:110px 64px 1fr 90px; gap:12px; align-items:center;
padding:8px 4px; border-bottom:1px solid var(--line);
}
.milestone-row:last-child{ border-bottom:none; }
.milestone-row.log-row{ grid-template-columns:110px 64px 72px 64px 1fr; }
.milestone-label{ font-size:13px; font-weight:600; color:var(--ink); }
.milestone-plan{
font-family:var(--font-mono); font-size:13px; color:var(--ink-2); text-align:center;
background:var(--surface-sunken); border-radius:var(--radius-sm); padding:6px 4px;
}
.milestone-time{ text-align:center; }
.band-track{
position:relative; height:6px; border-radius:999px; background:var(--surface-sunken);
}
.band-range{ position:absolute; top:0; bottom:0; background:var(--pass-bg); border-radius:999px; }
.band-marker{
position:absolute; top:50%; width:10px; height:10px; border-radius:50%;
background:var(--ink); border:2px solid var(--surface-raised); box-shadow:0 0 0 1px var(--ink-3);
transform:translate(-50%,-50%); display:none; transition:left .15s;
}
.band-marker.out-of-band{ background:var(--fail); box-shadow:0 0 0 1px var(--fail); }
@media (max-width:640px){
.milestone-row, .milestone-row.log-row{ grid-template-columns:1fr; gap:6px; }
.band-track{ order:3; }
}
/* ─── Actuator timeline ───────────────────────────────────────────────── */
.actuator-timeline{ display:flex; flex-direction:column; }
.actuator-step{ display:flex; gap:14px; position:relative; padding-bottom:16px; }
.actuator-step:last-child{ padding-bottom:0; }
.actuator-rail{ display:flex; flex-direction:column; align-items:center; flex:0 0 auto; }
.actuator-dot{
width:10px; height:10px; border-radius:50%; background:var(--ember); margin-top:10px; flex:0 0 auto;
}
.actuator-line{ width:1px; flex:1 1 auto; background:var(--line); margin-top:4px; }
.actuator-step:last-child .actuator-line{ display:none; }
.actuator-card{
flex:1 1 auto; background:var(--surface-sunken); border-radius:var(--radius-md); padding:12px 14px;
display:grid; grid-template-columns:70px 1fr 1fr auto; gap:10px; align-items:end;
}
.actuator-card .field-label{ font-size:10.5px; }
.actuator-why{ grid-column:1 / -1; }
.actuator-remove{
background:none; border:1px solid var(--line); color:var(--ink-3); width:28px; height:28px;
border-radius:var(--radius-sm); cursor:pointer; font-size:12px; align-self:start;
}
.actuator-remove:hover{ background:var(--fail-bg); color:var(--fail); border-color:var(--fail); }
@media (max-width:640px){ .actuator-card{ grid-template-columns:1fr 1fr; } }
/* ─── Dock: shared card chrome ───────────────────────────────────────── */
.dock-card{
background:var(--surface-raised); border:1px solid var(--line); border-radius:var(--radius-lg);
box-shadow:var(--shadow-card); padding:18px 20px;
}
.dock-card-head{ display:flex; align-items:center; justify-content:space-between; margin-bottom:12px; }
.dock-card-head h2{ font-size:13px; font-weight:700; text-transform:uppercase; letter-spacing:.05em; color:var(--ink-2); }
/* ─── Ledger ──────────────────────────────────────────────────────────── */
.ledger-group{ display:flex; flex-direction:column; }
.ledger-row{ display:flex; align-items:center; gap:8px; padding:6px 0; border-bottom:1px solid var(--line); }
.ledger-row:last-child{ border-bottom:none; }
.l-fid{ font-family:var(--font-mono); font-size:9.5px; color:var(--ink-3); background:var(--surface-sunken); border-radius:4px; padding:1px 4px; flex:0 0 auto; }
.l-label{ font-size:12.5px; color:var(--ink-2); flex:1 1 auto; }
.l-val{ font-family:var(--font-mono); font-size:13px; font-weight:600; color:var(--ink); text-align:right; min-width:52px; }
.ledger-input{
width:60px; text-align:right; font-family:var(--font-mono); font-size:13px; font-weight:600;
padding:4px 7px; border:1px solid var(--line); border-radius:var(--radius-sm); background:var(--surface-sunken); color:var(--ink);
}
.ledger-input:focus{ outline:none; border-color:var(--ember); box-shadow:0 0 0 3px var(--ember-soft); background:var(--surface-raised); }
.ledger-total{
display:flex; align-items:baseline; justify-content:space-between;
margin:10px 0; padding-top:8px; border-top:1.5px solid var(--ink);
}
.ledger-total span{ font-size:12px; font-weight:700; text-transform:uppercase; letter-spacing:.04em; color:var(--ink-2); }
.ledger-total output{ font-family:var(--font-serif); font-size:26px; font-weight:600; color:var(--ink); }
.ledger-total.drop{ border-top:none; margin-top:4px; padding-top:10px; background:var(--ember-soft); border-radius:var(--radius-md); padding:12px 14px; margin-bottom:0; }
.ledger-total.drop span{ color:var(--ember-strong); }
.ledger-total.drop output{ color:var(--ember-strong); font-size:30px; }
.ledger-info{
display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin:10px 0 16px;
padding:8px 10px; border-left:2px solid var(--line-strong); background:var(--surface-sunken); border-radius:0 var(--radius-sm) var(--radius-sm) 0;
}
.info-pill{ display:flex; align-items:center; gap:5px; font-size:11.5px; color:var(--ink-2); }
.info-pill span{ color:var(--ink-3); }
.info-pill output{ font-family:var(--font-mono); font-weight:600; }
.info-note{ font-size:10px; color:var(--ink-3); flex-basis:100%; }
/* ─── Sanity checks ───────────────────────────────────────────────────── */
.checks-grid{ display:grid; grid-template-columns:1fr 1fr; gap:10px; }
.check-tile{
border:1px solid var(--line); border-radius:var(--radius-md); padding:10px 12px;
display:flex; flex-direction:column; gap:2px; position:relative;
}
.check-name{ font-size:10.5px; color:var(--ink-3); font-weight:600; text-transform:uppercase; letter-spacing:.02em; }
.check-value{ font-family:var(--font-mono); font-size:18px; font-weight:700; color:var(--ink); }
.check-band{ font-size:10.5px; color:var(--ink-3); }
.check-tile::before{
content:""; position:absolute; top:10px; right:10px; width:8px; height:8px; border-radius:50%;
background:var(--ink-3);
}
.check-tile.pass::before{ background:var(--pass); }
.check-tile.fail::before{ background:var(--fail); }
.check-tile.pass{ border-color:var(--pass-bg); background:var(--pass-bg); }
.check-tile.fail{ border-color:var(--fail-bg); background:var(--fail-bg); }
.check-tile.pass .check-value{ color:var(--pass); }
.check-tile.fail .check-value{ color:var(--fail); }
/* ─── Curve card ──────────────────────────────────────────────────────── */
.curve-legend{ display:flex; align-items:center; gap:10px; font-size:10.5px; color:var(--ink-3); text-transform:none; letter-spacing:0; font-weight:500; }
.curve-legend .sw{ display:inline-block; width:12px; height:2px; margin-right:3px; vertical-align:middle; }
.sw-plan{ background:var(--ink); }
.sw-ref{ background:var(--ink-3); border-top:1px dashed var(--ink-3); background:none; height:0; border-bottom:1.5px dashed var(--ink-3); }
.curve-wrap{ border-radius:var(--radius-md); overflow:hidden; border:1px solid var(--line); }
/* ─── Print: hide the screen shell, show the paper worksheet ───────────── */
#print-sheet{ display:none; }
.pv{ display:none; }
@media print{
.app-header, .drawer, .drawer-overlay, .workspace-grid{ display:none !important; }
body{ background:#fff; }
#print-sheet{ display:block; }
.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:"✗ "; }
}
/* ─── Legacy .opt styling reused by the (hidden-on-screen) print worksheet's own radios ── */
.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:#f6efe7; border:.9pt solid #7a3b12; color:#7a3b12; font-weight:700; }
input.f, select.f{ border:none; border-bottom:.9pt solid #1a1512; background:transparent; font:inherit; height:4.2mm; padding:0 .5mm; color:#1a1512; }
+430 -31
View File
@@ -2,48 +2,447 @@
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Roast Planner</title>
<link rel="stylesheet" href="/worksheet.css">
<link rel="stylesheet" href="/worksheet.css" media="print">
<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>
<header class="app-header">
<div class="brand">
<span class="brand-mark" aria-hidden="true"></span>
<div class="brand-text">
<h1>Roast Planner</h1>
<p class="brand-sub" id="header-coffee-name">New plan</p>
</div>
</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>
<nav class="section-nav" id="section-nav" aria-label="Jump to section">
<a href="#sec-coffee">Coffee</a>
<a href="#sec-blend">Blend</a>
<a href="#sec-process">Roast target</a>
<a href="#sec-bean">Bean condition</a>
<a href="#sec-machine">Machine plan</a>
<a href="#sec-roastlog">Roast log</a>
<a href="#sec-after">After the roast</a>
</nav>
<div class="header-actions">
<button id="btn-toggle-fids" type="button" class="ghost-btn" aria-pressed="false" title="Show the worksheet reference codes (e.g. 1.4) on every field">Field IDs</button>
<button id="btn-toggle-prefill" type="button" class="ghost-btn">Prefill from URL</button>
<button id="btn-toggle-alog" type="button" class="ghost-btn">Reference curve</button>
<span class="header-divider" aria-hidden="true"></span>
<button id="btn-load" type="button" class="ghost-btn">Load</button>
<input id="file-load" type="file" accept="application/json">
<button id="btn-save" type="button" class="ghost-btn">Download</button>
<button id="btn-print" type="button" class="primary-btn">Print</button>
<span class="autosave-chip" id="autosave-status"><span class="dot"></span><span class="autosave-text">Not saved yet</span></span>
</div>
<ul class="lib-list hidden" id="alog-library-list"></ul>
<div id="alog-result"></div>
</header>
<div class="drawer-overlay hidden" id="drawer-overlay"></div>
<aside class="drawer hidden" id="panel-prefill" aria-hidden="true">
<div class="drawer-head">
<h3>Prefill from a bean product URL</h3>
<button class="icon-btn" type="button" data-close-drawer aria-label="Close"></button>
</div>
<div class="drawer-body">
<label class="stack-label" for="prefill-url">Product page URL</label>
<input type="url" id="prefill-url" class="text-input" placeholder="https://roaster.example/shop/some-coffee">
<label class="checkbox-row"><input type="checkbox" id="prefill-overwrite"> Overwrite already-filled fields</label>
<div class="drawer-actions">
<button id="prefill-go" type="button" class="primary-btn">Fetch &amp; prefill</button>
<button id="prefill-undo" type="button" class="ghost-btn" disabled>Undo prefill</button>
</div>
<div id="prefill-result" class="drawer-result"></div>
</div>
</aside>
<aside class="drawer hidden" id="panel-alog" aria-hidden="true">
<div class="drawer-head">
<h3>Attach an Artisan .alog reference curve</h3>
<button class="icon-btn" type="button" data-close-drawer aria-label="Close"></button>
</div>
<div class="drawer-body">
<label class="filebtn primary-btn">Choose .alog file<input type="file" id="alog-file" accept=".alog"></label>
<button id="alog-library-refresh" type="button" class="ghost-btn">Show local library</button>
<ul class="lib-list hidden" id="alog-library-list"></ul>
<div id="alog-result" class="drawer-result"></div>
</div>
</aside>
<form id="plan-form">
<div class="workspace-grid">
<div class="form-col">
<section class="panel-card" id="sec-coffee">
<div class="panel-head"><h2>The Coffee</h2></div>
<div class="panel-body">
<div class="field-grid">
<label class="field" data-fid="0.1"><span class="field-label">Coffee / lot</span><input class="field-input" name="0.1" placeholder="e.g. Finca El Ocaso"></label>
<label class="field" data-fid="0.2"><span class="field-label">Date</span><input class="field-input" type="date" name="0.2"></label>
<label class="field" data-fid="0.3"><span class="field-label">Batch #</span><input class="field-input" name="0.3"></label>
<label class="field" data-fid="0.4"><span class="field-label">Green in</span><div class="unit-input"><input class="field-input" name="0.4"><span class="unit">g</span></div></label>
</div>
<div class="subhead">Cultivar</div>
<div class="field-grid">
<label class="field" data-fid="1.1"><span class="field-label">Cultivar</span><input class="field-input" name="1.1" list="cultivar-list" placeholder="e.g. Caturra" autocomplete="off"></label>
<div class="field" data-fid="1.3"><span class="field-label">Origin</span><input class="field-input" name="1.3" placeholder="e.g. Huila, Colombia"></div>
<div class="field wide" data-fid="1.2">
<span class="field-label">Group</span>
<div class="segmented" data-radio-group="1.2">
<label class="seg-opt"><input type="radio" name="1.2" value="typica"><span>Typica</span></label>
<label class="seg-opt"><input type="radio" name="1.2" value="bourbon"><span>Bourbon</span></label>
<label class="seg-opt"><input type="radio" name="1.2" value="ethiopian"><span>Ethiopian</span></label>
<label class="seg-opt"><input type="radio" name="1.2" value="hybrid"><span>Hybrid</span></label>
</div>
</div>
</div>
<div class="field-grid">
<label class="field" data-fid="1.4"><span class="field-label">FC anchor <span class="hint" title="Feeds Time Ledger line 1"></span></span><input class="field-input" name="1.4" placeholder="8:45"></label>
<label class="field" data-fid="1.5"><span class="field-label">Profile mm:ss|mm:ss|mm:ss</span><input class="field-input" name="1.5" placeholder="4:15|3:15|1:30"></label>
<label class="field" data-fid="1.6"><span class="field-label">± Refine <span class="hint" title="0 on the first cook of this cultivar, otherwise carried from Step 12"></span></span><input class="field-input" name="1.6" placeholder="0"></label>
<label class="field" data-fid="1.7"><span class="field-label">± Dev modifier <span class="hint" title="Step 1's profile 3rd number, minus 1:30"></span></span><input class="field-input" name="1.7"></label>
</div>
</div>
</section>
<section class="panel-card" id="sec-blend">
<div class="panel-head">
<h2>Blend</h2>
<div class="segmented small" data-radio-group="2.1">
<label class="seg-opt"><input type="radio" name="2.1" value="single" checked><span>Single-origin</span></label>
<label class="seg-opt"><input type="radio" name="2.1" value="blend"><span>Blend</span></label>
</div>
</div>
<div class="panel-body" id="blend-body">
<div class="blend-cards" id="blend-cards"></div>
<div class="blend-total" id="blend-total">
<div class="blend-total-bar"><div class="blend-total-fill" id="blend-total-fill"></div></div>
<span class="blend-total-label" id="blend-total-label">0% of 100%</span>
</div>
<button type="button" class="ghost-btn small" id="btn-add-blend">+ Add component</button>
<div class="subhead">How the components combine</div>
<div class="field wide" data-fid="2.3">
<div class="segmented" data-radio-group="2.3">
<label class="seg-opt"><input type="radio" name="2.3" value="dominant"><span>Dominant (≥60%)</span></label>
<label class="seg-opt"><input type="radio" name="2.3" value="weighted"><span>Weighted average</span></label>
<label class="seg-opt"><input type="radio" name="2.3" value="split"><span>Split-roast, blend after</span></label>
</div>
</div>
<div class="field-grid">
<label class="field" data-fid="2.4"><span class="field-label">Resulting FC anchor</span><input class="field-input" name="2.4"></label>
<label class="field" data-fid="2.5"><span class="field-label">Resulting processing modifier</span><input class="field-input" name="2.5"></label>
</div>
</div>
</section>
<section class="panel-card" id="sec-process">
<div class="panel-head"><h2>Roast Target</h2></div>
<div class="panel-body">
<div class="field wide" data-fid="3.1">
<span class="field-label">Process</span>
<div class="chip-group" data-radio-group="3.1">
<label class="chip-opt"><input type="radio" name="3.1" value="washed"><span>Washed</span></label>
<label class="chip-opt"><input type="radio" name="3.1" value="yellow-honey"><span>Yellow / white honey</span></label>
<label class="chip-opt"><input type="radio" name="3.1" value="red-black-honey"><span>Red / black honey</span></label>
<label class="chip-opt"><input type="radio" name="3.1" value="natural"><span>Natural</span></label>
<label class="chip-opt"><input type="radio" name="3.1" value="anaerobic"><span>Anaerobic / CM</span></label>
</div>
</div>
<div class="field-grid">
<label class="field" data-fid="3.2"><span class="field-label">Dev modifier</span><input class="field-input" name="3.2"></label>
</div>
<div class="subhead">Roast level</div>
<div class="field wide" data-fid="4.1">
<div class="chip-group" data-radio-group="4.1">
<label class="chip-opt"><input type="radio" name="4.1" value="light"><span>Light</span></label>
<label class="chip-opt"><input type="radio" name="4.1" value="light-medium"><span>Light-medium</span></label>
<label class="chip-opt"><input type="radio" name="4.1" value="medium"><span>Medium</span></label>
<label class="chip-opt"><input type="radio" name="4.1" value="dark"><span>Dark</span></label>
<label class="chip-opt"><input type="radio" name="4.1" value="very-dark"><span>Very dark</span></label>
</div>
</div>
<div class="field-grid">
<label class="field" data-fid="4.2"><span class="field-label">Target weight loss</span><div class="unit-input"><input class="field-input" name="4.2"><span class="unit">%</span></div></label>
<label class="field" data-fid="4.3"><span class="field-label">Dev base</span><input class="field-input" name="4.3"></label>
</div>
</div>
</section>
<section class="panel-card" id="sec-bean">
<div class="panel-head"><h2>Bean Condition</h2></div>
<div class="panel-body">
<div class="field-grid">
<label class="field" data-fid="5.1"><span class="field-label">Moisture</span><div class="unit-input"><input class="field-input" name="5.1"><span class="unit">%</span></div></label>
<label class="field" data-fid="5.2"><span class="field-label">Density</span><div class="unit-input"><input class="field-input" name="5.2"><span class="unit">g/L</span></div></label>
<label class="field" data-fid="5.3"><span class="field-label">Altitude</span><input class="field-input" name="5.3"></label>
<label class="field" data-fid="5.4"><span class="field-label">Screen</span><input class="field-input" name="5.4"></label>
</div>
<div class="field-grid">
<div class="field" data-fid="5.5">
<span class="field-label">Sweet spot</span>
<div class="segmented" data-radio-group="5.5">
<label class="seg-opt"><input type="radio" name="5.5" value="narrow"><span>Narrow</span></label>
<label class="seg-opt"><input type="radio" name="5.5" value="med"><span>Medium</span></label>
<label class="seg-opt"><input type="radio" name="5.5" value="wide"><span>Wide</span></label>
</div>
</div>
<label class="field" data-fid="5.6"><span class="field-label">± Net correction <span class="hint" title="Judgment call — rarely more than ±0:15"></span></span><input class="field-input" name="5.6"></label>
</div>
</div>
</section>
<section class="panel-card" id="sec-machine">
<div class="panel-head"><h2>Machine Plan</h2></div>
<div class="panel-body">
<div class="subhead">Temperatures &amp; rate-of-rise</div>
<div class="milestone-list">
<div class="milestone-row band-row" data-band-lo="133" data-band-hi="195">
<div class="milestone-label">Charge</div>
<output class="milestone-plan" data-out="t-charge">0:00</output>
<div class="band-track"><div class="band-range"></div><div class="band-marker"></div></div>
<div class="unit-input sm"><input class="field-input sm" name="temps.charge.tempC" placeholder="°C"><span class="unit">°C</span></div>
</div>
<div class="milestone-row band-row" data-band-lo="77" data-band-hi="100">
<div class="milestone-label">Turning point</div>
<input class="field-input sm milestone-time" name="temps.tp.time" placeholder="m:ss">
<div class="band-track"><div class="band-range"></div><div class="band-marker"></div></div>
<div class="unit-input sm"><input class="field-input sm" name="temps.tp.tempC" placeholder="°C"><span class="unit">°C</span></div>
</div>
<div class="milestone-row band-row" data-band-lo="148" data-band-hi="166">
<div class="milestone-label">Yellow</div>
<output class="milestone-plan" data-out="t-yellow"></output>
<div class="band-track"><div class="band-range"></div><div class="band-marker"></div></div>
<div class="unit-input sm"><input class="field-input sm" name="temps.yellow.tempC" placeholder="°C"><span class="unit">°C</span></div>
</div>
<div class="milestone-row band-row" data-band-lo="176" data-band-hi="187">
<div class="milestone-label">First crack</div>
<output class="milestone-plan" data-out="t-fc"></output>
<div class="band-track"><div class="band-range"></div><div class="band-marker"></div></div>
<div class="unit-input sm"><input class="field-input sm" name="temps.fc.tempC" placeholder="°C"><span class="unit">°C</span></div>
</div>
<div class="milestone-row band-row" data-band-lo="183" data-band-hi="197">
<div class="milestone-label">Drop</div>
<output class="milestone-plan" data-out="t-drop"></output>
<div class="band-track"><div class="band-range"></div><div class="band-marker"></div></div>
<div class="unit-input sm"><input class="field-input sm" name="temps.drop.tempC" placeholder="°C"><span class="unit">°C</span></div>
</div>
</div>
<div class="subhead">Actuator schedule (planned)</div>
<div class="actuator-timeline" id="actuator-timeline"></div>
<button type="button" class="ghost-btn small" id="btn-add-actuator">+ Add step</button>
</div>
</section>
<section class="panel-card" id="sec-roastlog">
<div class="panel-head"><h2>Roast Log — Plan vs. Actual</h2></div>
<div class="panel-body">
<div class="milestone-list">
<div class="milestone-row log-row">
<div class="milestone-label">Charge</div>
<output class="milestone-plan" data-out="pa-charge">0:00</output>
<input class="field-input sm" name="planActual.charge.actualTime" placeholder="0:00">
<input class="field-input sm" name="planActual.charge.actualBt" placeholder="°C">
<input class="field-input note" name="planActual.charge.note" placeholder="Note">
</div>
<div class="milestone-row log-row">
<div class="milestone-label">Turning point</div>
<output class="milestone-plan" data-out="pa-tp"></output>
<input class="field-input sm" name="planActual.tp.actualTime" placeholder="0:00">
<input class="field-input sm" name="planActual.tp.actualBt" placeholder="°C">
<input class="field-input note" name="planActual.tp.note" placeholder="Note">
</div>
<div class="milestone-row log-row">
<div class="milestone-label">Yellow</div>
<output class="milestone-plan" data-out="pa-yellow"></output>
<input class="field-input sm" name="planActual.yellow.actualTime" placeholder="0:00">
<input class="field-input sm" name="planActual.yellow.actualBt" placeholder="°C">
<input class="field-input note" name="planActual.yellow.note" placeholder="Note">
</div>
<div class="milestone-row log-row">
<div class="milestone-label">First crack</div>
<output class="milestone-plan" data-out="pa-fc"></output>
<input class="field-input sm" name="planActual.fc.actualTime" placeholder="0:00">
<input class="field-input sm" name="planActual.fc.actualBt" placeholder="°C">
<input class="field-input note" name="planActual.fc.note" placeholder="Note">
</div>
<div class="milestone-row log-row">
<div class="milestone-label">Drop</div>
<output class="milestone-plan" data-out="pa-drop"></output>
<input class="field-input sm" name="planActual.drop.actualTime" placeholder="0:00">
<input class="field-input sm" name="planActual.drop.actualBt" placeholder="°C">
<input class="field-input note" name="planActual.drop.note" placeholder="Note">
</div>
</div>
</div>
</section>
<section class="panel-card" id="sec-after">
<div class="panel-head"><h2>After the Roast</h2></div>
<div class="panel-body">
<div class="field-grid">
<label class="field"><span class="field-label">Green in</span><div class="unit-input"><input class="field-input" name="afterRoast.greenIn"><span class="unit">g</span></div></label>
<label class="field"><span class="field-label">Out</span><div class="unit-input"><input class="field-input" name="afterRoast.out"><span class="unit">g</span></div></label>
<label class="field"><span class="field-label">Weight loss</span><div class="unit-input"><input class="field-input" name="afterRoast.weightLossPct"><span class="unit">%</span></div></label>
<label class="field"><span class="field-label">Actual DTR</span><div class="unit-input"><input class="field-input" name="afterRoast.actualDtrPct"><span class="unit">%</span></div></label>
</div>
<div class="field-grid">
<label class="field"><span class="field-label">Colour WB/GR</span><input class="field-input" name="afterRoast.colour"></label>
<label class="field"><span class="field-label">Rested</span><div class="unit-input"><input class="field-input" name="afterRoast.restedDays"><span class="unit">days</span></div></label>
<label class="field"><span class="field-label">Brew ratio</span><input class="field-input" name="afterRoast.brewRatio"></label>
<label class="field"><span class="field-label">Method</span><input class="field-input" name="afterRoast.method"></label>
</div>
<label class="field"><span class="field-label">Cup notes</span><input class="field-input" name="afterRoast.cupNotes"></label>
<div class="field-grid">
<label class="field accent"><span class="field-label">One change next batch</span><input class="field-input" name="afterRoast.oneChange"></label>
<label class="field"><span class="field-label">What would prove it wrong</span><input class="field-input" name="afterRoast.disproof"></label>
</div>
</div>
</section>
</div>
<aside class="dock">
<div class="dock-card ledger-card">
<div class="dock-card-head"><h2>Time Ledger</h2></div>
<div class="ledger-group">
<div class="ledger-row"><span class="l-fid">1.4</span><span class="l-label">Cultivar / blend anchor</span><output class="l-val" data-out="l1"></output></div>
<div class="ledger-row"><span class="l-fid">1.6</span><span class="l-label">± Refinement</span><output class="l-val" data-out="l2"></output></div>
<div class="ledger-row"><span class="l-fid">5.6</span><span class="l-label">± Bean condition</span><output class="l-val" data-out="l3"></output></div>
<div class="ledger-row"><span class="l-fid">6.4</span><span class="l-label">± Batch size</span><input class="ledger-input" name="6.4" placeholder="0"></div>
</div>
<div class="ledger-total hero"><span>First crack</span><output data-out="A"></output></div>
<div class="ledger-info">
<div class="info-pill"><span>Yellow</span><output data-out="yellow"></output></div>
<div class="info-pill"><span>Maillard</span><output data-out="maillard"></output></div>
<span class="info-note">informational — not summed into development</span>
</div>
<div class="ledger-group">
<div class="ledger-row"><span class="l-fid">4.3</span><span class="l-label">Development base</span><output class="l-val" data-out="l7"></output></div>
<div class="ledger-row"><span class="l-fid">3.2</span><span class="l-label">± Processing modifier</span><output class="l-val" data-out="l8"></output></div>
<div class="ledger-row"><span class="l-fid">1.7</span><span class="l-label">± Cultivar dev modifier</span><output class="l-val" data-out="l9"></output></div>
</div>
<div class="ledger-total hero"><span>Development</span><output data-out="C"></output></div>
<div class="ledger-total hero drop"><span>Drop (end of roast)</span><output data-out="D"></output></div>
</div>
<div class="dock-card checks-card">
<div class="dock-card-head"><h2>Sanity Checks</h2></div>
<div class="checks-grid">
<div class="check-tile unknown" data-pass="drying">
<span class="check-name">Drying share</span>
<output class="check-value" data-out="check-drying"></output>
<span class="check-band">target 4351%</span>
</div>
<div class="check-tile unknown" data-pass="maillard">
<span class="check-name">Maillard share</span>
<output class="check-value" data-out="check-maillard"></output>
<span class="check-band">target 3339%</span>
</div>
<div class="check-tile unknown" data-pass="dtr">
<span class="check-name">Development ratio</span>
<output class="check-value" data-out="check-dtr"></output>
<span class="check-band">target 1220%</span>
</div>
<div class="check-tile unknown" data-pass="ceiling">
<span class="check-name">Development ceiling</span>
<output class="check-value" data-out="check-ceiling"></output>
<span class="check-band">under 4:30</span>
</div>
</div>
</div>
<div class="dock-card curve-card">
<div class="dock-card-head"><h2>The Curve</h2><span class="curve-legend"><i class="sw sw-plan"></i>plan <i class="sw sw-ref"></i>reference</span></div>
<div class="curve-wrap">
<svg id="curve-svg-live" viewBox="0 0 714 334" width="100%" role="img" aria-label="Roast curve, live plan">
<defs><style>
#curve-svg-live .maj{stroke:var(--line);stroke-width:.7}
#curve-svg-live .min{stroke:var(--surface-sunken);stroke-width:.6}
#curve-svg-live .ax{stroke:var(--ink-3);stroke-width:1.1}
#curve-svg-live .al{font-size:8px;fill:var(--ink-3)}
#curve-svg-live .band{fill:var(--ember);opacity:.08}
#curve-svg-live .bandline{font-size:7.5px;fill:var(--ember)}
</style></defs>
<rect x="52" y="24" width="600" height="270" fill="var(--surface-raised)"/>
<rect class="band" x="52" y="65.4" width="600" height="19.8"/>
<text x="58" y="62.4" class="bandline">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>
<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>
<g id="plan-curve-live"></g>
<g id="ref-curve-live"></g>
</svg>
</div>
</div>
</aside>
</div>
</form>
<!-- Kept OUTSIDE #plan-form on purpose: identically-named radios here must not fight for
mutual exclusivity with the screen form's radios (same name + same form owner would
silently uncheck one another). This whole block is display:none except when printing;
main.js re-syncs it from state.plan on beforeprint. -->
<div id="print-sheet">
<div class="sheet-wrap">
<div class="sheet">
<form id="plan-form">
<!-- ══════════════════ FRONT ══════════════════ -->
<section class="first">
@@ -348,7 +747,7 @@
</div>
</section>
</form>
</div>
</div>
</div>
+269 -29
View File
@@ -9,11 +9,22 @@ import { initPrint } from "./print.js";
const FIELD_ID_SET = new Set(FIELD_IDS);
const STORAGE_KEY = "roastPlannerPlan.v1";
const BAND_DOMAIN = [60, 220]; // shared °C domain for every band-track in the Machine Plan section
export const state = { plan: loadFromStorage() ?? blankPlan() };
const form = document.getElementById("plan-form");
// The print worksheet (#print-sheet) sits outside #plan-form on purpose — see index.html —
// so its radios don't fight the screen form's identically-named radios for exclusivity.
// Every sync pass therefore has to reach both containers explicitly.
function allNamedInputs() {
return document.querySelectorAll(
"#plan-form input, #plan-form select, #plan-form textarea, " +
"#print-sheet input, #print-sheet select, #print-sheet textarea",
);
}
// ---- 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);
@@ -43,7 +54,10 @@ function setValueForName(name, value) {
}
// ---- dynamic rows: blend components + actuator schedule
function renderBlendRows() {
// Each renders into TWO places that share `name` attributes: the interactive screen
// component, and the hidden print-only worksheet table (#blend-rows / #actuator-rows),
// which is kept in sync by renderFormFromPlan() and only needs to look right on paper.
function renderBlendPrintRows() {
const tbody = document.getElementById("blend-rows");
tbody.innerHTML = "";
state.plan.blendComponents.forEach((_, i) => {
@@ -59,7 +73,56 @@ function renderBlendRows() {
});
}
function renderActuatorRows() {
function renderBlendCards() {
const wrap = document.getElementById("blend-cards");
wrap.innerHTML = "";
state.plan.blendComponents.forEach((_, i) => {
const card = document.createElement("div");
card.className = "blend-card";
card.innerHTML = `
<label class="field"><span class="field-label">Cultivar</span><input class="field-input" name="blendComponents.${i}.cultivar" placeholder="e.g. Caturra"></label>
<label class="field"><span class="field-label">Group</span><input class="field-input" name="blendComponents.${i}.group"></label>
<label class="field"><span class="field-label">Process</span><input class="field-input" name="blendComponents.${i}.process"></label>
<label class="field"><span class="field-label">Share %</span><input class="field-input sm" name="blendComponents.${i}.sharePct"></label>
<label class="field"><span class="field-label">FC anchor</span><input class="field-input sm" name="blendComponents.${i}.fcAnchor"></label>
<button type="button" class="blend-remove" data-remove-blend="${i}" aria-label="Remove component" ${state.plan.blendComponents.length <= 1 ? "disabled" : ""}>✕</button>
`;
wrap.appendChild(card);
});
for (const btn of wrap.querySelectorAll("[data-remove-blend]")) {
btn.addEventListener("click", () => {
const i = Number(btn.dataset.removeBlend);
if (state.plan.blendComponents.length <= 1) return;
state.plan.blendComponents.splice(i, 1);
renderBlend();
renderFormFromPlan();
recompute();
});
}
}
function updateBlendTotal() {
const total = state.plan.blendComponents.reduce((sum, c) => sum + (Number.parseFloat(c.sharePct) || 0), 0);
const fill = document.getElementById("blend-total-fill");
const label = document.getElementById("blend-total-label");
if (!fill || !label) return;
fill.style.width = `${Math.min(100, total)}%`;
fill.classList.toggle("over", total > 100);
fill.classList.toggle("under", total > 0 && total < 100);
label.textContent = `${Math.round(total * 10) / 10}% of 100%`;
}
function updateBlendVisibility(mode) {
document.getElementById("blend-body").classList.toggle("collapsed", mode !== "blend");
}
function renderBlend() {
renderBlendPrintRows();
renderBlendCards();
updateBlendTotal();
}
function renderActuatorPrintRows() {
const tbody = document.getElementById("actuator-rows");
tbody.innerHTML = "";
state.plan.actuators.forEach((_, i) => {
@@ -75,9 +138,45 @@ function renderActuatorRows() {
});
}
function renderActuatorTimeline() {
const wrap = document.getElementById("actuator-timeline");
wrap.innerHTML = "";
state.plan.actuators.forEach((_, i) => {
const step = document.createElement("div");
step.className = "actuator-step";
step.innerHTML = `
<div class="actuator-rail"><div class="actuator-dot"></div><div class="actuator-line"></div></div>
<div class="actuator-card">
<label class="field"><span class="field-label">Time</span><input class="field-input sm" name="actuators.${i}.time" placeholder="m:ss"></label>
<label class="field"><span class="field-label">Heat %</span><input class="field-input sm" name="actuators.${i}.heatPct"></label>
<label class="field"><span class="field-label">Fan %</span><input class="field-input sm" name="actuators.${i}.fanPct"></label>
<button type="button" class="actuator-remove" data-remove-actuator="${i}" aria-label="Remove step" ${state.plan.actuators.length <= 1 ? "disabled" : ""}>✕</button>
<label class="field"><span class="field-label">Expected BT</span><input class="field-input sm" name="actuators.${i}.expectedBt"></label>
<label class="field actuator-why"><span class="field-label">Why this change</span><input class="field-input" name="actuators.${i}.why" placeholder="What you're watching for"></label>
</div>
`;
wrap.appendChild(step);
});
for (const btn of wrap.querySelectorAll("[data-remove-actuator]")) {
btn.addEventListener("click", () => {
const i = Number(btn.dataset.removeActuator);
if (state.plan.actuators.length <= 1) return;
state.plan.actuators.splice(i, 1);
renderActuators();
renderFormFromPlan();
recompute();
});
}
}
function renderActuators() {
renderActuatorPrintRows();
renderActuatorTimeline();
}
// ---- populate every control in the form from state.plan
export function renderFormFromPlan() {
for (const el of form.querySelectorAll("input, select, textarea")) {
for (const el of allNamedInputs()) {
const name = el.name;
if (!name) continue;
const value = valueForName(name);
@@ -89,11 +188,12 @@ export function renderFormFromPlan() {
el.value = value ?? "";
}
}
updateBlendVisibility(state.plan.fields["2.1"]);
document.getElementById("header-coffee-name").textContent = state.plan.fields["0.1"] || "New plan";
}
function fmtOut(id, text) {
const el = document.querySelector(`[data-out="${id}"]`);
if (el) el.textContent = text ?? "—";
for (const el of document.querySelectorAll(`[data-out="${id}"]`)) el.textContent = text ?? "—";
}
function renderLedger() {
@@ -120,10 +220,10 @@ function renderLedger() {
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");
for (const cell of document.querySelectorAll(`[data-pass="${key}"]`)) {
cell.classList.remove("pass", "fail", "unknown");
cell.classList.add(check.pass === null ? "unknown" : check.pass ? "pass" : "fail");
}
}
// Box 8 / box 11 derived time columns
@@ -138,33 +238,67 @@ function renderLedger() {
fmtOut("pa-drop", d(ledger.D));
document.getElementById("back-coffee-name").textContent = state.plan.fields["0.1"] || "";
document.getElementById("header-coffee-name").textContent = state.plan.fields["0.1"] || "New plan";
return ledger;
}
function renderCurve(ledger) {
const planPoints = buildPlanCurve(state.plan, ledger);
const planGroup = document.getElementById("plan-curve");
function domainPct(v) {
const [lo, hi] = BAND_DOMAIN;
return Math.max(0, Math.min(100, ((v - lo) / (hi - lo)) * 100));
}
function renderBandRanges() {
for (const row of document.querySelectorAll(".band-row")) {
const lo = Number(row.dataset.bandLo);
const hi = Number(row.dataset.bandHi);
const range = row.querySelector(".band-range");
if (!range) continue;
const loPct = domainPct(lo);
range.style.left = `${loPct}%`;
range.style.width = `${domainPct(hi) - loPct}%`;
}
}
function renderBandMarkers() {
for (const row of document.querySelectorAll(".band-row")) {
const input = row.querySelector('input[name$=".tempC"]');
const marker = row.querySelector(".band-marker");
if (!input || !marker) continue;
const v = Number.parseFloat(input.value);
if (!Number.isFinite(v)) {
marker.style.display = "none";
continue;
}
const lo = Number(row.dataset.bandLo);
const hi = Number(row.dataset.bandHi);
marker.style.display = "block";
marker.style.left = `${domainPct(v)}%`;
marker.classList.toggle("out-of-band", v < lo || v > hi);
}
}
function paintCurveInto(planGroup, refGroup, planPoints, ref) {
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", "currentColor");
path.setAttribute("stroke-width", "1.6");
path.style.color = "var(--ink, #1a1512)";
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");
c.setAttribute("fill", "currentColor");
c.style.color = "var(--ink, #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");
@@ -173,10 +307,11 @@ function renderCurve(ledger) {
.join(" ");
path.setAttribute("d", d);
path.setAttribute("fill", "none");
path.setAttribute("stroke", "#5a5048");
path.setAttribute("stroke", "currentColor");
path.setAttribute("stroke-width", "1.3");
path.setAttribute("stroke-dasharray", "4 3");
path.setAttribute("opacity", ".65");
path.style.color = "var(--ink-2, #5a5048)";
refGroup.appendChild(path);
for (const m of ref.milestones ?? []) {
if (m.tempC === null) continue;
@@ -185,27 +320,64 @@ function renderCurve(ledger) {
c.setAttribute("cy", tempToY(m.tempC).toFixed(1));
c.setAttribute("r", "2.6");
c.setAttribute("fill", "none");
c.setAttribute("stroke", "#5a5048");
c.setAttribute("stroke", "currentColor");
c.setAttribute("stroke-width", "1.2");
c.style.color = "var(--ink-2, #5a5048)";
refGroup.appendChild(c);
}
}
}
function renderCurve(ledger) {
const planPoints = buildPlanCurve(state.plan, ledger);
const ref = state.plan.reference;
paintCurveInto(
document.getElementById("plan-curve"),
document.getElementById("ref-curve"),
planPoints,
ref,
);
paintCurveInto(
document.getElementById("plan-curve-live"),
document.getElementById("ref-curve-live"),
planPoints,
ref,
);
}
export function recompute() {
const ledger = renderLedger();
renderCurve(ledger);
renderBandMarkers();
autosave();
}
let autosaveAgeTimer = null;
function autosave() {
const chip = document.getElementById("autosave-status");
const text = chip.querySelector(".autosave-text");
chip.classList.remove("saved");
chip.classList.add("saving");
text.textContent = "Saving…";
clearTimeout(autosave._t);
autosave._t = setTimeout(() => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state.plan));
document.getElementById("autosave-status").textContent = `saved ${new Date().toLocaleTimeString()}`;
const savedAt = Date.now();
chip.classList.remove("saving");
chip.classList.add("saved");
const tick = () => {
const secs = Math.round((Date.now() - savedAt) / 1000);
text.textContent = secs < 8 ? "Saved just now" : secs < 60 ? `Saved ${secs}s ago` : `Saved ${Math.round(secs / 60)}m ago`;
};
tick();
clearInterval(autosaveAgeTimer);
autosaveAgeTimer = setInterval(tick, 5000);
} catch {
/* storage unavailable — non-fatal */
chip.classList.remove("saving", "saved");
text.textContent = "Save unavailable";
}
}, 400);
}
@@ -241,17 +413,49 @@ function wireForm() {
if (el.type === "radio" && !el.checked) return;
setValueForName(el.name, el.value);
if (el.name === "1.1") cultivarAutofill(el.value);
if (el.name === "2.1") updateBlendVisibility(el.value);
if (el.name.startsWith("blendComponents.")) updateBlendTotal();
recompute();
});
}
function wireToolbar() {
function wireDrawers() {
const overlay = document.getElementById("drawer-overlay");
const prefill = document.getElementById("panel-prefill");
const alog = document.getElementById("panel-alog");
function open(panel) {
for (const p of [prefill, alog]) p.classList.add("hidden");
panel.classList.remove("hidden");
panel.setAttribute("aria-hidden", "false");
overlay.classList.remove("hidden");
}
function closeAll() {
for (const p of [prefill, alog]) {
p.classList.add("hidden");
p.setAttribute("aria-hidden", "true");
}
overlay.classList.add("hidden");
}
document.getElementById("btn-toggle-prefill").addEventListener("click", () => {
document.getElementById("panel-prefill").classList.toggle("hidden");
prefill.classList.contains("hidden") ? open(prefill) : closeAll();
});
document.getElementById("btn-toggle-alog").addEventListener("click", () => {
document.getElementById("panel-alog").classList.toggle("hidden");
alog.classList.contains("hidden") ? open(alog) : closeAll();
});
overlay.addEventListener("click", closeAll);
for (const btn of document.querySelectorAll("[data-close-drawer]")) {
btn.addEventListener("click", closeAll);
}
}
function wireToolbar() {
document.getElementById("btn-toggle-fids").addEventListener("click", (e) => {
const on = document.body.classList.toggle("show-fids");
e.currentTarget.setAttribute("aria-pressed", String(on));
});
document.getElementById("btn-save").addEventListener("click", () => {
const blob = new Blob([JSON.stringify(state.plan, null, 2)], { type: "application/json" });
const a = document.createElement("a");
@@ -267,26 +471,62 @@ function wireToolbar() {
try {
const loaded = JSON.parse(await file.text());
state.plan = { ...blankPlan(), ...loaded };
renderBlendRows();
renderActuatorRows();
renderBlend();
renderActuators();
renderFormFromPlan();
recompute();
} catch (err) {
alert(`Could not load plan: ${err.message}`);
}
});
document.getElementById("btn-add-blend").addEventListener("click", () => {
state.plan.blendComponents.push({ cultivar: "", group: "", process: "", sharePct: "", fcAnchor: "" });
renderBlend();
renderFormFromPlan();
recompute();
});
document.getElementById("btn-add-actuator").addEventListener("click", () => {
state.plan.actuators.push({ time: "", heatPct: "", fanPct: "", expectedBt: "", why: "" });
renderActuators();
renderFormFromPlan();
recompute();
});
}
function wireSectionNav() {
const links = [...document.querySelectorAll(".section-nav a")];
const sections = links
.map((a) => document.querySelector(a.getAttribute("href")))
.filter(Boolean);
if (sections.length === 0) return;
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
const id = `#${entry.target.id}`;
for (const a of links) a.classList.toggle("active", a.getAttribute("href") === id);
}
},
{ rootMargin: "-20% 0px -70% 0px" },
);
for (const s of sections) observer.observe(s);
}
function init() {
renderBlendRows();
renderActuatorRows();
renderBlend();
renderActuators();
wireCultivarDatalist();
renderBandRanges();
renderFormFromPlan();
wireForm();
wireDrawers();
wireToolbar();
initPrefillPanel({ state, renderBlendRows, renderActuatorRows, renderFormFromPlan, recompute });
wireSectionNav();
initPrefillPanel({ state, renderBlendRows: renderBlend, renderActuatorRows: renderActuators, renderFormFromPlan, recompute });
initAlogPanel({ state, recompute });
initPrint({ form });
initPrint({ beforePrint: renderFormFromPlan });
recompute();
}
+17 -7
View File
@@ -1,22 +1,32 @@
// 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.
//
// Operates on #print-sheet (not the passed-in screen `form`): the print worksheet lives
// outside #plan-form on purpose, so its inputs are addressed by container id instead.
export function initPrint({ form }) {
export function initPrint({ beforePrint }) {
document.getElementById("btn-print").addEventListener("click", () => window.print());
window.addEventListener("beforeprint", () => bakeValues(form));
window.addEventListener("afterprint", () => clearBaked(form));
window.addEventListener("beforeprint", () => {
beforePrint?.(); // re-sync the print worksheet's inputs from state.plan before baking
bakeValues();
});
window.addEventListener("afterprint", () => clearBaked());
}
function bakeValues(form) {
for (const el of form.querySelectorAll("input.ws-input, textarea.ws-input")) {
function printSheet() {
return document.getElementById("print-sheet");
}
function bakeValues() {
for (const el of printSheet().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 = "";
function clearBaked() {
for (const pv of printSheet().querySelectorAll(".pv")) pv.textContent = "";
}