formlab-mcp 0.6.50 → 0.6.52

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -30,7 +30,7 @@ Ask Claude (or another MCP client) things like:
30
30
  | Tool | Purpose |
31
31
  |---|---|
32
32
  | `list_formulations` | Filtered list of recipes |
33
- | `get_formulation` | Full record + flattened wt-% composition (sub-formulas expanded), the ordered **step procedure**, user **custom fields**, any **process-factor values** (DOE inputs — cure temp, mix time, RPM, pH…), the declared **finishedDensity** (g/mL of the mixed product; volume-targeted batches scale by it), and its **theoretical parameters computed** the way the app's Params tab does (Energy kcal/100g, NV %, Cost/kg…: value, unit, formula, spec pass/fail, and a `warning` when ingredients lack a value or carry mixed units) — the app's own engine, vendored in `mcp/vendor/` and run in an isolated `node:vm` context |
33
+ | `get_formulation` | Full record (incl. `versionLabel`, the label the user types, e.g. 1.0) + **version history** (`versionHistory`: one snapshot per save, v1, v2…, newest first, with the label each save had and its note) + flattened wt-% composition (sub-formulas expanded), the ordered **step procedure**, user **custom fields**, any **process-factor values** (DOE inputs — cure temp, mix time, RPM, pH…), the declared **finishedDensity** (g/mL of the mixed product; volume-targeted batches scale by it), and its **theoretical parameters computed** the way the app's Params tab does (Energy kcal/100g, NV %, Cost/kg…: value, unit, formula, spec pass/fail, and a `warning` when ingredients lack a value or carry mixed units) — the app's own engine, vendored in `mcp/vendor/` and run in an isolated `node:vm` context |
34
34
  | `find_similar_formulations` | Find formulas using a given ingredient ≥ threshold % |
35
35
  | `compare_formulations` | Pairwise side-by-side composition diff |
36
36
  | `list_ingredients` | Filtered list of raw materials. Filters: `family`, `supplier`, `name_contains`, `in_stock_only`, `ingredient_class` (small-molecule / surfactant / polymer / extract / fragrance / pigment / sequence / mixture), `sequence_contains` (e.g. `KTTKS` → Matrixyl), `taxon_contains` (e.g. `Centella`) |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "formlab-mcp",
3
- "version": "0.6.50",
3
+ "version": "0.6.52",
4
4
  "mcpName": "io.github.juliu1980/formlab-mcp",
5
5
  "description": "Read-only Model Context Protocol server for FormLab \u2014 lets Claude (and other MCP clients) read and analyze your FormLab data, from a local export file OR your live cloud workspace.",
6
6
  "type": "module",
package/server.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
3
  "name": "io.github.juliu1980/formlab-mcp",
4
4
  "description": "Read-only MCP for FormLab — let Claude query your formulation lab. Free reads a local export; Pro connects to your live cloud workspace with a dedicated read-only token.",
5
- "version": "0.6.50",
5
+ "version": "0.6.52",
6
6
  "websiteUrl": "https://formvix.com/mcp",
7
7
  "repository": {
8
8
  "url": "https://github.com/juliu1980/FormLab",
@@ -13,7 +13,7 @@
13
13
  {
14
14
  "registryType": "npm",
15
15
  "identifier": "formlab-mcp",
16
- "version": "0.6.50",
16
+ "version": "0.6.52",
17
17
  "runtimeHint": "npx",
18
18
  "transport": {
19
19
  "type": "stdio"
@@ -1073,6 +1073,15 @@ function _trimDoeDesign(d, db) {
1073
1073
  factors: (d.factors || []).map(f => ({ ingredient: f.name || nameOf(_doeFactorId(f)), low: f.low, high: f.high, unit: f.unit || '%' })),
1074
1074
  constraints: (d.constraints || []).map(c => _doeConstraintText(c, nameOf)),
1075
1075
  mixtureMode: !!d.mixtureMode,
1076
+ // A design on a base formula (app 21r): every run is that recipe with the factors
1077
+ // changed, the other rows held, and the balance row filling it to 100%.
1078
+ baseFormula: (() => {
1079
+ const st = d.settings || {};
1080
+ if (!st.baseFormulaId) return null;
1081
+ const bf = (db.formulations || []).find(x => x.id === st.baseFormulaId);
1082
+ return bf ? (bf.name || bf.uid) : '(deleted formula)';
1083
+ })(),
1084
+ baseBalance: (d.settings || {}).baseBalanceId ? nameOf(d.settings.baseBalanceId) : null,
1076
1085
  // The model the design was built for. Lives in settings, so without this a
1077
1086
  // caller listing designs can see "D-optimal" but not whether it was fitted
1078
1087
  // as a process or a Scheffé (mixture) model — which changes what the runs
@@ -1143,7 +1152,7 @@ const list_doe_designs = {
1143
1152
  const get_doe_design = {
1144
1153
  definition: {
1145
1154
  name: 'get_doe_design',
1146
- description: 'Get one saved DOE design in full: factors and ranges, constraints, the model it was optimized for, generation settings (run budget, replicates, center points, seed), D-efficiency, and the complete run matrix with the formulation each run became. Everything needed to reproduce or audit the design.',
1155
+ description: 'Get one saved DOE design in full: factors and ranges, constraints, the model it was optimized for, the base formula it was built on (if any) and its balance, generation settings (run budget, replicates, center points, axial placement, seed), D-efficiency, and the complete run matrix with the formulation each run became. Everything needed to reproduce or audit the design.',
1147
1156
  inputSchema: {
1148
1157
  type: 'object',
1149
1158
  properties: {
@@ -1174,7 +1183,12 @@ const get_doe_design = {
1174
1183
  }
1175
1184
  }
1176
1185
  if (d.designType === 'lhs') { settings.runBudget = st.nRuns; settings.randomSeed = st.seed; }
1177
- if (d.designType === 'ccd') { settings.centerPoints = st.centerPoints; settings.axialDistance = st.alpha; }
1186
+ if (d.designType === 'ccd') {
1187
+ settings.centerPoints = st.centerPoints; settings.axialDistance = st.alpha;
1188
+ // Where the axial points sit (app 21r): outside low–high by α (classic,
1189
+ // circumscribed) or at low/high with the cube moved in (inscribed).
1190
+ settings.axialPoints = st.ccdInscribed ? 'inside low–high (inscribed)' : 'outside low–high by α (circumscribed)';
1191
+ }
1178
1192
  if (d.designType === 'bbd') settings.centerPoints = st.centerPoints;
1179
1193
  if (d.designType === 'simplex') settings.latticeDegree = st.simplexDegree;
1180
1194
 
@@ -1193,6 +1207,9 @@ const get_doe_design = {
1193
1207
  ? 'No balance factor: this design places its runs on the blend simplex directly, so every component is designed and each run already sums to the mixture total. Nothing has to absorb the remainder.'
1194
1208
  : undefined,
1195
1209
  settings,
1210
+ baseFormulaNote: base.baseFormula
1211
+ ? `Built on the base formula "${base.baseFormula}": each run is that recipe with the factors set to the run's levels, every other row kept at its amount${base.baseBalance ? `, and ${base.baseBalance} adjusted so the run totals 100% (the "balance" column in runs)` : ' (no balance, so run totals vary)'}.`
1212
+ : undefined,
1196
1213
  augmentedFromProjectId: d.augmentedFromProjectId || null,
1197
1214
  formulationsGenerated: (d.formulationIds || []).length,
1198
1215
  formulationsStillPresent: linked.length,
@@ -1209,6 +1226,9 @@ const get_doe_design = {
1209
1226
  out.runs = (d.runs || []).map((r, i) => {
1210
1227
  const row = { run: i + 1 };
1211
1228
  facs.forEach(f => { row[f.name || nameOf(_doeFactorId(f))] = r[_doeFactorId(f)]; });
1229
+ // On a base, the balance row's amount for this run (it is not a factor).
1230
+ const _bal = (d.settings || {}).baseBalanceId;
1231
+ if (_bal && r[_bal] != null) row.balance = r[_bal];
1212
1232
  const lf = formsById.get((d.formulationIds || [])[i]);
1213
1233
  row.formulation = lf ? (lf.name || lf.uid) : null;
1214
1234
  return row;
@@ -19,6 +19,9 @@ function _trimFormulation(f) {
19
19
  name: f.name,
20
20
  description: f.description || '',
21
21
  status: f.status || 'Draft',
22
+ // The Version LABEL the user types (1.0, 2.1-alpha) — not the save count;
23
+ // the automatic history is get_formulation's versionHistory.
24
+ versionLabel: f.version != null ? String(f.version) : '',
22
25
  family: f.family || '',
23
26
  application: f.application || '',
24
27
  owner: f.owner || '',
@@ -83,7 +86,7 @@ const list_formulations = {
83
86
  const get_formulation = {
84
87
  definition: {
85
88
  name: 'get_formulation',
86
- description: 'Get full details for a single formulation including its composition (raw rows AND flattened wt-% leaves with sub-formulas expanded), step procedure, any recorded process-factor values (cure temp, mix time, RPM, pH — DOE inputs), and recent batches. Accepts either internal id or UID (e.g. FORM-001). Also returns `theoreticalParameters`: values computed from the composition the way the app Params tab does (e.g. Energy kcal/100g, NV %, Cost/kg) — each with name, value, unit, formula, spec + pass, and `warning` when ingredients lack a value the formula reads or carry mixed units (those rows are not counted). Quote these; do not recompute nutrition or NV yourself.',
89
+ description: 'Get full details for a single formulation including its composition (raw rows AND flattened wt-% leaves with sub-formulas expanded), step procedure, any recorded process-factor values (cure temp, mix time, RPM, pH — DOE inputs), and recent batches. Accepts either internal id or UID (e.g. FORM-001). Also returns `theoreticalParameters`: values computed from the composition the way the app Params tab does (e.g. Energy kcal/100g, NV %, Cost/kg) — each with name, value, unit, formula, spec + pass, and `warning` when ingredients lack a value the formula reads or carry mixed units (those rows are not counted). Quote these; do not recompute nutrition or NV yourself. `versionLabel` is the label the user types (e.g. 1.0); `versionHistory` is the automatic save history (v1, v2… one per save, newest first, up to 20, with the label each save had and its note) — two different things.',
87
90
  inputSchema: {
88
91
  type: 'object',
89
92
  properties: {
@@ -134,6 +137,20 @@ const get_formulation = {
134
137
  processFactors: (f.factorValues || []).filter(x => x && x.name).map(x => ({ name: x.name, value: x.value != null ? x.value : '', unit: x.unit || '' })),
135
138
  // Computed by the app's own engine (mcp/theoretical.js → mcp/vendor/).
136
139
  theoreticalParameters: theoreticalParams(db, 'formulation', f.id),
140
+ // Automatic history: one snapshot per save (v1, v2…), newest first,
141
+ // with the Version label each save had. Independent of versionLabel.
142
+ versionHistory: (() => {
143
+ const vs = (db.formulaVersions || []).filter(v => v && v.formulationId === f.id)
144
+ .sort((a, b) => (b.versionNumber || 0) - (a.versionNumber || 0));
145
+ return {
146
+ count: vs.length,
147
+ versions: vs.slice(0, 20).map(v => ({
148
+ version: v.versionNumber, label: (v.snapshot && v.snapshot.version != null) ? String(v.snapshot.version) : '',
149
+ savedAt: v.snapshotAt || null, savedBy: v.snapshotBy || '', note: v.changeNote || '',
150
+ ingredientRows: ((v.snapshot && v.snapshot.composition) || []).length, stepCount: ((v.snapshot && v.snapshot.steps) || []).length,
151
+ })),
152
+ };
153
+ })(),
137
154
  batchCount: batches.length,
138
155
  sampleCount: samples.length,
139
156
  };
package/vendor/cost.js CHANGED
@@ -177,6 +177,9 @@ function getQuantityUnits() {
177
177
  if (typeof document === 'undefined') return;
178
178
  var POP_ID = 'fl-unit-combo-pop';
179
179
  var activeInput = null, activeIdx = -1;
180
+ // True only while choose() hands focus back to the field: that focus must not
181
+ // reopen the list the pick just closed (it did — the list stayed open, 19e).
182
+ var refocusing = false;
180
183
  // Show the in-popup search box once a list has more than this many units.
181
184
  var SEARCH_THRESHOLD = 8;
182
185
 
@@ -283,7 +286,9 @@ function getQuantityUnits() {
283
286
  inp.dispatchEvent(new Event('input', { bubbles: true }));
284
287
  inp.dispatchEvent(new Event('change', { bubbles: true }));
285
288
  hide();
289
+ refocusing = true;
286
290
  try { inp.focus(); } catch (_) {}
291
+ refocusing = false;
287
292
  }
288
293
  function highlight(i) {
289
294
  var its = items(); if (!its.length) { activeIdx = -1; return; }
@@ -303,7 +308,10 @@ function getQuantityUnits() {
303
308
  } else if (e.key === 'Escape') { hide(); if (activeInput) try { activeInput.focus(); } catch (_) {} }
304
309
  }
305
310
 
306
- document.addEventListener('focusin', function (e) { if (isCombo(e.target)) open(e.target); });
311
+ document.addEventListener('focusin', function (e) { if (isCombo(e.target) && !refocusing) open(e.target); });
312
+ // A click on the field reopens the list after a pick (the field kept focus,
313
+ // so no focusin fires). Capture phase: dialogs stop click propagation.
314
+ document.addEventListener('click', function (e) { if (isCombo(e.target) && !isOpen()) open(e.target); }, true);
307
315
  // Typing directly in the field filters too (only matters when there's no
308
316
  // search box — i.e. short lists; otherwise focus is in the search box).
309
317
  document.addEventListener('input', function (e) {
@@ -898,7 +906,7 @@ function displayCurrencyControlHtml(reRenderJs, betweenHtml) {
898
906
  return `<select class="cost-ccy-select" onclick="event.stopPropagation()" `
899
907
  + `title="Display currency for all cost figures, live FX" `
900
908
  + `onchange="setDisplayCurrency(this.value);${after}" `
901
- + `style="padding:3px 6px;border:1px solid var(--border);border-radius:5px;font-size:12px;font-family:inherit;background:#fff">${opts}</select>`
909
+ + `style="padding:3px 6px;border:1px solid var(--border);border-radius:5px;font-size:12px;font-family:inherit;background:var(--dk-surface, #fff)">${opts}</select>`
902
910
  + (betweenHtml || '')
903
911
  + `<span class="cost-ccy-fxhint" style="font-size:10.5px;color:var(--text-muted);margin-left:6px;white-space:nowrap">${fxAsOfHint()}</span>`;
904
912
  }
@@ -956,7 +964,7 @@ function displayCostUnitControlHtml(cost, density, densityUnit, reRenderJs) {
956
964
  return `<select class="cost-ccy-select cost-unit-select" onclick="event.stopPropagation()" `
957
965
  + `title="Show the price per this unit (the entered price is unchanged)" `
958
966
  + `onchange="setDisplayCostUnit(this.value);${after}" `
959
- + `style="padding:3px 6px;border:1px solid var(--border);border-radius:5px;font-size:12px;font-family:inherit;background:#fff">${opts}</select>`;
967
+ + `style="padding:3px 6px;border:1px solid var(--border);border-radius:5px;font-size:12px;font-family:inherit;background:var(--dk-surface, #fff)">${opts}</select>`;
960
968
  }
961
969
 
962
970
  // ============================================================
@@ -1655,16 +1663,16 @@ function effectiveBatchAmount(batch) {
1655
1663
  function deviationTier(pct) {
1656
1664
  const p = Math.abs(parseFloat(pct));
1657
1665
  if (!isFinite(p)) return { tier: 'none', color: '#94a3b8', bg: 'transparent', label: '—' };
1658
- if (p < 2) return { tier: 'on', color: '#15803d', bg: '#f0fdf4', label: 'on-target' };
1659
- if (p < 10) return { tier: 'drift', color: '#b45309', bg: '#fffbeb', label: 'drift' };
1660
- return { tier: 'off', color: '#b91c1c', bg: '#fef2f2', label: 'off-target' };
1666
+ if (p < 2) return { tier: 'on', color: 'var(--dk-green-fg, #15803d)', bg: 'var(--dk-green-bg, #f0fdf4)', label: 'on-target' };
1667
+ if (p < 10) return { tier: 'drift', color: 'var(--dk-amber-fg, #b45309)', bg: 'var(--dk-amber-bg, #fffbeb)', label: 'drift' };
1668
+ return { tier: 'off', color: 'var(--dk-red-fg, #b91c1c)', bg: 'var(--dk-red-bg, #fef2f2)', label: 'off-target' };
1661
1669
  }
1662
1670
 
1663
1671
  // Plain-text tooltip + inline color legend for the deviation tiers.
1664
1672
  const DEVIATION_TIP = 'Δ vs plan = worst-case wt-% drift of the as-prepared composition from the proposal. Colors: under 2% on-target (green), 2–10% drift (amber), 10%+ off-target (red).';
1665
1673
  function deviationLegendHtml() {
1666
1674
  const dot = c => `<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:${c};vertical-align:middle;margin-right:3px"></span>`;
1667
- return `<span title="${DEVIATION_TIP}" style="font-size:10.5px;color:#64748b;white-space:nowrap">Δ vs plan: ${dot('#15803d')}&lt;2% ${dot('#b45309')}2–10% ${dot('#b91c1c')}≥10%</span>`;
1675
+ return `<span title="${DEVIATION_TIP}" style="font-size:10.5px;color:var(--dk-muted, #64748b);white-space:nowrap">Δ vs plan: ${dot('#15803d')}&lt;2% ${dot('#b45309')}2–10% ${dot('#b91c1c')}≥10%</span>`;
1668
1676
  }
1669
1677
 
1670
1678
  // Effective % active for grossing up a weigh-out amount of a technical
@@ -1831,13 +1839,13 @@ function _weighoutDensityLineHtml(w) {
1831
1839
  let src = d.how === 'measured' ? 'measured' : (d.how === 'spec' ? 'from spec' : 'entered');
1832
1840
  if (d.batchId && typeof db !== 'undefined') {
1833
1841
  const b = (db.batches || []).find(x => x.id === d.batchId);
1834
- if (b) src += ` on <a href="#" onclick="navigate('batch-detail','${esc(b.id)}');return false" style="color:#2563eb">${esc(b.uid)}</a>`;
1842
+ if (b) src += ` on <a href="#" onclick="navigate('batch-detail','${esc(b.id)}');return false" style="color:var(--dk-blue-fg, #2563eb)">${esc(b.uid)}</a>`;
1835
1843
  }
1836
1844
  const parts = (w.partsSum != null && isFinite(w.partsSum) && Math.abs(w.partsSum - w.qty) > 1e-6)
1837
1845
  ? ` Pure-ingredient volumes sum to ${+w.partsSum.toFixed(2)} ${esc(w.unit)} — volumes aren't additive on mixing; the masses are what to weigh.` : '';
1838
- return `<div style="margin-top:4px;font-size:11.5px;color:#0f172a"><strong>${qty} ≙ ${mass}</strong> at finished density <strong>${rho}</strong>${temp} <span style="color:#64748b">(${src})</span>.${parts}</div>`;
1846
+ return `<div style="margin-top:4px;font-size:11.5px;color:var(--dk-text, #0f172a)"><strong>${qty} ≙ ${mass}</strong> at finished density <strong>${rho}</strong>${temp} <span style="color:var(--dk-muted, #64748b)">(${src})</span>.${parts}</div>`;
1839
1847
  }
1840
- return `<div style="margin-top:4px;font-size:11.5px;color:#92400e"><strong>${qty} ≙ ${mass}</strong> at <strong>${rho}</strong> — an <strong>ideal-mix estimate</strong> from the ingredient densities (assumes volumes add up; real mixes contract or expand). Record a measured density on a batch and adopt it as the formula's finished density to make this exact.</div>`;
1848
+ return `<div style="margin-top:4px;font-size:11.5px;color:var(--dk-amber-fg, #92400e)"><strong>${qty} ≙ ${mass}</strong> at <strong>${rho}</strong> — an <strong>ideal-mix estimate</strong> from the ingredient densities (assumes volumes add up; real mixes contract or expand). Record a measured density on a batch and adopt it as the formula's finished density to make this exact.</div>`;
1841
1849
  }
1842
1850
 
1843
1851
  function renderWeighoutPanel(w, actuals, formula, copyOnclick, noun) {
@@ -1845,9 +1853,9 @@ function renderWeighoutPanel(w, actuals, formula, copyOnclick, noun) {
1845
1853
  const sgn = n => (n == null || !isFinite(n)) ? '—' : (n > 0 ? '+' : '') + parseFloat(n.toFixed(4)).toString();
1846
1854
  if (!w || !w.available) {
1847
1855
  if (w && w.reason === 'bad-unit')
1848
- return `<div style="padding:8px 11px;margin-bottom:12px;background:#fffbeb;border:1px solid #fde68a;border-radius:6px;color:#92400e;font-size:12px"><span class="fl-warn" aria-hidden="true"></span> Set the ${noun} quantity in a <strong>mass or volume unit</strong> (g, kg, mL, L…) to get a weigh-out sheet — currently <strong>${esc(w.unit || '—')}</strong>.</div>`;
1856
+ return `<div style="padding:8px 11px;margin-bottom:12px;background:var(--dk-amber-bg, #fffbeb);border:1px solid var(--dk-amber-line, #fde68a);border-radius:6px;color:var(--dk-amber-fg, #92400e);font-size:12px"><span class="fl-warn" aria-hidden="true"></span> Set the ${noun} quantity in a <strong>mass or volume unit</strong> (g, kg, mL, L…) to get a weigh-out sheet — currently <strong>${esc(w.unit || '—')}</strong>.</div>`;
1849
1857
  if (w && w.reason === 'no-qty')
1850
- return `<div style="padding:8px 11px;margin-bottom:12px;background:#eff6ff;border:1px solid #bfdbfe;border-radius:6px;color:#1e40af;font-size:12px">Set a <strong>${noun} quantity</strong> to compute the per-ingredient weigh-out amounts technicians need.</div>`;
1858
+ return `<div style="padding:8px 11px;margin-bottom:12px;background:var(--dk-blue-bg, #eff6ff);border:1px solid var(--dk-blue-line, #bfdbfe);border-radius:6px;color:var(--dk-blue-fg, #1e40af);font-size:12px">Set a <strong>${noun} quantity</strong> to compute the per-ingredient weigh-out amounts technicians need.</div>`;
1851
1859
  return '';
1852
1860
  }
1853
1861
  // Map as-prepared actuals by ingredient / sub-formula key. Only absolute
@@ -1900,12 +1908,12 @@ function renderWeighoutPanel(w, actuals, formula, copyOnclick, noun) {
1900
1908
  _woUnits = [...set].sort((a, b) => factorOf(a) - factorOf(b));
1901
1909
  }
1902
1910
  const _woResetBtn = (_woDim && displayUnit !== w.unit)
1903
- ? `<button type="button" onclick="_weighoutResetDisplayUnit('${_woDim}')" title="Reset to the batch's target unit (${esc(w.unit)})" style="font-size:11px;padding:2px 7px;border:1px solid var(--border);border-radius:5px;background:#fff;color:#64748b;cursor:pointer;white-space:nowrap">↺ ${esc(w.unit)}</button>`
1911
+ ? `<button type="button" onclick="_weighoutResetDisplayUnit('${_woDim}')" title="Reset to the batch's target unit (${esc(w.unit)})" style="font-size:11px;padding:2px 7px;border:1px solid var(--border);border-radius:5px;background:var(--dk-surface, #fff);color:var(--dk-muted, #64748b);cursor:pointer;white-space:nowrap">↺ ${esc(w.unit)}</button>`
1904
1912
  : '';
1905
1913
  const unitDropdown = (_woDim && _woUnits.length > 1)
1906
1914
  ? `<span style="display:inline-flex;align-items:center;gap:6px;white-space:nowrap;margin-left:16px">
1907
- <label style="font-size:11px;color:#64748b;display:inline-flex;align-items:center;gap:4px;white-space:nowrap">show in
1908
- <select onchange="_weighoutSetDisplayUnit('${_woDim}', this.value)" title="Display the weigh-out amounts in this unit; the sheet stays in one consistent unit" style="padding:2px 6px;border:1px solid var(--border);border-radius:5px;font-size:11px;background:#fff">
1915
+ <label style="font-size:11px;color:var(--dk-muted, #64748b);display:inline-flex;align-items:center;gap:4px;white-space:nowrap">show in
1916
+ <select onchange="_weighoutSetDisplayUnit('${_woDim}', this.value)" title="Display the weigh-out amounts in this unit; the sheet stays in one consistent unit" style="padding:2px 6px;border:1px solid var(--border);border-radius:5px;font-size:11px;background:var(--dk-surface, #fff)">
1909
1917
  ${_woUnits.map(u => `<option value="${esc(u)}" ${u === displayUnit ? 'selected' : ''}>${esc(u)}</option>`).join('')}
1910
1918
  </select></label>
1911
1919
  ${_woResetBtn}
@@ -1950,17 +1958,17 @@ function renderWeighoutPanel(w, actuals, formula, copyOnclick, noun) {
1950
1958
  }
1951
1959
  const _rNumFmt = v => (typeof _fmpNum === 'function') ? _fmpNum(v) : (Math.abs(v) >= 100 ? (+v).toFixed(1) : (+v).toFixed(3)).replace(/\.?0+$/, '');
1952
1960
  const _purityTagBase = r => (r.purity == null) ? '' :
1953
- ` <span style="font-size:9.5px;color:#6d28d9;font-weight:600;background:#f5f3ff;border:1px solid #ddd6fe;border-radius:4px;padding:0 3px;vertical-align:middle;white-space:nowrap" title="Grossed up for ${esc(String(+r.purity))}% purity, delivers ${cv(r.activeAmount)} ${esc(displayUnit)} of active">↑${esc(String(+r.purity))}%</span>`;
1961
+ ` <span style="font-size:9.5px;color:var(--dk-violet-fg, #6d28d9);font-weight:600;background:var(--dk-violet-bg, #f5f3ff);border:1px solid var(--dk-violet-line, #ddd6fe);border-radius:4px;padding:0 3px;vertical-align:middle;white-space:nowrap" title="Grossed up for ${esc(String(+r.purity))}% purity, delivers ${cv(r.activeAmount)} ${esc(displayUnit)} of active">↑${esc(String(+r.purity))}%</span>`;
1954
1962
  // Lot-assay deviation: the lot this row draws from assays away from the nominal.
1955
1963
  const purityTag = r => _purityTagBase(r) + (!r.lotDev ? '' :
1956
- ` <span style="font-size:9.5px;color:#b45309;font-weight:600;background:#fffbeb;border:1px solid #fde68a;border-radius:4px;padding:0 3px;vertical-align:middle;white-space:nowrap" title="Lot ${esc(String(r.lotDev.lotNumber))} assays ${esc(String(+r.lotDev.lotPurity))}% but the weigh-out is grossed up on the nominal ${esc(String(+r.lotDev.nominal))}%. ${r.lotDev.consumed ? 'This batch drew from that lot.' : 'FIFO will draw from that lot next.'} To deliver the spec active at the lot's assay, weigh ${r.lotDev.correctedAmount != null ? esc(_rNumFmt(r.lotDev.correctedAmount)) + ' ' + esc(String(displayUnit || w.unit || '')) : 'the corrected amount'} instead.">⚠ lot ${esc(String(+r.lotDev.lotPurity))}% vs ${esc(String(+r.lotDev.nominal))}%${r.lotDev.correctedAmount != null ? ' → ' + esc(_rNumFmt(r.lotDev.correctedAmount)) : ''}</span>`);
1964
+ ` <span style="font-size:9.5px;color:var(--dk-amber-fg, #b45309);font-weight:600;background:var(--dk-amber-bg, #fffbeb);border:1px solid var(--dk-amber-line, #fde68a);border-radius:4px;padding:0 3px;vertical-align:middle;white-space:nowrap" title="Lot ${esc(String(r.lotDev.lotNumber))} assays ${esc(String(+r.lotDev.lotPurity))}% but the weigh-out is grossed up on the nominal ${esc(String(+r.lotDev.nominal))}%. ${r.lotDev.consumed ? 'This batch drew from that lot.' : 'FIFO will draw from that lot next.'} To deliver the spec active at the lot's assay, weigh ${r.lotDev.correctedAmount != null ? esc(_rNumFmt(r.lotDev.correctedAmount)) + ' ' + esc(String(displayUnit || w.unit || '')) : 'the corrected amount'} instead.">⚠ lot ${esc(String(+r.lotDev.lotPurity))}% vs ${esc(String(+r.lotDev.nominal))}%${r.lotDev.correctedAmount != null ? ' → ' + esc(_rNumFmt(r.lotDev.correctedAmount)) : ''}</span>`);
1957
1965
  const _woVolTarget = (typeof isVolUnit === 'function') && isVolUnit(w.unit);
1958
1966
  const qsTag = r => (!r.qs) ? '' :
1959
- ` <span style="font-size:9.5px;color:#0369a1;font-weight:600;background:#eff6ff;border:1px solid #bfdbfe;border-radius:4px;padding:0 3px;vertical-align:middle;white-space:nowrap" title="${_woVolTarget ? `Balance ingredient — add it up to the ${w.qty} ${esc(w.unit)} mark rather than to this number, then log what actually went in` : 'Balance ingredient, set to hold the batch at its target size'}">${_woVolTarget ? `q.s. to ${w.qty} ${esc(w.unit)}` : 'q.s.'}</span>`;
1967
+ ` <span style="font-size:9.5px;color:var(--dk-sky-fg, #0369a1);font-weight:600;background:var(--dk-blue-bg, #eff6ff);border:1px solid var(--dk-blue-line, #bfdbfe);border-radius:4px;padding:0 3px;vertical-align:middle;white-space:nowrap" title="${_woVolTarget ? `Balance ingredient — add it up to the ${w.qty} ${esc(w.unit)} mark rather than to this number, then log what actually went in` : 'Balance ingredient, set to hold the batch at its target size'}">${_woVolTarget ? `q.s. to ${w.qty} ${esc(w.unit)}` : 'q.s.'}</span>`;
1960
1968
  const dColor = rel => rel == null ? '#94a3b8' : (rel < 0.02 ? '#15803d' : rel < 0.10 ? '#b45309' : '#b91c1c');
1961
1969
  // Tint a whole row when it deviates, so off-plan ingredients pop at a glance
1962
1970
  // (matches the composition-% table's row tinting). On-target rows stay white.
1963
- const dRowBg = rel => (rel == null || rel < 0.02) ? '' : (rel < 0.10 ? 'background:#fffbeb' : 'background:#fef2f2');
1971
+ const dRowBg = rel => (rel == null || rel < 0.02) ? '' : (rel < 0.10 ? 'background:var(--dk-amber-bg, #fffbeb)' : 'background:var(--dk-red-bg, #fef2f2)');
1964
1972
  // ---- Secondary reference column: each row shown in the OTHER dimension,
1965
1973
  // derived per-row from that ingredient's OWN density (sub-formulas use their
1966
1974
  // rolled-up density). The prepared unit stays the single basis; this is a
@@ -2006,40 +2014,40 @@ function renderWeighoutPanel(w, actuals, formula, copyOnclick, noun) {
2006
2014
  const _altAmt = r => (r.planned != null) ? r.planned : r.actInUnit;
2007
2015
  const altCell = r => {
2008
2016
  const v = altConv(_altAmt(r), rowDensity(r));
2009
- if (v == null) return `<span style="color:#cbd5e1" title="No density set for this ingredient — add one to show its ${esc(_altDim || '')} equivalent">—</span>`;
2010
- return `≈ ${fmt(v)} <span translate="no" style="color:#94a3b8;font-size:10px">${esc(altUnit)}</span>`;
2017
+ if (v == null) return `<span style="color:var(--dk-dim, #cbd5e1)" title="No density set for this ingredient — add one to show its ${esc(_altDim || '')} equivalent">—</span>`;
2018
+ return `≈ ${fmt(v)} <span translate="no" style="color:var(--dk-faint, #94a3b8);font-size:10px">${esc(altUnit)}</span>`;
2011
2019
  };
2012
2020
  const _altAny = !!_altDim && rows.some(r => altConv(_altAmt(r), rowDensity(r)) != null);
2013
2021
  // Unit picker for the ≈ column (mirrors the show-in dropdown). Only when the
2014
2022
  // column is actually showing and there's more than one unit to choose.
2015
2023
  const altDropdown = (_altAny && _altDim && _altUnits.length > 1)
2016
2024
  ? `<span style="display:inline-flex;align-items:center;gap:6px;white-space:nowrap;margin-left:10px">
2017
- <label style="font-size:11px;color:#64748b;display:inline-flex;align-items:center;gap:4px;white-space:nowrap">≈ in
2018
- <select onchange="_weighoutSetAltUnit('${_altDim}', this.value)" title="Unit for the ≈ reference column — the other dimension, derived per-ingredient from its density" style="padding:2px 6px;border:1px solid var(--border);border-radius:5px;font-size:11px;background:#fff">
2025
+ <label style="font-size:11px;color:var(--dk-muted, #64748b);display:inline-flex;align-items:center;gap:4px;white-space:nowrap">≈ in
2026
+ <select onchange="_weighoutSetAltUnit('${_altDim}', this.value)" title="Unit for the ≈ reference column — the other dimension, derived per-ingredient from its density" style="padding:2px 6px;border:1px solid var(--border);border-radius:5px;font-size:11px;background:var(--dk-surface, #fff)">
2019
2027
  ${_altUnits.map(u => `<option value="${esc(u)}" ${u === altUnit ? 'selected' : ''}>${esc(u)}</option>`).join('')}
2020
2028
  </select></label>
2021
2029
  </span>`
2022
2030
  : '';
2023
- const th = (txt, extra = '') => `<th style="padding:5px 8px;color:#475569;font-weight:400;${extra}">${txt}</th>`;
2024
- const head = `<tr style="background:#eff6ff">
2031
+ const th = (txt, extra = '') => `<th style="padding:5px 8px;color:var(--dk-text-3, #475569);font-weight:400;${extra}">${txt}</th>`;
2032
+ const head = `<tr style="background:var(--dk-blue-bg, #eff6ff)">
2025
2033
  ${th('#', 'text-align:left;width:28px')}
2026
2034
  ${th('Ingredient / Sub-formula', 'text-align:left')}
2027
2035
  ${th('Planned', 'text-align:right')}
2028
2036
  ${hasActuals ? th('Actual', 'text-align:right') + th('Δ', 'text-align:right') : ''}
2029
2037
  ${th('Unit', 'text-align:left')}
2030
- ${_altAny ? `<th style="padding:5px 8px;color:#475569;font-weight:400;text-align:right" title="Approx. ${esc(_altDim || '')} equivalent, derived per-ingredient from its own density. Reference only — not additive, so no total.">≈ ${esc(altUnit)}</th>` : ''}
2038
+ ${_altAny ? `<th style="padding:5px 8px;color:var(--dk-text-3, #475569);font-weight:400;text-align:right" title="Approx. ${esc(_altDim || '')} equivalent, derived per-ingredient from its own density. Reference only — not additive, so no total.">≈ ${esc(altUnit)}</th>` : ''}
2031
2039
  </tr>`;
2032
2040
  const body = rows.map((r, i) => `<tr data-wo-key="${esc(r.key)}" style="${hasActuals ? dRowBg(r.rel) : ''}">
2033
- <td style="padding:4px 8px;color:#94a3b8">${i + 1}</td>
2034
- <td style="padding:4px 8px">${esc(r.name)}${r._added ? ` <span style="font-size:9.5px;color:#b91c1c;font-weight:600;background:#fef2f2;border:1px solid #fecaca;border-radius:4px;padding:0 4px;vertical-align:middle;white-space:nowrap" title="Added to the as-prepared batch — not part of the linked recipe">+ not in recipe</span>` : ''}</td>
2035
- <td style="padding:4px 8px;text-align:right;font-variant-numeric:tabular-nums;font-weight:600;color:#0f172a">${r.planned != null ? `${cv(r.planned)}${purityTag(r)}${qsTag(r)}` : '<span style="color:#cbd5e1">—</span>'}</td>
2041
+ <td style="padding:4px 8px;color:var(--dk-faint, #94a3b8)">${i + 1}</td>
2042
+ <td style="padding:4px 8px">${esc(r.name)}${r._added ? ` <span style="font-size:9.5px;color:var(--dk-red-fg, #b91c1c);font-weight:600;background:var(--dk-red-bg, #fef2f2);border:1px solid var(--dk-red-line, #fecaca);border-radius:4px;padding:0 4px;vertical-align:middle;white-space:nowrap" title="Added to the as-prepared batch — not part of the linked recipe">+ not in recipe</span>` : ''}</td>
2043
+ <td style="padding:4px 8px;text-align:right;font-variant-numeric:tabular-nums;font-weight:600;color:var(--dk-text, #0f172a)">${r.planned != null ? `${cv(r.planned)}${purityTag(r)}${qsTag(r)}` : '<span style="color:var(--dk-dim, #cbd5e1)">—</span>'}</td>
2036
2044
  ${hasActuals ? `<td style="padding:4px 8px;text-align:right;font-variant-numeric:tabular-nums">${
2037
2045
  r.actInUnit != null ? cv(r.actInUnit)
2038
- : (r.actRaw ? `${fmt(r.actRaw.amount)} <span translate="no" style="color:#94a3b8;font-size:10px">${esc(r.actRaw.unit)}</span>` : '<span style="color:#cbd5e1">—</span>')
2046
+ : (r.actRaw ? `${fmt(r.actRaw.amount)} <span translate="no" style="color:var(--dk-faint, #94a3b8);font-size:10px">${esc(r.actRaw.unit)}</span>` : '<span style="color:var(--dk-dim, #cbd5e1)">—</span>')
2039
2047
  }</td>
2040
2048
  <td style="padding:4px 8px;text-align:right;font-variant-numeric:tabular-nums;color:${dColor(r.rel)};font-weight:${r.delta ? 600 : 400}">${r.delta != null ? cvs(r.delta) : '—'}</td>` : ''}
2041
- <td style="padding:4px 8px;color:#64748b">${esc(displayUnit)}</td>
2042
- ${_altAny ? `<td style="padding:4px 8px;text-align:right;font-variant-numeric:tabular-nums;color:#64748b">${altCell(r)}</td>` : ''}
2049
+ <td style="padding:4px 8px;color:var(--dk-muted, #64748b)">${esc(displayUnit)}</td>
2050
+ ${_altAny ? `<td style="padding:4px 8px;text-align:right;font-variant-numeric:tabular-nums;color:var(--dk-muted, #64748b)">${altCell(r)}</td>` : ''}
2043
2051
  </tr>`).join('');
2044
2052
  const totalDelta = hasActuals ? (actualTotal - w.total) : null;
2045
2053
  // Volume target: the Planned column's sum is the sum of PURE-ingredient
@@ -2051,30 +2059,30 @@ function renderWeighoutPanel(w, actuals, formula, copyOnclick, noun) {
2051
2059
  if (!_volTarget || !_altAny || _altDim !== 'mass') return null;
2052
2060
  const g = w.totalMassKg * 1000;
2053
2061
  const inAlt = (typeof convertQuantity === 'function') ? convertQuantity(g, 'g', altUnit, formula) : null;
2054
- return (inAlt != null && isFinite(inAlt)) ? `<strong>${fmt(inAlt)}</strong> <span translate="no" style="color:#94a3b8;font-size:10px">${esc(altUnit)}</span>` : null;
2062
+ return (inAlt != null && isFinite(inAlt)) ? `<strong>${fmt(inAlt)}</strong> <span translate="no" style="color:var(--dk-faint, #94a3b8);font-size:10px">${esc(altUnit)}</span>` : null;
2055
2063
  })();
2056
2064
  const tfoot = `<tr style="border-top:1px solid #dbeafe">
2057
- <td></td><td style="padding:5px 8px;font-weight:600">Total${_volTarget ? ` <span style="font-weight:400;color:#94a3b8;font-size:10.5px" title="Sum of the pure-ingredient volumes in the Planned column. Not a lab quantity — the finished batch is ${w.qty} ${esc(w.unit)}; the mass in the ≈ column is what the batch weighs.">(pure-ingredient volumes)</span>` : ''}</td>
2058
- <td style="padding:5px 8px;text-align:right;font-variant-numeric:tabular-nums;font-weight:${_volTarget ? 400 : 700};color:${_volTarget ? '#94a3b8' : 'inherit'}" title="${_volTarget ? `Volumes aren't additive on mixing — this sum has no bench meaning. Finished batch: ${w.qty} ${esc(w.unit)}.` : ''}">${cv(w.total)}</td>
2065
+ <td></td><td style="padding:5px 8px;font-weight:600">Total${_volTarget ? ` <span style="font-weight:400;color:var(--dk-faint, #94a3b8);font-size:10.5px" title="Sum of the pure-ingredient volumes in the Planned column. Not a lab quantity — the finished batch is ${w.qty} ${esc(w.unit)}; the mass in the ≈ column is what the batch weighs.">(pure-ingredient volumes)</span>` : ''}</td>
2066
+ <td style="padding:5px 8px;text-align:right;font-variant-numeric:tabular-nums;font-weight:${_volTarget ? 400 : 700};color:${_volTarget ? 'var(--dk-faint, #94a3b8)' : 'inherit'}" title="${_volTarget ? `Volumes aren't additive on mixing — this sum has no bench meaning. Finished batch: ${w.qty} ${esc(w.unit)}.` : ''}">${cv(w.total)}</td>
2059
2067
  ${hasActuals ? `<td style="padding:5px 8px;text-align:right;font-variant-numeric:tabular-nums;font-weight:700">${cv(actualTotal)}</td>
2060
- <td style="padding:5px 8px;text-align:right;font-variant-numeric:tabular-nums;color:#64748b">${cvs(totalDelta)}</td>` : ''}
2061
- <td style="padding:5px 8px;color:#64748b">${esc(displayUnit)}</td>
2068
+ <td style="padding:5px 8px;text-align:right;font-variant-numeric:tabular-nums;color:var(--dk-muted, #64748b)">${cvs(totalDelta)}</td>` : ''}
2069
+ <td style="padding:5px 8px;color:var(--dk-muted, #64748b)">${esc(displayUnit)}</td>
2062
2070
  ${_altAny ? (_totalMassCell
2063
2071
  ? `<td style="padding:5px 8px;text-align:right;font-variant-numeric:tabular-nums" title="Total mass of the finished ${w.qty} ${esc(w.unit)} = volume × finished density. This is what the batch weighs — the number to reconcile the balance against.">${_totalMassCell}</td>`
2064
- : `<td style="padding:5px 8px;text-align:right;color:#cbd5e1" title="No total — the ${esc(_altDim || '')} equivalent isn't additive across ingredients">—</td>`) : ''}
2072
+ : `<td style="padding:5px 8px;text-align:right;color:var(--dk-dim, #cbd5e1)" title="No total — the ${esc(_altDim || '')} equivalent isn't additive across ingredients">—</td>`) : ''}
2065
2073
  </tr>`;
2066
2074
  const densityNote = w.anyNull
2067
- ? `<div style="margin-top:7px;font-size:11.5px;color:#92400e"><span class="fl-warn" aria-hidden="true"></span> Some rows can't be scaled to ${esc(displayUnit)} — set the formula / ingredient <strong>density</strong> to convert % across mass↔volume.</div>`
2075
+ ? `<div style="margin-top:7px;font-size:11.5px;color:var(--dk-amber-fg, #92400e)"><span class="fl-warn" aria-hidden="true"></span> Some rows can't be scaled to ${esc(displayUnit)} — set the formula / ingredient <strong>density</strong> to convert % across mass↔volume.</div>`
2068
2076
  : '';
2069
2077
  // Purity / q.s. footer. Three cases: q.s. balanced (on target), q.s. short
2070
2078
  // (actives exceed target), or drift (corrected, no balance ingredient).
2071
2079
  let purityNote = '';
2072
2080
  if (w.qsApplied && w.qsShort) {
2073
- purityNote = `<div style="margin-top:7px;font-size:11.5px;color:#b91c1c"><span class="fl-warn" aria-hidden="true"></span> Purity-corrected amounts already <strong>exceed the ${cv(w.qty)} ${esc(displayUnit)} target</strong> — the q.s. ingredient can't balance it (clamped to 0). Increase the batch size or reduce the actives.</div>`;
2081
+ purityNote = `<div style="margin-top:7px;font-size:11.5px;color:var(--dk-red-fg, #b91c1c)"><span class="fl-warn" aria-hidden="true"></span> Purity-corrected amounts already <strong>exceed the ${cv(w.qty)} ${esc(displayUnit)} target</strong> — the q.s. ingredient can't balance it (clamped to 0). Increase the batch size or reduce the actives.</div>`;
2074
2082
  } else if (w.qsApplied && w.correctedAny) {
2075
- purityNote = `<div style="margin-top:7px;font-size:11.5px;color:#0369a1">↑ Actives <strong>grossed up for purity</strong>; the <strong>q.s. ingredient</strong> was shrunk to hold the batch at <strong>${cv(w.total)} ${esc(displayUnit)}</strong> — on target, concentrations on-spec.</div>`;
2083
+ purityNote = `<div style="margin-top:7px;font-size:11.5px;color:var(--dk-sky-fg, #0369a1)">↑ Actives <strong>grossed up for purity</strong>; the <strong>q.s. ingredient</strong> was shrunk to hold the batch at <strong>${cv(w.total)} ${esc(displayUnit)}</strong> — on target, concentrations on-spec.</div>`;
2076
2084
  } else if (w.correctedAny && w.nominalTotal != null && isFinite(w.nominalTotal)) {
2077
- purityNote = `<div style="margin-top:7px;font-size:11.5px;color:#5b21b6">↑ Amounts <strong>grossed up for purity</strong> so each delivers its target active. Total <strong>${cv(w.total)} ${esc(displayUnit)}</strong> vs ${cv(w.nominalTotal)} nominal (+${cv(w.total - w.nominalTotal)}). Other rows are unchanged, so the batch runs a little over target — mark a diluent's role <strong>“${esc(QS_ROLE)}”</strong> to auto-balance back to ${cv(w.qty)} ${esc(displayUnit)}.</div>`;
2085
+ purityNote = `<div style="margin-top:7px;font-size:11.5px;color:var(--dk-violet-fg, #5b21b6)">↑ Amounts <strong>grossed up for purity</strong> so each delivers its target active. Total <strong>${cv(w.total)} ${esc(displayUnit)}</strong> vs ${cv(w.nominalTotal)} nominal (+${cv(w.total - w.nominalTotal)}). Other rows are unchanged, so the batch runs a little over target — mark a diluent's role <strong>“${esc(QS_ROLE)}”</strong> to auto-balance back to ${cv(w.qty)} ${esc(displayUnit)}.</div>`;
2078
2086
  }
2079
2087
  // Make-up-to-volume instruction: a VOLUME target with a balance row is best
2080
2088
  // made the pharmacist's way — weigh everything else, fill the balance
@@ -2087,7 +2095,7 @@ function renderWeighoutPanel(w, actuals, formula, copyOnclick, noun) {
2087
2095
  const bal = (_qsRow.amount != null && isFinite(_qsRow.amount)) ? _qsRow.amount : null;
2088
2096
  const balMass = (bal != null && typeof convertQuantity === 'function' && _qsRow.comp && _qsRow.comp.ingredientId)
2089
2097
  ? (() => { const ing = (db.ingredients || []).find(i => i.id === _qsRow.comp.ingredientId); const d = ing ? _densityToKgPerL(ing.density, ing.densityUnit) : null; return (d && VOL_TO_L[w.unit]) ? bal * VOL_TO_L[w.unit] * d * 1000 : null; })() : null;
2090
- makeUpNote = `<div style="margin-top:7px;padding:7px 10px;background:#f0f9ff;border:1px solid #bae6fd;border-radius:6px;font-size:11.5px;color:#0c4a6e;line-height:1.45"><strong>Make up to volume.</strong> Weigh the other rows as planned, then add <strong>${esc(_qsRow.name)}</strong> up to the <strong>${w.qty} ${esc(w.unit)}</strong> mark and log the amount that actually went in${balMass != null ? ` (planned ≈ ${+balMass.toFixed(1)} g)` : ''}. Volumes aren't additive, so the mark — not the planned number — decides the balance. Once logged, the batch page derives the finished density from the total mass ÷ ${w.qty} ${esc(w.unit)} and offers to save it on the formula for next time.</div>`;
2098
+ makeUpNote = `<div style="margin-top:7px;padding:7px 10px;background:var(--dk-sky-bg, #f0f9ff);border:1px solid var(--dk-sky-line, #bae6fd);border-radius:6px;font-size:11.5px;color:var(--dk-sky-fg, #0c4a6e);line-height:1.45"><strong>Make up to volume.</strong> Weigh the other rows as planned, then add <strong>${esc(_qsRow.name)}</strong> up to the <strong>${w.qty} ${esc(w.unit)}</strong> mark and log the amount that actually went in${balMass != null ? ` (planned ≈ ${+balMass.toFixed(1)} g)` : ''}. Volumes aren't additive, so the mark — not the planned number — decides the balance. Once logged, the batch page derives the finished density from the total mass ÷ ${w.qty} ${esc(w.unit)} and offers to save it on the formula for next time.</div>`;
2091
2099
  }
2092
2100
  const title = hasActuals ? '⚖ Weigh-out — planned vs actual amounts' : '⚖ Weigh-out — target amounts';
2093
2101
  // One-line explainer so this table's job (concrete amounts) is unmistakable
@@ -2105,27 +2113,27 @@ function renderWeighoutPanel(w, actuals, formula, copyOnclick, noun) {
2105
2113
  const pct = (d / w.qty) * 100;
2106
2114
  const near = Math.abs(pct) < 0.5;
2107
2115
  const mag = Math.abs(pct);
2108
- const c = near ? '#15803d' : (mag < 5 ? '#b45309' : '#b91c1c');
2109
- const bg = near ? '#f0fdf4' : (mag < 5 ? '#fffbeb' : '#fef2f2');
2110
- const bd = near ? '#bbf7d0' : (mag < 5 ? '#fde68a' : '#fecaca');
2116
+ const c = near ? 'var(--dk-green-fg, #15803d)' : (mag < 5 ? 'var(--dk-amber-fg, #b45309)' : 'var(--dk-red-fg, #b91c1c)');
2117
+ const bg = near ? 'var(--dk-green-bg, #f0fdf4)' : (mag < 5 ? 'var(--dk-amber-bg, #fffbeb)' : 'var(--dk-red-bg, #fef2f2)');
2118
+ const bd = near ? 'var(--dk-green-line, #bbf7d0)' : (mag < 5 ? 'var(--dk-amber-line, #fde68a)' : 'var(--dk-red-line, #fecaca)');
2111
2119
  yieldBanner = `<div style="display:flex;align-items:baseline;gap:8px;flex-wrap:wrap;margin:0 0 10px;padding:8px 12px;background:${bg};border:1px solid ${bd};border-radius:7px;font-size:13px">
2112
2120
  <span style="font-weight:700;color:${c}">As-prepared total ${cv(actualTotal)} ${esc(displayUnit)}</span>
2113
2121
  ${near
2114
- ? `<span style="color:#15803d;font-weight:600">✓ on the ${cv(w.qty)} ${esc(displayUnit)} target</span>`
2122
+ ? `<span style="color:var(--dk-green-fg, #15803d);font-weight:600">✓ on the ${cv(w.qty)} ${esc(displayUnit)} target</span>`
2115
2123
  : `<span style="color:${c};font-weight:600">${cvs(d)} ${esc(displayUnit)} (${d > 0 ? '+' : ''}${pct.toFixed(1)}%) ${d > 0 ? 'over' : 'under'} the ${cv(w.qty)} ${esc(displayUnit)} target</span>`}
2116
2124
  </div>`;
2117
2125
  }
2118
2126
  const copyLabel = hasActuals ? '<span class="fl-import" aria-hidden="true"></span> Reset to planned' : '<span class="fl-import" aria-hidden="true"></span> Copy into As-prepared';
2119
2127
  return `
2120
- <div style="border:1px solid #dbeafe;border-radius:8px;padding:12px 14px;margin-bottom:12px;background:#f8fbff">
2128
+ <div style="border:1px solid #dbeafe;border-radius:8px;padding:12px 14px;margin-bottom:12px;background:var(--dk-subtle, #f8fbff)">
2121
2129
  <div style="display:flex;align-items:center;gap:8px;margin-bottom:2px;flex-wrap:wrap">
2122
- <span style="font-size:13px;font-weight:600;color:#0f172a">${title}</span>
2123
- <span style="font-size:11.5px;color:#64748b">scaled to ${cv(w.qty)} ${esc(displayUnit)}</span>
2130
+ <span style="font-size:13px;font-weight:600;color:var(--dk-text, #0f172a)">${title}</span>
2131
+ <span style="font-size:11.5px;color:var(--dk-muted, #64748b)">scaled to ${cv(w.qty)} ${esc(displayUnit)}</span>
2124
2132
  ${unitDropdown}
2125
2133
  ${altDropdown}
2126
2134
  <button type="button" class="btn btn-secondary btn-sm" style="margin-left:auto" onclick="${copyOnclick}" title="Seed / reset the as-prepared amounts to the planned weigh-out, then edit each to what you actually weighed.">${copyLabel}</button>
2127
2135
  </div>
2128
- <div style="font-size:11px;color:#64748b;line-height:1.5;margin:0 0 9px">${subtitle}</div>
2136
+ <div style="font-size:11px;color:var(--dk-muted, #64748b);line-height:1.5;margin:0 0 9px">${subtitle}</div>
2129
2137
  ${yieldBanner}
2130
2138
  <table style="width:100%;border-collapse:collapse;font-size:12px">
2131
2139
  <thead>${head}</thead>
@@ -2256,9 +2264,9 @@ function shelfLifeBadgeHtml(iso) {
2256
2264
  if (!iso) return '—';
2257
2265
  const d = Math.floor((Date.parse(iso) - Date.now()) / 86400000);
2258
2266
  let style = 'color:var(--text)', note = '';
2259
- if (d < 0) { style = 'background:#fee2e2;color:#991b1b;padding:1px 7px;border-radius:10px;font-weight:600'; note = ' · expired'; }
2260
- else if (d <= 30) { style = 'background:#fef3c7;color:#b45309;padding:1px 7px;border-radius:10px;font-weight:600'; note = ` · ${d}d`; }
2261
- else if (d <= 90) { style = 'color:#b45309'; note = ` · ${d}d`; }
2267
+ if (d < 0) { style = 'background:var(--dk-red-bg2, #fee2e2);color:var(--dk-red-fg, #991b1b);padding:1px 7px;border-radius:10px;font-weight:600'; note = ' · expired'; }
2268
+ else if (d <= 30) { style = 'background:var(--dk-amber-bg2, #fef3c7);color:var(--dk-amber-fg, #b45309);padding:1px 7px;border-radius:10px;font-weight:600'; note = ` · ${d}d`; }
2269
+ else if (d <= 90) { style = 'color:var(--dk-amber-fg, #b45309)'; note = ` · ${d}d`; }
2262
2270
  return `<span style="${style}">${(typeof esc === 'function' ? esc(iso) : iso)}${note}</span>`;
2263
2271
  }
2264
2272
 
@@ -2478,7 +2486,7 @@ const COST_DRIFT_PCT = 10;
2478
2486
  function entityCostCellHtml(summary, label) {
2479
2487
  const lbl = label || 'Cost';
2480
2488
  if (!summary || summary.total == null) {
2481
- return `<span title="${esc(lbl)}, not priced (no ingredient prices, or a volume amount with no density)" style="color:#94a3b8">—</span>`;
2489
+ return `<span title="${esc(lbl)}, not priced (no ingredient prices, or a volume amount with no density)" style="color:var(--dk-faint, #94a3b8)">—</span>`;
2482
2490
  }
2483
2491
  const money = v => (typeof formatMoney === 'function') ? formatMoney(v, summary.currency) : (summary.currency + ' ' + (+v).toFixed(2));
2484
2492
  const amt = money(summary.total);
@@ -2492,8 +2500,8 @@ function entityCostCellHtml(summary, label) {
2492
2500
  + (drifted ? ' · drifted from live cost' : '')
2493
2501
  + (summary.note ? ` · “${summary.note}”` : '');
2494
2502
  const mark = drifted
2495
- ? ` <span style="color:#b45309;font-size:11px" aria-hidden="true">⚠</span>`
2496
- : `<sup style="color:#2563eb;font-size:9px;font-weight:700;margin-left:1px" aria-hidden="true">M</sup>`;
2503
+ ? ` <span style="color:var(--dk-amber-fg, #b45309);font-size:11px" aria-hidden="true">⚠</span>`
2504
+ : `<sup style="color:var(--dk-blue-fg, #2563eb);font-size:9px;font-weight:700;margin-left:1px" aria-hidden="true">M</sup>`;
2497
2505
  return `<span title="${esc(tip)}">${amt}${mark}</span>`;
2498
2506
  }
2499
2507
 
@@ -2516,12 +2524,12 @@ function entityCostValueHtml(o) {
2516
2524
  const editBtn = `<a onclick="openCostOverrideModal('${o.kind}','${o.id}')" style="color:var(--primary);cursor:pointer;font-size:11px;margin-left:8px">${ov != null ? 'Edit' : 'Override'}</a>`;
2517
2525
  let chipTitle = note ? 'Reason: ' + note : 'Manually overridden cost';
2518
2526
  if (ovRaw != null && ovCcy !== ccy) chipTitle += ` · entered as ${formatMoney(ovRaw, ovCcy)}`;
2519
- const manualChip = `<span title="${esc(chipTitle)}" style="font-size:10.5px;background:#eff6ff;border:1px solid #bfdbfe;color:#1e40af;padding:1px 6px;border-radius:999px;margin-left:6px">manual</span>`;
2527
+ const manualChip = `<span title="${esc(chipTitle)}" style="font-size:10.5px;background:var(--dk-blue-bg, #eff6ff);border:1px solid var(--dk-blue-line, #bfdbfe);color:var(--dk-blue-fg, #1e40af);padding:1px 6px;border-radius:999px;margin-left:6px">manual</span>`;
2520
2528
 
2521
2529
  // Currency source hint: viewer's own display preference always wins.
2522
2530
  // Only fall back to "from formula" if the user has no display preference set.
2523
2531
  const fromFormula = !!(o.formula && o.formula.costCurrency && !getDisplayCurrency());
2524
- const ccyLine = `<div style="font-size:10.5px;color:#94a3b8;margin-top:2px">${esc(ccy)} · <a onclick="event.stopPropagation();${fromFormula ? `navigate('formulation-detail','${o.formula.id}')` : `goToDisplayCurrencySetting()`}" title="${fromFormula ? esc('Currency inherited from the formula; click to change it there') : esc('Global display currency; click to change in Settings → Preferences')}" style="color:var(--primary);cursor:pointer">${fromFormula ? 'from formula' : 'display currency'}</a></div>`;
2532
+ const ccyLine = `<div style="font-size:10.5px;color:var(--dk-faint, #94a3b8);margin-top:2px">${esc(ccy)} · <a onclick="event.stopPropagation();${fromFormula ? `navigate('formulation-detail','${o.formula.id}')` : `goToDisplayCurrencySetting()`}" title="${fromFormula ? esc('Currency inherited from the formula; click to change it there') : esc('Global display currency; click to change in Settings → Preferences')}" style="color:var(--primary);cursor:pointer">${fromFormula ? 'from formula' : 'display currency'}</a></div>`;
2525
2533
 
2526
2534
  if (ov != null) {
2527
2535
  let ref = '';
@@ -2529,7 +2537,7 @@ function entityCostValueHtml(o) {
2529
2537
  if (computed > 0) {
2530
2538
  const pct = (ov - computed) / computed * 100;
2531
2539
  if (Math.abs(pct) >= 1) {
2532
- ref = `<span title="Manual override differs from the live computed cost; recheck if the recipe or ingredient prices changed" style="margin-left:8px;font-size:10.5px;background:#fef3c7;border:1px solid #fcd34d;color:#92400e;padding:1px 6px;border-radius:999px"><span class="fl-warn" aria-hidden="true"></span> computed ${money(computed)} (Δ ${pct > 0 ? '+' : ''}${pct.toFixed(0)}%)</span>`;
2540
+ ref = `<span title="Manual override differs from the live computed cost; recheck if the recipe or ingredient prices changed" style="margin-left:8px;font-size:10.5px;background:var(--dk-amber-bg2, #fef3c7);border:1px solid var(--dk-amber-line, #fcd34d);color:var(--dk-amber-fg, #92400e);padding:1px 6px;border-radius:999px"><span class="fl-warn" aria-hidden="true"></span> computed ${money(computed)} (Δ ${pct > 0 ? '+' : ''}${pct.toFixed(0)}%)</span>`;
2533
2541
  }
2534
2542
  }
2535
2543
  if (!ref) ref = `<span style="font-size:11px;color:var(--text-muted);margin-left:8px">computed ${money(computed)}</span>`;
@@ -752,10 +752,10 @@ function _tpCalcHtml(p, evalRows, valueMap) {
752
752
  if (aggs.length && spansOrig.length === aggs.length && spansSub.length === aggs.length) {
753
753
  const sym = _tpReplaceSpans(formula, spansOrig, L).replace(/\s+/g, ' ').trim();
754
754
  const nums = _tpReplaceSpans(substituted, spansSub, k => num(aggs[k].result)).replace(/\s+/g, ' ').trim();
755
- resultHtml = `<div style="${mono};font-size:12.5px;margin-top:8px;color:#0f172a">Value = ${esc(sym)}${sym === nums ? '' : ` = ${esc(nums)}`} = <strong>${esc(_tpFmtValue(t.value))}</strong>${unit}</div>`;
755
+ resultHtml = `<div style="${mono};font-size:12.5px;margin-top:8px;color:var(--dk-text, #0f172a)">Value = ${esc(sym)}${sym === nums ? '' : ` = ${esc(nums)}`} = <strong>${esc(_tpFmtValue(t.value))}</strong>${unit}</div>`;
756
756
  } else if (!aggs.length && refs.length) {
757
757
  const nums = substituted.replace(/\((-?[\d.e+-]+)\)/gi, (m, v) => _tpFmtValue(+v)).replace(/\s+/g, ' ').trim();
758
- resultHtml = `<div style="${mono};font-size:12.5px;margin-top:8px;color:#0f172a">Value = ${esc(formula.replace(/\s+/g, ' ').trim())} = ${esc(nums)} = <strong>${esc(_tpFmtValue(t.value))}</strong>${unit}</div>`;
758
+ resultHtml = `<div style="${mono};font-size:12.5px;margin-top:8px;color:var(--dk-text, #0f172a)">Value = ${esc(formula.replace(/\s+/g, ' ').trim())} = ${esc(nums)} = <strong>${esc(_tpFmtValue(t.value))}</strong>${unit}</div>`;
759
759
  } else {
760
760
  resultHtml = `<div style="font-size:12.5px;margin-top:8px">Value = <strong>${esc(_tpFmtValue(t.value))}</strong>${unit}</div>`;
761
761
  }
@@ -772,19 +772,19 @@ function _tpCalcHtml(p, evalRows, valueMap) {
772
772
  const units = {};
773
773
  fields.forEach(f => { const r = (evalRows || []).find(x => x && x._cfUnits && x._cfUnits[f]); units[f] = r ? r._cfUnits[f] : ''; });
774
774
  const byAgg = aggs.map(a => { const m = new Map(); (a.terms || []).forEach(tm => m.set(tm.idx, tm.value)); return m; });
775
- const th = (txt, title, right) => `<th${title ? ` title="${esc(title)}"` : ''} style="text-align:${right ? 'right' : 'left'};padding:4px 10px;font-size:10.5px;font-weight:600;color:#64748b;text-transform:uppercase;letter-spacing:.04em;border-bottom:1px solid #cbd5e1;white-space:nowrap">${txt}</th>`;
775
+ const th = (txt, title, right) => `<th${title ? ` title="${esc(title)}"` : ''} style="text-align:${right ? 'right' : 'left'};padding:4px 10px;font-size:10.5px;font-weight:600;color:var(--dk-muted, #64748b);text-transform:uppercase;letter-spacing:.04em;border-bottom:1px solid var(--dk-line, #cbd5e1);white-space:nowrap">${txt}</th>`;
776
776
  const td = (html, extra) => `<td style="padding:4px 10px;text-align:right;font-variant-numeric:tabular-nums;white-space:nowrap;${extra || ''}">${html}</td>`;
777
- const muted = 'color:#94a3b8';
777
+ const muted = 'color:var(--dk-faint, #94a3b8)';
778
778
  const head = th('Ingredient') + (showEntered ? th('As entered', 'Amount and unit as typed on the composition row', true) : '')
779
779
  + (usesAmount ? th('wt%', 'row.amount — grams, kg and mL are put on a per-100 basis (volume through each ingredient\'s density) before the formula runs', true) : '')
780
780
  + fields.map(f => th(esc(f) + (units[f] ? ` <span style="text-transform:none;font-weight:400">(${esc(units[f])})</span>` : ''), 'ing.' + f + ' — the bare number; the unit is a label only', true)).join('')
781
- + aggs.map((a, k) => th(`<span style="${mono};text-transform:none;color:#1e40af">${L(k)}</span>`, (spansOrig[k] && spansOrig[k].text) || a.op, true)).join('');
781
+ + aggs.map((a, k) => th(`<span style="${mono};text-transform:none;color:var(--dk-blue-fg, #1e40af)">${L(k)}</span>`, (spansOrig[k] && spansOrig[k].text) || a.op, true)).join('');
782
782
  // Banded rows (Jun, 2026-10-06): white / slate-100 on the panel's slate-50,
783
783
  // so a row reads across a wide table without a ruler.
784
784
  let band = 0;
785
785
  const body = (evalRows || []).map((r, i) => {
786
786
  if (!r) return '';
787
- const bg = (band++ % 2) ? '#f1f5f9' : '#ffffff';
787
+ const bg = (band++ % 2) ? 'var(--dk-raised, #f1f5f9)' : 'var(--dk-surface, #ffffff)';
788
788
  const isSub = r.ing && r.ing._isSubFormula;
789
789
  const name = isSub ? `${esc(r._subName || 'sub-formula')} <span style="${muted}">(sub-formula)</span>` : esc((r.ing && (r.ing.name || r.ing.uid)) || '(row)');
790
790
  const e = r._entered || {};
@@ -795,23 +795,23 @@ function _tpCalcHtml(p, evalRows, valueMap) {
795
795
  if (usesAmount) cells.push(td(num(r.amount)));
796
796
  fields.forEach(f => {
797
797
  const v = isSub ? undefined : (r.ing && r.ing[f]);
798
- cells.push(typeof v === 'number' && Number.isFinite(v) ? td(num(v)) : td('missing', 'color:#b45309;font-style:italic'));
798
+ cells.push(typeof v === 'number' && Number.isFinite(v) ? td(num(v)) : td('missing', 'color:var(--dk-amber-fg, #b45309);font-style:italic'));
799
799
  });
800
800
  byAgg.forEach((m, k) => {
801
801
  if (!m.has(i)) { cells.push(td('·', muted + `" title="Left out by the WHERE in ${L(k)}`)); return; }
802
802
  const v = m.get(i);
803
- cells.push(v == null ? td('not counted', 'color:#b45309;font-style:italic') : td(num(v)));
803
+ cells.push(v == null ? td('not counted', 'color:var(--dk-amber-fg, #b45309);font-style:italic') : td(num(v)));
804
804
  });
805
805
  return `<tr class="tp-calc-row" style="background:${bg}">${cells.join('')}</tr>`;
806
806
  }).join('');
807
807
  const nLead = 1 + (showEntered ? 1 : 0) + (usesAmount ? 1 : 0) + fields.length;
808
808
  const opWord = { SUM: 'Sum', AVG: 'Average', MIN: 'Min', MAX: 'Max', COUNT: 'Count' };
809
- const foot = `<tr style="border-top:1px solid #cbd5e1">
809
+ const foot = `<tr style="border-top:1px solid var(--dk-line, #cbd5e1)">
810
810
  <td colspan="${nLead}" style="padding:5px 10px;font-weight:600">Total</td>
811
811
  ${aggs.map((a, k) => td(`<span style="${muted};font-weight:400">${opWord[a.op] || a.op}</span> <strong>${num(a.result)}</strong>`)).join('')}
812
812
  </tr>`;
813
- const legend = aggs.map((a, k) => `<span style="white-space:nowrap"><span style="${mono};color:#1e40af;font-weight:600">${L(k)}</span> = <code style="${mono}">${esc((spansOrig[k] && spansOrig[k].text) || a.op)}</code></span>`).join(' &nbsp;·&nbsp; ');
814
- tableHtml = `<div style="font-size:11.5px;color:#475569;margin:2px 0 6px;line-height:1.7">${legend}</div>
813
+ const legend = aggs.map((a, k) => `<span style="white-space:nowrap"><span style="${mono};color:var(--dk-blue-fg, #1e40af);font-weight:600">${L(k)}</span> = <code style="${mono}">${esc((spansOrig[k] && spansOrig[k].text) || a.op)}</code></span>`).join(' &nbsp;·&nbsp; ');
814
+ tableHtml = `<div style="font-size:11.5px;color:var(--dk-text-3, #475569);margin:2px 0 6px;line-height:1.7">${legend}</div>
815
815
  <table style="border-collapse:collapse;font-size:12px;min-width:60%"><thead><tr>${head}</tr></thead><tbody>${body}</tbody><tfoot>${foot}</tfoot></table>`;
816
816
  } else if (aggs.length) {
817
817
  // Many aggregates, or rows we can't line up: the plain per-aggregate lists.
@@ -820,7 +820,7 @@ function _tpCalcHtml(p, evalRows, valueMap) {
820
820
  <table style="font-size:11.5px;border-collapse:collapse">${a.terms.map(term => `<tr><td style="padding:1px 12px 1px 0;white-space:nowrap">${esc(term.label)}</td><td style="text-align:right;font-variant-numeric:tabular-nums">${term.value == null ? 'not counted' : num(term.value)}</td></tr>`).join('')}</table>
821
821
  </div>`).join('');
822
822
  }
823
- return `<div style="padding:12px 14px;background:#f8fafc;border-top:1px solid #e2e8f0;font-size:12px;color:var(--text);overflow-x:auto">
823
+ return `<div style="padding:12px 14px;background:var(--dk-subtle, #f8fafc);border-top:1px solid var(--dk-line-soft, #e2e8f0);font-size:12px;color:var(--text);overflow-x:auto">
824
824
  ${refBits.length ? `<div style="font-size:11.5px;color:var(--text-muted);margin-bottom:6px">References: ${refBits.join(' &nbsp;·&nbsp; ')}</div>` : ''}
825
825
  ${tableHtml || (refBits.length ? '' : '<div style="color:var(--text-muted);font-size:11px">Value comes straight from the expression (no row aggregation).</div>')}
826
826
  ${resultHtml}
@@ -950,21 +950,21 @@ function renderTheoreticalParamsHtml(entityType, entity) {
950
950
  const pfBadge = (!errorText && Number.isFinite(value)) ? _tpSpecBadgeHtml(value, p.spec) : '';
951
951
 
952
952
  const miss = (!errorText && Number.isFinite(value)) ? _tpMissingNote(_tpMissingInputs(p.formula, evalRows)) : null;
953
- const missHtml = miss ? `<div class="tp-miss" title="${esc(miss.tip)}" style="margin-top:3px;font-size:11px;font-weight:400;color:#b45309;cursor:help">⚠ ${esc(miss.text)}</div>` : '';
953
+ const missHtml = miss ? `<div class="tp-miss" title="${esc(miss.tip)}" style="margin-top:3px;font-size:11px;font-weight:400;color:var(--dk-amber-fg, #b45309);cursor:help">⚠ ${esc(miss.text)}</div>` : '';
954
954
 
955
955
  const formulaPreview = (p.formula || '').length > 80
956
956
  ? esc(p.formula.slice(0, 80)) + '…'
957
957
  : esc(p.formula || '');
958
958
 
959
- return `<tr style="border-bottom:1px solid #f1f5f9">
960
- <td style="padding:8px 12px;font-weight:500;color:#0f172a">${esc(p.name || '—')}${p.pinned ? ` <span class="fl-pin" title="Pinned to the summary" role="img" aria-label="pinned" style="width:.8em;height:.8em;color:var(--primary);margin-left:3px"></span>` : ''}${missHtml}</td>
959
+ return `<tr style="border-bottom:1px solid var(--dk-line-soft, #f1f5f9)">
960
+ <td style="padding:8px 12px;font-weight:500;color:var(--dk-text, #0f172a)">${esc(p.name || '—')}${p.pinned ? ` <span class="fl-pin" title="Pinned to the summary" role="img" aria-label="pinned" style="width:.8em;height:.8em;color:var(--primary);margin-left:3px"></span>` : ''}${missHtml}</td>
961
961
  <td style="padding:8px 12px;text-align:right;font-variant-numeric:tabular-nums">
962
- <strong>${valueStr}</strong>${p.unit ? ` <span translate="no" style="font-size:11px;color:#94a3b8">${esc(p.unit)}</span>` : ''}
962
+ <strong>${valueStr}</strong>${p.unit ? ` <span translate="no" style="font-size:11px;color:var(--dk-faint, #94a3b8)">${esc(p.unit)}</span>` : ''}
963
963
  </td>
964
964
  <td style="padding:8px 12px;text-align:center">${pfBadge}</td>
965
- <td style="padding:8px 12px;font-family:ui-monospace,monospace;font-size:11px;color:#475569" title="${esc(p.formula || '')}">
965
+ <td style="padding:8px 12px;font-family:ui-monospace,monospace;font-size:11px;color:var(--dk-text-3, #475569)" title="${esc(p.formula || '')}">
966
966
  <span>${formulaPreview}</span>
967
- ${(!errorText && Number.isFinite(value)) ? `<button onclick="_tpToggleCalc('${p.id}')" title="Show how this value was calculated" style="margin-left:8px;font-family:inherit;font-size:10px;padding:1px 7px;background:#eff6ff;border:1px solid #bfdbfe;color:#1e40af;border-radius:9999px;cursor:pointer;white-space:nowrap">Show calc</button>` : ''}
967
+ ${(!errorText && Number.isFinite(value)) ? `<button onclick="_tpToggleCalc('${p.id}')" title="Show how this value was calculated" style="margin-left:8px;font-family:inherit;font-size:10px;padding:1px 7px;background:var(--dk-blue-bg, #eff6ff);border:1px solid var(--dk-blue-line, #bfdbfe);color:var(--dk-blue-fg, #1e40af);border-radius:9999px;cursor:pointer;white-space:nowrap">Show calc</button>` : ''}
968
968
  </td>
969
969
  <td style="padding:8px 12px;text-align:right;white-space:nowrap">
970
970
  <button class="btn btn-secondary btn-sm btn-icon" onclick="openTheoreticalParamModal('${entityType}','${entity.id}','${p.id}')" title="Edit">${typeof ICON !== 'undefined' && ICON.edit ? ICON.edit : '✎'}</button>
@@ -978,11 +978,11 @@ function renderTheoreticalParamsHtml(entityType, entity) {
978
978
  <div style="overflow-x:auto">
979
979
  <table style="width:100%;border-collapse:collapse;font-size:13px">
980
980
  <thead>
981
- <tr style="background:#f8fafc;border-bottom:2px solid var(--border)">
982
- <th style="text-align:left;padding:8px 12px;font-size:11px;color:#64748b;text-transform:uppercase;letter-spacing:.05em">Parameter</th>
983
- <th style="text-align:right;padding:8px 12px;font-size:11px;color:#64748b;text-transform:uppercase;letter-spacing:.05em">Value</th>
984
- <th style="text-align:center;padding:8px 12px;font-size:11px;color:#64748b;text-transform:uppercase;letter-spacing:.05em" title="Pass/fail vs. spec, when a spec is defined">Result</th>
985
- <th style="text-align:left;padding:8px 12px;font-size:11px;color:#64748b;text-transform:uppercase;letter-spacing:.05em">Formula</th>
981
+ <tr style="background:var(--dk-subtle, #f8fafc);border-bottom:2px solid var(--border)">
982
+ <th style="text-align:left;padding:8px 12px;font-size:11px;color:var(--dk-muted, #64748b);text-transform:uppercase;letter-spacing:.05em">Parameter</th>
983
+ <th style="text-align:right;padding:8px 12px;font-size:11px;color:var(--dk-muted, #64748b);text-transform:uppercase;letter-spacing:.05em">Value</th>
984
+ <th style="text-align:center;padding:8px 12px;font-size:11px;color:var(--dk-muted, #64748b);text-transform:uppercase;letter-spacing:.05em" title="Pass/fail vs. spec, when a spec is defined">Result</th>
985
+ <th style="text-align:left;padding:8px 12px;font-size:11px;color:var(--dk-muted, #64748b);text-transform:uppercase;letter-spacing:.05em">Formula</th>
986
986
  <th></th>
987
987
  </tr>
988
988
  </thead>
@@ -1028,22 +1028,22 @@ function _tpPinnedChipsHtml(entityType, entity) {
1028
1028
  ? 'ERR'
1029
1029
  : (Number.isFinite(r.value) ? _tpFmtValue(r.value) : '—');
1030
1030
  // Spec tint (matches the table's pass/fail badge colours)
1031
- let bg = '#f1f5f9', fg = '#334155', bd = '#e2e8f0';
1031
+ let bg = 'var(--dk-raised, #f1f5f9)', fg = 'var(--dk-text-2, #334155)', bd = 'var(--dk-line-soft, #e2e8f0)';
1032
1032
  if (!r.error && Number.isFinite(r.value) && p.spec) {
1033
1033
  const okMin = p.spec.min == null || r.value >= p.spec.min;
1034
1034
  const okMax = p.spec.max == null || r.value <= p.spec.max;
1035
- if (okMin && okMax) { bg = '#dcfce7'; fg = '#15803d'; bd = '#bbf7d0'; }
1036
- else { bg = '#fee2e2'; fg = '#b91c1c'; bd = '#fecaca'; }
1037
- } else if (r.error) { fg = '#b91c1c'; }
1035
+ if (okMin && okMax) { bg = 'var(--dk-green-bg2, #dcfce7)'; fg = 'var(--dk-green-fg, #15803d)'; bd = 'var(--dk-green-line, #bbf7d0)'; }
1036
+ else { bg = 'var(--dk-red-bg2, #fee2e2)'; fg = 'var(--dk-red-fg, #b91c1c)'; bd = 'var(--dk-red-line, #fecaca)'; }
1037
+ } else if (r.error) { fg = 'var(--dk-red-fg, #b91c1c)'; }
1038
1038
  const miss = (!r.error && Number.isFinite(r.value)) ? _tpMissingNote(_tpMissingInputs(p.formula, chipRows)) : null;
1039
1039
  const title = r.error ? r.error : ((p.description || `${p.name}${p.unit ? ' (' + p.unit + ')' : ''}`) + (miss ? '\n⚠ ' + miss.text + '\n' + miss.tip : ''));
1040
1040
  return `<span title="${esc(title)}" style="display:inline-flex;align-items:baseline;gap:5px;padding:3px 9px;border-radius:9999px;background:${bg};border:1px solid ${bd};font-size:11.5px;line-height:1.4;white-space:nowrap">
1041
- <span style="color:#64748b">${esc(p.name)}</span>
1042
- <strong style="color:${fg};font-variant-numeric:tabular-nums">${esc(valueStr)}</strong>${miss ? '<span style="color:#b45309" aria-label="some ingredient values missing">⚠</span>' : ''}${p.unit ? `<span translate="no" style="color:#94a3b8;font-size:10.5px">${esc(p.unit)}</span>` : ''}
1041
+ <span style="color:var(--dk-muted, #64748b)">${esc(p.name)}</span>
1042
+ <strong style="color:${fg};font-variant-numeric:tabular-nums">${esc(valueStr)}</strong>${miss ? '<span style="color:var(--dk-amber-fg, #b45309)" aria-label="some ingredient values missing">⚠</span>' : ''}${p.unit ? `<span translate="no" style="color:var(--dk-faint, #94a3b8);font-size:10.5px">${esc(p.unit)}</span>` : ''}
1043
1043
  </span>`;
1044
1044
  }).join('');
1045
1045
  return `<div class="tp-pinned-chips" style="display:flex;flex-wrap:wrap;gap:6px;align-items:center" title="Pinned theoretical parameters">
1046
- <span style="font-size:11px;color:#94a3b8" aria-hidden="true">ƒx</span>${chips}
1046
+ <span style="font-size:11px;color:var(--dk-faint, #94a3b8)" aria-hidden="true">ƒx</span>${chips}
1047
1047
  </div>`;
1048
1048
  }
1049
1049
 
@@ -1077,8 +1077,8 @@ function _tpSpecBadgeHtml(value, spec) {
1077
1077
  const min = spec.min != null ? _tpFmtValue(spec.min) : '–∞';
1078
1078
  const max = spec.max != null ? _tpFmtValue(spec.max) : '+∞';
1079
1079
  return pass
1080
- ? `<span style="display:inline-block;padding:1px 7px;border-radius:9999px;background:#dcfce7;color:#15803d;font-size:11px;font-weight:500" title="Within spec ${min}..${max}"><span class="fl-check" aria-hidden="true"></span></span>`
1081
- : `<span style="display:inline-block;padding:1px 7px;border-radius:9999px;background:#fee2e2;color:#b91c1c;font-size:11px;font-weight:500" title="Outside spec ${min}..${max}"><span class="fl-cross" aria-hidden="true"></span></span>`;
1080
+ ? `<span style="display:inline-block;padding:1px 7px;border-radius:9999px;background:var(--dk-green-bg2, #dcfce7);color:var(--dk-green-fg, #15803d);font-size:11px;font-weight:500" title="Within spec ${min}..${max}"><span class="fl-check" aria-hidden="true"></span></span>`
1081
+ : `<span style="display:inline-block;padding:1px 7px;border-radius:9999px;background:var(--dk-red-bg2, #fee2e2);color:var(--dk-red-fg, #b91c1c);font-size:11px;font-weight:500" title="Outside spec ${min}..${max}"><span class="fl-cross" aria-hidden="true"></span></span>`;
1082
1082
  }
1083
1083
 
1084
1084
  // ---- Modal ----
@@ -1156,10 +1156,10 @@ function _tpFieldPaletteHtml(evalRows, params, currentParamId) {
1156
1156
  .filter(pp => pp.id !== currentParamId && pp.name)
1157
1157
  .map(pp => chip(`[${pp.name}]`, `[${pp.name}]`, 'Use this parameter’s computed value')).join('');
1158
1158
 
1159
- const numNote = `<span style="font-size:11px;color:#b45309">No numeric fields on these ingredients yet — add values like density, or a custom field such as “solids”, on the Ingredients page to use them here.</span>`;
1159
+ const numNote = `<span style="font-size:11px;color:var(--dk-amber-fg, #b45309)">No numeric fields on these ingredients yet — add values like density, or a custom field such as “solids”, on the Ingredients page to use them here.</span>`;
1160
1160
 
1161
1161
  return `
1162
- <div style="margin-top:6px;padding:9px 11px;background:#f8fafc;border:1px solid var(--border);border-radius:6px">
1162
+ <div style="margin-top:6px;padding:9px 11px;background:var(--dk-subtle, #f8fafc);border:1px solid var(--border);border-radius:6px">
1163
1163
  <div style="font-size:11.5px;color:var(--text-muted);margin-bottom:7px;line-height:1.55">
1164
1164
  <strong style="color:var(--text)">Click to build.</strong> A formula runs across every ingredient row, so wrap fields in an aggregation like <code>SUM()</code> or <code>AVG()</code>. Try: click <code>AVG()</code>, then (cursor inside the brackets) click <code>ing.density</code>.
1165
1165
  </div>
@@ -1340,14 +1340,14 @@ function _tpRefreshPreview(entityType, entityId) {
1340
1340
  };
1341
1341
  const pfBadge = _tpSpecBadgeHtml(result, liveSpec);
1342
1342
  const missingHtml = ingMissingFields.size
1343
- ? `<div style="margin-top:6px;color:#d97706;font-size:11.5px"><strong><span class="fl-warn" aria-hidden="true"></span> ${ingMissingFields.size} missing field${ingMissingFields.size===1?'':'s'}:</strong> ${[...ingMissingFields].slice(0, 4).map(esc).join(', ')}${ingMissingFields.size > 4 ? '…' : ''}</div>`
1343
+ ? `<div style="margin-top:6px;color:var(--dk-amber-fg, #d97706);font-size:11.5px"><strong><span class="fl-warn" aria-hidden="true"></span> ${ingMissingFields.size} missing field${ingMissingFields.size===1?'':'s'}:</strong> ${[...ingMissingFields].slice(0, 4).map(esc).join(', ')}${ingMissingFields.size > 4 ? '…' : ''}</div>`
1344
1344
  : '';
1345
1345
  const unknownRefHtml = unknownRefs.length
1346
- ? `<div style="margin-top:6px;color:#d97706;font-size:11.5px"><strong><span class="fl-warn" aria-hidden="true"></span> Unknown parameter${unknownRefs.length===1?'':'s'}:</strong> ${unknownRefs.slice(0, 4).map(n => esc('[' + n + ']')).join(', ')}${unknownRefs.length > 4 ? '…' : ''} — check the name matches another parameter exactly.</div>`
1346
+ ? `<div style="margin-top:6px;color:var(--dk-amber-fg, #d97706);font-size:11.5px"><strong><span class="fl-warn" aria-hidden="true"></span> Unknown parameter${unknownRefs.length===1?'':'s'}:</strong> ${unknownRefs.slice(0, 4).map(n => esc('[' + n + ']')).join(', ')}${unknownRefs.length > 4 ? '…' : ''} — check the name matches another parameter exactly.</div>`
1347
1347
  : '';
1348
1348
  wrap.innerHTML = `
1349
1349
  <div style="display:flex;align-items:baseline;gap:8px;flex-wrap:wrap">
1350
- <span style="font-size:15px;color:#94a3b8;font-weight:600" aria-hidden="true">=</span>
1350
+ <span style="font-size:15px;color:var(--dk-faint, #94a3b8);font-weight:600" aria-hidden="true">=</span>
1351
1351
  <span style="font-size:28px;line-height:1.1;font-weight:700;color:var(--primary);font-variant-numeric:tabular-nums">${esc(valueStr)}</span>
1352
1352
  ${unit ? `<span translate="no" style="font-size:14px;color:var(--text-muted);font-weight:500">${esc(unit)}</span>` : ''}
1353
1353
  ${pfBadge}
@@ -1401,7 +1401,7 @@ function _tpPresetCatalog() {
1401
1401
  name: 'VOC (g/L)',
1402
1402
  unit: 'g/L',
1403
1403
  formula: "1000 * SUM(row.amount WHERE ing.class == 'Solvent') / SUM(row.amount / ing.density WHERE ing.density)",
1404
- description: 'Volatile-organic-compound load per litre of paint. Needs ing.class and ing.density.',
1404
+ description: 'Volatile-organic-compound load per liter of paint. Needs ing.class and ing.density.',
1405
1405
  },
1406
1406
  {
1407
1407
  group: 'Coatings',
@@ -1525,7 +1525,7 @@ function openTheoreticalParamPresets(entityType, entityId) {
1525
1525
  return `<button type="button" class="btn btn-secondary" style="display:flex;flex-direction:column;align-items:flex-start;text-align:left;padding:10px 14px;width:100%;gap:4px;white-space:normal;line-height:1.4"
1526
1526
  onclick="_tpAddPresetByIndex('${entityType}','${entityId}',${globalIdx})">
1527
1527
  <div style="font-weight:600;font-size:13.5px">${esc(p.name)}${p.unit ? ` <span style="font-weight:400;color:var(--text-muted)">(${esc(p.unit)})</span>` : ''}</div>
1528
- <div style="font-family:ui-monospace,monospace;font-size:11px;color:#475569;word-break:break-all">${esc(p.formula)}</div>
1528
+ <div style="font-family:ui-monospace,monospace;font-size:11px;color:var(--dk-text-3, #475569);word-break:break-all">${esc(p.formula)}</div>
1529
1529
  ${p.description ? `<div style="font-size:11.5px;color:var(--text-muted)">${esc(p.description)}</div>` : ''}
1530
1530
  </button>`;
1531
1531
  }).join('')}